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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }}]},
|
||||
|
||||
@@ -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()}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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/) }) });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user