Files
hope-events/backend/src/payments/webhook.js
T
joshuaandClaude Opus 5.5 f2b51dbd88 PayFast provider, Pay online page, amount checks on payments
- PayFast (custom integration): signed form (PHP-style encoding, checked
  against a real sandbox), ITN checks (signature over all posted fields,
  PayFast host, VALID from /eng/query/validate, amount vs checkout).
- ProviderCheckout records every checkout (amount, metadata, request);
  webhooks match payments to it and hold a different amount for review
  (ProviderTransaction.note).
- /pay/<id>: payers choose a provider when several are on, and the
  browser posts PayFast's form from there, so payment links work for
  every provider.
- Site settings → Payments: PayFast card (merchant ID/key, encrypted
  passphrase, sandbox/live) while PayFast is enabled.
- Payer-facing wording no longer names Yoco; legal pages name both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:25:49 +02:00

290 lines
15 KiB
JavaScript

/**
* Payment webhooks, for every provider: the provider checks the signature and reads the
* body (providers/*.js); everything after that is shared here.
*
* - Every event is stored as a ProviderTransaction (by provider + external id, so repeats
* are idempotent). Events that aren't for one of our checkouts stay unreconciled for the
* Payments page.
* - The event is matched to the checkout we started (ProviderCheckout, by our id or the
* provider's): that gives who/what it's for, and the amount we asked for. A payment of a
* different amount isn't recorded — it waits on the Payments page with a note.
* - payment.succeeded for a registration or donation records a Payment at the full amount
* paid (provider fees are dealt with at cashup), splits off any donation added at checkout,
* updates the registration's status and generates tickets once it's paid, then emails.
* - A provider that has been switched off still has its webhooks processed, so checkouts
* started before it was turned off complete.
*/
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { getProvider, findCheckout } = require('./index');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
/** Express handler for POST /api/webhooks/<providerId> (needs req.rawBody). */
const handleProviderWebhook = (providerId) => async (req, res) => {
try {
const provider = getProvider(providerId);
if (!provider) return res.status(404).json({ success: false, message: 'Unknown payment provider' });
const verified = await provider.verifyWebhook(req);
if (!verified.ok) {
return res.status(verified.status).json({ success: false, message: verified.message, ...(verified.details ? { details: verified.details } : {}) });
}
const body = provider.readWebhookBody ? provider.readWebhookBody(req.rawBody) : JSON.parse(req.rawBody);
const event = provider.parseWebhook(body);
const tx = event.transaction;
const receivedCheckoutId = tx.checkoutId;
// The checkout we started: its metadata says who/what this is for (PayFast sends none back)
const checkout = await findCheckout(provider.id, tx.checkoutId).catch(() => null);
if (checkout) tx.metadata = { ...(checkout.metadata || {}), ...(tx.metadata || {}) };
// A registration remembers our checkout id (older ones: the provider's)
event.checkoutRefs = [...new Set([checkout?.id, tx.checkoutId].filter(Boolean))];
// Optional filtering: only process specific checkoutIds or prefixes on this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
if (allowedIds.length > 0 || allowedPrefixes.length > 0) {
const idMatches = receivedCheckoutId && (allowedIds.includes(receivedCheckoutId) || allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref)));
if (!idMatches) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store filtered webhook event:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
data: { filtered: true, receivedCheckoutId, allowedIdsCount: allowedIds.length, allowedPrefixesCount: allowedPrefixes.length },
});
}
}
} catch (filterErr) {
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
}
// Not one of our registration checkouts and not a donation → keep it for reconciling
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: { in: event.checkoutRefs } }, select: { id: true } });
if (!existingRegistration) {
const meta = tx.metadata || {};
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
if (!isDonation) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
data: { filtered: true, reason: 'no_registration_for_checkoutId', receivedCheckoutId },
});
}
}
}
} catch (dbFilterErr) {
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
}
if (event.type !== 'payment.succeeded') {
console.log(`Unhandled ${provider.id} webhook event type: ${event.type}`);
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store unhandled webhook event:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook received but event type not handled',
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp },
});
}
// Paid a different amount from the checkout (e.g. changed on the way): don't record it
if (checkout && tx.amountCents !== checkout.amountCents) {
const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`;
console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`);
try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); }
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
}
const processedData = await recordSucceededPayment(provider.id, event, body);
return res.status(200).json({
success: true,
message: `Successfully processed ${event.type} webhook`,
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp, processedData },
});
} catch (error) {
console.error('Webhook error:', error);
return res.status(500).json({ success: false, message: error.message, details: { error: error.stack, timestamp: new Date().toISOString() } });
}
};
const REG_INCLUDE = { event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } };
/** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
async function recordSucceededPayment(providerId, event, body) {
const { externalId, amountCents, status, metadata = {}, methodType, checkoutId } = event.transaction;
console.log(`Payment succeeded (${providerId}):`, externalId);
const existingPayment = await prisma.payment.findFirst({ where: { externalId } });
if (existingPayment) {
console.log(`Payment ${externalId} already processed`);
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existingPayment.id }); } catch (e) { console.warn('Failed to reconcile transaction for existing payment:', e?.message || e); }
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
}
let registration = null;
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
if (!registration && metadata.registrationId) registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: REG_INCLUDE });
// Resolve userId: registration owner → metadata → first active admin (never a non-existent UUID)
let resolvedUserId = registration?.userId || metadata.userId || null;
if (!resolvedUserId) {
try {
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
resolvedUserId = adminUser?.id || null;
} catch { /* payment will fail below if still null */ }
}
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
// recorded as its own donation payment so it never counts towards the registration.
const grossAmount = amountCents / 100;
const donationPart = registration?.id ? Math.min(Math.max(Number(metadata.donationAmount) || 0, 0), grossAmount) : 0;
const method = methodType || 'card';
const paymentStatus = status === 'succeeded' ? 'completed' : status;
const payment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: Math.round((grossAmount - donationPart) * 100) / 100,
method,
provider: providerId,
status: paymentStatus,
externalId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata.eventId || null,
isDonation: !registration?.id,
...(!registration?.id && metadata.type === 'sponsorship' ? {
isSponsorship: true,
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
} : {}),
},
});
let donationPayment = null;
if (donationPart > 0) {
donationPayment = await prisma.payment.create({
data: {
id: uuidv4(), amount: donationPart, method, provider: providerId, status: paymentStatus,
userId: resolvedUserId, recordedById: resolvedUserId, registrationId: null, eventId: registration.eventId,
isDonation: true, originalPaymentId: payment.id,
},
});
}
let updatedRegistration = null;
let generatedTickets = [];
if (registration) {
updatedRegistration = await updateRegistrationStatus(registration.id);
if (updatedRegistration.status === 'paid') {
generatedTickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: registration.id } },
select: { id: true, qrCode: true, isUsed: true, registrationOptionId: true, eventId: true },
});
}
}
console.log(`Payment ${externalId} processed successfully`);
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: payment.id }); } catch (e) { console.warn('Failed to reconcile transaction after payment create:', e?.message || e); }
// Emails: payment confirmation first, then tickets (guarantees order)
const regUserId = registration?.userId;
const regId = registration?.id;
const sendTickets = generatedTickets.length > 0;
(async () => {
const { sendPaymentEmails } = require('../utils/notifications');
try { await sendPaymentEmails(payment.id); } catch (e) { console.error('Failed to send webhook payment emails:', e); }
if (donationPayment) {
try { await sendPaymentEmails(donationPayment.id); } catch (e) { console.error('Failed to send webhook donation emails:', e); }
}
if (sendTickets && regUserId && regId) {
const { emailTickets } = require('../controllers/ticketController');
const mockReq = { user: { id: regUserId }, body: { registrationId: regId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
}
})();
return {
status: 'success',
payment: { id: payment.id, externalId, amount: payment.amount, method: payment.method, status: payment.status, createdAt: payment.createdAt },
registration: registration ? {
id: registration.id,
status: updatedRegistration ? updatedRegistration.status : registration.status,
eventId: registration.eventId,
eventTitle: registration.event?.title,
userId: registration.userId,
userName: registration.user?.name,
} : null,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
};
}
/** Sets a registration's status from its payments; generates tickets once it's paid. */
async function updateRegistrationStatus(registrationId) {
// Refresh early-bird pricing first so a registration is never marked paid on stale prices.
try { await refreshPricingForRegistration(registrationId); } catch (e) { console.warn('[webhook] Price refresh failed:', e?.message); }
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
if (!registration) throw new Error(`Registration ${registrationId} not found`);
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const newStatus = totalPaid >= totalDue ? 'paid' : totalPaid > 0 ? 'partial_paid' : 'pending';
const updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
// Tickets are generated here; the caller emails them after the payment email.
let generatedTickets = [];
if (newStatus === 'paid') {
try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); }
}
return { ...updatedRegistration, totalPaid, totalDue, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined };
}
/** Stores (or updates) the transaction behind a webhook event. Idempotent per provider + id. */
async function saveProviderTransaction(providerId, event, body, updates = {}) {
const tx = event.transaction;
const externalId = tx.externalId;
if (!externalId) throw new Error('Missing external id in webhook payload');
const where = { provider_externalId: { provider: providerId, externalId } };
const touch = (existing) => (Object.keys(updates).length
? prisma.providerTransaction.update({ where: { id: existing.id }, data: updates })
: existing);
try {
const existing = await prisma.providerTransaction.findUnique({ where });
if (existing) return touch(existing);
return await prisma.providerTransaction.create({
data: {
provider: providerId, externalId,
amount: tx.amountCents, currency: tx.currency, createdDate: tx.createdDate,
checkoutId: tx.checkoutId, methodType: tx.methodType,
reconciled: false, raw: body, ...updates,
},
});
} catch (e) {
// Unique constraint race: another request stored it first
if (e?.code === 'P2002') {
const existing = await prisma.providerTransaction.findUnique({ where });
return existing ? touch(existing) : null;
}
throw e;
}
}
module.exports = { handleProviderWebhook, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };