import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { PaymentClientService } from './payment-client.service'; import { CurrencyService } from '../currency/currency.service'; import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod, ProviderPaymentStatus, } from '@edr/types'; import { PaymentPlatformDto } from './payments.dto'; import { PaymentMethodType } from '@prisma/client'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours /** * WALLET is an internal balance debit handled inside this app, not a provider — the payment * microservice rejects it as one. Supplementary charges have no wallet path, so it is refused up * front with a message the payer can act on rather than a 502 from the gateway layer. */ const UNSUPPORTED_METHODS = new Set([PaymentMethodType.WALLET]); /** * Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time * password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could * collect the number later (mirrors PaymentsService and ExcessBaggageService). */ const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ PaymentMethodType.CAC_BANK, PaymentMethodType.EBIRR, ]); @Injectable() export class SupplementaryChargesService { private readonly logger = new Logger(SupplementaryChargesService.name); constructor( private prisma: PrismaService, private auditService: AuditService, private smsClient: SmsClientService, private emailClient: EmailClientService, private paymentClient: PaymentClientService, private currencyService: CurrencyService, ) {} async create(dto: { bookingRef: string; amountMinor: number; reason: string; notes?: string; contactPhone?: string; contactEmail?: string; createdBy: string; }) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { passenger: { include: { user: true } } }, }); if (!booking) throw new NotFoundException('Booking not found'); if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge'); } if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive'); const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); const charge = await this.prisma.supplementaryCharge.create({ data: { bookingId: booking.id, reason: dto.reason, amountMinor: dto.amountMinor, notes: dto.notes ?? null, createdBy: dto.createdBy, expiresAt, }, }); const phone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null); const email = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null); await this.sendLink(charge, booking.bookingRef, phone, email); await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.SupplementaryCharge, entityId: charge.id, newData: { bookingId: booking.id, bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, currency: charge.currency, reason: dto.reason, notes: dto.notes, createdBy: dto.createdBy, status: charge.status, expiresAt: charge.expiresAt?.toISOString(), }, }); return charge; } async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) { const { bookingRef, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; if (status) where.status = status; if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; await this.prisma.supplementaryCharge.updateMany({ where: { status: 'PENDING', expiresAt: { lt: new Date() } }, data: { status: 'EXPIRED' }, }); const [items, total] = await Promise.all([ this.prisma.supplementaryCharge.findMany({ where, include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } }, orderBy: { createdAt: 'desc' }, skip, take: pageSize, }), this.prisma.supplementaryCharge.count({ where }), ]); return { items, total, page, pageSize }; } async getByToken(token: string) { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { paymentToken: token }, include: { booking: { select: { bookingRef: true } } }, }); if (!charge) throw new NotFoundException('Payment link not found'); if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid'); if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived'); if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) { if (charge.status === 'PENDING') { await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } }); } throw new BadRequestException('This payment link has expired'); } return charge; } async markPaid(id: string, providerTxnId?: string) { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status === 'PAID') return charge; // Conditional update rather than a plain update: this transition is also reachable from the // payment webhook, and claiming it atomically means exactly one of the two racing callers // writes the audit row. const { count } = await this.prisma.supplementaryCharge.updateMany({ where: { id, status: { not: 'PAID' } }, data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null }, }); const updated = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); if (count === 1) { await this.auditService.log({ action: AUDIT_ACTIONS.PAY, entityType: AUDIT_ENTITIES.SupplementaryCharge, entityId: id, oldData: { status: charge.status }, newData: { status: 'PAID', bookingId: charge.bookingId, amountMinor: charge.amountMinor, currency: charge.currency, providerTxnId: providerTxnId ?? null, }, }); } return updated!; } /** * What the payer is actually charged when settling this charge with `method`. * * The charge is raised in ETB, but the selected method settles in its own currency — WAAFI and * D-Money in DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row. * The payment microservice is currency-agnostic and hands whatever it is given straight to the * gateway, so the ETB->settlement conversion has to happen here or the provider is asked to * debit an ETB number labelled as its own currency. * * Both the quote shown to the payer and the amount sent to the provider come through this one * method, so the price on the button and the price debited cannot drift apart. */ private async resolveChargeAmount( charge: { amountMinor: number; currency: string }, method: string, ): Promise<{ amount: number; currency: string }> { if (UNSUPPORTED_METHODS.has(method)) { throw new BadRequestException( `${method} is not available for balance payments`, ); } const paymentMethod = await this.prisma.paymentMethod.findUnique({ where: { type: method as PaymentMethodType }, }); // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted. const chargeCurrency = method === PaymentMethodType.CBE_BILL ? 'ETB' : (paymentMethod?.currency ?? charge.currency).toUpperCase(); // Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents. const amount = await this.currencyService.convertMinorToChargeMajor( charge.amountMinor, charge.currency, chargeCurrency, ); return { amount, currency: chargeCurrency }; } /** * Price quote for the pay page: what `method` would debit, in that method's settlement * currency. The payer sees this before committing, and pay() recomputes it the same way. */ async quoteAmount(token: string, method: string) { const charge = await this.getByToken(token); const { amount, currency } = await this.resolveChargeAmount(charge, method); return { chargeId: charge.id, method, currency, amount }; } /** * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, * expired or waived charge — reporting those states is the entire point. A CBE bill can settle * long after the payer closed the tab, and redirect methods only converge when the settlement * event lands, so the page needs something it can watch. */ async getStatus(token: string) { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { paymentToken: token }, select: { id: true, status: true, paidAt: true, amountMinor: true, currency: true, expiresAt: true, }, }); if (!charge) throw new NotFoundException('Payment link not found'); return { chargeId: charge.id, status: charge.status, paid: charge.status === 'PAID', paidAt: charge.paidAt, amountMinor: charge.amountMinor, currency: charge.currency, expiresAt: charge.expiresAt, }; } /** * Full_Name for CBE's confirmation screen — mandatory in its envelope. The traveller the balance * is owed against: lead passenger on the booking, falling back to the account holder. */ private async resolvePayerName(bookingId: string): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { seats: true, passenger: { include: { user: true } } }, }); if (!booking) return null; return ( booking.seats?.find((s: any) => s.leg === 1)?.passengerName ?? booking.seats?.[0]?.passengerName ?? booking.passenger?.user?.fullName ?? null ); } /** * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the * payerAccount given at pay(); this forwards it to the payment service and marks the charge paid * when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays open, * so the payer can simply re-enter it. * * Deliberately reads the charge directly rather than through getByToken: the bank is already * holding a debit against this payer, and refusing to submit their OTP because the link TTL * lapsed while they read the SMS would strand a payment that is mid-flight. */ async confirmOtp(token: string, otp: string) { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { paymentToken: token }, }); if (!charge) throw new NotFoundException('Payment link not found'); if (charge.status === 'PAID') { return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; } const snapshot = await this.paymentClient.getIntentByReference( PaymentReferenceType.SUPPLEMENTARY_CHARGE, charge.id, ); if (!snapshot) { throw new NotFoundException('No active payment to confirm for this charge'); } const confirmed = await this.paymentClient.confirmOtp( snapshot.intentId, otp, ); if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { await this.markPaid(charge.id, confirmed.providerTxnId); } return { chargeId: charge.id, status: confirmed.status, alreadyPaid: false, }; } async pay( token: string, method: string, platform?: PaymentPlatformDto, requestOrigin?: string | null, payerAccount?: string, ) { const charge = await this.getByToken(token); // validates status/expiry if (METHODS_REQUIRING_PAYER_ACCOUNT.has(method) && !payerAccount?.trim()) { throw new BadRequestException( `payerAccount (mobile number) is required for ${method}`, ); } const paymentMethod = method as ProviderMethod; // Self-pay links are opened on whichever portal domain the recipient used // (bookingedr.et vs passenger.edrsc.com), so the return pages must live on // that same domain. `requestOrigin` is already allowlist-validated by the // controller; PORTAL_URL is the fallback for non-browser callers. const portalUrl = requestOrigin ?? process.env.PORTAL_URL ?? 'http://localhost:5174'; const returnUrl = `${portalUrl}/pay-balance/${token}/success`; const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; const { amount, currency } = await this.resolveChargeAmount(charge, method); // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's // system until someone pays it. It needs a real deadline and a payer name (Full_Name is // mandatory in CBE's envelope) rather than the redirect flow's session semantics. // // Unlike an excess baggage charge (30-minute link TTL), this charge already carries a 72-hour // deadline of its own, which is a sane bill lifetime — so it is passed straight through with // no extension. That deadline is what stops the reconciliation sweep from expiring the intent // early (CBE_IMPLEMENTATION_PLAN.md §6.4). A charge with no expiry at all yields no intent // expiry either, which is correct: an open-ended debt backs an open-ended bill. let payerName: string | undefined; let expiresAt: string | undefined; if (method === PaymentMethodType.CBE_BILL) { expiresAt = charge.expiresAt?.toISOString(); payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; } const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE, referenceId: charge.id, orderRef: `SC-${charge.id.substring(0, 8)}`, // `amountMinor` is the contract's name but its value is MAJOR units — the provider layer // charges it verbatim at the currency's own precision (see PaymentIntentSnapshot). amountMinor: amount, currency, provider: paymentMethod, platform, payerAccount: payerAccount?.trim() || undefined, payerName, expiresAt, returnUrl, failureUrl, }); return snapshot; } async waive(id: string, notes: string, waivedBy: string) { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge'); const updated = await this.prisma.supplementaryCharge.update({ where: { id }, data: { status: 'WAIVED', notes }, }); await this.auditService.log({ action: AUDIT_ACTIONS.WAIVE, entityType: AUDIT_ENTITIES.SupplementaryCharge, entityId: id, oldData: { status: charge.status }, newData: { status: 'WAIVED', bookingId: charge.bookingId, amountMinor: charge.amountMinor, currency: charge.currency, waivedBy, notes, }, }); return updated; } async resendLink(id: string) { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id }, include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } }, }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges'); const updated = await this.prisma.supplementaryCharge.update({ where: { id }, data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, }); await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail); // Re-exposes a live payment token and extends its deadline, so it is a state change worth // attributing even though the charge's status is unchanged. Contact details stay out of the // row — only the fact that a link was re-sent. await this.auditService.log({ action: AUDIT_ACTIONS.RESEND, entityType: AUDIT_ENTITIES.SupplementaryCharge, entityId: id, oldData: { expiresAt: charge.expiresAt?.toISOString() }, newData: { bookingRef: charge.booking.bookingRef, expiresAt: updated.expiresAt?.toISOString(), }, }); return { sent: true }; } private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) { const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`; const amount = (charge.amountMinor / 100).toFixed(2); const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`; if (phone) { try { await this.smsClient.sendSms({ to: phone, message: msg }); } catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); } } if (email) { try { await this.emailClient.sendEmail({ to: email, subject: `EDR — Outstanding balance for booking ${bookingRef}`, text: msg, }); } catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); } } if (!phone && !email) { this.logger.warn(`No contact info for supplementary charge ${charge.id}`); } } }