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 };