Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
132 changes: 68 additions & 64 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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])
Expand Down
1 change: 1 addition & 0 deletions apps/api/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
14 changes: 10 additions & 4 deletions apps/api/src/modules/bookings/bookings.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand All @@ -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: {
Expand All @@ -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: {
Expand Down
25 changes: 23 additions & 2 deletions apps/api/src/modules/commerce/dto/request/payments.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
70 changes: 55 additions & 15 deletions apps/api/src/modules/commerce/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 } });
});
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading