Files
edr-platform/apps/edr-payment-api/src/modules/intents/intents.service.ts

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,
};
}
}