mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
fix: ( payments ) stop reconcile-before-cancel deferring bookings forever
This commit is contained in:
@@ -14,6 +14,15 @@ const AUDIT_LOG_RETENTION_DAYS = 365;
|
||||
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
|
||||
const GATE_LOG_RETENTION_DAYS = 180;
|
||||
|
||||
// How long past its payment deadline a booking may sit undecided because the GATEWAY cannot be
|
||||
// reached (PROVIDER_ERROR) before the sweep stops deferring and cancels anyway. Without a bound,
|
||||
// a permanently unreachable provider pins a booking as PENDING_PAYMENT forever — its seats stay
|
||||
// held and the sweep re-queries it once a minute, indefinitely. NEVER applied to an IN_FLIGHT
|
||||
// settlement: money that is actually moving is waited out no matter how long it takes.
|
||||
// Raise this in production — a 10-minute gateway outage should not mass-cancel bookings that may
|
||||
// well be paid (a late payment then lands on a CANCELLED booking and needs a manual refund).
|
||||
const RECONCILE_GRACE_MINUTES = Number(process.env.RECONCILE_GRACE_MINUTES) || 5;
|
||||
|
||||
function fmtTime(d: Date): string {
|
||||
return d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
@@ -325,15 +334,38 @@ export class TasksService {
|
||||
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
|
||||
// event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck
|
||||
// PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously
|
||||
// if paid. Only proceed to cancel when settlement is VERIFIED unpaid.
|
||||
// if paid. Cancel only on a VERIFIED-unpaid settlement — or, past the grace window below,
|
||||
// on a settlement the gateway simply refuses to answer for.
|
||||
const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (settlement.paid || !settlement.verified) {
|
||||
this.logger.log(
|
||||
`Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`,
|
||||
);
|
||||
if (settlement.paid) {
|
||||
this.logger.log(`Skip auto-cancel ${booking.bookingRef}: PAID → confirmed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unverifiable: defer — but not forever. IN_FLIGHT is real money moving, so it is waited
|
||||
// out indefinitely. A PROVIDER_ERROR (dead gateway, payment service down) is bounded by
|
||||
// RECONCILE_GRACE_MINUTES past the deadline; beyond that the booking is cancelled on an
|
||||
// UNVERIFIED settlement, which is recorded explicitly below so finance can chase it.
|
||||
let unverifiedGiveUp = false;
|
||||
if (!settlement.verified) {
|
||||
const graceExpiresAt = new Date(
|
||||
paymentDeadline.getTime() + RECONCILE_GRACE_MINUTES * 60 * 1000,
|
||||
);
|
||||
if (settlement.reason === 'IN_FLIGHT' || now < graceExpiresAt) {
|
||||
this.logger.log(
|
||||
`Skip auto-cancel ${booking.bookingRef}: unverifiable (${settlement.reason ?? 'PROVIDER_ERROR'}) → deferred`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
unverifiedGiveUp = true;
|
||||
this.logger.error(
|
||||
`Auto-cancelling ${booking.bookingRef} on an UNVERIFIED settlement — the gateway has ` +
|
||||
`been unreachable for ${RECONCILE_GRACE_MINUTES}+ min past the deadline. If this ` +
|
||||
`booking was in fact paid, the payment will land on a CANCELLED booking and needs a ` +
|
||||
`manual refund.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
@@ -349,15 +381,21 @@ export class TasksService {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Audit record (no refund — payment was never completed)
|
||||
// 2. Audit record (no refund — payment was verified never completed, or, on an unverified
|
||||
// give-up, flagged for review because we could not establish that)
|
||||
await this.prisma.bookingCancellation.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
cancelledBy: 'SYSTEM',
|
||||
reason: 'Payment not completed before deadline',
|
||||
reason: unverifiedGiveUp
|
||||
? `Payment not completed before deadline; settlement UNVERIFIED — gateway unreachable ` +
|
||||
`for ${RECONCILE_GRACE_MINUTES}+ min past the deadline. Confirm no payment was taken.`
|
||||
: 'Payment not completed before deadline',
|
||||
refundAmount: 0,
|
||||
refundMethod: booking.paymentIntent?.method ?? 'NONE',
|
||||
refundStatus: 'NOT_APPLICABLE',
|
||||
// An unverified give-up may yet turn out to have been paid, so it is neither
|
||||
// NOT_APPLICABLE nor a refund actually owed — flag it for a human instead.
|
||||
refundStatus: unverifiedGiveUp ? 'REVIEW_REQUIRED' : 'NOT_APPLICABLE',
|
||||
},
|
||||
}).catch(() => null);
|
||||
|
||||
@@ -380,7 +418,10 @@ export class TasksService {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
|
||||
this.logger.log(
|
||||
`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})` +
|
||||
(unverifiedGiveUp ? ' — UNVERIFIED settlement, review required' : ''),
|
||||
);
|
||||
cancelledCount++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
|
||||
Reference in New Issue
Block a user