feat(billing): enhance invoice payment processing and revert handling

feat(bookings): add event handlers for booking invoice payment processing
fix(bookings): include PAYMENT_VERIFICATION_IN_PROGRESS status in queries
fix(train-scheduling): update status checks to include PAYMENT_VERIFICATION_IN_PROGRESS
feat(notifier): notify customers when a train is cancelled
This commit is contained in:
Marshal
2026-08-05 20:46:44 +00:00
parent 1b5f364294
commit 8089e5cfd9
8 changed files with 102 additions and 24 deletions

View File

@@ -1058,16 +1058,24 @@ export class BillingService {
* settlement; settleByPaymentId still performs the real transition. * settlement; settleByPaymentId still performs the real transition.
*/ */
async markInvoicePaymentProcessing(paymentId: string): Promise<void> { async markInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource.getRepository(Invoice).update( const repo = this.dataSource.getRepository(Invoice);
{ const invoices = await repo.findBy({
paymentId, paymentId,
status: In([ status: In([
Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Pending,
]), ]),
}, });
{ status: Freight.InvoiceStatus.PaymentProcessing }, 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. * retry. No-op from any other status.
*/ */
async revertInvoicePaymentProcessing(paymentId: string): Promise<void> { async revertInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource const repo = this.dataSource.getRepository(Invoice);
.getRepository(Invoice) const invoices = await repo.findBy({
.update( paymentId,
{ paymentId, status: Freight.InvoiceStatus.PaymentProcessing }, status: Freight.InvoiceStatus.PaymentProcessing,
});
for (const invoice of invoices) {
await repo.update(
{ id: invoice.id, status: Freight.InvoiceStatus.PaymentProcessing },
{ status: Freight.InvoiceStatus.Pending }, { status: Freight.InvoiceStatus.Pending },
); );
this.emitInvoiceEvent("payment-processing-reverted", {
...invoice,
status: Freight.InvoiceStatus.Pending,
} as Invoice);
}
} }
// ── Payment initiation & settlement (the gateway boundary) ─────────────────── // ── Payment initiation & settlement (the gateway boundary) ───────────────────

View File

@@ -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<void> {
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<void> {
await this.dataSource.getRepository(Booking).update(
{ id: payload.sourceId, status: "PAYMENT_VERIFICATION_IN_PROGRESS" },
{ status: "SELECTED_FOR_BATCH" },
);
}
updateStatus( updateStatus(
invoiceId: string, invoiceId: string,
status: Freight.InvoiceStatus, status: Freight.InvoiceStatus,

View File

@@ -1405,7 +1405,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId }) .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(); .getMany();
} }

View File

@@ -242,11 +242,12 @@ export class PaymentService {
// debited against the intent amount, so the dev shortcut would break it. // debited against the intent amount, so the dev shortcut would break it.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev // CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// shortcut floor is 10, not 1. // shortcut floor is 10, not 1.
amountMinor: isCbeBill // amountMinor: isCbeBill
? input.amountMinor // ? input.amountMinor
: input.method === ProviderMethod.CAC_BANK // : input.method === ProviderMethod.CAC_BANK
? 10 // ? 10
: 1, // : 1,
amountMinor: input.amountMinor,
currency: input.currency, currency: input.currency,
provider: input.method as ProviderMethod, provider: input.method as ProviderMethod,
platform: input.platform, platform: input.platform,

View File

@@ -422,7 +422,9 @@ export class BookingBatchService implements OnModuleInit {
.getRepository(Booking) .getRepository(Booking)
.createQueryBuilder("b") .createQueryBuilder("b")
.select("DISTINCT b.train_schedule_id", "scheduleId") .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") .andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>(); .getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId); for (const { scheduleId } of reserved) this.armSettle(scheduleId);
@@ -2118,7 +2120,9 @@ export class BookingBatchService implements OnModuleInit {
if (linked) return "ALLOCATED"; if (linked) return "ALLOCATED";
if ( if (
booking.status === "SELECTED_FOR_BATCH" || 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"; return "SELECTED_FOR_BATCH";
} }

View File

@@ -300,6 +300,19 @@ export class BookingNotifierService {
this.inApp(b, 'Removed from train', msg); 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 * The train carrying this booking was moved for maintenance to a new departure
* date. The booking stays on the train — only the date moved. * date. The booking stays on the train — only the date moved.

View File

@@ -610,7 +610,9 @@ export class BookingWindowService implements OnModuleInit {
.getRepository(Booking) .getRepository(Booking)
.createQueryBuilder('b') .createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId') .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 // Deadline is the line — expire() itself reconciles against the gateway
// before actually expiring, so a late in-window payment is still caught. // before actually expiring, so a late in-window payment is still caught.
.andWhere('b.payment_deadline <= now()') .andWhere('b.payment_deadline <= now()')

View File

@@ -2653,7 +2653,9 @@ export class TrainSchedulingService {
paymentDeadline: null, paymentDeadline: null,
}) })
.where('train_schedule_id = :scheduleId', { scheduleId }) .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(); .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. // Window retired (DONE) — remove the card from portal/GL lists right away.
void this.emitWindowState(id); void this.emitWindowState(id);
return this.getTrainScheduleById(id); return this.getTrainScheduleById(id);