Files
edr-platform/apps/edr-freight-api/src/modules/payment/payment.service.ts
Nathnael 97bfe95ec3 feat(payment): integrate CAC Bank OTP payments into freight flows
CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a
code to the payer's mobile, and the charge only settles when that code is
confirmed. The payment service already spoke it (passenger uses it); the
freight side had the enum values but none of the flow.

API:
- PaymentClientService.confirmOtp forwards the code to
  POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a
  mistyped code stays retryable instead of surfacing as a gateway failure.
- PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's
  paymentId) rather than the domain reference, so the right invoice settles
  when several share a booking. On success billing settles the invoice.
- payInvoice rejects CAC_BANK without payerAccount before calling the
  gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent
  (it is not paid until the payer confirms).
- POST /billing/my-invoices/:id/confirm — ownership-checked, and since
  warehouse fee invoices are central invoices it covers those too.

Portal:
- useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm)
  and replaces the five near-identical pay mutations at the call sites.
- PaymentMethodModal gains the CAC Bank option, the payer mobile field, and
  the OTP step. Click-outside is disabled there so a stray click cannot drop
  the payer out of a live OTP window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:11:23 +00:00

629 lines
20 KiB
TypeScript

import {
BadRequestException,
forwardRef,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import * as fs from "fs";
import * as path from "path";
import * as Handlebars from "handlebars";
import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers";
import {
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
PaymentIntentSnapshot,
ProviderMethod,
} from "@edr/types";
import {
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
} from "./payments.dto";
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
export interface InitiateIntentInput {
/** Opaque domain reference (booking id, …). */
referenceId: string;
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
source: string;
/** Gateway reference type the intent is opened with (caller's domain decides it). */
referenceType: PaymentReferenceType;
/** Human-readable order ref shown on provider pages. */
orderRef: string;
/** Authoritative amount in minor units, computed by the caller. */
amountMinor: number;
currency: string;
/** Stored on the intent projection for receipts/dashboards. */
reason?: string;
/** Provider/method selector. */
method: ProviderMethod | string;
platform?: PaymentPlatformDto;
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface InitiateIntentResult {
intentId: string;
response: InitiateResponseDto;
/** True when the provider settled the charge synchronously during initiate. */
immediateSuccess: boolean;
providerTxnId?: string;
paidAt?: Date;
}
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
processing: ProviderPaymentStatus.PROCESSING,
success: ProviderPaymentStatus.SUCCEEDED,
failed: ProviderPaymentStatus.FAILED,
canceled: ProviderPaymentStatus.CANCELLED,
refunded: ProviderPaymentStatus.CANCELLED,
};
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
/**
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
* NOTHING domain-specific: it never loads a booking, computes an amount, or
* advances a domain record. On settlement it notifies billing directly
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
*/
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
constructor(
private readonly paymentRepo: PaymentRepository,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BillingService))
private readonly billing: BillingService,
) { }
async getAll(filters: {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const qb = this.paymentRepo.createQueryBuilder("payment");
if (search) {
qb.andWhere(
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
{ search: `%${search}%` },
);
}
if (status) {
qb.andWhere("payment.status = :status", { status });
}
if (method) {
qb.andWhere("payment.method = :method", { method });
}
const [items, total] = await qb
.orderBy("payment.createdAt", "DESC")
.skip(skip)
.take(pageSize)
.getManyAndCount();
return {
items: items.map((p) => ({
id: p.id,
bookingId: p.refId,
amount: p.amount,
currency: p.currency,
method: p.method,
status: p.status,
merchantOrderId: p.merchantOrderId,
paidAt: p.paidAt,
createdAt: p.createdAt,
})),
total,
page,
pageSize,
};
}
/** Aggregate counts across ALL payments for the dashboard summary cards. */
async getSummary() {
const rows = await this.paymentRepo
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.getRawMany<{ status: string; count: number }>();
const byStatus: Record<string, number> = {};
let total = 0;
for (const row of rows) {
byStatus[row.status] = row.count;
total += row.count;
}
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
.where("payment.status = :status", { status: "success" })
.getRawOne<{ sum: string }>();
return {
total,
success: byStatus["success"] ?? 0,
processing:
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
refunded: byStatus["refunded"] ?? 0,
paidAmount: Number(paidAgg?.sum ?? 0),
};
}
/**
* Open a gateway intent for a caller-supplied amount/reference and project it
* locally. Returns the intent id (so billing can correlate the invoice) plus
* the client action. When the provider settles synchronously, the intent is
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
/**
* Reconcile-before-cancel: ask the payment service whether ANY intent for
* this shipment actually settled at the provider (bank/gateway). A late
* capture found there is registered as SUCCEEDED and emits payment.succeeded,
* which drives the normal paid flow. A network/provider error reports
* `unverifiable` — the caller must not expire the order on unknown.
*/
async reconcileShipment(
referenceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
try {
const result = await this.paymentClient.reconcileReference(
PaymentReferenceType.SHIPMENT,
referenceId,
);
return { paid: result.paid, unverifiable: result.unverifiable };
} catch (err) {
this.logger.warn(
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
);
return { paid: false, unverifiable: true };
}
}
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try {
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// amountMinor: input.amountMinor,
amountMinor:1,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl:
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
const immediateSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
const intent = await this.upsertIntent(input, snapshot);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
// avoiding a settle-before-correlation race.
await this.markIntentSucceeded(intent.id, {
providerTxnId: snapshot.providerTxnId,
paidAt,
notify: false,
});
}
return {
intentId: intent.id,
// `intent` still reflects the projection status ("processing" on immediate
// success — settlement is applied by the caller, not shown synchronously).
response: this.formatIntentResponse(intent),
immediateSuccess,
providerTxnId: snapshot.providerTxnId,
paidAt,
};
} catch (err) {
console.log(err);
throw err;
}
}
/** Create or update the local intent projection from a provider snapshot. */
private async upsertIntent(
input: InitiateIntentInput,
snapshot: PaymentIntentSnapshot,
): Promise<PaymentEntity> {
const existing = await this.paymentRepo.findOneBy({
refId: input.referenceId,
});
const method: PaymentEntity["method"] =
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const clientAction = (snapshot.clientAction ?? undefined) as
| Record<string, unknown>
| undefined;
const data = {
status,
method,
merchantOrderId:
snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
expiresAt: snapshot.expiresAt
? new Date(snapshot.expiresAt)
: existing?.expiresAt,
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
};
if (existing) {
await this.paymentRepo.update({ id: existing.id }, {
...data,
clientAction,
} as any);
return { ...existing, ...data, clientAction } as PaymentEntity;
}
return this.paymentRepo.create({
refId: input.referenceId,
type: input.source,
referenceType: input.referenceType,
amount: input.amountMinor,
currency: input.currency as PaymentEntity["currency"],
reason: input.reason ?? `Payment for ${input.orderRef}`,
rawInitiation: snapshot as unknown as Record<string, unknown>,
clientAction: clientAction ?? {},
...data,
} as any);
}
/**
* Reconcile an intent's status with the gateway by reference. Read-only on the
* domain side: it syncs the local projection and, when the provider reports a
* newly-observed success, notifies billing to settle. `referenceId` is opaque
* (the booking id, but this service does not load it).
*/
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
(local?.referenceType as PaymentReferenceType) ??
PaymentReferenceType.SHIPMENT,
referenceId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
);
}
if (!snapshot) {
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
if (!local) throw new NotFoundException("PaymentIntent not found");
// Sync local projection with provider-reported status.
const becameSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED &&
local.status !== "success";
if (becameSuccess) {
await this.markIntentSucceeded(local.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
notify: true,
});
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(snapshot.status),
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
* id (the invoice's `paymentId`) so the right invoice settles even when several
* invoices share a domain reference. The active gateway intent is looked up by
* reference, the OTP is forwarded, and the projection is refreshed. On success
* billing settles the linked invoice (idempotent — the outbox path converges too).
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
*/
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ id: intentId });
if (!local) throw new NotFoundException("PaymentIntent not found");
const snapshot = await this.paymentClient.getIntentByReference(
(local.referenceType as PaymentReferenceType) ??
PaymentReferenceType.SHIPMENT,
local.refId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm");
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
notify: true,
});
} else {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(confirmed.status),
failerCode: confirmed.failureCode ?? undefined,
failureMessage: confirmed.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
* when the caller settles inline and will trigger settlement itself.
*/
async markIntentSucceeded(
intentId: string,
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = opts.paidAt ?? new Date();
await this.paymentRepo.update(
{ id: intent.id },
{
status: "success",
paidAt,
transactionId: opts.providerTxnId ?? intent.transactionId,
},
);
if (opts.notify !== false) {
await this.billing.settleByPaymentId(
intent.id,
opts.providerTxnId,
paidAt,
);
}
return { alreadyFinalized: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
failureMessage?: string;
}): Promise<void> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success" || intent.status === "canceled") return;
await this.paymentRepo.update(
{ id: intent.id },
{
status: "failed",
failerCode: input.failureCode,
failureMessage: input.failureMessage,
},
);
// Invoice stays open for retry — nothing to settle. Logged only.
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),
);
}
async getActivePaymentByOrderIdAndMethod(
orderId: string,
method: PaymentEntity["method"],
): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method);
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
status: "success",
});
if (!payment)
throw new BadRequestException(
"No successful payment found for this order",
);
const filePath = path.join(__dirname, "templates", "receipt.hbs");
if (!fs.existsSync(filePath)) throw new InternalServerErrorException();
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
return template({
vendorName: "Ethio Djibouti Railway Freight Booking",
vendorAddress: "Addis Ababa",
receiptDate: payment.paidAt,
paymentMethod: payment.method,
subtotal: payment.amount.toString(),
total: payment.amount.toString(),
currency: payment.currency,
reason: payment.reason,
});
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id });
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
return {
intentId: intent.id,
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
return {
...this.formatIntentResponse(intent),
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failerCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
};
}
async handlePaymentEvent(event: {
eventType: string;
eventId: string;
referenceId: string;
intentId: string;
providerTxnId?: string;
paidAt?: string;
failureCode?: string;
failureMessage?: string;
}): Promise<{
processed: boolean;
alreadyFinalized?: boolean;
reason?: string;
}> {
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({
refId: event.referenceId,
});
if (!intent) {
return {
processed: false,
reason: `No local intent for reference ${event.referenceId}`,
};
}
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
this.logger.log(
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
);
// The payment service stays domain-agnostic: it settles the intent and
// lets billing settle the invoice (markIntentSucceeded → settleByPaymentId),
// which emits `${source}.invoice.paid`. Per-source advances (booking → PAID,
// warehouse → release, …) live in the domain services that listen for it.
return { processed: true, alreadyFinalized };
}
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({
refId: event.referenceId,
});
if (!intent) {
return {
processed: false,
reason: `No local intent for reference ${event.referenceId}`,
};
}
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
return { processed: true };
}
return {
processed: false,
reason: `Unknown event type: ${event.eventType}`,
};
}
private toLocalStatus(
status: ProviderPaymentStatus,
): PaymentEntity["status"] {
switch (status) {
case ProviderPaymentStatus.SUCCEEDED:
return "success";
case ProviderPaymentStatus.FAILED:
return "failed";
case ProviderPaymentStatus.CANCELLED:
return "canceled";
case ProviderPaymentStatus.PROCESSING:
return "processing";
default:
return "action-required";
}
}
async findByCompanyId(companyId: string) {
return this.paymentRepo.findByCompanyId(companyId);
}
}