fix: ( supplementary-charges ) pay in the selected method's currency, add CAC Bank and CBE

This commit is contained in:
Abubeker Yasin
2026-08-18 10:16:59 +03:00
parent fa16087a4a
commit a9eac93fa3
7 changed files with 1047 additions and 17 deletions

View File

@@ -58,6 +58,11 @@ export class InternalPaymentsController {
if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) {
return this.paymentsService.billQueryExcessBaggage(request.referenceId);
}
if (request.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
return this.paymentsService.billQuerySupplementaryCharge(
request.referenceId,
);
}
return this.paymentsService.billQuery(request.referenceId);
}
}

View File

@@ -57,6 +57,18 @@ class WaiveSupplementaryChargeDto {
class PaySupplementaryChargeDto {
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto;
@ApiPropertyOptional({
description:
'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' +
'SMSes a one-time password to it) and EBIRR (the wallet pushes a USSD PIN prompt to it).',
example: '77123456',
})
@IsOptional() @IsString() payerAccount?: string;
}
class ConfirmSupplementaryOtpDto {
@ApiProperty({ description: 'One-time password the payer received by SMS (CAC Bank).', example: '4530' })
@IsString() otp: string;
}
@ApiTags("Payment")
@@ -413,6 +425,52 @@ export class PaymentsController {
return this.supplementaryService.getByToken(token);
}
@Get('supplementary/by-token/:token/amount')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'Quote a supplementary charge in a payment methods settlement currency (public)',
description:
'Returns what the given method would debit, converted from the charges stored ETB amount ' +
'to that methods settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' +
'wallets in ETB). The self-pay page quotes this before the payer commits; paying recomputes ' +
'it identically.',
})
@ApiQuery({ name: 'method', required: true, example: 'WAAFI' })
quoteSupplementaryAmount(
@Param('token') token: string,
@Query('method') method: string,
) {
return this.supplementaryService.quoteAmount(token, method);
}
@Get('supplementary/by-token/:token/status')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'Poll a supplementary charges settlement status (public)',
description:
'Reports the charges current status without the payability gate on the by-token lookup, ' +
'so a page can watch for settlement that happens out of band (a CBE bill paid at a branch, ' +
'or a redirect payment confirmed by webhook).',
})
getSupplementaryStatus(@Param('token') token: string) {
return this.supplementaryService.getStatus(token);
}
@Post('supplementary/by-token/:token/confirm')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'Confirm an OTP-debit balance payment (CAC Bank, public — self-pay)',
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.',
})
confirmSupplementaryOtp(
@Param('token') token: string,
@Body() dto: ConfirmSupplementaryOtpDto,
) {
return this.supplementaryService.confirmOtp(token, dto.otp);
}
@Post('supplementary/by-token/:token/pay')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
@@ -430,6 +488,7 @@ export class PaymentsController {
dto.method,
dto.platform,
resolveAllowedOrigin(origin, referer, frontendBaseUrl),
dto.payerAccount,
);
}

View File

@@ -50,6 +50,10 @@ describe("PaymentsService", () => {
findUnique: jest.fn(),
update: jest.fn(),
},
supplementaryCharge: {
findUnique: jest.fn(),
update: jest.fn(),
},
currencyExchangeRate: {
findFirst: jest.fn(),
},
@@ -758,4 +762,76 @@ describe("PaymentsService", () => {
).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" });
});
});
/**
* The live hop CBE makes while a teller is on the line, for a balance bill. Same
* double-payment guard as bookings and baggage: anything other than stillPayable=true makes
* CBE refuse the debit.
*/
describe("billQuerySupplementaryCharge", () => {
const payable = {
id: "sc-1",
amountMinor: 100_000,
status: "PENDING",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
booking: {
bookingRef: "BAL-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.supplementaryCharge.findUnique.mockResolvedValue(payable);
const result = await service.billQuerySupplementaryCharge("sc-1");
expect(result).toMatchObject({
stillPayable: true,
currency: "ETB",
currentAmountMinor: 1000,
payerName: "Abebe Kebede",
});
expect(result.paymentReason).toContain("BAL-001");
});
it("refuses an already paid charge", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "PAID" });
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: false,
reason: "ALREADY_PAID",
});
});
it("refuses a waived charge", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "WAIVED" });
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: false,
reason: "CANCELLED",
});
});
it("refuses within the settle margin so a debit cannot land after expiry", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({
...payable,
expiresAt: new Date(Date.now() + 5_000),
});
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: false,
reason: "EXPIRED",
});
});
it("treats a null expiry as an open-ended debt, still payable", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, expiresAt: null });
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: true,
});
});
it("reports NOT_FOUND for a bill whose charge is gone", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(null);
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toEqual({
stillPayable: false,
reason: "NOT_FOUND",
});
});
});
});

View File

@@ -600,6 +600,72 @@ export class PaymentsService {
return { ...base, stillPayable: true, reason: null };
}
/**
* Bill-query for a supplementary charge — the same live "still payable?" hop as bookings,
* against `SupplementaryCharge`. This is the double-payment guard for balance bills: once the
* charge is paid, waived or lapsed, CBE is told to refuse the debit.
*
* The charge's own 72-hour `expiresAt` is the deadline. It is nullable — a charge raised with
* no expiry is an open-ended debt and stays payable indefinitely, which is the intended reading
* of a null here rather than an immediate refusal.
*/
async billQuerySupplementaryCharge(
chargeId: string,
): Promise<BillQueryResponseDto> {
const charge = await this.prisma.supplementaryCharge.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 raised in ETB and CBE settles ETB only, so no conversion applies.
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
charge.amountMinor,
"ETB",
),
currency: "ETB",
// Rendered beside the amount on CBE's confirmation screen. The booking ref is on the
// passenger's ticket, so they can match the two before confirming.
paymentReason: `Outstanding balance — booking ${
charge.booking?.bookingRef ?? ""
}`.trim(),
};
if (charge.status === "PAID") {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
// Staff wrote the balance 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 &&
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.

View File

@@ -4,11 +4,35 @@ import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
import { CurrencyService } from '../currency/currency.service';
import {
PaymentReferenceType,
PaymentService as PaymentServiceEnum,
ProviderMethod,
ProviderPaymentStatus,
} from '@edr/types';
import { PaymentPlatformDto } from './payments.dto';
import { PaymentMethodType } from '@prisma/client';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
/**
* WALLET is an internal balance debit handled inside this app, not a provider — the payment
* microservice rejects it as one. Supplementary charges have no wallet path, so it is refused up
* front with a message the payer can act on rather than a 502 from the gateway layer.
*/
const UNSUPPORTED_METHODS = new Set<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 (mirrors PaymentsService and ExcessBaggageService).
*/
const METHODS_REQUIRING_PAYER_ACCOUNT = new Set<string>([
PaymentMethodType.CAC_BANK,
PaymentMethodType.EBIRR,
]);
@Injectable()
export class SupplementaryChargesService {
private readonly logger = new Logger(SupplementaryChargesService.name);
@@ -19,6 +43,7 @@ export class SupplementaryChargesService {
private smsClient: SmsClientService,
private emailClient: EmailClientService,
private paymentClient: PaymentClientService,
private currencyService: CurrencyService,
) {}
async create(dto: {
@@ -117,14 +142,161 @@ export class SupplementaryChargesService {
return updated;
}
/**
* What the payer is actually charged when settling this charge with `method`.
*
* The charge is raised in ETB, but the selected method settles in its own currency — WAAFI and
* D-Money in DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row.
* The payment microservice is currency-agnostic and hands whatever it is given straight to the
* gateway, so the ETB->settlement conversion has to happen here or the provider is asked to
* debit an ETB number labelled as its own currency.
*
* Both the quote shown to the payer and the amount sent to the provider come through this one
* method, so the price on the button and the price debited cannot drift apart.
*/
private async resolveChargeAmount(
charge: { amountMinor: number; currency: string },
method: string,
): Promise<{ amount: number; currency: string }> {
if (UNSUPPORTED_METHODS.has(method)) {
throw new BadRequestException(
`${method} is not available for balance payments`,
);
}
const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method as PaymentMethodType },
});
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted.
const chargeCurrency =
method === PaymentMethodType.CBE_BILL
? 'ETB'
: (paymentMethod?.currency ?? charge.currency).toUpperCase();
// Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents.
const amount = await this.currencyService.convertMinorToChargeMajor(
charge.amountMinor,
charge.currency,
chargeCurrency,
);
return { amount, currency: chargeCurrency };
}
/**
* Price quote for the pay page: what `method` would debit, in that method's settlement
* currency. The payer sees this before committing, and pay() recomputes it the same way.
*/
async quoteAmount(token: string, method: string) {
const charge = await this.getByToken(token);
const { amount, currency } = await this.resolveChargeAmount(charge, method);
return { chargeId: charge.id, method, currency, amount };
}
/**
* Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid,
* expired or waived charge — reporting those states is the entire point. A CBE bill can settle
* long after the payer closed the tab, and redirect methods only converge when the settlement
* event lands, so the page needs something it can watch.
*/
async getStatus(token: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
select: {
id: true,
status: true,
paidAt: true,
amountMinor: true,
currency: true,
expiresAt: true,
},
});
if (!charge) throw new NotFoundException('Payment link not found');
return {
chargeId: charge.id,
status: charge.status,
paid: charge.status === 'PAID',
paidAt: charge.paidAt,
amountMinor: charge.amountMinor,
currency: charge.currency,
expiresAt: charge.expiresAt,
};
}
/**
* Full_Name for CBE's confirmation screen — mandatory in its envelope. The traveller the balance
* is owed against: lead passenger on the booking, falling back to the account holder.
*/
private async resolvePayerName(bookingId: string): Promise<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
);
}
/**
* Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the
* payerAccount given at pay(); this forwards it to the payment service and marks the charge paid
* when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays open,
* so the payer can simply re-enter it.
*
* Deliberately reads the charge directly rather than through getByToken: the bank is already
* holding a debit against this payer, and refusing to submit their OTP because the link TTL
* lapsed while they read the SMS would strand a payment that is mid-flight.
*/
async confirmOtp(token: string, otp: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'PAID') {
return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true };
}
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SUPPLEMENTARY_CHARGE,
charge.id,
);
if (!snapshot) {
throw new NotFoundException('No active payment to confirm for this charge');
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markPaid(charge.id, confirmed.providerTxnId);
}
return {
chargeId: charge.id,
status: confirmed.status,
alreadyPaid: false,
};
}
async pay(
token: string,
method: string,
platform?: PaymentPlatformDto,
requestOrigin?: string | null,
payerAccount?: string,
) {
const charge = await this.getByToken(token); // validates status/expiry
if (METHODS_REQUIRING_PAYER_ACCOUNT.has(method) && !payerAccount?.trim()) {
throw new BadRequestException(
`payerAccount (mobile number) is required for ${method}`,
);
}
const paymentMethod = method as ProviderMethod;
// Self-pay links are opened on whichever portal domain the recipient used
// (bookingedr.et vs passenger.edrsc.com), so the return pages must live on
@@ -135,15 +307,38 @@ export class SupplementaryChargesService {
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
const { amount, currency } = await this.resolveChargeAmount(charge, method);
// CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's
// system until someone pays it. It needs a real deadline and a payer name (Full_Name is
// mandatory in CBE's envelope) rather than the redirect flow's session semantics.
//
// Unlike an excess baggage charge (30-minute link TTL), this charge already carries a 72-hour
// deadline of its own, which is a sane bill lifetime — so it is passed straight through with
// no extension. That deadline is what stops the reconciliation sweep from expiring the intent
// early (CBE_IMPLEMENTATION_PLAN.md §6.4). A charge with no expiry at all yields no intent
// expiry either, which is correct: an open-ended debt backs an open-ended bill.
let payerName: string | undefined;
let expiresAt: string | undefined;
if (method === PaymentMethodType.CBE_BILL) {
expiresAt = charge.expiresAt?.toISOString();
payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined;
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
referenceId: charge.id,
orderRef: `SC-${charge.id.substring(0, 8)}`,
amountMinor: charge.amountMinor / 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: paymentMethod,
platform,
payerAccount: payerAccount?.trim() || undefined,
payerName,
expiresAt,
returnUrl,
failureUrl,
});

View File

@@ -0,0 +1,245 @@
import { BadRequestException } from '@nestjs/common';
import { PaymentMethodType } from '@prisma/client';
import { SupplementaryChargesService } from './supplementary-charges.service';
import { CurrencyService } from '../currency/currency.service';
/**
* A supplementary charge is raised 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, plus the two methods that could not complete at all before: CAC Bank
* (OTP debit) and CBE (inbound bill).
*/
describe('SupplementaryChargesService — payment methods', () => {
const CHARGE_ID = 'sc-1';
const TOKEN = 'tok-1';
let prisma: Record<string, any>;
let paymentClient: Record<string, jest.Mock>;
let service: SupplementaryChargesService;
let charge: any;
const build = (rate?: { rate: number }) => {
charge = {
id: CHARGE_ID,
bookingId: 'booking-1',
amountMinor: 100_000,
currency: 'ETB',
status: 'PENDING',
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
booking: { bookingRef: 'BAL-001' },
};
prisma = {
supplementaryCharge: {
findUnique: jest.fn().mockResolvedValue(charge),
update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }),
},
booking: {
findUnique: jest.fn().mockResolvedValue({
seats: [{ leg: 1, passengerName: 'Abebe Kebede' }],
passenger: { user: { fullName: 'Account Holder' } },
}),
},
paymentMethod: { findUnique: jest.fn() },
currencyExchangeRate: {
findFirst: jest.fn().mockResolvedValue(rate ?? null),
},
};
paymentClient = {
initiate: jest.fn().mockResolvedValue({
intentId: 'intent-1',
status: 'REQUIRES_ACTION',
clientAction: { type: 'REDIRECT', url: 'https://gw.test/pay' },
}),
getIntentByReference: jest
.fn()
.mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }),
confirmOtp: jest.fn().mockResolvedValue({
intentId: 'intent-1',
status: 'SUCCEEDED',
providerTxnId: 'CAC-77',
}),
};
service = new SupplementaryChargesService(
prisma as any,
{ log: jest.fn() } as any,
{} as any,
{} as any,
paymentClient as any,
new CurrencyService(prisma as any),
);
};
const withMethod = (type: string, currency: string) =>
prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency });
describe('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: 1000 });
expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled();
});
it('converts to DJF and rounds to whole francs', async () => {
build({ rate: 3.25 });
withMethod(PaymentMethodType.DMONEY, 'DJF');
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.DMONEY);
expect(quote).toMatchObject({ currency: 'DJF', amount: 3250 });
expect(Number.isInteger(quote.amount)).toBe(true);
});
it('sends the provider the converted amount, not the stored ETB total', async () => {
build({ rate: 0.018 });
withMethod(PaymentMethodType.CARD, 'USD');
await service.pay(TOKEN, PaymentMethodType.CARD, 'web' as any, null);
expect(paymentClient.initiate).toHaveBeenCalledWith(
expect.objectContaining({
referenceType: 'SUPPLEMENTARY_CHARGE',
amountMinor: 18,
currency: 'USD',
}),
);
});
it('quotes and charges the same figure', async () => {
build({ rate: 3.25 });
withMethod(PaymentMethodType.WAAFI, 'DJF');
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI);
await service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null);
const sent = paymentClient.initiate.mock.calls[0][0];
expect(quote.amount).toBe(sent.amountMinor);
expect(quote.currency).toBe(sent.currency);
});
it('refuses WALLET, which has no supplementary-charge path', async () => {
build();
await expect(
service.quoteAmount(TOKEN, PaymentMethodType.WALLET),
).rejects.toBeInstanceOf(BadRequestException);
expect(paymentClient.initiate).not.toHaveBeenCalled();
});
it('fails closed when no exchange rate is configured', async () => {
build();
withMethod(PaymentMethodType.WAAFI, 'DJF');
await expect(
service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null),
).rejects.toBeInstanceOf(BadRequestException);
expect(paymentClient.initiate).not.toHaveBeenCalled();
});
});
describe('CAC Bank OTP debit', () => {
beforeEach(() => {
build({ rate: 3.25 });
withMethod(PaymentMethodType.CAC_BANK, 'DJF');
});
it('rejects pay() without a payer mobile', async () => {
await expect(
service.pay(TOKEN, PaymentMethodType.CAC_BANK, 'web' as any, null),
).rejects.toBeInstanceOf(BadRequestException);
expect(paymentClient.initiate).not.toHaveBeenCalled();
});
it('forwards the trimmed payer mobile', async () => {
await service.pay(
TOKEN,
PaymentMethodType.CAC_BANK,
'web' as any,
null,
' 77123456 ',
);
expect(paymentClient.initiate).toHaveBeenCalledWith(
expect.objectContaining({ payerAccount: '77123456', currency: 'DJF' }),
);
});
it('submits the OTP against the active intent and marks the charge paid', async () => {
const result = await service.confirmOtp(TOKEN, '4530');
expect(paymentClient.getIntentByReference).toHaveBeenCalledWith(
'SUPPLEMENTARY_CHARGE',
CHARGE_ID,
);
expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530');
expect(prisma.supplementaryCharge.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'PAID',
providerTxnId: 'CAC-77',
}),
}),
);
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',
});
await service.confirmOtp(TOKEN, '0000');
expect(prisma.supplementaryCharge.update).not.toHaveBeenCalled();
});
it('is idempotent once already paid', async () => {
prisma.supplementaryCharge.findUnique.mockResolvedValue({
...charge,
status: 'PAID',
});
await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({
alreadyPaid: true,
});
expect(paymentClient.confirmOtp).not.toHaveBeenCalled();
});
});
describe('CBE bill', () => {
beforeEach(() => {
build({ rate: 3.25 });
withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win
});
it('forces ETB regardless of the PaymentMethod row', async () => {
const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL);
expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 });
});
it('passes the charge own 72h deadline as the intent expiry', async () => {
await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null);
const sent = paymentClient.initiate.mock.calls[0][0];
expect(sent.currency).toBe('ETB');
expect(sent.expiresAt).toBe(charge.expiresAt.toISOString());
// Comfortably longer than a browser-session TTL, so the sweep cannot kill the bill early.
expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan(
Date.now() + 24 * 60 * 60 * 1000,
);
});
it('sends the lead passenger as Full_Name, which CBE requires', async () => {
await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null);
expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe(
'Abebe Kebede',
);
});
it('leaves the intent expiry unset for an open-ended charge', async () => {
charge.expiresAt = null;
await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null);
expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBeUndefined();
});
it('reports a paid charge through getStatus without the payability gate', async () => {
prisma.supplementaryCharge.findUnique.mockResolvedValue({
...charge,
status: 'PAID',
paidAt: new Date(),
});
await expect(service.getStatus(TOKEN)).resolves.toMatchObject({
status: 'PAID',
paid: true,
});
});
});
});