mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
764 lines
29 KiB
TypeScript
764 lines
29 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { DataSource } from "typeorm";
|
|
import {
|
|
createMerchantOrderId,
|
|
CacBankProvider,
|
|
EBirrProvider,
|
|
} from "@edr/payment-providers";
|
|
import {
|
|
ConfirmPaymentRequest,
|
|
InitiatePaymentRequest,
|
|
PaymentIntentSnapshot,
|
|
PaymentReferenceType,
|
|
PaymentService,
|
|
ProviderMethod,
|
|
ProviderPaymentStatus,
|
|
ProviderStatus,
|
|
} from "@edr/types";
|
|
import {
|
|
PAYMENT_PROVIDER_MAP,
|
|
PaymentProviderMap,
|
|
} from "../providers/providers.module";
|
|
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
|
import { buildOutboxRow } from "../outbox/payment-event.factory";
|
|
import {
|
|
PaymentIntent,
|
|
TERMINAL_INTENT_STATUSES,
|
|
} from "./entities/payment-intent.entity";
|
|
import { IntentsRepository } from "./intents.repository";
|
|
import { BillReferenceService } from "./bill-reference.service";
|
|
|
|
/** Don't hit the provider again if the intent was refreshed this recently. */
|
|
const REFRESH_MIN_AGE_MS = 5_000;
|
|
|
|
/** Result of a provider signal (webhook or status query) applied to the state machine. */
|
|
export interface ProviderResultInput {
|
|
status: ProviderPaymentStatus;
|
|
providerTxnId?: string;
|
|
paidAt?: Date;
|
|
confirmedAmountMinor?: number;
|
|
failureCode?: string;
|
|
failureMessage?: string;
|
|
/** Raw provider status-query body, merged into the intent's audit payload when present. */
|
|
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);
|
|
|
|
constructor(
|
|
private readonly intentsRepository: IntentsRepository,
|
|
// DataSource is used only for the finalize transaction (intent update + outbox insert
|
|
// must commit atomically); routine access still goes through the custom repository.
|
|
private readonly dataSource: DataSource,
|
|
@Inject(PAYMENT_PROVIDER_MAP)
|
|
private readonly providers: PaymentProviderMap,
|
|
private readonly cacBankProvider: CacBankProvider,
|
|
// eBirr's debit is awaited on the request path (see settleEBirrPurchase), so we need the
|
|
// concrete class for its non-interface `purchase()` — same pattern as CacBankProvider.
|
|
private readonly eBirrProvider: EBirrProvider,
|
|
private readonly billReferenceService: BillReferenceService,
|
|
) {}
|
|
|
|
/* ------------------------------------------------------------------ initiate */
|
|
|
|
async initiate(
|
|
request: InitiatePaymentRequest,
|
|
): Promise<PaymentIntentSnapshot> {
|
|
if (request.idempotencyKey) {
|
|
const byKey = await this.intentsRepository.findByIdempotencyKey(
|
|
request.service,
|
|
request.idempotencyKey,
|
|
);
|
|
if (byKey) return this.toSnapshot(byKey);
|
|
}
|
|
|
|
// CBE_BILL is inbound-only: there is no provider session to open and deliberately no entry
|
|
// in PAYMENT_PROVIDER_MAP (plan D5 — the sweep and refreshIfStale must no-op on it).
|
|
if (request.provider === ProviderMethod.CBE_BILL) {
|
|
return this.initiateCbeBill(request);
|
|
}
|
|
|
|
// Free method changes: no reuse/supersede. Every initiate opens a fresh intent, so a booking
|
|
// may accumulate many intents (each method attempt is its own row). The `idempotencyKey` check
|
|
// above still collapses exact duplicate submissions (e.g. a double-click). Confirm-once is
|
|
// enforced downstream — only the first success to reach a still-PENDING_PAYMENT booking
|
|
// confirms it; any other real payment is simply registered as its own SUCCEEDED intent.
|
|
const provider = this.providers.get(request.provider);
|
|
if (!provider) {
|
|
throw new BadRequestException(
|
|
`Unsupported payment provider: ${request.provider}`,
|
|
);
|
|
}
|
|
|
|
// Push-debit providers charge an account we must be told up front — there is no hosted page
|
|
// that could collect it later.
|
|
if (
|
|
(request.provider === ProviderMethod.CAC_BANK ||
|
|
request.provider === ProviderMethod.EBIRR) &&
|
|
!request.payerAccount?.trim()
|
|
) {
|
|
throw new BadRequestException(
|
|
`payerAccount (customer mobile number) is required for ${request.provider}`,
|
|
);
|
|
}
|
|
|
|
const merchantOrderId = createMerchantOrderId();
|
|
const providerInput = {
|
|
merchantOrderId,
|
|
orderRef: request.orderRef ?? request.referenceId,
|
|
amountMinor: request.amountMinor,
|
|
currency: request.currency,
|
|
platform: request.platform,
|
|
payerAccount: request.payerAccount,
|
|
returnUrl: request.returnUrl,
|
|
redirectUrl: request.returnUrl,
|
|
failureUrl: request.failureUrl,
|
|
};
|
|
const result = await provider.initiate(providerInput);
|
|
|
|
const intent = await this.intentsRepository.create({
|
|
service: request.service,
|
|
referenceType: request.referenceType,
|
|
referenceId: request.referenceId,
|
|
merchantOrderId,
|
|
provider: request.provider,
|
|
providerOrderId: result.providerOrderId,
|
|
amountMinor: request.amountMinor,
|
|
currency: request.currency,
|
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
|
clientAction: result.clientAction,
|
|
idempotencyKey: request.idempotencyKey ?? null,
|
|
expiresAt: result.expiresAt,
|
|
rawInitiation: result.rawInitiation,
|
|
});
|
|
this.logger.log(
|
|
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
|
|
);
|
|
|
|
if (request.provider === ProviderMethod.EBIRR) {
|
|
return this.settleEBirrPurchase(intent.id, providerInput);
|
|
}
|
|
|
|
return this.toSnapshot(intent);
|
|
}
|
|
|
|
/**
|
|
* Issue the eBirr debit and hand back the settled intent.
|
|
*
|
|
* eBirr has no webhook: API_PURCHASE's response IS the settlement notification. So it is awaited
|
|
* here, on the request path, and the caller gets a terminal snapshot — the portal shows success
|
|
* or the real failure straight from the initiate response, with nothing to poll.
|
|
*
|
|
* The wait is bounded by EBIRR_PURCHASE_TIMEOUT_MS (45s), which must stay under the passenger
|
|
* API's 60s PAYMENT_API_HTTP_TIMEOUT_MS. A payer slower than that comes back PROCESSING and the
|
|
* intent keeps its AWAIT_PUSH client action, so the existing poll and the reconciliation sweep
|
|
* settle it as before. That fallback is rare but must not be removed: an unanswered purchase may
|
|
* still have moved money (vendor doc §10).
|
|
*
|
|
* purchase() does not throw — transport failures are already mapped to FAILED (never dispatched)
|
|
* or PROCESSING (sent, unanswered). The catch is for anything unforeseen: leaving the intent
|
|
* REQUIRES_ACTION also lands on the poll/sweep fallback, which is the safe direction.
|
|
*/
|
|
private async settleEBirrPurchase(
|
|
intentId: string,
|
|
providerInput: Parameters<EBirrProvider["purchase"]>[0],
|
|
): Promise<PaymentIntentSnapshot> {
|
|
try {
|
|
const status = await this.eBirrProvider.purchase(providerInput);
|
|
await this.applyProviderResult(intentId, status);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`eBirr purchase for intent ${intentId} (${providerInput.merchantOrderId}) could not be ` +
|
|
`settled: ${err instanceof Error ? err.message : err} — leaving it to the sweep`,
|
|
);
|
|
}
|
|
return this.snapshotOf(intentId);
|
|
}
|
|
|
|
/**
|
|
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill
|
|
* reference is created here, before CBE ever sees the bill; settlement arrives later through
|
|
* the inbound /cbe/payment endpoint and the unchanged applyProviderResult() state machine.
|
|
*/
|
|
private async initiateCbeBill(
|
|
request: InitiatePaymentRequest,
|
|
): Promise<PaymentIntentSnapshot> {
|
|
// D8: CBE settles ETB only. The domain app must price/charge the order in ETB.
|
|
if (request.currency !== "ETB") {
|
|
throw new BadRequestException(
|
|
`CBE_BILL supports ETB only (got ${request.currency})`,
|
|
);
|
|
}
|
|
|
|
// The bill reference is issued ONCE per order: the domain app persists it (freight stores it
|
|
// as the booking's PNR) and the payer may already have written it down, so re-initiating the
|
|
// same open bill must hand back the same number. A different amount/currency means a
|
|
// different debt — /cbe/payment verifies the debited amount against the intent — so that
|
|
// case mints a fresh bill instead of silently repricing an outstanding one.
|
|
const open = (
|
|
await this.intentsRepository.findAllByReference(
|
|
request.service,
|
|
request.referenceType,
|
|
request.referenceId,
|
|
)
|
|
).find(
|
|
(i) =>
|
|
i.provider === ProviderMethod.CBE_BILL &&
|
|
i.status === ProviderPaymentStatus.REQUIRES_ACTION &&
|
|
!!i.billReference &&
|
|
i.amountMinor === request.amountMinor &&
|
|
i.currency === request.currency,
|
|
);
|
|
if (open) {
|
|
this.logger.log(
|
|
`intent ${open.id} reused for ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${open.billReference})`,
|
|
);
|
|
return this.toSnapshot(open);
|
|
}
|
|
|
|
const merchantOrderId = createMerchantOrderId();
|
|
const billReference = await this.billReferenceService.generate();
|
|
// expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider
|
|
// session TTL (plan §6.4: a short TTL would make the sweep cancel the booking within the hour).
|
|
const expiresAt = request.expiresAt ? new Date(request.expiresAt) : null;
|
|
|
|
const intent = await this.intentsRepository.create({
|
|
service: request.service,
|
|
referenceType: request.referenceType,
|
|
referenceId: request.referenceId,
|
|
merchantOrderId,
|
|
provider: request.provider,
|
|
amountMinor: request.amountMinor,
|
|
currency: request.currency,
|
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
|
clientAction: {
|
|
type: "SHOW_BILL_REFERENCE",
|
|
billReference,
|
|
instructions:
|
|
"Pay this bill at any CBE branch, CBE Birr app, mobile banking or USSD.",
|
|
expiresAt: expiresAt?.toISOString(),
|
|
},
|
|
idempotencyKey: request.idempotencyKey ?? null,
|
|
expiresAt,
|
|
billReference,
|
|
payerName: request.payerName ?? null,
|
|
});
|
|
this.logger.log(
|
|
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${billReference})`,
|
|
);
|
|
return this.toSnapshot(intent);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ confirm (OTP providers) */
|
|
|
|
async confirm(
|
|
intentId: string,
|
|
request: ConfirmPaymentRequest,
|
|
): Promise<PaymentIntentSnapshot> {
|
|
const intent = await this.intentsRepository.findById(intentId);
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
|
|
if (intent.provider !== ProviderMethod.CAC_BANK) {
|
|
throw new BadRequestException(
|
|
`Confirm is not supported for provider: ${intent.provider}`,
|
|
);
|
|
}
|
|
|
|
// REQUIRES_ACTION is the normal awaiting-OTP state; PROCESSING is tolerated so an intent
|
|
// that a poll/sweep nudged forward can still be confirmed. Terminal states are rejected.
|
|
if (
|
|
intent.status !== ProviderPaymentStatus.REQUIRES_ACTION &&
|
|
intent.status !== ProviderPaymentStatus.PROCESSING
|
|
) {
|
|
throw new BadRequestException(
|
|
`Intent is not awaiting confirmation (status=${intent.status})`,
|
|
);
|
|
}
|
|
|
|
if (!intent.providerOrderId) {
|
|
throw new BadRequestException("Intent has no provider order id");
|
|
}
|
|
|
|
const confirmResult = await this.cacBankProvider.confirmPayment(
|
|
intent.providerOrderId,
|
|
request.otp,
|
|
);
|
|
|
|
if (confirmResult.reference) {
|
|
await this.intentsRepository.update(intent.id, {
|
|
rawInitiation: {
|
|
...(intent.rawInitiation ?? {}),
|
|
reference: confirmResult.reference,
|
|
confirmResponse: confirmResult.rawResponse,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (confirmResult.status === "SUCCEEDED") {
|
|
await this.applyProviderResult(intent.id, {
|
|
status: ProviderPaymentStatus.SUCCEEDED,
|
|
providerTxnId: confirmResult.providerTxnId,
|
|
paidAt: new Date(),
|
|
});
|
|
return this.snapshotOf(intent.id);
|
|
}
|
|
|
|
// Confirm did not clearly succeed. CAC has no callback and the confirm response can be
|
|
// lost after the customer was charged, so before failing anything verify the source of
|
|
// truth by paymentRequestId (GetPaymentByReferenceRequest keys on it).
|
|
const verified = await this.cacBankProvider
|
|
.queryStatus(intent.providerOrderId)
|
|
.catch((err: unknown) => {
|
|
this.logger.warn(
|
|
`CAC verify after failed confirm errored for intent ${intent.id}: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
return null;
|
|
});
|
|
|
|
if (verified?.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
await this.applyProviderResult(intent.id, {
|
|
status: ProviderPaymentStatus.SUCCEEDED,
|
|
providerTxnId: verified.providerTxnId,
|
|
paidAt: new Date(),
|
|
});
|
|
return this.snapshotOf(intent.id);
|
|
}
|
|
|
|
// Genuinely not paid — almost always a wrong or expired OTP. Leave the intent in
|
|
// REQUIRES_ACTION so the payer can re-enter the code, and do NOT emit payment.failed:
|
|
// a mistyped OTP must not cancel the booking. The reconciliation sweep CANCELs the
|
|
// intent once its OTP window (expiresAt) passes.
|
|
throw new BadRequestException(
|
|
confirmResult.failureMessage ??
|
|
"OTP confirmation failed — please re-enter the code sent to your phone",
|
|
);
|
|
}
|
|
|
|
private async snapshotOf(intentId: string): Promise<PaymentIntentSnapshot> {
|
|
const updated = await this.intentsRepository.findById(intentId);
|
|
if (!updated) throw new NotFoundException("PaymentIntent not found");
|
|
return this.toSnapshot(updated);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ lookups */
|
|
|
|
async getIntent(id: string): Promise<PaymentIntentSnapshot> {
|
|
const intent = await this.intentsRepository.findById(id);
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
return this.toSnapshot(await this.refreshIfStale(intent));
|
|
}
|
|
|
|
async getIntentByReference(
|
|
service: PaymentService,
|
|
referenceType: PaymentReferenceType,
|
|
referenceId: string,
|
|
): Promise<PaymentIntentSnapshot> {
|
|
const intent = await this.intentsRepository.findLatestByReference(
|
|
service,
|
|
referenceType,
|
|
referenceId,
|
|
);
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
return this.toSnapshot(await this.refreshIfStale(intent));
|
|
}
|
|
|
|
/**
|
|
* Diagnostic lookup by domain reference (service + referenceType + referenceId). Returns the
|
|
* stored intent AND a LIVE provider status query side by side — the reference-keyed twin of
|
|
* {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without
|
|
* knowing the merchant order id. Pure read (no state-machine mutation).
|
|
*
|
|
* - `db`: the stored intent that represents the order's payment status (a SUCCEEDED one if the
|
|
* order is paid, else the current open session), or `null` when none exists.
|
|
* - `provider`: the raw provider status response (queried using the intent's own provider), or
|
|
* `null` when there is no intent or the query fails.
|
|
*/
|
|
async getDiagnosticByReference(
|
|
service: PaymentService,
|
|
referenceType: PaymentReferenceType,
|
|
referenceId: string,
|
|
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
|
|
const intent = await this.intentsRepository.findLatestByReference(
|
|
service,
|
|
referenceType,
|
|
referenceId,
|
|
);
|
|
const providerStatus = intent
|
|
? await this.queryProviderForMerchantOrder(
|
|
intent.merchantOrderId,
|
|
intent,
|
|
undefined,
|
|
)
|
|
: null;
|
|
return { db: intent ?? null, provider: providerStatus };
|
|
}
|
|
|
|
/**
|
|
* Diagnostic lookup by provider-facing merchant order id (PSG-/FRT-…). Returns the stored
|
|
* intent AND a LIVE provider status query side by side, so the caller can compare what the
|
|
* platform believes against what the provider currently reports. This is a pure read — it
|
|
* does NOT mutate the intent (no state-machine transition, no outbox event).
|
|
*
|
|
* - `db`: the full stored intent row, or `null` when no intent has this merchant order id.
|
|
* - `provider`: the raw provider status response. When there is a DB row its provider is
|
|
* used; when there is no DB row a `providerHint` must be supplied to know which provider
|
|
* to ask (the merchant-order prefix only identifies the service). `null` if the provider
|
|
* is unknown or the query fails.
|
|
*/
|
|
async getByMerchantOrderId(
|
|
merchantOrderId: string,
|
|
providerHint?: ProviderMethod,
|
|
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
|
|
const intent =
|
|
await this.intentsRepository.findByMerchantOrderId(merchantOrderId);
|
|
|
|
const providerStatus = await this.queryProviderForMerchantOrder(
|
|
merchantOrderId,
|
|
intent,
|
|
providerHint,
|
|
);
|
|
|
|
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,
|
|
intent: PaymentIntent | null,
|
|
providerHint?: ProviderMethod,
|
|
): Promise<ProviderStatus | null> {
|
|
try {
|
|
if (intent) {
|
|
const provider = this.providers.get(intent.provider);
|
|
if (!provider) return null;
|
|
return await this.queryProviderStatus(intent);
|
|
}
|
|
// No DB row — fall back to the caller-supplied provider hint keyed on merchantOrderId.
|
|
if (!providerHint) return null;
|
|
const provider = this.providers.get(providerHint);
|
|
if (!provider) return null;
|
|
return await provider.queryStatus(merchantOrderId);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
this.logger.warn(
|
|
`provider status query failed for merchantOrderId ${merchantOrderId}: ${message}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the
|
|
* provider for the truth and run the answer through the state machine. The browser
|
|
* redirect never confirms payment — this query (or a webhook) does.
|
|
*/
|
|
private async refreshIfStale(intent: PaymentIntent): Promise<PaymentIntent> {
|
|
const refreshable =
|
|
intent.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
|
intent.status === ProviderPaymentStatus.PROCESSING;
|
|
const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS;
|
|
const provider = this.providers.get(intent.provider);
|
|
if (!refreshable || !stale || !provider) return intent;
|
|
|
|
try {
|
|
const status = await this.queryProviderStatus(intent);
|
|
await this.applyProviderResult(
|
|
intent.id,
|
|
this.fromProviderStatus(status),
|
|
);
|
|
return (await this.intentsRepository.findById(intent.id)) ?? intent;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
this.logger.warn(
|
|
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
|
|
);
|
|
return intent;
|
|
}
|
|
}
|
|
|
|
private async queryProviderStatus(
|
|
intent: PaymentIntent,
|
|
): Promise<ProviderStatus> {
|
|
const provider = this.providers.get(intent.provider);
|
|
if (!provider) {
|
|
throw new Error(`Unknown provider: ${intent.provider}`);
|
|
}
|
|
|
|
if (intent.provider === ProviderMethod.CAC_BANK) {
|
|
if (!intent.providerOrderId) {
|
|
throw new Error(
|
|
`CAC intent ${intent.id} has no providerOrderId to verify`,
|
|
);
|
|
}
|
|
return this.cacBankProvider.queryStatus(intent.providerOrderId);
|
|
}
|
|
|
|
return provider.queryStatus(intent.merchantOrderId);
|
|
}
|
|
|
|
fromProviderStatus(status: ProviderStatus): ProviderResultInput {
|
|
return {
|
|
status: status.status,
|
|
providerTxnId: status.providerTxnId,
|
|
failureCode: status.failureCode,
|
|
failureMessage: status.failureMessage,
|
|
rawResponse: status.rawResponse,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ state machine */
|
|
|
|
/**
|
|
* Advance the intent state machine with a verified provider signal. Terminal states are
|
|
* absorbing; a terminal transition writes the notification_outbox row IN THE SAME
|
|
* TRANSACTION as the intent update (transactional outbox — architecture.md §8).
|
|
*/
|
|
async applyProviderResult(
|
|
intentId: string,
|
|
result: ProviderResultInput,
|
|
): Promise<{ alreadyTerminal: boolean }> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const intent = await manager
|
|
.getRepository(PaymentIntent)
|
|
.createQueryBuilder("intent")
|
|
.setLock("pessimistic_write")
|
|
.where("intent.id = :intentId", { intentId })
|
|
.getOne();
|
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
|
|
|
if (
|
|
(TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes(
|
|
intent.status,
|
|
)
|
|
) {
|
|
// A SUCCEEDED signal on an intent we already retired (EXPIRED/CANCELLED/FAILED) means the
|
|
// provider session was paid late — its page stayed payable after we retired it. Register
|
|
// the capture: fall through so it becomes SUCCEEDED and emits payment.succeeded. The owning
|
|
// app confirms the booking only if it is still payable; otherwise it just records the
|
|
// payment (many SUCCEEDED per order are allowed now that the unique index is gone). An
|
|
// already-SUCCEEDED intent, or any non-success signal on a terminal intent, stays absorbing.
|
|
const lateCapture =
|
|
result.status === ProviderPaymentStatus.SUCCEEDED &&
|
|
intent.status !== ProviderPaymentStatus.SUCCEEDED;
|
|
if (!lateCapture) return { alreadyTerminal: true };
|
|
}
|
|
|
|
// Keep the audit payload current with the latest provider status body (surfaced as
|
|
// `providerResponse` in the snapshot). Merged so the initiation keys are preserved.
|
|
if (result.rawResponse) {
|
|
intent.rawInitiation = {
|
|
...(intent.rawInitiation ?? {}),
|
|
statusResponse: result.rawResponse,
|
|
};
|
|
}
|
|
|
|
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
const paidAt = result.paidAt ?? new Date();
|
|
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
|
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
|
intent.paidAt = paidAt;
|
|
intent.confirmedAmountMinor =
|
|
result.confirmedAmountMinor ?? intent.confirmedAmountMinor;
|
|
intent.failureCode = null;
|
|
intent.failureMessage = null;
|
|
await manager.save(intent);
|
|
await manager.getRepository(NotificationOutbox).save(
|
|
buildOutboxRow(intent, {
|
|
eventType: "payment.succeeded",
|
|
providerTxnId: intent.providerTxnId ?? undefined,
|
|
paidAt,
|
|
}),
|
|
);
|
|
if (
|
|
result.confirmedAmountMinor != null &&
|
|
result.confirmedAmountMinor !== intent.amountMinor
|
|
) {
|
|
this.logger.error(
|
|
`intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`,
|
|
);
|
|
}
|
|
this.logger.log(
|
|
`intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`,
|
|
);
|
|
return { alreadyTerminal: false };
|
|
}
|
|
|
|
if (
|
|
result.status === ProviderPaymentStatus.FAILED ||
|
|
result.status === ProviderPaymentStatus.CANCELLED
|
|
) {
|
|
intent.status = result.status;
|
|
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
|
intent.failureCode = result.failureCode ?? null;
|
|
intent.failureMessage = result.failureMessage ?? null;
|
|
await manager.save(intent);
|
|
await manager.getRepository(NotificationOutbox).save(
|
|
buildOutboxRow(intent, {
|
|
eventType: "payment.failed",
|
|
failureCode: result.failureCode,
|
|
failureMessage: result.failureMessage,
|
|
}),
|
|
);
|
|
this.logger.log(
|
|
`intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`,
|
|
);
|
|
return { alreadyTerminal: false };
|
|
}
|
|
|
|
// Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse.
|
|
if (
|
|
result.status === ProviderPaymentStatus.PROCESSING &&
|
|
intent.status === ProviderPaymentStatus.REQUIRES_ACTION
|
|
) {
|
|
intent.status = ProviderPaymentStatus.PROCESSING;
|
|
}
|
|
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
|
await manager.save(intent);
|
|
return { alreadyTerminal: false };
|
|
});
|
|
}
|
|
|
|
/** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */
|
|
async expireIntent(intentId: string): Promise<void> {
|
|
await this.applyProviderResult(intentId, {
|
|
status: ProviderPaymentStatus.CANCELLED,
|
|
failureCode: "EXPIRED",
|
|
failureMessage: "Payment session expired before completion",
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ mapping */
|
|
|
|
toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot {
|
|
return {
|
|
intentId: intent.id,
|
|
service: intent.service,
|
|
referenceType: intent.referenceType,
|
|
referenceId: intent.referenceId,
|
|
merchantOrderId: intent.merchantOrderId,
|
|
provider: intent.provider,
|
|
status: intent.status,
|
|
amountMinor: intent.amountMinor,
|
|
currency: intent.currency,
|
|
clientAction: intent.clientAction ?? undefined,
|
|
providerTxnId: intent.providerTxnId ?? undefined,
|
|
paidAt: intent.paidAt?.toISOString(),
|
|
failureCode: intent.failureCode ?? undefined,
|
|
failureMessage: intent.failureMessage ?? undefined,
|
|
expiresAt: intent.expiresAt?.toISOString(),
|
|
billReference: intent.billReference ?? undefined,
|
|
providerResponse: intent.rawInitiation ?? undefined,
|
|
};
|
|
}
|
|
}
|