Files
hope-events/backend/tests/payments.test.js
T
joshuaandClaude Opus 5.5 eb0f390215 Payment status checks for notifications that never arrive
- src/payments/status.js: open checkouts, checkCheckouts and the
  payment-status job (every 15 min, checkouts 10 min - 3 days old). Found
  payments are settled exactly as their webhook would be (settlePayment,
  extracted from the webhook handler: amount check, record, emails).
- PayFast: signed API client (alphabetical signature, testing=true in
  sandbox), findPayments from the transaction history CSV matched on
  M Payment ID, and testConnection (signed /ping). Verified against a
  real sandbox account.
- Yoco documents no checkout lookup, so its checkouts are "can't check".
- Payments -> Reconcile: "Waiting for payment" with Check status /
  Check all; PayFast card: Test connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:28 +02:00

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(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); fn.get = 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/) }) });
});
});
});