From eb0f390215087f52db1a676ae38e4d38c3cbf81f Mon Sep 17 00:00:00 2001 From: joshua Date: Fri, 23 Oct 2026 15:28:28 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 5 + FEATURES.md | 4 + backend/.env.example | 2 + backend/API_DOCUMENTATION.md | 4 +- backend/src/controllers/payfastController.js | 16 +- .../controllers/paymentProvidersController.js | 68 +++++++-- backend/src/index.js | 3 + backend/src/jobs/index.js | 4 + backend/src/payments/providers/payfast.js | 118 ++++++++++++++- backend/src/payments/status.js | 99 +++++++++++++ backend/src/payments/webhook.js | 30 ++-- backend/src/routes/payfastRoutes.js | 3 +- backend/src/routes/paymentRoutes.js | 4 +- backend/tests/paymentStatus.test.js | 136 +++++++++++++++++ backend/tests/payments.test.js | 6 +- docs/HANDOVER.md | 10 +- e2e/payfast.spec.ts | 9 ++ .../dashboard/supervisor/payments/page.tsx | 5 + .../src/components/admin/PayFastSettings.tsx | 25 +++- .../components/payments/WaitingForPayment.tsx | 137 ++++++++++++++++++ frontend/src/content/help/site-settings.tsx | 2 +- .../src/content/help/supervisor-payments.tsx | 2 +- 22 files changed, 654 insertions(+), 38 deletions(-) create mode 100644 backend/src/payments/status.js create mode 100644 backend/tests/paymentStatus.test.js create mode 100644 frontend/src/components/payments/WaitingForPayment.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1475caa..182edc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before. +- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast. + ## [1.37.1] - 2026-10-23 ### Changed diff --git a/FEATURES.md b/FEATURES.md index 29fdcbc..e70a16c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -208,6 +208,9 @@ Feedback → Attendance**. payment of a different amount from the checkout is held for review instead of being recorded. When no provider is on (or the amount is under R15), nothing offers to pay online. +- **Payment status checks** — if a provider's notification never arrives, Payments → + Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be + asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes. - **Payment links** — supervisors can generate and send a Yoco link for any amount (including partial). - **Manual payments** — cash, card, EFT, voucher or other, recorded by staff. @@ -563,6 +566,7 @@ can't start, the same jobs run on in-process timers. | Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) | | `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) | | `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) | +| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_JOB_ENABLED=false`) | | `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) | | `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) | | `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) | diff --git a/backend/.env.example b/backend/.env.example index 902ec6e..6804668 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -69,6 +69,8 @@ CONSENT_JOBS_ENABLED=true # Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default) WAITLIST_JOB_ENABLED=true # WAITLIST_JOB_INTERVAL_MS=600000 +# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived +PAYMENT_STATUS_JOB_ENABLED=true # Nightly database backup at 02:00 (requires the `pg_dump` binary on this host). # Retention count is set via Admin → Site Settings → Backups (default 14). BACKUP_ENABLED=true diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 671afc0..f4fc532 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -581,6 +581,8 @@ Options and variants also accept an optional `staffPrice`, charged automatically | GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) | | GET | `/api/payments/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it | | POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid | +| GET | `/api/payments/checkouts/open` | supervisor+ | Online checkouts from the last 14 days with no payment yet: `{ checkouts: [{ id, provider, providerLabel, canCheck, amount, description, type, registrationId, payer, createdAt }], days }` | +| POST | `/api/payments/checkouts/check` | supervisor+ | `{ ids? }` (default: every open one) → asks each provider and records payments it received: `{ results: { [checkoutId]: { status: paid\|recorded\|needs_review\|not_paid\|cant_check, note? } }, recorded, message }`. PayFast: transaction history (API); Yoco: `cant_check` | | PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) | | GET | `/api/payments/mypayments` | user+ | Own payments | | GET | `/api/payments/:id` | user+ | Payment by ID | @@ -810,7 +812,7 @@ For what each individual setting key defaults to when it's never been saved, see Managed from Admin → Site Settings → Payments. -PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`). +PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`); `POST /api/payfast/test` (admin) checks the saved settings with a signed PayFast API ping. Every checkout is stored as a `ProviderCheckout` (amount, metadata, request): `POST /api/payments/checkout` returns `checkoutId` (ours) and a `redirectUrl` — the provider's page, or `/pay/` when the payer must choose or a form has to be posted. Registrations keep that `checkoutId`; webhooks find the checkout by our id or the provider's. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value. diff --git a/backend/src/controllers/payfastController.js b/backend/src/controllers/payfastController.js index e7883c1..5b84aec 100644 --- a/backend/src/controllers/payfastController.js +++ b/backend/src/controllers/payfastController.js @@ -80,4 +80,18 @@ const updateConfig = async (req, res) => { } }; -module.exports = { getConfig, updateConfig }; +// @desc Check the saved merchant ID and passphrase with PayFast (a signed API ping) +// @route POST /api/payfast/test +// @access Admin +const testConnection = async (req, res) => { + try { + const c = await getPayfastConfig(); + if (!c.merchantId) { res.status(400); throw new Error('Save the merchant ID first.'); } + await require('../payments/providers/payfast').testConnection(); + res.json({ message: `Connected to PayFast (${c.mode === 'live' ? 'live' : 'sandbox'}).` }); + } catch (e) { + fail(res, e); + } +}; + +module.exports = { getConfig, updateConfig, testConnection }; diff --git a/backend/src/controllers/paymentProvidersController.js b/backend/src/controllers/paymentProvidersController.js index bcd174d..0decced 100644 --- a/backend/src/controllers/paymentProvidersController.js +++ b/backend/src/controllers/paymentProvidersController.js @@ -1,5 +1,6 @@ const prisma = require('../config/db'); const payments = require('../payments'); +const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status'); const { safeErrorMessage } = require('../utils/errorUtils'); const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache'); const { logAdminAction } = require('../utils/adminAudit'); @@ -62,15 +63,7 @@ const updatePaymentProviderSettings = async (req, res) => { async function loadCheckout(id) { const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } }); if (!checkout) return null; - const refs = [checkout.id, checkout.externalId].filter(Boolean); - const paidTx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } }); - let paid = !!paidTx; - const registrationId = checkout.metadata?.registrationId; - if (!paid && registrationId) { - const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } }); - paid = reg?.status === 'paid'; - } - return { checkout, paid }; + return { checkout, paid: await checkoutPaid(checkout) }; } const providerList = (list) => list.map(p => ({ id: p.id, label: p.label })); @@ -126,4 +119,59 @@ const choosePayProvider = async (req, res) => { } }; -module.exports = { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider }; +const OPEN_DAYS = 14; + +/** An open checkout for the Payments page: who/what it's for, and whether its provider can be asked. */ +async function describeCheckout(c, users) { + const meta = c.metadata || {}; + const provider = payments.getProvider(c.provider); + return { + id: c.id, + provider: c.provider, + providerLabel: provider?.label || c.provider, + canCheck: !!provider?.findPayments, + amount: c.amountCents / 100, + description: c.request?.description || meta.eventTitle || '', + type: meta.type || null, + registrationId: meta.registrationId || null, + payer: users.get(meta.userId) || c.request?.payer?.name || null, + createdAt: c.createdAt, + }; +} + +// @desc Online checkouts started in the last 14 days that have no payment yet +// @route GET /api/payments/checkouts/open +// @access Supervisor +const listOpenCheckouts = async (req, res) => { + try { + const open = await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) }); + const userIds = [...new Set(open.map(c => c.metadata?.userId).filter(Boolean))]; + const users = new Map((await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })).map(u => [u.id, u.name])); + res.json({ checkouts: await Promise.all(open.map(c => describeCheckout(c, users))), days: OPEN_DAYS }); + } catch (e) { + fail(res, e); + } +}; + +// @desc Ask the providers whether open checkouts were paid, and record what they find +// @route POST /api/payments/checkouts/check { ids?: string[] } (default: every open one) +// @access Supervisor +const checkCheckoutStatus = async (req, res) => { + try { + const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String).slice(0, 200) : null; + const list = ids + ? await prisma.providerCheckout.findMany({ where: { id: { in: ids } } }) + : await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) }); + const results = await checkCheckouts(list); + const out = Object.fromEntries(results); + const recorded = [...results.values()].filter(r => r.status === 'recorded').length; + res.json({ results: out, recorded, message: recorded ? `Found and recorded ${recorded} payment${recorded === 1 ? '' : 's'}.` : 'No new payments found.' }); + } catch (e) { + fail(res, e); + } +}; + +module.exports = { + listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, + listOpenCheckouts, checkCheckoutStatus, +}; diff --git a/backend/src/index.js b/backend/src/index.js index ca31c77..db76b44 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -805,6 +805,8 @@ app.get('/docs', async (req, res) => { responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]}, { method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' }, { method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' }, + { method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' }, + { method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' }, { method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' }, { method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.', request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}}, @@ -1087,6 +1089,7 @@ app.get('/docs', async (req, res) => { { title: 'PayFast', base: '/api/payfast', endpoints: [ { method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' }, { method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' }, + { method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' }, ]}, { title: 'Yoco', base: '/api/yoco', endpoints: [ { method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.', diff --git a/backend/src/jobs/index.js b/backend/src/jobs/index.js index 1f53e4b..851bd3d 100644 --- a/backend/src/jobs/index.js +++ b/backend/src/jobs/index.js @@ -29,6 +29,10 @@ const JOBS = [ name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED', run: () => require('../utils/waitlist').runWaitlistJob(), }, + { + name: 'payment-status', label: 'Payments: check providers for notifications that never arrived', cron: '*/15 * * * *', env: 'PAYMENT_STATUS_JOB_ENABLED', + run: () => require('../payments/status').runPaymentStatusJob(), + }, { name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED', run: () => require('../utils/feedback').runFeedbackJob(), diff --git a/backend/src/payments/providers/payfast.js b/backend/src/payments/providers/payfast.js index 9e01ee2..133e979 100644 --- a/backend/src/payments/providers/payfast.js +++ b/backend/src/payments/providers/payfast.js @@ -12,8 +12,9 @@ * it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast * says it's VALID. The amount is checked against the checkout in ../webhook.js. * - * Refunds and transaction lookups aren't done through PayFast's API here: refunds are made in - * the PayFast dashboard and recorded with the manual refund flow. + * Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's + * transaction history (its API, signed differently — see apiSignature). Refunds aren't done + * through the API: they're made in the PayFast dashboard and recorded with the manual flow. */ const crypto = require('crypto'); @@ -21,7 +22,8 @@ const dns = require('dns').promises; const axios = require('axios'); const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast'); -const capabilities = { refund: false, getTransaction: false, listTransactions: false }; +// getTransaction: payments can be looked up in PayFast's transaction history (findPayments). +const capabilities = { refund: false, getTransaction: true, listTransactions: false }; // Hosts PayFast sends ITNs from. const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za']; @@ -187,9 +189,115 @@ function parseWebhook(body) { }; } +// ─── PayFast API (developers.payfast.co.za/api) ────────────────────────────── + +const API_BASE = 'https://api.payfast.co.za'; + +/** + * The API signature: every header and query variable plus the passphrase, sorted + * alphabetically, URL-encoded, joined with '&' (not the payment form's field order). Pure. + */ +function apiSignature(vars) { + const s = Object.keys(vars).sort() + .filter(k => vars[k] !== undefined && vars[k] !== null && String(vars[k]) !== '') + .map(k => `${k}=${pfEncode(vars[k])}`).join('&'); + return crypto.createHash('md5').update(s).digest('hex'); +} + +/** A signed GET to the PayFast API; the sandbox adds ?testing=true (left out of the signature). */ +async function apiGet(path, query = {}, { get = axios.get } = {}) { + const c = await getPayfastConfig(); + const headers = { 'merchant-id': c.merchantId, version: 'v1', timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') }; + const signature = apiSignature({ ...headers, ...query, ...(c.passphrase ? { passphrase: c.passphrase } : {}) }); + const params = { ...query, ...(c.mode === 'live' ? {} : { testing: 'true' }) }; + try { + const res = await get(`${API_BASE}${path}`, { params, headers: { ...headers, signature }, timeout: 20000, responseType: 'text', transformResponse: r => r }); + return res.data; + } catch (e) { + const status = e.response?.status; + let detail = ''; + try { detail = JSON.parse(e.response?.data || '{}')?.data?.response || ''; } catch { /* not JSON */ } + const err = new Error(status === 401 + ? 'PayFast refused the merchant ID or passphrase (check they match your PayFast dashboard, and the mode).' + : `Could not reach PayFast${detail ? `: ${detail}` : status ? ` (${status})` : `: ${e.message}`}`); + err.statusCode = status === 401 ? 400 : 502; + throw err; + } +} + +/** Checks the credentials with PayFast (GET /ping, signed). */ +async function testConnection(deps) { + await apiGet('/ping', {}, deps); + return true; +} + +/** Rows of a CSV with a header line, as objects keyed by the header. Pure. */ +function parseCsv(text) { + const rows = []; + let row = [], field = '', quoted = false; + const s = String(text || ''); + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (quoted) { + if (ch === '"' && s[i + 1] === '"') { field += '"'; i++; } + else if (ch === '"') quoted = false; + else field += ch; + } else if (ch === '"') quoted = true; + else if (ch === ',') { row.push(field); field = ''; } + else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && s[i + 1] === '\n') i++; + row.push(field); field = ''; + if (row.some(v => v !== '')) rows.push(row); + row = []; + } else field += ch; + } + row.push(field); + if (row.some(v => v !== '')) rows.push(row); + const [header, ...data] = rows; + return header ? data.map(r => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))) : []; +} + +const day = (d) => new Date(d).toISOString().slice(0, 10); + +/** + * Payments PayFast received for our checkouts (from its transaction history, matched on + * "M Payment ID" = our checkout id). For notifications (ITNs) that never arrived. + * @param {object[]} checkouts - ProviderCheckout rows + * @returns {Promise>} checkout id → transaction (as parseWebhook gives) + */ +async function findPayments(checkouts, deps) { + const found = new Map(); + if (!checkouts.length) return found; + const ids = new Set(checkouts.map(c => c.id)); + const from = day(Math.min(...checkouts.map(c => new Date(c.createdAt).getTime())) - 24 * 60 * 60 * 1000); + const to = day(Date.now() + 24 * 60 * 60 * 1000); + const PAGE = 1000; + for (let offset = 0; offset < 20 * PAGE; offset += PAGE) { + const rows = parseCsv(await apiGet('/transactions/history', { from, to, limit: PAGE, offset }, deps)); + for (const r of rows) { + const id = r['M Payment ID']; + if (!ids.has(id) || r.Type !== 'FUNDS_RECEIVED' || found.has(id)) continue; + const gross = parseFloat(String(r.Gross).replace(/,/g, '')); + found.set(id, { + externalId: r['PF Payment ID'] || null, + amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null, + currency: r.Currency || 'ZAR', + // PayFast's history times are South African time + createdDate: r.Date ? new Date(`${r.Date.replace(' ', 'T')}+02:00`) : null, + checkoutId: id, + methodType: null, + metadata: {}, + status: 'succeeded', + }); + } + if (rows.length < PAGE || found.size === ids.size) break; + } + return found; +} + module.exports = { id: 'payfast', label: 'PayFast', capabilities, - isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, + isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection, // for tests - pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, + pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv, }; diff --git a/backend/src/payments/status.js b/backend/src/payments/status.js new file mode 100644 index 0000000..785f346 --- /dev/null +++ b/backend/src/payments/status.js @@ -0,0 +1,99 @@ +/** + * Payment status checks — for when a provider's notification (webhook / ITN) never arrives. + * + * An open checkout is one we started with a provider that has no payment recorded yet. A + * provider that can look payments up (capabilities.getTransaction, findPayments) is asked + * whether it received one; anything it found is settled exactly as its webhook would have been + * (same amount check, same recording, same emails). Run every 15 minutes by the + * 'payment-status' job for recent checkouts, and on demand from the Payments page. + * Providers that can't be asked (Yoco: its API has no lookup) show as "can't check" — their + * payments are found in the provider's portal and recorded with Reconcile. + */ + +const prisma = require('../config/db'); +const { getProvider } = require('./index'); +const { settlePayment } = require('./webhook'); + +/** Whether a checkout has been paid: a payment came back for it, or its registration is paid. */ +async function checkoutPaid(checkout) { + const refs = [checkout.id, checkout.externalId].filter(Boolean); + const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } }); + if (tx) return true; + const registrationId = checkout.metadata?.registrationId; + if (!registrationId) return false; + const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } }); + return reg?.status === 'paid'; +} + +/** A payment held for review on this checkout (paid a different amount), if any. */ +async function heldForReview(checkout) { + const refs = [checkout.id, checkout.externalId].filter(Boolean); + return prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: null, ignored: false, note: { not: null } }, select: { id: true, note: true } }); +} + +/** + * Asks each checkout's provider whether it was paid and records what it finds. + * @returns {Promise>} + */ +async function checkCheckouts(checkouts) { + const out = new Map(); + const byProvider = new Map(); + for (const c of checkouts) { + if (await checkoutPaid(c)) { out.set(c.id, { status: 'paid' }); continue; } + const provider = getProvider(c.provider); + if (!provider?.findPayments) { out.set(c.id, { status: 'cant_check' }); continue; } + if (!byProvider.has(provider.id)) byProvider.set(provider.id, []); + byProvider.get(provider.id).push(c); + } + for (const [providerId, list] of byProvider) { + const provider = getProvider(providerId); + const found = await provider.findPayments(list); + for (const c of list) { + const tx = found.get(c.id); + if (!tx) { + const held = await heldForReview(c); + out.set(c.id, held ? { status: 'needs_review', note: held.note } : { status: 'not_paid' }); + continue; + } + const event = { + type: 'payment.succeeded', + transaction: { ...tx, metadata: { ...(c.metadata || {}), ...(tx.metadata || {}) } }, + checkoutRefs: [c.id, c.externalId].filter(Boolean), + }; + const settled = await settlePayment(provider, event, { source: 'status-check', provider: providerId, transaction: tx }, c); + out.set(c.id, settled.needsReview ? { status: 'needs_review', note: settled.note } : { status: 'recorded' }); + } + } + return out; +} + +/** Recent checkouts started with a provider and not paid yet, newest first. */ +async function openCheckouts({ since, until = new Date() } = {}) { + const rows = await prisma.providerCheckout.findMany({ + where: { provider: { not: null }, createdAt: { gte: since, lte: until } }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + const open = []; + for (const c of rows) if (!(await checkoutPaid(c))) open.push(c); + return open; +} + +/** + * Background job: checks checkouts started between 3 days and 10 minutes ago (giving the + * webhook time to arrive first) with providers that can be asked. + */ +async function runPaymentStatusJob(now = new Date()) { + const since = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + const until = new Date(now.getTime() - 10 * 60 * 1000); + const checkable = (await openCheckouts({ since, until })).filter(c => getProvider(c.provider)?.findPayments); + if (!checkable.length) return { checked: 0, recorded: 0, needsReview: 0 }; + const results = [...(await checkCheckouts(checkable)).values()]; + return { + checked: checkable.length, + recorded: results.filter(r => r.status === 'recorded').length, + needsReview: results.filter(r => r.status === 'needs_review').length, + }; +} + +module.exports = { checkoutPaid, checkCheckouts, openCheckouts, runPaymentStatusJob }; diff --git a/backend/src/payments/webhook.js b/backend/src/payments/webhook.js index e14a6c4..781124b 100644 --- a/backend/src/payments/webhook.js +++ b/backend/src/payments/webhook.js @@ -95,15 +95,11 @@ const handleProviderWebhook = (providerId) => async (req, res) => { }); } - // Paid a different amount from the checkout (e.g. changed on the way): don't record it - if (checkout && tx.amountCents !== checkout.amountCents) { - const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`; - console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`); - try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); } + const settled = await settlePayment(provider, event, body, checkout); + if (settled.needsReview) { return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } }); } - - const processedData = await recordSucceededPayment(provider.id, event, body); + const processedData = settled.processedData; return res.status(200).json({ success: true, message: `Successfully processed ${event.type} webhook`, @@ -115,7 +111,23 @@ const handleProviderWebhook = (providerId) => async (req, res) => { } }; -const REG_INCLUDE = { event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } }; +/** + * A successful payment for one of our checkouts (from a webhook, or found by a status check): + * held for review with a note when the amount differs from the checkout, otherwise recorded. + * @returns {Promise<{ needsReview: true, note } | { needsReview: false, processedData }>} + */ +async function settlePayment(provider, event, body, checkout) { + const tx = event.transaction; + if (checkout && tx.amountCents !== checkout.amountCents) { + const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`; + console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`); + try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); } + return { needsReview: true, note }; + } + return { needsReview: false, processedData: await recordSucceededPayment(provider.id, event, body) }; +} + +const REG_INCLUDE ={ event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } }; /** Records a successful payment from a webhook. Idempotent on the provider's payment id. */ async function recordSucceededPayment(providerId, event, body) { @@ -286,4 +298,4 @@ async function saveProviderTransaction(providerId, event, body, updates = {}) { } } -module.exports = { handleProviderWebhook, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction }; +module.exports = { handleProviderWebhook, settlePayment, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction }; diff --git a/backend/src/routes/payfastRoutes.js b/backend/src/routes/payfastRoutes.js index 9b11723..2b32e3f 100644 --- a/backend/src/routes/payfastRoutes.js +++ b/backend/src/routes/payfastRoutes.js @@ -1,9 +1,10 @@ const express = require('express'); const router = express.Router(); -const { getConfig, updateConfig } = require('../controllers/payfastController'); +const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController'); const { protect, admin } = require('../middleware/authMiddleware'); router.get('/config', protect, admin, getConfig); router.put('/config', protect, admin, updateConfig); +router.post('/test', protect, admin, testConnection); module.exports = router; diff --git a/backend/src/routes/paymentRoutes.js b/backend/src/routes/paymentRoutes.js index d11e95b..bc664ab 100644 --- a/backend/src/routes/paymentRoutes.js +++ b/backend/src/routes/paymentRoutes.js @@ -15,7 +15,7 @@ const { getPaymentStats, sendReceipt, } = require('../controllers/paymentController'); -const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider } = require('../controllers/paymentProvidersController'); +const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController'); const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware'); // Protected routes @@ -23,6 +23,8 @@ router.post('/', protect, supervisor, createPayment); router.get('/providers', listPaymentProviders); router.get('/pay/:id', getPayPage); router.post('/pay/:id', choosePayProvider); +router.get('/checkouts/open', protect, supervisor, listOpenCheckouts); +router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus); router.get('/providers/settings', protect, admin, getPaymentProviderSettings); router.put('/providers/settings', protect, admin, updatePaymentProviderSettings); router.post('/checkout', protect, createCheckout); diff --git a/backend/tests/paymentStatus.test.js b/backend/tests/paymentStatus.test.js new file mode 100644 index 0000000..68d14aa --- /dev/null +++ b/backend/tests/paymentStatus.test.js @@ -0,0 +1,136 @@ +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×tamp=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' })]); + }); +}); diff --git a/backend/tests/payments.test.js b/backend/tests/payments.test.js index 59d5bd5..b73dedd 100644 --- a/backend/tests/payments.test.js +++ b/backend/tests/payments.test.js @@ -2,12 +2,12 @@ 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() }, + 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(); return 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() })); diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index e652726..c63a688 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -19,10 +19,8 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md). ## Next up -1. **1.37.2 — payment status checks** for notifications that never arrive (see the - roadmap's payment edge cases). -2. **1.38.0–1.39.0 — POPIA toolkit** (spec §3 in the roadmap). -3. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo +1. **1.38.0–1.39.0 — POPIA toolkit** (spec §3 in the roadmap). +2. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo `joshua/hope-events-hub`). ## Working on this repo @@ -94,6 +92,10 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md). - `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`, donation split, registration status, tickets, emails, `ProviderTransaction`). - `backend/src/payments/checkouts.js` — registration checkouts (balance, part, + donation). +- `backend/src/payments/status.js` — status checks for notifications that never arrived + (`payment-status` job, Payments → Reconcile → "Waiting for payment"). PayFast looks payments + up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so + Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`. - **Adding a provider:** write `providers/.js` with the same interface, add it to `PROVIDERS` in `index.js`, mount `handleProviderWebhook('')` at `/api/webhooks/` (`routes/webhookRoutes.js`), and give it a settings card in diff --git a/e2e/payfast.spec.ts b/e2e/payfast.spec.ts index a465d8b..c870f6d 100644 --- a/e2e/payfast.spec.ts +++ b/e2e/payfast.spec.ts @@ -43,6 +43,15 @@ test.describe("PayFast", () => { await page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 }); await expect(page.locator("body")).not.toContainText(/signature/i); await expect(page.locator("body")).toContainText(/150[.,]00/); + + // Not paid at PayFast: the status check asks PayFast's API (signed) and says so + await loginAs(page, "supervisor"); + await page.goto("/dashboard/supervisor/payments"); + await page.getByText("Reconcile", { exact: true }).click(); + const row = page.getByRole("row").filter({ hasText: "PayFast" }).filter({ hasText: "R 150.00" }).first(); + await row.getByRole("button", { name: "Check status" }).click(); + await expect(row.getByText("Not paid (yet)")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("No new payments found.")).toBeVisible(); } finally { await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] }); await api(page, "admin", "PUT", "/api/payfast/config", { merchantId: "", merchantKey: "", passphrase: "" }); diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index 77f2c8f..6a7338b 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -12,6 +12,7 @@ import { registrationOptionUnitPrice } from "@/lib/pricing"; import type { Event, Payment, Registration, User } from "@/types"; import { BackLink } from "@/components/shared/BackLink"; import { useOnlinePayments } from "@/lib/onlinePayments"; +import { WaitingForPayment } from "@/components/payments/WaitingForPayment"; // A donation is never mutated once created — assigning it to a registration creates a separate // "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. @@ -556,6 +557,10 @@ function PaymentsContent() {
+ {mode === 'reconcile' && ( + { loadUnreconciled(); loadPayments(); loadRegistrations(); }} /> + )} + {/* Online payment reconciliation panel */} {mode === 'reconcile' && (
diff --git a/frontend/src/components/admin/PayFastSettings.tsx b/frontend/src/components/admin/PayFastSettings.tsx index eef193b..6a295e0 100644 --- a/frontend/src/components/admin/PayFastSettings.tsx +++ b/frontend/src/components/admin/PayFastSettings.tsx @@ -72,6 +72,21 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan } }; + const [testing, setTesting] = useState(false); + // Checks the saved merchant ID and passphrase with PayFast (a signed API ping). + const test = async () => { + if (!token) return; + setTesting(true); + try { + const res = await apiFetch<{ message: string }>("/api/payfast/test", { method: "POST", authToken: token }); + setMessage({ type: "ok", text: res.message }); + } catch (err) { + setMessage({ type: "err", text: errorMessage(err) || "PayFast didn't accept the settings" }); + } finally { + setTesting(false); + } + }; + const removePassphrase = async () => { if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return; try { @@ -134,7 +149,15 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan Payment notifications go to {cfg.notifyUrl || "(not set)"}. {cfg.notifyUrlProblem &&

{cfg.notifyUrlProblem}

}
- +
+ + {cfg.configured && ( + + )} +
+

Test connection checks the saved settings — save any changes first.

)}
diff --git a/frontend/src/components/payments/WaitingForPayment.tsx b/frontend/src/components/payments/WaitingForPayment.tsx new file mode 100644 index 0000000..5a12c25 --- /dev/null +++ b/frontend/src/components/payments/WaitingForPayment.tsx @@ -0,0 +1,137 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { apiFetch } from "@/lib/api"; +import { errorMessage } from "@/lib/utils"; + +type OpenCheckout = { + id: string; + provider: string; + providerLabel: string; + canCheck: boolean; + amount: number; + description: string; + type: string | null; + registrationId: string | null; + payer: string | null; + createdAt: string; +}; +type CheckResult = { status: "paid" | "recorded" | "needs_review" | "not_paid" | "cant_check"; note?: string }; + +const RESULT_TEXT: Record = { + paid: "Already paid", + recorded: "Payment found and recorded", + needs_review: "Paid a different amount — see Unreconciled below", + not_paid: "Not paid (yet)", + cant_check: "Can't check — look in the provider's portal", +}; + +/** + * Online checkouts from the last 14 days with no payment yet (Payments → Reconcile). For when a + * provider's notification never arrived: "Check status" asks the provider and records any + * payment it received. A background job does the same every 15 minutes. + */ +export function WaitingForPayment({ token, onRecorded }: { token: string | null; onRecorded: () => void }) { + const [list, setList] = useState(null); + const [results, setResults] = useState>({}); + const [busy, setBusy] = useState(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + const load = useCallback(async () => { + if (!token) return; + try { + const res = await apiFetch<{ checkouts: OpenCheckout[] }>("/api/payments/checkouts/open", { authToken: token }); + setList(res.checkouts); + } catch (e) { + setMessage({ ok: false, text: errorMessage(e) || "Couldn't load waiting payments" }); + } + }, [token]); + useEffect(() => { load(); }, [load]); + + const check = async (ids?: string[]) => { + if (!token) return; + setBusy(ids?.[0] || "all"); + setMessage(null); + try { + const res = await apiFetch<{ results: Record; recorded: number; message: string }>("/api/payments/checkouts/check", { + method: "POST", authToken: token, body: ids ? { ids } : {}, + }); + setResults(r => ({ ...r, ...res.results })); + setMessage({ ok: true, text: res.message }); + if (res.recorded > 0) onRecorded(); + } catch (e) { + setMessage({ ok: false, text: errorMessage(e) || "Couldn't check" }); + } finally { + setBusy(null); + } + }; + + const checkable = (list || []).some(c => c.canCheck); + + return ( +
+
+
Waiting for payment
+
+ {checkable && ( + + )} + +
+
+

+ Online payments started in the last 14 days that haven't been confirmed. If the provider's notification didn't arrive, check the status here — PayFast payments are also checked automatically every 15 minutes. +

+ {message &&
{message.text}
} + {!list ? ( +
Loading…
+ ) : list.length === 0 ? ( +
Nothing waiting.
+ ) : ( +
+ + + + + + + + + + + + + {list.map(c => { + const result = results[c.id]; + return ( + + + + + + + + + ); + })} + +
StartedPayerForAmountProviderStatus
{new Date(c.createdAt).toLocaleString()}{c.payer || "—"}{c.description || (c.type === "donation" ? "Donation" : "—")}R {c.amount.toFixed(2)}{c.providerLabel} + {result ? ( + + {RESULT_TEXT[result.status]} + + ) : c.canCheck ? ( + + ) : ( + Can't check automatically — look in the {c.providerLabel} portal, then use Reconcile + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/content/help/site-settings.tsx b/frontend/src/content/help/site-settings.tsx index 51d46f0..10813f2 100644 --- a/frontend/src/content/help/site-settings.tsx +++ b/frontend/src/content/help/site-settings.tsx @@ -111,7 +111,7 @@ export const siteSettingsHelpContent: HelpContent = { Shown while Yoco is ticked. Paste the secret key, then generate the webhook (or enter its secret) so payments are confirmed automatically. Secrets are stored encrypted and never shown in full. - Shown while PayFast is ticked. Enter the merchant ID, merchant key and salt passphrase from your PayFast dashboard (Settings), and choose Sandbox to test or Live for real payments. The passphrase must match the dashboard exactly. When more than one provider is ticked, payers choose how to pay. + Shown while PayFast is ticked. Enter the merchant ID, merchant key and salt passphrase from your PayFast dashboard (Settings), and choose Sandbox to test or Live for real payments. The passphrase must match the dashboard exactly — save, then use Test connection to check. When more than one provider is ticked, payers choose how to pay. Online payments must be at least R15. Payments are recorded at the full amount paid; provider fees aren't deducted or added to the payer's total — deal with them at cashup. diff --git a/frontend/src/content/help/supervisor-payments.tsx b/frontend/src/content/help/supervisor-payments.tsx index b6c9dbb..314c146 100644 --- a/frontend/src/content/help/supervisor-payments.tsx +++ b/frontend/src/content/help/supervisor-payments.tsx @@ -25,7 +25,7 @@ export const supervisorPaymentsHelpContent: HelpContent = { Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from "Sponsor a spot" is marked as a sponsorship. Tick "Only the sponsorship pool" to assign just those, and you'll see the sponsor's message. The Donations breakdown report shows each event's pool: received, assigned and remaining. - Match unreconciled online payments (from Yoco, PayFast…) to a registration, log them as a donation, or ignore them. A payment of a different amount from its checkout lands here with a note — check it in the provider's dashboard first. + Match unreconciled online payments (from Yoco, PayFast…) to a registration, log them as a donation, or ignore them. A payment of a different amount from its checkout lands here with a note — check it in the provider's dashboard first. "Waiting for payment" lists online checkouts with no payment yet: if someone paid but it hasn't shown up, press Check status (PayFast) — it's also checked automatically every 15 minutes. Yoco can't be checked automatically: find the payment in the Yoco portal. Generate an online payment link for a user — copy it or send by email/WhatsApp. It uses the default payment provider (Site settings → Payments). Online payments must be at least R15. Generating a new link invalidates the previous one.