From fa16087a4a373ad1c5522b7e119fbcb47b376c8a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 15:33:43 +0300 Subject: [PATCH] fix: ( excess-baggage ) pay in the selected method's currency and record settlement --- .../excess-baggage-currency.spec.ts | 451 ++++++++++++++++++ .../excess-baggage.controller.ts | 48 +- .../excess-baggage/excess-baggage.dto.ts | 29 +- .../excess-baggage/excess-baggage.module.ts | 9 +- .../excess-baggage/excess-baggage.service.ts | 235 ++++++++- .../payments/internal-payments.controller.ts | 7 + .../modules/payments/payments.service.spec.ts | 196 ++++++++ .../src/modules/payments/payments.service.ts | 142 ++++++ .../test/money-integrity.e2e-spec.ts | 12 +- .../app/excess-baggage/pay/[token]/page.tsx | 421 +++++++++++++++- 10 files changed, 1522 insertions(+), 28 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts new file mode 100644 index 000000000..abc79c798 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts @@ -0,0 +1,451 @@ +import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodType } from '@prisma/client'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { CurrencyService } from '../currency/currency.service'; + +/** + * An excess baggage charge is always booked in ETB, but each payment method settles in its own + * currency and the payment microservice forwards whatever it is given straight to the gateway. + * These cover the ETB→settlement conversion that has to happen here — and that the quote shown to + * the payer is computed from the same code path as the amount actually charged. + */ +describe('ExcessBaggageService — charge currency', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + // 350.00 ETB owed for 7kg at 50.00 ETB/kg. + const charge = { + id: CHARGE_ID, + totalMinor: 35_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + const build = (rate?: { rate: number }) => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + paymentMethod: { findUnique: jest.fn() }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue(rate ?? null), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + status: 'REQUIRES_ACTION', + clientAction: { type: 'REDIRECT', url: 'https://gateway.test/pay' }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest.fn(), + confirmOtp: jest.fn(), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, // auditService + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, // notifications + {} as any, // smsClient + {} as any, // emailClient + ); + }; + + const withMethod = (type: string, currency: string) => + prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency }); + + it('charges an Ethiopian wallet in ETB, unconverted', async () => { + build(); + withMethod(PaymentMethodType.TELEBIRR, 'ETB'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled(); + }); + + it('converts to DJF for Waafi and rounds to whole francs', async () => { + build({ rate: 3.2 }); // 1 ETB = 3.2 DJF + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI); + + // 350.00 ETB × 3.2 = 1120 DJF — DJF has no minor unit. + expect(quote).toMatchObject({ currency: 'DJF', amount: 1120 }); + expect(Number.isInteger(quote.amount)).toBe(true); + }); + + it('sends the provider the converted amount and its own currency, not the stored ETB total', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + platform: 'web', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + referenceType: 'EXCESS_BAGGAGE', + referenceId: CHARGE_ID, + amountMinor: 1120, + currency: 'DJF', + provider: PaymentMethodType.WAAFI, + }), + ); + }); + + it('quotes and charges the same figure for the same method', async () => { + build({ rate: 0.0175 }); // 1 ETB = 0.0175 USD + withMethod(PaymentMethodType.CARD, 'USD'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CARD); + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CARD, + platform: 'web', + } as any); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(quote.amount).toBe(sent.amountMinor); + expect(quote.currency).toBe(sent.currency); + expect(sent.amountMinor).toBe(6.13); // 350 × 0.0175 = 6.125 → 6.13 USD + }); + + it('forces ETB for CBE_BILL, which settles ETB only', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + }); + + it('refuses WALLET, which has no excess-baggage path', async () => { + build(); + + await expect( + service.quoteAmount(TOKEN, PaymentMethodType.WALLET), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WALLET, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('fails closed when no exchange rate is configured — never charges at parity', async () => { + build(); // no rate rows at all + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); +}); + +/** + * CAC Bank is an OTP debit: the bank SMSes a one-time password to a mobile number it must be given + * at initiate, and the payment only settles once that password is submitted back. + */ +describe('ExcessBaggageService — CAC Bank OTP debit', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + const charge = { + id: CHARGE_ID, + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + beforeEach(() => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CAC_BANK', currency: 'DJF' }), + }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue({ rate: 3.25 }), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'COLLECT_OTP', + message: 'Enter the OTP sent to 77****56', + }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'SUCCEEDED', + providerTxnId: 'CAC-TXN-9', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + it('rejects initiate without a payer mobile — the bank has nowhere to send the OTP', async () => { + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('forwards the payer mobile and returns the OTP client action', async () => { + const result = await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + payerAccount: ' 77123456 ', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + payerAccount: '77123456', // trimmed + currency: 'DJF', + amountMinor: 813, // 250.00 ETB × 3.25, whole francs + }), + ); + expect(result.clientAction).toMatchObject({ type: 'COLLECT_OTP' }); + }); + + it('submits the OTP against the charge’s active intent and marks it paid', async () => { + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(paymentClient.getIntentByReference).toHaveBeenCalledWith( + 'EXCESS_BAGGAGE', + CHARGE_ID, + ); + expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530'); + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ status: 'PAID' }), + }), + ); + expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false }); + }); + + it('leaves the charge unpaid when the OTP does not settle', async () => { + paymentClient.confirmOtp.mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + }); + + const result = await service.confirmOtp(TOKEN, '0000'); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 'REQUIRES_ACTION' }); + }); + + it('confirms an OTP even after the link TTL lapsed — the debit is already in flight', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + expiresAt: new Date(Date.now() - 60_000), + }); + + await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({ + status: 'SUCCEEDED', + }); + }); + + it('is idempotent once the charge is already paid', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + }); + + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(result).toMatchObject({ alreadyPaid: true }); + expect(paymentClient.confirmOtp).not.toHaveBeenCalled(); + }); +}); + +/** + * CBE bill payment is inbound-only: no provider session is opened, a bill reference is minted and + * the payer settles it at a branch/app hours later. The expiry handed to the payment service is + * therefore the charge's own deadline, never the 30-minute link TTL — a short one would have the + * reconciliation sweep kill the intent within the hour (CBE plan §6.4). + */ +describe('ExcessBaggageService — CBE bill', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + const THIRTY_MIN = 30 * 60 * 1000; + + let prisma: Record; + let paymentClient: { initiate: jest.Mock }; + let service: ExcessBaggageService; + let charge: any; + + beforeEach(() => { + charge = { + id: CHARGE_ID, + bookingId: 'booking-1', + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + // A freshly created charge: the short browser-session TTL. + expiresAt: new Date(Date.now() + THIRTY_MIN), + booking: { bookingRef: 'BAG-001' }, + }; + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue(charge), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + seats: [{ leg: 1, passengerName: 'Abebe Kebede' }], + passenger: { user: { fullName: 'Account Holder' } }, + }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CBE_BILL', currency: 'ETB' }), + }, + currencyExchangeRate: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }, + merchantOrderId: 'MO-1', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + const initiate = () => + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CBE_BILL, + platform: 'web', + } as any); + + it('extends the charge deadline past the 30-minute link TTL', async () => { + await initiate(); + + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ expiresAt: expect.any(Date) }), + }), + ); + const written = + prisma.excessBaggageCharge.update.mock.calls[0][0].data.expiresAt; + // Comfortably beyond the session TTL — a payer has to reach a branch. + expect(written.getTime()).toBeGreaterThan(Date.now() + 2 * THIRTY_MIN); + }); + + it('hands the payment service that deadline as the intent expiry, in ETB', async () => { + await initiate(); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(sent.currency).toBe('ETB'); + expect(sent.amountMinor).toBe(250); + expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan( + Date.now() + 2 * THIRTY_MIN, + ); + }); + + it('sends the lead passenger as Full_Name, which CBE requires', async () => { + await initiate(); + + expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe( + 'Abebe Kebede', + ); + }); + + it('never shortens a deadline the payer already has', async () => { + const farFuture = new Date(Date.now() + 90 * 60 * 60 * 1000); + charge.expiresAt = farFuture; + + await initiate(); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBe( + farFuture.toISOString(), + ); + }); + + it('returns the bill reference to the caller', async () => { + const result = await initiate(); + expect(result.clientAction).toMatchObject({ + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }); + }); + + it('reports a paid charge through getStatus without the payability gate', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + paidAt: new Date(), + }); + + // getByToken would throw "already paid" here; the poll must simply report it. + await expect(service.getStatus(TOKEN)).resolves.toMatchObject({ + status: 'PAID', + paid: true, + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 40a44f36b..b246566e4 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards, SetMetadata } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; import { LogExcessBaggageDto, WaiveChargeDto, InitiateExcessPaymentDto, + ConfirmExcessOtpDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { PassengerAdmin } from '../../common/passenger-guards'; @@ -118,6 +119,37 @@ export class ExcessBaggagePublicController { return this.service.getByToken(token); } + @Get('pay/:token/amount') + @ApiOperation({ + summary: 'Quote the charge in a payment method’s settlement currency', + description: + 'Returns what the given method would debit, converted from the charge’s stored ETB total ' + + 'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' + + 'wallets in ETB) at the latest exchange rate. The pay page quotes this before the payer ' + + 'commits; initiating a payment recomputes it identically.', + }) + @ApiQuery({ + name: 'method', + required: true, + example: 'WAAFI', + description: 'Payment method type the payer has selected', + }) + quoteAmount(@Param('token') token: string, @Query('method') method: string) { + return this.service.quoteAmount(token, method); + } + + @Get('pay/:token/status') + @ApiOperation({ + summary: 'Poll the charge’s settlement status (public)', + description: + 'Reports the charge’s current status without the payability gate on GET /pay/:token, so a ' + + 'page can watch for settlement. Used while a CBE bill is outstanding and after a redirect ' + + 'payment returns — both settle server-side, out of band from the browser.', + }) + getStatus(@Param('token') token: string) { + return this.service.getStatus(token); + } + @Post('pay/:token/initiate') @ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' }) initiatePayment( @@ -126,4 +158,18 @@ export class ExcessBaggagePublicController { ) { return this.service.initiatePayment(token, dto); } + + @Post('pay/:token/confirm') + @ApiOperation({ + summary: 'Confirm an OTP-debit excess baggage payment (CAC Bank)', + description: + 'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' + + '400 and the payment stays open for retry.', + }) + confirmOtp( + @Param('token') token: string, + @Body() dto: ConfirmExcessOtpDto, + ) { + return this.service.confirmOtp(token, dto.otp); + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index df0abd8be..4b06e906e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -20,7 +20,34 @@ export class WaiveChargeDto { } export class InitiateExcessPaymentDto { - @ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] }) + @ApiProperty({ + enum: [ + 'TELEBIRR', + 'CBE_BIRR', + 'EBIRR', + 'WAAFI', + 'DMONEY', + 'CARD', + 'CAC_BANK', + 'CBE_BILL', + ], + }) @IsString() method: string; @ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string; + @ApiPropertyOptional({ + description: + 'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' + + 'SMSes a one-time password to this number) and EBIRR (the wallet pushes a USSD PIN prompt ' + + 'to it). Normalised server-side by the payment service.', + example: '77123456', + }) + @IsOptional() @IsString() payerAccount?: string; +} + +export class ConfirmExcessOtpDto { + @ApiProperty({ + description: 'One-time password the payer received by SMS (CAC Bank).', + example: '4530', + }) + @IsString() otp: string; } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts index e734d4fb4..e9c648ca8 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts @@ -6,11 +6,18 @@ import { ExcessBaggagePublicController, } from './excess-baggage.controller'; import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { AuditModule } from '../../common/audit.module'; @Module({ - imports: [HttpModule, PaymentsModule, NotificationsModule, AuditModule], + imports: [ + HttpModule, + PaymentsModule, + CurrencyModule, + NotificationsModule, + AuditModule, + ], controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController], providers: [ExcessBaggageService], exports: [ExcessBaggageService], diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 32fc06c13..d70c823e5 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; +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'; @@ -25,6 +26,41 @@ 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); @@ -32,6 +68,7 @@ export class ExcessBaggageService { constructor( private prisma: PrismaService, private auditService: AuditService, + private currencyService: CurrencyService, private paymentClient: PaymentClientService, private notifications: NotificationsService, private smsClient: SmsClientService, @@ -165,21 +202,103 @@ export class ExcessBaggageService { 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: charge.totalMinor / 100, - currency: charge.currency, + // `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, }); @@ -196,6 +315,118 @@ export class ExcessBaggageService { }; } + /** + * 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'); diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index df5949bde..be46135a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentReferenceType } from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { PaymentEventDto, @@ -51,6 +52,12 @@ export class InternalPaymentsController { async billQuery( @Body() request: BillQueryRequestDto, ): Promise { + // Routed on referenceType: the passenger app issues CBE bills for bookings AND for excess + // baggage charges, and they live in different tables. Treating every referenceId as a + // bookingId would report a perfectly payable baggage bill as NOT_FOUND to the teller. + if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.paymentsService.billQueryExcessBaggage(request.referenceId); + } return this.paymentsService.billQuery(request.referenceId); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 9b94053b6..36b062261 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -46,6 +46,10 @@ describe("PaymentsService", () => { paymentMethod: { findUnique: jest.fn(), }, + excessBaggageCharge: { + findUnique: jest.fn(), + update: jest.fn(), + }, currencyExchangeRate: { findFirst: jest.fn(), }, @@ -562,4 +566,196 @@ describe("PaymentsService", () => { ); }); }); + + /** + * Excess baggage settles through the same outbox → RabbitMQ path as bookings. Before this + * existed the consumer dropped every EXCESS_BAGGAGE event as "foreign-reference", so a charge + * the payer had genuinely paid stayed PENDING until its TTL flipped it to EXPIRED. + */ + describe("handlePaymentEvent — excess baggage", () => { + const CHARGE_ID = "charge-1"; + + const succeededEvent = (overrides: Record = {}) => + ({ + eventId: "evt-1", + eventType: "payment.succeeded", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.EXCESS_BAGGAGE, + referenceId: CHARGE_ID, + amountMinor: 500, + currency: "ETB", + providerTxnId: "TXN-9", + ...overrides, + }) as any; + + it("marks a pending charge PAID", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ status: "PAID" }), + }), + ); + expect(result).toEqual({ processed: true }); + }); + + it("marks an EXPIRED charge PAID — the TTL governs starting a payment, not receiving one", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "EXPIRED", + }); + + await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("does not re-pay an already PAID charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PAID", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true, alreadyFinalized: true }); + }); + + it("accepts a foreign-currency settlement without a short-pay comparison", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + // 500.00 ETB charge settled as 1625 DJF — numerically unlike the stored total. + await service.handlePaymentEvent( + succeededEvent({ amountMinor: 1625, currency: "DJF" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("acks a failure event without touching the charge", async () => { + const result = await service.handlePaymentEvent( + succeededEvent({ eventType: "payment.failed" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true }); + }); + + it("acks an event for a charge that no longer exists", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(result).toEqual({ + processed: false, + reason: "charge-not-found", + }); + }); + }); + + /** + * The live hop CBE makes while a teller is on the line, for a baggage bill. This is the + * double-payment guard: anything other than stillPayable=true makes CBE refuse the debit. + */ + describe("billQueryExcessBaggage", () => { + const payable = { + id: "charge-1", + excessWeightKg: 7, + totalMinor: 25_000, + status: "PENDING", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + booking: { + bookingRef: "BAG-001", + seats: [{ leg: 1, passengerName: "Abebe Kebede" }], + passenger: { user: { fullName: "Account Holder" } }, + }, + }; + + it("reports a pending charge as payable, in ETB, with the passenger name", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(payable); + + const result = await service.billQueryExcessBaggage("charge-1"); + + expect(result).toMatchObject({ + stillPayable: true, + currency: "ETB", + currentAmountMinor: 250, + payerName: "Abebe Kebede", + }); + expect(result.paymentReason).toContain("BAG-001"); + }); + + it("refuses a charge already paid at the counter in cash", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "CASH_COLLECTED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ + stillPayable: false, + reason: "ALREADY_PAID", + }); + }); + + it("refuses a waived charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "WAIVED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "CANCELLED" }); + }); + + it("refuses a charge whose deadline has passed", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("refuses within the settle margin, so a debit cannot land after expiry", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() + 5_000), // inside the 60s margin + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("reports NOT_FOUND for a bill whose charge is gone", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" }); + }); + }); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5cd483830..1415c1dcc 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -533,6 +533,73 @@ export class PaymentsService { return { ...base, stillPayable: true, reason: null }; } + /** + * Bill-query for an excess baggage charge — the same live "still payable?" hop as bookings, + * against `ExcessBaggageCharge` instead. This is the double-payment guard for baggage bills: + * once the charge is paid, waived or lapsed, CBE is told to refuse the debit. + * + * The charge's own `expiresAt` is the deadline (extended to the CBE bill window when the bill + * was issued), so there is no separate schedule-derived deadline to compute as there is for a + * booking. + */ + async billQueryExcessBaggage( + chargeId: string, + ): Promise { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: chargeId }, + include: { + booking: { + include: { seats: true, passenger: { include: { user: true } } }, + }, + }, + }); + // A bill reference we issued whose charge has since been deleted — a data problem, not a + // customer-facing cancellation. + if (!charge) return { stillPayable: false, reason: "NOT_FOUND" }; + + const base = { + payerName: + charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ?? + charge.booking?.seats?.[0]?.passengerName ?? + charge.booking?.passenger?.user?.fullName ?? + null, + // The charge is always booked in ETB and CBE settles ETB only, so no conversion applies. + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + charge.totalMinor, + "ETB", + ), + currency: "ETB", + // Rendered beside the amount on CBE's confirmation screen. The weight and booking ref are + // both on the agent's slip, so the payer can match the two before confirming. + paymentReason: `Excess baggage ${charge.excessWeightKg}kg — booking ${ + charge.booking?.bookingRef ?? "" + }`.trim(), + }; + + // Paid first: a charge settled by any method (including cash at the counter) must be reported + // as already paid, never as merely "not payable". + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + // A supervisor wrote the charge off; from the payer's side the debt is gone. + if (charge.status === "WAIVED") { + return { ...base, stillPayable: false, reason: "CANCELLED" }; + } + // Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline + // that the sweep expires the intent before the capture is registered. + if ( + charge.status === "EXPIRED" || + charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < + Date.now() + ) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + if (charge.status !== "PENDING") { + return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; + } + return { ...base, stillPayable: true, reason: null }; + } + /** * The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's * origin-segment time and that stop's own check-in window, falling back to the route default. @@ -1396,6 +1463,77 @@ export class PaymentsService { return { processed: true }; } + /** + * Settlement for an excess baggage charge paid through the passenger portal link. + * + * Deliberately has NO short-payment amount guard, unlike the booking path: the charge is stored + * in ETB while `event.amountMinor` arrives in the provider's settlement currency (DJF for + * Waafi/D-Money/CAC, USD for card), so comparing the two directly would reject every legitimate + * cross-currency payment. The amount actually charged was computed server-side at initiate. + * + * An EXPIRED charge is still marked PAID. The link TTL only governs whether a NEW payment may be + * started; once a provider has captured the money the charge is paid, and leaving it EXPIRED + * would hide a real settlement from the agent who has to reconcile it. + */ + private async handleExcessBaggageChargeEvent( + event: PaymentEventDto, + ): Promise { + if (event.eventType === "payment.failed") { + this.logger.warn( + `excess baggage charge ${event.referenceId} payment failed`, + ); + return { processed: true }; + } + + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: event.referenceId }, + }); + if (!charge) { + // Ack — a missing charge will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no excess baggage charge for reference ${event.referenceId}`, + ); + return { processed: false, reason: "charge-not-found" }; + } + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { processed: true, alreadyFinalized: true }; + } + // Money arrived against a charge nobody expected to be paid — record it as PAID (that is the + // truth) but say so loudly: a waived charge that settles anyway needs a refund decision. + if (charge.status !== "PENDING") { + this.logger.warn( + `mark-paid: excess baggage charge ${charge.id} settled while ${charge.status} ` + + `(${event.amountMinor} ${event.currency}) — marking PAID; needs review`, + ); + } + + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { + status: "PAID", + // The provider's own capture time, not when this event happened to be processed — a + // replayed or dead-lettered event must not backdate the money to the wrong minute. + paidAt: event.paidAt ? new Date(event.paidAt) : new Date(), + }, + }); + await this.auditService.log({ + action: "UPDATE", + entityType: "ExcessBaggageCharge", + entityId: charge.id, + oldData: { status: charge.status }, + newData: { + status: "PAID", + providerTxnId: event.providerTxnId, + settledAmount: event.amountMinor, + settledCurrency: event.currency, + }, + }); + this.logger.log( + `excess baggage charge ${charge.id} marked PAID (${event.amountMinor} ${event.currency}, txn ${event.providerTxnId ?? "n/a"})`, + ); + return { processed: true }; + } + async handlePaymentEvent( event: PaymentEventDto, ): Promise { @@ -1410,6 +1548,10 @@ export class PaymentsService { return this.handleSupplementaryChargeEvent(event); } + if (event.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.handleExcessBaggageChargeEvent(event); + } + if (event.referenceType !== PaymentReferenceType.BOOKING) { this.logger.warn( `mark-paid: ignoring unknown referenceType ${event.referenceType}`, diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index dace45843..91b0a4f2f 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -130,11 +130,12 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), + asyncStub(), // auditService + asyncStub(), // currencyService + asyncStub(), // paymentClient + asyncStub(), // notifications + asyncStub(), // smsClient + asyncStub(), // emailClient ); const charge: any = await service.logCharge({ @@ -174,6 +175,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, asyncStub(), // auditService + asyncStub(), // currencyService asyncStub(), // paymentClient asyncStub(), // notifications asyncStub(), // smsClient diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx index 209651756..73976fcda 100644 --- a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx @@ -1,14 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { PaymentMethod } from "@/types"; import { AlertCircle, + Check, CheckCircle, + Copy, CreditCard, + KeyRound, Landmark, Loader2, Smartphone, @@ -22,6 +25,28 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; +// WALLET is an internal balance debit with no excess-baggage path — the API refuses it, so it is +// never offered here. +const UNSUPPORTED_METHODS = ["WALLET"]; + +// Push-debit methods charge an account we must know before initiating: 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 afterwards, so it is asked for up front. +const requiresPayerMobile = (method: string | null) => + method === "CAC_BANK" || method === "EBIRR"; + +// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding, +// so the quote renders exactly the figure the provider will debit. +const formatAmount = (amount: number, currency: string) => + amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2); + +interface AmountQuote { + chargeId: string; + method: string; + currency: string; + amount: number; +} + export default function ExcessBaggagePayPage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); @@ -29,6 +54,26 @@ export default function ExcessBaggagePayPage() { const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC — + // the OTP the bank SMSes to it. + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [payerMobile, setPayerMobile] = useState(""); + const [phoneError, setPhoneError] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(null); + const [pushMessage, setPushMessage] = useState(null); + + // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a + // branch/app later, so the page shows the number and watches for settlement. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ queryKey: ["excessBaggageCharge", token], queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`), @@ -45,24 +90,104 @@ export default function ExcessBaggagePayPage() { enabled: !!charge, }); - const amountDisplay = useMemo(() => { - const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0); - return (amountMinor / 100).toFixed(2); - }, [charge]); + const availableMethods = useMemo( + () => + paymentMethods.filter( + (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type), + ), + [paymentMethods], + ); - const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + // The charge is always booked in ETB; this is what it costs before a method is chosen. + const chargeCurrency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + const chargeAmount = useMemo( + () => Number(charge?.totalMinor ?? charge?.amountMinor ?? 0) / 100, + [charge], + ); + + // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets + // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the + // stored ETB total is not what a Djiboutian wallet would debit. + const { + data: quote, + isFetching: fetchingQuote, + error: quoteError, + } = useQuery({ + queryKey: ["excessBaggageAmount", token, selectedMethod], + queryFn: () => + apiClient.get( + `/excess-baggage/pay/${token}/amount?method=${selectedMethod}`, + ), + enabled: !!token && !!selectedMethod, + retry: false, + staleTime: 30_000, + }); + + // A quote is only usable once it belongs to the method currently selected — otherwise it is a + // leftover from the previous selection and would price the payment in the wrong currency. + const quoteReady = !fetchingQuote && quote?.method === selectedMethod; + + const displayCurrency = selectedMethod + ? (quote?.currency ?? "") + : chargeCurrency; + const displayAmount = selectedMethod ? quote?.amount : chargeAmount; + const amountLabel = + quoteReady && displayAmount != null + ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}` + : !selectedMethod && displayAmount != null + ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}` + : null; + + // Never let Pay fire against a price the payer has not been shown. + const awaitingQuote = !!selectedMethod && !quoteReady; const payMutation = useMutation({ - mutationFn: (method: string) => + mutationFn: (vars: { method: string; payerAccount?: string }) => apiClient.post(`/excess-baggage/pay/${token}/initiate`, { - method, + method: vars.method, platform: "web", + ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}), }), onSuccess: (data: any) => { - if (data?.clientAction?.type === "REDIRECT") { - window.location.href = data.clientAction.url; + const action = data?.clientAction; + + if (action?.type === "REDIRECT") { + window.location.href = action.url; return; } + + // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm. + if (action?.type === "COLLECT_OTP") { + setOtpMessage(action.message ?? "Enter the OTP sent to your phone"); + setOtpCode(""); + setOtpError(null); + setOtpModalOpen(true); + setIsProcessing(false); + return; + } + + // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number. + if (action?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: action.billReference, + instructions: action.instructions, + expiresAt: action.expiresAt, + }); + setBillCopied(false); + setIsProcessing(false); + return; + } + + // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to. + if (action?.type === "AWAIT_PUSH") { + setPushMessage( + action.message ?? + `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`, + ); + setIsProcessing(false); + return; + } + router.push(`/excess-baggage/pay/${token}/result`); }, onError: (err: any) => { @@ -71,11 +196,92 @@ export default function ExcessBaggagePayPage() { }, }); - const handlePay = () => { + // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP — + // keep the modal open so the payer can re-enter it (the intent stays open). + const otpMutation = useMutation({ + mutationFn: (otp: string) => + apiClient.post(`/excess-baggage/pay/${token}/confirm`, { otp }), + onSuccess: () => { + setOtpModalOpen(false); + router.push(`/excess-baggage/pay/${token}/result`); + }, + onError: (err: any) => { + setOtpError( + err?.response?.data?.message ?? + err?.message ?? + "Invalid or expired OTP. Please try again.", + ); + }, + }); + + const startPayment = (mobile?: string) => { if (!selectedMethod) return; setIsProcessing(true); setPaymentError(null); - payMutation.mutate(selectedMethod); + payMutation.mutate({ + method: selectedMethod, + payerAccount: requiresPayerMobile(selectedMethod) + ? mobile?.trim() + : undefined, + }); + }; + + const handlePay = () => { + if (!selectedMethod || awaitingQuote) return; + setPaymentError(null); + + if (requiresPayerMobile(selectedMethod)) { + // Prefill with the number the charge was raised against, but leave it editable — the + // handset paying is often not the one the booking was made under. + if (!payerMobile.trim() && charge?.contactPhone) { + setPayerMobile(charge.contactPhone); + } + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + + // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens + // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal. + // Success is only ever claimed from this, never from a client-side guess. + const watching = !!billAction || !!pushMessage; + const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({ + queryKey: ["excessBaggageStatus", token], + queryFn: () => + apiClient.get<{ status: string; paid: boolean }>( + `/excess-baggage/pay/${token}/status`, + ), + enabled: !!token && watching, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (watching && liveStatus?.paid) { + router.push(`/excess-baggage/pay/${token}/result`); + } + }, [watching, liveStatus?.paid, router, token]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } }; if (loadingCharge) { @@ -112,10 +318,25 @@ export default function ExcessBaggagePayPage() {
Amount due - - {currency} {amountDisplay} - + {amountLabel ? ( + {amountLabel} + ) : quoteError ? ( + + ) : ( + + )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && ( +

+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate +

+ )} + {quoteError && ( +

+ {(quoteError as any)?.response?.data?.message ?? + "This payment method is unavailable right now. Please choose another."} +

+ )}
Weight {charge.excessWeightKg ?? "—"} kg @@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( @@ -166,17 +387,181 @@ export default function ExcessBaggagePayPage() { + + {/* CBE bill — show the number; confirmation only ever comes from the status poll */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount: ETB {formatAmount(chargeAmount, "ETB")} +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {new Date(billAction.expiresAt).toLocaleString()} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + + {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */} + {pushMessage && ( +
+ +
+

Check your phone

+

{pushMessage}

+
+
+ )} + + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ {selectedMethod === "EBIRR" + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."} +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }} + placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"} + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

{otpMessage}

+ { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }} + placeholder="Enter code" + maxLength={10} + className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {otpError && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )}
);