import { BadRequestException, forwardRef, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException, } from "@nestjs/common"; import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; import * as fs from "fs"; 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 { PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, ProviderMethod, } from "@edr/types"; import { InitiatePaymentDto, InitiateResponseDto, IntentStatusDto, RefundDto, } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { FirstMileService } from "../first-mile/first-mile.service"; const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, "processing": ProviderPaymentStatus.PROCESSING, "success": ProviderPaymentStatus.SUCCEEDED, "failed": ProviderPaymentStatus.FAILED, "canceled": ProviderPaymentStatus.CANCELLED, "refunded": ProviderPaymentStatus.CANCELLED, }; @Injectable() export class PaymentService { private readonly logger = new Logger(PaymentService.name); constructor( private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, private readonly firstMileService: FirstMileService, ) { } 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; } // Sum of successfully collected amounts. 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), }; } async initiatePayment(dto: InitiatePaymentDto): Promise { const booking = await this.datasource .getRepository(Booking) .findOneBy({ id: dto.bookingId }); if (!booking) throw new NotFoundException("Booking not found"); const amountMinor = Math.round(Number(booking.totalAmount)); const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, referenceId: booking.id, orderRef: booking.reference, amountMinor, currency: booking.paymentCurrency, provider: dto.method as unknown as ProviderMethod, platform: dto.platform, payerAccount: dto.payerAccount, returnUrl:'https://edrfreight.triaplc.com/payment/success', failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { await this.finalizePaymentSuccess({ intentId: intent.id, bookingId: booking.id, providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); } return this.formatIntentResponse(intent); } private async syncIntentProjection( bookingId: string, booking: Booking, snapshot: PaymentIntentSnapshot, ): Promise { const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); const PROVIDER_TO_METHOD: Record = { TELEBIRR: "telebirr", CBE_BIRR: "cbe-birr", EBIRR: "ebirr", WAAFI: "waafi", CARD: "card", DMONEY: "dmoney", CAC_BANK: "cac-bank", }; 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: bookingId, type: "booking", amount: booking.totalAmount, currency: booking.paymentCurrency, reason: `Payment for booking ${booking.reference}`, rawInitiation: snapshot as unknown as Record, clientAction: clientAction ?? {}, ...data, } as any); } async getIntentByBookingId(bookingId: string): Promise { const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); let snapshot: PaymentIntentSnapshot | null = null; try { snapshot = await this.paymentClient.getIntentByReference( PaymentReferenceType.SHIPMENT, bookingId, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, ); } if (!snapshot) { if (!local) throw new NotFoundException("PaymentIntent not found"); return this.formatIntentStatus(local); } const booking = await this.datasource .getRepository(Booking) .findOneBy({ id: bookingId }); if (!booking) throw new NotFoundException("Booking not found"); const intent = await this.syncIntentProjection(bookingId, booking, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { await this.finalizePaymentSuccess({ intentId: intent.id, bookingId: booking.id, providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); } const refreshed = await this.paymentRepo.findOneBy({ id: intent.id }); return this.formatIntentStatus(refreshed ?? intent); } 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"); } 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 finalizePaymentSuccess(input: { intentId: string; bookingId: string; providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === "success") return { alreadyFinalized: true }; const paidAt = input.paidAt ?? new Date(); // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { await mg.update( PaymentEntity, { id: intent.id }, { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, ); await mg.update( Booking, { id: input.bookingId }, { paymentStatus: "PAID", status: "PAID" }, ); await this.firstMileService.acceptBooking(input.bookingId); }); try { await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); } catch (err) { this.logger.error( `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, ); } 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 }, ); } 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, type: "booking" }); } 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 }> { const { alreadyFinalized } = await this.finalizePaymentSuccess({ intentId:event.intentId, bookingId: event.referenceId, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, }); // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; // console.log(`Received payment event: ${JSON.stringify(event)}`); // if (event.eventType === "payment.succeeded") { // console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`); // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); // if (!intent) { // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; // } // console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`); // const { alreadyFinalized } = await this.finalizePaymentSuccess({ // intentId: intent.id, // bookingId: event.referenceId, // providerTxnId: event.providerTxnId, // paidAt: event.paidAt ? new Date(event.paidAt) : undefined, // }); // 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, type: "booking" }); // if (!intent) { // return { processed: false, reason: `No local intent for booking ${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); } }