fix: ( excess-baggage ) pay in the selected method's currency and record settlement

This commit is contained in:
Abubeker Yasin
2026-08-17 15:33:43 +03:00
parent 41080c1650
commit fa16087a4a
10 changed files with 1522 additions and 28 deletions

View File

@@ -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 charges 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,
});
});
});

View File

@@ -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 methods settlement currency',
description:
'Returns what the given method would debit, converted from the charges stored ETB total ' +
'to that methods 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 charges settlement status (public)',
description:
'Reports the charges 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);
}
}

View File

@@ -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;
}

View File

@@ -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],

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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" });
});
});
});

View File

@@ -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<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.
@@ -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<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> {
@@ -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}`,

View File

@@ -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