Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9137e252 | ||
|
|
4e63a78e8e | ||
|
|
2a067a3687 | ||
|
|
bdae0c6b08 | ||
|
|
c84cc257d0 | ||
|
|
7cbb147b00 | ||
|
|
b75be18a87 | ||
|
|
05840541c2 | ||
|
|
96aa9b10cc | ||
|
|
bc64069021 | ||
|
|
e9cb238ce1 | ||
|
|
f0f8d4c242 | ||
|
|
f2c3172e16 |
@@ -7,6 +7,40 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Contact-only events (no registration possible) were still getting the daily summary email — it now skips them since there's nothing to summarize.
|
||||
|
||||
## [1.9.1] - 2026-08-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- The browser tab title and homepage "Welcome to..." heading always showed the app's built-in default name instead of the organisation name configured in Site Settings → Organisation.
|
||||
- The backend's status page (`/`) and API docs page (`/docs`) always showed "Cross Code Events" instead of the configured organisation name.
|
||||
|
||||
## [1.9.0] - 2026-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- Events now have an optional Location field (address), defaulting to the organisation's configured address when creating a new event. Wherever an address is shown — event admin form, public event page, event card listings, the Contact page, and Site Settings → Organisation — there's now a "Directions"/"View on map" link, and the event detail and Contact pages also show an embedded Google Maps view (no API key required).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"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";
|
||||
+29
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
|
||||
@@ -101,6 +101,11 @@ model Event {
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
requiresRegistration Boolean @default(true)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
@@ -163,6 +168,7 @@ model EarlyBirdTier {
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
registrationOptions RegistrationOption[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
|
||||
@@index([eventOptionId, deadline])
|
||||
@@index([variantId])
|
||||
@@ -217,6 +223,7 @@ model RegistrationOption {
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
||||
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@ -224,6 +231,27 @@ model RegistrationOption {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
amount Float
|
||||
|
||||
@@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
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, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -58,14 +58,20 @@ const createEvent = async (req, res) => {
|
||||
redirectUrl,
|
||||
isHidden: isHidden === true || isHidden === 'true',
|
||||
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
|
||||
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
|
||||
contactName: contactName || null,
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
location: location || null,
|
||||
};
|
||||
|
||||
try {
|
||||
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 {
|
||||
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({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
@@ -454,7 +460,7 @@ const updateEvent = async (req, res) => {
|
||||
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
||||
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, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -468,6 +474,11 @@ const updateEvent = async (req, res) => {
|
||||
isActive: isActive !== undefined ? isActive : event.isActive,
|
||||
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
|
||||
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,
|
||||
location: location !== undefined ? (location || null) : event.location,
|
||||
updatedAt: new Date(),
|
||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||
};
|
||||
@@ -682,26 +693,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)) {
|
||||
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++) {
|
||||
const t = earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
await prisma.earlyBirdTier.create({
|
||||
data: {
|
||||
id: require('uuid').v4(),
|
||||
const data = {
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const createPayment = async (req, res) => {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true } }
|
||||
}
|
||||
@@ -134,7 +134,7 @@ const createPayment = async (req, res) => {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true } }
|
||||
}
|
||||
@@ -242,7 +242,8 @@ const createPayment = async (req, res) => {
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } }
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
@@ -641,7 +642,8 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } }
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
@@ -812,7 +814,7 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
@@ -846,7 +848,7 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
const updatedRegistration = await prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
@@ -903,6 +905,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: true,
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
@@ -1017,7 +1020,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
@@ -1040,6 +1043,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: true,
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
@@ -1270,7 +1274,7 @@ const createRefund = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: linkRegistrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
// Load tickets to check usage if needed
|
||||
_count: true
|
||||
@@ -1324,7 +1328,7 @@ const createRefund = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: linkRegistrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: 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 = {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -1429,5 +1447,6 @@ module.exports = {
|
||||
createRegistrationCheckoutInternal,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ const axios = require("axios");
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -226,17 +226,31 @@ const createRegistration = async (req, res) => {
|
||||
let registration;
|
||||
let isNewRegistration = false;
|
||||
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) {
|
||||
// Match on eventOptionId + variantId for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.registrationOption.update({
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
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 {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
@@ -247,6 +261,14 @@ const createRegistration = async (req, res) => {
|
||||
variantId: opt.variantId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
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({
|
||||
where: { id: existingReg.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
@@ -286,11 +308,19 @@ const createRegistration = async (req, res) => {
|
||||
variantId: option.variantId || null,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: option.quantity,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: 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) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -385,6 +415,7 @@ const getRegistrations = async (req, res) => {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { id: true, name: true, price: true } },
|
||||
tranches: 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.
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
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);
|
||||
@@ -415,7 +446,7 @@ const getRegistrations = async (req, res) => {
|
||||
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) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -434,6 +465,18 @@ const getUserRegistrations = async (req, res) => {
|
||||
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({
|
||||
where: whereClause,
|
||||
include: {
|
||||
@@ -441,6 +484,7 @@ const getUserRegistrations = async (req, res) => {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: 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
|
||||
@@ -453,12 +497,13 @@ const getUserRegistrations = async (req, res) => {
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(registrations);
|
||||
res.json(attachComputedTotalsToList(registrations));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -469,14 +514,13 @@ const getUserRegistrations = async (req, res) => {
|
||||
// @access Private
|
||||
const getRegistrationById = async (req, res) => {
|
||||
try {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
const include = {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { id: true, name: true, price: true } },
|
||||
tickets: true
|
||||
tickets: true,
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
@@ -490,14 +534,22 @@ const getRegistrationById = async (req, res) => {
|
||||
},
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
formResponses: { include: { answers: true } }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
|
||||
|
||||
if (!registration) {
|
||||
res.status(404);
|
||||
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)
|
||||
// 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') {
|
||||
@@ -505,7 +557,7 @@ const getRegistrationById = async (req, res) => {
|
||||
throw new Error('Not authorized to view this registration');
|
||||
}
|
||||
|
||||
res.json(registration);
|
||||
res.json(attachComputedTotals(registration));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -659,6 +711,17 @@ const cancelRegistration = async (req, res) => {
|
||||
const getRegistrationsByEvent = async (req, res) => {
|
||||
try {
|
||||
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({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: {
|
||||
@@ -667,6 +730,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { id: true, name: true, price: true } },
|
||||
tickets: true,
|
||||
tranches: 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) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -891,20 +955,33 @@ const createManualRegistration = async (req, res) => {
|
||||
let isNewRegistration = false;
|
||||
|
||||
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 => {
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
||||
);
|
||||
if (existing) {
|
||||
return prisma.registrationOption.update({
|
||||
return prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
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,
|
||||
}
|
||||
});
|
||||
})
|
||||
]);
|
||||
}
|
||||
return prisma.registrationOption.create({
|
||||
data: {
|
||||
@@ -915,6 +992,14 @@ const createManualRegistration = async (req, res) => {
|
||||
variantId: opt.variantId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
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({
|
||||
where: { id: existingReg.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
}
|
||||
});
|
||||
@@ -937,7 +1022,7 @@ const createManualRegistration = async (req, res) => {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: existingReg.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
},
|
||||
@@ -960,11 +1045,19 @@ const createManualRegistration = async (req, res) => {
|
||||
variantId: option.variantId || null,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: option.quantity,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: 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) {
|
||||
console.error(error);
|
||||
return res.status(400).json({message: error.message});
|
||||
@@ -1063,6 +1156,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
tickets: true,
|
||||
tranches: true,
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } }
|
||||
}
|
||||
},
|
||||
@@ -1134,46 +1228,98 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
}
|
||||
const mergedOptions = Array.from(mergedOptionsMap.values());
|
||||
|
||||
// Resolve pricing for each incoming option (variant-aware, with stock check)
|
||||
// Group existing registrationOptions by eventOptionId::variantId so tickets that
|
||||
// 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();
|
||||
for (const ro of registration.registrationOptions) {
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
if (!oldByKey.has(key)) oldByKey.set(key, []);
|
||||
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, opt.quantity);
|
||||
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
|
||||
priceSnapshot = variantResolved.price;
|
||||
appliedTierId = variantResolved.tierId;
|
||||
} else {
|
||||
const resolved = await resolveOptionPrice(eventOption, opt.quantity);
|
||||
const resolved = await resolveOptionPrice(eventOption, delta);
|
||||
priceSnapshot = resolved.price;
|
||||
appliedTierId = resolved.tierId;
|
||||
}
|
||||
} catch (e) {
|
||||
priceSnapshot = Number(eventOption?.price || 0);
|
||||
}
|
||||
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId });
|
||||
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;
|
||||
}
|
||||
|
||||
const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0);
|
||||
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');
|
||||
}
|
||||
|
||||
// Group existing registrationOptions by eventOptionId::variantId so tickets that
|
||||
// have already been issued are never deleted, only ever updated in place.
|
||||
const oldByKey = new Map();
|
||||
for (const ro of registration.registrationOptions) {
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
if (!oldByKey.has(key)) oldByKey.set(key, []);
|
||||
oldByKey.get(key).push(ro);
|
||||
}
|
||||
|
||||
// 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)
|
||||
// below the quantity of any ticket already issued for it.
|
||||
@@ -1208,20 +1354,63 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
const existingRows = oldByKey.get(key);
|
||||
if (existingRows && existingRows.length > 0) {
|
||||
const [primary, ...dupes] = existingRows;
|
||||
await tx.registrationOption.update({
|
||||
where: { id: primary.id },
|
||||
data: {
|
||||
quantity: opt.quantity,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
}
|
||||
});
|
||||
|
||||
// Consolidate dupes into primary first: move their tranches and tickets rather
|
||||
// than discarding them, so price history (and money) survives duplicate cleanup.
|
||||
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) {
|
||||
await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.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 {
|
||||
await tx.registrationOption.create({
|
||||
data: {
|
||||
@@ -1232,6 +1421,14 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
variantId: opt.variantId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
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({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
payments: true,
|
||||
@@ -1277,7 +1474,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
})();
|
||||
}
|
||||
|
||||
return res.json(updated);
|
||||
return res.json(attachComputedTotals(updated));
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -1296,7 +1493,7 @@ const submitFormResponses = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
}
|
||||
});
|
||||
@@ -1388,7 +1585,7 @@ const replaceFormResponses = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
registrationOptions: { include: { eventOption: true, tranches: 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 = {
|
||||
createRegistration,
|
||||
getRegistrations,
|
||||
@@ -1564,4 +1775,5 @@ module.exports = {
|
||||
replaceFormResponses,
|
||||
getFormDraft,
|
||||
saveFormDraft,
|
||||
sendInvoice,
|
||||
};
|
||||
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
}
|
||||
},
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
|
||||
+26
-6
@@ -141,7 +141,8 @@ app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
require('./utils/settingsCache').warmCache().catch(() => {});
|
||||
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||
warmCache().catch(() => {});
|
||||
app.use('/uploads', express.static('public/uploads'));
|
||||
|
||||
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||||
@@ -244,8 +245,9 @@ app.get('/', async (req, res) => {
|
||||
? `<span class="badge badge-warn">testing</span>`
|
||||
: `<span class="badge badge-warn">development</span>`;
|
||||
|
||||
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
|
||||
<h1>Cross Code Events API</h1>
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
|
||||
<h1>${orgName} Events API</h1>
|
||||
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
||||
|
||||
<div class="stat-grid">
|
||||
@@ -529,6 +531,15 @@ app.get('/docs', async (req, res) => {
|
||||
pathParams:{ ':id':'Registration UUID' },
|
||||
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' }}]},
|
||||
{ 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: [
|
||||
@@ -546,6 +557,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"',
|
||||
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 }}]},
|
||||
{ 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',
|
||||
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 }}]},
|
||||
@@ -1013,13 +1032,14 @@ app.get('/docs', async (req, res) => {
|
||||
}
|
||||
|
||||
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cross Code Events — API Docs</title>
|
||||
<title>${orgName} Events — API Docs</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
||||
@@ -1043,7 +1063,7 @@ app.get('/docs', async (req, res) => {
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Cross Code Events — API Reference</h1>
|
||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events — API Reference</h1>
|
||||
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
||||
</div>
|
||||
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
||||
@@ -1070,7 +1090,7 @@ app.get('/docs', async (req, res) => {
|
||||
${notificationsHtml}
|
||||
|
||||
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
||||
Cross Code Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||
${orgName} Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
createYocoCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
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/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -20,6 +21,7 @@ router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
|
||||
@@ -83,7 +83,7 @@ async function computeEventFinancials(eventId) {
|
||||
}),
|
||||
prisma.registrationOption.findMany({
|
||||
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({
|
||||
where: { eventId },
|
||||
@@ -191,15 +191,17 @@ async function computeEventFinancials(eventId) {
|
||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||
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 = {};
|
||||
for (const ro of salesRows) {
|
||||
const opt = ro.eventOption;
|
||||
if (!opt) continue;
|
||||
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].revenue += unitPrice * ro.quantity;
|
||||
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
|
||||
}
|
||||
const salesByOption = Object.values(salesByOptionMap);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -72,7 +73,7 @@ async function loadRegistrationFull(registrationId) {
|
||||
return prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: 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 } } } },
|
||||
@@ -98,7 +99,7 @@ async function loadPaymentFull(paymentId) {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
registration: {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: 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 } } } },
|
||||
@@ -116,11 +117,11 @@ function selectionsTable(registrationOptions) {
|
||||
const rows = (registrationOptions || []).map(ro => {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 1;
|
||||
const price = ro.eventOption?.price || 0;
|
||||
const lineTotal = computeOptionLineTotal(ro, null, new Date());
|
||||
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 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>`;
|
||||
});
|
||||
|
||||
@@ -270,7 +271,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
${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}`;
|
||||
|
||||
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 })}
|
||||
${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
|
||||
? `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
|
||||
@@ -338,7 +339,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } =
|
||||
`<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;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('');
|
||||
|
||||
const body = `
|
||||
@@ -898,25 +899,74 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
|
||||
// ─── 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) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
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)
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
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
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
// WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
|
||||
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 });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
@@ -925,25 +975,34 @@ async function sendRegistrationEmails(registrationId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
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')) {
|
||||
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 });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
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);
|
||||
} catch (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 } = {}) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
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')) {
|
||||
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 });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
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);
|
||||
} catch (e) {
|
||||
console.error('Failed to send self-service registration emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPaymentEmails(paymentId) {
|
||||
let receiptPdf = null;
|
||||
try {
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment) return;
|
||||
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 { generateReceiptPdf } = require('./pdfDocs');
|
||||
|
||||
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
|
||||
|
||||
const sends = [];
|
||||
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasValidEmail) {
|
||||
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) {
|
||||
sends.push(waText(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
|
||||
} 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);
|
||||
if (hasEvent) {
|
||||
@@ -1010,6 +1090,8 @@ async function sendPaymentEmails(paymentId) {
|
||||
await Promise.all(sends);
|
||||
} catch (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({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: 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 } } } },
|
||||
@@ -1152,19 +1234,150 @@ 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()) {
|
||||
try {
|
||||
const today = new Date(now);
|
||||
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
||||
let events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today } },
|
||||
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
|
||||
include: notifyInclude,
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
|
||||
try {
|
||||
events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
|
||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
|
||||
include: notifyInclude,
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
@@ -1175,7 +1388,7 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
where: { eventId: ev.id },
|
||||
include: {
|
||||
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,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -1210,4 +1423,6 @@ module.exports = {
|
||||
sendDonationUnassignmentEmails,
|
||||
sendCheckInEmails,
|
||||
buildCheckInConfirmation,
|
||||
sendInvoiceToUser,
|
||||
sendReceiptToUser,
|
||||
};
|
||||
|
||||
@@ -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
@@ -7,11 +7,21 @@
|
||||
* Used at registration-creation time and again at payment-initiation time.
|
||||
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
|
||||
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
|
||||
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after
|
||||
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice.
|
||||
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption
|
||||
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier
|
||||
* is now expired or its stock is exhausted.
|
||||
* - A RegistrationOption is an aggregate line (one per registration+option+variant); each
|
||||
* purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
|
||||
* priceSnapshot + appliedTierId, never mutated after creation — mirrors the Payment model's
|
||||
* append-only pattern). This is what lets someone buy more of the same ticket type after an
|
||||
* 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');
|
||||
@@ -74,12 +84,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
// Skip expired tiers
|
||||
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) {
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
||||
where: {
|
||||
appliedTierId: tier.id,
|
||||
registration: { status: { not: 'cancelled' } }
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
@@ -125,8 +138,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
|
||||
for (const tier of tiers) {
|
||||
if (now >= new Date(tier.deadline)) continue;
|
||||
if (tier.stockLimit > 0) {
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } },
|
||||
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
||||
where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const tierSold = soldAgg._sum?.quantity || 0;
|
||||
@@ -154,21 +167,46 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } }
|
||||
}
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } },
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
|
||||
// Each registrationOption 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(registration.registrationOptions.map(async (ro) => {
|
||||
// Only refresh options that were priced via a tier
|
||||
if (!ro.appliedTierId) return false;
|
||||
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
// For each option, only its single newest tranche is ever a repricing candidate — older
|
||||
// tranches were already locked in at purchase time and must never be touched. A newest
|
||||
// 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
|
||||
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)) {
|
||||
// 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
|
||||
const resolved = ro.variantId
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
|
||||
: await resolveOptionPrice(ro.eventOption, ro.quantity);
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
|
||||
: await resolveOptionPrice(ro.eventOption, newest.quantity);
|
||||
|
||||
const tierChanged = resolved.tierId !== ro.appliedTierId;
|
||||
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
|
||||
const tierChanged = resolved.tierId !== newest.appliedTierId;
|
||||
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
|
||||
|
||||
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({
|
||||
where: { id: ro.id },
|
||||
data: {
|
||||
priceSnapshot: resolved.price,
|
||||
appliedTierId: resolved.tierId
|
||||
}
|
||||
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -199,14 +239,43 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
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.
|
||||
*
|
||||
* Uses priceSnapshot when present (authoritative — set at registration time and refreshed
|
||||
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice
|
||||
* for legacy rows without a snapshot.
|
||||
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
|
||||
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
|
||||
*
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption}
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
|
||||
* and optionally payments[]
|
||||
* @param {Date} atTime - evaluation time (used for legacy fallback only)
|
||||
* @returns {number}
|
||||
@@ -222,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return registration.registrationOptions.reduce((sum, ro) => {
|
||||
const qty = Number(ro.quantity || 0);
|
||||
let unit;
|
||||
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
|
||||
}
|
||||
|
||||
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);
|
||||
/**
|
||||
* Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
|
||||
* each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
|
||||
* responses hand the frontend an exact total instead of every page re-deriving
|
||||
* unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
|
||||
*
|
||||
* @param {object} registration - requires registrationOptions[].tranches included
|
||||
* @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;
|
||||
}, 0);
|
||||
function attachComputedTotalsToList(registrations) {
|
||||
return (registrations || []).map(attachComputedTotals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -245,5 +330,8 @@ module.exports = {
|
||||
resolveOptionPrice,
|
||||
resolveVariantTierPrice,
|
||||
refreshPricingForRegistration,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
attachComputedTotals,
|
||||
attachComputedTotalsToList,
|
||||
};
|
||||
@@ -46,8 +46,9 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
|
||||
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
|
||||
: `Your registration for *${eventTitle}* has been updated.`;
|
||||
|
||||
const { computeOptionLineTotal } = require('./pricing');
|
||||
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');
|
||||
|
||||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
|
||||
export default function ContactPage() {
|
||||
const { settings, loading } = useSiteSettings();
|
||||
@@ -68,6 +69,12 @@ export default function ContactPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{address && (
|
||||
<div className="mt-8 border rounded-xl p-5 bg-white shadow-sm">
|
||||
<LocationMap address={address} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -158,7 +158,9 @@ export default function AdminRegistrationsPage() {
|
||||
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)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
@@ -334,25 +336,40 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="mb-3">
|
||||
<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">
|
||||
{r.registrationOptions.map((opt: any) => (
|
||||
{r.registrationOptions.map((opt: any) => {
|
||||
// A line can span multiple price tranches (e.g. tickets bought
|
||||
// before and after an early-bird tier expired) — show one row per
|
||||
// tranche so its own price/tier status is accurate, not blended.
|
||||
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
|
||||
? opt.tranches
|
||||
: [{
|
||||
id: opt.id,
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
|
||||
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>
|
||||
<div className="text-gray-500">
|
||||
{(() => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
|
||||
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
|
||||
})()}
|
||||
</div>
|
||||
{opt.appliedTierId && (
|
||||
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</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>
|
||||
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon }
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
||||
|
||||
@@ -410,9 +411,15 @@ function SiteSettingsPageInner() {
|
||||
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Address">
|
||||
<Field label="Address" hint="Used as the default location for new events.">
|
||||
<input className={inputCls} placeholder="123 Church St, City"
|
||||
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
||||
{orgAddress.trim() && (
|
||||
<a href={mapsSearchUrl(orgAddress.trim())} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-block text-xs text-brand-600 hover:underline mt-1">
|
||||
View on map ↗
|
||||
</a>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||
|
||||
@@ -608,8 +608,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
const options = registration.options || registration.registrationOptions || [];
|
||||
const payments = registration.payments || [];
|
||||
|
||||
const totalValue = options.reduce((sum: number, opt: any) => {
|
||||
// Use priceSnapshot (authoritative backend price, variant-aware) if available
|
||||
// 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.
|
||||
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
@@ -650,7 +651,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
const updatedOptions = updated.options || updated.registrationOptions || [];
|
||||
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)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (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="text-xs text-gray-500">TOTAL</div>
|
||||
<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
|
||||
? Number(o.priceSnapshot)
|
||||
: (o.eventOption?.price || o.price || 0);
|
||||
return s + price * (o.quantity || 0);
|
||||
}, 0).toFixed(2)}
|
||||
}, 0)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<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}`;
|
||||
}
|
||||
|
||||
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 [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
try {
|
||||
const tiers = rows
|
||||
.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()));
|
||||
onSave(tiers);
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -755,12 +756,16 @@ interface EventDraft {
|
||||
title: string; description: string; startDate: string; endDate: string;
|
||||
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
const blankDraft = (): EventDraft => ({
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
title: "", description: "", startDate: "", endDate: "",
|
||||
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||
location,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -779,19 +784,25 @@ interface EventModalProps {
|
||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const { token, user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const { settings } = useSiteSettings();
|
||||
const [step, setStep] = useState<StepIdx>(0);
|
||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── event draft ──
|
||||
// New events default their location to the organisation's address (settings.org_address);
|
||||
// editing an existing event always reflects its own saved location instead.
|
||||
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
|
||||
title: ev.title || "", description: ev.description || "",
|
||||
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
|
||||
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
|
||||
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
|
||||
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
||||
} : blankDraft());
|
||||
requiresRegistration: ev.requiresRegistration !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
|
||||
// ── options (with per-variant tiers) ──
|
||||
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
||||
@@ -873,6 +884,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// ── save helpers ──
|
||||
|
||||
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") {
|
||||
for (const opt of options) {
|
||||
// Build flat tier array: option-level + all variant tiers
|
||||
@@ -1032,7 +1054,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const handleSave = async () => {
|
||||
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 (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 (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 +1065,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
|
||||
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).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,
|
||||
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
|
||||
requiresRegistration: draft.requiresRegistration,
|
||||
contactName: draft.contactName || undefined,
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
location: draft.location.trim(),
|
||||
};
|
||||
|
||||
if (mode === "edit") {
|
||||
@@ -1115,10 +1142,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
|
||||
// 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.
|
||||
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
|
||||
// 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;
|
||||
|
||||
// ── render ──
|
||||
@@ -1189,11 +1217,45 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Location</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation's address — shown to attendees with a map link.</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
{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>
|
||||
<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" />}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ManualRegistrationPage() {
|
||||
const regOutstanding = useMemo(() => {
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
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);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
|
||||
@@ -199,8 +199,9 @@ function PaymentsContent() {
|
||||
const now = new Date();
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of list) {
|
||||
// totalDue uses priceSnapshot — not time-dependent
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 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.
|
||||
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);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
|
||||
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
|
||||
|
||||
// Registration details modal
|
||||
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
|
||||
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
|
||||
@@ -140,12 +142,11 @@ export default function UserDashboardPage() {
|
||||
setRegistrations(myRegs);
|
||||
setTickets(myTicks);
|
||||
|
||||
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware).
|
||||
// priceSnapshot is set at registration time and refreshed before each payment.
|
||||
const now = new Date();
|
||||
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
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: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
@@ -156,8 +157,8 @@ export default function UserDashboardPage() {
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDue uses priceSnapshot — 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);
|
||||
// totalDueComputed is not time-dependent, no need to recompute per payment time
|
||||
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);
|
||||
setBilling(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[]) => {
|
||||
if (!token || ticketIds.length === 0) 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 (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
|
||||
setError(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 {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
body: { registrationId, channel: "whatsapp" },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e: any) {
|
||||
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 —
|
||||
// choosing a partial amount is only available from the supervisor payments dashboard.
|
||||
const payNow = async (registrationId: string) => {
|
||||
@@ -638,11 +653,10 @@ export default function UserDashboardPage() {
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
// Recompute billing totals using priceSnapshot
|
||||
const now2 = new Date();
|
||||
// Recompute billing totals using the backend's tranche-aware totalDueComputed
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
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: [] };
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
{!editMode ? (
|
||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||
{(activeReg.registrationOptions || []).map((opt: any) => {
|
||||
{!editMode ? (() => {
|
||||
// A line can span multiple price tranches (e.g. tickets bought before
|
||||
// and after an early-bird tier expired, or across repeat registrations).
|
||||
// Merge tranches that share a name/price/tier into one row, then group
|
||||
// early-bird rows separately from standard-price rows so repeat purchases
|
||||
// of the same item don't show as a wall of near-duplicate lines.
|
||||
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
|
||||
(activeReg.registrationOptions || []).forEach((opt: any) => {
|
||||
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
|
||||
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
|
||||
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
|
||||
? opt.tranches
|
||||
: [{
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
|
||||
return (
|
||||
<li key={opt.id}>
|
||||
{opt.eventOption?.name}{variantLabel} x {opt.quantity} — {formatRand(unitPrice * (opt.quantity || 0))}
|
||||
{unitPrice < (opt.eventOption?.price || 0) && (
|
||||
: (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">
|
||||
{editError && <p className="text-xs text-red-600">{editError}</p>}
|
||||
{editLoading ? (
|
||||
@@ -1091,9 +1145,12 @@ export default function UserDashboardPage() {
|
||||
{activeBill && activeBill.payments.length > 0 && (
|
||||
<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) => (
|
||||
<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>
|
||||
</div>
|
||||
@@ -1105,24 +1162,21 @@ export default function UserDashboardPage() {
|
||||
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
||||
>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
|
||||
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)}
|
||||
>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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
@@ -1170,7 +1249,7 @@ export default function UserDashboardPage() {
|
||||
</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>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
|
||||
const [payments, setPayments] = useState<PaymentItem[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
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
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
|
||||
{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="flex flex-wrap items-end gap-3 mb-4">
|
||||
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -32,6 +34,11 @@ type Event = {
|
||||
eventOptions?: EventOption[];
|
||||
attachments?: EventAttachment[];
|
||||
requiresAuth?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
@@ -51,6 +58,16 @@ function lowStockThreshold(stockLimit: number): number {
|
||||
}
|
||||
|
||||
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 end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
@@ -127,6 +144,13 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
|
||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||
|
||||
{event.location && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
|
||||
<LocationMap address={event.location} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.attachments && event.attachments.length > 0 && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -44,10 +44,11 @@ async function getServerSettings(): Promise<SiteSettings> {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getServerSettings();
|
||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
||||
const displayName = settings.org_name || appName;
|
||||
|
||||
return {
|
||||
title: appName,
|
||||
description: `Manage and register for events with ${appName}`,
|
||||
title: displayName,
|
||||
description: `Manage and register for events with ${displayName}`,
|
||||
icons: {
|
||||
icon: faviconUrl || "/favicon.ico",
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
|
||||
type Event = {
|
||||
@@ -26,8 +27,22 @@ const FEATURES = [
|
||||
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
|
||||
];
|
||||
|
||||
async function getOrgName(): Promise<string> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return appName;
|
||||
const settings = await res.json();
|
||||
return settings?.org_name || appName;
|
||||
} catch {
|
||||
return appName;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const [events, displayName] = await Promise.all([
|
||||
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||
getOrgName(),
|
||||
]);
|
||||
const now = Date.now();
|
||||
const upcoming = (events || []).filter(e => {
|
||||
const t = new Date(e.startDate).getTime();
|
||||
@@ -52,7 +67,7 @@ export default async function HomePage() {
|
||||
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
@@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
|
||||
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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
@@ -56,6 +56,9 @@ function RegistrationSuccessContent() {
|
||||
|
||||
const totalDue = React.useMemo(() => {
|
||||
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 {
|
||||
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
|
||||
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 { Calendar } from "lucide-react";
|
||||
import { Calendar, MapPin } from "lucide-react";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -12,6 +13,11 @@ import { Calendar } from "lucide-react";
|
||||
price: number;
|
||||
picture?: string;
|
||||
isSoldOut?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
};
|
||||
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
@@ -35,6 +41,12 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||
{dateRange}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
|
||||
<MapPin className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -47,6 +59,15 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
View details
|
||||
</a>
|
||||
{(() => {
|
||||
if (event.requiresRegistration === false) {
|
||||
return (
|
||||
<ContactButton
|
||||
contactName={event.contactName}
|
||||
contactPhone={event.contactPhone}
|
||||
contactEmail={event.contactEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MapPin, ExternalLink } from "lucide-react";
|
||||
import { mapsSearchUrl, mapsEmbedUrl } from "@/lib/maps";
|
||||
|
||||
export function LocationMap({ address, className }: { address?: string | null; className?: string }) {
|
||||
if (!address) return null;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-gray-700 flex items-start gap-1.5">
|
||||
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
|
||||
<span>{address}</span>
|
||||
</p>
|
||||
<a
|
||||
href={mapsSearchUrl(address)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-brand-600 hover:underline flex items-center gap-1 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
Directions <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg overflow-hidden border">
|
||||
<iframe
|
||||
title={`Map showing ${address}`}
|
||||
src={mapsEmbedUrl(address)}
|
||||
className="w-full h-48 border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -434,10 +434,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
Object.keys(registrationsByEvent).forEach(evId => {
|
||||
(registrationsByEvent[evId] || []).forEach((r: any) => {
|
||||
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;
|
||||
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);
|
||||
if (paid >= dueAtLast) outstanding = 0;
|
||||
}
|
||||
@@ -722,10 +726,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const ro = (r.registrationOptions || [])
|
||||
.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.orderTotal += price * qty;
|
||||
baseRow.__prices[opt.name] = qty > 0 ? lineTotal / qty : 0;
|
||||
baseRow.__revenue = baseRow.__revenue || {};
|
||||
baseRow.__revenue[opt.name] = lineTotal;
|
||||
baseRow.orderTotal += lineTotal;
|
||||
});
|
||||
|
||||
rows.push(baseRow);
|
||||
@@ -768,14 +780,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const qty = row[opt.name] || 0;
|
||||
totals[opt.name] += qty;
|
||||
|
||||
// revenue per option
|
||||
const price =
|
||||
Number(
|
||||
masterRows
|
||||
.find(r => r === row)?.__prices?.[opt.name] ?? 0
|
||||
);
|
||||
|
||||
totals[`${opt.name}_revenue`] += qty * price;
|
||||
// revenue per option — use the tranche-aware per-row total computed above rather
|
||||
// than re-deriving qty*price from a blended average price.
|
||||
totals[`${opt.name}_revenue`] += Number(row.__revenue?.[opt.name] ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { HelpContent } from "./types";
|
||||
|
||||
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
|
||||
Tap a registration to change quantities or options, pay what's outstanding, or cancel it entirely.
|
||||
</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's still owing (or "Paid in full") — to your email or WhatsApp, whichever you pick.
|
||||
</GuideItem>
|
||||
<GuideItem icon={History} title="Show past events" tone="gray">
|
||||
Registrations page defaults to upcoming events only — tick "Show past events" at the top to bring back ones that have already happened.
|
||||
</GuideItem>
|
||||
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={Receipt} title="Payment history" tone="green">
|
||||
The Payment history page lists every payment you've made across all your registrations, past and present.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Receipt} title="Receipts" tone="amber">
|
||||
Every payment — in a registration'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">
|
||||
A donation isn'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>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Opens Google Maps with the address pre-filled — no API key required. */
|
||||
export function mapsSearchUrl(address: string): string {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
||||
}
|
||||
|
||||
/** Embeddable Google Maps iframe src for the given address — no API key required. */
|
||||
export function mapsEmbedUrl(address: string): string {
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.7.0",
|
||||
"version": "1.9.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user