PayFast provider, Pay online page, amount checks on payments

- PayFast (custom integration): signed form (PHP-style encoding, checked
  against a real sandbox), ITN checks (signature over all posted fields,
  PayFast host, VALID from /eng/query/validate, amount vs checkout).
- ProviderCheckout records every checkout (amount, metadata, request);
  webhooks match payments to it and hold a different amount for review
  (ProviderTransaction.note).
- /pay/<id>: payers choose a provider when several are on, and the
  browser posts PayFast's form from there, so payment links work for
  every provider.
- Site settings → Payments: PayFast card (merchant ID/key, encrypted
  passphrase, sandbox/live) while PayFast is enabled.
- Payer-facing wording no longer names Yoco; legal pages name both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:25:49 +02:00
co-authored by Claude Opus 5.5
parent 25e3e9d3a6
commit f2b51dbd88
42 changed files with 1220 additions and 109 deletions
+10
View File
@@ -39,6 +39,16 @@ FRONTEND_URL=http://localhost:3000
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Payments (PayFast) — optional fallbacks ──────────────────────────────────
# Preferred: Admin → Site Settings → Payments → PayFast. Only used if the database has no value.
# PAYFAST_MERCHANT_ID=
# PAYFAST_MERCHANT_KEY=
# PAYFAST_PASSPHRASE=
# PAYFAST_MODE=sandbox
# PayFast notifications (ITNs) must come from a PayFast address. Behind a reverse proxy, set
# TRUST_PROXY so the real address is seen. Only for testing through a tunnel:
# PAYFAST_SKIP_IP_CHECK=true
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
APP_BASE_URL=https://your-frontend-domain.com
+8 -1
View File
@@ -579,6 +579,8 @@ Options and variants also accept an optional `staffPrice`, charged automatically
| POST | `/api/payments/checkout/send` | supervisor+ | Send a generated payment link by email or WhatsApp (also `/api/payments/yoco-checkout/send`) |
| GET | `/api/payments/providers` | public | Providers people can pay with now (enabled and set up), in order: `{ providers: [{ id, label }], minimumAmount: 15 }` |
| GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) |
| GET | `/api/payments/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it |
| POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid |
| PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) |
| GET | `/api/payments/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID |
@@ -784,6 +786,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/webhooks/payfast` | PayFast ITN | Form-encoded. Checked as PayFast requires: signature (every posted field + passphrase), sender is a PayFast host (`PAYFAST_SKIP_IP_CHECK=true` turns this off for tunnels; behind a proxy set `TRUST_PROXY`), PayFast answers VALID at `/eng/query/validate`, and `amount_gross` equals the checkout (otherwise held for review with a `note`). `COMPLETE` records the payment (`provider: "payfast"`) |
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies the `webhook-signature` header (src/payments/providers/yoco.js), creates `Payment` (`provider: "yoco"`) + `ProviderTransaction`, updates registration status, generates tickets (src/payments/webhook.js). Processed even when Yoco is switched off, so checkouts already started complete |
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
@@ -805,7 +808,11 @@ For what each individual setting key defaults to when it's never been saved, see
## Yoco
Managed from Admin → Site Settings → Payments. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
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`).
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.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -0,0 +1,24 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'payfast_config_updated';
-- AlterTable
ALTER TABLE "ProviderTransaction" ADD COLUMN "note" TEXT;
-- CreateTable
CREATE TABLE "ProviderCheckout" (
"id" TEXT NOT NULL,
"provider" TEXT,
"externalId" TEXT,
"redirectUrl" TEXT,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ZAR',
"metadata" JSONB NOT NULL,
"request" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderCheckout_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProviderCheckout_provider_externalId_idx" ON "ProviderCheckout"("provider", "externalId");
+25
View File
@@ -564,6 +564,7 @@ enum AdminAuditAction {
yoco_webhook_registered
yoco_webhook_deleted
payment_providers_updated
payfast_config_updated
event_sessions_changed
attendance_marked
attendance_unmarked
@@ -700,6 +701,9 @@ model ProviderTransaction {
paymentId String?
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull)
raw Json?
// Why it wasn't recorded automatically and needs a look (e.g. paid a different amount
// from the checkout).
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -707,6 +711,27 @@ model ProviderTransaction {
@@index([paymentId])
}
// An online checkout we started: what it's for and how much, so the payment that comes back
// can be checked against it. Its id is the payer's link (/pay/<id>) and, for PayFast, the
// m_payment_id. `provider` is null until the payer picks one (when several are enabled).
model ProviderCheckout {
id String @id @default(uuid())
provider String?
// The provider's own checkout id (Yoco) and where it sends the payer.
externalId String?
redirectUrl String?
amountCents Int
currency String @default("ZAR")
// Who/what the payment is for: registrationId, userId, eventId, type, donationAmount, …
metadata Json
// Everything needed to start the checkout with any provider: lineItems, return URLs, payer.
request Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([provider, externalId])
}
model Section {
id String @id @default(uuid())
eventId String
@@ -0,0 +1,83 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { maskSecret } = require('../utils/yoco');
const {
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
getPayfastConfig, payfastNotifyUrl, notifyUrlProblem,
} = require('../utils/payfast');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// @desc PayFast settings (passphrase masked) and the ITN notify URL
// @route GET /api/payfast/config
// @access Admin
const getConfig = async (req, res) => {
try {
const c = await getPayfastConfig();
const notifyUrl = payfastNotifyUrl();
res.json({
merchantId: c.merchantId,
merchantKey: c.merchantKey,
passphraseMasked: c.passphrase ? maskSecret(c.passphrase) : '',
hasPassphrase: !!c.passphrase,
mode: c.mode,
notifyUrl,
notifyUrlProblem: notifyUrlProblem(notifyUrl),
configured: !!(c.merchantId && c.merchantKey),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save PayFast settings ('' clears the passphrase; omitted fields stay as they are)
// @route PUT /api/payfast/config { merchantId?, merchantKey?, passphrase?, mode? }
// @access Admin
const updateConfig = async (req, res) => {
try {
const { merchantId, merchantKey, passphrase, mode } = req.body || {};
const updates = {};
if (typeof merchantId === 'string') {
const v = merchantId.trim();
if (v && !/^\d{5,12}$/.test(v)) { res.status(400); throw new Error('The merchant ID is a number, e.g. 10000100.'); }
updates[KEY_MERCHANT_ID] = v;
}
if (typeof merchantKey === 'string') {
const v = merchantKey.trim();
if (v && !/^[A-Za-z0-9]{8,32}$/.test(v)) { res.status(400); throw new Error('The merchant key is letters and numbers, as shown on your PayFast dashboard.'); }
updates[KEY_MERCHANT_KEY] = v;
}
if (typeof passphrase === 'string') {
// PayFast passphrases are 32 characters at most; spaces at the ends are trimmed by PayFast.
if (passphrase.trim().length > 32) { res.status(400); throw new Error('The passphrase can be at most 32 characters.'); }
updates[KEY_PASSPHRASE] = passphrase.trim();
}
if (mode !== undefined) {
if (!['sandbox', 'live'].includes(mode)) { res.status(400); throw new Error('Mode is sandbox or live.'); }
updates[KEY_MODE] = mode;
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await prisma.$transaction(Object.entries(updates).map(([key, value]) => {
const stored = value && key === KEY_PASSPHRASE ? encrypt(value) : value;
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
}));
invalidateSettingsCache();
await warmCache();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'payfast_config_updated', targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates), ...(updates[KEY_MODE] ? { mode: updates[KEY_MODE] } : {}) },
ip: getClientIp(req),
});
res.json({ message: 'PayFast settings saved' });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig };
@@ -1010,6 +1010,8 @@ const createCheckout = async (req, res) => {
amountCents: amountInCents,
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
description: `Donation to ${event.title}`,
payer: { name: req.user.name || '', email: req.user.email || '' },
successUrl, cancelUrl, failureUrl,
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
});
@@ -58,4 +58,72 @@ const updatePaymentProviderSettings = async (req, res) => {
}
};
module.exports = { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings };
/** A checkout and whether it has been paid (a payment came back for it, or its registration is paid). */
async function loadCheckout(id) {
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
if (!checkout) return null;
const refs = [checkout.id, checkout.externalId].filter(Boolean);
const paidTx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
let paid = !!paidTx;
const registrationId = checkout.metadata?.registrationId;
if (!paid && registrationId) {
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
paid = reg?.status === 'paid';
}
return { checkout, paid };
}
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
// @desc A checkout for the payer's page (/pay/:id): amount, what it's for, the providers
// they can pay with, and — once a form provider (PayFast) is chosen — its signed form
// @route GET /api/payments/pay/:id
// @access Public (the checkout id is the credential)
const getPayPage = async (req, res) => {
try {
const found = await loadCheckout(req.params.id);
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
const { checkout, paid } = found;
const request = checkout.request || {};
const out = {
id: checkout.id,
status: paid ? 'paid' : 'open',
amount: checkout.amountCents / 100,
description: request.description || '',
provider: checkout.provider,
providers: providerList(await payments.availableProviders()),
cancelUrl: request.cancelUrl || null,
redirectUrl: null,
form: null,
};
if (!paid && checkout.provider) {
const provider = payments.getProvider(checkout.provider);
if (checkout.redirectUrl) out.redirectUrl = checkout.redirectUrl;
else if (provider) out.form = (await payments.startCheckout(checkout, provider)).form || null;
}
res.json(out);
} catch (e) {
fail(res, e);
}
};
// @desc Start a checkout with the provider the payer picked
// @route POST /api/payments/pay/:id { provider }
// @access Public (the checkout id is the credential)
const choosePayProvider = async (req, res) => {
try {
const found = await loadCheckout(req.params.id);
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
if (found.paid) { res.status(409); throw new Error('This has already been paid.'); }
const provider = payments.getProvider(req.body?.provider);
if (!provider) { res.status(400); throw new Error('Choose how you\'d like to pay.'); }
const usable = (await payments.availableProviders()).some(p => p.id === provider.id);
if (!usable) { res.status(400); throw new Error(`Paying with ${provider.label} isn't available right now.`); }
const started = await payments.startCheckout(found.checkout, provider);
res.json({ provider: provider.id, redirectUrl: started.form ? null : started.redirectUrl, form: started.form || null });
} catch (e) {
fail(res, e);
}
};
module.exports = { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider };
+10 -7
View File
@@ -8,9 +8,12 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
// through the generic settings endpoints.
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
// Payment provider settings are only readable (secrets masked) and writable through
// /api/yoco and /api/payfast — never through the generic settings endpoints.
const PAYMENT_PRIVATE_KEYS = new Set([
'yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id',
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
]);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
@@ -50,7 +53,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -86,7 +89,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -125,7 +128,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -202,7 +205,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -84,6 +84,8 @@ const createSponsorCheckout = async (req, res) => {
cancelUrl: back,
failureUrl: `${back}?sponsored=failed`,
lineItems: [{ name: label, quantity: 1, unitCents: amountInCents }],
description: label,
payer: { name: payer.name || '', email: payer.email || '' },
idempotencyKey: `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}`,
});
res.json({ redirectUrl: checkout.redirectUrl, checkoutId: checkout.checkoutId, provider: checkout.provider, amount: charge.amount });
+9
View File
@@ -147,6 +147,7 @@ const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
const payfastRoutes = require('./routes/payfastRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -190,6 +191,7 @@ app.use('/api/attendance', require('./routes/attendanceRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
app.use('/api/payfast', payfastRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -801,6 +803,8 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/payments/checkout/send', auth:'supervisor+', desc:'Send a generated payment link to the registrant by email or WhatsApp (also at /api/payments/yoco-checkout/send)', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'GET', path:'/api/payments/providers', auth:'—', desc:'Payment providers people can pay with now (enabled in Site settings → Payments and set up), in display order, plus the online minimum (R15)',
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
@@ -1071,6 +1075,7 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/payfast', auth:'PayFast ITN', desc:'PayFast payment notification (form-encoded). Checked as PayFast requires: signature, PayFast host, VALID from /eng/query/validate, and amount_gross equal to the checkout (otherwise held for review). COMPLETE records the payment.' },
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
@@ -1079,6 +1084,10 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'PayFast', base: '/api/payfast', endpoints: [
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
]},
{ 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.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
+5 -2
View File
@@ -1,8 +1,9 @@
/**
* Online checkouts for registrations (the balance, part of it, or the balance plus a
* donation). Used by the payment pages, payment links in emails and invoices, and the
* self-service kiosk. The provider is the one asked for or the first available one
* (./index.js); the registration remembers the checkout so the webhook can find it.
* self-service kiosk. The provider is the one asked for, the only available one, or the
* payer's choice on /pay/<id> (./index.js); the registration remembers the checkout so the
* webhook can find it.
*/
const prisma = require('../config/db');
@@ -76,6 +77,8 @@ async function createRegistrationCheckout(registrationId, userId, { successUrl,
},
lineItems,
successUrl, cancelUrl, failureUrl,
payer: { name: registration.user?.name || '', email: registration.user?.email || '' },
description: registration.event.title,
idempotencyKey: `reg_${registrationId}_${amountCents}_${Date.now()}`,
});
+93 -26
View File
@@ -1,29 +1,37 @@
/**
* Online payment providers.
*
* Every provider implements the same interface (see providers/yoco.js):
* Every provider implements the same interface (see providers/yoco.js, providers/payfast.js):
* id, label
* capabilities { refund, getTransaction, listTransactions } — what its API supports
* isConfigured() → Promise<boolean> credentials are set
* createCheckout(input) → Promise<{ checkoutId, redirectUrl }>
* input: { amountCents, currency, metadata, lineItems, successUrl,
* cancelUrl, failureUrl, idempotencyKey }; lineItems:
* [{ name, quantity, unitCents }] (negative unitCents = a deduction)
* createCheckout(input) → Promise<{ externalId?, redirectUrl? } | { form: { action, fields } }>
* input: { checkoutId, amountCents, currency, metadata, lineItems,
* successUrl, cancelUrl, failureUrl, payer?, description,
* idempotencyKey }; lineItems: [{ name, quantity, unitCents }]
* (negative unitCents = a deduction). A provider that takes the
* payer with a signed form (PayFast) returns { form }; the payer's
* browser posts it from /pay/<checkoutId>.
* verifyWebhook(req) → Promise<{ ok: true } | { ok: false, status, message, details? }>
* parseWebhook(body) → { type: 'payment.succeeded' | <other>, transaction }
* body: the request body as the provider sent it (JSON or form)
* transaction: { externalId, amountCents, currency, createdDate,
* checkoutId, methodType, metadata, status }
* readWebhookBody(raw) → (optional) turns req.rawBody into the body; default JSON.parse
* getTransaction / refund / listTransactions — only when the capability is true
*
* Admins switch providers on and off and set their order in Site settings → Payments
* (AppSetting `payment_providers`). People pay with the first enabled provider that's
* configured, unless they pick one. Provider fees are never deducted or added: payments are
* recorded at the full amount and fees are handled at cashup.
* Every checkout is recorded as a ProviderCheckout (amount, what it's for, return URLs), so
* the payment that comes back can be matched and checked, and so the payer can be sent to
* /pay/<id>: there they choose a provider when several are enabled, or their browser posts a
* provider's form. Admins switch providers on and off and set their order in Site settings →
* Payments (AppSetting `payment_providers`). Provider fees are never deducted or added:
* payments are recorded at the full amount and fees are handled at cashup.
*/
const prisma = require('../config/db');
const { getSetting } = require('../utils/settingsCache');
const PROVIDERS = [require('./providers/yoco')];
const PROVIDERS = [require('./providers/yoco'), require('./providers/payfast')];
const BY_ID = new Map(PROVIDERS.map(p => [p.id, p]));
// One minimum for every provider: online payments must be at least R15.
@@ -77,35 +85,94 @@ function serializeProviderSettings(list) {
return JSON.stringify({ order: ids, enabled: Object.fromEntries(list.filter(p => getProvider(p.id)).map(p => [p.id, p.enabled === true])) });
}
/** The payer's page for a checkout: /pay/<id> on the frontend. */
function payPageUrl(checkoutId) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3000').split(',')[0].trim().replace(/\/$/, '');
return `${base}/pay/${encodeURIComponent(checkoutId)}`;
}
/** Throws unless `provider` may take payments now (enabled; and set up, or it explains). */
async function assertUsable(provider) {
const state = (await providerSettings()).find(p => p.id === provider.id);
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
}
/**
* Starts a checkout with `providerId` (or the first available provider).
* @returns {Promise<{ provider: string, checkoutId: string, redirectUrl: string }>}
* Starts a recorded checkout with a provider. Idempotent for a provider that was already
* started with a redirect (Yoco); form providers rebuild their (deterministic) form.
* @returns {Promise<{ provider, redirectUrl, form? }>}
*/
async function startCheckout(checkout, provider) {
if (checkout.provider === provider.id && checkout.redirectUrl) return { provider: provider.id, redirectUrl: checkout.redirectUrl };
const request = checkout.request || {};
const started = await provider.createCheckout({
...request,
checkoutId: checkout.id,
amountCents: checkout.amountCents,
currency: checkout.currency,
metadata: checkout.metadata,
});
if (started.form) {
if (checkout.provider !== provider.id) await prisma.providerCheckout.update({ where: { id: checkout.id }, data: { provider: provider.id } });
return { provider: provider.id, redirectUrl: payPageUrl(checkout.id), form: started.form };
}
await prisma.providerCheckout.update({
where: { id: checkout.id },
data: { provider: provider.id, externalId: started.externalId || null, redirectUrl: started.redirectUrl },
});
return { provider: provider.id, redirectUrl: started.redirectUrl };
}
/**
* Starts a checkout with `providerId`, or the only available provider; with several
* available, the payer chooses on /pay/<id>.
* @param {object} input - { amountCents, metadata, lineItems, successUrl, cancelUrl, failureUrl,
* payer?: { name, email }, description?, idempotencyKey? }
* @returns {Promise<{ checkoutId, redirectUrl, provider: string|null }>}
*/
async function createCheckout(providerId, input) {
if (!(input.amountCents >= MIN_ONLINE_AMOUNT * 100)) {
throw paymentError(`Online payments must be at least R${MIN_ONLINE_AMOUNT}. Please pay at the door or contact the organiser.`);
}
let provider = null;
const available = await availableProviders();
let provider;
if (providerId) {
provider = getProvider(providerId);
if (!provider) throw paymentError('Unknown payment provider.');
const state = (await providerSettings()).find(p => p.id === provider.id);
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
} else {
provider = available.length ? getProvider(available[0].id) : null;
if (!provider) {
// Nothing enabled: say so. Enabled but not configured: the provider explains what's missing.
const enabled = (await providerSettings()).find(p => p.enabled);
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
provider = getProvider(enabled.id);
}
await assertUsable(provider);
} else if (available.length === 1) {
provider = getProvider(available[0].id);
} else if (available.length === 0) {
// Nothing enabled: say so. Enabled but not set up: the provider explains what's missing.
const enabled = (await providerSettings()).find(p => p.enabled);
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
provider = getProvider(enabled.id);
}
const { checkoutId, redirectUrl } = await provider.createCheckout({ currency: 'ZAR', ...input });
return { provider: provider.id, checkoutId, redirectUrl };
const { amountCents, metadata, currency, ...request } = input;
const checkout = await prisma.providerCheckout.create({
data: { amountCents, currency: currency || 'ZAR', metadata: metadata || {}, request },
});
if (!provider) return { checkoutId: checkout.id, redirectUrl: payPageUrl(checkout.id), provider: null };
try {
const started = await startCheckout(checkout, provider);
return { checkoutId: checkout.id, redirectUrl: started.redirectUrl, provider: started.provider };
} catch (e) {
await prisma.providerCheckout.delete({ where: { id: checkout.id } }).catch(() => {});
throw e;
}
}
/** The checkout a webhook is about: by our id, or by the provider's own checkout id. */
async function findCheckout(providerId, ref) {
if (!ref) return null;
const ours = await prisma.providerCheckout.findUnique({ where: { id: String(ref) } }).catch(() => null);
if (ours) return ours;
return prisma.providerCheckout.findFirst({ where: { provider: providerId, externalId: String(ref) } });
}
module.exports = {
PROVIDERS, MIN_ONLINE_AMOUNT, SETTINGS_KEY, paymentError,
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders, createCheckout,
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders,
payPageUrl, assertUsable, createCheckout, startCheckout, findCheckout,
};
+195
View File
@@ -0,0 +1,195 @@
/**
* PayFast as a payment provider (see ../index.js for the interface), using PayFast's
* "custom integration" (developers.payfast.co.za/docs → Custom Integration).
*
* Checkout: the payer's browser posts a signed form to https://<host>/eng/process (from our
* /pay/<checkoutId> page). Fields go in the order PayFast lists them; the signature is the MD5
* of the non-blank fields as name=value pairs (URL-encoded, upper-case escapes, spaces as '+')
* joined with '&', plus &passphrase=… when one is set.
*
* ITN (Instant Transaction Notification): PayFast posts the result, form-encoded, to
* /api/webhooks/payfast. Before trusting it we check, as PayFast requires: the signature, that
* it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast
* says it's VALID. The amount is checked against the checkout in ../webhook.js.
*
* Refunds and transaction lookups aren't done through PayFast's API here: refunds are made in
* the PayFast dashboard and recorded with the manual refund flow.
*/
const crypto = require('crypto');
const dns = require('dns').promises;
const axios = require('axios');
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
const capabilities = { refund: false, getTransaction: false, listTransactions: false };
// Hosts PayFast sends ITNs from.
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
async function isConfigured() {
const c = await getPayfastConfig();
return !!(c.merchantId && c.merchantKey);
}
/**
* URL-encodes a value the way PayFast's signature expects (PHP urlencode): every character
* but letters, digits and -_. is %-escaped in upper case, and spaces become '+'. Checked
* against the sandbox: PayFast's own Node sample, which leaves ' ( ) ! * ~ as they are, is
* rejected for names like "Men's Camp (Youth)"; this is accepted.
*/
function pfEncode(value) {
return encodeURIComponent(String(value).trim())
.replace(/[!'()*~]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/%20/g, '+');
}
/**
* name=value&… of the pairs, in order. Pure. A checkout signs only the non-blank fields; an
* ITN is checked over every field PayFast posted, blanks included (`allFields`).
*/
function paramString(pairs, { allFields = false } = {}) {
const keep = allFields ? pairs : pairs.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
return keep.map(([k, v]) => `${k}=${pfEncode(v ?? '')}`).join('&');
}
/** PayFast's MD5 signature over ordered pairs (and the passphrase, if any). Pure. */
function signature(pairs, passphrase = '', opts = {}) {
let s = paramString(pairs, opts);
if (passphrase) s += `&passphrase=${pfEncode(passphrase)}`;
return crypto.createHash('md5').update(s).digest('hex');
}
/** Text we send PayFast (item and payer names): one line, within PayFast's length limit. Pure. */
function cleanText(value, max) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
}
/** "Camp ticket x2, T-shirt" → the item description (charged lines only). Pure. */
function describeLines(lineItems) {
return (lineItems || []).filter(l => l.unitCents > 0).map(l => `${l.name}${l.quantity > 1 ? ` x${l.quantity}` : ''}`).join(', ');
}
async function createCheckout({ checkoutId, amountCents, successUrl, cancelUrl, payer, description, lineItems }) {
const c = await getPayfastConfig();
if (!c.merchantId || !c.merchantKey) {
const err = new Error('PayFast isn\'t set up. An admin needs to add the merchant ID and key in Site Settings → Payments → PayFast.');
err.statusCode = 503;
throw err;
}
const notifyUrl = payfastNotifyUrl();
const [first, ...rest] = String(payer?.name || '').trim().split(/\s+/);
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(String(payer?.email || '')) && !String(payer.email).endsWith('.local') && !String(payer.email).endsWith('.invalid') ? payer.email : '';
const items = describeLines(lineItems);
// In the order PayFast documents them — the signature depends on it.
const pairs = [
['merchant_id', c.merchantId],
['merchant_key', c.merchantKey],
['return_url', successUrl],
['cancel_url', cancelUrl],
['notify_url', notifyUrl],
['name_first', cleanText(first, 100)],
['name_last', cleanText(rest.join(' '), 100)],
['email_address', email],
['m_payment_id', checkoutId],
['amount', (amountCents / 100).toFixed(2)],
['item_name', cleanText(description || items || 'Payment', 100)],
['item_description', cleanText(items, 255)],
].filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
const fields = Object.fromEntries(pairs.map(([k, v]) => [k, String(v).trim()]));
fields.signature = signature(pairs, c.passphrase);
return { form: { action: `https://${payfastHost(c.mode)}/eng/process`, fields } };
}
/** The ITN's name/value pairs, decoded, in the order PayFast sent them. Pure. */
function parsePairs(rawBody) {
return String(rawBody || '').split('&').filter(Boolean).map(kv => {
const i = kv.indexOf('=');
const dec = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
return i < 0 ? [dec(kv), ''] : [dec(kv.slice(0, i)), dec(kv.slice(i + 1))];
});
}
function readWebhookBody(rawBody) {
return Object.fromEntries(parsePairs(rawBody));
}
/** The caller's IP, without IPv4-mapped-IPv6 noise (needs TRUST_PROXY behind a proxy). */
function requestIp(req) {
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
}
async function payfastIps() {
const all = await Promise.all(VALID_HOSTS.map(h => dns.lookup(h, { all: true }).catch(() => [])));
return new Set(all.flat().map(a => a.address));
}
/** PayFast's four ITN checks, less the amount (checked against the checkout in ../webhook.js). */
async function verifyWebhook(req, { lookupIps = payfastIps, post = axios.post } = {}) {
const pairs = parsePairs(req.rawBody);
const data = Object.fromEntries(pairs);
if (!data.signature || !data.pf_payment_id) return { ok: false, status: 400, message: 'Not a PayFast notification' };
const c = await getPayfastConfig();
if (!c.merchantId) return { ok: false, status: 500, message: 'PayFast isn\'t set up' };
if (data.merchant_id && data.merchant_id !== c.merchantId) return { ok: false, status: 403, message: 'Notification for a different PayFast merchant' };
// 1. Signature: every posted field before the signature, in order, plus our passphrase.
const signed = [];
for (const pair of pairs) { if (pair[0] === 'signature') break; signed.push(pair); }
if (signature(signed, c.passphrase, { allFields: true }) !== data.signature) {
console.error('PayFast ITN: invalid signature');
return { ok: false, status: 403, message: 'Invalid signature' };
}
// 2. It came from PayFast.
if (process.env.PAYFAST_SKIP_IP_CHECK !== 'true') {
const ip = requestIp(req);
if (!(await lookupIps()).has(ip)) {
console.error(`PayFast ITN from ${ip}, which isn't a PayFast address (behind a proxy? set TRUST_PROXY)`);
return { ok: false, status: 403, message: 'Notification did not come from PayFast' };
}
}
// 3. PayFast confirms the details.
let reply = '';
try {
const res = await post(`https://${payfastHost(c.mode)}/eng/query/validate`, paramString(signed, { allFields: true }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 });
reply = String(res.data || '').trim();
} catch (e) {
console.error('PayFast ITN: could not confirm with PayFast:', e.message);
return { ok: false, status: 502, message: 'Could not confirm the notification with PayFast' };
}
if (reply !== 'VALID') {
console.error('PayFast ITN: PayFast did not confirm it:', reply.slice(0, 100));
return { ok: false, status: 403, message: 'PayFast did not confirm the notification' };
}
return { ok: true, webhookId: data.pf_payment_id, timestamp: null };
}
/** An ITN body → { type, transaction }. Pure. */
function parseWebhook(body) {
const status = String(body?.payment_status || '').toUpperCase();
const gross = parseFloat(body?.amount_gross);
return {
type: status === 'COMPLETE' ? 'payment.succeeded' : `payment.${status.toLowerCase() || 'unknown'}`,
transaction: {
externalId: body?.pf_payment_id || null,
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
currency: 'ZAR',
createdDate: null,
checkoutId: body?.m_payment_id || null,
// PayFast doesn't say how the payer paid; it settles like a card payment.
methodType: null,
metadata: {},
status: status === 'COMPLETE' ? 'succeeded' : status.toLowerCase(),
},
};
}
module.exports = {
id: 'payfast', label: 'PayFast', capabilities,
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody,
// for tests
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS,
};
+1 -1
View File
@@ -30,7 +30,7 @@ async function createCheckout({ amountCents, currency, metadata, lineItems, succ
const data = await yocoRequest('post', '/checkouts', {
amount: amountCents, currency, metadata, successUrl, cancelUrl, failureUrl, lineItems: toYocoLineItems(lineItems),
}, undefined, idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {});
return { checkoutId: data.id, redirectUrl: data.redirectUrl };
return { externalId: data.id, redirectUrl: data.redirectUrl };
}
/**
+22 -4
View File
@@ -5,6 +5,9 @@
* - Every event is stored as a ProviderTransaction (by provider + external id, so repeats
* are idempotent). Events that aren't for one of our checkouts stay unreconciled for the
* Payments page.
* - The event is matched to the checkout we started (ProviderCheckout, by our id or the
* provider's): that gives who/what it's for, and the amount we asked for. A payment of a
* different amount isn't recorded — it waits on the Payments page with a note.
* - payment.succeeded for a registration or donation records a Payment at the full amount
* paid (provider fees are dealt with at cashup), splits off any donation added at checkout,
* updates the registration's status and generates tickets once it's paid, then emails.
@@ -14,7 +17,7 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { getProvider } = require('./index');
const { getProvider, findCheckout } = require('./index');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
@@ -29,11 +32,17 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
return res.status(verified.status).json({ success: false, message: verified.message, ...(verified.details ? { details: verified.details } : {}) });
}
const body = JSON.parse(req.rawBody);
const body = provider.readWebhookBody ? provider.readWebhookBody(req.rawBody) : JSON.parse(req.rawBody);
const event = provider.parseWebhook(body);
const tx = event.transaction;
const receivedCheckoutId = tx.checkoutId;
// The checkout we started: its metadata says who/what this is for (PayFast sends none back)
const checkout = await findCheckout(provider.id, tx.checkoutId).catch(() => null);
if (checkout) tx.metadata = { ...(checkout.metadata || {}), ...(tx.metadata || {}) };
// A registration remembers our checkout id (older ones: the provider's)
event.checkoutRefs = [...new Set([checkout?.id, tx.checkoutId].filter(Boolean))];
// Optional filtering: only process specific checkoutIds or prefixes on this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
@@ -58,7 +67,7 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
// Not one of our registration checkouts and not a donation → keep it for reconciling
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: receivedCheckoutId }, select: { id: true } });
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: { in: event.checkoutRefs } }, select: { id: true } });
if (!existingRegistration) {
const meta = tx.metadata || {};
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
@@ -86,6 +95,14 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
});
}
// Paid a different amount from the checkout (e.g. changed on the way): don't record it
if (checkout && tx.amountCents !== checkout.amountCents) {
const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`;
console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`);
try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); }
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
}
const processedData = await recordSucceededPayment(provider.id, event, body);
return res.status(200).json({
success: true,
@@ -113,7 +130,8 @@ async function recordSucceededPayment(providerId, event, body) {
}
let registration = null;
if (checkoutId) registration = await prisma.registration.findFirst({ where: { checkoutId }, include: REG_INCLUDE });
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
if (!registration && metadata.registrationId) registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: REG_INCLUDE });
// Resolve userId: registration owner → metadata → first active admin (never a non-existent UUID)
+9
View File
@@ -0,0 +1,9 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig } = require('../controllers/payfastController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
module.exports = router;
+3 -1
View File
@@ -15,12 +15,14 @@ const {
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings } = require('../controllers/paymentProvidersController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
// Protected routes
router.post('/', protect, supervisor, createPayment);
router.get('/providers', listPaymentProviders);
router.get('/pay/:id', getPayPage);
router.post('/pay/:id', choosePayProvider);
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
router.post('/checkout', protect, createCheckout);
+3 -1
View File
@@ -1,9 +1,11 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Yoco webhook endpoint
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
module.exports = router;
+57
View File
@@ -0,0 +1,57 @@
/**
* PayFast configuration (Admin → Site Settings → Payments → PayFast).
*
* Stored in AppSetting: payfast_merchant_id, payfast_merchant_key, payfast_passphrase
* (AES-256-GCM encrypted — it signs every payment) and payfast_mode ('sandbox' | 'live',
* default sandbox). PAYFAST_MERCHANT_ID / PAYFAST_MERCHANT_KEY / PAYFAST_PASSPHRASE /
* PAYFAST_MODE in .env are fallbacks when the database has no value. The merchant key isn't
* secret: it travels in the payment form the payer's browser posts to PayFast.
*/
const { getSetting } = require('./settingsCache');
const KEY_MERCHANT_ID = 'payfast_merchant_id';
const KEY_MERCHANT_KEY = 'payfast_merchant_key';
const KEY_PASSPHRASE = 'payfast_passphrase';
const KEY_MODE = 'payfast_mode';
const HOSTS = { live: 'www.payfast.co.za', sandbox: 'sandbox.payfast.co.za' };
async function getPayfastConfig() {
const [merchantId, merchantKey, passphrase, mode] = await Promise.all([
getSetting(KEY_MERCHANT_ID, process.env.PAYFAST_MERCHANT_ID),
getSetting(KEY_MERCHANT_KEY, process.env.PAYFAST_MERCHANT_KEY),
getSetting(KEY_PASSPHRASE, process.env.PAYFAST_PASSPHRASE),
getSetting(KEY_MODE, process.env.PAYFAST_MODE),
]);
return {
merchantId: String(merchantId || '').trim(),
merchantKey: String(merchantKey || '').trim(),
passphrase: String(passphrase || ''),
mode: mode === 'live' ? 'live' : 'sandbox',
};
}
/** PayFast's host for a mode: where payers are sent and where ITNs are confirmed. */
const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
function notifyUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Set BACKEND_URL on the server so PayFast knows where to send payment notifications.'; }
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'PayFast can\'t reach localhost, so payments won\'t be confirmed automatically. Use a public BACKEND_URL (or a tunnel) to test notifications.';
}
return null;
}
module.exports = {
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
getPayfastConfig, payfastHost, payfastNotifyUrl, notifyUrlProblem,
};
+1 -1
View File
@@ -11,7 +11,7 @@ const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret', 'payfast_passphrase']);
let _cache = {};
let _lastFetch = 0;
+218 -42
View File
@@ -3,32 +3,45 @@ jest.mock('../src/config/db', () => ({
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); return fn; });
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
const axios = require('axios');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payments = require('../src/payments');
const yoco = require('../src/payments/providers/yoco');
const payfast = require('../src/payments/providers/payfast');
const { handleProviderWebhook } = require('../src/payments/webhook');
const SECRET = 'whsec_' + Buffer.from('test-webhook-secret').toString('base64');
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
function settings(values) {
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
settingsCache.invalidate();
}
const ENV = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...ENV };
delete process.env.YOCO_SECRET_KEY;
delete process.env.YOCO_WEBHOOK_SECRET;
for (const k of ['PAYFAST_MERCHANT_ID', 'PAYFAST_MERCHANT_KEY', 'PAYFAST_PASSPHRASE', 'PAYFAST_MODE', 'PAYFAST_SKIP_IP_CHECK']) delete process.env[k];
process.env.FRONTEND_URL = 'https://events.example.org';
process.env.BACKEND_URL = 'https://api.example.org';
prisma.providerCheckout.create.mockImplementation(({ data }) => Promise.resolve({ id: 'chk_1', provider: null, externalId: null, redirectUrl: null, ...data }));
prisma.providerCheckout.update.mockResolvedValue({});
prisma.providerCheckout.delete.mockResolvedValue({});
});
describe('provider settings', () => {
@@ -58,6 +71,7 @@ describe('provider settings', () => {
describe('createCheckout', () => {
test('online payments must be at least R15', async () => {
await expect(payments.createCheckout(null, { amountCents: 1499 })).rejects.toThrow(/at least R15/);
expect(prisma.providerCheckout.create).not.toHaveBeenCalled();
});
test('nothing enabled: online payments are off', async () => {
@@ -70,19 +84,36 @@ describe('createCheckout', () => {
await expect(payments.createCheckout('yoco', { amountCents: 5000 })).rejects.toThrow(/Yoco is turned off/);
});
test('enabled but no key: the provider explains what is missing', async () => {
test('enabled but no key: the provider explains, and the checkout record is removed', async () => {
settings({});
axios.mockRejectedValue(new Error('should not be called'));
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toThrow(/Yoco secret key/);
expect(prisma.providerCheckout.delete).toHaveBeenCalledWith({ where: { id: 'chk_1' } });
});
test('uses the first available provider and passes ZAR', async () => {
test('one provider available: starts it straight away and records the checkout', async () => {
settings({ yoco_secret_key: 'sk_test_abc' });
const spy = jest.spyOn(yoco, 'createCheckout').mockResolvedValue({ checkoutId: 'ch_1', redirectUrl: 'https://pay/1' });
const out = await payments.createCheckout(null, { amountCents: 5000, metadata: { a: 1 } });
expect(out).toEqual({ provider: 'yoco', checkoutId: 'ch_1', redirectUrl: 'https://pay/1' });
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 5000, currency: 'ZAR', metadata: { a: 1 } }));
const spy = jest.spyOn(yoco, 'createCheckout').mockResolvedValue({ externalId: 'ch_1', redirectUrl: 'https://pay/1' });
const out = await payments.createCheckout(null, { amountCents: 5000, metadata: { a: 1 }, description: 'Camp' });
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'yoco', redirectUrl: 'https://pay/1' });
expect(prisma.providerCheckout.create).toHaveBeenCalledWith({ data: { amountCents: 5000, currency: 'ZAR', metadata: { a: 1 }, request: { description: 'Camp' } } });
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ checkoutId: 'chk_1', amountCents: 5000, currency: 'ZAR', metadata: { a: 1 } }));
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'yoco', externalId: 'ch_1', redirectUrl: 'https://pay/1' } });
spy.mockRestore();
});
test('several available: the payer chooses on /pay/<id>', async () => {
settings({ yoco_secret_key: 'sk_test_abc', ...PF, payment_providers: JSON.stringify({ order: ['payfast', 'yoco'], enabled: { yoco: true, payfast: true } }) });
const out = await payments.createCheckout(null, { amountCents: 5000 });
expect(out).toEqual({ checkoutId: 'chk_1', provider: null, redirectUrl: 'https://events.example.org/pay/chk_1' });
});
test('a form provider (PayFast) sends the payer to /pay/<id>, which posts the form', async () => {
settings({ ...PF, payment_providers: JSON.stringify({ order: ['payfast'], enabled: { yoco: false, payfast: true } }) });
const out = await payments.createCheckout(null, { amountCents: 5000 });
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'payfast', redirectUrl: 'https://events.example.org/pay/chk_1' });
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'payfast' } });
});
});
describe('Yoco provider', () => {
@@ -101,8 +132,8 @@ describe('Yoco provider', () => {
});
test('a correctly signed webhook verifies', async () => {
settings({ yoco_webhook_secret: '' });
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: true, webhookId: 'msg_1' });
});
@@ -136,12 +167,109 @@ describe('Yoco provider', () => {
});
});
describe('PayFast provider', () => {
test('encodes like PHP urlencode: upper-case escapes, + for spaces, and \' ( ) ! * ~ escaped', () => {
expect(payfast.pfEncode("Men's Camp (Youth)! *~")).toBe('Men%27s+Camp+%28Youth%29%21+%2A%7E');
expect(payfast.pfEncode('https://x.org/a?b=1&c=2')).toBe('https%3A%2F%2Fx.org%2Fa%3Fb%3D1%26c%3D2');
expect(payfast.pfEncode(' padded ')).toBe('padded');
});
test('a checkout signs the non-blank fields in order; an ITN signs every field', () => {
const pairs = [['a', '1'], ['b', ''], ['c', 'x y']];
expect(payfast.paramString(pairs)).toBe('a=1&c=x+y');
expect(payfast.paramString(pairs, { allFields: true })).toBe('a=1&b=&c=x+y');
const md5 = (s) => require('crypto').createHash('md5').update(s).digest('hex');
expect(payfast.signature(pairs, 'my pass')).toBe(md5('a=1&c=x+y&passphrase=my+pass'));
expect(payfast.signature(pairs)).toBe(md5('a=1&c=x+y'));
});
test('builds the signed form in PayFast\'s field order', async () => {
settings(PF);
const { form } = await payfast.createCheckout({
checkoutId: 'chk_9', amountCents: 12550, successUrl: 'https://e.org/ok', cancelUrl: 'https://e.org/no',
payer: { name: "Thandi O'Neil Mokoena", email: 'thandi@example.org' }, description: "Men's Camp (Youth)",
lineItems: [{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }],
});
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
expect(signature).toBe(payfast.signature(Object.entries(rest), 'Salt pass'));
});
test('live mode posts to www.payfast.co.za; test emails are left out', async () => {
settings({ ...PF, payfast_mode: 'live' });
const { form } = await payfast.createCheckout({ checkoutId: 'c', amountCents: 2000, payer: { name: 'A', email: 'walkin@guest.local' } });
expect(form.action).toBe('https://www.payfast.co.za/eng/process');
expect(form.fields.email_address).toBeUndefined();
});
test('not set up: says what is missing', async () => {
settings({});
await expect(payfast.createCheckout({ checkoutId: 'c', amountCents: 2000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/merchant ID and key/) });
});
describe('ITN checks', () => {
// An ITN as PayFast posts it: every field, blanks included, signature last
const itn = (overrides = {}, passphrase = 'Salt pass') => {
const pairs = Object.entries({
m_payment_id: 'chk_9', pf_payment_id: '1089250', payment_status: 'COMPLETE', item_name: "Men's Camp (Youth)", item_description: '',
amount_gross: '125.50', amount_fee: '-4.60', amount_net: '120.90', custom_str1: '', name_first: 'Thandi', name_last: '', email_address: 'thandi@example.org',
merchant_id: '10000100', ...overrides,
});
const sig = payfast.signature(pairs, passphrase, { allFields: true });
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${sig}`;
};
const req = (rawBody, ip = '197.97.145.144') => ({ rawBody, ip, headers: {} });
const deps = (reply = 'VALID', ips = ['197.97.145.144']) => ({ lookupIps: async () => new Set(ips), post: jest.fn().mockResolvedValue({ data: reply }) });
beforeEach(() => settings(PF));
test('a genuine ITN passes, and is confirmed with PayFast over every field', async () => {
const d = deps();
await expect(payfast.verifyWebhook(req(itn()), d)).resolves.toMatchObject({ ok: true, webhookId: '1089250' });
const [url, sent] = d.post.mock.calls[0];
expect(url).toBe('https://sandbox.payfast.co.za/eng/query/validate');
expect(sent).toContain('item_description=&');
expect(sent).not.toContain('signature=');
});
test('a forged or wrongly signed ITN is refused', async () => {
await expect(payfast.verifyWebhook(req(itn({}, 'wrong passphrase')), deps())).resolves.toMatchObject({ ok: false, status: 403, message: 'Invalid signature' });
const tampered = itn().replace('amount_gross=125.50', 'amount_gross=1.00');
await expect(payfast.verifyWebhook(req(tampered), deps())).resolves.toMatchObject({ ok: false, status: 403 });
});
test('not from a PayFast address: refused (unless the check is switched off)', async () => {
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: false, status: 403, message: expect.stringMatching(/did not come from PayFast/) });
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: true });
});
test('PayFast doesn\'t confirm it, or is unreachable', async () => {
await expect(payfast.verifyWebhook(req(itn()), deps('INVALID'))).resolves.toMatchObject({ ok: false, status: 403 });
const down = { lookupIps: async () => new Set(['197.97.145.144']), post: jest.fn().mockRejectedValue(new Error('timeout')) };
await expect(payfast.verifyWebhook(req(itn()), down)).resolves.toMatchObject({ ok: false, status: 502 });
});
test('another merchant\'s ITN is refused', async () => {
await expect(payfast.verifyWebhook(req(itn({ merchant_id: '99999999' })), deps())).resolves.toMatchObject({ ok: false, status: 403 });
});
test('parses COMPLETE as a successful payment', () => {
expect(payfast.parseWebhook(payfast.readWebhookBody(itn()))).toEqual({
type: 'payment.succeeded',
transaction: { externalId: '1089250', amountCents: 12550, currency: 'ZAR', createdDate: null, checkoutId: 'chk_9', methodType: null, metadata: {}, status: 'succeeded' },
});
expect(payfast.parseWebhook({ payment_status: 'CANCELLED' }).type).toBe('payment.cancelled');
});
});
});
describe('webhook flow', () => {
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_9', amount: 15000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_9', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r9', userId: 'u9', donationAmount: '50.00' } } });
const request = () => {
const ts = String(Math.floor(Date.now() / 1000));
return { headers: { 'webhook-id': 'msg_9', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_9', ts, body)}` }, rawBody: body };
};
const response = () => {
const res = { statusCode: 200, body: null };
res.status = (c) => { res.statusCode = c; return res; };
@@ -150,8 +278,6 @@ describe('webhook flow', () => {
};
beforeEach(() => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Camp' }, user: { id: 'u9', name: 'T' } });
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', registrationOptions: [], payments: [{ amount: 100 }] });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', userId: 'u9', eventId: 'e9', ...data }));
@@ -159,41 +285,91 @@ describe('webhook flow', () => {
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
prisma.providerTransaction.findUnique.mockResolvedValue(null);
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
prisma.providerCheckout.findUnique.mockResolvedValue(null);
prisma.providerCheckout.findFirst.mockResolvedValue(null);
prisma.ticket.findMany.mockResolvedValue([]);
});
test('records the payment (donation split off), tagged with the provider, and a reconciled transaction', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
const res = response();
await handleProviderWebhook('yoco')(request(), res);
describe('Yoco', () => {
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_9', amount: 15000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_9', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r9', userId: 'u9', donationAmount: '50.00' } } });
const request = () => {
const ts = String(Math.floor(Date.now() / 1000));
return { headers: { 'webhook-id': 'msg_9', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_9', ts, body)}` }, rawBody: body };
};
beforeEach(() => { process.env.YOCO_WEBHOOK_SECRET = SECRET; settings({}); });
expect(res.statusCode).toBe(200);
expect(res.body.data.processedData.registration).toMatchObject({ id: 'r9', status: 'paid' });
const [main, donation] = prisma.payment.create.mock.calls.map(c => c[0].data);
expect(main).toMatchObject({ amount: 100, method: 'card', provider: 'yoco', externalId: 'p_9', registrationId: 'r9', isDonation: false, status: 'completed' });
expect(donation).toMatchObject({ amount: 50, provider: 'yoco', isDonation: true, originalPaymentId: main.id });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'yoco', externalId: 'p_9', amount: 15000, reconciled: true, paymentId: main.id }) });
test('records the payment (donation split off), tagged with the provider, and a reconciled transaction', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
const res = response();
await handleProviderWebhook('yoco')(request(), res);
expect(res.statusCode).toBe(200);
expect(res.body.data.processedData.registration).toMatchObject({ id: 'r9', status: 'paid' });
const [main, donation] = prisma.payment.create.mock.calls.map(c => c[0].data);
expect(main).toMatchObject({ amount: 100, method: 'card', provider: 'yoco', externalId: 'p_9', registrationId: 'r9', isDonation: false, status: 'completed' });
expect(donation).toMatchObject({ amount: 50, provider: 'yoco', isDonation: true, originalPaymentId: main.id });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'yoco', externalId: 'p_9', amount: 15000, reconciled: true, paymentId: main.id }) });
});
test('found through our checkout record: the registration is looked up by our id too', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
prisma.providerCheckout.findFirst.mockResolvedValue({ id: 'chk_ours', provider: 'yoco', externalId: 'ch_9', amountCents: 15000, metadata: { registrationId: 'r9' } });
await handleProviderWebhook('yoco')(request(), response());
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_ours', 'ch_9'] } } }));
expect(prisma.payment.create).toHaveBeenCalled();
});
test('a repeated webhook records nothing new', async () => {
prisma.payment.findFirst.mockResolvedValue({ id: 'existing' });
prisma.providerTransaction.findUnique.mockResolvedValue({ id: 'ptx' });
prisma.providerTransaction.update.mockResolvedValue({ id: 'ptx' });
const res = response();
await handleProviderWebhook('yoco')(request(), res);
expect(res.body.data.processedData).toMatchObject({ status: 'already_processed', paymentId: 'existing' });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.update).toHaveBeenCalledWith({ where: { id: 'ptx' }, data: { reconciled: true, paymentId: 'existing' } });
});
test('a bad signature is refused before anything is stored', async () => {
const req = request();
req.headers['webhook-signature'] = 'v1,bm9wZQ==';
const res = response();
await handleProviderWebhook('yoco')(req, res);
expect(res.statusCode).toBe(403);
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).not.toHaveBeenCalled();
});
});
test('a repeated webhook records nothing new', async () => {
prisma.payment.findFirst.mockResolvedValue({ id: 'existing' });
prisma.providerTransaction.findUnique.mockResolvedValue({ id: 'ptx' });
prisma.providerTransaction.update.mockResolvedValue({ id: 'ptx' });
const res = response();
await handleProviderWebhook('yoco')(request(), res);
describe('PayFast', () => {
const itn = (amount = '100.00') => {
const pairs = Object.entries({ m_payment_id: 'chk_pf', pf_payment_id: '555', payment_status: 'COMPLETE', item_name: 'Camp', amount_gross: amount, merchant_id: '10000100' });
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${payfast.signature(pairs, 'Salt pass', { allFields: true })}`;
};
beforeEach(() => {
settings(PF);
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
axios.post.mockResolvedValue({ data: 'VALID' });
prisma.payment.findFirst.mockResolvedValue(null);
prisma.providerCheckout.findUnique.mockResolvedValue({ id: 'chk_pf', provider: 'payfast', amountCents: 10000, metadata: { registrationId: 'r9', userId: 'u9', eventId: 'e9', type: 'registration_payment' } });
});
expect(res.body.data.processedData).toMatchObject({ status: 'already_processed', paymentId: 'existing' });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.update).toHaveBeenCalledWith({ where: { id: 'ptx' }, data: { reconciled: true, paymentId: 'existing' } });
});
test('a confirmed ITN records the payment against the checkout\'s registration', async () => {
const res = response();
await handleProviderWebhook('payfast')({ rawBody: itn(), ip: '1.2.3.4', headers: {} }, res);
expect(res.statusCode).toBe(200);
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 100, provider: 'payfast', method: 'card', externalId: '555', registrationId: 'r9', status: 'completed' });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '555', checkoutId: 'chk_pf', reconciled: true }) });
});
test('a bad signature is refused before anything is stored', async () => {
const req = request();
req.headers['webhook-signature'] = 'v1,bm9wZQ==';
const res = response();
await handleProviderWebhook('yoco')(req, res);
expect(res.statusCode).toBe(403);
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).not.toHaveBeenCalled();
test('paid a different amount from the checkout: held for review, not recorded', async () => {
const res = response();
await handleProviderWebhook('payfast')({ rawBody: itn('20.00'), ip: '1.2.3.4', headers: {} }, res);
expect(res.statusCode).toBe(200);
expect(res.body.data).toEqual({ needsReview: true });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', reconciled: false, note: expect.stringMatching(/Paid R20.00, but the checkout was for R100.00/) }) });
});
});
});