diff --git a/apps/api/prisma/migrations/20260917190000_live_course_checkout/migration.sql b/apps/api/prisma/migrations/20260917190000_live_course_checkout/migration.sql new file mode 100644 index 0000000..639206d --- /dev/null +++ b/apps/api/prisma/migrations/20260917190000_live_course_checkout/migration.sql @@ -0,0 +1,10 @@ +-- Link the class times selected during a live-course purchase to its receipt. +-- The pending bookings hold those slots until an admin approves or rejects it. +ALTER TABLE "Booking" ADD COLUMN "courseSessionPaymentId" TEXT; + +CREATE INDEX "Booking_courseSessionPaymentId_idx" ON "Booking"("courseSessionPaymentId"); + +ALTER TABLE "Booking" +ADD CONSTRAINT "Booking_courseSessionPaymentId_fkey" +FOREIGN KEY ("courseSessionPaymentId") REFERENCES "Payment"("id") +ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7d606ff..e44c8c9 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -705,46 +705,49 @@ model BlockedPeriod { } model Booking { - id String @id @default(cuid()) - studentId String - student User @relation(fields: [studentId], references: [id]) - teacherId String - teacher Teacher @relation(fields: [teacherId], references: [id]) - enrollmentId String? - enrollment Enrollment? @relation(fields: [enrollmentId], references: [id]) - startsAt DateTime - endsAt DateTime - timezone String - type String - status BookingStatus @default(PENDING_PAYMENT) - price Int - policySnapshot Json - paymentExpiresAt DateTime? - meetingUrl String? - attendanceStudent Boolean? - attendanceTeacher Boolean? - cancelledAt DateTime? - cancellationReason String? + id String @id @default(cuid()) + studentId String + student User @relation(fields: [studentId], references: [id]) + teacherId String + teacher Teacher @relation(fields: [teacherId], references: [id]) + enrollmentId String? + enrollment Enrollment? @relation(fields: [enrollmentId], references: [id]) + startsAt DateTime + endsAt DateTime + timezone String + type String + status BookingStatus @default(PENDING_PAYMENT) + price Int + policySnapshot Json + paymentExpiresAt DateTime? + meetingUrl String? + attendanceStudent Boolean? + attendanceTeacher Boolean? + cancelledAt DateTime? + cancellationReason String? // Rescheduling requires both sides to agree, so a proposed new time is held // here until the counterparty accepts. Either party may propose; only the // other one can accept, which is what makes it mutual. All four are cleared // together once the proposal is accepted, declined, or superseded. - reschedulerId String? - rescheduleStartsAt DateTime? - rescheduleTimezone String? - rescheduleAskedAt DateTime? - payment Payment? - creditReservations CreditEntry[] - classRecord ClassRecord? - trialEvaluation TrialEvaluation? - earning Earning? - review Review? - reminders Reminder[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + reschedulerId String? + rescheduleStartsAt DateTime? + rescheduleTimezone String? + rescheduleAskedAt DateTime? + payment Payment? + courseSessionPaymentId String? + courseSessionPayment Payment? @relation("CourseSessionPayment", fields: [courseSessionPaymentId], references: [id], onDelete: SetNull) + creditReservations CreditEntry[] + classRecord ClassRecord? + trialEvaluation TrialEvaluation? + earning Earning? + review Review? + reminders Reminder[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([teacherId, startsAt, endsAt]) @@index([studentId, startsAt, endsAt]) + @@index([courseSessionPaymentId]) } model ClassRecord { @@ -878,43 +881,44 @@ model Assignment { } model Payment { - id String @id @default(cuid()) - bookingId String? @unique - booking Booking? @relation(fields: [bookingId], references: [id]) - userId String - user User @relation(fields: [userId], references: [id]) - purpose String - referenceId String - subtotal Int - discountAmount Int @default(0) - walletAmount Int @default(0) - gatewayAmount Int - amount Int - status PaymentStatus @default(PENDING) - authority String? @unique - gatewayReference String? - idempotencyKey String @unique + id String @id @default(cuid()) + bookingId String? @unique + booking Booking? @relation(fields: [bookingId], references: [id]) + courseSessionBookings Booking[] @relation("CourseSessionPayment") + userId String + user User @relation(fields: [userId], references: [id]) + purpose String + referenceId String + subtotal Int + discountAmount Int @default(0) + walletAmount Int @default(0) + gatewayAmount Int + amount Int + status PaymentStatus @default(PENDING) + authority String? @unique + gatewayReference String? + idempotencyKey String @unique // Records which discount this payment reserved a use of, so the reservation // can be released again when the payment fails or expires. - discountId String? - discount Discount? @relation(fields: [discountId], references: [id]) + discountId String? + discount Discount? @relation(fields: [discountId], references: [id]) // Set instead of `discountId` when an automatic rule (e.g. birthday) gave the // better deal. At most one of the two applies to a payment. - discountRuleId String? - discountRule DiscountRule? @relation(fields: [discountRuleId], references: [id]) - callbackPayload Json? - verifiedAt DateTime? + discountRuleId String? + discountRule DiscountRule? @relation(fields: [discountRuleId], references: [id]) + callbackPayload Json? + verifiedAt DateTime? // Manual card-to-card top-ups (used until the Zarinpal gateway is live): the // student uploads a bank receipt and an admin approves or rejects it. - receiptFileId String? - receiptFile StoredFile? @relation("PaymentReceipt", fields: [receiptFileId], references: [id], onDelete: SetNull) - reviewedById String? - reviewNote String? - reviewedAt DateTime? - refunds Refund[] - reconciliations Reconciliation[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + receiptFileId String? + receiptFile StoredFile? @relation("PaymentReceipt", fields: [receiptFileId], references: [id], onDelete: SetNull) + reviewedById String? + reviewNote String? + reviewedAt DateTime? + refunds Refund[] + reconciliations Reconciliation[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([discountId]) @@index([discountRuleId]) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index e96e168..bb65a0f 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1647,6 +1647,7 @@ async function seedTicketsCmsAndSettings() { // seeded rather than hardcoded so changing them never needs a deploy; each // reader falls back to the same default when the row is missing. const rules: [string, unknown, boolean][] = [ + ['payment.card', { cardNumber: '0000-0000-0000-0000', holder: 'نام صاحب حساب', bank: 'نام بانک' }, true], ['commerce.commissionPercent', { value: 20 }, false], ['commerce.escrowHoldDays', { value: 7 }, false], ['booking.minLeadMinutes', { value: 120 }, true], diff --git a/apps/api/src/modules/bookings/bookings.repository.ts b/apps/api/src/modules/bookings/bookings.repository.ts index 1c3eae3..e4dc238 100644 --- a/apps/api/src/modules/bookings/bookings.repository.ts +++ b/apps/api/src/modules/bookings/bookings.repository.ts @@ -7,8 +7,14 @@ export class BookingsRepository { adminList() { return this.prisma.booking.findMany({ - include: { student: { select: { name: true, phone: true } }, teacher: { select: { nameFa: true, nameEn: true, slug: true } }, payment: true, classRecord: true }, - orderBy: { startsAt: 'desc' }, take: 200, + include: { + student: { select: { name: true, phone: true } }, + teacher: { select: { nameFa: true, nameEn: true, slug: true } }, + payment: true, + classRecord: true, + }, + orderBy: { startsAt: 'desc' }, + take: 200, }); } @@ -33,7 +39,7 @@ export class BookingsRepository { findStudentBookings(userId: string) { return this.prisma.booking.findMany({ - where: { studentId: userId }, + where: { studentId: userId, NOT: { type: 'course_session', status: 'PENDING_PAYMENT' } }, include: { teacher: { select: { @@ -55,7 +61,7 @@ export class BookingsRepository { findTeacherBookings(userId: string) { return this.prisma.booking.findMany({ - where: { teacher: { userId } }, + where: { teacher: { userId }, NOT: { type: 'course_session', status: 'PENDING_PAYMENT' } }, include: { teacher: { select: { diff --git a/apps/api/src/modules/commerce/dto/request/payments.dto.ts b/apps/api/src/modules/commerce/dto/request/payments.dto.ts index 9ab6479..5f8d780 100644 --- a/apps/api/src/modules/commerce/dto/request/payments.dto.ts +++ b/apps/api/src/modules/commerce/dto/request/payments.dto.ts @@ -1,5 +1,15 @@ -import { IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; - +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; export class PayDto { @IsIn(['booking', 'package']) purpose!: 'booking' | 'package'; @@ -34,9 +44,20 @@ export class ReceiptTopUpDto { @IsString() receiptFileId!: string; @IsString() idempotencyKey!: string; @IsOptional() @IsString() courseId?: string; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CourseSessionSelectionDto) + sessions?: CourseSessionSelectionDto[]; @IsOptional() @IsString() @MaxLength(500) note?: string; } +export class CourseSessionSelectionDto { + @IsDateString() startsAt!: string; + @IsDateString() endsAt!: string; + @IsString() timezone!: string; +} + export class ReceiptApproveDto { @IsOptional() @IsString() @MaxLength(100) reference?: string; } diff --git a/apps/api/src/modules/commerce/payments/payments.service.ts b/apps/api/src/modules/commerce/payments/payments.service.ts index 9959a55..71ee224 100644 --- a/apps/api/src/modules/commerce/payments/payments.service.ts +++ b/apps/api/src/modules/commerce/payments/payments.service.ts @@ -338,19 +338,31 @@ export class PaymentsService implements OnModuleInit, OnModuleDestroy { * winner committed and returns it as an ordinary success instead. */ async settleVerified(paymentId: string, reference: string | undefined, payload: object) { - const commit = () => this.db.$transaction(async tx => { - const current = await tx.payment.findUniqueOrThrow({ where: { id: paymentId } }); - // Re-checked inside the transaction, not just before it: on the retry - // path the winner may have moved the payment on to PAID *or* straight to - // REFUNDED via `returnCapture`, and re-fulfilling either one would grant - // a second entitlement or undo the return. - if (!PaymentsService.SETTLEABLE.includes(current.status)) return current; - await tx.payment.update({ where: { id: paymentId }, data: { status: 'PAID', gatewayReference: reference, verifiedAt: new Date(), callbackPayload: payload as Prisma.InputJsonValue } }); - await this.fulfill(tx, paymentId, current.status); - // `fulfill` can move the payment straight on to REFUNDED, so the row is - // re-read rather than returning the pre-fulfil update result. - return tx.payment.findUniqueOrThrow({ where: { id: paymentId } }); - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + const commit = () => + this.db.$transaction( + async (tx) => { + const current = await tx.payment.findUniqueOrThrow({ where: { id: paymentId } }); + // Re-checked inside the transaction, not just before it: on the retry + // path the winner may have moved the payment on to PAID *or* straight to + // REFUNDED via `returnCapture`, and re-fulfilling either one would grant + // a second entitlement or undo the return. + if (!PaymentsService.SETTLEABLE.includes(current.status)) return current; + await tx.payment.update({ + where: { id: paymentId }, + data: { + status: 'PAID', + gatewayReference: reference, + verifiedAt: new Date(), + callbackPayload: payload as Prisma.InputJsonValue, + }, + }); + await this.fulfill(tx, paymentId, current.status); + // `fulfill` can move the payment straight on to REFUNDED, so the row is + // re-read rather than returning the pre-fulfil update result. + return tx.payment.findUniqueOrThrow({ where: { id: paymentId } }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); let paid; try { @@ -407,6 +419,12 @@ export class PaymentsService implements OnModuleInit, OnModuleDestroy { `wallet-rollback:${payment.id}`, ); await releaseDiscount(tx, payment.discountId); + if (payment.purpose === 'course') { + await tx.booking.updateMany({ + where: { courseSessionPaymentId: payment.id, status: 'PENDING_PAYMENT' }, + data: { status: 'CANCELLED', cancelledAt: new Date(), cancellationReason: 'payment receipt rejected' }, + }); + } return tx.payment.update({ where: { id: payment.id }, data: { status: 'FAILED', callbackPayload: payload } }); }); } @@ -438,10 +456,32 @@ export class PaymentsService implements OnModuleInit, OnModuleDestroy { // also grants credits against the course's linked Package. const course = await tx.course.findUnique({ where: { id: payment.referenceId } }); if (course?.format === 'LIVE_ONLINE' && course.packageId) { - const alreadyGranted = await tx.enrollment.findFirst({ + let enrollment = await tx.enrollment.findFirst({ where: { studentId: payment.userId, packageId: course.packageId }, }); - if (!alreadyGranted) await this.createEnrollmentWithCredits(tx, payment.userId, course.packageId, payment.id); + if (!enrollment) + enrollment = await this.createEnrollmentWithCredits(tx, payment.userId, course.packageId, payment.id); + const pendingSessions = await tx.booking.findMany({ + where: { courseSessionPaymentId: payment.id, studentId: payment.userId, status: 'PENDING_PAYMENT' }, + }); + for (const session of pendingSessions) { + await tx.booking.update({ + where: { id: session.id }, + data: { enrollmentId: enrollment.id, status: 'CONFIRMED' }, + }); + await tx.creditEntry.create({ + data: { + enrollmentId: enrollment.id, + bookingId: session.id, + type: 'CONSUME', + amount: 1, + idempotencyKey: `course-receipt-session:${session.id}`, + }, + }); + await this.outbox.enqueue(tx, OUTBOX_EVENT_TYPES.bookingConfirmed, bookingConfirmedKey(session.id), { + bookingId: session.id, + }); + } } return; } diff --git a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts index b11ef3e..6f0efe6 100644 --- a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts +++ b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts @@ -11,22 +11,30 @@ function harness( ) { const db = { payment: { - findUnique: jest.fn().mockImplementation(({ where }: { where: { id?: string } }) => - Promise.resolve(where.id ? (opts.payment ?? null) : null), - ), + findUnique: jest + .fn() + .mockImplementation(({ where }: { where: { id?: string } }) => + Promise.resolve(where.id ? (opts.payment ?? null) : null), + ), create: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve(data)), updateMany: jest.fn().mockResolvedValue({ count: opts.claimed ?? 1 }), findFirst: jest.fn().mockResolvedValue(null), }, storedFile: { - findFirst: jest.fn().mockResolvedValue( - opts.file === undefined ? { id: 'file-1', _count: { paymentReceipts: 0 } } : opts.file, - ), + findFirst: jest + .fn() + .mockResolvedValue(opts.file === undefined ? { id: 'file-1', _count: { paymentReceipts: 0 } } : opts.file), }, auditLog: { create: jest.fn().mockResolvedValue({}) }, - course: { findFirst: jest.fn().mockResolvedValue(opts.course === undefined ? { id: 'course-1', price: 900_000 } : opts.course) }, + course: { + findFirst: jest + .fn() + .mockResolvedValue(opts.course === undefined ? { id: 'course-1', price: 900_000 } : opts.course), + }, courseEnrollment: { findUnique: jest.fn().mockResolvedValue(opts.enrolled ? { id: 'e-1' } : null) }, + booking: { count: jest.fn().mockResolvedValue(0) }, }; + Object.assign(db, { $transaction: jest.fn((callback: (tx: typeof db) => unknown) => callback(db)) }); const payments = { settleVerified: jest.fn().mockResolvedValue({ id: 'p-1', status: 'PAID' }), failPayment: jest.fn().mockResolvedValue({ id: 'p-1', status: 'FAILED' }), @@ -40,7 +48,12 @@ describe('ReceiptTopUpsService', () => { it('creates a PENDING wallet top-up bound to the uploaded receipt', async () => { const h = harness(); const payment = await h.svc.submit('student-1', { amount: 500_000, receiptFileId: 'file-1', idempotencyKey: 'k1' }); - expect(payment).toMatchObject({ purpose: 'wallet_top_up', status: 'PENDING', amount: 500_000, receiptFileId: 'file-1' }); + expect(payment).toMatchObject({ + purpose: 'wallet_top_up', + status: 'PENDING', + amount: 500_000, + receiptFileId: 'file-1', + }); expect(h.db.storedFile.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ ownerId: 'student-1', status: 'SAFE' }) }), ); @@ -76,7 +89,9 @@ describe('ReceiptTopUpsService', () => { it('only one reviewer can act on a receipt', async () => { const h = harness({ payment: pending, claimed: 0 }); - await expect(h.svc.approve('admin-1', 'p-1')).rejects.toMatchObject({ response: { code: 'RECEIPT_ALREADY_REVIEWED' } }); + await expect(h.svc.approve('admin-1', 'p-1')).rejects.toMatchObject({ + response: { code: 'RECEIPT_ALREADY_REVIEWED' }, + }); expect(h.payments.settleVerified).not.toHaveBeenCalled(); }); @@ -98,6 +113,46 @@ describe('ReceiptTopUpsService', () => { expect(payment).toMatchObject({ purpose: 'course', referenceId: 'course-1', amount: 900_000 }); }); + it('holds every selected live-course slot until the receipt is reviewed', async () => { + const h = harness({ + course: { + id: 'course-live', + price: 1_200_000, + format: 'LIVE_ONLINE', + teacherId: 'teacher-1', + package: { credits: 2 }, + teacher: { meetingUrl: 'https://meet.google.com/abc-defg-hij' }, + }, + }); + const sessions = [ + { startsAt: '2099-01-01T09:00:00.000Z', endsAt: '2099-01-01T10:00:00.000Z', timezone: 'Asia/Tehran' }, + { startsAt: '2099-01-03T09:00:00.000Z', endsAt: '2099-01-03T10:00:00.000Z', timezone: 'Asia/Tehran' }, + ]; + await h.svc.submit('student-1', { + amount: 0, + receiptFileId: 'file-1', + idempotencyKey: 'live-1', + courseId: 'course-live', + sessions, + }); + expect(h.db.booking.count).toHaveBeenCalledTimes(2); + expect(h.db.payment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + courseSessionBookings: { + create: expect.arrayContaining([ + expect.objectContaining({ + teacherId: 'teacher-1', + status: 'PENDING_PAYMENT', + meetingUrl: 'https://meet.google.com/abc-defg-hij', + }), + ]), + }, + }), + }), + ); + }); + it('refuses a course receipt when already enrolled', async () => { const h = harness({ enrolled: true }); await expect( diff --git a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts index d3d11b0..91268d0 100644 --- a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts +++ b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts @@ -24,7 +24,14 @@ export class ReceiptTopUpsService { async submit( userId: string, - input: { amount: number; receiptFileId: string; idempotencyKey: string; note?: string; courseId?: string }, + input: { + amount: number; + receiptFileId: string; + idempotencyKey: string; + note?: string; + courseId?: string; + sessions?: Array<{ startsAt: string; endsAt: string; timezone: string }>; + }, ) { const replay = await this.db.payment.findUnique({ where: { idempotencyKey: input.idempotencyKey } }); if (replay) { @@ -47,11 +54,25 @@ export class ReceiptTopUpsService { if (file._count.paymentReceipts > 0) throw conflict('RECEIPT_ALREADY_SUBMITTED'); // A course receipt is charged the course's own price, never the amount the // client typed, and enrolls the student once approved (see `fulfill`). - let course: { id: string; price: number } | null = null; + let course: { + id: string; + price: number; + format: string; + teacherId: string | null; + package: { credits: number } | null; + teacher: { meetingUrl: string | null } | null; + } | null = null; if (input.courseId) { course = await this.db.course.findFirst({ where: { OR: [{ id: input.courseId }, { slug: input.courseId }], published: true }, - select: { id: true, price: true }, + select: { + id: true, + price: true, + format: true, + teacherId: true, + package: { select: { credits: true } }, + teacher: { select: { meetingUrl: true } }, + }, }); if (!course) throw notFound('COURSE_NOT_FOUND'); const enrolled = await this.db.courseEnrollment.findUnique({ @@ -65,26 +86,89 @@ export class ReceiptTopUpsService { }); if (waiting) throw conflict('COURSE_RECEIPT_PENDING'); } + const sessions = input.sessions ?? []; + if (course?.format === 'LIVE_ONLINE') { + if (!course.teacherId || !course.package) throw badRequest('COURSE_SCHEDULE_NOT_CONFIGURED'); + if (sessions.length !== course.package.credits) throw badRequest('COURSE_SESSION_COUNT_INVALID'); + const normalized = sessions + .map((session) => ({ ...session, startsAt: new Date(session.startsAt), endsAt: new Date(session.endsAt) })) + .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime()); + for (let index = 0; index < normalized.length; index += 1) { + const session = normalized[index]!; + if ( + !Number.isFinite(session.startsAt.getTime()) || + !Number.isFinite(session.endsAt.getTime()) || + session.startsAt <= new Date() || + session.endsAt <= session.startsAt || + !session.timezone.trim() + ) + throw badRequest('COURSE_SESSION_INVALID'); + if (index > 0 && normalized[index - 1]!.endsAt > session.startsAt) { + throw conflict('COURSE_SESSIONS_OVERLAP'); + } + } + } else if (sessions.length) { + throw badRequest('COURSE_SESSIONS_NOT_ALLOWED'); + } const amount = course ? course.price : input.amount; if (!course && amount < 10_000) throw badRequest('RECEIPT_AMOUNT_TOO_LOW'); const id = `receipt_${randomUUID()}`; + const paymentData = { + id, + userId, + purpose: course ? 'course' : 'wallet_top_up', + referenceId: course ? course.id : id, + subtotal: amount, + amount, + gatewayAmount: amount, + walletAmount: 0, + status: 'PENDING' as const, + idempotencyKey: input.idempotencyKey, + receiptFileId: file.id, + reviewNote: input.note?.trim() || null, + }; try { - return await this.db.payment.create({ - data: { - id, - userId, - purpose: course ? 'course' : 'wallet_top_up', - referenceId: course ? course.id : id, - subtotal: amount, - amount, - gatewayAmount: amount, - walletAmount: 0, - status: 'PENDING', - idempotencyKey: input.idempotencyKey, - receiptFileId: file.id, - reviewNote: input.note?.trim() || null, + if (course?.format !== 'LIVE_ONLINE' || !course.teacherId) { + return await this.db.payment.create({ data: paymentData }); + } + return await this.db.$transaction( + async (tx) => { + for (const session of sessions) { + const startsAt = new Date(session.startsAt), + endsAt = new Date(session.endsAt); + const overlap = await tx.booking.count({ + where: { + teacherId: course.teacherId!, + status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] }, + startsAt: { lt: endsAt }, + endsAt: { gt: startsAt }, + }, + }); + if (overlap) throw conflict('SLOT_NOT_AVAILABLE'); + } + return tx.payment.create({ + data: { + ...paymentData, + courseSessionBookings: { + create: sessions.map((session) => ({ + studentId: userId, + teacherId: course.teacherId!, + startsAt: new Date(session.startsAt), + endsAt: new Date(session.endsAt), + timezone: session.timezone, + type: 'course_session', + status: 'PENDING_PAYMENT', + price: 0, + policySnapshot: {}, + meetingUrl: course.teacher?.meetingUrl ?? null, + })), + }, + }, + include: { courseSessionBookings: true }, + }); }, - }); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); } catch (error) { if (!isPrismaKnownError(error) || error.code !== 'P2002') throw error; const raced = await this.db.payment.findUnique({ where: { idempotencyKey: input.idempotencyKey } }); diff --git a/apps/api/src/modules/courses/courses.service.ts b/apps/api/src/modules/courses/courses.service.ts index ef65d8d..2851f52 100644 --- a/apps/api/src/modules/courses/courses.service.ts +++ b/apps/api/src/modules/courses/courses.service.ts @@ -21,6 +21,7 @@ export class CoursesService { const course = await this.db.course.findFirst({ where: { OR: [{ id: slug }, { slug }], published: true }, include: { + package: { select: { credits: true } }, chapters: { where: { published: true }, orderBy: { order: 'asc' }, @@ -205,11 +206,16 @@ export class CoursesService { if (course.format !== 'LIVE_ONLINE' || !course.packageId) return []; const enrollments = await this.db.enrollment.findMany({ where: { packageId: course.packageId, active: true }, - include: { student: { select: { id: true, name: true } }, creditEntries: { select: { type: true, amount: true } } }, + include: { + student: { select: { id: true, name: true } }, + creditEntries: { select: { type: true, amount: true } }, + }, }); return enrollments.map((enrollment) => { const sum = (type: string) => - enrollment.creditEntries.filter((entry) => entry.type === type).reduce((total, entry) => total + entry.amount, 0); + enrollment.creditEntries + .filter((entry) => entry.type === type) + .reduce((total, entry) => total + entry.amount, 0); return { studentId: enrollment.studentId, name: enrollment.student.name, @@ -292,7 +298,8 @@ export class CoursesService { if (input.published && !teacher) throw badRequest('COURSE_PUBLISH_REQUIRES_INSTRUCTOR'); // LIVE_ONLINE courses carry no chapters/lessons by design — their content // is the scheduled sessions, not a video player. - if (input.published && format === 'SELF_PACED' && !lessonsCount) throw badRequest('COURSE_PUBLISH_REQUIRES_LESSONS'); + if (input.published && format === 'SELF_PACED' && !lessonsCount) + throw badRequest('COURSE_PUBLISH_REQUIRES_LESSONS'); return { slug: input.slug, titleFa: input.titleFa.trim(), diff --git a/apps/web/src/app/courses/[slug]/page.tsx b/apps/web/src/app/courses/[slug]/page.tsx index 28041aa..6fa31b0 100644 --- a/apps/web/src/app/courses/[slug]/page.tsx +++ b/apps/web/src/app/courses/[slug]/page.tsx @@ -39,6 +39,7 @@ type CourseDetail = Course & { reviews: PublicReview[]; distribution: Record; chapters: CourseChapter[]; + package?: { credits: number } | null; }; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { @@ -138,7 +139,14 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s {formatNumber(course.price, locale)} {t('تومان', 'Toman')} - +
    {duration && (
  • diff --git a/apps/web/src/features/courses/components/course-enrollment-cta.tsx b/apps/web/src/features/courses/components/course-enrollment-cta.tsx index 765d498..1f9b1d5 100644 --- a/apps/web/src/features/courses/components/course-enrollment-cta.tsx +++ b/apps/web/src/features/courses/components/course-enrollment-cta.tsx @@ -1,14 +1,42 @@ 'use client'; import Link from 'next/link'; +import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { PlayCircle } from 'lucide-react'; -import { api, ApiError } from '@/shared/services/api'; +import { CalendarDays, Check, PlayCircle } from 'lucide-react'; +import { api, ApiError, publicApi } from '@/shared/services/api'; import { ReceiptTopUp } from '@/features/student/components/student-wallet'; import { walletService } from '@/lib/wallet-service'; import { useTranslations } from '@/components/shared/locale-provider'; import { localePath } from '@/lib/i18n'; import type { CoursePlayerPayload } from '../course-types'; -export function CourseEnrollmentCta({ slug, courseId, price }: { slug: string; courseId: string; price: number }) { +type Slot = { startsAt: string; endsAt: string; date: string; timezone: string; type: 'trial' | 'regular' }; + +export function CourseEnrollmentCta({ + slug, + courseId, + price, + format, + teacherId, + sessionsCount, +}: { + slug: string; + courseId: string; + price: number; + format?: 'SELF_PACED' | 'LIVE_ONLINE'; + teacherId?: string | null; + sessionsCount: number; +}) { + const [selected, setSelected] = useState([]); + const ranges = useMemo(() => { + const from = new Date(); + from.setSeconds(0, 0); + const middle = new Date(from.getTime() + 30 * 86_400_000); + const to = new Date(from.getTime() + 60 * 86_400_000); + return [ + { from: from.toISOString(), to: middle.toISOString() }, + { from: middle.toISOString(), to: to.toISOString() }, + ]; + }, []); const { locale } = useTranslations(), english = locale === 'en', query = useQuery({ @@ -26,6 +54,22 @@ export function CourseEnrollmentCta({ slug, courseId, price }: { slug: string; c (row) => row.courseId === courseId && row.status === 'PENDING' && row.hasReceipt, ), enabled: notEnrolled, + }), + slots = useQuery({ + queryKey: ['course-slots', teacherId, ranges], + queryFn: async () => { + const chunks = await Promise.all( + ranges.map(({ from, to }) => + publicApi( + `/availability/${teacherId}/slots?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&type=regular`, + ), + ), + ); + return [...new Map(chunks.flat().map((slot) => [slot.startsAt, slot])).values()].sort((a, b) => + a.startsAt.localeCompare(b.startsAt), + ); + }, + enabled: notEnrolled && format === 'LIVE_ONLINE' && Boolean(teacherId) && !pendingReceipt.data, }); if (notEnrolled) return pendingReceipt.data ? ( @@ -34,6 +78,73 @@ export function CourseEnrollmentCta({ slug, courseId, price }: { slug: string; c ? 'Your payment receipt is awaiting review. The course unlocks once it is approved.' : 'رسید پرداخت شما در انتظار تأیید است. پس از تأیید، دوره فعال می‌شود.'}

    + ) : format === 'LIVE_ONLINE' ? ( +
    +
    +
    + + {english ? 'Choose your class times' : 'انتخاب نوبت‌های کلاس'} +
    +

    + {english + ? `Choose ${sessionsCount} available times from the teacher before uploading your receipt.` + : `پیش از بارگذاری فیش، ${sessionsCount.toLocaleString('fa-IR')} نوبت از زمان‌های آزاد مدرس انتخاب کنید.`} +

    + {slots.isLoading ?
    : null} + {slots.isError ? ( +

    + {english ? 'Available times could not be loaded.' : 'دریافت زمان‌های آزاد مدرس ناموفق بود.'} +

    + ) : null} + {slots.data && !slots.data.length ? ( +

    + {english + ? 'The teacher has no available times right now.' + : 'در حال حاضر نوبت آزادی برای این مدرس ثبت نشده است.'} +

    + ) : null} +
    + {slots.data?.map((slot) => { + const active = selected.some((item) => item.startsAt === slot.startsAt); + return ( + + ); + })} +
    +

    + {english + ? `${selected.length} of ${sessionsCount} selected` + : `${selected.length.toLocaleString('fa-IR')} از ${sessionsCount.toLocaleString('fa-IR')} نوبت انتخاب شده`} +

    +
    + {selected.length === sessionsCount ? ( + ({ startsAt, endsAt, timezone }))} + /> + ) : null} +
    ) : ( ); diff --git a/apps/web/src/features/panel/components/actions/admin/admin-settings-actions.tsx b/apps/web/src/features/panel/components/actions/admin/admin-settings-actions.tsx index 5e6f8cb..5ad0faf 100644 --- a/apps/web/src/features/panel/components/actions/admin/admin-settings-actions.tsx +++ b/apps/web/src/features/panel/components/actions/admin/admin-settings-actions.tsx @@ -9,38 +9,71 @@ export function AdminSettingsActions({ section, }: { endpoint: string; section: 'settings' | 'cms' } & Localized) { const action = useAction(endpoint); + const cardAction = useAction(endpoint); return (
    {section === 'settings' && ( - -
    { - event.preventDefault(); - const form = new FormData(event.currentTarget); - action.mutate(() => - api(`/admin/settings/${encodeURIComponent(value(form, 'key'))}`, { - method: 'PUT', - body: JSON.stringify({ - value: { value: value(form, 'settingValue') }, - public: form.get('public') === 'on', + <> + + { + event.preventDefault(); + const form = new FormData(event.currentTarget); + cardAction.mutate(() => + api('/admin/settings/payment.card', { + method: 'PUT', + body: JSON.stringify({ + value: { + cardNumber: value(form, 'cardNumber'), + holder: value(form, 'holder'), + bank: value(form, 'bank'), + }, + public: true, + }), }), - }), - ); - }} - > - - - - - {translate(fa, 'legacySaveSetting')} - - - -
    + ); + }} + > + + + + + {fa ? 'ذخیره اطلاعات کارت' : 'Save payment card'} + + + +
    + +
    { + event.preventDefault(); + const form = new FormData(event.currentTarget); + action.mutate(() => + api(`/admin/settings/${encodeURIComponent(value(form, 'key'))}`, { + method: 'PUT', + body: JSON.stringify({ + value: { value: value(form, 'settingValue') }, + public: form.get('public') === 'on', + }), + }), + ); + }} + > + + + + + {translate(fa, 'legacySaveSetting')} + + + +
    + )} {section === 'cms' && ( diff --git a/apps/web/src/features/student/components/student-wallet.tsx b/apps/web/src/features/student/components/student-wallet.tsx index 005f0d4..27a55c9 100644 --- a/apps/web/src/features/student/components/student-wallet.tsx +++ b/apps/web/src/features/student/components/student-wallet.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FileText, History, ReceiptText, Upload, WalletCards } from 'lucide-react'; -import { apiMessage } from '@/shared/services/api'; +import { apiMessage, publicApi } from '@/shared/services/api'; import { upload, uploadErrorMessage } from '@/shared/services/upload'; import { digitsOnly, faNumber, jalali, toman } from '@/lib/format'; import { walletService, type Invoice, type Transaction } from '@/lib/wallet-service'; @@ -244,8 +244,23 @@ const RECEIPT_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf * Card-to-card top-up: the student uploads the bank receipt and the wallet is * credited once an admin approves it in the finance center. */ -export function ReceiptTopUp({ course }: { course?: { id: string; price: number } }) { +export function ReceiptTopUp({ + course, + sessions, +}: { + course?: { id: string; price: number }; + sessions?: Array<{ startsAt: string; endsAt: string; timezone: string }>; +}) { const queryClient = useQueryClient(); + const paymentSettings = useQuery({ + queryKey: ['public-payment-card'], + queryFn: async () => { + const settings = await publicApi>('/support/public-settings'); + return settings.find((item) => item.key === 'payment.card')?.value as + | { cardNumber?: string; holder?: string; bank?: string } + | undefined; + }, + }); const [typedAmount, setAmount] = useState(0), [file, setFile] = useState(), [note, setNote] = useState(''); @@ -265,6 +280,7 @@ export function ReceiptTopUp({ course }: { course?: { id: string; price: number receiptFileId: fileId, note: note.trim() || undefined, courseId: course?.id, + sessions, }); }, onSuccess: async () => { @@ -283,6 +299,22 @@ export function ReceiptTopUp({ course }: { course?: { id: string; price: number ? 'مبلغ دوره را کارت‌به‌کارت واریز کنید و تصویر یا PDF رسید را بارگذاری کنید. پس از تأیید پشتیبانی، دوره به «دوره‌های من» اضافه می‌شود.' : 'مبلغ را کارت‌به‌کارت واریز کنید و تصویر یا PDF رسید را بارگذاری کنید. پس از تأیید پشتیبانی، موجودی کیف پول شما افزایش می‌یابد.'}

    + {course && paymentSettings.data?.cardNumber ? ( +
    +

    واریز به کارت اعلام‌شده توسط مدیریت

    +

    + {paymentSettings.data.cardNumber} +

    +

    + {[paymentSettings.data.holder, paymentSettings.data.bank].filter(Boolean).join(' · ')} +

    +
    + ) : null} + {course && sessions?.length ? ( +

    + {sessions.length.toLocaleString('fa-IR')} نوبت انتخاب شده همراه رسید برای تأیید مدیر ثبت می‌شود. +

    + ) : null}