import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException, } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { SeatsService } from "../seats/seats.service"; import { TicketsService } from "../tickets/tickets.service"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion, } from "@prisma/client"; import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum, ForceConfirmDto, } from "./payments.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; import { PaymentClientService, PaymentDiagnostic, } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util"; import { PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, ProviderMethod, ClientAction, ProviderPaymentStatus, } from "@edr/types"; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.REQUIRES_ACTION, PaymentIntentStatus.PROCESSING, PaymentIntentStatus.SUCCEEDED, ]; // Methods whose return/failure URLs are browser-facing pages on the passenger // portal, so they should follow whichever domain the user came in on. DMONEY is // deliberately excluded — its return URL is a server-to-server webhook host, not // a page the browser lands on. const DOMAIN_AWARE_METHODS = new Set([ PaymentMethodType.TELEBIRR, PaymentMethodType.WAAFI, ]); @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); private readonly waafiDemoTrustReturn = true; constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, private paymentClient: PaymentClientService, private currencyService: CurrencyService, private auditService: AuditService, ) {} async deletePayment(id: string) { const intent = await this.prisma.paymentIntent.findUnique({ where: { id } }); if (!intent) throw new NotFoundException('Payment intent not found'); await this.prisma.paymentIntent.delete({ where: { id } }); return { deleted: true, id }; } 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 where: any = {}; if (search) { where.OR = [ { id: { contains: search, mode: "insensitive" } }, { booking: { bookingRef: { contains: search, mode: "insensitive" } } }, ]; } if (status) { where.status = status; } if (method) { where.method = method; } const [items, total] = await Promise.all([ this.prisma.paymentIntent.findMany({ where, include: { booking: { select: { bookingRef: true, bookingType: true, packageId: true, priceTierId: true, adultCount: true, childCount: true, totalMinor: true, currency: true, priceTier: { select: { priceMinor: true } }, }, }, }, skip, take: pageSize, orderBy: { createdAt: "desc" }, }), this.prisma.paymentIntent.count({ where }), ]); return { items: items.map((item) => { const b = item.booking as any; // For package round-trip bookings the stored amountMinor may be the single-leg // amount. Recompute from the tier price when applicable. let amountMinor = item.amountMinor; if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) { const adultFare = b.priceTier.priceMinor * 2; const childFare = Math.round(adultFare * 0.1); const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare; // Convert to the charge currency ratio: stored amountMinor is in charge currency // (may be DJF/USD), but correctMinor is in ETB minor. Only override when the // currency is ETB (most common case); for foreign currencies keep stored value. if (item.currency === 'ETB') amountMinor = correctMinor; } return { id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency }, amountMinor, currency: item.currency, method: item.method, status: item.status, createdAt: item.createdAt, paidAt: item.paidAt, }; }), total, page, pageSize, }; } /** * Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip * bookings where totalMinor may have been stored as a single-leg amount before the server fix. */ private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null; displayTotalMinor?: number | null; }): Promise { if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') { return booking.totalMinor; } // New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their // totalMinor was already computed in ETB at creation time — no recomputation needed. if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) { return booking.totalMinor; } // Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier. const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } }); if (!tier) return booking.totalMinor; const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } }); const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1; const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length; // tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is // always in the same units as totalMinor (which is always the ETB canonical). const rawFare = tier.priceMinor * 2; const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB' ? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any) : rawFare; const childFareMinor = Math.round(adultFareMinor * 0.1); return adultCount * adultFareMinor + childCount * childFareMinor; } async initiatePayment( dto: InitiatePaymentDto, requestOrigin?: string | null, ): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true }, }); if (!booking) throw new NotFoundException("Booking not found"); if (booking.status !== "PENDING_PAYMENT") { throw new BadRequestException("Booking not payable"); } const method = dto.method as PaymentMethodType; // CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required. if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) { throw new BadRequestException( "payerAccount (mobile number) is required for CAC Bank", ); } const correctTotalMinor = await this.resolveBookingTotal(booking as any); // Patch the DB if the stored total is wrong (single-leg for a round-trip package booking) if (correctTotalMinor !== booking.totalMinor) { await this.prisma.booking.update({ where: { id: booking.id }, data: { totalMinor: correctTotalMinor }, }); (booking as any).totalMinor = correctTotalMinor; } // WALLET is an internal balance debit — it never leaves this app. if (method === PaymentMethodType.WALLET) { const existing = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId }, }); if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { return this.formatIntentResponse(existing); } return this.initiateWalletPayment(booking); } const { returnUrl, failureUrl } = this.resolveReturnUrls( method, requestOrigin, ); // The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY // settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency // already matches the charge currency, use displayTotalMinor directly — the rate is already // baked in at booking creation time. Only fall back to ETB→target conversion when they differ. const paymentMethod = await this.prisma.paymentMethod.findUnique({ where: { type: method }, }); const chargeCurrency = ( paymentMethod?.currency ?? booking.currency ).toUpperCase(); const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase(); const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null; let chargeAmount: number; if ( chargeCurrency === bookingDisplayCurrency && chargeCurrency !== 'ETB' && bookingDisplayTotalMinor != null ) { // Display currency matches charge currency — use the pre-converted amount directly. chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency); } else if (chargeCurrency === 'ETB') { chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB'); } else { // Booking is in ETB — convert to the provider's settlement currency. chargeAmount = await this.currencyService.convertMinorToChargeMajor( booking.totalMinor, booking.currency, chargeCurrency, ); } const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, referenceId: booking.id, orderRef: booking.bookingRef, amountMinor: chargeAmount, currency: chargeCurrency, provider: method as unknown as ProviderMethod, platform: dto.platform, payerAccount: dto.payerAccount, returnUrl, failureUrl, }); let intent = await this.syncIntentProjection(booking.id, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { // Already-paid order re-initiated: converge the booking now (idempotent). await this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); intent = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id }, }); } return this.formatIntentResponse(intent); } /** * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active * remote intent is looked up by reference, the OTP is forwarded to the payment service, * and the projection is refreshed. On success the booking is converged immediately * (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP * bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION. */ async confirmOtpPayment( bookingId: string, otp: string, ): Promise { const snapshot = await this.paymentClient.getIntentByReference( PaymentReferenceType.BOOKING, bookingId, ); if (!snapshot) { throw new NotFoundException("No active payment to confirm for this booking"); } const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp); let intent = await this.syncIntentProjection(bookingId, confirmed); if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { await this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: confirmed.providerTxnId, paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined, }); intent = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id }, }); } return this.formatIntentStatus(intent); } private resolveReturnUrls( method: PaymentMethodType, requestOrigin?: string | null, ): { returnUrl?: string; failureUrl?: string; } { const perMethod: Partial< Record > = { [PaymentMethodType.TELEBIRR]: { returnUrl: process.env.TELEBIRR_RETURN_URL, }, [PaymentMethodType.WAAFI]: { returnUrl: process.env.WAAFI_SUCCESS_REDIRECT, failureUrl: process.env.WAAFI_FAIL_REDIRECT, }, [PaymentMethodType.DMONEY]: { returnUrl: process.env.DMONEY_RETURN_URL, }, [PaymentMethodType.CBE_BIRR]: { returnUrl: process.env.CBE_RETURN_URL, }, [PaymentMethodType.EBIRR]: { returnUrl: process.env.EBIRR_RETURN_URL, }, [PaymentMethodType.CARD]: { returnUrl: process.env.CARD_RETURN_URL, }, }; const m = perMethod[method] ?? {}; let returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined; let failureUrl = m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl; // For browser-facing methods, swap the configured URL's host for whichever // allowlisted domain the user is currently on (bookingedr.et vs // passenger.edrsc.com). `requestOrigin` is already validated against the // allowlist by the controller; when it's null the configured URL is kept. if (DOMAIN_AWARE_METHODS.has(method) && requestOrigin) { returnUrl = rebaseUrlOrigin(returnUrl, requestOrigin); failureUrl = rebaseUrlOrigin(failureUrl, requestOrigin); } return { returnUrl, failureUrl }; } async confirmWaafiReturnDemo(params: { referenceId?: string; state?: string; transactionId?: string; }): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> { if (!this.waafiDemoTrustReturn) { return { confirmed: false, reason: "demo-disabled" }; } if ((params.state ?? "").toUpperCase() !== "APPROVED") { return { confirmed: false, reason: `not-approved (${params.state})` }; } if (!params.referenceId) { return { confirmed: false, reason: "missing-referenceId" }; } const intent = await this.prisma.paymentIntent.findFirst({ where: { merchantOrderId: params.referenceId }, }); if (!intent) { this.logger.warn( `waafi demo return: no local intent for referenceId ${params.referenceId}`, ); return { confirmed: false, reason: "intent-not-found" }; } this.logger.warn( `WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`, ); await this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: params.transactionId, }); return { confirmed: true, bookingId: intent.bookingId }; } private async syncIntentProjection( bookingId: string, snapshot: PaymentIntentSnapshot, ) { const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED ? PaymentIntentStatus.PROCESSING : (snapshot.status as unknown as PaymentIntentStatus); const data = { status, method: snapshot.provider as unknown as PaymentMethodType, merchantOrderId: snapshot.merchantOrderId, clientAction: snapshot.clientAction ? (snapshot.clientAction as unknown as Prisma.InputJsonValue) : Prisma.DbNull, providerTxnId: snapshot.providerTxnId ?? null, expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, rawInitiation: (snapshot as any).providerResponse ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue) : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({ where: { bookingId }, // amountMinor/currency are refreshed on update too: a cross-currency method switch // (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection // must reflect the currency the new provider actually charges — not the first one's. update: { ...data, amountMinor: snapshot.amountMinor, currency: snapshot.currency, }, create: { bookingId, amountMinor: snapshot.amountMinor, currency: snapshot.currency, ...data, }, }); } private async initiateWalletPayment( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ): Promise { const debitResult = await this.prisma.$transaction(async (tx) => { const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId }, }); if (!wallet || wallet.balanceMinor < booking.totalMinor) { return { success: false }; } const newBalance = wallet.balanceMinor - booking.totalMinor; await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance }, }); await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: "DEBIT", amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id, }, }); return { success: true }; }); if (!debitResult.success) { const failed = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.FAILED, failureCode: "INSUFFICIENT_BALANCE", }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.FAILED, failureCode: "INSUFFICIENT_BALANCE", }, }); return this.formatIntentResponse(failed); } const intent = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.PROCESSING }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.PROCESSING, providerRef: `WALLET-${Date.now()}`, }, }); await this.finalizePaymentSuccess({ intentId: intent.id }); const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id }, }); return this.formatIntentResponse(refreshed); } private formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): InitiateResponseDto { const clientAction = intent.clientAction && typeof intent.clientAction === "object" ? (intent.clientAction as unknown as ClientAction) : undefined; return { intentId: intent.id, status: intent.status, clientAction, merchantOrderId: intent.merchantOrderId ?? undefined, }; } /** * Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001). * Resolves the PNR to its booking id, then pulls the authoritative status from the payment * microservice (via {@link getIntentByBookingId}). */ async getIntentByBookingRefOrId( bookingRefOrId: string, ): Promise { const bookingId = await this.resolveBookingId(bookingRefOrId); return this.getIntentByBookingId(bookingId); } /** * Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's * stored intent row and a live provider status query, side by side ({ db, provider }). * Pure read — does not reconcile or confirm the booking. */ async getPaymentDiagnosticByBookingRefOrId( bookingRefOrId: string, ): Promise { const bookingId = await this.resolveBookingId(bookingRefOrId); return this.paymentClient.getDiagnosticByReference( PaymentReferenceType.BOOKING, bookingId, ); } /** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */ private async resolveBookingId(bookingRefOrId: string): Promise { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( bookingRefOrId, ); if (isUuid) return bookingRefOrId; const booking = await this.prisma.booking.findUnique({ where: { bookingRef: bookingRefOrId }, select: { id: true }, }); if (!booking) { throw new NotFoundException(`Booking not found: ${bookingRefOrId}`); } return booking.id; } async getIntentByBookingId(bookingId: string): Promise { const local = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, }); if (local?.status === PaymentIntentStatus.SUCCEEDED) { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, select: { status: true }, }); if (booking?.status === "CONFIRMED") { return this.formatIntentStatus(local); } } // WALLET payments never leave this app — no remote intent exists for them. if (local?.method === PaymentMethodType.WALLET) { return this.formatIntentStatus(local); } // Pull/reconcile through the payment microservice (it refreshes stale intents from the // provider itself). Falls back to the legacy local path when the service is unreachable // or only a pre-cutover local intent exists. let snapshot: PaymentIntentSnapshot | null = null; try { snapshot = await this.paymentClient.getIntentByReference( PaymentReferenceType.BOOKING, 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) { // Pre-cutover/local-only intent (or service briefly unreachable): serve the cached // status. The payment service owns provider refresh for everything initiated after // the cutover; webhooks/mark-paid converge the rest. if (!local) throw new NotFoundException("PaymentIntent not found"); return this.formatIntentStatus(local); } let intent = await this.syncIntentProjection(bookingId, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { // Poll observed success before (or instead of) the mark-paid event — converge now. await this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); intent = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id }, }); } return this.formatIntentStatus(intent); } private formatIntentStatus( intent: Prisma.PaymentIntentGetPayload>, ): IntentStatusDto { const base = this.formatIntentResponse(intent); return { ...base, paidAt: intent.paidAt?.toISOString(), failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, providerResponse: intent.rawInitiation && typeof intent.rawInitiation === "object" ? (intent.rawInitiation as Record) : undefined, }; } async refund(dto: RefundDto) { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId }, }); if (!intent || intent.status !== "SUCCEEDED") throw new BadRequestException("No successful payment to refund"); await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: "CANCELLED" }, }); const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true }, }); if (booking) { await this.seatsService.releaseSeats(booking.id); await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: "CANCELLED" }, }); } await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } }); return { refunded: true, bookingRef: booking?.bookingRef }; } addPaymentMethod(dto: AddPaymentMethodDto) { const data = { type: dto.type as unknown as PaymentMethodType, displayName: dto.displayName, region: dto.region as unknown as PaymentRegion, currency: dto.currency ?? "ETB", providerId: dto.providerId, enabled: dto.enabled ?? true, sortOrder: dto.sortOrder ?? 0, }; return this.prisma.paymentMethod.upsert({ where: { type: data.type }, update: data, create: data, }); } async updatePaymentMethod(id: string, dto: Partial) { const existing = await this.prisma.paymentMethod.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Payment method not found'); const updateData: any = {}; if (dto.displayName !== undefined) updateData.displayName = dto.displayName; if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion; if (dto.currency !== undefined) updateData.currency = dto.currency; if (dto.providerId !== undefined) updateData.providerId = dto.providerId; if (dto.enabled !== undefined) updateData.enabled = dto.enabled; if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder; return this.prisma.paymentMethod.update({ where: { id }, data: updateData, }); } getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { ...(region ? { region: { in: [ region, PaymentRegionEnum.GLOBAL, ] as unknown as PaymentRegion[], }, } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } async getBookingAmountByCurrency( bookingId: string, currency: string, ): Promise<{ booking_id: string; currency: string; amount: number }> { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true, currency: true, displayCurrency: true, displayTotalMinor: true, }, }); if (!booking) throw new NotFoundException('Booking not found'); const correctTotalMinor = await this.resolveBookingTotal(booking as any); const requestedCurrency = currency.toUpperCase(); // Source of truth: displayTotalMinor in displayCurrency when available, // otherwise totalMinor in ETB (bookings with no display currency override). const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase(); const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor; // Same currency — return directly, no conversion needed. if (requestedCurrency === sourceCurrency) { return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 }; } const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any }, orderBy: { effectiveDate: 'desc' }, }); let rate: number; if (exchangeRate) { rate = Number(exchangeRate.rate); } else { // Try inverse rate const inverseRate = await this.prisma.currencyExchangeRate.findFirst({ where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any }, orderBy: { effectiveDate: 'desc' }, }); if (inverseRate) { rate = 1 / Number(inverseRate.rate); } else { // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD)) rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any); } } const converted = (sourceMinor / 100) * rate; return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; } /** * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the * whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the * booking still confirms. */ private sanitizePaidAt(value?: Date): Date { const now = new Date(); if (!value) return now; const t = value.getTime(); const oneDayMs = 86_400_000; if ( Number.isNaN(t) || t > now.getTime() + oneDayMs || t < Date.UTC(2000, 0, 1) ) { this.logger.warn( `finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`, ); return now; } return value; } async finalizePaymentSuccess(input: { intentId: string; providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === PaymentIntentStatus.SUCCEEDED) { // Idempotency guard — but still repair missing tickets. They can be absent // when the first finalization threw from generate() after the transaction // committed: the caller got a 500, retried, and now hits this early-return. const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } }); if (ticketCount === 0) { try { await this.ticketsService.generate(intent.bookingId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.logger.warn( `Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`, ); try { await this.ticketsService.smartAssignAndGenerate(intent.bookingId); } catch (retryErr) { this.logger.error( `Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, ); } } } return { alreadyFinalized: true }; } if (intent.status === PaymentIntentStatus.CANCELLED) { throw new BadRequestException( "PaymentIntent is cancelled; cannot finalize", ); } const booking = await this.prisma.booking.findUnique({ where: { id: intent.bookingId }, include: { seats: true }, }); if (!booking) throw new NotFoundException("Booking not found"); const paidAt = this.sanitizePaidAt(input.paidAt); await this.prisma.$transaction(async (tx) => { await tx.paymentIntent.update({ where: { id: intent.id }, data: { status: PaymentIntentStatus.SUCCEEDED, providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, paidAt, }, }); await tx.booking.update({ where: { id: booking.id }, data: { status: "CONFIRMED" }, }); }); try { await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); } catch (err) { this.logger.error( `Error confirming seats: ${err instanceof Error ? err.message : String(err)}`, ); } try { await this.createJourneySegments(booking); } catch (err) { this.logger.error( `Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`, ); } try { await this.ticketsService.generate(booking.id); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Only reassign seats when a *different* booking genuinely holds the seat // (ConflictException). Any other error (transient DB issue, etc.) is logged // and swallowed — the passenger keeps their original seat and the ticket can // be retried via "Generate Missing" in the backoffice. if (err instanceof ConflictException) { this.logger.warn( `Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`, ); try { await this.ticketsService.smartAssignAndGenerate(booking.id); } catch (retryErr) { this.logger.error( `Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, ); } } else { this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`); } } try { await this.awardLoyaltyPoints( booking.passengerId, booking.totalMinor, booking.id, ); } catch (err) { this.logger.warn( `Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`, ); } this.eventEmitter.emit("payment.succeeded", { booking }); return { alreadyFinalized: false }; } private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise { if (event.eventType === 'payment.failed') { this.logger.warn(`supplementary charge ${event.referenceId} payment failed`); return { processed: true }; } const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } }); if (!charge) { this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`); return { processed: false, reason: 'charge-not-found' }; } if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true }; await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null }, }); await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } }); return { processed: true }; } async handlePaymentEvent( event: PaymentEventDto, ): Promise { if (event.service !== PaymentServiceEnum.PASSENGER) { this.logger.warn( `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, ); return { processed: false, reason: "foreign-reference" }; } if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) { return this.handleSupplementaryChargeEvent(event); } if (event.referenceType !== PaymentReferenceType.BOOKING) { this.logger.warn( `mark-paid: ignoring unknown referenceType ${event.referenceType}`, ); return { processed: false, reason: "foreign-reference" }; } if (event.eventType === "payment.failed") { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: event.referenceId }, }); if (intent) { await this.markPaymentFailed({ intentId: intent.id, failureCode: event.failureCode, failureMessage: event.failureMessage, }); } const failedBooking = await this.prisma.booking.findUnique({ where: { id: event.referenceId }, }); if (failedBooking) { this.eventEmitter.emit("payment.failed", { booking: failedBooking }); } return { processed: true }; } const booking = await this.prisma.booking.findUnique({ where: { id: event.referenceId }, }); if (!booking) { // Ack (200) — a missing booking will not appear on redelivery; needs investigation. this.logger.error( `mark-paid: no booking for reference ${event.referenceId}`, ); return { processed: false, reason: "booking-not-found" }; } // Local intent row is a projection during the strangler migration: reuse it when the // legacy initiate path created one, otherwise materialize it from the event. let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: event.referenceId }, }); if (!intent) { intent = await this.prisma.paymentIntent.create({ data: { bookingId: event.referenceId, amountMinor: event.amountMinor, currency: event.currency, method: event.provider as unknown as PaymentMethodType, status: PaymentIntentStatus.PROCESSING, merchantOrderId: event.merchantOrderId, providerTxnId: event.providerTxnId, }, }); } const { alreadyFinalized } = await this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, }).catch((err) => { this.logger.error( `finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`, ); return { alreadyFinalized: false }; }); return { processed: true, alreadyFinalized }; } async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } }); if (!booking) throw new NotFoundException('Booking not found'); const resolvedMethod = dto.paymentMethod ? (dto.paymentMethod as unknown as PaymentMethodType) : PaymentMethodType.TELEBIRR; let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } }); if (!intent) { intent = await this.prisma.paymentIntent.create({ data: { bookingId, amountMinor: booking.totalMinor, currency: booking.currency, method: resolvedMethod, status: PaymentIntentStatus.PROCESSING, providerRef: `FORCE-${Date.now()}`, providerTxnId: dto.paymentReference ?? null, failureMessage: dto.notes ?? null, }, }); } else { // Update method/reference/notes regardless of current status const updateData: any = {}; if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference; if (dto.paymentMethod) updateData.method = resolvedMethod; if (dto.notes) updateData.failureMessage = dto.notes; if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) { updateData.status = PaymentIntentStatus.PROCESSING; } if (Object.keys(updateData).length) { intent = await this.prisma.paymentIntent.update({ where: { id: intent.id }, data: updateData, }); } } return this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined, }).then(async (result) => { await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } }); return result; }); } async markPaymentFailed(input: { intentId: string; failureCode?: string; failureMessage?: string; }): Promise { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if ( intent.status === PaymentIntentStatus.SUCCEEDED || intent.status === PaymentIntentStatus.CANCELLED ) { return; } await this.prisma.paymentIntent.update({ where: { id: intent.id }, data: { status: PaymentIntentStatus.FAILED, failureCode: input.failureCode, failureMessage: input.failureMessage, }, }); } private async awardLoyaltyPoints( passengerId: string, amountMinor: number, bookingId: string, ) { const points = Math.floor(amountMinor / 100); const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, }); if (!account) return; const newBalance = account.pointsBalance + points; const tier = newBalance >= 10000 ? "PLATINUM" : newBalance >= 5000 ? "GOLD" : newBalance >= 2000 ? "SILVER" : "BRONZE"; await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any }, }); await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: "TRIP_COMPLETED", bookingId, balanceAfter: newBalance, }, }); } private async createJourneySegments( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ) { const b = booking as any; // Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] } // BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2 type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] }; const legDefs: LegDef[] = []; const seatsForLeg = (legNum: number) => booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId); if (booking.bookingType === 'ONE_WAY') { legDefs.push({ scheduleId: booking.scheduleId, originStationId: b.originStationId, destinationStationId: b.destinationStationId, seatIds: booking.seats.map((s: any) => s.seatId), }); } else if (booking.bookingType === 'ROUND_TRIP') { legDefs.push({ scheduleId: booking.scheduleId, originStationId: b.originStationId, destinationStationId: b.destinationStationId, seatIds: seatsForLeg(1), }); if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { legDefs.push({ scheduleId: b.returnScheduleId, originStationId: b.returnOriginStationId, destinationStationId: b.returnDestinationStationId, seatIds: seatsForLeg(2), }); } } else if (booking.bookingType === 'TRANSIT') { legDefs.push({ scheduleId: booking.scheduleId, originStationId: b.originStationId, destinationStationId: b.leg2OriginStationId, // transit station seatIds: seatsForLeg(1), }); if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { legDefs.push({ scheduleId: b.leg2ScheduleId, originStationId: b.leg2OriginStationId, destinationStationId: b.leg2DestinationStationId, seatIds: seatsForLeg(2), }); } } else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') { legDefs.push({ scheduleId: booking.scheduleId, originStationId: b.originStationId, destinationStationId: b.leg2OriginStationId, seatIds: seatsForLeg(1), }); if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { legDefs.push({ scheduleId: b.leg2ScheduleId, originStationId: b.leg2OriginStationId, destinationStationId: b.leg2DestinationStationId, seatIds: seatsForLeg(2), }); } if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { legDefs.push({ scheduleId: b.returnScheduleId, originStationId: b.returnOriginStationId, destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId, seatIds: seatsForLeg(3), }); } if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) { legDefs.push({ scheduleId: b.returnLeg2ScheduleId, originStationId: b.returnLeg2OriginStationId, destinationStationId: b.returnLeg2DestStationId, seatIds: seatsForLeg(4), }); } } if (legDefs.length === 0) return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, bookingId: booking.id, status: 'CONFIRMED', totalMinor: booking.totalMinor, currency: booking.currency, } as any, }); const journeySegments: any[] = []; let segmentOrder = 0; for (const leg of legDefs) { if (leg.seatIds.length === 0) continue; const stopTimes = await this.prisma.tripStopTime.findMany({ where: { scheduleId: leg.scheduleId }, orderBy: { sequence: 'asc' }, select: { stationId: true, sequence: true }, }); const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId); const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId); if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue; for (const seatId of leg.seatIds) { for (let i = originIdx; i < destIdx; i++) { journeySegments.push({ journeyId: journey.id, scheduleId: leg.scheduleId, segmentOrder: segmentOrder++, seatId, departureStationId: stopTimes[i].stationId, arrivalStationId: stopTimes[i + 1].stationId, }); } } } if (journeySegments.length > 0) { await this.prisma.journeySegment.createMany({ data: journeySegments }); } } }