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

@@ -10,6 +10,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service'; import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@@ -109,6 +111,7 @@ export class BookingsService {
private readonly currencyService: CurrencyService, private readonly currencyService: CurrencyService,
private readonly fareEngine: FareEngineService, private readonly fareEngine: FareEngineService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly paymentsService: PaymentsService,
) {} ) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
@@ -2129,6 +2132,14 @@ export class BookingsService {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } }); const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found'); if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled'); if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
// Verify-before-cancel: a still-PENDING_PAYMENT booking may actually be paid (its confirm event
// was lost/late). reconcileAndConfirmIfPaid confirms it synchronously if so — refuse to cancel a
// paid, or currently-unverifiable, booking as "unpaid".
if (booking.status === 'PENDING_PAYMENT') {
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id);
if (paid) throw new BadRequestException('Payment for this booking has completed; it is now confirmed and cannot be cancelled as unpaid.');
if (!verified) throw new BadRequestException('Could not verify payment status right now; please try again shortly.');
}
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0; const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } }); await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.id); await this.seatsService.releaseSeats(booking.id);
@@ -2265,11 +2276,24 @@ export class BookingsService {
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() { async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000); // NEUTRALIZED (was 20 minutes): the payment window is MAX_PAYMENT_HOURS (2h). Bookings must
// NEVER be cancelled at 20 minutes — the payer still has up to 2 hours, and the seat hold is
// held for exactly this window. Aligned to the 2-hour window so this cron can only ever act as
// a safe backup to the primary deadline-aware sweep (TasksService.cancelExpiredPendingBookings);
// it never cancels prematurely, and paid bookings are still protected by the guard below.
const cutoff = new Date(Date.now() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } }); const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) { for (const b of expired) {
await this.seatsService.releaseSeats(b.id); try {
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); // Never cancel a paid booking whose confirm event was lost/late — verify first (this
// confirms it synchronously if paid). Skip when paid or currently unverifiable.
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(b.id);
if (paid || !verified) continue;
await this.seatsService.releaseSeats(b.id);
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
} catch (err) {
this.logger.error(`expirePendingBookings failed for ${b.id}: ${err instanceof Error ? err.message : String(err)}`);
}
} }
} }

View File

@@ -22,6 +22,17 @@ export interface PaymentDiagnostic {
provider: ProviderStatus | null; 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 * 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; * 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). * 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 * 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 { Cron } from '@nestjs/schedule';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentsService } from './payments.service'; import { PaymentsService } from './payments.service';
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up). // PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
// This service keeps only singleton deps so its @Cron method registers correctly, // This service keeps only singleton deps so its @Cron method registers correctly,
@@ -16,7 +14,6 @@ export class PaymentSyncService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly paymentClient: PaymentClientService,
private readonly moduleRef: ModuleRef, private readonly moduleRef: ModuleRef,
) {} ) {}
@@ -47,7 +44,6 @@ export class PaymentSyncService {
if (bookings.length === 0) return; if (bookings.length === 0) return;
let confirmed = 0; let confirmed = 0;
let failed = 0;
let errored = 0; let errored = 0;
// resolve() (not get()) because PaymentsService is scoped — same pattern // resolve() (not get()) because PaymentsService is scoped — same pattern
@@ -59,37 +55,17 @@ export class PaymentSyncService {
); );
for (const booking of bookings) { for (const booking of bookings) {
if (!booking.paymentIntent) continue;
try { try {
const snapshot = await this.paymentClient.getIntentByReference( // Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones —
PaymentReferenceType.BOOKING, // and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH
booking.id, // 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 (!snapshot) continue; if (paid) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { confirmed++;
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++;
} }
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle // not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline)
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Payment sync error for ${booking.bookingRef}: ` + `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( this.logger.log(
`Payment sync run: ${bookings.length} checked, ` + `Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`,
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
); );
} }
} }

View File

@@ -833,6 +833,70 @@ export class PaymentsService {
return value; 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: { async finalizePaymentSuccess(input: {
intentId: string; intentId: string;
providerTxnId?: string; providerTxnId?: string;

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module'; import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module'; import { CurrencyModule } from '../currency/currency.module';
import { PaymentsModule } from '../payments/payments.module';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
@Module({ @Module({
imports: [PrismaModule, NotificationsModule, CurrencyModule], imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
providers: [TasksService], providers: [TasksService],
}) })
export class TasksModule {} export class TasksModule {}

View File

@@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service'; import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows // Retention windows
@@ -28,6 +29,7 @@ export class TasksService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly sms: SmsClientService, private readonly sms: SmsClientService,
private readonly currencyService: CurrencyService, private readonly currencyService: CurrencyService,
private readonly paymentsService: PaymentsService,
) {} ) {}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -308,6 +310,18 @@ export class TasksService {
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue; if (now < paymentDeadline) continue;
// 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.
const settlement = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id);
if (settlement.paid || !settlement.verified) {
this.logger.log(
`Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`,
);
continue;
}
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid) // 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 }); await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });

View File

@@ -20,7 +20,10 @@ import {
IntentReferenceQueryDto, IntentReferenceQueryDto,
} from "./dto/initiate-payment.dto"; } from "./dto/initiate-payment.dto";
import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { ConfirmPaymentDto } from "./dto/confirm-payment.dto";
import { IntentsService } from "./intents.service"; import {
IntentsService,
ReconcileReferenceResult,
} from "./intents.service";
import { PaymentIntent } from "./entities/payment-intent.entity"; import { PaymentIntent } from "./entities/payment-intent.entity";
/** /**
@@ -108,6 +111,26 @@ export class IntentsController {
return this.intentsService.getByMerchantOrderId(merchantOrderId, provider); return this.intentsService.getByMerchantOrderId(merchantOrderId, provider);
} }
@Post("reconcile")
@ApiOperation({
summary: "Settlement check for a domain order (reconcile-before-cancel)",
description:
"Returns whether ANY intent for the reference is paid. Live-queries every non-failed intent " +
"— including already-retired (expired/cancelled) ones — at the provider and registers any " +
"late capture found (flips it to SUCCEEDED and emits payment.succeeded). " +
"`unverifiable: true` means settlement could not be confirmed (a provider query errored or a " +
"payment is still in flight) — the caller MUST NOT cancel the order in that case.",
})
async reconcile(
@Body() dto: IntentReferenceQueryDto,
): Promise<ReconcileReferenceResult> {
return this.intentsService.reconcileReference(
dto.service,
dto.referenceType,
dto.referenceId,
);
}
@Post("intents/:id/confirm") @Post("intents/:id/confirm")
@ApiOperation({ @ApiOperation({
summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)",

View File

@@ -58,6 +58,18 @@ export class IntentsRepository extends BaseRepository<PaymentIntent> {
}); });
} }
/** Every intent for a domain order (newest first) — input for reconcile/settlement checks. */
async findAllByReference(
service: PaymentService,
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntent[]> {
return this.repository.find({
where: { service, referenceType, referenceId },
order: { createdAt: "DESC" },
});
}
async findByMerchantOrderId( async findByMerchantOrderId(
merchantOrderId: string, merchantOrderId: string,
): Promise<PaymentIntent | null> { ): Promise<PaymentIntent | null> {

View File

@@ -44,6 +44,19 @@ export interface ProviderResultInput {
rawResponse?: Record<string, unknown>; rawResponse?: Record<string, unknown>;
} }
/** Result of {@link IntentsService.reconcileReference} — a settlement check for a domain order. */
export interface ReconcileReferenceResult {
/** True when at least one intent for the order is settled (SUCCEEDED, incl. a just-registered late capture). */
paid: boolean;
/** Snapshot of the paying intent when `paid`. */
intent?: PaymentIntentSnapshot;
/**
* True when we could NOT confirm "not paid": at least one candidate intent's provider status
* query errored, so its settlement is unknown. Callers must treat this as "do not cancel".
*/
unverifiable: boolean;
}
@Injectable() @Injectable()
export class IntentsService { export class IntentsService {
private readonly logger = new Logger(IntentsService.name); private readonly logger = new Logger(IntentsService.name);
@@ -301,6 +314,93 @@ export class IntentsService {
return { db: intent ?? null, provider: providerStatus }; return { db: intent ?? null, provider: providerStatus };
} }
/**
* Settlement check for a domain order, tolerant of MANY intents. Used before the owning app
* cancels a still-unpaid booking — a paid booking whose `payment.succeeded` event was lost (MQ
* down, late/missing webhook) must NOT be cancelled. Resolution:
* 1. any intent already SUCCEEDED → paid;
* 2. else live-query every non-FAILED intent (incl. expired/cancelled — the session may have
* been paid after we retired it) and feed the result through the state machine, so a paid
* terminal intent is registered SUCCEEDED (and emits payment.succeeded) → paid;
* 3. else not paid.
* `unverifiable` is set when a candidate's provider query errored, or a payment is still in
* flight (PROCESSING) — i.e. we could NOT confirm "not paid"; the caller must then NOT cancel.
*/
async reconcileReference(
service: PaymentService,
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<ReconcileReferenceResult> {
const intents = await this.intentsRepository.findAllByReference(
service,
referenceType,
referenceId,
);
if (intents.length === 0) {
return { paid: false, unverifiable: false };
}
const alreadyPaid = intents.find(
(i) => i.status === ProviderPaymentStatus.SUCCEEDED,
);
if (alreadyPaid) {
return {
paid: true,
intent: this.toSnapshot(alreadyPaid),
unverifiable: false,
};
}
// Live-query every intent that could plausibly hold a payment (skip FAILED; SUCCEEDED handled
// above). A CANCELLED/EXPIRED intent may still have been paid at the provider after we retired it.
const candidates = intents.filter(
(i) => i.status !== ProviderPaymentStatus.FAILED,
);
let providerErrors = 0;
let inFlight = false;
for (const intent of candidates) {
let status: ProviderStatus;
try {
status = await this.queryProviderStatus(intent);
} catch (err) {
providerErrors++;
this.logger.warn(
`reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${
err instanceof Error ? err.message : String(err)
}`,
);
continue;
}
if (
status.status === ProviderPaymentStatus.SUCCEEDED ||
status.status === ProviderPaymentStatus.PROCESSING
) {
// Register the (possibly late) result through the state machine — a terminal intent that
// was paid flips to SUCCEEDED and emits payment.succeeded.
await this.applyProviderResult(
intent.id,
this.fromProviderStatus(status),
);
}
if (status.status === ProviderPaymentStatus.SUCCEEDED) {
const refreshed =
(await this.intentsRepository.findById(intent.id)) ?? intent;
return {
paid: true,
intent: this.toSnapshot(refreshed),
unverifiable: false,
};
}
if (status.status === ProviderPaymentStatus.PROCESSING) {
inFlight = true; // money in flight — not settled, but not safe to cancel either
}
}
return { paid: false, unverifiable: providerErrors > 0 || inFlight };
}
/** Best-effort live provider status for a merchant order id; never throws (returns null). */ /** Best-effort live provider status for a merchant order id; never throws (returns null). */
private async queryProviderForMerchantOrder( private async queryProviderForMerchantOrder(
merchantOrderId: string, merchantOrderId: string,