const crypto = require('crypto'); const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { emailTickets } = require('./ticketController'); const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing'); // @desc Handle Yoco webhook events // @route POST /api/webhooks/yoco // @access Public const handleYocoWebhook = async (req, res) => { try { const headers = req.headers; const requestBody = req.rawBody; // Log every JSON request body for webhook controller try { const contentType = headers['content-type'] || headers['Content-Type'] || ''; let parsedBody = null; if (typeof requestBody === 'string') { try { parsedBody = JSON.parse(requestBody); } catch (e) { // Not valid JSON; keep as raw string } } else if (requestBody && typeof requestBody === 'object') { parsedBody = requestBody; } } catch (logErr) { // Fail-safe: never block webhook processing due to logging issues console.error('Failed to log webhook JSON request:', logErr); } // Verify webhook signature const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const rawSigHeader = headers['webhook-signature']; if (!id || !timestamp || !rawSigHeader) { const responseObj = { success: false, message: 'Missing webhook headers', details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) } }; return res.status(400).json(responseObj); } // Reject replayed requests (Yoco recommends a 3-minute tolerance window) const webhookTimeMs = parseInt(timestamp, 10) * 1000; if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) { return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' }); } // Construct the signed content const signedContent = `${id}.${timestamp}.${requestBody}`; // Get the webhook secret from environment variables const secret = process.env.YOCO_WEBHOOK_SECRET; if (!secret) { console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables'); const responseObj = { success: false, message: 'Server configuration error', details: { error: 'Missing webhook secret configuration' } }; return res.status(500).json(responseObj); } const secretBytes = Buffer.from(secret.split('_')[1], "base64"); // Calculate expected signature const expectedSignature = crypto .createHmac('sha256', secretBytes) .update(signedContent) .digest('base64'); // Accept if any of the space-separated signatures match (supports key rotation) const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean); const signatureValid = signatures.some(sig => { try { return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig)); } catch { return false; } }); if (!signatureValid) { console.error('Invalid webhook signature'); const responseObj = { success: false, message: 'Invalid signature', details: { error: 'Webhook signature verification failed' } }; return res.status(403).json(responseObj); } // Parse the webhook payload const webhookData = JSON.parse(requestBody); // Extract checkoutId from payload or metadata const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id; // Optional filtering: allow only specific checkoutIds or prefixes for 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); const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0; if (hasFilter) { const idMatches = receivedCheckoutId && ( allowedIds.includes(receivedCheckoutId) || allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref)) ); if (!idMatches) { // Store event for audit/unreconciled tracking try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); } // Acknowledge but ignore processing for unrelated checkout flows const responseObj = { success: true, message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint', data: { filtered: true, receivedCheckoutId, allowedIdsCount: allowedIds.length, allowedPrefixesCount: allowedPrefixes.length } }; return res.status(200).json(responseObj); } } } catch (filterErr) { // Never block webhook due to filter parsing errors; log only console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr); } // Automatic filtering by database registration record, with donation allowance try { if (receivedCheckoutId) { const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: receivedCheckoutId }, select: { id: true } }); if (!existingRegistration) { // Allow donations (no registration) to proceed based on metadata const meta = webhookData?.payload?.metadata || {}; const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId; if (!isDonation) { try { await saveYocoTransaction(webhookData); } catch (saveErr) { console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr); } const responseObj = { success: true, message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId', data: { filtered: true, reason: 'no_registration_for_checkoutId', receivedCheckoutId } }; return res.status(200).json(responseObj); } } } } catch (dbFilterErr) { // Do not fail webhook due to DB filter issues console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr); } let processedData = null; // Process different event types switch (webhookData.type) { case 'payment.succeeded': processedData = await handlePaymentSucceeded(webhookData); break; // Add more event types as needed default: console.log(`Unhandled webhook event type: ${webhookData.type}`); try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); } const responseObj = { success: true, message: 'Webhook received but event type not handled', data: { eventType: webhookData.type, webhookId: id, timestamp: timestamp } }; return res.status(200).json(responseObj); } // Return detailed success response const responseObj = { success: true, message: `Successfully processed ${webhookData.type} webhook`, data: { eventType: webhookData.type, webhookId: id, timestamp: timestamp, processedData } }; return res.status(200).json(responseObj); } catch (error) { console.error('Webhook error:', error); const responseObj = { success: false, message: error.message, details: { error: error.stack, timestamp: new Date().toISOString() } }; return res.status(500).json(responseObj); } }; // Handle payment.succeeded event const handlePaymentSucceeded = async (webhookData) => { try { const paymentData = webhookData.payload; // Log the payment data console.log('Payment succeeded:', paymentData); // Extract payment details const { id: yocoPaymentId, amount, currency, status, metadata, paymentMethodDetails: method, checkoutId } = paymentData; // Check if payment already exists to avoid duplicates const existingPayment = await prisma.payment.findFirst({ where: { externalId: yocoPaymentId } }); if (existingPayment) { console.log(`Payment ${yocoPaymentId} already processed`); try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); } return { status: 'already_processed', paymentId: existingPayment.id, externalId: yocoPaymentId }; } // Find the registration by checkoutId let registration = null; if (checkoutId) { registration = await prisma.registration.findFirst({ where: { checkoutId: checkoutId }, include: { event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } } }); } // If no registration found by checkoutId, try metadata if (!registration && metadata && metadata.registrationId) { registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: { event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } } }); } // Resolve userId: registration owner → metadata → first active admin (never use 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 (e) { /* ignore — payment will fail below if still null */ } } // Create payment record const payment = await prisma.payment.create({ data: { id: uuidv4(), // Generate a UUID for the payment amount: amount / 100, // Convert cents to your currency unit method: method.type, // The type of payment from the webhook status: status === 'succeeded' ? 'completed' : status, externalId: yocoPaymentId, registrationId: registration?.id || null, userId: resolvedUserId, eventId: registration?.eventId || metadata?.eventId || null, isDonation: !registration?.id }, include: { user: { select: { id: true, name: true, email: true } }, registration: registration ? { include: { event: true } } : undefined, event: (registration?.eventId || metadata?.eventId) ? true : undefined } }); // If this payment is for a registration, update the registration status let updatedRegistration = null; let generatedTickets = []; if (registration) { updatedRegistration = await updateRegistrationStatus(registration.id); // If registration status is 'paid', tickets were generated if (updatedRegistration.status === 'paid') { // Find the generated tickets generatedTickets = await prisma.ticket.findMany({ where: { registrationOption: { registrationId: registration.id } }, select: { id: true, qrCode: true, isUsed: true, registrationOptionId: true, eventId: true } }); } } console.log(`Payment ${yocoPaymentId} processed successfully`); // Mark the Yoco transaction as reconciled and link the payment try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); } // Send emails: payment confirmation first, then tickets (guarantees order) const _whPaymentId = payment.id; const _whUserId = registration?.userId; const _whRegId = registration?.id; const _whTicketsGenerated = generatedTickets.length > 0; (async () => { try { const { sendPaymentEmails } = require('../utils/notifications'); await sendPaymentEmails(_whPaymentId); } catch (e) { console.error('Failed to send webhook payment emails:', e); } if (_whTicketsGenerated && _whUserId && _whRegId) { const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } }; const mockRes = { status: () => mockRes, json: () => {} }; try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); } } })(); // Return processed data return { status: 'success', payment: { id: payment.id, externalId: yocoPaymentId, 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 }; } catch (error) { console.error('Error processing payment.succeeded event:', error); throw error; } }; // Update registration status based on payments const updateRegistrationStatus = async (registrationId) => { try { // Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers // are accounted for so we never mark a registration paid based 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 } } } }, payments: true } }); if (!registration) { throw new Error(`Registration ${registrationId} not found`); } // Calculate total amount paid const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0); // Calculate total amount due (uses priceSnapshot — refreshed above) const totalDue = computeRegistrationTotalDue(registration, new Date()); // Update registration status based on payment let newStatus; if (totalPaid >= totalDue) { newStatus = 'paid'; } else if (totalPaid > 0) { newStatus = 'partial_paid'; } else { newStatus = 'pending'; } const updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } }); // Generate tickets if status is "paid" (email handled by caller after payment email) let generatedTickets = []; if (newStatus === 'paid') { try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); // Don't throw the error, just log it - we still want to update the registration status } } // Return the updated registration return { ...updatedRegistration, totalPaid, totalDue, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined }; } catch (error) { console.error('Error updating registration status:', error); throw error; } }; const handleWhatsappWebhook = async (req, res) => { try { const { body } = req; const { message } = body; } catch (error) { res.status(500).json({ error: 'Internal Server Error' }); } } module.exports = { handleYocoWebhook, handleWhatsappWebhook, }; // Helper: save or update Yoco transactions for any webhook event async function saveYocoTransaction(webhookData, updates = {}) { try { const payload = webhookData?.payload || {}; const externalId = payload?.id || webhookData?.id; if (!externalId) { throw new Error('Missing external id in webhook payload'); } const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided const currency = payload?.currency || null; const createdDateStr = payload?.createdDate || webhookData?.createdDate; const createdDate = createdDateStr ? new Date(createdDateStr) : null; const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null; const methodType = payload?.paymentMethodDetails?.type || null; // Try find existing const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } }); if (existing) { // Update with any provided updates if (updates && Object.keys(updates).length > 0) { return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } }); } return existing; } // Create new const record = await prisma.yocoTransaction.create({ data: { externalId, amount, currency, createdDate, checkoutId, methodType, reconciled: false, raw: webhookData, ...updates } }); return record; } catch (e) { // Unique constraint race: fetch existing if (e?.code === 'P2002') { const payload = webhookData?.payload || {}; const externalId = payload?.id || webhookData?.id; const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } }); if (existing && updates && Object.keys(updates).length > 0) { return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } }); } return existing; } throw e; } }