- 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>
97 lines
4.9 KiB
JavaScript
97 lines
4.9 KiB
JavaScript
/**
|
|
* Online checkouts for registrations (the balance, part of it, or the balance plus a
|
|
* donation). Used by the payment pages, payment links in emails and invoices, and the
|
|
* self-service kiosk. The provider is the one asked for, the only available one, or the
|
|
* payer's choice on /pay/<id> (./index.js); the registration remembers the checkout so the
|
|
* webhook can find it.
|
|
*/
|
|
|
|
const prisma = require('../config/db');
|
|
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
|
const payments = require('./index');
|
|
|
|
const CHECKOUT_INCLUDE = {
|
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
|
payments: true,
|
|
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
|
bundleApplications: { include: { bundle: { select: { name: true } } } },
|
|
user: { select: { id: true, name: true, email: true } },
|
|
event: true,
|
|
};
|
|
|
|
/**
|
|
* The checkout's line items for a registration: one per line (with its tranches' prices),
|
|
* then bundle savings and the coupon discount as deductions. Callers add payment lines.
|
|
* @returns {{ name: string, quantity: number, unitCents: number }[]}
|
|
*/
|
|
function registrationLineItems(registration) {
|
|
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
|
|
const items = buildLineItems(registration.registrationOptions).map(row => ({
|
|
name: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
|
|
quantity: row.quantity,
|
|
unitCents: Math.round(row.unitPrice * 100),
|
|
}));
|
|
for (const line of discountLines(registration)) items.push({ name: line.label, quantity: 1, unitCents: -Math.round(line.amount * 100) });
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Starts a checkout for a registration. Doesn't check who's asking — callers do.
|
|
* @param {object} opts
|
|
* @param {number} [opts.amount] - pay only this much of the balance (at least the online minimum)
|
|
* @param {number} [opts.donationAmount] - added on top as its own line; the webhook records it
|
|
* as a separate donation (it travels in the checkout metadata)
|
|
* @param {string} [opts.provider] - payment provider id (default: first available)
|
|
* @returns {Promise<{ checkoutId, redirectUrl, provider, amount, registration }>}
|
|
*/
|
|
async function createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0, amount = null, provider = null } = {}) {
|
|
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: CHECKOUT_INCLUDE });
|
|
if (!registration) throw payments.paymentError('Registration not found', 404);
|
|
|
|
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
|
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
|
const remaining = totalDue - totalPaid;
|
|
if (remaining <= 0) throw payments.paymentError('This registration is already paid in full');
|
|
|
|
let charge = remaining;
|
|
if (amount !== null && amount !== undefined) {
|
|
const requested = parseFloat(amount);
|
|
if (!(requested > 0)) throw payments.paymentError('Amount must be greater than 0');
|
|
charge = Math.min(requested, remaining);
|
|
}
|
|
|
|
const donationCents = Math.round((Number(donationAmount) || 0) * 100);
|
|
const chargeCents = Math.round(charge * 100);
|
|
const lineItems = registrationLineItems(registration);
|
|
if (totalPaid > 0) lineItems.push({ name: 'Previous Payments', quantity: 1, unitCents: -Math.round(totalPaid * 100) });
|
|
// Paying part of the balance: the rest is deferred to a later payment
|
|
if (charge < remaining) lineItems.push({ name: 'Partial Payment', quantity: 1, unitCents: -Math.round((remaining - charge) * 100) });
|
|
if (donationCents > 0) lineItems.push({ name: 'Donation', quantity: 1, unitCents: donationCents });
|
|
|
|
const amountCents = chargeCents + donationCents;
|
|
const checkout = await payments.createCheckout(provider, {
|
|
amountCents,
|
|
metadata: {
|
|
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
|
|
...(donationCents > 0 ? { donationAmount: (donationCents / 100).toFixed(2) } : {}),
|
|
},
|
|
lineItems,
|
|
successUrl, cancelUrl, failureUrl,
|
|
payer: { name: registration.user?.name || '', email: registration.user?.email || '' },
|
|
description: registration.event.title,
|
|
idempotencyKey: `reg_${registrationId}_${amountCents}_${Date.now()}`,
|
|
});
|
|
|
|
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: checkout.checkoutId, updatedAt: new Date() } });
|
|
|
|
return {
|
|
checkoutId: checkout.checkoutId,
|
|
redirectUrl: checkout.redirectUrl,
|
|
provider: checkout.provider,
|
|
amount: amount !== null && amount !== undefined ? charge : amountCents / 100,
|
|
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title },
|
|
};
|
|
}
|
|
|
|
module.exports = { createRegistrationCheckout, registrationLineItems };
|