Files
hope-events/backend/tests/paymentStatus.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

137 lines
8.7 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: { findMany: jest.fn(), findUnique: jest.fn(), findFirst: 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 crypto = require('crypto');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payfast = require('../src/payments/providers/payfast');
const status = require('../src/payments/status');
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();
}
// As PayFast's sandbox returned it (CRLF lines, quoted amounts with thousands separators)
const CSV = [
'Date,Type,Sign,Party,Name,Description,Currency,"Funding Type",Gross,Fee,Net,Balance,"M Payment ID","PF Payment ID","custom str1"',
'"2026-10-23 14:19:24",FUNDS_RECEIVED,CREDIT,"Test User 01","Family Fun Day",Adult,ZAR,WALLET,80.00,-2.30,77.70,"22,574.02",chk_a,3471967,',
'"2026-10-23 15:00:00",FUNDS_RECEIVED,CREDIT,"Test User 01","Big ""Camp""",,ZAR,CC,"1,250.00",-20.00,"1,230.00",,chk_b,3471999,',
'"2026-10-23 16:00:00",REFUND,DEBIT,"Test User 01",x,,ZAR,CC,-80.00,0,-80.00,,chk_c,3472000,',
].join('\r\n');
beforeEach(() => {
jest.clearAllMocks();
settings(PF);
});
describe('PayFast API', () => {
test('requests are signed over the alphabetically sorted variables and passphrase', () => {
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
expect(payfast.apiSignature({ version: 'v1', 'merchant-id': '10000100', timestamp: '2026-10-23T10:00:00+00:00', passphrase: 'Salt pass', from: '' }))
.toBe(md5('merchant-id=10000100&passphrase=Salt+pass&timestamp=2026-10-23T10%3A00%3A00%2B00%3A00&version=v1'));
});
test('reads PayFast CSV, quoted commas and quotes included', () => {
const rows = payfast.parseCsv(CSV);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ Type: 'FUNDS_RECEIVED', Gross: '80.00', Balance: '22,574.02', 'M Payment ID': 'chk_a', 'PF Payment ID': '3471967' });
expect(rows[1]).toMatchObject({ Name: 'Big "Camp"', Gross: '1,250.00' });
});
test('findPayments matches history rows on our checkout id (money received only)', async () => {
const get = jest.fn().mockResolvedValue({ data: CSV });
const list = ['chk_a', 'chk_b', 'chk_c'].map(id => ({ id, createdAt: new Date() }));
const found = await payfast.findPayments(list, { get });
expect([...found.keys()]).toEqual(['chk_a', 'chk_b']);
expect(found.get('chk_a')).toMatchObject({ externalId: '3471967', amountCents: 8000, checkoutId: 'chk_a', status: 'succeeded' });
expect(found.get('chk_a').createdDate.toISOString()).toBe('2026-10-23T12:19:24.000Z');
expect(found.get('chk_b').amountCents).toBe(125000);
const [url, opts] = get.mock.calls[0];
expect(url).toBe('https://api.payfast.co.za/transactions/history');
expect(opts.params).toMatchObject({ testing: 'true', limit: 1000, offset: 0 });
expect(opts.headers).toMatchObject({ 'merchant-id': '10000100', version: 'v1', signature: expect.any(String) });
});
test('live mode leaves out testing=true', async () => {
settings({ ...PF, payfast_mode: 'live' });
const get = jest.fn().mockResolvedValue({ data: CSV });
await payfast.findPayments([{ id: 'chk_a', createdAt: new Date() }], { get });
expect(get.mock.calls[0][1].params.testing).toBeUndefined();
});
test('a refused passphrase reads clearly', async () => {
const get = jest.fn().mockRejectedValue({ response: { status: 401, data: '{"data":{"response":"Merchant authorization failed."}}' } });
await expect(payfast.testConnection({ get })).rejects.toThrow(/refused the merchant ID or passphrase/);
});
});
describe('checkCheckouts', () => {
const checkout = (id, provider, cents) => ({ id, provider, externalId: null, amountCents: cents, metadata: { registrationId: 'r9', userId: 'u9' }, createdAt: new Date() });
let spy;
beforeEach(() => {
prisma.providerTransaction.findFirst.mockResolvedValue(null);
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', status: 'pending', registrationOptions: [], payments: [{ amount: 80 }] });
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Fun Day' }, user: { id: 'u9', name: 'T' } });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', ...data }));
computeRegistrationTotalDue.mockReturnValue(80);
prisma.payment.findFirst.mockResolvedValue(null);
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([]);
spy = jest.spyOn(payfast, 'findPayments');
});
afterEach(() => spy.mockRestore());
test('a payment PayFast received is recorded as its ITN would have been', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '3471967', amountCents: 8000, currency: 'ZAR', createdDate: new Date(), checkoutId: 'chk_a', methodType: null, metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000)]);
expect(out.get('chk_a')).toEqual({ status: 'recorded' });
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 80, provider: 'payfast', externalId: '3471967', registrationId: 'r9', status: 'completed' });
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_a'] } } }));
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '3471967', reconciled: true }) });
});
test('a different amount is held for review; nothing found is not paid', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '1', amountCents: 2000, checkoutId: 'chk_a', metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000), checkout('chk_n', 'payfast', 5000)]);
expect(out.get('chk_a')).toMatchObject({ status: 'needs_review', note: expect.stringMatching(/Paid R20.00/) });
expect(out.get('chk_n')).toEqual({ status: 'not_paid' });
expect(prisma.payment.create).not.toHaveBeenCalled();
});
test('already paid checkouts are skipped; Yoco can\'t be asked', async () => {
prisma.providerTransaction.findFirst.mockImplementation(({ where }) => Promise.resolve(where.checkoutId.in.includes('chk_paid') && where.paymentId ? { id: 'x' } : null));
spy.mockResolvedValue(new Map());
const out = await status.checkCheckouts([checkout('chk_paid', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
expect(out.get('chk_paid')).toEqual({ status: 'paid' });
expect(out.get('chk_y')).toEqual({ status: 'cant_check' });
expect(spy).not.toHaveBeenCalled();
});
test('the job checks checkouts between 3 days and 10 minutes old, with providers that can be asked', async () => {
const now = new Date('2026-10-23T12:00:00Z');
prisma.providerCheckout.findMany.mockResolvedValue([checkout('chk_a', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
spy.mockResolvedValue(new Map());
await expect(status.runPaymentStatusJob(now)).resolves.toEqual({ checked: 1, recorded: 0, needsReview: 0 });
expect(prisma.providerCheckout.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { provider: { not: null }, createdAt: { gte: new Date('2026-10-20T12:00:00Z'), lte: new Date('2026-10-23T11:50:00Z') } },
}));
expect(spy).toHaveBeenCalledWith([expect.objectContaining({ id: 'chk_a' })]);
});
});