- src/payments: provider registry and PaymentProvider interface (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund, listTransactions as capabilities), Yoco as the first provider. All four direct Yoco checkout calls go through payments.createCheckout. - Webhook split into the provider part (signature, parsing) and shared processing; same URL (/api/webhooks/yoco), same behaviour. - YocoTransaction renamed in place to ProviderTransaction (+ provider, unique per provider + externalId); Payment.provider added and backfilled to "yoco". Payment.method stays the wallet type so cashups are unchanged. - Site settings → Payments: Enabled toggle and order per provider; Yoco's settings shown while it's enabled. R15 minimum for all online payments. - New endpoint names (/api/payments/checkout, /api/provider-transactions); the old /yoco-checkout and /yoco-transactions paths still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
11 KiB
JavaScript
200 lines
11 KiB
JavaScript
jest.mock('../src/config/db', () => ({
|
|
appSetting: { findMany: jest.fn() },
|
|
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
|
|
payment: { findFirst: jest.fn(), create: jest.fn() },
|
|
providerTransaction: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
|
|
ticket: { findMany: jest.fn() },
|
|
user: { findFirst: jest.fn() },
|
|
}));
|
|
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
|
|
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
|
|
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
|
|
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
|
|
|
|
const prisma = require('../src/config/db');
|
|
const settingsCache = require('../src/utils/settingsCache');
|
|
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
|
|
const payments = require('../src/payments');
|
|
const yoco = require('../src/payments/providers/yoco');
|
|
const { handleProviderWebhook } = require('../src/payments/webhook');
|
|
|
|
const SECRET = 'whsec_' + Buffer.from('test-webhook-secret').toString('base64');
|
|
|
|
function settings(values) {
|
|
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
|
|
settingsCache.invalidate();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
delete process.env.YOCO_SECRET_KEY;
|
|
delete process.env.YOCO_WEBHOOK_SECRET;
|
|
});
|
|
|
|
describe('provider settings', () => {
|
|
test('Yoco is on by default (it was the only provider)', () => {
|
|
expect(payments.parseProviderSettings('', ['yoco', 'payfast'])).toEqual([
|
|
{ id: 'yoco', enabled: true }, { id: 'payfast', enabled: false },
|
|
]);
|
|
});
|
|
|
|
test('stored order and flags win; unknown ids are dropped, new ones come last', () => {
|
|
const raw = JSON.stringify({ order: ['payfast', 'gone', 'yoco'], enabled: { yoco: false, payfast: true } });
|
|
expect(payments.parseProviderSettings(raw, ['yoco', 'payfast', 'snapscan'])).toEqual([
|
|
{ id: 'payfast', enabled: true }, { id: 'yoco', enabled: false }, { id: 'snapscan', enabled: false },
|
|
]);
|
|
});
|
|
|
|
test('bad JSON falls back to the defaults', () => {
|
|
expect(payments.parseProviderSettings('{nope', ['yoco'])).toEqual([{ id: 'yoco', enabled: true }]);
|
|
});
|
|
|
|
test('serialising keeps the order and drops unknown providers', () => {
|
|
expect(JSON.parse(payments.serializeProviderSettings([{ id: 'yoco', enabled: false }, { id: 'zzz', enabled: true }])))
|
|
.toEqual({ order: ['yoco'], enabled: { yoco: false } });
|
|
});
|
|
});
|
|
|
|
describe('createCheckout', () => {
|
|
test('online payments must be at least R15', async () => {
|
|
await expect(payments.createCheckout(null, { amountCents: 1499 })).rejects.toThrow(/at least R15/);
|
|
});
|
|
|
|
test('nothing enabled: online payments are off', async () => {
|
|
settings({ payment_providers: JSON.stringify({ order: ['yoco'], enabled: { yoco: false } }) });
|
|
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/turned off/) });
|
|
});
|
|
|
|
test('asking for a provider that is switched off is refused', async () => {
|
|
settings({ payment_providers: JSON.stringify({ order: ['yoco'], enabled: { yoco: false } }) });
|
|
await expect(payments.createCheckout('yoco', { amountCents: 5000 })).rejects.toThrow(/Yoco is turned off/);
|
|
});
|
|
|
|
test('enabled but no key: the provider explains what is missing', async () => {
|
|
settings({});
|
|
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toThrow(/Yoco secret key/);
|
|
});
|
|
|
|
test('uses the first available provider and passes ZAR', async () => {
|
|
settings({ yoco_secret_key: 'sk_test_abc' });
|
|
const spy = jest.spyOn(yoco, 'createCheckout').mockResolvedValue({ checkoutId: 'ch_1', redirectUrl: 'https://pay/1' });
|
|
const out = await payments.createCheckout(null, { amountCents: 5000, metadata: { a: 1 } });
|
|
expect(out).toEqual({ provider: 'yoco', checkoutId: 'ch_1', redirectUrl: 'https://pay/1' });
|
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 5000, currency: 'ZAR', metadata: { a: 1 } }));
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Yoco provider', () => {
|
|
const now = 1_800_000_000_000;
|
|
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_1', amount: 12000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_1', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r1' } } });
|
|
const signed = (overrides = {}) => {
|
|
const ts = String(Math.floor(now / 1000));
|
|
return { headers: { 'webhook-id': 'msg_1', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_1', ts, body)}`, ...overrides }, rawBody: body };
|
|
};
|
|
|
|
test('line items map to Yoco cents, deductions stay negative', () => {
|
|
expect(yoco.toYocoLineItems([{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }])).toEqual([
|
|
{ displayName: 'Ticket', quantity: 2, pricingDetails: { price: 5000 } },
|
|
{ displayName: 'Coupon', quantity: 1, pricingDetails: { price: -1000 } },
|
|
]);
|
|
});
|
|
|
|
test('a correctly signed webhook verifies', async () => {
|
|
settings({ yoco_webhook_secret: '' });
|
|
process.env.YOCO_WEBHOOK_SECRET = SECRET;
|
|
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: true, webhookId: 'msg_1' });
|
|
});
|
|
|
|
test('any of several signatures may match (key rotation)', async () => {
|
|
process.env.YOCO_WEBHOOK_SECRET = SECRET;
|
|
settings({});
|
|
const req = signed();
|
|
req.headers['webhook-signature'] = `v1,bm9wZQ== ${req.headers['webhook-signature']}`;
|
|
await expect(yoco.verifyWebhook(req, now)).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
test('bad signature, old timestamp, missing headers, no secret', async () => {
|
|
process.env.YOCO_WEBHOOK_SECRET = SECRET;
|
|
settings({});
|
|
await expect(yoco.verifyWebhook(signed({ 'webhook-signature': 'v1,bm9wZQ==' }), now)).resolves.toMatchObject({ ok: false, status: 403 });
|
|
await expect(yoco.verifyWebhook(signed(), now + 4 * 60 * 1000)).resolves.toMatchObject({ ok: false, status: 400, message: expect.stringMatching(/timestamp/) });
|
|
await expect(yoco.verifyWebhook({ headers: {}, rawBody: body }, now)).resolves.toMatchObject({ ok: false, status: 400, details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'] } });
|
|
delete process.env.YOCO_WEBHOOK_SECRET;
|
|
settings({});
|
|
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: false, status: 500 });
|
|
});
|
|
|
|
test('parses a payment.succeeded body', () => {
|
|
expect(yoco.parseWebhook(JSON.parse(body))).toEqual({
|
|
type: 'payment.succeeded',
|
|
transaction: {
|
|
externalId: 'p_1', amountCents: 12000, currency: 'ZAR', createdDate: null, checkoutId: 'ch_1',
|
|
methodType: 'card', metadata: { registrationId: 'r1' }, status: 'succeeded',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('webhook flow', () => {
|
|
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_9', amount: 15000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_9', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r9', userId: 'u9', donationAmount: '50.00' } } });
|
|
const request = () => {
|
|
const ts = String(Math.floor(Date.now() / 1000));
|
|
return { headers: { 'webhook-id': 'msg_9', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_9', ts, body)}` }, rawBody: body };
|
|
};
|
|
const response = () => {
|
|
const res = { statusCode: 200, body: null };
|
|
res.status = (c) => { res.statusCode = c; return res; };
|
|
res.json = (b) => { res.body = b; return res; };
|
|
return res;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
process.env.YOCO_WEBHOOK_SECRET = SECRET;
|
|
settings({});
|
|
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Camp' }, user: { id: 'u9', name: 'T' } });
|
|
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', registrationOptions: [], payments: [{ amount: 100 }] });
|
|
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', userId: 'u9', eventId: 'e9', ...data }));
|
|
computeRegistrationTotalDue.mockReturnValue(100);
|
|
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
|
|
prisma.providerTransaction.findUnique.mockResolvedValue(null);
|
|
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
|
|
prisma.ticket.findMany.mockResolvedValue([]);
|
|
});
|
|
|
|
test('records the payment (donation split off), tagged with the provider, and a reconciled transaction', async () => {
|
|
prisma.payment.findFirst.mockResolvedValue(null);
|
|
const res = response();
|
|
await handleProviderWebhook('yoco')(request(), res);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.data.processedData.registration).toMatchObject({ id: 'r9', status: 'paid' });
|
|
const [main, donation] = prisma.payment.create.mock.calls.map(c => c[0].data);
|
|
expect(main).toMatchObject({ amount: 100, method: 'card', provider: 'yoco', externalId: 'p_9', registrationId: 'r9', isDonation: false, status: 'completed' });
|
|
expect(donation).toMatchObject({ amount: 50, provider: 'yoco', isDonation: true, originalPaymentId: main.id });
|
|
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'yoco', externalId: 'p_9', amount: 15000, reconciled: true, paymentId: main.id }) });
|
|
});
|
|
|
|
test('a repeated webhook records nothing new', async () => {
|
|
prisma.payment.findFirst.mockResolvedValue({ id: 'existing' });
|
|
prisma.providerTransaction.findUnique.mockResolvedValue({ id: 'ptx' });
|
|
prisma.providerTransaction.update.mockResolvedValue({ id: 'ptx' });
|
|
const res = response();
|
|
await handleProviderWebhook('yoco')(request(), res);
|
|
|
|
expect(res.body.data.processedData).toMatchObject({ status: 'already_processed', paymentId: 'existing' });
|
|
expect(prisma.payment.create).not.toHaveBeenCalled();
|
|
expect(prisma.providerTransaction.update).toHaveBeenCalledWith({ where: { id: 'ptx' }, data: { reconciled: true, paymentId: 'existing' } });
|
|
});
|
|
|
|
test('a bad signature is refused before anything is stored', async () => {
|
|
const req = request();
|
|
req.headers['webhook-signature'] = 'v1,bm9wZQ==';
|
|
const res = response();
|
|
await handleProviderWebhook('yoco')(req, res);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(prisma.payment.create).not.toHaveBeenCalled();
|
|
expect(prisma.providerTransaction.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|