mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
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:
@@ -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) ───────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 10–100,000), so its dev
|
// CAC bank rejects amounts below 10 (DJF bounds 10–100,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,
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user