Files
hope-events/backend/src/payments/status.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

100 lines
4.6 KiB
JavaScript

/**
* Payment status checks — for when a provider's notification (webhook / ITN) never arrives.
*
* An open checkout is one we started with a provider that has no payment recorded yet. A
* provider that can look payments up (capabilities.getTransaction, findPayments) is asked
* whether it received one; anything it found is settled exactly as its webhook would have been
* (same amount check, same recording, same emails). Run every 15 minutes by the
* 'payment-status' job for recent checkouts, and on demand from the Payments page.
* Providers that can't be asked (Yoco: its API has no lookup) show as "can't check" — their
* payments are found in the provider's portal and recorded with Reconcile.
*/
const prisma = require('../config/db');
const { getProvider } = require('./index');
const { settlePayment } = require('./webhook');
/** Whether a checkout has been paid: a payment came back for it, or its registration is paid. */
async function checkoutPaid(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
if (tx) return true;
const registrationId = checkout.metadata?.registrationId;
if (!registrationId) return false;
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
return reg?.status === 'paid';
}
/** A payment held for review on this checkout (paid a different amount), if any. */
async function heldForReview(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
return prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: null, ignored: false, note: { not: null } }, select: { id: true, note: true } });
}
/**
* Asks each checkout's provider whether it was paid and records what it finds.
* @returns {Promise<Map<string, { status: 'paid'|'recorded'|'needs_review'|'not_paid'|'cant_check', note? }>>}
*/
async function checkCheckouts(checkouts) {
const out = new Map();
const byProvider = new Map();
for (const c of checkouts) {
if (await checkoutPaid(c)) { out.set(c.id, { status: 'paid' }); continue; }
const provider = getProvider(c.provider);
if (!provider?.findPayments) { out.set(c.id, { status: 'cant_check' }); continue; }
if (!byProvider.has(provider.id)) byProvider.set(provider.id, []);
byProvider.get(provider.id).push(c);
}
for (const [providerId, list] of byProvider) {
const provider = getProvider(providerId);
const found = await provider.findPayments(list);
for (const c of list) {
const tx = found.get(c.id);
if (!tx) {
const held = await heldForReview(c);
out.set(c.id, held ? { status: 'needs_review', note: held.note } : { status: 'not_paid' });
continue;
}
const event = {
type: 'payment.succeeded',
transaction: { ...tx, metadata: { ...(c.metadata || {}), ...(tx.metadata || {}) } },
checkoutRefs: [c.id, c.externalId].filter(Boolean),
};
const settled = await settlePayment(provider, event, { source: 'status-check', provider: providerId, transaction: tx }, c);
out.set(c.id, settled.needsReview ? { status: 'needs_review', note: settled.note } : { status: 'recorded' });
}
}
return out;
}
/** Recent checkouts started with a provider and not paid yet, newest first. */
async function openCheckouts({ since, until = new Date() } = {}) {
const rows = await prisma.providerCheckout.findMany({
where: { provider: { not: null }, createdAt: { gte: since, lte: until } },
orderBy: { createdAt: 'desc' },
take: 200,
});
const open = [];
for (const c of rows) if (!(await checkoutPaid(c))) open.push(c);
return open;
}
/**
* Background job: checks checkouts started between 3 days and 10 minutes ago (giving the
* webhook time to arrive first) with providers that can be asked.
*/
async function runPaymentStatusJob(now = new Date()) {
const since = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const until = new Date(now.getTime() - 10 * 60 * 1000);
const checkable = (await openCheckouts({ since, until })).filter(c => getProvider(c.provider)?.findPayments);
if (!checkable.length) return { checked: 0, recorded: 0, needsReview: 0 };
const results = [...(await checkCheckouts(checkable)).values()];
return {
checked: checkable.length,
recorded: results.filter(r => r.status === 'recorded').length,
needsReview: results.filter(r => r.status === 'needs_review').length,
};
}
module.exports = { checkoutPaid, checkCheckouts, openCheckouts, runPaymentStatusJob };