Files
hope-events/backend/src/payments/checkouts.js
T
joshuaandClaude Opus 5.5 f2b51dbd88 PayFast provider, Pay online page, amount checks on payments
- 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>
2026-10-23 14:25:49 +02:00

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 };