- 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>
130 lines
5.8 KiB
JavaScript
130 lines
5.8 KiB
JavaScript
const prisma = require('../config/db');
|
|
const payments = require('../payments');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
|
|
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
|
|
|
|
// @desc Payment providers people can pay with now (enabled and set up), in display order
|
|
// @route GET /api/payments/providers
|
|
// @access Public
|
|
const listPaymentProviders = async (req, res) => {
|
|
try {
|
|
const providers = await payments.availableProviders();
|
|
res.json({ providers: providers.map(p => ({ id: p.id, label: p.label })), minimumAmount: payments.MIN_ONLINE_AMOUNT });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Every provider with its Enabled flag, order and whether it's set up
|
|
// @route GET /api/payments/providers/settings
|
|
// @access Admin
|
|
const getPaymentProviderSettings = async (req, res) => {
|
|
try {
|
|
res.json({ providers: await payments.providerSettings(), minimumAmount: payments.MIN_ONLINE_AMOUNT });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Turn providers on/off and set their order
|
|
// @route PUT /api/payments/providers/settings { providers: [{ id, enabled }] } (in display order)
|
|
// @access Admin
|
|
const updatePaymentProviderSettings = async (req, res) => {
|
|
try {
|
|
const list = req.body?.providers;
|
|
if (!Array.isArray(list) || list.length === 0) { res.status(400); throw new Error('providers must be a list'); }
|
|
const unknown = list.find(p => !payments.getProvider(p?.id));
|
|
if (unknown) { res.status(400); throw new Error(`Unknown payment provider: ${unknown?.id}`); }
|
|
|
|
const before = await payments.providerSettings();
|
|
const value = payments.serializeProviderSettings(list);
|
|
await prisma.appSetting.upsert({ where: { key: payments.SETTINGS_KEY }, update: { value }, create: { key: payments.SETTINGS_KEY, value } });
|
|
invalidateSettingsCache();
|
|
await warmCache();
|
|
const after = await payments.providerSettings();
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'payment_providers_updated', targetType: 'AppSetting',
|
|
metadata: { before: before.map(p => ({ id: p.id, enabled: p.enabled })), after: after.map(p => ({ id: p.id, enabled: p.enabled })) },
|
|
ip: getClientIp(req),
|
|
});
|
|
res.json({ message: 'Payment providers saved', providers: after });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
/** 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 };
|