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

@@ -20,7 +20,10 @@ import {
IntentReferenceQueryDto,
} from "./dto/initiate-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";
/**
@@ -108,6 +111,26 @@ export class IntentsController {
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")
@ApiOperation({
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(
merchantOrderId: string,
): Promise<PaymentIntent | null> {

View File

@@ -44,6 +44,19 @@ export interface ProviderResultInput {
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()
export class IntentsService {
private readonly logger = new Logger(IntentsService.name);
@@ -301,6 +314,93 @@ export class IntentsService {
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). */
private async queryProviderForMerchantOrder(
merchantOrderId: string,