From 9ab66c8ca7e45e9aac96bcc3ce79daff98df3c89 Mon Sep 17 00:00:00 2001
From: firoozeh daeizadeh <81993335+fdaei@users.noreply.github.com>
Date: Thu, 17 Sep 2026 20:07:32 +0330
Subject: [PATCH 1/2] feat: complete live course receipt scheduling
---
.../migration.sql | 10 ++
apps/api/prisma/schema.prisma | 132 +++++++++---------
apps/api/prisma/seed.ts | 1 +
.../modules/bookings/bookings.repository.ts | 14 +-
.../commerce/dto/request/payments.dto.ts | 25 +++-
.../commerce/payments/payments.service.ts | 70 ++++++++--
.../payments/receipt-top-ups.service.spec.ts | 73 ++++++++--
.../payments/receipt-top-ups.service.ts | 120 +++++++++++++---
.../src/modules/courses/courses.service.ts | 13 +-
apps/web/src/app/courses/[slug]/page.tsx | 10 +-
.../components/course-enrollment-cta.tsx | 104 +++++++++++++-
.../actions/admin/admin-settings-actions.tsx | 89 ++++++++----
.../student/components/student-wallet.tsx | 36 ++++-
apps/web/src/lib/marketplace-data.ts | 2 +
apps/web/src/lib/wallet-service.ts | 8 +-
15 files changed, 557 insertions(+), 150 deletions(-)
create mode 100644 apps/api/prisma/migrations/20260917190000_live_course_checkout/migration.sql
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
{duration && (
+ {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} ++ {english + ? `${selected.length} of ${sessionsCount} selected` + : `${selected.length.toLocaleString('fa-IR')} از ${sessionsCount.toLocaleString('fa-IR')} نوبت انتخاب شده`} +
+واریز به کارت اعلامشده توسط مدیریت
++ {paymentSettings.data.cardNumber} +
++ {[paymentSettings.data.holder, paymentSettings.data.bank].filter(Boolean).join(' · ')} +
++ {sessions.length.toLocaleString('fa-IR')} نوبت انتخاب شده همراه رسید برای تأیید مدیر ثبت میشود. +
+ ) : null}