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 { snapshot } from '../../common/audit-snapshot'; const ALLOWANCE_AUDIT_FIELDS = [ 'seatClassId', 'maxWeightKg', 'maxPiecesCount', 'excessFeePerKg', ] as const; import { CurrencyService } from '../currency/currency.service'; import { PaymentClientService } from '../payments/payment-client.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { LogExcessBaggageDto, WaiveChargeDto, InitiateExcessPaymentDto, } from './excess-baggage.dto'; import { PaymentService as PaymentServiceEnum, PaymentReferenceType, ProviderMethod, ProviderPaymentStatus, } from '@edr/types'; import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes /** * WALLET is an internal balance debit handled entirely inside this app (PaymentsService * .initiateWalletPayment) — it is not a provider and the payment microservice rejects it as one. * Excess baggage has no wallet path, so it is refused up front with a message a 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, so initiate is rejected without it (mirrors PaymentsService). */ const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ PaymentMethodType.CAC_BANK, PaymentMethodType.EBIRR, ]); /** * How long an excess baggage charge stays payable once a CBE bill has been issued for it. * * The 30-minute link TTL is a browser-session window: it assumes the payer is sitting in front of * the page. A CBE bill is the opposite — the payer walks to a branch, or opens CBE Birr later, and * the bill reference may already be written on a slip of paper. Handing the payment service a * 30-minute `expiresAt` would also make the reconciliation sweep expire the intent and emit * payment.failed within the hour (CBE_IMPLEMENTATION_PLAN.md §6.4 calls this the single most * important detail of the integration). * * So issuing a bill EXTENDS the charge's own deadline to this window. `charge.expiresAt` stays the * single source of truth for both the pay link and the bill. */ const CBE_BILL_WINDOW_HOURS = Number( process.env.EXCESS_BAGGAGE_CBE_BILL_HOURS ?? 24, ); @Injectable() export class ExcessBaggageService { private readonly logger = new Logger(ExcessBaggageService.name); constructor( private prisma: PrismaService, private auditService: AuditService, private currencyService: CurrencyService, private paymentClient: PaymentClientService, private notifications: NotificationsService, private smsClient: SmsClientService, private emailClient: EmailClientService, ) {} async logCharge(dto: LogExcessBaggageDto) { const bookingRef = dto.bookingReference?.trim(); const bookingId = dto.bookingId?.trim(); const booking = bookingRef ? await this.prisma.booking.findFirst({ where: { bookingRef: { equals: bookingRef, mode: 'insensitive' } }, include: { passenger: { include: { user: true } }, }, }) : bookingId ? await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { passenger: { include: { user: true } }, }, }) : null; if (!booking) throw new NotFoundException('Booking not found'); if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage'); } const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } }); if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.'); const feePerKgMinor = allowance.excessFeePerKg; const totalMinor = feePerKgMinor * dto.excessWeightKg; const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null); const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null); const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING'; const paidAt = dto.collectCash ? new Date() : null; const charge = await this.prisma.excessBaggageCharge.create({ data: { bookingId: booking.id, agentId: dto.agentId ?? '', excessWeightKg: dto.excessWeightKg, feePerKgMinor, totalMinor, status, expiresAt, paidAt, contactPhone, contactEmail, }, }); if (!dto.collectCash) { await this.sendPaymentLink(charge, booking, contactPhone, contactEmail); } // The charge row carries contactPhone/contactEmail for the payment link; those stay out of // the audit payload, which needs only the money and who raised it. await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.ExcessBaggageCharge, entityId: charge.id, newData: { bookingId: booking.id, bookingRef: booking.bookingRef, excessWeightKg: dto.excessWeightKg, feePerKgMinor, totalMinor, currency: charge.currency, status, agentId: charge.agentId || null, collectCash: dto.collectCash ?? false, }, }); return charge; } private async sendPaymentLink( charge: any, booking: any, phone: string | null, email: string | null, ) { const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; const payUrl = `${portalUrl}/excess-baggage/pay/${charge.paymentToken}`; const amountStr = (charge.totalMinor / 100).toFixed(2); const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`; if (phone) { try { await this.smsClient.sendSms({ to: phone, message: msg }); } catch (err) { this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`); } } if (email) { try { await this.emailClient.sendEmail({ to: email, subject: `EDR — Excess baggage payment required (${booking.bookingRef})`, text: msg, }); } catch (err) { this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`); } } if (!phone && !email) { this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`); } } async getCharge(id: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id }, include: { booking: { select: { bookingRef: true, status: true } } }, }); if (!charge) throw new NotFoundException('Charge not found'); return charge; } async getByToken(token: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { paymentToken: token }, include: { booking: { select: { bookingRef: true, scheduleId: true } } }, }); if (!charge) throw new NotFoundException('Payment link not found'); if (charge.status === 'EXPIRED' || new Date() > charge.expiresAt) { if (charge.status === 'PENDING') { await this.prisma.excessBaggageCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' }, }); } throw new BadRequestException('This payment link has expired'); } if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { throw new BadRequestException('This charge has already been paid'); } if (charge.status === 'WAIVED') { throw new BadRequestException('This charge has been waived'); } return charge; } /** * What the payer is actually charged when paying this charge with `method`. * * The charge itself is always booked in ETB (`ExcessBaggageCharge.currency` defaults to ETB and * nothing overrides it), but the selected method settles in its own currency — WAAFI/DMONEY 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 * verbatim, 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: { totalMinor: number; currency: string }, method: string, ): Promise<{ amount: number; currency: string }> { if (UNSUPPORTED_METHODS.has(method)) { throw new BadRequestException( `${method} is not available for excess baggage 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. Every other // method charges in its configured settlement currency, falling back to the charge's own. 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.totalMinor, 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 `initiatePayment` 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 }; } async initiatePayment(token: string, dto: InitiateExcessPaymentDto) { const charge = await this.getByToken(token); if ( METHODS_REQUIRING_PAYER_ACCOUNT.has(dto.method) && !dto.payerAccount?.trim() ) { throw new BadRequestException( `payerAccount (mobile number) is required for ${dto.method}`, ); } const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`; const { amount, currency } = await this.resolveChargeAmount( charge, dto.method, ); // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's // system until someone pays it. It therefore needs a real deadline and a payer name (Full_Name // is mandatory in CBE's envelope) rather than the redirect flow's session semantics. let payerName: string | undefined; let expiresAt: string | undefined; if (dto.method === PaymentMethodType.CBE_BILL) { const deadline = await this.extendForCbeBill(charge); expiresAt = deadline.toISOString(); payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; } const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType, referenceId: charge.id, orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`, // `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: dto.method as unknown as ProviderMethod, platform: dto.platform as any, payerAccount: dto.payerAccount?.trim() || undefined, payerName, expiresAt, returnUrl, failureUrl: returnUrl, }); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { await this.markPaid(charge.id, snapshot.providerTxnId); } return { chargeId: charge.id, status: snapshot.status, clientAction: snapshot.clientAction, merchantOrderId: snapshot.merchantOrderId, }; } /** * Pushes the charge's deadline out to the CBE bill window and returns it. Only ever extends — * a charge that already has longer left (a re-issued bill, an agent's resend) keeps it, so * re-initiating a bill can never shorten a window the payer was already given. */ private async extendForCbeBill(charge: { id: string; expiresAt: Date; }): Promise { const target = new Date(Date.now() + CBE_BILL_WINDOW_HOURS * 60 * 60 * 1000); if (charge.expiresAt >= target) return charge.expiresAt; await this.prisma.excessBaggageCharge.update({ where: { id: charge.id }, data: { expiresAt: target }, }); this.logger.log( `charge ${charge.id}: expiry extended to ${target.toISOString()} for CBE bill`, ); return target; } /** * Full_Name for CBE's confirmation screen — mandatory in its envelope. The passenger the * baggage belongs to: lead traveller 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 ); } /** * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, * expired or waived charge — the whole point is to report those states. A CBE bill can settle * long after the payer closed the tab, and the 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.excessBaggageCharge.findUnique({ where: { paymentToken: token }, select: { id: true, status: true, paidAt: true, totalMinor: 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' || charge.status === 'CASH_COLLECTED', paidAt: charge.paidAt, totalMinor: charge.totalMinor, currency: charge.currency, expiresAt: charge.expiresAt, }; } /** * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the * payerAccount given at initiate; 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 30-minute * link TTL lapsed while they were reading the SMS would strand a payment that is mid-flight. */ async confirmOtp(token: string, otp: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { paymentToken: token }, }); if (!charge) throw new NotFoundException('Payment link not found'); if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; } const snapshot = await this.paymentClient.getIntentByReference( 'EXCESS_BAGGAGE' as PaymentReferenceType, 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 markPaid(chargeId: string, providerTxnId?: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status === 'PAID') return charge; // Conditional claim: PaymentsService.handleExcessBaggageChargeEvent drives the same // transition from the webhook. Whichever caller actually flips the row writes the audit // event, so the settlement is recorded exactly once regardless of which path won. const { count } = await this.prisma.excessBaggageCharge.updateMany({ where: { id: chargeId, status: { notIn: ['PAID', 'CASH_COLLECTED'] } }, data: { status: 'PAID', paidAt: new Date() }, }); const updated = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); if (count === 1) { await this.auditService.log({ action: AUDIT_ACTIONS.PAY, entityType: AUDIT_ENTITIES.ExcessBaggageCharge, entityId: chargeId, oldData: { status: charge.status }, newData: { status: 'PAID', bookingId: charge.bookingId, totalMinor: charge.totalMinor, currency: charge.currency, providerTxnId: providerTxnId ?? null, }, }); } return updated!; } async waiveCharge(id: string, dto: WaiveChargeDto) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } }); if (!charge) throw new NotFoundException('Charge not found'); if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) { throw new BadRequestException('Cannot waive a charge that has already been paid'); } const waived = await this.prisma.excessBaggageCharge.update({ where: { id }, data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason }, }); // `dto.waivedBy` is a client-supplied label kept for the business column; the audit actor // is resolved from the session by AuditService, so the two cannot disagree about who acted. await this.auditService.log({ action: AUDIT_ACTIONS.WAIVE, entityType: AUDIT_ENTITIES.ExcessBaggageCharge, entityId: id, oldData: { status: charge.status }, newData: { status: 'WAIVED', bookingId: charge.bookingId, totalMinor: charge.totalMinor, currency: charge.currency, waivedBy: dto.waivedBy, waivedReason: dto.waivedReason, }, }); return waived; } async resendLink(id: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id }, include: { booking: { select: { bookingRef: true, passengerId: true } } }, }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status !== 'PENDING') { throw new BadRequestException('Can only resend link for PENDING charges'); } // Extend expiry by 30 minutes from now const updatedCharge = await this.prisma.excessBaggageCharge.update({ where: { id }, data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, }); await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail); await this.auditService.log({ action: AUDIT_ACTIONS.RESEND, entityType: AUDIT_ENTITIES.ExcessBaggageCharge, entityId: id, oldData: { expiresAt: charge.expiresAt?.toISOString() }, newData: { bookingRef: charge.booking.bookingRef, expiresAt: updatedCharge.expiresAt?.toISOString(), }, }); return { sent: true }; } async getAll(filters: { status?: string; bookingRef?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number; }) { const { status, bookingRef, dateFrom, dateTo, 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' } }; if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), }; } // Mark expired charges before fetching await this.prisma.excessBaggageCharge.updateMany({ where: { status: 'PENDING', expiresAt: { lt: new Date() }, }, data: { status: 'EXPIRED' }, }); const [items, total] = await Promise.all([ this.prisma.excessBaggageCharge.findMany({ where, include: { booking: { select: { bookingRef: true, status: true } } }, orderBy: { createdAt: 'desc' }, skip, take: pageSize, }), this.prisma.excessBaggageCharge.count({ where }), ]); return { items, total, page, pageSize }; } async getAllowances() { const [allowances, seatClasses] = await Promise.all([ this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }), this.prisma.seatClass.findMany({ select: { id: true, name: true } }), ]); const scMap = new Map(seatClasses.map(s => [s.id, s])); return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null })); } async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) { const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } }); if (existing) { const updated = await this.prisma.baggageAllowance.update({ where: { id: existing.id }, data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg }, }); // An upsert, so report the edit rather than always claiming a create — this is a tariff // change and the previous fee is the whole point of the row. await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.BaggageAllowance, entityId: existing.id, oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS), newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS), }); return updated; } const created = await this.prisma.baggageAllowance.create({ data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg }, }); await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.BaggageAllowance, entityId: created.id, newData: snapshot(created, ALLOWANCE_AUDIT_FIELDS), }); return created; } async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) { const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Baggage allowance not found'); const updated = await this.prisma.baggageAllowance.update({ where: { id }, data: dto }); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.BaggageAllowance, entityId: id, oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS), newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS), }); return updated; } async deleteAllowance(id: string) { const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } }); await this.prisma.baggageAllowance.deleteMany({ where: { id } }); if (existing) { await this.auditService.log({ action: AUDIT_ACTIONS.DELETE, entityType: AUDIT_ENTITIES.BaggageAllowance, entityId: id, oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS), }); } return { deleted: true }; } async deleteCharge(id: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } }); if (!charge) throw new NotFoundException('Charge not found'); await this.prisma.excessBaggageCharge.delete({ where: { id } }); // Hard delete of a money record — previously silent. await this.auditService.log({ action: AUDIT_ACTIONS.DELETE, entityType: AUDIT_ENTITIES.ExcessBaggageCharge, entityId: id, oldData: { bookingId: charge.bookingId, excessWeightKg: charge.excessWeightKg, totalMinor: charge.totalMinor, currency: charge.currency, status: charge.status, }, }); return { deleted: true }; } }