- src/payments/status.js: open checkouts, checkCheckouts and the payment-status job (every 15 min, checkouts 10 min - 3 days old). Found payments are settled exactly as their webhook would be (settlePayment, extracted from the webhook handler: amount check, record, emails). - PayFast: signed API client (alphabetical signature, testing=true in sandbox), findPayments from the transaction history CSV matched on M Payment ID, and testConnection (signed /ping). Verified against a real sandbox account. - Yoco documents no checkout lookup, so its checkouts are "can't check". - Payments -> Reconcile: "Waiting for payment" with Check status / Check all; PayFast card: Test connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
7.8 KiB
JavaScript
178 lines
7.8 KiB
JavaScript
const prisma = require('../config/db');
|
|
const payments = require('../payments');
|
|
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
|
|
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;
|
|
return { checkout, paid: await checkoutPaid(checkout) };
|
|
}
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const OPEN_DAYS = 14;
|
|
|
|
/** An open checkout for the Payments page: who/what it's for, and whether its provider can be asked. */
|
|
async function describeCheckout(c, users) {
|
|
const meta = c.metadata || {};
|
|
const provider = payments.getProvider(c.provider);
|
|
return {
|
|
id: c.id,
|
|
provider: c.provider,
|
|
providerLabel: provider?.label || c.provider,
|
|
canCheck: !!provider?.findPayments,
|
|
amount: c.amountCents / 100,
|
|
description: c.request?.description || meta.eventTitle || '',
|
|
type: meta.type || null,
|
|
registrationId: meta.registrationId || null,
|
|
payer: users.get(meta.userId) || c.request?.payer?.name || null,
|
|
createdAt: c.createdAt,
|
|
};
|
|
}
|
|
|
|
// @desc Online checkouts started in the last 14 days that have no payment yet
|
|
// @route GET /api/payments/checkouts/open
|
|
// @access Supervisor
|
|
const listOpenCheckouts = async (req, res) => {
|
|
try {
|
|
const open = await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
|
|
const userIds = [...new Set(open.map(c => c.metadata?.userId).filter(Boolean))];
|
|
const users = new Map((await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })).map(u => [u.id, u.name]));
|
|
res.json({ checkouts: await Promise.all(open.map(c => describeCheckout(c, users))), days: OPEN_DAYS });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
// @desc Ask the providers whether open checkouts were paid, and record what they find
|
|
// @route POST /api/payments/checkouts/check { ids?: string[] } (default: every open one)
|
|
// @access Supervisor
|
|
const checkCheckoutStatus = async (req, res) => {
|
|
try {
|
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String).slice(0, 200) : null;
|
|
const list = ids
|
|
? await prisma.providerCheckout.findMany({ where: { id: { in: ids } } })
|
|
: await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
|
|
const results = await checkCheckouts(list);
|
|
const out = Object.fromEntries(results);
|
|
const recorded = [...results.values()].filter(r => r.status === 'recorded').length;
|
|
res.json({ results: out, recorded, message: recorded ? `Found and recorded ${recorded} payment${recorded === 1 ? '' : 's'}.` : 'No new payments found.' });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider,
|
|
listOpenCheckouts, checkCheckoutStatus,
|
|
};
|