- 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>
100 lines
4.6 KiB
JavaScript
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 };
|