mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
251 lines
9.1 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|
|
});
|