mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1322 from Tria-plc/alpha
fix: ( excess-baggage ) pay in the selected method's currency and rec…
This commit is contained in:
@@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string>([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<string>([
|
||||
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<Date> {
|
||||
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<string | null> {
|
||||
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');
|
||||
|
||||
@@ -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<BillQueryResponseDto> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, any> = {}) =>
|
||||
({
|
||||
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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,6 +544,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<BillQueryResponseDto> {
|
||||
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.
|
||||
@@ -1407,6 +1474,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<MarkPaidResponseDto> {
|
||||
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<MarkPaidResponseDto> {
|
||||
@@ -1421,6 +1559,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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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<string | null>(null);
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpMessage, setOtpMessage] = useState<string | null>(null);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [pushMessage, setPushMessage] = useState<string | null>(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<any>(`/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<AmountQuote>({
|
||||
queryKey: ["excessBaggageAmount", token, selectedMethod],
|
||||
queryFn: () =>
|
||||
apiClient.get<AmountQuote>(
|
||||
`/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<any>(`/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<any>(`/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() {
|
||||
<div className="card space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Amount due</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{currency} {amountDisplay}
|
||||
</span>
|
||||
{amountLabel ? (
|
||||
<span className="text-2xl font-bold text-primary">{amountLabel}</span>
|
||||
) : quoteError ? (
|
||||
<span className="text-2xl font-bold text-gray-400">—</span>
|
||||
) : (
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
{selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate
|
||||
</p>
|
||||
)}
|
||||
{quoteError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||
{(quoteError as any)?.response?.data?.message ??
|
||||
"This payment method is unavailable right now. Please choose another."}
|
||||
</p>
|
||||
)}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Weight</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{charge.excessWeightKg ?? "—"} kg</span>
|
||||
@@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paymentMethods.filter((m) => m.enabled).map((method) => {
|
||||
{availableMethods.map((method) => {
|
||||
const Icon = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
@@ -166,18 +387,182 @@ export default function ExcessBaggagePayPage() {
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
disabled={!selectedMethod || isProcessing || awaitingQuote}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : quoteError ? (
|
||||
"Choose another payment method"
|
||||
) : awaitingQuote ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${currency} ${amountDisplay}`
|
||||
`Pay ${amountLabel ?? ""}`.trim()
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* CBE bill — show the number; confirmation only ever comes from the status poll */}
|
||||
{billAction && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{billAction.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
|
||||
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
|
||||
{billAction.billReference}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyBillReference}
|
||||
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
|
||||
title="Copy bill number"
|
||||
>
|
||||
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
{billCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
|
||||
<p>
|
||||
Amount: <span className="font-semibold">ETB {formatAmount(chargeAmount, "ETB")}</span>
|
||||
</p>
|
||||
{billAction.expiresAt && (
|
||||
<p>
|
||||
Pay before:{" "}
|
||||
<span className="font-semibold">
|
||||
{new Date(billAction.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
Waiting for payment confirmation — this page updates automatically once CBE
|
||||
confirms your payment.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBillAction(null)}
|
||||
className="btn-secondary w-full py-2.5 mt-4"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
|
||||
{pushMessage && (
|
||||
<div className="card flex items-start gap-3">
|
||||
<Smartphone className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">Check your phone</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{pushMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
|
||||
{phoneModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Smartphone className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{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."}
|
||||
</p>
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={payerMobile}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {phoneError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setPhoneModalOpen(false)}
|
||||
className="btn-secondary flex-1 py-2.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submitPhone}
|
||||
disabled={!payerMobile.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CAC Bank OTP entry */}
|
||||
{otpModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{otpMessage}</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={otpCode}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {otpError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setOtpModalOpen(false)}
|
||||
disabled={otpMutation.isPending}
|
||||
className="btn-secondary flex-1 py-2.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
|
||||
disabled={otpMutation.isPending || !otpCode.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{otpMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
|
||||
</span>
|
||||
) : (
|
||||
"Confirm payment"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user