Files
edr-platform/apps/edr-freight-api/src/modules/payment/payment.service.ts
2026-08-02 18:28:40 +00:00

660 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
payerName?: string;
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
expiresAt?: 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",
CBE_BILL: "cbe-bill",
};
/**
* 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 isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
throw new BadRequestException(
"CBE bill payment is only available for ETB invoices",
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
// debited against the intent amount, so the dev shortcut would break it.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// shortcut floor is 10, not 1.
amountMinor: isCbeBill
? input.amountMinor
: input.method === ProviderMethod.CAC_BANK
? 10
: 1,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
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") {
// Still notify billing: a prior delivery may have flipped the intent to
// success and then died before the invoice settled (the two steps are not
// atomic). settleByPaymentId is idempotent — no open invoice, no-op.
if (opts.notify !== false) {
await this.billing.settleByPaymentId(
intent.id,
opts.providerTxnId ?? intent.transactionId ?? undefined,
opts.paidAt ?? intent.paidAt ?? undefined,
);
}
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);
}
}