From 7fa18b8ee726ea41420afad945c73e48ba15a87a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:54:43 +0000 Subject: [PATCH] fix: reference type in payment service --- .../src/modules/payment/payment.service.ts | 988 ++++++++++-------- 1 file changed, 527 insertions(+), 461 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..738a6d118 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,11 +1,11 @@ import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, } from "@nestjs/common"; import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; @@ -18,73 +18,70 @@ import * as path from "path"; import * as Handlebars from "handlebars"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { - ClientAction, - ProviderPaymentStatus, -} from "@edr/payment-providers"; -import { - PaymentService as PaymentServiceEnum, - PaymentReferenceType, - PaymentIntentSnapshot, - ProviderMethod, + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, } from "@edr/types"; import { - InitiateResponseDto, - IntentStatusDto, - PaymentPlatformDto, - RefundDto, + InitiateResponseDto, + IntentStatusDto, + PaymentPlatformDto, + RefundDto, } 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; + /** 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; + 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, + "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", + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; /** @@ -96,426 +93,495 @@ const PROVIDER_TO_METHOD: Record = { */ @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); + private readonly logger = new Logger(PaymentService.name); - constructor( - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BillingService)) - private readonly billing: BillingService, - ) { } + constructor( + private readonly datasource: DataSource, + 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; + 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"); + 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 }); - } + 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(); + 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. + */ + async initiate(input: InitiateIntentInput): Promise { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + 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, + }; + } + + /** 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); + } + + /** + * 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 refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ + refId: dto.bookingId, + type: "booking", + }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + // NOTE: refunding still mutates the booking directly — left intact pending + // the refund redesign. TODO: route refunds through billing.refundPayable + + // a `${source}.invoice.refunded` reaction, like settlement. + await this.datasource.transaction(async (mg) => { + await mg.update( + PaymentEntity, + { id: intent.id }, + { status: "refunded", refundedAt: new Date() }, + ); + await mg.update( + Booking, + { id: dto.bookingId }, + { paymentStatus: "FAILED", status: "CANCELLED" }, + ); + }); + + return { refunded: true, bookingId: dto.bookingId }; + } + + 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; + }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { 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, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; - } + } + console.log(`Processing payment succeeded event for intent: }`, intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, + }); + console.log( + `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, + ); - /** 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. - */ - async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: input.referenceType, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - 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, - }; - } - - /** 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); - } - - /** - * 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 }, + // When the intent references a booking, flip the booking itself paid. + // refId holds the booking id (the domain reference the intent opened with). + if (intent.referenceType === PaymentReferenceType.BOOKING) { + await this.datasource.manager.update( + Booking, + { id: intent.refId }, + { status: "PAID", paymentStatus: "PAID" }, ); - - if (opts.notify !== false) { - await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); - } - - return { alreadyFinalized: false }; + } + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + return { processed: true, alreadyFinalized }; } - 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 refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - 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; + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - intentId: intent.id, - status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, - clientAction, - merchantOrderId: intent.merchantOrderId ?? undefined, + 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 }; } - private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { - return { - ...this.formatIntentResponse(intent), - paidAt: intent.paidAt?.toISOString(), - failureCode: intent.failerCode ?? undefined, - failureMessage: intent.failureMessage ?? undefined, - }; + 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 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 }> { - console.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}` }; - } - console.log(`Processing payment succeeded event for intent: }`,intent); - const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { - providerTxnId: event.providerTxnId, - paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - notify: true, - }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - 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); - } + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } }