Compare commits
9
Commits
feat/payfast
...
v1.37.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8eb9f22cc | ||
|
|
4cae0fc672 | ||
|
|
cd33ba281e | ||
|
|
220dafb400 | ||
|
|
eb0f390215 | ||
|
|
55a7ee2970 | ||
|
|
4bc7dbc989 | ||
|
|
71b9da2458 | ||
|
|
654f51b86d |
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.37.3] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
|
||||
|
||||
## [1.37.2] - 2026-10-23
|
||||
|
||||
### 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
|
||||
|
||||
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
|
||||
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
|
||||
|
||||
## [1.37.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+6
-1
@@ -206,7 +206,11 @@ Feedback → Attendance**.
|
||||
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
|
||||
modes. When several providers are on, payers choose on the "Pay online" page; a
|
||||
payment of a different amount from the checkout is held for review instead of being
|
||||
recorded.
|
||||
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.
|
||||
@@ -562,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`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<checkoutId>` 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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Map<string, object>>} 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,
|
||||
};
|
||||
|
||||
@@ -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<Map<string, { status: 'paid'|'recorded'|'needs_review'|'not_paid'|'cant_check', note? }>>}
|
||||
*/
|
||||
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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' })]);
|
||||
});
|
||||
});
|
||||
@@ -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() }));
|
||||
|
||||
+11
-12
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.37.0**. The plan itself is in
|
||||
Last updated: 2026-10-23, after **1.37.3**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -12,23 +12,17 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
|
||||
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
|
||||
| 1.37.1 | "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap |
|
||||
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
|
||||
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
|
||||
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.38.0 SnapScan → 1.39.0 Zapper** (one release each), each as a provider in
|
||||
`backend/src/payments` (see "Where the payment code lives" below). Get the current API
|
||||
documentation links from Joshua before building each one, and don't guess at an API —
|
||||
sandbox/test credentials needed. They're QR/app payments: the `/pay/<id>` page is where the
|
||||
QR shows (desktop) or the app deep link opens (mobile), polling until the webhook lands.
|
||||
Still open from the roadmap: hide "pay online" buttons below R15 / when no provider is on
|
||||
(today the server refuses with a clear message), and a "check payment status" /
|
||||
reconcile job for webhooks that never arrive. Decisions: fees are **never** deducted or
|
||||
passed on (people handle fees at cashup); minimum online payment **R15** for all providers.
|
||||
2. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
|
||||
3. **1.42.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
|
||||
@@ -38,6 +32,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||
@@ -100,6 +95,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/<id>.js` with the same interface, add it to
|
||||
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
|
||||
(`routes/webhookRoutes.js`), and give it a settings card in
|
||||
|
||||
+23
-22
@@ -17,8 +17,10 @@ reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gite
|
||||
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
|
||||
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
|
||||
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page.
|
||||
**Next: 1.38.0 SnapScan, then Zapper.**
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23).
|
||||
**Next: 1.38.0 POPIA toolkit.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -32,7 +34,7 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
## Contents
|
||||
|
||||
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
|
||||
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
|
||||
2. [Payment providers: PayFast](#2-payment-providers-payfast)
|
||||
3. [POPIA toolkit](#3-popia-toolkit)
|
||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
||||
@@ -267,11 +269,12 @@ it's not used for check-in decisions; "checked in" is per session
|
||||
|
||||
---
|
||||
|
||||
## 2. Payment providers: PayFast, SnapScan, Zapper
|
||||
## 2. Payment providers: PayFast
|
||||
|
||||
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
|
||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
||||
checkout the user picks from the enabled ones.
|
||||
Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
|
||||
settings. At checkout the user picks from the enabled ones. (Shipped in
|
||||
1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23:
|
||||
two providers are enough.)
|
||||
|
||||
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||
reconcile.
|
||||
@@ -280,10 +283,10 @@ reconcile.
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
|
||||
| Providers | Yoco · PayFast — each with an **Enabled** toggle |
|
||||
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
|
||||
| Mode | Live / Sandbox per provider (where supported) |
|
||||
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
|
||||
| Display order / default | Which one is shown first. Default order: Yoco · PayFast |
|
||||
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
|
||||
|
||||
**Fees are not handled automatically.** Payments are recorded at the full
|
||||
@@ -300,10 +303,7 @@ total. Fees are dealt with by people during **cashup**, as today. There is no
|
||||
`getTransaction`, `refund` (if the provider supports it via API),
|
||||
`listTransactions` (for reconciliation).
|
||||
- Yoco is moved behind the same interface first (no behaviour change), then
|
||||
PayFast, SnapScan and Zapper are added one release each.
|
||||
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
|
||||
deep-link into the app; the page polls/waits for the webhook and moves on
|
||||
automatically when payment lands.
|
||||
PayFast is added.
|
||||
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
||||
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
||||
works for all providers.
|
||||
@@ -726,14 +726,14 @@ keep adding to.
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 – 1.39.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.40.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.41.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.42.0 – 1.43.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.44.0 – 1.46.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.47.0 – 1.49.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.50.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -756,7 +756,8 @@ feature.
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
|
||||
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
|
||||
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
| — | Minimum online payment | R15 for all providers |
|
||||
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
+30
-1
@@ -1,4 +1,4 @@
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
|
||||
|
||||
@@ -34,4 +34,33 @@ test.describe("payment providers", () => {
|
||||
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }] });
|
||||
}
|
||||
});
|
||||
|
||||
test("with no provider on, nothing offers to pay online", async ({ page }) => {
|
||||
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
|
||||
const event = await api<EventWithOptions>(page, "admin", "GET", `/api/events/${seed().events.paid}`);
|
||||
const option = event.eventOptions.find((o) => o.isMainTicket)!;
|
||||
const reg = await api<{ id: string }>(page, "admin", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
|
||||
|
||||
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: false }, { id: "payfast", enabled: false }] });
|
||||
try {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(`/registration/success?registrationId=${reg.id}`);
|
||||
await expect(page.getByText(/Online payment isn.t available/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Pay online" })).toHaveCount(0);
|
||||
|
||||
await page.goto("/dashboard/user");
|
||||
await expect(page.getByRole("button", { name: "Make donation" })).toHaveCount(0);
|
||||
await page.goto("/dashboard/user/donate");
|
||||
await expect(page.getByText(/Online donations aren.t available/)).toBeVisible();
|
||||
} finally {
|
||||
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
|
||||
}
|
||||
|
||||
// Back on (when Yoco is set up in this environment), the button returns
|
||||
const on = await api<{ providers: unknown[] }>(page, "admin", "GET", "/api/payments/providers");
|
||||
if (on.providers.length > 0) {
|
||||
await page.goto(`/registration/success?registrationId=${reg.id}`);
|
||||
await expect(page.getByRole("button", { name: "Pay online" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
|
||||
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.
|
||||
@@ -145,6 +147,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
|
||||
|
||||
function PaymentsContent() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
|
||||
@@ -541,16 +544,23 @@ function PaymentsContent() {
|
||||
<ScanLine className="w-4 h-4" />
|
||||
Reconcile
|
||||
</label>
|
||||
{/* Payment links are paid online, so only while a payment provider is on */}
|
||||
{online.available && (
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
|
||||
<Link2 className="w-4 h-4" />
|
||||
Payment Link
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{mode === 'reconcile' && (
|
||||
<WaitingForPayment token={token} onRecorded={() => { loadUnreconciled(); loadPayments(); loadRegistrations(); }} />
|
||||
)}
|
||||
|
||||
{/* Online payment reconciliation panel */}
|
||||
{mode === 'reconcile' && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
@@ -758,7 +768,7 @@ function PaymentsContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'link' && (
|
||||
{mode === 'link' && online.available && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">Create payment link</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
|
||||
@@ -7,9 +7,11 @@ import { HandHeart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
|
||||
export default function DonatePage() {
|
||||
const { token } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
@@ -72,6 +74,11 @@ export default function DonatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{online.ready && !online.available ? (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm text-sm text-gray-700">
|
||||
Online donations aren't available at the moment. You can give at an event, or contact the organiser.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
|
||||
@@ -99,6 +106,7 @@ export default function DonatePage() {
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
|
||||
>{loading?"Starting checkout...":"Donate"}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { printTicketCards } from "@/lib/ticketPrint";
|
||||
@@ -81,6 +82,7 @@ function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null
|
||||
|
||||
export default function UserDashboardPage() {
|
||||
const { token, user } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -654,10 +656,12 @@ export default function UserDashboardPage() {
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user/payments")}
|
||||
>Payment history</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user/donate")}
|
||||
>Make donation</button>
|
||||
{online.available && (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user/donate")}
|
||||
>Make donation</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
@@ -1194,7 +1198,7 @@ export default function UserDashboardPage() {
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
|
||||
>Invoice</button>
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
|
||||
{canModifyActive && activeBill && online.canPay(activeBill.outstanding) && (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => payNow(activeReg.id)}
|
||||
@@ -1207,6 +1211,10 @@ export default function UserDashboardPage() {
|
||||
}}>Print all tickets</button>
|
||||
</div>
|
||||
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && online.reason(activeBill.outstanding) && (
|
||||
<p className="text-xs text-gray-500">{online.reason(activeBill.outstanding)}</p>
|
||||
)}
|
||||
|
||||
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
|
||||
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
||||
<div className="pt-3 border-t mt-1">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
@@ -375,6 +376,7 @@ function DonationPicker({
|
||||
|
||||
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
||||
const { token, user } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const eventId = baseEvent.id;
|
||||
|
||||
// A waitlist claim link (?waitlist=<token>): the places held for this person are
|
||||
@@ -507,7 +509,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
|
||||
const [customDonation, setCustomDonation] = useState("");
|
||||
const presets = useMemo(() => donationPresets(total), [total]);
|
||||
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
|
||||
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut && online.available;
|
||||
const donation = !showDonation ? 0
|
||||
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
|
||||
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
@@ -108,6 +109,11 @@ function RegistrationSuccessContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const online = useOnlinePayments();
|
||||
const owed = totalDue > 0 || (!reg && fallbackTotalDue > 0);
|
||||
const payAmount = (reg ? totalDue : fallbackTotalDue) + donation;
|
||||
const payOnline = owed && online.canPay(payAmount);
|
||||
|
||||
const goDashboard = () => {
|
||||
router.push("/dashboard/user");
|
||||
};
|
||||
@@ -129,7 +135,7 @@ function RegistrationSuccessContent() {
|
||||
<div className="font-medium">This event requires attendee details.</div>
|
||||
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
|
||||
</div>
|
||||
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
|
||||
) : payOnline ? (
|
||||
<>
|
||||
{donation > 0 && (
|
||||
<p className="text-gray-700 mb-2">
|
||||
@@ -138,10 +144,12 @@ function RegistrationSuccessContent() {
|
||||
)}
|
||||
<p className="text-gray-700 mb-6">Would you like to pay online now?</p>
|
||||
</>
|
||||
) : owed && online.reason(payAmount) ? (
|
||||
<p className="text-gray-700 mb-6">{online.reason(payAmount)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-4">
|
||||
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
|
||||
{payOnline && (
|
||||
<button
|
||||
onClick={goPay}
|
||||
disabled={!registrationId || payLoading}
|
||||
@@ -151,7 +159,7 @@ function RegistrationSuccessContent() {
|
||||
<button
|
||||
onClick={goDashboard}
|
||||
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||
>{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
|
||||
>{payOnline ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
|
||||
{!registrationId && (
|
||||
|
||||
@@ -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 <span className="font-mono break-all">{cfg.notifyUrl || "(not set)"}</span>.
|
||||
{cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>}
|
||||
</div>
|
||||
<button type="submit" className={primaryBtn} disabled={saving}>{saving ? "Saving…" : "Save PayFast settings"}</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="submit" className={primaryBtn} disabled={saving}>{saving ? "Saving…" : "Save PayFast settings"}</button>
|
||||
{cfg.configured && (
|
||||
<button type="button" className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50" disabled={testing} onClick={test}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Test connection checks the saved settings — save any changes first.</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
@@ -30,6 +31,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returned, setReturned] = useState<string | null>(null);
|
||||
const online = useOnlinePayments();
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
|
||||
@@ -39,7 +41,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
} catch { /* no window */ }
|
||||
}, [eventId]);
|
||||
|
||||
if (!data?.enabled) return null;
|
||||
// Sponsoring is paid online, so it's offered only when online payment is possible.
|
||||
if (!data?.enabled || (!online.available && !returned)) return null;
|
||||
const price = data.spotPrice || null;
|
||||
const loggedIn = !!(token && user);
|
||||
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
|
||||
|
||||
@@ -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<CheckResult["status"], string> = {
|
||||
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<OpenCheckout[] | null>(null);
|
||||
const [results, setResults] = useState<Record<string, CheckResult>>({});
|
||||
const [busy, setBusy] = useState<string | null>(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<string, CheckResult>; 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 (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="text-lg font-semibold">Waiting for payment</div>
|
||||
<div className="flex gap-2">
|
||||
{checkable && (
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50" disabled={!!busy} onClick={() => check()}>
|
||||
{busy === "all" ? "Checking…" : "Check all"}
|
||||
</button>
|
||||
)}
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => { setResults({}); load(); }}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
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.
|
||||
</p>
|
||||
{message && <div className={`text-sm mb-3 p-2 rounded ${message.ok ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
|
||||
{!list ? (
|
||||
<div className="text-sm text-gray-500">Loading…</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">Nothing waiting.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto w-full">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="py-2 pr-3">Started</th>
|
||||
<th className="py-2 pr-3">Payer</th>
|
||||
<th className="py-2 pr-3">For</th>
|
||||
<th className="py-2 pr-3">Amount</th>
|
||||
<th className="py-2 pr-3">Provider</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map(c => {
|
||||
const result = results[c.id];
|
||||
return (
|
||||
<tr key={c.id} className="border-b align-top">
|
||||
<td className="py-2 pr-3 whitespace-nowrap">{new Date(c.createdAt).toLocaleString()}</td>
|
||||
<td className="py-2 pr-3">{c.payer || "—"}</td>
|
||||
<td className="py-2 pr-3">{c.description || (c.type === "donation" ? "Donation" : "—")}</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap">R {c.amount.toFixed(2)}</td>
|
||||
<td className="py-2 pr-3">{c.providerLabel}</td>
|
||||
<td className="py-2">
|
||||
{result ? (
|
||||
<span className={result.status === "recorded" || result.status === "paid" ? "text-green-700" : result.status === "needs_review" ? "text-amber-800" : "text-gray-600"}>
|
||||
{RESULT_TEXT[result.status]}
|
||||
</span>
|
||||
) : c.canCheck ? (
|
||||
<button className="px-2 py-1 rounded border border-brand-300 text-brand-700 hover:bg-brand-50 disabled:opacity-50" disabled={!!busy} onClick={() => check([c.id])}>
|
||||
{busy === c.id ? "Checking…" : "Check status"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">Can't check automatically — look in the {c.providerLabel} portal, then use Reconcile</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
</GuideItem>
|
||||
<GuideItem icon={KeyRound} title="PayFast" tone="green">
|
||||
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.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CreditCard} title="Minimum and fees" tone="gray">
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
||||
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.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Link2} title="Payment Link" tone="violet">
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ProvidersResponse = { providers: { id: string; label: string }[]; minimumAmount: number };
|
||||
|
||||
export type OnlinePayments = {
|
||||
/** The answer has arrived (until then, nothing offers online payment). */
|
||||
ready: boolean;
|
||||
/** At least one payment provider is switched on and set up. */
|
||||
available: boolean;
|
||||
/** Online payments must be at least this much (R15). */
|
||||
minimum: number;
|
||||
/** Whether `amount` can be paid online now. */
|
||||
canPay: (amount: number) => boolean;
|
||||
/** Why `amount` can't be paid online, for showing instead of the button; null when it can. */
|
||||
reason: (amount: number) => string | null;
|
||||
};
|
||||
|
||||
// One request per page load, shared by every component that asks.
|
||||
let pending: Promise<ProvidersResponse | null> | null = null;
|
||||
function load() {
|
||||
if (!pending) pending = apiFetch<ProvidersResponse>("/api/payments/providers").catch(() => null);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether people can pay online: only when a payment provider is enabled (Site settings →
|
||||
* Payments) and the amount is at least the online minimum. "Pay online" buttons use this to
|
||||
* stay hidden otherwise.
|
||||
*/
|
||||
export function useOnlinePayments(): OnlinePayments {
|
||||
const [state, setState] = useState<{ ready: boolean; available: boolean; minimum: number }>({ ready: false, available: false, minimum: 15 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
load().then(res => {
|
||||
if (!cancelled) setState({ ready: true, available: !!res && res.providers.length > 0, minimum: res?.minimumAmount ?? 15 });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const canPay = (amount: number) => state.ready && state.available && amount >= state.minimum;
|
||||
const reason = (amount: number) => {
|
||||
if (!state.ready || canPay(amount)) return null;
|
||||
if (!state.available) return "Online payment isn't available at the moment. You can pay at the event, or contact the organiser.";
|
||||
return `Online payments must be at least R${state.minimum}. You can pay this at the event.`;
|
||||
};
|
||||
return { ...state, canPay, reason };
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user