- 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>
98 lines
4.0 KiB
JavaScript
98 lines
4.0 KiB
JavaScript
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);
|
|
}
|
|
};
|
|
|
|
// @desc Check the saved merchant ID and passphrase with PayFast (a signed API ping)
|
|
// @route POST /api/payfast/test
|
|
// @access Admin
|
|
const testConnection = async (req, res) => {
|
|
try {
|
|
const c = await getPayfastConfig();
|
|
if (!c.merchantId) { res.status(400); throw new Error('Save the merchant ID first.'); }
|
|
await require('../payments/providers/payfast').testConnection();
|
|
res.json({ message: `Connected to PayFast (${c.mode === 'live' ? 'live' : 'sandbox'}).` });
|
|
} catch (e) {
|
|
fail(res, e);
|
|
}
|
|
};
|
|
|
|
module.exports = { getConfig, updateConfig, testConnection };
|