mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
501 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|