Files
edr-platform/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts

251 lines
9.1 KiB
TypeScript

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' }),
// markPaid claims the PAID transition conditionally so it cannot double-log with the
// webhook path; count: 1 means this caller won.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
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),
{ emit: jest.fn() } 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.updateMany).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();
expect(prisma.supplementaryCharge.updateMany).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,
});
});
});
});