feat: ( payment ): verify-before-cancel never cancel a paid booking

This commit is contained in:
Abubeker Yasin
2026-07-29 17:29:59 +03:00
parent 9a98d460b5
commit d7168a8d77
9 changed files with 290 additions and 41 deletions

View File

@@ -22,6 +22,17 @@ export interface PaymentDiagnostic {
provider: ProviderStatus | null;
}
/** Settlement check from POST /payments/reconcile (verify-before-cancel). */
export interface SettlementResult {
/** At least one intent for the order is paid (incl. a late capture just registered). */
paid: boolean;
/** The paying intent when `paid`. */
intent?: PaymentIntentSnapshot;
/** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the
* payment service was unreachable. The caller MUST NOT cancel the order. */
unverifiable: boolean;
}
/**
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
@@ -85,6 +96,31 @@ export class PaymentClientService {
}
}
/**
* POST /payments/reconcile — settlement check before cancelling an order. Live-queries every
* intent at the provider and registers any late capture found. A transport failure (payment
* service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so
* the caller does not cancel a booking whose payment simply could not be verified.
*/
async reconcileByReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<SettlementResult> {
try {
return await this.call<SettlementResult>("POST", "/payments/reconcile", {
service: PaymentService.PASSENGER,
referenceType,
referenceId,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`,
);
return { paid: false, unverifiable: true };
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a

View File

@@ -2,9 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ModuleRef } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentsService } from './payments.service';
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
// This service keeps only singleton deps so its @Cron method registers correctly,
@@ -16,7 +14,6 @@ export class PaymentSyncService {
constructor(
private readonly prisma: PrismaService,
private readonly paymentClient: PaymentClientService,
private readonly moduleRef: ModuleRef,
) {}
@@ -47,7 +44,6 @@ export class PaymentSyncService {
if (bookings.length === 0) return;
let confirmed = 0;
let failed = 0;
let errored = 0;
// resolve() (not get()) because PaymentsService is scoped — same pattern
@@ -59,37 +55,17 @@ export class PaymentSyncService {
);
for (const booking of bookings) {
if (!booking.paymentIntent) continue;
try {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
if (!snapshot) continue;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
const result = await paymentsService.finalizePaymentSuccess({
intentId: booking.paymentIntent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
if (!result.alreadyFinalized) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
} else if (
snapshot.status === ProviderPaymentStatus.FAILED ||
snapshot.status === ProviderPaymentStatus.CANCELLED
) {
this.logger.warn(
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status}` +
`booking will be auto-cancelled at payment deadline`,
);
failed++;
// Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones —
// and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH
// a lost confirm event (payment-api already SUCCEEDED) AND a payment recorded only at the
// provider (local intent terminal). Idempotent, so a re-run is safe.
const { paid } = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
if (paid) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
// not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline)
} catch (err) {
this.logger.error(
`Payment sync error for ${booking.bookingRef}: ` +
@@ -99,10 +75,9 @@ export class PaymentSyncService {
}
}
if (confirmed > 0 || failed > 0 || errored > 0) {
if (confirmed > 0 || errored > 0) {
this.logger.log(
`Payment sync run: ${bookings.length} checked, ` +
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
`Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`,
);
}
}

View File

@@ -833,6 +833,70 @@ export class PaymentsService {
return value;
}
/**
* Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is
* actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a
* verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike
* getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even
* terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider.
*
* - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is
* now CONFIRMED, so a cancellation caller must NOT cancel.
* - not paid → verified unpaid; a cancellation caller may proceed.
* - unverifiable (provider query errored, in-flight, or payment service unreachable) → a
* cancellation caller must NOT cancel this cycle; defer and retry later.
*/
async reconcileAndConfirmIfPaid(
bookingId: string,
): Promise<{ paid: boolean; verified: boolean }> {
const settlement = await this.paymentClient.reconcileByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
if (settlement.unverifiable) {
this.logger.warn(
`reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`,
);
return { paid: false, verified: false };
}
if (settlement.paid) {
if (settlement.intent) {
// Paid, but the confirm event may have been lost. Confirm synchronously (idempotent).
const intent = await this.syncIntentProjection(
bookingId,
settlement.intent,
);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: settlement.intent.providerTxnId,
paidAt: settlement.intent.paidAt
? new Date(settlement.intent.paidAt)
: undefined,
}).catch((err) => {
this.logger.error(
`reconcile-before-cancel: finalize failed for booking ${bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { alreadyFinalized: false };
});
this.logger.log(
`reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`,
);
} else {
this.logger.error(
`reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`,
);
}
return { paid: true, verified: true };
}
// Verified not paid — safe to cancel.
return { paid: false, verified: true };
}
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;