Files
hope-events/backend/src/controllers/payfastController.js
T
joshuaandClaude Opus 5.5 eb0f390215 Payment status checks for notifications that never arrive
- 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>
2026-10-23 15:28:28 +02:00

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