/** * 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/ (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 };