Files
edr-platform/apps/edr-payment-api/src/modules/intents/intents.service.ts
2026-07-16 12:05:47 +03:00

501 lines
18 KiB
TypeScript

import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { DataSource, QueryFailedError } from "typeorm";
import { createMerchantOrderId, CacBankProvider } 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";
const PG_UNIQUE_VIOLATION = "23505";
/** 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>;
}
@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,
) {}
/* ------------------------------------------------------------------ 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);
}
const existing = await this.intentsRepository.findActiveByReference(
request.service,
request.referenceType,
request.referenceId,
);
if (existing) {
// Payer switched method (e.g. Waafi → Telebirr) on an uncharged session: retire the
// open intent and fall through to open a fresh one for the new provider. Only safe
// while REQUIRES_ACTION — PROCESSING/SUCCEEDED intents may have money in flight, so
// they keep the reuse path (the switch is silently refused until they resolve).
const switchingProvider =
existing.provider !== request.provider &&
existing.status === ProviderPaymentStatus.REQUIRES_ACTION;
if (switchingProvider) {
await this.intentsRepository.update(existing.id, {
status: ProviderPaymentStatus.CANCELLED,
failureCode: "METHOD_CHANGED",
failureMessage: `Payer switched from ${existing.provider} to ${request.provider}`,
});
this.logger.log(
`intent ${existing.id} retired (METHOD_CHANGED ${existing.provider}${request.provider}) for ` +
`${request.service}/${request.referenceType}/${request.referenceId}`,
);
} else {
const reusable = await this.reuseOrRetire(existing);
if (reusable) return this.toSnapshot(reusable);
}
}
const provider = this.providers.get(request.provider);
if (!provider) {
throw new BadRequestException(
`Unsupported payment provider: ${request.provider}`,
);
}
if (
request.provider === ProviderMethod.CAC_BANK &&
!request.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (customer mobile number) is required for CAC_BANK",
);
}
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
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,
});
try {
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})`,
);
return this.toSnapshot(intent);
} catch (err) {
if (
err instanceof QueryFailedError &&
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
) {
const winner = await this.intentsRepository.findActiveByReference(
request.service,
request.referenceType,
request.referenceId,
);
if (winner) return this.toSnapshot(winner);
}
throw err;
}
}
/* ------------------------------------------------------------------ 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);
}
/**
* Decide whether an existing active intent can be returned as-is. An expired
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
* so a fresh provider session can be opened.
*/
private async reuseOrRetire(
intent: PaymentIntent,
): Promise<PaymentIntent | null> {
const expired =
intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
intent.expiresAt != null &&
intent.expiresAt.getTime() < Date.now();
if (!expired) return intent;
await this.intentsRepository.update(intent.id, {
status: ProviderPaymentStatus.CANCELLED,
failureCode: "EXPIRED",
failureMessage: "Provider session expired before the payer acted",
});
return null;
}
/* ------------------------------------------------------------------ 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.findActiveByReference(
service,
referenceType,
referenceId,
);
if (!intent) throw new NotFoundException("PaymentIntent not found");
return this.toSnapshot(await this.refreshIfStale(intent));
}
/**
* 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,
)
) {
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(),
providerResponse: intent.rawInitiation ?? undefined,
};
}
}