3d381944d2
Next.js + Express event management app for Hope Family Church.
572 lines
19 KiB
JavaScript
572 lines
19 KiB
JavaScript
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;
|
|
}
|
|
} |