Files
hope-events/backend/tests/payments.test.js
T
joshuaandClaude Opus 5.5 ede1ddbe00 Payment provider interface: Yoco behind it, Site settings → Payments
- 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>
2026-10-23 12:17:29 +02:00

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