diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 60f173596..776f967be 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1058,16 +1058,24 @@ export class BillingService { * settlement; settleByPaymentId still performs the real transition. */ async markInvoicePaymentProcessing(paymentId: string): Promise { - await this.dataSource.getRepository(Invoice).update( - { - paymentId, - status: In([ - Freight.InvoiceStatus.Issued, - Freight.InvoiceStatus.Pending, - ]), - }, - { status: Freight.InvoiceStatus.PaymentProcessing }, - ); + const repo = this.dataSource.getRepository(Invoice); + const invoices = await repo.findBy({ + paymentId, + status: In([ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + ]), + }); + for (const invoice of invoices) { + await repo.update( + { id: invoice.id, status: invoice.status }, + { status: Freight.InvoiceStatus.PaymentProcessing }, + ); + this.emitInvoiceEvent("payment-processing", { + ...invoice, + status: Freight.InvoiceStatus.PaymentProcessing, + } as Invoice); + } } /** @@ -1076,12 +1084,21 @@ export class BillingService { * retry. No-op from any other status. */ async revertInvoicePaymentProcessing(paymentId: string): Promise { - await this.dataSource - .getRepository(Invoice) - .update( - { paymentId, status: Freight.InvoiceStatus.PaymentProcessing }, + const repo = this.dataSource.getRepository(Invoice); + const invoices = await repo.findBy({ + paymentId, + status: Freight.InvoiceStatus.PaymentProcessing, + }); + for (const invoice of invoices) { + await repo.update( + { id: invoice.id, status: Freight.InvoiceStatus.PaymentProcessing }, { status: Freight.InvoiceStatus.Pending }, ); + this.emitInvoiceEvent("payment-processing-reverted", { + ...invoice, + status: Freight.InvoiceStatus.Pending, + } as Invoice); + } } // ── Payment initiation & settlement (the gateway boundary) ─────────────────── diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 9212bcdef..cd803d719 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -115,6 +115,34 @@ export class BookingInvoiceService { } } + /** + * Success-redirect ack: the customer finished provider checkout, webhook not + * in yet. Mirror the invoice's PAYMENT_PROCESSING on the booking so the + * portal stops offering "Pay now". Display state only — settlement + * (`booking.invoice.paid`) still drives PAID. Status-guarded, so it never + * touches a booking that already advanced or was terminated. + */ + @OnEvent("booking.invoice.payment-processing") + async onBookingInvoicePaymentProcessing( + payload: InvoiceEventPayload, + ): Promise { + await this.dataSource.getRepository(Booking).update( + { id: payload.sourceId, status: "SELECTED_FOR_BATCH" }, + { status: "PAYMENT_VERIFICATION_IN_PROGRESS" }, + ); + } + + /** Payment failed after a redirect ack — the booking reads payable again. */ + @OnEvent("booking.invoice.payment-processing-reverted") + async onBookingInvoicePaymentProcessingReverted( + payload: InvoiceEventPayload, + ): Promise { + await this.dataSource.getRepository(Booking).update( + { id: payload.sourceId, status: "PAYMENT_VERIFICATION_IN_PROGRESS" }, + { status: "SELECTED_FOR_BATCH" }, + ); + } + updateStatus( invoiceId: string, status: Freight.InvoiceStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 35f84e419..1c5cc7481 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1405,7 +1405,9 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) - .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere( + `booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) .getMany(); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index afe6009ef..316f3e8fe 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -242,11 +242,12 @@ export class PaymentService { // debited against the intent amount, so the dev shortcut would break it. // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev // shortcut floor is 10, not 1. - amountMinor: isCbeBill - ? input.amountMinor - : input.method === ProviderMethod.CAC_BANK - ? 10 - : 1, + // amountMinor: isCbeBill + // ? input.amountMinor + // : input.method === ProviderMethod.CAC_BANK + // ? 10 + // : 1, + amountMinor: input.amountMinor, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 966e051d3..5185dcd99 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -422,7 +422,9 @@ export class BookingBatchService implements OnModuleInit { .getRepository(Booking) .createQueryBuilder("b") .select("DISTINCT b.train_schedule_id", "scheduleId") - .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .where( + `b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); @@ -2118,7 +2120,9 @@ export class BookingBatchService implements OnModuleInit { if (linked) return "ALLOCATED"; if ( booking.status === "SELECTED_FOR_BATCH" || - booking.status === "AWAITING_PAYMENT" + booking.status === "AWAITING_PAYMENT" || + // Redirect-acked, webhook pending — still a reserved (unpaid) hold. + booking.status === "PAYMENT_VERIFICATION_IN_PROGRESS" ) { return "SELECTED_FOR_BATCH"; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 84d029e4f..efb540066 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -300,6 +300,19 @@ export class BookingNotifierService { this.inApp(b, 'Removed from train', msg); } + /** + * The train carrying this booking was cancelled. The booking is detached and + * returns to the eligible pool — the customer must rebook or pick a new schedule. + */ + scheduleCancelled(b: Booking): void { + const msg = + `The train for booking ${b.reference ?? b.id} has been cancelled. ` + + `Your booking is not lost — please rebook or select a new schedule from the portal.`; + void this.notifyContact(b, msg, 'TRAIN CANCELLED'); + // HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. + this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH }); + } + /** * The train carrying this booking was moved for maintenance to a new departure * date. The booking stays on the train — only the date moved. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index c60dd14a6..1a3baa6b4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -610,7 +610,9 @@ export class BookingWindowService implements OnModuleInit { .getRepository(Booking) .createQueryBuilder('b') .select('DISTINCT b.train_schedule_id', 'scheduleId') - .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .where( + `b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) // Deadline is the line — expire() itself reconciles against the gateway // before actually expiring, so a late in-window payment is still caught. .andWhere('b.payment_deadline <= now()') diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 44c50e185..df5c44fde 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2653,7 +2653,9 @@ export class TrainSchedulingService { paymentDeadline: null, }) .where('train_schedule_id = :scheduleId', { scheduleId }) - .andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere( + `status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) .execute(); }); @@ -4247,6 +4249,15 @@ export class TrainSchedulingService { } }); + // Best-effort customer notice (SMS + email + in-app) — the cancel itself has + // already committed, so a notification failure must never fail the cancel. + for (const sb of schedule.scheduleBookings ?? []) { + const booking = await this.bookingsRepository + .findByIdWithFiles(sb.bookingId) + .catch(() => null); + if (booking) this.bookingNotifier.scheduleCancelled(booking); + } + // Window retired (DONE) — remove the card from portal/GL lists right away. void this.emitWindowState(id); return this.getTrainScheduleById(id);