Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96adf20cf0 | ||
|
|
a9c7a1129b | ||
|
|
d8eb9f22cc | ||
|
|
4cae0fc672 | ||
|
|
cd33ba281e | ||
|
|
220dafb400 | ||
|
|
eb0f390215 | ||
|
|
55a7ee2970 | ||
|
|
4bc7dbc989 | ||
|
|
71b9da2458 |
@@ -31,6 +31,9 @@ yarn-error.log*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
|
||||||
|
/ecosystem.config.js
|
||||||
|
|
||||||
# misc scratch / generated files
|
# misc scratch / generated files
|
||||||
temp/
|
temp/
|
||||||
backend/public/uploads/
|
backend/public/uploads/
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.37.4] - 2026-10-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deploy script** (`scripts/deploy.sh`): without a tag it now deploys the latest `main`. The PM2 config is per install: `ecosystem.config.js` is no longer in git (the repo has `ecosystem.config.example.js`), so deploys stop overwriting the install's app names and port; the script keeps the install's copy across checkouts and rollbacks, and creates it from the example on a fresh install. The script also runs from a temporary copy (so checking out a new version of it mid-deploy is safe), and a failure rolls back once.
|
||||||
|
|
||||||
|
## [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
|
## [1.37.0] - 2026-10-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+6
-1
@@ -206,7 +206,11 @@ Feedback → Attendance**.
|
|||||||
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
|
- **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
|
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
|
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
|
- **Payment links** — supervisors can generate and send a Yoco link for any
|
||||||
amount (including partial).
|
amount (including partial).
|
||||||
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
|
- **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) |
|
| 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) |
|
| `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`) |
|
| `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`) |
|
| `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`) |
|
| `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`) |
|
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||||
|
|||||||
@@ -98,8 +98,18 @@ Documentation: see Frontend README for environment and scripts.
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
|
To update a running install, on the server in the repo folder:
|
||||||
(backs up the database, migrates, builds, reloads PM2).
|
|
||||||
|
- `bash scripts/deploy.sh` — the latest `main`
|
||||||
|
- `bash scripts/deploy.sh v1.37.4` — a tagged release
|
||||||
|
|
||||||
|
It backs up the database, checks the version out, installs, migrates, builds and reloads PM2.
|
||||||
|
If anything fails before the reload, it goes back to the previous version (the running app
|
||||||
|
isn't touched).
|
||||||
|
|
||||||
|
PM2 uses the install's own `ecosystem.config.js` (app names, frontend port). It isn't in git,
|
||||||
|
so deploys never change it; the first deploy creates it from `ecosystem.config.example.js` —
|
||||||
|
set names that match your PM2 apps (`pm2 ls`) and the port you use.
|
||||||
|
|
||||||
### Backend Deployment
|
### Backend Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ CONSENT_JOBS_ENABLED=true
|
|||||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||||
WAITLIST_JOB_ENABLED=true
|
WAITLIST_JOB_ENABLED=true
|
||||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
# 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).
|
# 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).
|
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||||
BACKUP_ENABLED=true
|
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/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 |
|
| 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 |
|
| 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`) |
|
| 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/mypayments` | user+ | Own payments |
|
||||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
| 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.
|
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.
|
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",
|
"name": "event-management-backend",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.10.1",
|
"@prisma/adapter-pg": "^7.10.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"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 prisma = require('../config/db');
|
||||||
const payments = require('../payments');
|
const payments = require('../payments');
|
||||||
|
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
|
||||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||||
const { logAdminAction } = require('../utils/adminAudit');
|
const { logAdminAction } = require('../utils/adminAudit');
|
||||||
@@ -62,15 +63,7 @@ const updatePaymentProviderSettings = async (req, res) => {
|
|||||||
async function loadCheckout(id) {
|
async function loadCheckout(id) {
|
||||||
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
|
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
|
||||||
if (!checkout) return null;
|
if (!checkout) return null;
|
||||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
return { checkout, paid: await checkoutPaid(checkout) };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
|
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 }}]},
|
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:'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:'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:'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.',
|
{ 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 }] }}},
|
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
|
||||||
@@ -1087,6 +1089,7 @@ app.get('/docs', async (req, res) => {
|
|||||||
{ title: 'PayFast', base: '/api/payfast', endpoints: [
|
{ 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:'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:'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: [
|
{ 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.',
|
{ 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',
|
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
|
||||||
run: () => require('../utils/waitlist').runWaitlistJob(),
|
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',
|
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
|
||||||
run: () => require('../utils/feedback').runFeedbackJob(),
|
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
|
* 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.
|
* 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
|
* Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
|
||||||
* the PayFast dashboard and recorded with the manual refund flow.
|
* 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');
|
const crypto = require('crypto');
|
||||||
@@ -21,7 +22,8 @@ const dns = require('dns').promises;
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
|
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.
|
// Hosts PayFast sends ITNs from.
|
||||||
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
|
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 = {
|
module.exports = {
|
||||||
id: 'payfast', label: 'PayFast', capabilities,
|
id: 'payfast', label: 'PayFast', capabilities,
|
||||||
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody,
|
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
|
||||||
// for tests
|
// 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
|
const settled = await settlePayment(provider, event, body, checkout);
|
||||||
if (checkout && tx.amountCents !== checkout.amountCents) {
|
if (settled.needsReview) {
|
||||||
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 res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
|
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
|
||||||
}
|
}
|
||||||
|
const processedData = settled.processedData;
|
||||||
const processedData = await recordSucceededPayment(provider.id, event, body);
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully processed ${event.type} webhook`,
|
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. */
|
/** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
|
||||||
async function recordSucceededPayment(providerId, event, body) {
|
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 express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getConfig, updateConfig } = require('../controllers/payfastController');
|
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
|
||||||
const { protect, admin } = require('../middleware/authMiddleware');
|
const { protect, admin } = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
router.get('/config', protect, admin, getConfig);
|
router.get('/config', protect, admin, getConfig);
|
||||||
router.put('/config', protect, admin, updateConfig);
|
router.put('/config', protect, admin, updateConfig);
|
||||||
|
router.post('/test', protect, admin, testConnection);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const {
|
|||||||
getPaymentStats,
|
getPaymentStats,
|
||||||
sendReceipt,
|
sendReceipt,
|
||||||
} = require('../controllers/paymentController');
|
} = 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');
|
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
@@ -23,6 +23,8 @@ router.post('/', protect, supervisor, createPayment);
|
|||||||
router.get('/providers', listPaymentProviders);
|
router.get('/providers', listPaymentProviders);
|
||||||
router.get('/pay/:id', getPayPage);
|
router.get('/pay/:id', getPayPage);
|
||||||
router.post('/pay/:id', choosePayProvider);
|
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.get('/providers/settings', protect, admin, getPaymentProviderSettings);
|
||||||
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||||
router.post('/checkout', protect, createCheckout);
|
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() },
|
appSetting: { findMany: jest.fn() },
|
||||||
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
|
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
|
||||||
payment: { findFirst: jest.fn(), create: jest.fn() },
|
payment: { findFirst: jest.fn(), create: jest.fn() },
|
||||||
providerTransaction: { findUnique: jest.fn(), create: jest.fn(), update: 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() },
|
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
|
||||||
ticket: { findMany: jest.fn() },
|
ticket: { findMany: jest.fn() },
|
||||||
user: { findFirst: 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/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
|
||||||
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
|
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
|
||||||
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
|
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
|
||||||
|
|||||||
+17
-12
@@ -1,6 +1,6 @@
|
|||||||
# Handover — where things stand and how to carry on
|
# 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.4**. The plan itself is in
|
||||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
[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).
|
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||||
|
|
||||||
@@ -12,23 +12,18 @@ 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.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.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.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||||
|
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
|
||||||
|
| 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.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.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) |
|
| 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
|
## Next up
|
||||||
|
|
||||||
1. **1.38.0 SnapScan → 1.39.0 Zapper** (one release each), each as a provider in
|
1. **1.38.0–1.39.0 — POPIA toolkit** (spec §3 in the roadmap).
|
||||||
`backend/src/payments` (see "Where the payment code lives" below). Get the current API
|
2. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||||
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
|
|
||||||
`joshua/hope-events-hub`).
|
`joshua/hope-events-hub`).
|
||||||
|
|
||||||
## Working on this repo
|
## Working on this repo
|
||||||
@@ -38,6 +33,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`
|
`## [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
|
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||||
version per feature.
|
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.
|
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
- **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
|
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||||
@@ -49,6 +45,11 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
|||||||
need a migration.
|
need a migration.
|
||||||
- **Server Node version:** the deployment notes install Node 20, so dependencies must
|
- **Server Node version:** the deployment notes install Node 20, so dependencies must
|
||||||
support Node 20 (that's why pg-boss is pinned to 10.x).
|
support Node 20 (that's why pg-boss is pinned to 10.x).
|
||||||
|
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). Each install
|
||||||
|
keeps its own `ecosystem.config.js` (PM2 app names, port), which is not in git. The script
|
||||||
|
runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was
|
||||||
|
tested by running it against a throwaway clone with npm/pm2 stubbed: across the tracked →
|
||||||
|
untracked change for the PM2 file, deploying main, failed builds and a bad tag.
|
||||||
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
|
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
|
||||||
saved credential); pushing from your own terminal works.
|
saved credential); pushing from your own terminal works.
|
||||||
|
|
||||||
@@ -100,6 +101,10 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
|||||||
- `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`,
|
- `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`,
|
||||||
donation split, registration status, tickets, emails, `ProviderTransaction`).
|
donation split, registration status, tickets, emails, `ProviderTransaction`).
|
||||||
- `backend/src/payments/checkouts.js` — registration checkouts (balance, part, + donation).
|
- `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
|
- **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>`
|
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
|
||||||
(`routes/webhookRoutes.js`), and give it a settings card in
|
(`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
|
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
|
(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
|
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.
|
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||||
**Next: 1.38.0 SnapScan, then Zapper.**
|
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:**
|
**Repositories:**
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
|||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
|
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)
|
3. [POPIA toolkit](#3-popia-toolkit)
|
||||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
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
|
Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
|
||||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
settings. At checkout the user picks from the enabled ones. (Shipped in
|
||||||
checkout the user picks from the enabled ones.
|
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
|
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||||
reconcile.
|
reconcile.
|
||||||
@@ -280,10 +283,10 @@ reconcile.
|
|||||||
|
|
||||||
| Setting | Options |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
|
||||||
|
|
||||||
**Fees are not handled automatically.** Payments are recorded at the full
|
**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),
|
`getTransaction`, `refund` (if the provider supports it via API),
|
||||||
`listTransactions` (for reconciliation).
|
`listTransactions` (for reconciliation).
|
||||||
- Yoco is moved behind the same interface first (no behaviour change), then
|
- Yoco is moved behind the same interface first (no behaviour change), then
|
||||||
PayFast, SnapScan and Zapper are added one release each.
|
PayFast is added.
|
||||||
- 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.
|
|
||||||
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
||||||
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
||||||
works for all providers.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.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 |
|
| 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.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 |
|
| 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.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
| 7 | 1.41.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 |
|
| 8 | 1.42.0 – 1.43.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 |
|
| 9 | 1.44.0 – 1.46.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 |
|
| 10 | 1.47.0 – 1.49.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 |
|
| 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 |
|
| 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/`),
|
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||||
@@ -756,7 +756,8 @@ feature.
|
|||||||
| — | Pro-rata rounding | Nearest R10 |
|
| — | Pro-rata rounding | Nearest R10 |
|
||||||
| — | Multi-campus | In scope (§12), inside one install |
|
| — | 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 |
|
| — | 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" |
|
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||||
| — | Minimum online payment | R15 for all providers |
|
| — | Minimum online payment | R15 for all providers |
|
||||||
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
|
| — | 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 page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 });
|
||||||
await expect(page.locator("body")).not.toContainText(/signature/i);
|
await expect(page.locator("body")).not.toContainText(/signature/i);
|
||||||
await expect(page.locator("body")).toContainText(/150[.,]00/);
|
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 {
|
} 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/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
|
||||||
await api(page, "admin", "PUT", "/api/payfast/config", { merchantId: "", merchantKey: "", passphrase: "" });
|
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 };
|
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 }] });
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// PM2 config template. Each install keeps its own copy as ecosystem.config.js (not in git,
|
||||||
|
// so deploys never change it); scripts/deploy.sh creates it from this file the first time.
|
||||||
|
// Change the app names (unique per install on a server) and the frontend port to suit.
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
|
|||||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||||
import type { Event, Payment, Registration, User } from "@/types";
|
import type { Event, Payment, Registration, User } from "@/types";
|
||||||
import { BackLink } from "@/components/shared/BackLink";
|
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
|
// 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.
|
// "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() {
|
function PaymentsContent() {
|
||||||
const { user, loading, token } = useAuth();
|
const { user, loading, token } = useAuth();
|
||||||
|
const online = useOnlinePayments();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
|
|
||||||
@@ -541,16 +544,23 @@ function PaymentsContent() {
|
|||||||
<ScanLine className="w-4 h-4" />
|
<ScanLine className="w-4 h-4" />
|
||||||
Reconcile
|
Reconcile
|
||||||
</label>
|
</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`}>
|
<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')} />
|
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
|
||||||
<Link2 className="w-4 h-4" />
|
<Link2 className="w-4 h-4" />
|
||||||
Payment Link
|
Payment Link
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{mode === 'reconcile' && (
|
||||||
|
<WaitingForPayment token={token} onRecorded={() => { loadUnreconciled(); loadPayments(); loadRegistrations(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Online payment reconciliation panel */}
|
{/* Online payment reconciliation panel */}
|
||||||
{mode === 'reconcile' && (
|
{mode === 'reconcile' && (
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
<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="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
<div className="text-lg font-semibold mb-3">Create payment link</div>
|
<div className="text-lg font-semibold mb-3">Create payment link</div>
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { HandHeart } from "lucide-react";
|
|||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
import type { Event } from "@/types";
|
import type { Event } from "@/types";
|
||||||
import { BackLink } from "@/components/shared/BackLink";
|
import { BackLink } from "@/components/shared/BackLink";
|
||||||
|
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||||
|
|
||||||
export default function DonatePage() {
|
export default function DonatePage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const online = useOnlinePayments();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [eventId, setEventId] = useState<string>("");
|
const [eventId, setEventId] = useState<string>("");
|
||||||
const [amount, setAmount] = useState<string>("");
|
const [amount, setAmount] = useState<string>("");
|
||||||
@@ -72,6 +74,11 @@ export default function DonatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>}
|
{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>}
|
{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"
|
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>
|
>{loading?"Starting checkout...":"Donate"}</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
||||||
|
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatDate } from "@/lib/date";
|
import { formatDate } from "@/lib/date";
|
||||||
import { printTicketCards } from "@/lib/ticketPrint";
|
import { printTicketCards } from "@/lib/ticketPrint";
|
||||||
@@ -81,6 +82,7 @@ function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null
|
|||||||
|
|
||||||
export default function UserDashboardPage() {
|
export default function UserDashboardPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
|
const online = useOnlinePayments();
|
||||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||||
const [error, setError] = useDismissingState<string | null>(null);
|
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"
|
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")}
|
onClick={() => router.push("/dashboard/user/payments")}
|
||||||
>Payment history</button>
|
>Payment history</button>
|
||||||
<button
|
{online.available && (
|
||||||
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"
|
<button
|
||||||
onClick={() => router.push("/dashboard/user/donate")}
|
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"
|
||||||
>Make donation</button>
|
onClick={() => router.push("/dashboard/user/donate")}
|
||||||
|
>Make donation</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
{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"
|
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 })}
|
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
|
||||||
>Invoice</button>
|
>Invoice</button>
|
||||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
|
{canModifyActive && activeBill && online.canPay(activeBill.outstanding) && (
|
||||||
<button
|
<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"
|
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)}
|
onClick={() => payNow(activeReg.id)}
|
||||||
@@ -1207,6 +1211,10 @@ export default function UserDashboardPage() {
|
|||||||
}}>Print all tickets</button>
|
}}>Print all tickets</button>
|
||||||
</div>
|
</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 */}
|
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
|
||||||
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
||||||
<div className="pt-3 border-t mt-1">
|
<div className="pt-3 border-t mt-1">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||||
@@ -375,6 +376,7 @@ function DonationPicker({
|
|||||||
|
|
||||||
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
|
const online = useOnlinePayments();
|
||||||
const eventId = baseEvent.id;
|
const eventId = baseEvent.id;
|
||||||
|
|
||||||
// A waitlist claim link (?waitlist=<token>): the places held for this person are
|
// 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 [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
|
||||||
const [customDonation, setCustomDonation] = useState("");
|
const [customDonation, setCustomDonation] = useState("");
|
||||||
const presets = useMemo(() => donationPresets(total), [total]);
|
const presets = useMemo(() => donationPresets(total), [total]);
|
||||||
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
|
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut && online.available;
|
||||||
const donation = !showDonation ? 0
|
const donation = !showDonation ? 0
|
||||||
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
|
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
|
||||||
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
|
: 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 { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
import type { Registration } from "@/types";
|
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 = () => {
|
const goDashboard = () => {
|
||||||
router.push("/dashboard/user");
|
router.push("/dashboard/user");
|
||||||
};
|
};
|
||||||
@@ -129,7 +135,7 @@ function RegistrationSuccessContent() {
|
|||||||
<div className="font-medium">This event requires attendee details.</div>
|
<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 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>
|
</div>
|
||||||
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
|
) : payOnline ? (
|
||||||
<>
|
<>
|
||||||
{donation > 0 && (
|
{donation > 0 && (
|
||||||
<p className="text-gray-700 mb-2">
|
<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>
|
<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}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mt-4">
|
<div className="flex flex-col sm:flex-row gap-3 mt-4">
|
||||||
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
|
{payOnline && (
|
||||||
<button
|
<button
|
||||||
onClick={goPay}
|
onClick={goPay}
|
||||||
disabled={!registrationId || payLoading}
|
disabled={!registrationId || payLoading}
|
||||||
@@ -151,7 +159,7 @@ function RegistrationSuccessContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={goDashboard}
|
onClick={goDashboard}
|
||||||
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
|
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>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
|
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
|
||||||
{!registrationId && (
|
{!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 () => {
|
const removePassphrase = async () => {
|
||||||
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
|
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
|
||||||
try {
|
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>.
|
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>}
|
{cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>}
|
||||||
</div>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HeartHandshake, X } from "lucide-react";
|
|||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { errorMessage } from "@/lib/utils";
|
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 };
|
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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [returned, setReturned] = useState<string | null>(null);
|
const [returned, setReturned] = useState<string | null>(null);
|
||||||
|
const online = useOnlinePayments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
|
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 */ }
|
} catch { /* no window */ }
|
||||||
}, [eventId]);
|
}, [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 price = data.spotPrice || null;
|
||||||
const loggedIn = !!(token && user);
|
const loggedIn = !!(token && user);
|
||||||
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
|
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.
|
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>
|
||||||
<GuideItem icon={KeyRound} title="PayFast" tone="green">
|
<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>
|
||||||
<GuideItem icon={CreditCard} title="Minimum and fees" tone="gray">
|
<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.
|
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.
|
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>
|
||||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
<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>
|
||||||
<GuideItem icon={Link2} title="Payment Link" tone="violet">
|
<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.
|
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",
|
"name": "hope-events",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.64.0",
|
"@playwright/test": "^1.64.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.37.0",
|
"version": "1.37.4",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
+63
-13
@@ -1,31 +1,81 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Updates this install to a tagged release: back up the database, check out the tag,
|
# Updates this install: back up the database, check out the release, install, migrate,
|
||||||
# install, migrate, build, and reload PM2. If anything fails before the reload, the
|
# build, and reload PM2. If anything fails before the reload, the previous version keeps
|
||||||
# previous version keeps running and the checkout goes back to it.
|
# running and the checkout goes back to it.
|
||||||
#
|
#
|
||||||
# Usage (on the server, from the repo folder): bash scripts/deploy.sh v1.34.0
|
# Usage (on the server, from the repo folder):
|
||||||
# CI runs this over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
|
# bash scripts/deploy.sh # the latest main
|
||||||
set -euo pipefail
|
# bash scripts/deploy.sh v1.37.3 # a tagged release
|
||||||
|
# CI runs it with the tag over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
|
||||||
|
#
|
||||||
|
# PM2 uses this install's own ecosystem.config.js (app names, ports). It isn't in git, so
|
||||||
|
# deploys never change it; the first deploy creates it from ecosystem.config.example.js.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
TAG="${1:?Usage: scripts/deploy.sh <tag>}"
|
# The checkout below replaces this file, and bash reads a script as it runs — so run from a
|
||||||
cd "$(dirname "$0")/.."
|
# copy that can't change underneath us.
|
||||||
|
if [ -z "${HOPE_DEPLOY_COPY:-}" ]; then
|
||||||
|
COPY="$(mktemp)"
|
||||||
|
cp "$0" "$COPY"
|
||||||
|
HOPE_DEPLOY_COPY=1 HOPE_DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)" exec bash "$COPY" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$HOPE_DEPLOY_DIR"
|
||||||
|
TARGET="${1:-main}"
|
||||||
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
||||||
|
|
||||||
echo "==> Deploying $TAG (currently $PREVIOUS)"
|
# Keep this install's PM2 config safe across the checkout (older releases tracked it in git).
|
||||||
|
PM2_CONFIG="ecosystem.config.js"
|
||||||
|
PM2_KEEP="$(mktemp)"
|
||||||
|
HAVE_PM2_CONFIG=0
|
||||||
|
if [ -f "$PM2_CONFIG" ]; then cp "$PM2_CONFIG" "$PM2_KEEP"; HAVE_PM2_CONFIG=1; fi
|
||||||
|
trap 'rm -f "$0" "$PM2_KEEP"' EXIT
|
||||||
|
# Out of the way before a checkout: back to the committed version if git tracks it in this
|
||||||
|
# release, otherwise removed (a release that still tracks it would refuse to overwrite it).
|
||||||
|
clear_pm2_config() {
|
||||||
|
git checkout -q -- "$PM2_CONFIG" 2>/dev/null || rm -f "$PM2_CONFIG"
|
||||||
|
}
|
||||||
|
restore_pm2_config() {
|
||||||
|
if [ "$HAVE_PM2_CONFIG" = 1 ]; then cp "$PM2_KEEP" "$PM2_CONFIG"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check out a tag, or the latest main (detached, so local main is never in the way).
|
||||||
|
checkout() {
|
||||||
|
clear_pm2_config
|
||||||
|
if [ "$1" = "main" ]; then
|
||||||
|
git fetch --quiet origin main
|
||||||
|
git checkout -q --detach origin/main
|
||||||
|
else
|
||||||
|
git fetch --tags --quiet origin
|
||||||
|
git checkout -q "$1"
|
||||||
|
fi
|
||||||
|
restore_pm2_config
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Deploying $TARGET (currently $PREVIOUS)"
|
||||||
|
|
||||||
echo "==> Backing up the database"
|
echo "==> Backing up the database"
|
||||||
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
|
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
|
||||||
|
|
||||||
rollback() {
|
rollback() {
|
||||||
|
# set -E passes this trap into ( … ) subshells too; only the script itself rolls back.
|
||||||
|
if [ "$BASH_SUBSHELL" -gt 0 ]; then return; fi
|
||||||
echo "!!> Deploy failed — going back to $PREVIOUS (the running version was not touched)"
|
echo "!!> Deploy failed — going back to $PREVIOUS (the running version was not touched)"
|
||||||
|
clear_pm2_config
|
||||||
git checkout -q "$PREVIOUS"
|
git checkout -q "$PREVIOUS"
|
||||||
|
restore_pm2_config
|
||||||
(cd backend && npm ci --silent) || true
|
(cd backend && npm ci --silent) || true
|
||||||
(cd frontend && npm ci --silent && npm run build --silent) || true
|
(cd frontend && npm ci --silent && npm run build --silent) || true
|
||||||
}
|
}
|
||||||
trap rollback ERR
|
trap rollback ERR
|
||||||
|
|
||||||
git fetch --tags --quiet origin
|
checkout "$TARGET"
|
||||||
git checkout -q "$TAG"
|
DEPLOYED="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
if [ ! -f "$PM2_CONFIG" ]; then
|
||||||
|
cp ecosystem.config.example.js "$PM2_CONFIG"
|
||||||
|
echo " Created $PM2_CONFIG from the example — check its app names and port."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Backend: install and migrate"
|
echo "==> Backend: install and migrate"
|
||||||
(cd backend && npm ci && npm run prisma:deploy)
|
(cd backend && npm ci && npm run prisma:deploy)
|
||||||
@@ -35,6 +85,6 @@ echo "==> Frontend: install and build"
|
|||||||
|
|
||||||
trap - ERR
|
trap - ERR
|
||||||
echo "==> Reloading PM2"
|
echo "==> Reloading PM2"
|
||||||
pm2 reload ecosystem.config.js --update-env
|
pm2 reload "$PM2_CONFIG" --update-env
|
||||||
|
|
||||||
echo "==> Deployed $TAG"
|
echo "==> Deployed $TARGET ($DEPLOYED)"
|
||||||
|
|||||||
Reference in New Issue
Block a user