- 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>
290 lines
15 KiB
JavaScript
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 };
|