Compare commits

...
5 Commits
Author SHA1 Message Date
joshuaandClaude Sonnet 5 96aa9b10cc Bump version to 1.8.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:32:17 +02:00
joshuaandClaude Sonnet 5 bc64069021 Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown,
  early-bird discount, balance due, Yoco pay-now link/QR) whenever a
  balance is outstanding; payment/donation confirmations attach a
  payment receipt PDF. Sent as an email attachment and, over WhatsApp,
  as the PDF itself with the existing message as its caption.
- Users can also (re)send either document on demand: an "Invoice"
  button on the registration detail popup, and a "Receipt" button next
  to each payment there and on the Payment history page, each opening
  an Email/WhatsApp choice popup, via two new endpoints restricted to
  the registration/payment's own owner.
- Fix: editing an event option's early-bird tiers deleted and
  recreated every tier for that option with brand-new ids, silently
  severing the appliedTierId link on all historical purchases (losing
  early-bird attribution and undercounting stock-limit usage) even for
  tiers the admin didn't touch. Tiers are now upserted by id.
- Update the "My Events" help content and the API docs index for the
  new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:26:49 +02:00
joshuaandClaude Sonnet 5 e9cb238ce1 Fix early-bird total in notifications; group dashboard registration items
Registration/payment/reminder emails and WhatsApp messages loaded
registrations without their price tranches, so any line spanning more
than one tranche fell back to charging the full quantity at the most
recent tranche's price, silently dropping the early-bird discount from
the outstanding balance and itemized amounts shown to the user.

The user dashboard's registration detail popup also listed one raw
line per tranche; it now merges same item/price/tier lines and groups
early-bird lines separately from standard-price ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:57:29 +02:00
joshuaandClaude Sonnet 5 f0f8d4c242 Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a
  separate price tranche instead of overwriting a single price/quantity
  on repeat purchases, so buying more tickets after a tier expires no
  longer re-prices tickets already bought at the old price. Stock-limit
  checks, total-due calculation, and the Finance report's revenue-by-
  option are all tranche-aware; pages that showed one blended price per
  line now render/total each tranche. Viewing a pending/partially-paid
  registration (dashboard, detail page, or an event's registration
  list) now refreshes stale pricing on the spot instead of only at
  payment time.
- Fixed the "(early bird)" dashboard label incorrectly firing on any
  line priced below the base option price (e.g. a plain cheaper
  variant) — it now checks the real applied-tier flag.
- Added contact-only events (e.g. baptism): no registration/payment
  flow, shown on the public site with a "Contact us" popup instead of
  a Register button. Configurable via the admin event wizard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:26:35 +02:00
joshua f2c3172e16 Merge branch 'feature/site-branding-customization' into main 2026-08-20 14:57:44 +02:00
34 changed files with 1689 additions and 325 deletions
+17
View File
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.8.0] - 2026-08-21
### Added
- Events can now be marked "contact-only" (e.g. baptism) — they appear on the public events list/detail pages with a "Contact us" button (opening a popup with name/phone/email) instead of a Register button, and have no ticket options or registration flow. Configurable from a new toggle in the admin event wizard's Basic Details step.
- Registration and payment notifications now include a branded PDF: registration confirmations attach an **invoice** (itemized breakdown, early-bird discount, balance due, and — when there's an outstanding balance — a clickable "pay now" link and QR code pointing at a Yoco checkout) whenever the registration isn't fully paid, and payment/donation confirmations attach a **payment receipt** (itemized breakdown plus the amount paid on that transaction). Sent as an email attachment and, over WhatsApp, as the PDF itself (with the existing message text as its caption) in place of the previous text-only send. Both documents pick up the org's configured name/logo/brand color from Site Settings → Branding.
- The user dashboard's registration detail popup now has an "Invoice" button and, next to each payment, a "Receipt" button — both pop up a small Email/WhatsApp choice and (re)send that document on demand, via two new endpoints (`POST /api/registrations/:id/send-invoice`, `POST /api/payments/:id/send-receipt`), restricted to the registration/payment's own owner. The invoice works regardless of balance, showing "Paid in full" when nothing is owed. The Payment history page (`/dashboard/user/payments`) got the same per-payment "Receipt" button too.
### Fixed
- The user dashboard's "(early bird)" label was a price-comparison heuristic — it fired on any line priced below the option's base price, including plain cheaper variants that were never actually early-bird tickets. It now checks the real applied-tier flag the backend already tracks.
- Buying more of an already-purchased ticket type after its early-bird tier expired re-priced the *entire* line at the new price instead of adding the new quantity at the new price (e.g. 5 tickets @ R50 + 1 more after the price rose to R100 came out to R600 instead of R350). Each purchase now gets its own price "tranche" recorded against the registration line, so previously-bought tickets keep their original price and only the newly added quantity uses the current price. Registration/reporting pages that showed a single blended price per line now render (or total) each tranche separately.
- An unpaid (or partially paid) registration's price only ever got refreshed when a payment was actually attempted — an early-bird tier that expired while tickets sat unpaid kept showing its old, no-longer-honoured price (and its "(early bird)" tag) indefinitely on the dashboard until the user tried to pay. Viewing a registration (dashboard, registration detail, or an event's registration list) now refreshes still-outstanding pricing on the spot, same as payment already did.
- The user dashboard's registration detail popup listed one line per price tranche in raw creation order, so a ticket bought across several separate registrations (e.g. some early-bird, some not) showed as a wall of near-duplicate lines. It now merges tranches with the same item/price/tier into one line and groups early-bird lines together, separately from standard-price lines.
- Registration confirmation, payment/donation, and reminder emails and WhatsApp messages computed "Total due"/"Balance" by loading a registration without its price tranches, so any line spanning more than one tranche (e.g. some tickets bought at the early-bird price, more added later at full price) fell back to charging the *entire* quantity at the most recent tranche's price — silently wiping out the early-bird discount from the outstanding balance shown to the user. These sends now load tranches and total each line the same tranche-aware way the dashboard already did; the itemized line amounts in those messages (which previously always showed the option's undiscounted base price) are now correct too.
- Editing an event option's early-bird tiers (even just tweaking one tier's deadline or price) deleted and recreated *every* tier for that option with brand-new ids, including tiers the admin didn't touch. Since past purchases point at a specific tier by id, this silently severed that link on every edit — wiping the "early bird" attribution (and locked-in stock-limit counts, risking oversold tiers) off historical registrations that were never meant to change. Tiers are now upserted by id, so their identity — and everything referencing them — survives an edit; a tier is only ever removed if it's no longer in the saved list *and* has no purchase history against it.
## [1.7.0] - 2026-08-20 ## [1.7.0] - 2026-08-20
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.7.0", "version": "1.8.0",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `feeAmount` on the `EventCashupLine` table. All the data in the column will be lost.
- You are about to drop the column `feeAmount` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeChannel` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeRate` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "EventCashupLine" DROP COLUMN "feeAmount";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feeAmount",
DROP COLUMN "feeChannel",
DROP COLUMN "feeRate";
@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactName" TEXT,
ADD COLUMN "contactPhone" TEXT,
ADD COLUMN "requiresRegistration" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "RegistrationOptionTranche" (
"id" TEXT NOT NULL,
"registrationOptionId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"priceSnapshot" DOUBLE PRECISION NOT NULL,
"appliedTierId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationOptionTranche_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_registrationOptionId_idx" ON "RegistrationOptionTranche"("registrationOptionId");
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_appliedTierId_idx" ON "RegistrationOptionTranche"("appliedTierId");
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_appliedTierId_fkey" FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+27
View File
@@ -101,6 +101,10 @@ model Event {
isActive Boolean @default(true) isActive Boolean @default(true)
isHidden Boolean @default(false) isHidden Boolean @default(false)
requiresAuth Boolean @default(true) requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
contactName String?
contactPhone String?
contactEmail String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdById String? createdById String?
@@ -163,6 +167,7 @@ model EarlyBirdTier {
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[] registrationOptions RegistrationOption[]
tranches RegistrationOptionTranche[]
@@index([eventOptionId, deadline]) @@index([eventOptionId, deadline])
@@index([variantId]) @@index([variantId])
@@ -217,6 +222,7 @@ model RegistrationOption {
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[] tickets Ticket[]
tranches RegistrationOptionTranche[]
@@index([registrationId]) @@index([registrationId])
@@index([eventOptionId]) @@index([eventOptionId])
@@ -224,6 +230,27 @@ model RegistrationOption {
@@index([appliedTierId]) @@index([appliedTierId])
} }
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
// (mirrors the Payment model's append-only pattern) — this is what lets a single ticket
// type be bought in multiple batches at different early-bird prices without either batch's
// price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId
// stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
}
model Payment { model Payment {
id String @id @default(uuid()) id String @id @default(uuid())
amount Float amount Float
+49 -17
View File
@@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin // @access Private/Admin
const createEvent = async (req, res) => { const createEvent = async (req, res) => {
try { try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body; const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
const data = { const data = {
id: uuidv4(), id: uuidv4(),
@@ -58,14 +58,19 @@ const createEvent = async (req, res) => {
redirectUrl, redirectUrl,
isHidden: isHidden === true || isHidden === 'true', isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true, requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
}; };
try { try {
const event = await prisma.event.create({ data }); const event = await prisma.event.create({ data });
// Automatically create a main ticket (event option) with the event price // Automatically create a main ticket (event option) with the event price — contact-only
// events have no bookable options, so there's nothing to auto-create for them.
try { try {
if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') { if (data.requiresRegistration !== false && prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
await prisma.eventOption.create({ await prisma.eventOption.create({
data: { data: {
id: uuidv4(), id: uuidv4(),
@@ -454,7 +459,7 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first. // totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res); await assertEventOpen(req.params.id, res);
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body; const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
const data = { const data = {
title: title || event.title, title: title || event.title,
@@ -468,6 +473,10 @@ const updateEvent = async (req, res) => {
isActive: isActive !== undefined ? isActive : event.isActive, isActive: isActive !== undefined ? isActive : event.isActive,
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false), isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true), requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
updatedAt: new Date(), updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl, redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
}; };
@@ -682,26 +691,49 @@ const updateEventOption = async (req, res) => {
} }
}); });
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones) // If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
// every tier for the option on every save, which silently reassigned each one a brand-new
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
// pointed at nothing, so every past purchase under that tier lost its "early bird"
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
// ids stable across edits so that history stays linked.
if (Array.isArray(earlyBirdTiers)) { if (Array.isArray(earlyBirdTiers)) {
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {} const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
for (const et of existingTiers) {
if (incomingIds.includes(et.id)) continue;
// Never delete a tier that's still attributed on past purchases — just leave it
// orphaned from the option's active tier list rather than nulling out history.
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
if (usageCount === 0) {
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
}
}
for (let i = 0; i < earlyBirdTiers.length; i++) { for (let i = 0; i < earlyBirdTiers.length; i++) {
const t = earlyBirdTiers[i]; const t = earlyBirdTiers[i];
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue; if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
const deadline = new Date(t.deadline); const deadline = new Date(t.deadline);
const p = parseFloat(t.price); const p = parseFloat(t.price);
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue; if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
await prisma.earlyBirdTier.create({ const data = {
data: { eventOptionId: updatedEventOption.id,
id: require('uuid').v4(), variantId: t.variantId || null,
eventOptionId: updatedEventOption.id, deadline,
variantId: t.variantId || null, price: p,
deadline, order: typeof t.order === 'number' ? t.order : i,
price: p, stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
order: typeof t.order === 'number' ? t.order : i, };
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0, if (t.id) {
} await prisma.earlyBirdTier.upsert({
}); where: { id: t.id },
update: data,
create: { id: t.id, ...data },
});
} else {
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
}
} }
} }
+29 -10
View File
@@ -78,7 +78,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
user: { select: { id: true } } user: { select: { id: true } }
} }
@@ -134,7 +134,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
user: { select: { id: true } } user: { select: { id: true } }
} }
@@ -242,7 +242,8 @@ const createPayment = async (req, res) => {
include: { include: {
registrationOptions: { registrationOptions: {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } } eventOption: { include: { earlyBirdTiers: true } },
tranches: true
} }
}, },
payments: true payments: true
@@ -641,7 +642,8 @@ const assignDonationToRegistration = async (req, res) => {
include: { include: {
registrationOptions: { registrationOptions: {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } } eventOption: { include: { earlyBirdTiers: true } },
tranches: true
} }
}, },
payments: true payments: true
@@ -812,7 +814,7 @@ const unassignDonationFromRegistration = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: leg.registrationId }, where: { id: leg.registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true payments: true
} }
}); });
@@ -846,7 +848,7 @@ const unassignDonationFromRegistration = async (req, res) => {
const updatedRegistration = await prisma.registration.findUnique({ const updatedRegistration = await prisma.registration.findUnique({
where: { id: leg.registrationId }, where: { id: leg.registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true payments: true
} }
}); });
@@ -903,6 +905,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: true, variant: true,
tranches: true,
} }
}, },
payments: true, payments: true,
@@ -1017,7 +1020,7 @@ const createYocoCheckout = async (req, res) => {
const freshReg = await prisma.registration.findUnique({ const freshReg = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true payments: true
} }
}); });
@@ -1040,6 +1043,7 @@ const createYocoCheckout = async (req, res) => {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: true, variant: true,
tranches: true,
} }
}, },
payments: true, payments: true,
@@ -1270,7 +1274,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId }, where: { id: linkRegistrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
// Load tickets to check usage if needed // Load tickets to check usage if needed
_count: true _count: true
@@ -1324,7 +1328,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId }, where: { id: linkRegistrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true payments: true
} }
}); });
@@ -1416,6 +1420,20 @@ const getPaymentStats = async (req, res) => {
} }
} }
// @desc Re-send the receipt PDF for one of the caller's own payments
// @route POST /api/payments/:id/send-receipt
// @access Private
const sendReceipt = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendReceiptToUser } = require('../utils/notifications');
await sendReceiptToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = { module.exports = {
createPayment, createPayment,
getPayments, getPayments,
@@ -1429,5 +1447,6 @@ module.exports = {
createRegistrationCheckoutInternal, createRegistrationCheckoutInternal,
sendPaymentLink, sendPaymentLink,
createRefund, createRefund,
getPaymentStats getPaymentStats,
sendReceipt,
}; };
+307 -95
View File
@@ -4,7 +4,7 @@ const axios = require("axios");
const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController'); const { emailTickets } = require('./ticketController');
const { hashPassword } = require('../config/auth'); const { hashPassword } = require('../config/auth');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue } = require('../utils/pricing'); const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { assertEventOpen } = require('../utils/cashupUtils'); const { assertEventOpen } = require('../utils/cashupUtils');
/** /**
@@ -226,17 +226,31 @@ const createRegistration = async (req, res) => {
let registration; let registration;
let isNewRegistration = false; let isNewRegistration = false;
if (existingReg) { if (existingReg) {
// Merge: upsert each requested option into the existing registration // Merge: add a new price tranche per requested option into the existing registration.
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) { for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row // Match on eventOptionId + variantId for correct row
const existing = existingReg.registrationOptions.find( const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
); );
if (existing) { if (existing) {
await prisma.registrationOption.update({ await prisma.$transaction([
where: { id: existing.id }, prisma.registrationOptionTranche.create({
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null } data: {
}); id: uuidv4(),
registrationOptionId: existing.id,
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
})
]);
} else { } else {
await prisma.registrationOption.create({ await prisma.registrationOption.create({
data: { data: {
@@ -247,6 +261,14 @@ const createRegistration = async (req, res) => {
variantId: opt.variantId || null, variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null, appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot, priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
} }
}); });
} }
@@ -263,7 +285,7 @@ const createRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: existingReg.id }, where: { id: existingReg.id },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } } user: { select: { id: true, name: true, email: true, phoneNumber: true } }
} }
@@ -286,11 +308,19 @@ const createRegistration = async (req, res) => {
variantId: option.variantId || null, variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null, appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot, priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
})) }))
} }
}, },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } } user: { select: { id: true, name: true, email: true, phoneNumber: true } }
} }
@@ -368,7 +398,7 @@ const createRegistration = async (req, res) => {
} }
})(); })();
res.status(201).json(registration); res.status(201).json(attachComputedTotals(registration));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -385,6 +415,7 @@ const getRegistrations = async (req, res) => {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } }, variant: { select: { id: true, name: true, price: true } },
tranches: true,
} }
}, },
event: true, event: true,
@@ -403,7 +434,7 @@ const getRegistrations = async (req, res) => {
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users. // the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') { if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ include }); const registrations = await prisma.registration.findMany({ include });
return res.json(registrations); return res.json(attachComputedTotalsToList(registrations));
} }
const page = Math.max(1, parseInt(req.query.page) || 1); const page = Math.max(1, parseInt(req.query.page) || 1);
@@ -415,7 +446,7 @@ const getRegistrations = async (req, res) => {
prisma.registration.count() prisma.registration.count()
]); ]);
res.json({ data: registrations, total, page, limit, pages: Math.ceil(total / limit) }); res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -434,6 +465,18 @@ const getUserRegistrations = async (req, res) => {
event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } } event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } }
}; };
// Keep pending/partial-paid registrations' prices current before serving them — an
// early-bird tier can expire while items sit unpaid in someone's registration, and
// without this the dashboard would keep showing a price that was never actually locked
// in by a payment, indefinitely, until the user happens to attempt a payment.
const staleCandidates = await prisma.registration.findMany({
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
const registrations = await prisma.registration.findMany({ const registrations = await prisma.registration.findMany({
where: whereClause, where: whereClause,
include: { include: {
@@ -441,6 +484,7 @@ const getUserRegistrations = async (req, res) => {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } }, variant: { select: { id: true, name: true, price: true } },
tranches: true,
} }
}, },
// Nest the event's form so the frontend can tell whether attendee forms are // Nest the event's form so the frontend can tell whether attendee forms are
@@ -453,12 +497,13 @@ const getUserRegistrations = async (req, res) => {
email: true, email: true,
phoneNumber: true phoneNumber: true
} }
} },
payments: true,
}, },
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' }
}); });
res.json(registrations); res.json(attachComputedTotalsToList(registrations));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -469,35 +514,42 @@ const getUserRegistrations = async (req, res) => {
// @access Private // @access Private
const getRegistrationById = async (req, res) => { const getRegistrationById = async (req, res) => {
try { try {
const registration = await prisma.registration.findUnique({ const include = {
where: { id: req.params.id }, registrationOptions: {
include: { include: {
registrationOptions: { eventOption: { include: { earlyBirdTiers: true } },
include: { variant: { select: { id: true, name: true, price: true } },
eventOption: { include: { earlyBirdTiers: true } }, tickets: true,
variant: { select: { id: true, name: true, price: true } }, tranches: true
tickets: true }
} },
}, event: true,
event: true, user: {
user: { select: {
select: { id: true,
id: true, name: true,
name: true, email: true,
email: true, phoneNumber: true
phoneNumber: true }
} },
}, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, formResponses: { include: { answers: true } }
formResponses: { include: { answers: true } } };
}
}); let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
if (!registration) { if (!registration) {
res.status(404); res.status(404);
throw new Error('Registration not found'); throw new Error('Registration not found');
} }
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable) // Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
// Authenticated users must be the owner or staff+ // Authenticated users must be the owner or staff+
if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
@@ -505,7 +557,7 @@ const getRegistrationById = async (req, res) => {
throw new Error('Not authorized to view this registration'); throw new Error('Not authorized to view this registration');
} }
res.json(registration); res.json(attachComputedTotals(registration));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -659,6 +711,17 @@ const cancelRegistration = async (req, res) => {
const getRegistrationsByEvent = async (req, res) => { const getRegistrationsByEvent = async (req, res) => {
try { try {
const { search } = req.query; const { search } = req.query;
// See getUserRegistrations — keep pending/partial-paid registrations' prices current
// before serving them, rather than only at payment time.
const staleCandidates = await prisma.registration.findMany({
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
let registrations = await prisma.registration.findMany({ let registrations = await prisma.registration.findMany({
where: { eventId: req.params.eventId }, where: { eventId: req.params.eventId },
include: { include: {
@@ -667,6 +730,7 @@ const getRegistrationsByEvent = async (req, res) => {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } }, variant: { select: { id: true, name: true, price: true } },
tickets: true, tickets: true,
tranches: true,
} }
}, },
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
@@ -684,7 +748,7 @@ const getRegistrationsByEvent = async (req, res) => {
); );
} }
res.json(registrations); res.json(attachComputedTotalsToList(registrations));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -891,20 +955,33 @@ const createManualRegistration = async (req, res) => {
let isNewRegistration = false; let isNewRegistration = false;
if (existingReg) { if (existingReg) {
// Upsert each requested option into the existing registration (all in parallel) // Add a new price tranche per requested option into the existing registration (all in
// parallel) — never overwrite an existing row's priceSnapshot/quantity in place, or
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => { await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find( const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
); );
if (existing) { if (existing) {
return prisma.registrationOption.update({ return prisma.$transaction([
where: { id: existing.id }, prisma.registrationOptionTranche.create({
data: { data: {
quantity: existing.quantity + opt.quantity, id: uuidv4(),
priceSnapshot: opt.priceSnapshot, registrationOptionId: existing.id,
appliedTierId: opt.appliedTierId || null, quantity: opt.quantity,
} priceSnapshot: opt.priceSnapshot,
}); appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: {
quantity: existing.quantity + opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
})
]);
} }
return prisma.registrationOption.create({ return prisma.registrationOption.create({
data: { data: {
@@ -915,6 +992,14 @@ const createManualRegistration = async (req, res) => {
variantId: opt.variantId || null, variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null, appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot, priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
} }
}); });
})); }));
@@ -922,7 +1007,7 @@ const createManualRegistration = async (req, res) => {
const freshForStatus = await prisma.registration.findUnique({ const freshForStatus = await prisma.registration.findUnique({
where: { id: existingReg.id }, where: { id: existingReg.id },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
} }
}); });
@@ -937,7 +1022,7 @@ const createManualRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: existingReg.id }, where: { id: existingReg.id },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
}, },
@@ -960,11 +1045,19 @@ const createManualRegistration = async (req, res) => {
variantId: option.variantId || null, variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null, appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot, priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
})), })),
}, },
}, },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
}, },
@@ -1035,7 +1128,7 @@ const createManualRegistration = async (req, res) => {
} }
})(); })();
return res.status(201).json(registration); return res.status(201).json(attachComputedTotals(registration));
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return res.status(400).json({message: error.message}); return res.status(400).json({message: error.message});
@@ -1063,6 +1156,7 @@ const updateRegistrationOptions = async (req, res) => {
registrationOptions: { registrationOptions: {
include: { include: {
tickets: true, tickets: true,
tranches: true,
eventOption: { include: { earlyBirdTiers: true, variants: true } } eventOption: { include: { earlyBirdTiers: true, variants: true } }
} }
}, },
@@ -1134,39 +1228,11 @@ const updateRegistrationOptions = async (req, res) => {
} }
const mergedOptions = Array.from(mergedOptionsMap.values()); const mergedOptions = Array.from(mergedOptionsMap.values());
// Resolve pricing for each incoming option (variant-aware, with stock check)
const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo]));
const resolvedUpdateOptions = [];
for (const opt of mergedOptions) {
const eventOption = eventOptionsMap.get(opt.eventOptionId);
const variantId = opt.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, opt.quantity);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, opt.quantity);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId });
}
const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0);
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
}
// Group existing registrationOptions by eventOptionId::variantId so tickets that // Group existing registrationOptions by eventOptionId::variantId so tickets that
// have already been issued are never deleted, only ever updated in place. // have already been issued are never deleted, only ever updated in place. Computed
// before pricing resolution because pricing now depends on whether a quantity is
// increasing (and by how much) — an unchanged or reduced quantity must never
// re-price tickets already locked in at an earlier price (see tranche design).
const oldByKey = new Map(); const oldByKey = new Map();
for (const ro of registration.registrationOptions) { for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`; const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
@@ -1174,6 +1240,86 @@ const updateRegistrationOptions = async (req, res) => {
oldByKey.get(key).push(ro); oldByKey.get(key).push(ro);
} }
// A legacy row (created before the tranche migration) has no tranches — fall back to
// its own priceSnapshot/quantity as a single implicit tranche for totals purposes.
const trancheSum = (tranches) => (tranches || []).reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
const rowsTotal = (rows) => rows.reduce((sum, ro) => {
const tranches = ro.tranches || [];
if (tranches.length > 0) return sum + trancheSum(tranches);
return sum + Number(ro.quantity || 0) * Number(ro.priceSnapshot ?? ro.eventOption?.price ?? 0);
}, 0);
// Remove `qtyToRemove` units from a set of tranches, newest-first (LIFO) — mirrors the
// ticket-floor invariant below: issued tickets always map to the oldest tranches, so the
// newest (least-committed) tranches are the ones trimmed first on a quantity decrease.
const planLIFORemoval = (tranches, qtyToRemove) => {
const sorted = [...tranches].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
let remaining = qtyToRemove;
const ops = [];
const kept = [];
for (const t of sorted) {
if (remaining <= 0) { kept.push(t); continue; }
if (t.quantity <= remaining) {
remaining -= t.quantity;
ops.push({ type: 'delete', trancheId: t.id });
} else {
const newQuantity = t.quantity - remaining;
remaining = 0;
ops.push({ type: 'update', trancheId: t.id, newQuantity });
kept.push({ ...t, quantity: newQuantity });
}
}
return { kept, ops };
};
// Resolve pricing for each incoming option (variant-aware, with stock check) — but only
// for the delta being newly added. Unchanged or decreasing quantities never re-resolve.
const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo]));
const resolvedUpdateOptions = [];
let newTotalDue = 0;
for (const opt of mergedOptions) {
const key = `${opt.eventOptionId}::${opt.variantId || ''}`;
const existingRows = oldByKey.get(key) || [];
const existingQty = existingRows.reduce((sum, ro) => sum + (ro.quantity || 0), 0);
const delta = (opt.quantity || 0) - existingQty;
const existingTotal = rowsTotal(existingRows);
const eventOption = eventOptionsMap.get(opt.eventOptionId);
const variantId = opt.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
let removalPlan = null;
if (delta > 0) {
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, delta);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
} else if (delta < 0) {
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
removalPlan = planLIFORemoval(existingTranches, -delta);
newTotalDue += trancheSum(removalPlan.kept);
} else {
newTotalDue += existingTotal;
}
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
}
// Per-item floor: a ticket is only ever created once a registration is paid, and it is // Per-item floor: a ticket is only ever created once a registration is paid, and it is
// never deleted or shrunk — only grown. So an option can never be reduced (or removed) // never deleted or shrunk — only grown. So an option can never be reduced (or removed)
// below the quantity of any ticket already issued for it. // below the quantity of any ticket already issued for it.
@@ -1208,20 +1354,63 @@ const updateRegistrationOptions = async (req, res) => {
const existingRows = oldByKey.get(key); const existingRows = oldByKey.get(key);
if (existingRows && existingRows.length > 0) { if (existingRows && existingRows.length > 0) {
const [primary, ...dupes] = existingRows; const [primary, ...dupes] = existingRows;
await tx.registrationOption.update({
where: { id: primary.id }, // Consolidate dupes into primary first: move their tranches and tickets rather
data: { // than discarding them, so price history (and money) survives duplicate cleanup.
quantity: opt.quantity,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
}
});
for (const dup of dupes) { for (const dup of dupes) {
if ((dup.tranches || []).length > 0) {
await tx.registrationOptionTranche.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
}
if ((dup.tickets || []).length > 0) { if ((dup.tickets || []).length > 0) {
await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } }); await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
} }
await tx.registrationOption.delete({ where: { id: dup.id } }); await tx.registrationOption.delete({ where: { id: dup.id } });
} }
if (opt.delta > 0) {
// Increase: a new tranche for the delta at the freshly-resolved price — never
// overwrite the existing tranches' locked-in prices.
await tx.registrationOptionTranche.create({
data: {
id: uuidv4(),
registrationOptionId: primary.id,
quantity: opt.delta,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
});
await tx.registrationOption.update({
where: { id: primary.id },
data: { quantity: opt.quantity, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot }
});
} else if (opt.delta < 0 && opt.removalPlan) {
// Decrease: trim tranches newest-first (LIFO); issued tickets always map to the
// oldest tranches, and the floor check above already guarantees this never dips
// below issued-ticket quantity.
for (const op of opt.removalPlan.ops) {
if (op.type === 'delete') {
await tx.registrationOptionTranche.delete({ where: { id: op.trancheId } });
} else {
await tx.registrationOptionTranche.update({ where: { id: op.trancheId }, data: { quantity: op.newQuantity } });
}
}
const newest = [...opt.removalPlan.kept].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0];
await tx.registrationOption.update({
where: { id: primary.id },
data: {
quantity: opt.quantity,
appliedTierId: newest ? (newest.appliedTierId || null) : null,
priceSnapshot: newest ? newest.priceSnapshot : null,
}
});
} else {
// Unchanged quantity: only reflect dupe-consolidation in the aggregate; leave
// the locked-in price/tier untouched.
await tx.registrationOption.update({
where: { id: primary.id },
data: { quantity: opt.quantity }
});
}
} else { } else {
await tx.registrationOption.create({ await tx.registrationOption.create({
data: { data: {
@@ -1232,6 +1421,14 @@ const updateRegistrationOptions = async (req, res) => {
variantId: opt.variantId || null, variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null, appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot, priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
} }
}); });
} }
@@ -1253,7 +1450,7 @@ const updateRegistrationOptions = async (req, res) => {
const updated = await prisma.registration.findUnique({ const updated = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true, payments: true,
@@ -1277,7 +1474,7 @@ const updateRegistrationOptions = async (req, res) => {
})(); })();
} }
return res.json(updated); return res.json(attachComputedTotals(updated));
} catch (error) { } catch (error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
@@ -1296,7 +1493,7 @@ const submitFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
} }
}); });
@@ -1388,7 +1585,7 @@ const replaceFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
} }
}); });
@@ -1550,6 +1747,20 @@ const saveFormDraft = async (req, res) => {
} }
}; };
// @desc Re-send the current invoice PDF for one of the caller's own registrations
// @route POST /api/registrations/:id/send-invoice
// @access Private
const sendInvoice = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendInvoiceToUser } = require('../utils/notifications');
await sendInvoiceToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Invoice sent to WhatsApp.' : 'Invoice emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = { module.exports = {
createRegistration, createRegistration,
getRegistrations, getRegistrations,
@@ -1564,4 +1775,5 @@ module.exports = {
replaceFormResponses, replaceFormResponses,
getFormDraft, getFormDraft,
saveFormDraft, saveFormDraft,
sendInvoice,
}; };
+2 -1
View File
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
include: { include: {
earlyBirdTiers: true earlyBirdTiers: true
} }
} },
tranches: true
} }
}, },
payments: true payments: true
+17
View File
@@ -529,6 +529,15 @@ app.get('/docs', async (req, res) => {
pathParams:{ ':id':'Registration UUID' }, pathParams:{ ':id':'Registration UUID' },
request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }}, request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }},
responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]}, responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]},
{ method:'POST', path:'/api/registrations/:id/send-invoice', auth:'user+', desc:'(Re)generate the invoice PDF for one of the caller\'s own registrations and send it on the requested channel — email or WhatsApp. Works regardless of balance (shows "Paid in full" when nothing is owed); fails only if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s invoiceNo is derived, not sequential: INV-<year of registration.createdAt>-<last 6 hex chars of the registration id, uppercased>. When there is an outstanding balance, generates a fresh Yoco checkout link for the invoice\'s QR code/pay-now link on each call.',
pathParams:{ ':id':'Registration UUID' },
request:{ body:{ channel:'email' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Invoice emailed.' }},
{ status:400, desc:'No valid contact for channel', body:{ message:'No valid email address on file. Add one in your profile.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this registration' }},
]},
]}, ]},
{ title: 'Payments', base: '/api/payments', endpoints: [ { title: 'Payments', base: '/api/payments', endpoints: [
@@ -546,6 +555,14 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"', { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' }, queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]}, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
{ method:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
pathParams:{ ':id':'Payment UUID' },
request:{ body:{ channel:'whatsapp' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
]},
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
+3 -1
View File
@@ -12,7 +12,8 @@ const {
createYocoCheckout, createYocoCheckout,
sendPaymentLink, sendPaymentLink,
createRefund, createRefund,
getPaymentStats getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController'); } = require('../controllers/paymentController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware'); const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
@@ -21,6 +22,7 @@ router.post('/', protect, supervisor, createPayment);
router.post('/yoco-checkout', protect, createYocoCheckout); router.post('/yoco-checkout', protect, createYocoCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink); router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.get('/mypayments', protect, getUserPayments); router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
router.get('/:id', protect, getPaymentById); router.get('/:id', protect, getPaymentById);
router.get('/registration/:registrationId', protect, getPaymentsByRegistration); router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
+2
View File
@@ -12,6 +12,7 @@ const {
updateRegistrationOptions, updateRegistrationOptions,
submitFormResponses, replaceFormResponses, submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft, getFormDraft, saveFormDraft,
sendInvoice,
} = require('../controllers/registrationController'); } = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware'); const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -20,6 +21,7 @@ router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations); router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions); router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration); router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId // Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById); router.get('/:id', optionalAuth, getRegistrationById);
+6 -4
View File
@@ -83,7 +83,7 @@ async function computeEventFinancials(eventId) {
}), }),
prisma.registrationOption.findMany({ prisma.registrationOption.findMany({
where: { registration: { eventId, status: 'paid' } }, where: { registration: { eventId, status: 'paid' } },
include: { eventOption: { select: { id: true, name: true, price: true } } } include: { eventOption: { select: { id: true, name: true, price: true } }, tranches: true }
}), }),
prisma.eventCashup.findMany({ prisma.eventCashup.findMany({
where: { eventId }, where: { eventId },
@@ -191,15 +191,17 @@ async function computeEventFinancials(eventId) {
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts; const netProfit = effectiveTotalRevenue - totalCosts;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown // What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
// at the price it was actually bought at, not one blended/stale price for the whole line.
const { computeOptionLineTotal } = require('./pricing');
const salesByOptionMap = {}; const salesByOptionMap = {};
for (const ro of salesRows) { for (const ro of salesRows) {
const opt = ro.eventOption; const opt = ro.eventOption;
if (!opt) continue; if (!opt) continue;
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 }; if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
salesByOptionMap[opt.id].quantitySold += ro.quantity; salesByOptionMap[opt.id].quantitySold += ro.quantity;
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity; salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
} }
const salesByOption = Object.values(salesByOptionMap); const salesByOption = Object.values(salesByOptionMap);
+240 -25
View File
@@ -1,6 +1,7 @@
const fs = require('fs');
const prisma = require('../config/db'); const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email'); const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
// ─── Formatting helpers ─────────────────────────────────────────────────────── // ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -72,7 +73,7 @@ async function loadRegistrationFull(registrationId) {
return prisma.registration.findUnique({ return prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true, payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
@@ -98,7 +99,7 @@ async function loadPaymentFull(paymentId) {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
registration: { registration: {
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true, payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
@@ -116,11 +117,11 @@ function selectionsTable(registrationOptions) {
const rows = (registrationOptions || []).map(ro => { const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option'; const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1; const qty = ro.quantity || 1;
const price = ro.eventOption?.price || 0; const lineTotal = computeOptionLineTotal(ro, null, new Date());
return `<tr> return `<tr>
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td> <td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td> <td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td> <td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(lineTotal)}</td>
</tr>`; </tr>`;
}); });
@@ -270,7 +271,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })} ${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`; ${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`; const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) }; return { subject, text, html: emailWrapper(body, { preheader }) };
@@ -310,7 +311,7 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })} ${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`; ${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const payText = balance > 0 const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` ? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired : formRequired
@@ -338,7 +339,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } =
`<tr> `<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td> <td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td> <td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td> <td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}</td>
</tr>`).join(''); </tr>`).join('');
const body = ` const body = `
@@ -898,25 +899,74 @@ function buildDailySummary(ev, registrations, payments, now) {
// ─── Send functions ─────────────────────────────────────────────────────────── // ─── Send functions ───────────────────────────────────────────────────────────
/**
* Generates an invoice PDF for a registration when it still has an outstanding balance,
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
* PDF generation fails callers fall back to the existing text-only notification either way.
*/
/**
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
*/
async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (paymentUrlHint) return paymentUrlHint;
if (totalDue - totalPaid <= 0.01) return null;
try {
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
return null;
}
}
// Only used by the automatic registration-confirmation sends, which should stay silent (no
// invoice attached) when nothing is owed — receipts already cover the fully-paid case.
async function maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (totalDue - totalPaid <= 0.01) return null;
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint });
try {
const { generateInvoicePdf } = require('./pdfDocs');
return await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
return null;
}
}
async function sendRegistrationEmails(registrationId) { async function sendRegistrationEmails(registrationId) {
let invoicePdf = null;
try { try {
const reg = await loadRegistrationFull(registrationId); const reg = await loadRegistrationFull(registrationId);
if (!reg) return; if (!reg) return;
const { shouldEmail, waText } = require('./notify'); const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages'); const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue } = require('./pricing');
const sends = []; const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
// Email: only for real addresses (skip guest.local placeholders) // Email: only for real addresses (skip guest.local placeholders)
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: true }); const msg = buildRegistrationConfirmation(reg, { isNew: true });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally // WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const waCaption = buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true }); const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
@@ -925,25 +975,34 @@ async function sendRegistrationEmails(registrationId) {
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send registration emails:', e); console.error('Failed to send registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
} }
} }
async function sendRegistrationUpdatedEmails(registrationId) { async function sendRegistrationUpdatedEmails(registrationId) {
let invoicePdf = null;
try { try {
const reg = await loadRegistrationFull(registrationId); const reg = await loadRegistrationFull(registrationId);
if (!reg) return; if (!reg) return;
const { shouldEmail, waText } = require('./notify'); const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages'); const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue } = require('./pricing');
const sends = []; const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: false }); const msg = buildRegistrationConfirmation(reg, { isNew: false });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const waCaption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true }); const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
@@ -951,25 +1010,34 @@ async function sendRegistrationUpdatedEmails(registrationId) {
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send registration updated emails:', e); console.error('Failed to send registration updated emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
} }
} }
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) { async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
let invoicePdf = null;
try { try {
const reg = await loadRegistrationFull(registrationId); const reg = await loadRegistrationFull(registrationId);
if (!reg) return; if (!reg) return;
const { shouldEmail, waText } = require('./notify'); const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages'); const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue } = require('./pricing');
const sends = []; const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint: paymentUrl });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew }); const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const waCaption = buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew }); const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
@@ -977,28 +1045,40 @@ async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl =
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send self-service registration emails:', e); console.error('Failed to send self-service registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
} }
} }
async function sendPaymentEmails(paymentId) { async function sendPaymentEmails(paymentId) {
let receiptPdf = null;
try { try {
const payment = await loadPaymentFull(paymentId); const payment = await loadPaymentFull(paymentId);
if (!payment) return; if (!payment) return;
const user = payment.registration?.user || payment.user; const user = payment.registration?.user || payment.user;
const { shouldEmail, waText, waTextAny } = require('./notify'); const { shouldEmail, waPdf, waPdfAny, waText, waTextAny } = require('./notify');
const { buildWAPayment } = require('./waMessages'); const { buildWAPayment } = require('./waMessages');
const { generateReceiptPdf } = require('./pdfDocs');
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
const sends = []; const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) { if (hasValidEmail) {
const msg = buildPaymentReceipt(payment); const msg = buildPaymentReceipt(payment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(user)) sends.push(sendMail({
to: user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(receiptPdf ? { attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't // WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't.
// Send the receipt PDF (with the usual text as its caption) when generation succeeded,
// otherwise fall back to the plain text message so a PDF failure never blocks delivery.
const waCaption = buildWAPayment(payment);
if (hasValidEmail) { if (hasValidEmail) {
sends.push(waText(user, buildWAPayment(payment))); sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
} else { } else {
sends.push(waTextAny(user, buildWAPayment(payment))); sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
} }
const hasEvent = !!(payment.registration?.eventId || payment.eventId); const hasEvent = !!(payment.registration?.eventId || payment.eventId);
if (hasEvent) { if (hasEvent) {
@@ -1010,6 +1090,8 @@ async function sendPaymentEmails(paymentId) {
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send payment emails:', e); console.error('Failed to send payment emails:', e);
} finally {
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
} }
} }
@@ -1104,7 +1186,7 @@ async function sendDonationUnassignmentEmails(leg) {
prisma.registration.findUnique({ prisma.registration.findUnique({
where: { id: leg.registrationId }, where: { id: leg.registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
@@ -1152,6 +1234,137 @@ async function sendDonationUnassignmentEmails(leg) {
} }
} }
// ─── Manual "send to me" requests ──────────────────────────────────────────────
// Unlike the fire-and-forget sends above, these are awaited directly from a
// request handler (dashboard "Email/WhatsApp invoice|receipt" buttons) — they
// throw an Error with a `statusCode` on failure instead of swallowing it, so the
// controller can surface a real error to the user rather than failing silently.
function userFacingError(message, statusCode) {
const e = new Error(message);
e.statusCode = statusCode;
return e;
}
/**
* (re)generates and sends a registration's invoice PDF to the registrant on a single,
* explicitly-chosen channel. Only the registrant themself may request their own invoice.
* @param {string} registrationId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendInvoiceToUser(registrationId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const reg = await loadRegistrationFull(registrationId);
if (!reg) throw userFacingError('Registration not found', 404);
if (reg.userId !== requesterId) throw userFacingError('Not authorized to access this registration', 403);
if (channel === 'email' && (!reg.user?.email || reg.user.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !reg.user?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
// Unlike the automatic post-registration send, a manually-requested invoice is generated
// regardless of balance — someone may want it as a paid-in-full record too.
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid });
let invoicePdf;
try {
const { generateInvoicePdf } = require('./pdfDocs');
invoicePdf = await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
throw userFacingError('Could not generate the invoice right now. Please try again shortly.', 500);
}
try {
const eventTitle = reg.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: reg.user.email,
subject: `Your invoice for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${reg.user?.name || 'there'},\n\nHere's the invoice you requested for ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }],
});
} else {
// Explicit user-requested channel — bypass the notification-preference gate (waPdfAny)
// the same way ticket resends do, so "WhatsApp invoice" works even if the user's saved
// preference is email-only.
const { waPdfAny } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const caption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
await waPdfAny(reg.user, invoicePdf.filePath, invoicePdf.filename, caption);
}
} finally {
try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
/**
* (Re)generates and sends a payment's receipt PDF to the payer on a single, explicitly-chosen
* channel. Only the person the payment belongs to (via the payment itself or its registration)
* may request their own receipt.
* @param {string} paymentId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendReceiptToUser(paymentId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const payment = await loadPaymentFull(paymentId);
if (!payment) throw userFacingError('Payment not found', 404);
const owner = payment.registration?.user || payment.user;
const ownerId = payment.registration?.userId || payment.userId;
if (ownerId !== requesterId) throw userFacingError('Not authorized to access this payment', 403);
if (channel === 'email' && (!owner?.email || owner.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !owner?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const { generateReceiptPdf } = require('./pdfDocs');
let receiptPdf;
try {
receiptPdf = await generateReceiptPdf(payment);
} catch (e) {
console.error('Failed to generate receipt PDF:', e);
throw userFacingError('Could not generate the receipt right now. Please try again shortly.', 500);
}
try {
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: owner.email,
subject: `Your payment receipt for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${owner?.name || 'there'},\n\nHere's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }],
});
} else {
const { waPdfAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
await waPdfAny(owner, receiptPdf.filePath, receiptPdf.filename, buildWAPayment(payment));
}
} finally {
try { fs.unlinkSync(receiptPdf.filePath); } catch {}
}
}
async function sendDailyEventSummaries(now = new Date()) { async function sendDailyEventSummaries(now = new Date()) {
try { try {
const today = new Date(now); const today = new Date(now);
@@ -1175,7 +1388,7 @@ async function sendDailyEventSummaries(now = new Date()) {
where: { eventId: ev.id }, where: { eventId: ev.id },
include: { include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
@@ -1210,4 +1423,6 @@ module.exports = {
sendDonationUnassignmentEmails, sendDonationUnassignmentEmails,
sendCheckInEmails, sendCheckInEmails,
buildCheckInConfirmation, buildCheckInConfirmation,
sendInvoiceToUser,
sendReceiptToUser,
}; };
+351
View File
@@ -0,0 +1,351 @@
/**
* Branded PDF documents: payment receipts and registration invoices.
*
* Both are generated with pdfkit into backend/temp and returned as
* { filePath, filename } for callers to attach to an email/WhatsApp send and
* clean up afterwards (see notifications.js).
*
* Line items are built the same tranche-aware way the user dashboard renders
* them (backend/src/utils/pricing.js) merging tranches that share a
* name/price/tier and separating early-bird lines from standard-price ones
* so the PDF total always matches computeRegistrationTotalDue().
*/
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const { getSetting } = require('./settingsCache');
function fmtAmount(amt) {
return `R${Number(amt || 0).toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch { return ''; }
}
function formatMethod(method) {
const m = String(method || '').toLowerCase();
if (!m) return '—';
if (m === 'eft') return 'EFT';
return m.replace(/^./, c => c.toUpperCase());
}
function tempDir() {
const dir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
function docNumber(prefix, id, date) {
const year = new Date(date || Date.now()).getFullYear();
const short = String(id || '').replace(/-/g, '').slice(-6).toUpperCase() || '000000';
return `${prefix}-${year}-${short}`;
}
async function getBranding() {
const [name, address, email, phone, primary, accent, logoUrl] = await Promise.all([
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
getSetting('org_address', ''),
getSetting('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
getSetting('org_phone', ''),
getSetting('primary_color', ''),
getSetting('accent_color', ''),
getSetting('logo_url', ''),
]);
const url = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
let logoPath = null;
if (logoUrl) {
const p = path.join(__dirname, '..', '..', 'public', logoUrl.replace(/^\//, ''));
if (fs.existsSync(p)) logoPath = p;
}
return { name, address, email, phone, brandColor: primary || accent || '#1e3a5f', url, logoPath };
}
/**
* Merge a registration's tranches into display rows, same grouping logic as
* the user dashboard: one row per (name, unit price, early-bird flag), with
* `basePrice` carried along so callers can work out the early-bird discount.
*/
function buildLineItems(registrationOptions) {
const rows = [];
for (const ro of (registrationOptions || [])) {
const variantLabel = ro.variant?.name ? ` (${ro.variant.name})` : '';
const label = `${ro.eventOption?.name || 'Option'}${variantLabel}`;
const basePrice = Number((ro.variant?.price ?? ro.eventOption?.price ?? 0));
const tranches = Array.isArray(ro.tranches) && ro.tranches.length > 0
? ro.tranches
: [{
quantity: ro.quantity,
priceSnapshot: (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : basePrice,
appliedTierId: ro.appliedTierId,
}];
for (const t of tranches) {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
let row = rows.find(r => r.key === key);
if (!row) { row = { key, label, quantity: 0, unitPrice, isEarlyBird, basePrice }; rows.push(row); }
row.quantity += (t.quantity || 0);
}
}
return rows;
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
const colDesc = x;
// Fixed-width columns anchored to the right edge so amounts never wrap,
// regardless of the overall table width (receipt vs. narrower invoice table).
const totalColW = 75, priceColW = 65, qtyColW = 35;
const colTotal = x + width - totalColW;
const colPrice = colTotal - priceColW;
const colQty = colPrice - qtyColW;
const rowH = 22;
if (headerLight) {
doc.rect(x, y, width, rowH).fill('#f8fafc');
doc.fillColor('#64748b').font('Helvetica-Bold').fontSize(9);
} else {
doc.rect(x, y, width, rowH).fill(brandColor);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(9);
}
doc.text('DESCRIPTION', colDesc + 8, y + 7);
doc.text('QTY', colQty, y + 7, { width: qtyColW - 8, align: 'right' });
doc.text('PRICE', colPrice, y + 7, { width: priceColW - 8, align: 'right' });
doc.text('TOTAL', colTotal, y + 7, { width: totalColW - 8, align: 'right' });
y += rowH;
doc.font('Helvetica').fontSize(10).fillColor('#374151');
for (const row of rows) {
doc.text(row.label + (row.isEarlyBird ? ' (early bird)' : ''), colDesc + 8, y + 6, { width: colQty - colDesc - 12 });
doc.text(String(row.quantity), colQty, y + 6, { width: qtyColW - 8, align: 'right' });
doc.text(fmtAmount(row.unitPrice), colPrice, y + 6, { width: priceColW - 8, align: 'right' });
doc.text(fmtAmount(row.unitPrice * row.quantity), colTotal, y + 6, { width: totalColW - 8, align: 'right' });
doc.moveTo(x, y + rowH).lineTo(x + width, y + rowH).strokeColor('#e2e8f0').lineWidth(0.5).stroke();
y += rowH;
}
return y;
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
/**
* @param {object} payment - from notifications.js loadPaymentFull: amount, method, externalId,
* createdAt, user, registration { event, registrationOptions[{eventOption,variant,tranches}] }
* @returns {Promise<{ filePath: string, filename: string }>}
*/
async function generateReceiptPdf(payment) {
const org = await getBranding();
const reg = payment.registration;
const user = reg?.user || payment.user;
const eventTitle = reg?.event?.title || payment.event?.title || 'Event';
const eventStartDate = reg?.event?.startDate || payment.event?.startDate || null;
const eventDate = eventStartDate ? fmtDate(eventStartDate) : '';
const receiptNo = docNumber('RCPT', payment.id, payment.createdAt);
const rows = reg ? buildLineItems(reg.registrationOptions) : [];
const filename = `receipt-${receiptNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const writeStream = fs.createWriteStream(filePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const marginX = 40;
// Header banner
const bannerH = 150;
doc.rect(0, 0, pageWidth, bannerH).fill(org.brandColor);
if (org.logoPath) {
try { doc.image(org.logoPath, marginX, 28, { fit: [36, 36] }); } catch {}
}
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(13).text(org.name, marginX + (org.logoPath ? 46 : 0), 38, { width: 260 });
doc.font('Helvetica-Bold').fontSize(26).text('Payment Receipt', marginX, 68, { width: 320 });
doc.font('Helvetica').fontSize(11).text('Thank you for your payment.', marginX, 102, { width: 320 });
if (eventStartDate) doc.font('Helvetica-Bold').fontSize(10).text('We look forward to seeing you at the event!', marginX, 122, { width: 320 });
const metaX = pageWidth - 250;
const meta = [
['Receipt No.', receiptNo],
['Date', fmtDate(payment.createdAt)],
['Payment Method', formatMethod(payment.method)],
['Transaction ID', payment.externalId || payment.id.slice(0, 12)],
];
let metaY = 34;
for (const [label, value] of meta) {
doc.font('Helvetica').fontSize(9).fillColor('#ffffff').fillOpacity(0.75).text(label, metaX, metaY, { width: 90 });
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').fillOpacity(1).text(value, metaX + 90, metaY, { width: 120, align: 'right' });
metaY += 24;
}
// Body
let y = bannerH + 30;
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYER DETAILS', marginX, y);
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('EVENT DETAILS', marginX + 280, y);
y += 16;
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(user?.name || 'Guest', marginX, y);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(eventTitle, marginX + 280, y, { width: 240 });
y += 16;
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user?.email || '', marginX, y);
if (eventDate) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(eventDate, marginX + 280, y);
y += 14;
if (user?.phoneNumber) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user.phoneNumber, marginX, y);
y += 30;
if (rows.length > 0) {
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
}
y += 20;
const boxW = 160, boxH = 32;
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('TOTAL PAID', pageWidth - marginX - boxW - 110, y + 10, { width: 90, align: 'right' });
doc.roundedRect(pageWidth - marginX - boxW, y, boxW, boxH, 4).fill(org.brandColor);
doc.font('Helvetica-Bold').fontSize(14).fillColor('#ffffff').text(fmtAmount(payment.amount), pageWidth - marginX - boxW, y + 9, { width: boxW, align: 'center' });
y += boxH + 30;
// Footer
const footerH = 60;
const footerY = Math.max(pageHeight - footerH, y + 20);
doc.rect(0, footerY, pageWidth, footerH).fill('#0f172a');
doc.font('Helvetica-BoldOblique').fontSize(16).fillColor('#ffffff').text('Thank you!', marginX, footerY + 20);
doc.font('Helvetica').fontSize(9).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 0, footerY + 24, { width: pageWidth - marginX, align: 'right' });
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
return { filePath, filename };
}
// ─── Invoice ───────────────────────────────────────────────────────────────────
/**
* @param {object} registration - from notifications.js loadRegistrationFull: id, createdAt,
* user, event, registrationOptions[{eventOption,variant,tranches}], payments
* @param {{ paymentUrl?: string|null, totalDue?: number, totalPaid?: number }} opts
* @returns {Promise<{ filePath: string, filename: string }>}
*/
async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = null, totalPaid = null } = {}) {
const org = await getBranding();
const { computeRegistrationTotalDue } = require('./pricing');
const due = totalDue !== null ? totalDue : computeRegistrationTotalDue(registration, new Date());
const paid = totalPaid !== null ? totalPaid : (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(due - paid, 0);
const invoiceNo = docNumber('INV', registration.id, registration.createdAt);
const rows = buildLineItems(registration.registrationOptions);
// Subtotal/discount: only early-bird rows are compared against today's base price, so an
// ordinary base-price change over time never shows up as a false "discount" on standard rows.
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
const discount = Math.max(0, fullPriceTotal - chargedTotal);
const filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const writeStream = fs.createWriteStream(filePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const sidebarW = 190;
const mainX = sidebarW + 30;
const mainW = pageWidth - mainX - 40;
// Sidebar
doc.rect(0, 0, sidebarW, pageHeight).fill('#111827');
let sy = 40;
if (org.logoPath) {
try { doc.image(org.logoPath, 28, sy, { fit: [32, 32] }); sy += 0; } catch {}
}
doc.font('Helvetica-Bold').fontSize(12).fillColor('#ffffff').text(org.name, org.logoPath ? 68 : 28, sy + 8, { width: sidebarW - (org.logoPath ? 96 : 56) });
sy += 60;
doc.font('Helvetica-Bold').fontSize(20).fillColor('#ffffff').text('INVOICE', 28, sy, { width: sidebarW - 56 });
sy += 28;
doc.font('Helvetica-Bold').fontSize(11).fillColor(org.brandColor).text(invoiceNo, 28, sy, { width: sidebarW - 56 });
sy += 34;
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('DATE ISSUED', 28, sy);
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(fmtDate(registration.createdAt), 28, sy + 11);
sy += 40;
doc.font('Helvetica').fontSize(8).fillColor(org.brandColor).text('BILL TO', 28, sy);
sy += 13;
doc.font('Helvetica-Bold').fontSize(10).fillColor('#ffffff').text(registration.user?.name || 'Guest', 28, sy, { width: sidebarW - 56 });
sy += 15;
doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user?.email || '', 28, sy, { width: sidebarW - 56 });
sy += 12;
if (registration.user?.phoneNumber) { doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user.phoneNumber, 28, sy, { width: sidebarW - 56 }); sy += 12; }
let by = pageHeight - 140;
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(org.name, 28, by, { width: sidebarW - 56 });
by += 13;
if (org.address) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.address, 28, by, { width: sidebarW - 56 }); by += 12 * Math.ceil(org.address.length / 28); }
if (org.email) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.email, 28, by, { width: sidebarW - 56 }); by += 12; }
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 28, by, { width: sidebarW - 56 });
// Main content
let y = 40;
doc.font('Helvetica-Bold').fontSize(9).fillColor(org.brandColor).text('EVENT', mainX, y);
y += 14;
doc.font('Helvetica-Bold').fontSize(14).fillColor('#0f172a').text(registration.event?.title || 'Event', mainX, y, { width: mainW });
y += 18;
if (registration.event?.startDate) { doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(fmtDate(registration.event.startDate), mainX, y); y += 14; }
y += 16;
y = drawItemsTable(doc, { x: mainX, width: mainW, y, rows, brandColor: org.brandColor, headerLight: false });
y += 16;
const totalsX = mainX + mainW - 220;
const totalLine = (label, value, opts = {}) => {
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
.text(label, totalsX, y, { width: 120 });
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
totalLine('TOTAL DUE', fmtAmount(due), { bold: true, size: 13, color: org.brandColor });
if (paid > 0) totalLine('Already paid', `-${fmtAmount(paid)}`, { size: 9 });
if (paid > 0) totalLine('BALANCE DUE', fmtAmount(balance), { bold: true, size: 12, color: balance > 0 ? org.brandColor : '#059669' });
y += 20;
// Payment section
if (balance > 0.01) {
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYMENT', mainX, y);
y += 16;
if (paymentUrl) {
const qrSize = 100;
try {
const qrBuffer = await QRCode.toBuffer(paymentUrl, { width: qrSize, margin: 1 });
doc.image(qrBuffer, mainX + mainW - qrSize, y, { width: qrSize, height: qrSize });
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('Scan to pay', mainX + mainW - qrSize, y + qrSize + 4, { width: qrSize, align: 'center' });
} catch {}
doc.font('Helvetica').fontSize(9).fillColor('#374151').text('Pay online — tap the link or scan the QR code:', mainX, y, { width: mainW - 120 });
doc.font('Helvetica-Bold').fontSize(10).fillColor('#2563eb').text(paymentUrl, mainX, y + 16, { width: mainW - 120, link: paymentUrl, underline: true });
y += 60;
} else {
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(`Pay online at ${org.url} or at the door (cash/card).`, mainX, y, { width: mainW });
y += 24;
}
} else {
doc.font('Helvetica-Bold').fontSize(11).fillColor('#059669').text('PAID IN FULL', mainX, y);
y += 24;
}
// Footer
const footerH = 40;
const footerY = Math.max(pageHeight - footerH, y + 20);
doc.rect(sidebarW, footerY, pageWidth - sidebarW, footerH).fill(org.brandColor);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#ffffff').text('Thank you for your support!', sidebarW, footerY + 13, { width: pageWidth - sidebarW, align: 'center' });
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
+131 -43
View File
@@ -7,11 +7,21 @@
* Used at registration-creation time and again at payment-initiation time. * Used at registration-creation time and again at payment-initiation time.
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and * - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot. * as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after * - A RegistrationOption is an aggregate line (one per registration+option+variant); each
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice. * purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption * priceSnapshot + appliedTierId, never mutated after creation mirrors the Payment model's
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier * append-only pattern). This is what lets someone buy more of the same ticket type after an
* is now expired or its stock is exhausted. * early-bird tier expires without the new price bleeding onto tickets already bought.
* RegistrationOption.quantity/priceSnapshot/appliedTierId are kept as a maintained mirror
* (quantity = sum of tranche quantities; priceSnapshot/appliedTierId = most recent tranche)
* for code that only needs "how many" or a single display price.
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
* rows created before tranches existed).
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
* applicable tier/price for just that tranche. Older tranches already priced-in are
* never touched.
*/ */
const prisma = require('../config/db'); const prisma = require('../config/db');
@@ -74,12 +84,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
// Skip expired tiers // Skip expired tiers
if (now >= new Date(tier.deadline)) continue; if (now >= new Date(tier.deadline)) continue;
// Check stock limit if one is set // Check stock limit if one is set. Sold-so-far is summed across tranches (not
// RegistrationOption rows directly) because one RegistrationOption can now span
// multiple tiers across its tranches — the row's own appliedTierId/quantity only
// reflects its most recent tranche.
if (tier.stockLimit > 0) { if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({ const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: { where: {
appliedTierId: tier.id, appliedTierId: tier.id,
registration: { status: { not: 'cancelled' } } registrationOption: { registration: { status: { not: 'cancelled' } } }
}, },
_sum: { quantity: true } _sum: { quantity: true }
}); });
@@ -125,8 +138,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
for (const tier of tiers) { for (const tier of tiers) {
if (now >= new Date(tier.deadline)) continue; if (now >= new Date(tier.deadline)) continue;
if (tier.stockLimit > 0) { if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({ const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } }, where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
_sum: { quantity: true } _sum: { quantity: true }
}); });
const tierSold = soldAgg._sum?.quantity || 0; const tierSold = soldAgg._sum?.quantity || 0;
@@ -154,21 +167,46 @@ async function refreshPricingForRegistration(registrationId) {
include: { include: {
registrationOptions: { registrationOptions: {
include: { include: {
eventOption: { include: { earlyBirdTiers: true, variants: true } } eventOption: { include: { earlyBirdTiers: true, variants: true } },
tranches: true,
} }
} },
payments: true,
} }
}); });
if (!registration) return { changed: false }; if (!registration) return { changed: false };
// Each registrationOption is independent, so resolve/update them concurrently const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
// instead of one at a time — this loop sits directly in the payment-capture path.
const results = await Promise.all(registration.registrationOptions.map(async (ro) => { // For each option, only its single newest tranche is ever a repricing candidate — older
// Only refresh options that were priced via a tier // tranches were already locked in at purchase time and must never be touched. A newest
if (!ro.appliedTierId) return false; // tranche is only touched once payments-so-far are established to not yet cover it (i.e.
// it's the still-unpaid remainder) — legacy rows with no tranches are left to the
// getEffectiveUnitPrice fallback in computeRegistrationTotalDue instead.
let registrationOlderTotal = 0;
const candidates = [];
for (const ro of registration.registrationOptions) {
const tranches = ro.tranches || [];
if (tranches.length === 0) continue;
const sorted = [...tranches].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const newest = sorted[sorted.length - 1];
const older = sorted.slice(0, -1);
registrationOlderTotal += older.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
candidates.push({ ro, newest });
}
// Payments don't even cover the already-locked-in older tranches yet — leave everything
// alone rather than guessing which portion is "paid for".
if (totalPaid < registrationOlderTotal - 0.001) return { changed: false };
// Each candidate is independent, so resolve/update them concurrently instead of one at a
// time — this loop sits directly in the payment-capture path.
const results = await Promise.all(candidates.map(async ({ ro, newest }) => {
// Only refresh tranches that were priced via a tier
if (!newest.appliedTierId) return false;
// Find the currently applied tier // Find the currently applied tier
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId); const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === newest.appliedTierId);
if (currentTier && new Date() < new Date(currentTier.deadline)) { if (currentTier && new Date() < new Date(currentTier.deadline)) {
// The tier's deadline is still in the future — honor the locked price. // The tier's deadline is still in the future — honor the locked price.
@@ -177,19 +215,21 @@ async function refreshPricingForRegistration(registrationId) {
// Deadline has passed (or tier record missing) — resolve the next applicable tier // Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity) ? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, ro.quantity); : await resolveOptionPrice(ro.eventOption, newest.quantity);
const tierChanged = resolved.tierId !== ro.appliedTierId; const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001; const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
if (tierChanged || priceChanged) { if (tierChanged || priceChanged) {
await prisma.registrationOptionTranche.update({
where: { id: newest.id },
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
});
// Mirror onto the aggregate row — it's this option's most recent tranche.
await prisma.registrationOption.update({ await prisma.registrationOption.update({
where: { id: ro.id }, where: { id: ro.id },
data: { data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
priceSnapshot: resolved.price,
appliedTierId: resolved.tierId
}
}); });
return true; return true;
} }
@@ -199,14 +239,43 @@ async function refreshPricingForRegistration(registrationId) {
return { changed: results.some(Boolean) }; return { changed: results.some(Boolean) };
} }
/**
* Compute the total for a single RegistrationOption line sums quantity*priceSnapshot
* across its tranches (each priced at whatever was in effect when it was purchased).
* Falls back to the legacy single priceSnapshot/getEffectiveUnitPrice path for rows
* created before tranches existed.
*
* @param {object} ro - RegistrationOption, optionally with .tranches[] included
* @param {Date|null} lastPaymentAt - used only for the legacy getEffectiveUnitPrice fallback
* @param {Date} atTime - evaluation time (used only for the legacy fallback)
* @returns {number}
*/
function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
if (Array.isArray(ro.tranches) && ro.tranches.length > 0) {
return ro.tranches.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
}
const qty = Number(ro.quantity || 0);
let unit;
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
// priceSnapshot is authoritative — set at registration creation and kept current
// by refreshPricingForRegistration at payment initiation time.
unit = ro.priceSnapshot;
} else {
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
const eo = ro.eventOption || {};
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
}
return qty * unit;
}
/** /**
* Compute total due for a registration at a given time. * Compute total due for a registration at a given time.
* *
* Uses priceSnapshot when present (authoritative set at registration time and refreshed * Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice * `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
* for legacy rows without a snapshot.
* *
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption} * @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[] * and optionally payments[]
* @param {Date} atTime - evaluation time (used for legacy fallback only) * @param {Date} atTime - evaluation time (used for legacy fallback only)
* @returns {number} * @returns {number}
@@ -222,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) {
} }
} catch {} } catch {}
return registration.registrationOptions.reduce((sum, ro) => { return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
const qty = Number(ro.quantity || 0); }
let unit;
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) { /**
// priceSnapshot is authoritative — set at registration creation and kept current * Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
// by refreshPricingForRegistration at payment initiation time. * each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
unit = ro.priceSnapshot; * responses hand the frontend an exact total instead of every page re-deriving
} else { * unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines *
const eo = ro.eventOption || {}; * @param {object} registration - requires registrationOptions[].tranches included
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime); * @returns {object} the same registration, mutated
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
} }
} catch {}
const now = new Date();
if (Array.isArray(registration.registrationOptions)) {
for (const ro of registration.registrationOptions) {
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
}
}
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
return sum + qty * unit; function attachComputedTotalsToList(registrations) {
}, 0); return (registrations || []).map(attachComputedTotals);
} }
module.exports = { module.exports = {
@@ -245,5 +330,8 @@ module.exports = {
resolveOptionPrice, resolveOptionPrice,
resolveVariantTierPrice, resolveVariantTierPrice,
refreshPricingForRegistration, refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue, computeRegistrationTotalDue,
attachComputedTotals,
attachComputedTotalsToList,
}; };
+2 -1
View File
@@ -46,8 +46,9 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.` ? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`; : `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || []) const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`) .map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n'); .join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.7.0", "version": "1.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -158,7 +158,9 @@ export default function AdminRegistrationsPage() {
return "text-gray-700 bg-gray-50"; return "text-gray-700 bg-gray-50";
}; };
const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => { // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0); : (opt.eventOption?.price || 0);
@@ -334,25 +336,40 @@ export default function AdminRegistrationsPage() {
<div className="mb-3"> <div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div> <div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
<div className="grid sm:grid-cols-2 gap-2"> <div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => ( {r.registrationOptions.map((opt: any) => {
<div key={opt.id} className="bg-white border rounded p-2 text-xs"> // A line can span multiple price tranches (e.g. tickets bought
<div className="font-medium"> // before and after an early-bird tier expired) — show one row per
{opt.eventOption?.name || opt.eventOptionId} // tranche so its own price/tier status is accurate, not blended.
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>} const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
</div> ? opt.tranches
<div className="text-gray-500"> : [{
{(() => { id: opt.id,
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0); : (opt.variant?.price ?? opt.eventOption?.price ?? 0),
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`; appliedTierId: opt.appliedTierId,
})()} }];
return (
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
{tranches.map((t: any, idx: number) => {
const unit = Number(t.priceSnapshot || 0);
return (
<div key={t.id || idx} className="text-gray-500">
{`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`}
{t.appliedTierId && (
<span className="text-green-700 text-[10px] ml-1">(early bird)</span>
)}
</div>
);
})}
</div> </div>
{opt.appliedTierId && ( );
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div> })}
)}
</div>
))}
</div> </div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div> <div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
</div> </div>
@@ -608,8 +608,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const options = registration.options || registration.registrationOptions || []; const options = registration.options || registration.registrationOptions || [];
const payments = registration.payments || []; const payments = registration.payments || [];
const totalValue = options.reduce((sum: number, opt: any) => { // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// Use priceSnapshot (authoritative backend price, variant-aware) if available // multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0); : (opt.eventOption?.price ?? opt.price ?? 0);
@@ -650,7 +651,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const updatedOptions = updated.options || updated.registrationOptions || []; const updatedOptions = updated.options || updated.registrationOptions || [];
const updatedPayments = updated.payments || []; const updatedPayments = updated.payments || [];
const totalValue = updatedOptions.reduce((sum: number, opt: any) => { const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0); : (opt.eventOption?.price ?? opt.price ?? 0);
@@ -1737,12 +1738,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="border rounded p-3 text-center"> <div className="border rounded p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div> <div className="text-xs text-gray-500">TOTAL</div>
<div className="font-semibold"> <div className="font-semibold">
R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => { R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
? Number(o.priceSnapshot) ? Number(o.priceSnapshot)
: (o.eventOption?.price || o.price || 0); : (o.eventOption?.price || o.price || 0);
return s + price * (o.quantity || 0); return s + price * (o.quantity || 0);
}, 0).toFixed(2)} }, 0)).toFixed(2)}
</div> </div>
</div> </div>
<div className="border rounded p-3 text-center bg-red-50"> <div className="border rounded p-3 text-center bg-red-50">
@@ -21,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
return `${y}-${m}-${day}T${hh}:${mm}`; return `${y}-${m}-${day}T${hh}:${mm}`;
} }
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) { function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]); const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
try { try {
const tiers = rows const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '') .filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i })) .map((r, i) => ({ id: r.id, deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime())); .filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers); onSave(tiers);
} finally { } finally {
@@ -755,12 +755,14 @@ interface EventDraft {
title: string; description: string; startDate: string; endDate: string; title: string; description: string; startDate: string; endDate: string;
registrationDeadline: string; goLiveAt: string; price: string; picture: string; registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean; redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
} }
const blankDraft = (): EventDraft => ({ const blankDraft = (): EventDraft => ({
title: "", description: "", startDate: "", endDate: "", title: "", description: "", startDate: "", endDate: "",
registrationDeadline: "", goLiveAt: "", price: "", picture: "", registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true, redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
}); });
const blankOptions = (): OptionDraft[] => [ const blankOptions = (): OptionDraft[] => [
@@ -791,6 +793,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt), registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "", price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false, isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
} : blankDraft()); } : blankDraft());
// ── options (with per-variant tiers) ── // ── options (with per-variant tiers) ──
@@ -873,6 +877,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── save helpers ── // ── save helpers ──
const saveOptions = async (eventId: string) => { const saveOptions = async (eventId: string) => {
if (!draft.requiresRegistration) {
// Contact-only events have no ticket options to save — but a form (if any) still needs
// saving in edit mode; for create mode the form is already included in the POST body.
if (mode === "edit") {
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
});
}
return;
}
if (mode === "edit") { if (mode === "edit") {
for (const opt of options) { for (const opt of options) {
// Build flat tier array: option-level + all variant tiers // Build flat tier array: option-level + all variant tiers
@@ -1032,7 +1047,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => { const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; } if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; } if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create") { if (mode === "create" && draft.requiresRegistration) {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; } if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; } if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
} }
@@ -1043,9 +1058,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(), startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined, registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined, goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.price ? parseFloat(draft.price) : 0, price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth, picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined, redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
requiresRegistration: draft.requiresRegistration,
contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
}; };
if (mode === "edit") { if (mode === "edit") {
@@ -1115,10 +1134,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2; const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on // Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event. // (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && isPriceInvalid(draft.price))); const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across // Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate). // all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
const optionsIncomplete = mode === "create" && step === 1 && options.some(o => isPriceInvalid(o.price)); // Not applicable to contact-only events, which have no ticket options at all.
const optionsIncomplete = mode === "create" && draft.requiresRegistration && step === 1 && options.some(o => isPriceInvalid(o.price));
const nextDisabled = basicDetailsIncomplete || optionsIncomplete; const nextDisabled = basicDetailsIncomplete || optionsIncomplete;
// ── render ── // ── render ──
@@ -1189,11 +1209,40 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} /> <DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" /> <DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div> </div>
<div> <div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label> <input
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" /> type="checkbox"
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below enter 0 for a free event.</p>} id="contact-only-toggle"
className="mt-0.5"
checked={!draft.requiresRegistration}
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
/>
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
</label>
</div> </div>
{draft.requiresRegistration ? (
<div>
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below enter 0 for a free event.</p>}
</div>
) : (
<div className="space-y-3 p-3 border rounded">
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Name</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactName} onChange={e => upd({ contactName: e.target.value })} placeholder="e.g. Pastor John" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Phone</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactPhone} onChange={e => upd({ contactPhone: e.target.value })} placeholder="e.g. 082 123 4567" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Email</label>
<input type="email" className="w-full border rounded px-3 py-2 text-sm" value={draft.contactEmail} onChange={e => upd({ contactEmail: e.target.value })} placeholder="e.g. info@hopefamilychurch.org" />
</div>
</div>
)}
<div> <div>
<label className="block text-xs text-gray-600 mb-1">Picture</label> <label className="block text-xs text-gray-600 mb-1">Picture</label>
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />} {draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
@@ -126,7 +126,7 @@ export default function ManualRegistrationPage() {
const regOutstanding = useMemo(() => { const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {}; const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) { for (const r of allRegistrations) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0); const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) }; map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
} }
@@ -199,8 +199,9 @@ function PaymentsContent() {
const now = new Date(); const now = new Date();
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {}; const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of list) { for (const r of list) {
// totalDue uses priceSnapshot — not time-dependent // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); // multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0); const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) }; map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
} }
+151 -72
View File
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
// Registration details modal // Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null); const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false }); const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
// Track whether the active registration's event has attendee forms // Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null); const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -140,12 +142,11 @@ export default function UserDashboardPage() {
setRegistrations(myRegs); setRegistrations(myRegs);
setTickets(myTicks); setTickets(myTicks);
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware). // Use the backend's tranche-aware totalDueComputed (exact even when a line spans
// priceSnapshot is set at registration time and refreshed before each payment. // multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
const now = new Date();
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {}; const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) { for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
} }
setBilling(totals); setBilling(totals);
@@ -156,8 +157,8 @@ export default function UserDashboardPage() {
try { try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token }); const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDue uses priceSnapshot — not time-dependent, no need to recompute per payment time // totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid); const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({ setBilling(prev => ({
...prev, ...prev,
@@ -254,26 +255,6 @@ export default function UserDashboardPage() {
} }
}; };
const emailRegistration = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
// Show loading dialog while sending
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
method: "POST",
body: { registrationId },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email registration tickets");
}
};
const whatsappTickets = async (ticketIds: string[]) => { const whatsappTickets = async (ticketIds: string[]) => {
if (!token || ticketIds.length === 0) return; if (!token || ticketIds.length === 0) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; } if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
@@ -294,26 +275,60 @@ export default function UserDashboardPage() {
} }
}; };
const whatsappRegistration = async (registrationId: string) => { const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
if (!token) return; if (!token) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
setError(null); setError(null);
setInfo(null); setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true }); setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try { try {
const res: any = await apiFetch("/api/tickets/email", { const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST", method: "POST",
body: { registrationId, channel: "whatsapp" }, body: { channel },
authToken: token, authToken: token,
}); });
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`; setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) { } catch (e: any) {
setDialog({ open: false, message: "", loading: false }); setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp"); setError(e?.message || "Failed to send invoice");
} }
}; };
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send receipt");
}
};
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
// whichever id it was opened with, then closes the popup.
const chooseChannel = (channel: 'email' | 'whatsapp') => {
if (!channelPicker) return;
const { kind, id } = channelPicker;
setChannelPicker(null);
if (kind === 'invoice') sendInvoice(id, channel);
else sendReceipt(id, channel);
};
// Creates a Yoco checkout for the full outstanding balance and redirects there directly — // Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard. // choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => { const payNow = async (registrationId: string) => {
@@ -638,11 +653,10 @@ export default function UserDashboardPage() {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token }); const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks); setTickets(myTicks);
} catch {} } catch {}
// Recompute billing totals using priceSnapshot // Recompute billing totals using the backend's tranche-aware totalDueComputed
const now2 = new Date();
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {}; const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) { for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
} }
setBilling(totals); setBilling(totals);
@@ -969,24 +983,64 @@ export default function UserDashboardPage() {
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button> <button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button>
)} )}
</div> </div>
{!editMode ? ( {!editMode ? (() => {
<ul className="text-sm list-disc pl-5 space-y-1"> // A line can span multiple price tranches (e.g. tickets bought before
{(activeReg.registrationOptions || []).map((opt: any) => { // and after an early-bird tier expired, or across repeat registrations).
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : ''; // Merge tranches that share a name/price/tier into one row, then group
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) // early-bird rows separately from standard-price rows so repeat purchases
? Number(opt.priceSnapshot) // of the same item don't show as a wall of near-duplicate lines.
: (opt.variant?.price ?? opt.eventOption?.price ?? 0); const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
return ( (activeReg.registrationOptions || []).forEach((opt: any) => {
<li key={opt.id}> const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
{opt.eventOption?.name}{variantLabel} x {opt.quantity} {formatRand(unitPrice * (opt.quantity || 0))} const label = `${opt.eventOption?.name || ''}${variantLabel}`;
{unitPrice < (opt.eventOption?.price || 0) && ( const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
<span className="ml-1 text-xs text-green-700">(early bird)</span> ? opt.tranches
)} : [{
</li> quantity: opt.quantity,
); priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
})} ? Number(opt.priceSnapshot)
</ul> : (opt.variant?.price ?? opt.eventOption?.price ?? 0),
) : ( appliedTierId: opt.appliedTierId,
}];
tranches.forEach((t: any) => {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
const existing = rows.find(r => r.key === key);
if (existing) {
existing.quantity += (t.quantity || 0);
} else {
rows.push({ key, label, quantity: t.quantity || 0, unitPrice, isEarlyBird });
}
});
});
const earlyBirdRows = rows.filter(r => r.isEarlyBird);
const standardRows = rows.filter(r => !r.isEarlyBird);
const showGroupLabels = earlyBirdRows.length > 0 && standardRows.length > 0;
const renderGroup = (heading: string, groupRows: typeof rows) => groupRows.length > 0 && (
<div key={heading}>
{showGroupLabels && (
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mt-2 first:mt-0">{heading}</div>
)}
<ul className="text-sm list-disc pl-5 space-y-1">
{groupRows.map(r => (
<li key={r.key}>
{r.label} x {r.quantity} {formatRand(r.unitPrice * r.quantity)}
{r.isEarlyBird && (
<span className="ml-1 text-xs text-green-700">(early bird)</span>
)}
</li>
))}
</ul>
</div>
);
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
</div>
);
})() : (
<div className="border rounded p-3 space-y-2 bg-gray-50"> <div className="border rounded p-3 space-y-2 bg-gray-50">
{editError && <p className="text-xs text-red-600">{editError}</p>} {editError && <p className="text-xs text-red-600">{editError}</p>}
{editLoading ? ( {editLoading ? (
@@ -1091,9 +1145,12 @@ export default function UserDashboardPage() {
{activeBill && activeBill.payments.length > 0 && ( {activeBill && activeBill.payments.length > 0 && (
<div> <div>
<div className="font-medium mb-1">Payments</div> <div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1"> <ul className="text-sm space-y-1">
{activeBill.payments.map((p: any) => ( {activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li> <li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
<span>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -1105,24 +1162,21 @@ export default function UserDashboardPage() {
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)} onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button> >Attendee forms</button>
)} )}
{canModifyActive && activeBill && activeBill.outstanding > 0 ? ( <button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
<button <button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)} onClick={() => payNow(activeReg.id)}
>Make payment</button> >Make payment</button>
) : (
<>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
)}
</>
)} )}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
</div> </div>
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */} {/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
@@ -1156,6 +1210,31 @@ export default function UserDashboardPage() {
</div> </div>
)} )}
{channelPicker && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
onClick={() => setChannelPicker(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => chooseChannel('email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => chooseChannel('whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
</div>
</div>
)}
{dialog.open && ( {dialog.open && (
<div <div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@@ -1170,7 +1249,7 @@ export default function UserDashboardPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="text-lg font-semibold mb-2">Tickets sent</div> <div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p> <p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
const [payments, setPayments] = useState<PaymentItem[]>([]); const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
const [sendingReceipt, setSendingReceipt] = useState(false);
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setReceiptPickerId(null);
setError(null);
setInfo(null);
setSendingReceipt(true);
try {
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setInfo((res && res.message) || "Receipt sent.");
} catch (e: any) {
setError(e?.message || "Failed to send receipt");
} finally {
setSendingReceipt(false);
}
};
// Filters // Filters
const [startDate, setStartDate] = useState(""); const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState(""); const [endDate, setEndDate] = useState("");
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
</div> </div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>} {error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm"> <div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4"> <div className="flex flex-wrap items-end gap-3 mb-4">
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
</div> </div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div> <div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div> </div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div> <div className="flex items-center justify-between gap-2">
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>} <div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</div>
{!isRefund && (
<button
className="text-xs text-brand-700 hover:underline shrink-0"
disabled={sendingReceipt}
onClick={() => setReceiptPickerId(p.id)}
>Receipt</button>
)}
</div>
</li> </li>
); );
})} })}
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
</div> </div>
)} )}
</div> </div>
{receiptPickerId && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setReceiptPickerId(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send receipt</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
</div>
</div>
)}
</div> </div>
); );
} }
+15
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions"; import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react"; import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60; export const revalidate = 60;
@@ -32,6 +33,10 @@ type Event = {
eventOptions?: EventOption[]; eventOptions?: EventOption[];
attachments?: EventAttachment[]; attachments?: EventAttachment[];
requiresAuth?: boolean; requiresAuth?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
}; };
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
@@ -51,6 +56,16 @@ function lowStockThreshold(stockLimit: number): number {
} }
function RegisterCta({ event }: { event: Event }) { function RegisterCta({ event }: { event: Event }) {
if (event.requiresRegistration === false) {
return (
<ContactButton
contactName={event.contactName}
contactPhone={event.contactPhone}
contactEmail={event.contactEmail}
className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors"
/>
);
}
const now = new Date(); const now = new Date();
const end = new Date(event.endDate); const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
+7 -1
View File
@@ -1,4 +1,4 @@
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
@@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
throw e; throw e;
} }
// Contact-only events (e.g. baptism) have no registration flow — bounce a stale/direct
// link back to the event detail page, which renders the Contact affordance instead.
if (event.requiresRegistration === false) {
redirect(`/events/${eventId}`);
}
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<Navbar /> <Navbar />
@@ -56,6 +56,9 @@ function RegistrationSuccessContent() {
const totalDue = React.useMemo(() => { const totalDue = React.useMemo(() => {
if (!reg) return 0; if (!reg) return 0;
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
try { try {
return (reg.registrationOptions || []).reduce((s: number, ro: any) => { return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { X, Phone, Mail, User } from "lucide-react";
type ContactButtonProps = {
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
className?: string;
label?: string;
};
export function ContactButton({ contactName, contactPhone, contactEmail, className, label = "Contact us" }: ContactButtonProps) {
const [open, setOpen] = useState(false);
const hasDetails = !!(contactName || contactPhone || contactEmail);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={className || "flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"}
>
{label}
</button>
{open && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setOpen(false)}
>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Contact us</h3>
<button
className="p-1 rounded hover:bg-gray-100 text-gray-500"
onClick={() => setOpen(false)}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{hasDetails ? (
<div className="space-y-2 text-sm">
{contactName && (
<div className="flex items-center gap-2 text-gray-800">
<User className="w-4 h-4 text-gray-400 shrink-0" />
<span>{contactName}</span>
</div>
)}
{contactPhone && (
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
</div>
)}
{contactEmail && (
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
)}
</div>
</div>
)}
</>
);
}
@@ -1,5 +1,6 @@
import { ApiImage } from "@/components/shared/ApiImage"; import { ApiImage } from "@/components/shared/ApiImage";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
import { ContactButton } from "@/components/events/ContactButton";
type Event = { type Event = {
id: string; id: string;
@@ -12,6 +13,10 @@ import { Calendar } from "lucide-react";
price: number; price: number;
picture?: string; picture?: string;
isSoldOut?: boolean; isSoldOut?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
}; };
import { formatDateTimeRange } from "@/lib/date"; import { formatDateTimeRange } from "@/lib/date";
@@ -47,6 +52,15 @@ export const EventCard = ({ event }: { event: Event }) => {
View details View details
</a> </a>
{(() => { {(() => {
if (event.requiresRegistration === false) {
return (
<ContactButton
contactName={event.contactName}
contactPhone={event.contactPhone}
contactEmail={event.contactEmail}
/>
);
}
const now = new Date(); const now = new Date();
const end = new Date(event.endDate); const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
+20 -13
View File
@@ -434,10 +434,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
Object.keys(registrationsByEvent).forEach(evId => { Object.keys(registrationsByEvent).forEach(evId => {
(registrationsByEvent[evId] || []).forEach((r: any) => { (registrationsByEvent[evId] || []).forEach((r: any) => {
const lastAt = lastPaymentAtByReg.get(r.id) || null; const lastAt = lastPaymentAtByReg.get(r.id) || null;
const dueNow = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0); // Backend attaches a tranche-aware totalDueComputed, which locks each tranche's price
// at the time it was purchased — it's already time-invariant, so the "dueNow vs
// dueAtLast" lock-in dance below is only needed as a fallback for legacy rows without it.
const hasComputed = r.totalDueComputed !== null && r.totalDueComputed !== undefined;
const dueNow = hasComputed ? r.totalDueComputed : (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
const paid = paidByReg.get(r.id) || 0; const paid = paidByReg.get(r.id) || 0;
let outstanding = Math.max(dueNow - paid, 0); let outstanding = Math.max(dueNow - paid, 0);
if (lastAt) { if (lastAt && !hasComputed) {
const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0); const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0);
if (paid >= dueAtLast) outstanding = 0; if (paid >= dueAtLast) outstanding = 0;
} }
@@ -722,10 +726,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ro = (r.registrationOptions || []) const ro = (r.registrationOptions || [])
.find((x: any) => x.eventOption?.id === opt.id); .find((x: any) => x.eventOption?.id === opt.id);
const price = ro ? optionUnitPrice(ro, null, new Date()) : 0; // Backend attaches a tranche-aware lineTotal (exact even when this line spans
// multiple early-bird prices) — fall back to the old blended-price estimate
// otherwise. __prices stores an *average* unit price derived from that, purely
// for display; __revenue carries the real total used for aggregation below.
const lineTotal = ro
? (ro.lineTotal !== null && ro.lineTotal !== undefined ? ro.lineTotal : optionUnitPrice(ro, null, new Date()) * qty)
: 0;
baseRow.__prices[opt.name] = price; // 👈 store price baseRow.__prices[opt.name] = qty > 0 ? lineTotal / qty : 0;
baseRow.orderTotal += price * qty; baseRow.__revenue = baseRow.__revenue || {};
baseRow.__revenue[opt.name] = lineTotal;
baseRow.orderTotal += lineTotal;
}); });
rows.push(baseRow); rows.push(baseRow);
@@ -768,14 +780,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const qty = row[opt.name] || 0; const qty = row[opt.name] || 0;
totals[opt.name] += qty; totals[opt.name] += qty;
// revenue per option // revenue per option — use the tranche-aware per-row total computed above rather
const price = // than re-deriving qty*price from a blended average price.
Number( totals[`${opt.name}_revenue`] += Number(row.__revenue?.[opt.name] ?? 0);
masterRows
.find(r => r === row)?.__prices?.[opt.name] ?? 0
);
totals[`${opt.name}_revenue`] += qty * price;
}); });
}); });
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt } from "lucide-react"; import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem"; import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; import type { HelpContent } from "./types";
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet"> <GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely. Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem> </GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) to your email or WhatsApp, whichever you pick.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray"> <GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only tick &quot;Show past events&quot; at the top to bring back ones that have already happened. Registrations page defaults to upcoming events only tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
</GuideItem> </GuideItem>
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Receipt} title="Payment history" tone="green"> <GuideItem icon={Receipt} title="Payment history" tone="green">
The Payment history page lists every payment you&apos;ve made across all your registrations, past and present. The Payment history page lists every payment you&apos;ve made across all your registrations, past and present.
</GuideItem> </GuideItem>
<GuideItem icon={Receipt} title="Receipts" tone="amber">
Every payment in a registration&apos;s Payments list, or on the Payment history page has its own Receipt button. Pick email or WhatsApp to get a PDF receipt for that specific payment.
</GuideItem>
<GuideItem icon={HandHeart} title="Donations" tone="rose"> <GuideItem icon={HandHeart} title="Donations" tone="rose">
A donation isn&apos;t tied to any one registration make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance. A donation isn&apos;t tied to any one registration make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance.
</GuideItem> </GuideItem>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.7.0", "version": "1.8.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",