- PayFast (custom integration): signed form (PHP-style encoding, checked against a real sandbox), ITN checks (signature over all posted fields, PayFast host, VALID from /eng/query/validate, amount vs checkout). - ProviderCheckout records every checkout (amount, metadata, request); webhooks match payments to it and hold a different amount for review (ProviderTransaction.note). - /pay/<id>: payers choose a provider when several are on, and the browser posts PayFast's form from there, so payment links work for every provider. - Site settings → Payments: PayFast card (merchant ID/key, encrypted passphrase, sandbox/live) while PayFast is enabled. - Payer-facing wording no longer names Yoco; legal pages name both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
22 KiB
JavaScript
376 lines
22 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() },
|
|
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() },
|
|
ticket: { findMany: jest.fn() },
|
|
user: { findFirst: jest.fn() },
|
|
}));
|
|
jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); return 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 axios = require('axios');
|
|
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 payfast = require('../src/payments/providers/payfast');
|
|
const { handleProviderWebhook } = require('../src/payments/webhook');
|
|
|
|
const SECRET = 'whsec_' + Buffer.from('test-webhook-secret').toString('base64');
|
|
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
|
|
|
|
function settings(values) {
|
|
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
|
|
settingsCache.invalidate();
|
|
}
|
|
|
|
const ENV = { ...process.env };
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
process.env = { ...ENV };
|
|
delete process.env.YOCO_SECRET_KEY;
|
|
delete process.env.YOCO_WEBHOOK_SECRET;
|
|
for (const k of ['PAYFAST_MERCHANT_ID', 'PAYFAST_MERCHANT_KEY', 'PAYFAST_PASSPHRASE', 'PAYFAST_MODE', 'PAYFAST_SKIP_IP_CHECK']) delete process.env[k];
|
|
process.env.FRONTEND_URL = 'https://events.example.org';
|
|
process.env.BACKEND_URL = 'https://api.example.org';
|
|
prisma.providerCheckout.create.mockImplementation(({ data }) => Promise.resolve({ id: 'chk_1', provider: null, externalId: null, redirectUrl: null, ...data }));
|
|
prisma.providerCheckout.update.mockResolvedValue({});
|
|
prisma.providerCheckout.delete.mockResolvedValue({});
|
|
});
|
|
|
|
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/);
|
|
expect(prisma.providerCheckout.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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, and the checkout record is removed', async () => {
|
|
settings({});
|
|
axios.mockRejectedValue(new Error('should not be called'));
|
|
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toThrow(/Yoco secret key/);
|
|
expect(prisma.providerCheckout.delete).toHaveBeenCalledWith({ where: { id: 'chk_1' } });
|
|
});
|
|
|
|
test('one provider available: starts it straight away and records the checkout', async () => {
|
|
settings({ yoco_secret_key: 'sk_test_abc' });
|
|
const spy = jest.spyOn(yoco, 'createCheckout').mockResolvedValue({ externalId: 'ch_1', redirectUrl: 'https://pay/1' });
|
|
const out = await payments.createCheckout(null, { amountCents: 5000, metadata: { a: 1 }, description: 'Camp' });
|
|
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'yoco', redirectUrl: 'https://pay/1' });
|
|
expect(prisma.providerCheckout.create).toHaveBeenCalledWith({ data: { amountCents: 5000, currency: 'ZAR', metadata: { a: 1 }, request: { description: 'Camp' } } });
|
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ checkoutId: 'chk_1', amountCents: 5000, currency: 'ZAR', metadata: { a: 1 } }));
|
|
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'yoco', externalId: 'ch_1', redirectUrl: 'https://pay/1' } });
|
|
spy.mockRestore();
|
|
});
|
|
|
|
test('several available: the payer chooses on /pay/<id>', async () => {
|
|
settings({ yoco_secret_key: 'sk_test_abc', ...PF, payment_providers: JSON.stringify({ order: ['payfast', 'yoco'], enabled: { yoco: true, payfast: true } }) });
|
|
const out = await payments.createCheckout(null, { amountCents: 5000 });
|
|
expect(out).toEqual({ checkoutId: 'chk_1', provider: null, redirectUrl: 'https://events.example.org/pay/chk_1' });
|
|
});
|
|
|
|
test('a form provider (PayFast) sends the payer to /pay/<id>, which posts the form', async () => {
|
|
settings({ ...PF, payment_providers: JSON.stringify({ order: ['payfast'], enabled: { yoco: false, payfast: true } }) });
|
|
const out = await payments.createCheckout(null, { amountCents: 5000 });
|
|
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'payfast', redirectUrl: 'https://events.example.org/pay/chk_1' });
|
|
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'payfast' } });
|
|
});
|
|
});
|
|
|
|
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 () => {
|
|
process.env.YOCO_WEBHOOK_SECRET = SECRET;
|
|
settings({});
|
|
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('PayFast provider', () => {
|
|
test('encodes like PHP urlencode: upper-case escapes, + for spaces, and \' ( ) ! * ~ escaped', () => {
|
|
expect(payfast.pfEncode("Men's Camp (Youth)! *~")).toBe('Men%27s+Camp+%28Youth%29%21+%2A%7E');
|
|
expect(payfast.pfEncode('https://x.org/a?b=1&c=2')).toBe('https%3A%2F%2Fx.org%2Fa%3Fb%3D1%26c%3D2');
|
|
expect(payfast.pfEncode(' padded ')).toBe('padded');
|
|
});
|
|
|
|
test('a checkout signs the non-blank fields in order; an ITN signs every field', () => {
|
|
const pairs = [['a', '1'], ['b', ''], ['c', 'x y']];
|
|
expect(payfast.paramString(pairs)).toBe('a=1&c=x+y');
|
|
expect(payfast.paramString(pairs, { allFields: true })).toBe('a=1&b=&c=x+y');
|
|
const md5 = (s) => require('crypto').createHash('md5').update(s).digest('hex');
|
|
expect(payfast.signature(pairs, 'my pass')).toBe(md5('a=1&c=x+y&passphrase=my+pass'));
|
|
expect(payfast.signature(pairs)).toBe(md5('a=1&c=x+y'));
|
|
});
|
|
|
|
test('builds the signed form in PayFast\'s field order', async () => {
|
|
settings(PF);
|
|
const { form } = await payfast.createCheckout({
|
|
checkoutId: 'chk_9', amountCents: 12550, successUrl: 'https://e.org/ok', cancelUrl: 'https://e.org/no',
|
|
payer: { name: "Thandi O'Neil Mokoena", email: 'thandi@example.org' }, description: "Men's Camp (Youth)",
|
|
lineItems: [{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }],
|
|
});
|
|
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
|
|
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
|
|
expect(form.fields).toMatchObject({
|
|
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
|
|
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
|
|
});
|
|
const { signature, ...rest } = form.fields;
|
|
expect(signature).toBe(payfast.signature(Object.entries(rest), 'Salt pass'));
|
|
});
|
|
|
|
test('live mode posts to www.payfast.co.za; test emails are left out', async () => {
|
|
settings({ ...PF, payfast_mode: 'live' });
|
|
const { form } = await payfast.createCheckout({ checkoutId: 'c', amountCents: 2000, payer: { name: 'A', email: 'walkin@guest.local' } });
|
|
expect(form.action).toBe('https://www.payfast.co.za/eng/process');
|
|
expect(form.fields.email_address).toBeUndefined();
|
|
});
|
|
|
|
test('not set up: says what is missing', async () => {
|
|
settings({});
|
|
await expect(payfast.createCheckout({ checkoutId: 'c', amountCents: 2000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/merchant ID and key/) });
|
|
});
|
|
|
|
describe('ITN checks', () => {
|
|
// An ITN as PayFast posts it: every field, blanks included, signature last
|
|
const itn = (overrides = {}, passphrase = 'Salt pass') => {
|
|
const pairs = Object.entries({
|
|
m_payment_id: 'chk_9', pf_payment_id: '1089250', payment_status: 'COMPLETE', item_name: "Men's Camp (Youth)", item_description: '',
|
|
amount_gross: '125.50', amount_fee: '-4.60', amount_net: '120.90', custom_str1: '', name_first: 'Thandi', name_last: '', email_address: 'thandi@example.org',
|
|
merchant_id: '10000100', ...overrides,
|
|
});
|
|
const sig = payfast.signature(pairs, passphrase, { allFields: true });
|
|
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${sig}`;
|
|
};
|
|
const req = (rawBody, ip = '197.97.145.144') => ({ rawBody, ip, headers: {} });
|
|
const deps = (reply = 'VALID', ips = ['197.97.145.144']) => ({ lookupIps: async () => new Set(ips), post: jest.fn().mockResolvedValue({ data: reply }) });
|
|
|
|
beforeEach(() => settings(PF));
|
|
|
|
test('a genuine ITN passes, and is confirmed with PayFast over every field', async () => {
|
|
const d = deps();
|
|
await expect(payfast.verifyWebhook(req(itn()), d)).resolves.toMatchObject({ ok: true, webhookId: '1089250' });
|
|
const [url, sent] = d.post.mock.calls[0];
|
|
expect(url).toBe('https://sandbox.payfast.co.za/eng/query/validate');
|
|
expect(sent).toContain('item_description=&');
|
|
expect(sent).not.toContain('signature=');
|
|
});
|
|
|
|
test('a forged or wrongly signed ITN is refused', async () => {
|
|
await expect(payfast.verifyWebhook(req(itn({}, 'wrong passphrase')), deps())).resolves.toMatchObject({ ok: false, status: 403, message: 'Invalid signature' });
|
|
const tampered = itn().replace('amount_gross=125.50', 'amount_gross=1.00');
|
|
await expect(payfast.verifyWebhook(req(tampered), deps())).resolves.toMatchObject({ ok: false, status: 403 });
|
|
});
|
|
|
|
test('not from a PayFast address: refused (unless the check is switched off)', async () => {
|
|
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: false, status: 403, message: expect.stringMatching(/did not come from PayFast/) });
|
|
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
|
|
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
test('PayFast doesn\'t confirm it, or is unreachable', async () => {
|
|
await expect(payfast.verifyWebhook(req(itn()), deps('INVALID'))).resolves.toMatchObject({ ok: false, status: 403 });
|
|
const down = { lookupIps: async () => new Set(['197.97.145.144']), post: jest.fn().mockRejectedValue(new Error('timeout')) };
|
|
await expect(payfast.verifyWebhook(req(itn()), down)).resolves.toMatchObject({ ok: false, status: 502 });
|
|
});
|
|
|
|
test('another merchant\'s ITN is refused', async () => {
|
|
await expect(payfast.verifyWebhook(req(itn({ merchant_id: '99999999' })), deps())).resolves.toMatchObject({ ok: false, status: 403 });
|
|
});
|
|
|
|
test('parses COMPLETE as a successful payment', () => {
|
|
expect(payfast.parseWebhook(payfast.readWebhookBody(itn()))).toEqual({
|
|
type: 'payment.succeeded',
|
|
transaction: { externalId: '1089250', amountCents: 12550, currency: 'ZAR', createdDate: null, checkoutId: 'chk_9', methodType: null, metadata: {}, status: 'succeeded' },
|
|
});
|
|
expect(payfast.parseWebhook({ payment_status: 'CANCELLED' }).type).toBe('payment.cancelled');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('webhook flow', () => {
|
|
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(() => {
|
|
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.providerCheckout.findUnique.mockResolvedValue(null);
|
|
prisma.providerCheckout.findFirst.mockResolvedValue(null);
|
|
prisma.ticket.findMany.mockResolvedValue([]);
|
|
});
|
|
|
|
describe('Yoco', () => {
|
|
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 };
|
|
};
|
|
beforeEach(() => { process.env.YOCO_WEBHOOK_SECRET = SECRET; settings({}); });
|
|
|
|
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('found through our checkout record: the registration is looked up by our id too', async () => {
|
|
prisma.payment.findFirst.mockResolvedValue(null);
|
|
prisma.providerCheckout.findFirst.mockResolvedValue({ id: 'chk_ours', provider: 'yoco', externalId: 'ch_9', amountCents: 15000, metadata: { registrationId: 'r9' } });
|
|
await handleProviderWebhook('yoco')(request(), response());
|
|
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_ours', 'ch_9'] } } }));
|
|
expect(prisma.payment.create).toHaveBeenCalled();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('PayFast', () => {
|
|
const itn = (amount = '100.00') => {
|
|
const pairs = Object.entries({ m_payment_id: 'chk_pf', pf_payment_id: '555', payment_status: 'COMPLETE', item_name: 'Camp', amount_gross: amount, merchant_id: '10000100' });
|
|
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${payfast.signature(pairs, 'Salt pass', { allFields: true })}`;
|
|
};
|
|
beforeEach(() => {
|
|
settings(PF);
|
|
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
|
|
axios.post.mockResolvedValue({ data: 'VALID' });
|
|
prisma.payment.findFirst.mockResolvedValue(null);
|
|
prisma.providerCheckout.findUnique.mockResolvedValue({ id: 'chk_pf', provider: 'payfast', amountCents: 10000, metadata: { registrationId: 'r9', userId: 'u9', eventId: 'e9', type: 'registration_payment' } });
|
|
});
|
|
|
|
test('a confirmed ITN records the payment against the checkout\'s registration', async () => {
|
|
const res = response();
|
|
await handleProviderWebhook('payfast')({ rawBody: itn(), ip: '1.2.3.4', headers: {} }, res);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 100, provider: 'payfast', method: 'card', externalId: '555', registrationId: 'r9', status: 'completed' });
|
|
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '555', checkoutId: 'chk_pf', reconciled: true }) });
|
|
});
|
|
|
|
test('paid a different amount from the checkout: held for review, not recorded', async () => {
|
|
const res = response();
|
|
await handleProviderWebhook('payfast')({ rawBody: itn('20.00'), ip: '1.2.3.4', headers: {} }, res);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.data).toEqual({ needsReview: true });
|
|
expect(prisma.payment.create).not.toHaveBeenCalled();
|
|
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', reconciled: false, note: expect.stringMatching(/Paid R20.00, but the checkout was for R100.00/) }) });
|
|
});
|
|
});
|
|
});
|