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