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 = { "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 = { 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 = {}; 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 { 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 { 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 | 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, 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 { 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 { 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 { 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 { 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); } }