Season passes (1.47.3)

A pass sells a ticket at each of several events in a series for one
price. Supervisors set passes up on the Event series page (events and
ticket per event; fixed once sold). Buying on the series page registers
the buyer at every event at once with one checkout (free passes give
tickets straight away; Pay now if online payment isn't possible). The
price is split over the events in proportion to their normal prices:
each registration line is priced at its share and the webhook records
one Payment per event, so cashups stay per event. Pass registrations
aren't repriced, take no coupons and can't be edited. Migration
20261028150000_season_pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 11:19:58 +02:00
co-authored by Claude Opus 5.5
parent 96d1e67b85
commit a7c8148588
34 changed files with 1396 additions and 26 deletions
+24
View File
@@ -7,6 +7,30 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.47.3] - 2026-10-28
### Added
- **Season passes** — one price for a ticket at each of several events in a series. On the Event
series page supervisors make a pass (name, price, optional sale end) and tick which events and
which ticket (and variant) at each it includes; the form shows what they'd cost separately. Youth
(parental consent) events, staff-only tickets and per-session tickets can't be on a pass. Events
and price are fixed once someone has bought it (switch it off instead); campus-scoped supervisors
can only use their campuses' events.
- **Buying** — on the series page signed-in people choose how many and buy: they're registered at
every event on the pass at once and pay once (a free pass gives the tickets straight away). If
online payment isn't possible the places stay booked and "Your passes" offers **Pay now**. A pass
stops selling when its first event starts.
- **Money per event** — the price is split over the events in proportion to their normal ticket
prices (whole cents). Each registration's line is priced at its event's share, and the payment is
recorded as one payment per event (all linked to the purchase), so each event's cashup and reports
count only its part. Pass registrations aren't repriced by early-bird changes, take no coupon
codes, and their tickets can't be edited.
- `/api/season-passes` (list, create, change, delete; `:id/buy`; `purchases/:id/checkout`; `mine`),
`passes` on `GET /api/series/by-slug/:slug`. Making, changing and buying passes is blocked in
licence read-only mode. Purchases are in "Download my data". Audit action `season_pass_changed`
(migration `20261028150000_season_pass`).
## [1.47.2] - 2026-10-28
### Added
+8
View File
@@ -559,6 +559,14 @@ keeps its own registrations; for one event on several dates see multi-day events
- **Year on year**: Reports → Series: year on year (or **Compare** on the Event series page)
compares the series' events — registrations, tickets, income, checked in (average per session on
multi-session events), attendance rate — each against the one before.
- **Season passes**: on the Event series page, a pass gives a ticket (and variant) at each event
ticked, at one price (optional sale end; stops when its first event starts; no youth events,
staff-only or per-session tickets). Buyers choose how many on the series page and are registered at
every event at once with one payment (or tickets at once when free; "Pay now" later if online
payment isn't possible). The price is split over the events in proportion to their normal prices;
each event gets its own registration line and payment for its share, so cashups stay per event.
Pass registrations aren't repriced, take no codes and can't be edited. Events and price lock once
sold.
## Multi-campus
+8
View File
@@ -12,6 +12,14 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.47.3
- **Season passes.** Sell a ticket for several events in a series at one price — say all of next
year's breakfasts. People buy it on the series page, pay once, and get a ticket for each event.
- The money is shared out over the events in line with their normal ticket prices, so each event's
cashup shows its part.
- Set passes up on the Event series page. A pass stops selling when its first event starts.
## 1.47.2
- **Compare a series year on year.** A new report puts the events in a series side by side —
+18
View File
@@ -949,6 +949,24 @@ Events take `seriesId` (null = none) on create and update; `GET /api/events?seri
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
followers once per event (`Event.seriesAnnouncedAt`) when it is public and hasn't started.
## Season passes
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/season-passes?seriesId=` | supervisor+ | Passes with events, normal prices, `share` per event, `sold`, `unpaid` |
| POST | `/api/season-passes` | supervisor+ | `{ seriesId, name, description?, price, saleEndsAt?, isActive?, items: [{ eventId, eventOptionId, variantId? }] }` |
| PUT | `/api/season-passes/:id` | supervisor+ | Change it; events and price only while unsold (409) |
| DELETE | `/api/season-passes/:id` | supervisor+ | Only while unsold |
| POST | `/api/season-passes/:id/buy` | user | `{ quantity }` → `{ purchaseId, total, paid, redirectUrl, message }`; 409 when not on sale or too few places |
| POST | `/api/season-passes/purchases/:id/checkout` | user (buyer) | A new checkout for what is still owed |
| GET | `/api/season-passes/mine` | user | My purchases with their registrations |
A purchase creates one `Registration` per event (`seasonPassPurchaseId`), its line priced at the
event's share. The checkout's metadata is `{ type: 'season_pass', purchaseId }`; the webhook records
one `Payment` per registration (`seasonPassPurchaseId`; only the first carries `externalId`).
`PUT /api/registrations/:id/options` and `POST /api/registrations/:id/coupon` answer 409 for pass
registrations.
## Volunteer rosters
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.47.2",
"version": "1.47.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.47.2",
"version": "1.47.3",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.47.2",
"version": "1.47.3",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,102 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'season_pass_changed';
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "seasonPassPurchaseId" TEXT;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "seasonPassPurchaseId" TEXT;
-- CreateTable
CREATE TABLE "SeasonPass" (
"id" TEXT NOT NULL,
"seriesId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"saleEndsAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SeasonPass_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeasonPassItem" (
"id" TEXT NOT NULL,
"passId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "SeasonPassItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeasonPassPurchase" (
"id" TEXT NOT NULL,
"passId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"total" DOUBLE PRECISION NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"checkoutId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SeasonPassPurchase_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SeasonPass_seriesId_idx" ON "SeasonPass"("seriesId");
-- CreateIndex
CREATE INDEX "SeasonPassItem_eventId_idx" ON "SeasonPassItem"("eventId");
-- CreateIndex
CREATE INDEX "SeasonPassItem_eventOptionId_idx" ON "SeasonPassItem"("eventOptionId");
-- CreateIndex
CREATE INDEX "SeasonPassItem_variantId_idx" ON "SeasonPassItem"("variantId");
-- CreateIndex
CREATE UNIQUE INDEX "SeasonPassItem_passId_eventId_key" ON "SeasonPassItem"("passId", "eventId");
-- CreateIndex
CREATE INDEX "SeasonPassPurchase_passId_idx" ON "SeasonPassPurchase"("passId");
-- CreateIndex
CREATE INDEX "SeasonPassPurchase_userId_idx" ON "SeasonPassPurchase"("userId");
-- CreateIndex
CREATE INDEX "SeasonPassPurchase_checkoutId_idx" ON "SeasonPassPurchase"("checkoutId");
-- AddForeignKey
ALTER TABLE "SeasonPass" ADD CONSTRAINT "SeasonPass_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassItem" ADD CONSTRAINT "SeasonPassItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_passId_fkey" FOREIGN KEY ("passId") REFERENCES "SeasonPass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeasonPassPurchase" ADD CONSTRAINT "SeasonPassPurchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_seasonPassPurchaseId_fkey" FOREIGN KEY ("seasonPassPurchaseId") REFERENCES "SeasonPassPurchase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+77
View File
@@ -163,6 +163,7 @@ model User {
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -324,6 +325,7 @@ model Event {
// seriesAnnouncedAt: when followers were emailed that this event is out (once, when it goes live).
seriesId String?
series EventSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
seasonPassItems SeasonPassItem[]
seriesAnnouncedAt DateTime?
@@index([createdById])
@@ -345,6 +347,7 @@ model EventSeries {
updatedAt DateTime @updatedAt
events Event[]
followers SeriesFollow[]
passes SeasonPass[]
}
/// Someone who wants to hear when a new event in the series is out (sent as news).
@@ -360,6 +363,69 @@ model SeriesFollow {
@@index([userId])
}
/// A season pass (1.47.3): one price for a ticket at each of several events in a series. Buying
/// it registers the buyer for every event on it in one checkout; the price is split across the
/// events in proportion to their normal ticket prices (for each event's cashup).
model SeasonPass {
id String @id @default(uuid())
seriesId String
name String
description String?
price Float
isActive Boolean @default(true)
// Stops selling then (it always stops when the first event on it starts).
saleEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
items SeasonPassItem[]
purchases SeasonPassPurchase[]
@@index([seriesId])
}
/// One event on a pass: which ticket (and variant) the pass gives there.
model SeasonPassItem {
id String @id @default(uuid())
passId String
eventId String
eventOptionId String
variantId String?
order Int @default(0)
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
@@unique([passId, eventId])
@@index([eventId])
@@index([eventOptionId])
@@index([variantId])
}
/// Someone's purchase of a pass: `quantity` passes, one registration per event.
model SeasonPassPurchase {
id String @id @default(uuid())
passId String
userId String
quantity Int
total Float
// pending | paid | cancelled
status String @default("pending")
// The online checkout started for it (our ProviderCheckout id).
checkoutId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
pass SeasonPass @relation(fields: [passId], references: [id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
registrations Registration[]
payments Payment[]
@@index([passId])
@@index([userId])
@@index([checkoutId])
}
model EventOption {
id String @id @default(uuid())
eventId String
@@ -381,6 +447,7 @@ model EventOption {
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
seasonPassItems SeasonPassItem[]
@@index([eventId])
}
@@ -417,6 +484,7 @@ model OptionVariant {
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
seasonPassItems SeasonPassItem[]
@@index([eventOptionId])
}
@@ -447,6 +515,10 @@ model Registration {
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
bundleApplications RegistrationBundle[]
// Season pass (1.47.3): one registration per event on the pass, its line priced at that
// event's share of the pass price (never repriced, no coupons, options not editable).
seasonPassPurchaseId String?
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
attendees Attendee[]
waitlistEntries WaitlistEntry[]
volunteerSlots VolunteerSlot[]
@@ -537,6 +609,10 @@ model Payment {
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
splitPayments Payment[] @relation("SplitPayments")
providerTransactions ProviderTransaction[]
// Season pass: one provider payment is recorded as one Payment per event on the pass (its
// share), all linked to the purchase. Only the first carries the provider's externalId.
seasonPassPurchaseId String?
seasonPassPurchase SeasonPassPurchase? @relation(fields: [seasonPassPurchaseId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([recordedById])
@@ -740,6 +816,7 @@ enum AdminAuditAction {
series_created
series_updated
series_deleted
season_pass_changed
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -0,0 +1,146 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { scopeFor, canManage } = require('../utils/campusScope');
const passes = require('../utils/seasonPass');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
const audit = (req, pass, action, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id,
metadata: { action, name: pass.name, ...metadata }, ip: getClientIp(req),
});
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
async function assertCampuses(req, campusIds) {
const scope = await scopeFor(req);
if (scope && campusIds.some(c => !canManage(scope, c))) {
const err = new Error('You can only put events from your own campuses on a pass (not events for all campuses).');
err.statusCode = 403;
throw err;
}
}
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// @desc A series' passes with their events, shares, and how many were sold
// @route GET /api/season-passes?seriesId=
// @access Private/Supervisor
const listPasses = async (req, res) => {
try {
const where = req.query.seriesId ? { seriesId: String(req.query.seriesId) } : {};
const rows = await prisma.seasonPass.findMany({ where, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
res.json(rows.map(p => ({
...passes.passView(p),
sold: sold.filter(s => s.passId === p.id && s.status === 'paid').reduce((n, s) => n + (s._sum.quantity || 0), 0),
unpaid: sold.filter(s => s.passId === p.id && s.status === 'pending').reduce((n, s) => n + (s._sum.quantity || 0), 0),
})));
} catch (error) { fail(res, error); }
};
// @route POST /api/season-passes { seriesId, name, description?, price, saleEndsAt?, items: [{ eventId, eventOptionId, variantId? }] }
// @access Private/Supervisor
const createPass = async (req, res) => {
try {
const seriesId = String(req.body?.seriesId || '');
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) return res.status(404).json({ message: 'Series not found' });
const { data, items } = await passes.cleanPassInput(req.body, seriesId);
await assertCampuses(req, items.map(i => i.campusId));
const pass = await prisma.seasonPass.create({
data: { ...data, seriesId, items: { create: items.map(({ campusId, ...i }) => i) } },
});
audit(req, pass, 'created', { events: items.length, price: pass.price });
res.status(201).json(passes.passView(await passes.loadPass(pass.id)));
} catch (error) { fail(res, error); }
};
// @desc Change a pass. Its events can only change while nobody has bought it.
// @route PUT /api/season-passes/:id
// @access Private/Supervisor
const updatePass = async (req, res) => {
try {
const current = await passes.loadPass(req.params.id);
await assertCampuses(req, current.items.map(i => i.event.campusId));
const { data, items } = await passes.cleanPassInput(req.body, current.seriesId, current);
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
return res.status(409).json({ message: 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.' });
}
if (items) await assertCampuses(req, items.map(i => i.campusId));
await prisma.$transaction(async tx => {
await tx.seasonPass.update({ where: { id: current.id }, data });
if (items) {
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
await tx.seasonPassItem.createMany({ data: items.map(({ campusId, ...i }) => ({ ...i, passId: current.id })) });
}
});
audit(req, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
res.json(passes.passView(await passes.loadPass(current.id)));
} catch (error) { fail(res, error); }
};
// @desc Delete a pass nobody has bought (otherwise switch it off)
// @route DELETE /api/season-passes/:id
// @access Private/Supervisor
const deletePass = async (req, res) => {
try {
const current = await passes.loadPass(req.params.id);
await assertCampuses(req, current.items.map(i => i.event.campusId));
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) {
return res.status(409).json({ message: 'People have bought this pass, so it can\'t be deleted — switch it off instead.' });
}
await prisma.seasonPass.delete({ where: { id: current.id } });
audit(req, current, 'deleted');
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc Buy passes: registers the buyer at every event on it, then the checkout (or tickets, if free)
// @route POST /api/season-passes/:id/buy { quantity, provider? }
// @access Private
const buy = async (req, res) => {
try {
const pass = await prisma.seasonPass.findUnique({ where: { id: req.params.id }, select: { series: { select: { slug: true } } } });
if (!pass) return res.status(404).json({ message: 'Season pass not found' });
const back = `${frontendUrl()}/series/${pass.series.slug}`;
const result = await passes.buyPass(req.params.id, req.user.id, {
quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null,
successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
});
res.status(201).json(result);
} catch (error) { fail(res, error); }
};
// @desc Pay what's still owed on a purchase (a new checkout)
// @route POST /api/season-passes/purchases/:id/checkout
// @access Private (the buyer)
const payPurchase = async (req, res) => {
try {
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: req.params.id }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
if (!purchase || purchase.userId !== req.user.id) return res.status(404).json({ message: 'Purchase not found' });
const back = `${frontendUrl()}/series/${purchase.pass.series.slug}`;
res.json(await passes.startPassCheckout(req.params.id, req.user.id, {
provider: req.body?.provider || null, successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
}));
} catch (error) { fail(res, error); }
};
// @desc The signed-in person's pass purchases
// @route GET /api/season-passes/mine
// @access Private
const myPurchases = async (req, res) => {
try {
const rows = await prisma.seasonPassPurchase.findMany({
where: { userId: req.user.id }, orderBy: { createdAt: 'desc' },
select: {
id: true, quantity: true, total: true, status: true, createdAt: true,
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
},
});
res.json(rows);
} catch (error) { fail(res, error); }
};
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
+9 -1
View File
@@ -69,6 +69,9 @@ const deleteSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
if (!current) return res.status(404).json({ message: 'Series not found' });
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
return res.status(409).json({ message: 'People have bought season passes for this series, so it can\'t be deleted.' });
}
await prisma.eventSeries.delete({ where: { id: current.id } });
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
res.json({ ok: true });
@@ -92,7 +95,12 @@ const getSeriesBySlug = async (req, res) => {
});
const { upcoming, past } = splitEvents(events);
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
res.json({ ...series, upcoming, past, following });
// Season passes switched on (1.47.3); `unavailable` says why one can't be bought now.
const { passView, PASS_INCLUDE } = require('../utils/seasonPass');
const passes = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: PASS_INCLUDE }))
.map(p => passView(p))
.map(({ items, ...p }) => ({ ...p, items: items.map(({ campusId, ...i }) => i) }));
res.json({ ...series, upcoming, past, following, passes });
} catch (error) { fail(res, error); }
};
+5
View File
@@ -16,6 +16,11 @@ const BLOCKED = [
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/series\/?$/],
['PUT', /^\/api\/series\/[^/]+\/?$/],
// Season passes: new or changed passes, and buying one (it makes registrations and payments).
['POST', /^\/api\/season-passes\/?$/],
['PUT', /^\/api\/season-passes\/[^/]+\/?$/],
['POST', /^\/api\/season-passes\/[^/]+\/buy\/?$/],
['POST', /^\/api\/season-passes\/purchases\/[^/]+\/checkout\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
+13
View File
@@ -186,6 +186,7 @@ app.use('/api/campuses', require('./routes/campusRoutes'));
app.use('/api/kids', require('./routes/kidsRoutes'));
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
app.use('/api/series', require('./routes/seriesRoutes'));
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -1010,6 +1011,18 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
]},
{ title: 'Season passes', base: '/api/season-passes', endpoints: [
{ method:'GET', path:'/api/season-passes?seriesId=', auth:'supervisor+', desc:'A series\x27 passes: events, normal prices, each event\x27s share, sold and unpaid counts' },
{ method:'POST', path:'/api/season-passes', auth:'supervisor+', desc:'Create a pass. Events must be in the series; not youth (consent) events, staff-only or per-session tickets. Campus-scoped supervisors: only their campuses\x27 events. Audited as season_pass_changed',
request:{ body:{ seriesId:'series-uuid', name:'2027 breakfasts', price:400, saleEndsAt:null, items:[{ eventId:'ev-uuid', eventOptionId:'opt-uuid', variantId:null }] } } },
{ method:'PUT', path:'/api/season-passes/:id', auth:'supervisor+', desc:'Change a pass (events and price only while nobody has bought it; 409 otherwise)' },
{ method:'DELETE', path:'/api/season-passes/:id', auth:'supervisor+', desc:'Delete a pass nobody has bought' },
{ method:'POST', path:'/api/season-passes/:id/buy', auth:'user', desc:'Buy passes: a registration at every event (lines priced at each event\x27s share), then one checkout for the total (redirectUrl), or tickets straight away when free. 409 when not on sale or an event has too few places',
request:{ body:{ quantity:2 } }, responses:[{ status:201, desc:'Bought', body:{ purchaseId:'uuid', total:800, paid:false, redirectUrl:'https://…', provider:'yoco', message:null } }] },
{ method:'POST', path:'/api/season-passes/purchases/:id/checkout', auth:'user', desc:'Pay what is still owed on my purchase (a new checkout)' },
{ method:'GET', path:'/api/season-passes/mine', auth:'user', desc:'My pass purchases with their registrations' },
]},
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
{ method:'GET', path:'/api/volunteers/me', auth:'user', desc:'My shifts (upcoming and the last week), cover requests I could take, and events looking for volunteers' },
{ method:'GET', path:'/api/volunteers/events/:eventId', auth:'user', desc:'An event\'s serving roles: shift, how many are needed and signed up, whether I\'m in it, open cover requests' },
+6
View File
@@ -19,6 +19,12 @@ async function checkoutPaid(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
if (tx) return true;
// A season pass checkout: paid once its purchase is.
const purchaseId = checkout.metadata?.purchaseId;
if (purchaseId) {
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: String(purchaseId) }, select: { status: true } });
return purchase?.status === 'paid';
}
const registrationId = checkout.metadata?.registrationId;
if (!registrationId) return false;
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
+8 -1
View File
@@ -71,7 +71,9 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
if (!existingRegistration) {
const meta = tx.metadata || {};
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
if (!isDonation) {
// A season pass checkout belongs to its purchase, not to one registration.
const isSeasonPass = meta.type === 'season_pass' && !!meta.purchaseId;
if (!isDonation && !isSeasonPass) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
return res.status(200).json({
success: true,
@@ -141,6 +143,11 @@ async function recordSucceededPayment(providerId, event, body) {
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
}
// Season pass: split over the purchase's registrations, one Payment per event.
if (metadata.type === 'season_pass' && metadata.purchaseId) {
return require('../utils/seasonPass').recordPassPayment(providerId, event, body);
}
let registration = null;
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
+3 -2
View File
@@ -18,6 +18,7 @@ const {
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
const { notOnSeasonPass } = require('../utils/seasonPass');
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
// as to staff check staff against the registration's event, and let the owner through (ownerOk).
@@ -25,10 +26,10 @@ const { scopeEvent, scopeList } = require('../utils/campusScope');
// Registration - auth optional when event doesn't require it
router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateRegistrationOptions);
router.put('/:id/options', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, updateRegistrationOptions);
router.delete('/:id', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), cancelRegistration);
router.post('/:id/send-invoice', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendInvoice);
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), applyCoupon);
router.post('/:id/coupon', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), notOnSeasonPass, applyCoupon);
router.delete('/:id/coupon', protect, staff, scopeEvent({ resolve: 'registration' }), removeCoupon);
router.put('/:id/attendees', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, scopeEvent({ resolve: 'registration', ownerOk: true }), sendAttendeeFormLink);
+18
View File
@@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases } = require('../controllers/seasonPassController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const SPANS = campusIndependent('a pass can span campuses; each event on it is checked against the person\'s campuses in the controller');
router.get('/mine', protect, myPurchases);
router.post('/:id/buy', protect, buy);
router.post('/purchases/:id/checkout', protect, payPurchase);
router.get('/', protect, supervisor, SPANS, listPasses);
router.post('/', protect, supervisor, SPANS, createPass);
router.put('/:id', protect, supervisor, SPANS, updatePass);
router.delete('/:id', protect, supervisor, SPANS, deletePass);
module.exports = router;
+2
View File
@@ -211,6 +211,8 @@ async function refreshPricingForRegistration(registrationId) {
}
});
if (!registration) return { changed: false };
// A season pass line is priced at its event's share of the pass, never at the ticket's price.
if (registration.seasonPassPurchaseId) return { changed: false };
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
+7 -1
View File
@@ -488,7 +488,7 @@ async function exportUserData(userId, db = prisma) {
});
if (!user) return null;
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed] = await Promise.all([
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed, seasonPasses] = await Promise.all([
db.registration.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -515,6 +515,7 @@ async function exportUserData(userId, db = prisma) {
require('./kids').exportFamily(userId, db),
require('./volunteers').exportVolunteering(userId, db),
require('./series').exportFollows(userId, db),
require('./seasonPass').exportPurchases(userId, db),
]);
return {
@@ -559,6 +560,7 @@ async function exportUserData(userId, db = prisma) {
kidsFamily: family,
volunteering,
seriesFollowed,
seasonPasses,
};
}
@@ -619,6 +621,10 @@ async function dataExportPdf(data) {
heading('Serving (volunteer rosters)');
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
}
if (data.seasonPasses?.length) {
heading('Season passes');
for (const s of data.seasonPasses) para(`${fmt(s.at)} — ${s.quantity} × ${s.pass}, R${Number(s.total).toFixed(2)} (${s.status})`);
}
if (data.seriesFollowed?.length) {
heading('Event series you follow');
for (const f of data.seriesFollowed) para(`${f.series} — since ${fmt(f.since)}`);
+361
View File
@@ -0,0 +1,361 @@
/**
* Season passes (1.47.3): one price for a ticket at each of several events in a series.
*
* - A supervisor picks the events on the pass, and which ticket (and variant) at each.
* - Buying registers the buyer at every event on it at once (`quantity` places each) and starts
* one online checkout for the total. A free pass gives the tickets straight away.
* - The price is split across the events in proportion to their normal ticket prices (whole
* cents, `splitCents`), and each registration's line is priced at its event's share, so every
* event's cashup and reports count only its part. Those lines are never repriced, take no
* coupons, and can't be edited.
* - The payment comes back as one provider payment; it's recorded as one Payment per event
* (`recordPassPayment`), all linked to the purchase.
* - A pass stops selling when the first event on it starts, at its sale end, or when switched off.
*/
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
function passError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const toCents = n => Math.round(Number(n || 0) * 100);
/**
* Splits `totalCents` in proportion to `weights` (whole cents that add up exactly; the cents
* left over by rounding down go to the largest remainders). All weights zero → equal parts.
* Pure, for tests.
*/
function splitCents(totalCents, weights) {
if (!weights.length) return [];
const w = weights.map(x => Math.max(0, Number(x) || 0));
const sum = w.reduce((s, x) => s + x, 0);
const shares = sum > 0 ? w.map(x => (totalCents * x) / sum) : w.map(() => totalCents / w.length);
const out = shares.map(Math.floor);
let left = totalCents - out.reduce((s, x) => s + x, 0);
const order = shares.map((s, i) => ({ i, frac: s - Math.floor(s) })).sort((a, b) => b.frac - a.frac || a.i - b.i);
for (let k = 0; left > 0; k = (k + 1) % order.length, left--) out[order[k].i]++;
return out;
}
/** The normal price of one place on an item: the variant's own price, else the option's. */
function normalPrice(item) {
const v = item.variant;
return Number(v && v.price !== null && v.price !== undefined ? v.price : item.eventOption?.price || 0);
}
/**
* Each item's share of the pass price (rand, one pass), in proportion to its normal price.
* Pure, for tests: items need eventOption.price (and variant.price when there is one).
*/
function itemShares(price, items) {
return splitCents(toCents(price), items.map(i => toCents(normalPrice(i)))).map(c => c / 100);
}
/**
* How much of a payment goes to each registration: each gets what it still owes, in order, and
* anything over goes to the last one. `dues` in rand. Pure, for tests.
*/
function allocate(dues, amount) {
let left = toCents(amount);
const out = dues.map(d => {
const take = Math.min(left, Math.max(0, toCents(d)));
left -= take;
return take;
});
if (left > 0 && out.length) out[out.length - 1] += left;
return out.map(c => c / 100);
}
const ITEM_INCLUDE = {
event: {
select: {
id: true, title: true, startDate: true, endDate: true, isActive: true, seriesId: true, campusId: true,
registrationDeadline: true, scheduleType: true, attendanceMode: true, lateJoinEnabled: true, lateJoinUntilSessionId: true,
requiresParentalConsent: true, requiresRegistration: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true,
sessions: { select: { id: true, startsAt: true, endsAt: true, cancelledAt: true } },
},
},
eventOption: { select: { id: true, name: true, price: true, stockLimit: true, eventId: true, sessionMode: true } },
variant: { select: { id: true, name: true, price: true, stockLimit: true, eventOptionId: true } },
};
const PASS_INCLUDE = { items: { orderBy: { order: 'asc' }, include: ITEM_INCLUDE }, series: { select: { id: true, name: true, slug: true } } };
/** Why a pass can't be bought right now, or null. */
function notOnSale(pass, now = new Date()) {
if (!pass.isActive) return 'This pass isn\'t on sale.';
if (!pass.items.length) return 'This pass has no events yet.';
if (pass.saleEndsAt && new Date(pass.saleEndsAt) <= now) return 'This pass is no longer on sale.';
const { wholeCourseClosesAt } = require('./sessions');
for (const i of pass.items) {
if (!i.event.isActive || i.event.cashupStatus === 'closed') return `${i.event.title} isn't taking registrations.`;
if (new Date(i.event.startDate) <= now) return 'This pass stopped selling when its first event started.';
if (wholeCourseClosesAt(i.event, i.event.sessions) <= now) return `Registration for ${i.event.title} has closed.`;
}
return null;
}
/** A pass as shown to buyers and supervisors: its events with normal prices and shares. */
function passView(pass, now = new Date()) {
const shares = itemShares(pass.price, pass.items);
const normalValue = Math.round(pass.items.reduce((s, i) => s + normalPrice(i), 0) * 100) / 100;
return {
id: pass.id, seriesId: pass.seriesId, name: pass.name, description: pass.description, price: pass.price,
isActive: pass.isActive, saleEndsAt: pass.saleEndsAt, series: pass.series,
normalValue, saving: Math.max(0, Math.round((normalValue - pass.price) * 100) / 100),
unavailable: notOnSale(pass, now),
items: pass.items.map((i, k) => ({
id: i.id, eventId: i.eventId, eventOptionId: i.eventOptionId, variantId: i.variantId,
eventTitle: i.event.title, startDate: i.event.startDate, endDate: i.event.endDate, campusId: i.event.campusId,
ticket: i.variant ? `${i.eventOption.name} — ${i.variant.name}` : i.eventOption.name,
normalPrice: normalPrice(i), share: shares[k],
})),
};
}
// ─── Setting passes up ───────────────────────────────────────────────────────
/**
* Checks a create/update body against the series' events: each item is an event in the series
* with one of its tickets (not the staff-only kids/volunteer ones, not a per-session ticket) and
* no parental consent (a pass can't collect children's details). Returns the data to save and the
* events on it (for the campus check).
*/
async function cleanPassInput(body, seriesId, current = null, db = prisma) {
const data = {};
if (body.name !== undefined || !current) {
const name = String(body.name || '').trim();
if (!name) throw passError('Give the pass a name');
data.name = name.slice(0, 120);
}
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
if (body.price !== undefined || !current) {
const price = Number(body.price);
if (!(price >= 0) || !Number.isFinite(price)) throw passError('Enter the pass price (0 for free)');
data.price = Math.round(price * 100) / 100;
}
if (body.isActive !== undefined) data.isActive = body.isActive !== false;
if (body.saleEndsAt !== undefined) {
const d = body.saleEndsAt ? new Date(body.saleEndsAt) : null;
if (d && isNaN(d.getTime())) throw passError('The sale end date isn\'t valid');
data.saleEndsAt = d;
}
let items = null;
if (body.items !== undefined || !current) {
if (!Array.isArray(body.items) || body.items.length === 0) throw passError('Choose at least one event for the pass');
const seen = new Set();
items = [];
for (const [order, raw] of body.items.entries()) {
const eventId = String(raw?.eventId || '');
if (seen.has(eventId)) throw passError('Each event can only be on a pass once');
seen.add(eventId);
const event = await db.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, seriesId: true, campusId: true, requiresParentalConsent: true, requiresRegistration: true, kidsOptionId: true, volunteerOptionId: true, scheduleType: true, attendanceMode: true, eventOptions: { select: { id: true, sessionMode: true, variants: { select: { id: true } } } } },
});
if (!event || event.seriesId !== seriesId) throw passError('Every event on a pass must be in its series');
if (event.requiresParentalConsent) throw passError(`${event.title} is a youth event (parental consent), so it can't be on a pass`);
if (event.requiresRegistration === false) throw passError(`${event.title} doesn't take registrations`);
const option = event.eventOptions.find(o => o.id === raw.eventOptionId);
if (!option || [event.kidsOptionId, event.volunteerOptionId].includes(option.id)) throw passError(`Choose a ticket for ${event.title}`);
if (event.scheduleType === 'sessions' && event.attendanceMode === 'pick' && option.sessionMode === 'pick') {
throw passError(`${event.title}: choose a ticket for all sessions, not a per-session one`);
}
const variantId = raw.variantId ? String(raw.variantId) : null;
if (variantId && !option.variants.some(v => v.id === variantId)) throw passError(`That variant isn't one of the ticket's at ${event.title}`);
items.push({ eventId, eventOptionId: option.id, variantId, order, campusId: event.campusId });
}
}
return { data, items };
}
async function loadPass(id, db = prisma) {
const pass = await db.seasonPass.findUnique({ where: { id }, include: PASS_INCLUDE });
if (!pass) throw passError('Season pass not found', 404);
return pass;
}
// ─── Buying ──────────────────────────────────────────────────────────────────
/**
* Buys `quantity` passes for `userId`: a registration at each event (lines priced at the share),
* then one checkout for the total — or, for a free pass, the tickets straight away.
* @returns {Promise<{ purchaseId, total, paid, redirectUrl, provider, message }>}
*/
async function buyPass(passId, userId, { quantity = 1, provider = null, successUrl, cancelUrl, failureUrl } = {}) {
const qty = parseInt(quantity, 10);
if (!(qty >= 1 && qty <= 20)) throw passError('Choose how many passes (1 to 20)');
const pass = await loadPass(passId);
const reason = notOnSale(pass);
if (reason) throw passError(reason, 409);
const shares = itemShares(pass.price, pass.items);
const total = Math.round(pass.price * qty * 100) / 100;
const free = total === 0;
const { remainingFor } = require('./stock');
const purchase = await prisma.$transaction(async tx => {
// One buyer at a time per pass, so the last places can't be sold twice through passes.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'seasonpass:' + pass.id}))`;
for (const i of pass.items) {
const left = await remainingFor(i.eventOption, i.variant, { db: tx });
if (left !== null && left < qty) throw passError(`${i.event.title} has ${left === 0 ? 'no places' : `only ${left} place${left === 1 ? '' : 's'}`} left for ${i.variant ? i.variant.name : i.eventOption.name}`, 409);
}
const created = await tx.seasonPassPurchase.create({ data: { passId: pass.id, userId, quantity: qty, total, status: free ? 'paid' : 'pending' } });
for (const [k, i] of pass.items.entries()) {
const registration = await tx.registration.create({
data: { userId, eventId: i.eventId, status: free ? 'paid' : 'pending', seasonPassPurchaseId: created.id },
});
const line = await tx.registrationOption.create({
data: { registrationId: registration.id, eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: qty, priceSnapshot: shares[k] },
});
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: qty, priceSnapshot: shares[k] } });
}
return created;
});
const registrations = await prisma.registration.findMany({ where: { seasonPassPurchaseId: purchase.id }, select: { id: true } });
if (free) {
const { generateTicketsForRegistration } = require('./ticketUtils');
for (const r of registrations) await generateTicketsForRegistration(r.id).catch(e => console.error('[season-pass] tickets:', e?.message || e));
emailTicketsFor(userId, registrations.map(r => r.id));
return { purchaseId: purchase.id, total, paid: true, redirectUrl: null, provider: null, message: 'Your tickets are ready.' };
}
return { purchaseId: purchase.id, total, paid: false, ...(await startPassCheckout(purchase.id, userId, { provider, successUrl, cancelUrl, failureUrl })) };
}
/**
* Starts (or restarts) the online checkout for what's still owed on a purchase. When online
* payment isn't possible (none set up, or under the minimum) the places stay booked unpaid and
* the buyer is told to pay the church.
*/
async function startPassCheckout(purchaseId, userId, { provider = null, successUrl, cancelUrl, failureUrl } = {}) {
const purchase = await prisma.seasonPassPurchase.findUnique({
where: { id: purchaseId },
include: {
pass: { select: { name: true } },
user: { select: { name: true, email: true } },
registrations: { include: { event: { select: { title: true } }, registrationOptions: { include: { tranches: true } }, payments: true } },
},
});
if (!purchase || purchase.userId !== userId) throw passError('Purchase not found', 404);
const { computeRegistrationTotalDue } = require('./pricing');
const owed = purchase.registrations
.filter(r => r.status !== 'cancelled')
.map(r => ({ r, due: Math.max(0, computeRegistrationTotalDue(r, new Date()) - r.payments.reduce((s, p) => s + p.amount, 0)) }))
.filter(x => x.due > 0.004);
const amountCents = owed.reduce((s, x) => s + toCents(x.due), 0);
if (amountCents <= 0) throw passError('This pass is already paid');
const payments = require('../payments');
try {
const checkout = await payments.createCheckout(provider, {
amountCents,
metadata: { type: 'season_pass', purchaseId: purchase.id, userId, passName: purchase.pass.name },
lineItems: owed.map(x => ({ name: `${purchase.pass.name}: ${x.r.event.title}`, quantity: 1, unitCents: toCents(x.due) })),
successUrl, cancelUrl, failureUrl,
payer: { name: purchase.user?.name || '', email: purchase.user?.email || '' },
description: purchase.pass.name,
idempotencyKey: `pass_${purchase.id}_${amountCents}_${Date.now()}`,
});
await prisma.seasonPassPurchase.update({ where: { id: purchase.id }, data: { checkoutId: checkout.checkoutId } });
return { redirectUrl: checkout.redirectUrl, provider: checkout.provider, message: null };
} catch (e) {
// Online payment not possible (switched off, not set up, or under the minimum): keep the places.
if (e.statusCode && (e.statusCode < 500 || e.statusCode === 503)) {
return { redirectUrl: null, provider: null, message: `Your places are booked. ${e.message}` };
}
throw e;
}
}
function emailTicketsFor(userId, registrationIds) {
(async () => {
const { emailTickets } = require('../controllers/ticketController');
for (const registrationId of registrationIds) {
const mockReq = { user: { id: userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('[season-pass] emailing tickets:', e?.message || e); }
}
})();
}
/**
* A pass checkout was paid (webhook or status check): one Payment per registration on the
* purchase for what it owed, all linked to the purchase, then each registration's status and
* tickets. Idempotent on the provider's payment id (the first Payment carries it).
*/
async function recordPassPayment(providerId, event, body) {
const { externalId, amountCents, status, metadata = {}, methodType } = event.transaction;
const webhook = require('../payments/webhook');
const existing = await prisma.payment.findFirst({ where: { externalId } });
if (existing) {
await webhook.saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existing.id }).catch(() => {});
return { status: 'already_processed', paymentId: existing.id, externalId };
}
const purchase = await prisma.seasonPassPurchase.findUnique({
where: { id: String(metadata.purchaseId || '') },
include: { registrations: { orderBy: { createdAt: 'asc' }, include: { registrationOptions: { include: { tranches: true } }, payments: true } } },
});
if (!purchase) throw passError(`Season pass purchase ${metadata.purchaseId} not found`, 404);
const { computeRegistrationTotalDue } = require('./pricing');
const regs = purchase.registrations.filter(r => r.status !== 'cancelled');
const dues = regs.map(r => Math.max(0, computeRegistrationTotalDue(r, new Date()) - r.payments.reduce((s, p) => s + p.amount, 0)));
const parts = allocate(dues, (amountCents || 0) / 100);
const method = methodType || 'card';
const paymentStatus = status === 'succeeded' ? 'completed' : status;
const created = [];
for (const [k, r] of regs.entries()) {
if (!(parts[k] > 0)) continue;
created.push(await prisma.payment.create({
data: {
id: uuidv4(), amount: parts[k], method, provider: providerId, status: paymentStatus,
externalId: created.length === 0 ? externalId : null,
registrationId: r.id, eventId: r.eventId, userId: purchase.userId, recordedById: purchase.userId,
seasonPassPurchaseId: purchase.id,
},
}));
}
const paidIds = [];
for (const r of regs) {
const updated = await webhook.updateRegistrationStatus(r.id);
if (updated.status === 'paid') paidIds.push(r.id);
}
if (paidIds.length === regs.length) await prisma.seasonPassPurchase.update({ where: { id: purchase.id }, data: { status: 'paid' } });
if (created[0]) await webhook.saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: created[0].id }).catch(() => {});
(async () => {
const { sendPaymentEmails } = require('./notifications');
for (const p of created) {
try { await sendPaymentEmails(p.id); } catch (e) { console.error('[season-pass] payment email:', e?.message || e); }
}
})();
emailTicketsFor(purchase.userId, paidIds);
return { status: 'success', purchaseId: purchase.id, payments: created.map(p => ({ id: p.id, amount: p.amount, eventId: p.eventId })) };
}
/** Route guard: a registration from a season pass keeps its lines as bought (no edits, no coupons). */
function notOnSeasonPass(req, res, next) {
prisma.registration.findUnique({ where: { id: req.params.id }, select: { seasonPassPurchaseId: true } })
.then(r => (r?.seasonPassPurchaseId
? res.status(409).json({ message: 'This registration is part of a season pass, so its tickets can\'t be changed and codes can\'t be added. Ask the organiser if something needs to change.' })
: next()))
.catch(next);
}
// ─── Privacy ─────────────────────────────────────────────────────────────────
async function exportPurchases(userId, db = prisma) {
const rows = await db.seasonPassPurchase.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { quantity: true, total: true, status: true, createdAt: true, pass: { select: { name: true } } } });
return rows.map(r => ({ pass: r.pass.name, quantity: r.quantity, total: r.total, status: r.status, at: r.createdAt }));
}
module.exports = {
splitCents, itemShares, allocate, normalPrice, notOnSale, passView, cleanPassInput, loadPass,
buyPass, startPassCheckout, recordPassPayment, notOnSeasonPass, exportPurchases, passError, PASS_INCLUDE,
};
+120
View File
@@ -0,0 +1,120 @@
const mockDb = {
payment: { findFirst: jest.fn(), create: jest.fn() },
seasonPassPurchase: { findUnique: jest.fn(), update: jest.fn() },
registration: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockStatus = jest.fn();
const mockSaveTx = jest.fn();
jest.mock('../src/payments/webhook', () => ({
updateRegistrationStatus: (...a) => mockStatus(...a),
saveProviderTransaction: (...a) => mockSaveTx(...a),
}));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn().mockResolvedValue() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn().mockResolvedValue() }));
const pass = require('../src/utils/seasonPass');
const { isBlocked } = require('../src/hub/readOnly');
describe('season passes', () => {
describe('splitting the price', () => {
test('in proportion to normal prices, in whole cents that add up', () => {
// R400 pass: a R300 camp and two R50 breakfasts.
expect(pass.splitCents(40000, [30000, 5000, 5000])).toEqual([30000, 5000, 5000]);
// R100 over three equal events: the odd cent goes to the first.
expect(pass.splitCents(10000, [5000, 5000, 5000])).toEqual([3334, 3333, 3333]);
const parts = pass.splitCents(9999, [1234, 5678, 91]);
expect(parts.reduce((s, x) => s + x, 0)).toBe(9999);
});
test('free tickets everywhere: split equally', () => {
expect(pass.splitCents(9000, [0, 0, 0])).toEqual([3000, 3000, 3000]);
});
test('a variant\'s own price counts, else the ticket\'s', () => {
const items = [
{ eventOption: { price: 300 }, variant: { price: 200 } },
{ eventOption: { price: 50 }, variant: { price: null } },
{ eventOption: { price: 50 }, variant: null },
];
expect(pass.itemShares(240, items)).toEqual([160, 40, 40]);
});
});
test('a payment pays what each registration owes, in order; anything over goes to the last', () => {
expect(pass.allocate([160, 40, 40], 240)).toEqual([160, 40, 40]);
expect(pass.allocate([160, 40, 40], 100)).toEqual([100, 0, 0]);
expect(pass.allocate([0, 40, 40], 90)).toEqual([0, 40, 50]);
});
describe('when a pass is on sale', () => {
const now = new Date('2027-01-10T08:00:00Z');
const item = (start, extra = {}) => ({ event: { title: 'Breakfast', isActive: true, cashupStatus: 'open', startDate: new Date(start), endDate: new Date(start), scheduleType: 'single', sessions: [], ...extra } });
const base = { isActive: true, saleEndsAt: null, items: [item('2027-02-01T07:00:00Z'), item('2027-03-01T07:00:00Z')] };
test('on sale before the first event starts', () => {
expect(pass.notOnSale(base, now)).toBeNull();
});
test('stops when the first event starts, at the sale end, or switched off', () => {
expect(pass.notOnSale({ ...base, items: [item('2027-01-09T07:00:00Z'), ...base.items] }, now)).toMatch(/first event started/);
expect(pass.notOnSale({ ...base, saleEndsAt: new Date('2027-01-01') }, now)).toMatch(/no longer on sale/);
expect(pass.notOnSale({ ...base, isActive: false }, now)).toMatch(/isn't on sale/);
expect(pass.notOnSale({ ...base, items: [item('2027-02-01T07:00:00Z', { registrationDeadline: new Date('2027-01-05') })] }, now)).toMatch(/has closed/);
});
});
describe('recording the payment', () => {
const regs = [
{ id: 'r1', eventId: 'camp', status: 'pending', createdAt: 1, registrationOptions: [{ quantity: 2, priceSnapshot: 160, tranches: [{ quantity: 2, priceSnapshot: 160 }] }], payments: [] },
{ id: 'r2', eventId: 'b1', status: 'pending', createdAt: 2, registrationOptions: [{ quantity: 2, priceSnapshot: 40, tranches: [{ quantity: 2, priceSnapshot: 40 }] }], payments: [] },
];
const event = { transaction: { externalId: 'ext-1', amountCents: 40000, status: 'succeeded', methodType: 'card', metadata: { type: 'season_pass', purchaseId: 'pp1' } } };
beforeEach(() => {
jest.clearAllMocks();
mockDb.payment.findFirst.mockResolvedValue(null);
mockDb.seasonPassPurchase.findUnique.mockResolvedValue({ id: 'pp1', userId: 'u1', registrations: regs });
mockDb.payment.create.mockImplementation(({ data }) => Promise.resolve(data));
mockStatus.mockResolvedValue({ status: 'paid' });
mockSaveTx.mockResolvedValue();
});
test('one payment per event for its share; only the first carries the provider id', async () => {
const out = await pass.recordPassPayment('yoco', event, {});
const made = mockDb.payment.create.mock.calls.map(c => c[0].data);
expect(made.map(p => [p.registrationId, p.eventId, p.amount, p.externalId, p.seasonPassPurchaseId])).toEqual([
['r1', 'camp', 320, 'ext-1', 'pp1'],
['r2', 'b1', 80, null, 'pp1'],
]);
expect(made.every(p => p.isDonation === undefined && p.originalPaymentId === undefined)).toBe(true);
expect(mockStatus).toHaveBeenCalledTimes(2);
expect(mockDb.seasonPassPurchase.update).toHaveBeenCalledWith({ where: { id: 'pp1' }, data: { status: 'paid' } });
expect(mockSaveTx.mock.calls[0][3]).toEqual({ reconciled: true, paymentId: made[0].id });
expect(out.status).toBe('success');
});
test('the same provider payment twice is recorded once', async () => {
mockDb.payment.findFirst.mockResolvedValue({ id: 'already' });
const out = await pass.recordPassPayment('yoco', event, {});
expect(out).toMatchObject({ status: 'already_processed', paymentId: 'already' });
expect(mockDb.payment.create).not.toHaveBeenCalled();
});
});
test('a registration from a pass can\'t be edited or take a code', async () => {
mockDb.registration.findUnique.mockResolvedValue({ seasonPassPurchaseId: 'pp1' });
const res = { status: jest.fn(() => res), json: jest.fn() };
const next = jest.fn();
pass.notOnSeasonPass({ params: { id: 'r1' } }, res, next);
await new Promise(r => setImmediate(r));
expect(res.status).toHaveBeenCalledWith(409);
expect(next).not.toHaveBeenCalled();
});
test('making, changing and buying passes is blocked in licence read-only mode', () => {
expect(isBlocked('POST', '/api/season-passes')).toBe(true);
expect(isBlocked('PUT', '/api/season-passes/p1')).toBe(true);
expect(isBlocked('POST', '/api/season-passes/p1/buy')).toBe(true);
expect(isBlocked('POST', '/api/season-passes/purchases/pp1/checkout')).toBe(true);
});
});
+20 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.47.2** (and Hub 1.4.0). The plan itself is in
Last updated: 2026-10-28, after **1.47.3** (and Hub 1.4.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.47.3 | **Season passes** (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration `20261028150000_season_pass` |
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
@@ -50,8 +51,7 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.47.3 season pass** (§6: a bundle across a series' events, extending event bundles) — then
**1.48.0 — outbound webhooks** (§8).
1. **1.48.0 — outbound webhooks** (§8).
## Working on this repo
@@ -314,6 +314,23 @@ a test server (both done 2026-10-28).
`FollowSeriesButton.tsx` (asks the API itself: the page is cached for everyone), the Series box in
the event editor. Series pages aren't in the sitemap yet (no public list endpoint).
## Where season passes live (1.47.3)
- `backend/src/utils/seasonPass.js` — `splitCents` / `itemShares` (the price over the events by
normal price, whole cents), `notOnSale`, `passView`, `cleanPassInput`, `buyPass` (advisory lock per
pass, stock checked per item, a registration per event with a tranche at the share),
`startPassCheckout` (checkout metadata `{ type: 'season_pass', purchaseId }`), `recordPassPayment`
(called from `payments/webhook.js` `recordSucceededPayment`; `allocate` pays each registration what
it owes; first Payment carries `externalId`), `notOnSeasonPass` (route guard on options edit and
coupons), `exportPurchases`. Tests: `tests/seasonPass.test.js`; e2e in `series.spec.ts`.
- `refreshPricingForRegistration` skips pass registrations; `payments/status.js` treats a pass
checkout as paid once its purchase is.
- Frontend: `components/series/SeasonPassesPanel.tsx` (Event series page),
`components/series/SeasonPassOffers.tsx` (series page: buy, "Your passes", Pay now).
- Not built yet: buying a pass at the door or by staff, cancelling a whole purchase in one go
(staff cancel and refund each event's registration), one combined payment email (each event's
payment sends its own).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+7 -5
View File
@@ -34,8 +34,8 @@ Wallet passes are removed from scope (2026-10-28), so everything after volunteer
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) and 1.47.2 **year-on-year
report** shipped. **Next: 1.47.3 Season pass.**
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. **Next: 1.48.0 Outbound webhooks.**
**Repositories:**
@@ -477,7 +477,9 @@ year-on-year reports, **1.47.3** season pass.
series goes live.
- **Year-on-year reports (1.47.2 ✅):** registrations, income and attendance compared
across events in a series.
- **Season pass (1.47.3):** a bundle across events in a series (extends event bundles).
- **Season pass (1.47.3 ✅):** a bundle across events in a series. One checkout registers the buyer at
every event on the pass; the supervisor picks the events (and ticket at each); the price is split
in proportion to the events' normal prices for each event's cashup (2026-10-28).
---
@@ -962,7 +964,7 @@ income justify the hardware:
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ 1.47.1 ✅ 1.47.2 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
@@ -1017,7 +1019,7 @@ feature.
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
## Still open
+40 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, loginAs, api } from "./fixtures";
import { test, expect, loginAs, api, seed, API } from "./fixtures";
// Event series (1.47.1): a supervisor makes a series, events join it, the public series page
// lists them, an event page links to the next one, a user follows it, and the series job tells
@@ -63,6 +63,45 @@ test.describe.serial("event series", () => {
expect(again.output.announced).toBe(0);
});
test("season pass: a supervisor sets one up, a user buys two, each event gets its share", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/series");
const row = page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" });
await row.getByRole("button", { name: "+ New pass" }).click();
await row.getByLabel("Pass name").fill("E2E Breakfast pass");
await row.getByLabel("Price (R)").fill("30");
// The two upcoming breakfasts are ticked; both tickets are free normally, so the split is equal.
await row.getByRole("button", { name: "Create pass" }).click();
await expect(row.getByTestId("season-pass")).toContainText("E2E Breakfast pass");
await expect(row.getByTestId("season-pass")).toContainText("share R 15.00");
// E2E has no online payment provider, so the places are booked and wait for payment.
await loginAs(page, "user");
await page.goto(`/series/${series.slug}`);
const offer = page.getByTestId("season-pass-offer").filter({ hasText: "E2E Breakfast pass" });
await offer.getByLabel("How many of E2E Breakfast pass").selectOption("2");
await offer.getByRole("button", { name: "Buy · R 60.00" }).click();
await expect(page.getByTestId("season-pass-offers").getByRole("status")).toContainText("Your places are booked");
await expect(page.getByTestId("my-passes")).toContainText("2 × E2E Breakfast pass");
await expect(page.getByTestId("my-passes").getByRole("button", { name: "Pay now" })).toBeVisible();
type Mine = { id: string; total: number; registrations: { id: string; event: { title: string } }[] }[];
const [purchase] = await api<Mine>(page, "user", "GET", "/api/season-passes/mine");
expect(purchase.total).toBe(60);
expect(purchase.registrations.map((r) => r.event.title).sort()).toEqual(["E2E Breakfast (in two months)", "E2E Breakfast (next month)"]);
// Each event's registration owes its share: 2 × R15.
type Reg = { id: string; totalDueComputed?: number; registrationOptions: { quantity: number; priceSnapshot: number }[] };
const regs = await api<Reg[]>(page, "supervisor", "GET", `/api/registrations/event/${next.id}`);
const fromPass = regs.find((r) => r.id === purchase.registrations.find((x) => x.event.title === "E2E Breakfast (next month)")!.id)!;
expect(fromPass.registrationOptions[0]).toMatchObject({ quantity: 2, priceSnapshot: 15 });
// A pass registration keeps its tickets as bought: no codes.
const res = await page.request.post(`${API}/api/registrations/${fromPass.id}/coupon`, {
headers: { Authorization: `Bearer ${seed().users.user.token}` }, data: { code: "ANY" },
});
expect(res.status()).toBe(409);
});
test("year on year: the series report compares its events, oldest first", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/series");
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.47.2",
"version": "1.47.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.47.2",
"version": "1.47.3",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.47.2",
"version": "1.47.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -120,6 +120,7 @@ const ACTION_LABELS: Record<string, string> = {
series_created: "Event series created",
series_updated: "Event series changed",
series_deleted: "Event series deleted",
season_pass_changed: "Season pass created, changed or deleted",
};
interface AuditLogEntry {
@@ -9,6 +9,7 @@ import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { CampusBadge } from "@/components/shared/CampusFilter";
import { SeasonPassesPanel } from "@/components/series/SeasonPassesPanel";
import type { EventSeries } from "@/types";
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
@@ -184,6 +185,7 @@ export default function SeriesPage() {
<p className="text-[11px] text-gray-400 mt-1">{s.eventCount - s.events.length} more at other campuses.</p>
)}
</div>
{s.events.length > 0 && <SeasonPassesPanel seriesId={s.id} seriesSlug={s.slug} />}
</>
)}
</li>
+8 -2
View File
@@ -7,16 +7,18 @@ import { EventCard } from "@/components/events/EventCard";
import { ApiImage } from "@/components/shared/ApiImage";
import { CampusBadge } from "@/components/shared/CampusFilter";
import { FollowSeriesButton } from "@/components/events/FollowSeriesButton";
import { SeasonPassOffers } from "@/components/series/SeasonPassOffers";
import { Suspense } from "react";
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
import { formatDateTimeRange } from "@/lib/date";
import { ArrowLeft, CalendarX, Layers } from "lucide-react";
import type { Event as FullEvent } from "@/types";
import type { Event as FullEvent, SeasonPassView } from "@/types";
export const revalidate = 60;
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
type PastEvent = { id: string; title: string; startDate: string; endDate: string; campus?: FullEvent["campus"] };
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[] };
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[]; passes?: SeasonPassView[] };
async function loadSeries(slug: string): Promise<Series | null> {
try {
@@ -69,6 +71,10 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
</div>
</div>
<Suspense fallback={null}>
<SeasonPassOffers slug={series.slug} passes={series.passes || []} />
</Suspense>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Coming up</h2>
{upcoming.length === 0 ? (
<div className="text-center py-12 border rounded-xl bg-gray-50 mb-10">
@@ -0,0 +1,141 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Ticket, CheckCircle2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { formatDateTimeRange } from "@/lib/date";
import type { SeasonPassPurchase, SeasonPassView } from "@/types";
const rand = (n: number) => `R ${n.toFixed(2)}`;
type BuyResult = { purchaseId: string; total: number; paid: boolean; redirectUrl: string | null; message: string | null };
/**
* The season passes on a series page: what each includes and costs, Buy (registers you for every
* event on it, then one payment), and your own passes for this series with "Pay now" when unpaid.
*/
export function SeasonPassOffers({ slug, passes: initial }: { slug: string; passes: SeasonPassView[] }) {
const { user, token, loading } = useAuth();
const params = useSearchParams();
const [qty, setQty] = useState<Record<string, number>>({});
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [mine, setMine] = useState<SeasonPassPurchase[]>([]);
// The page is cached for everyone; ask for the passes as they are now.
const [passes, setPasses] = useState<SeasonPassView[]>(initial);
useEffect(() => {
apiFetch<{ passes?: SeasonPassView[] }>(`/api/series/by-slug/${encodeURIComponent(slug)}`).then(s => setPasses(s.passes || [])).catch(() => {});
}, [slug]);
const loadMine = useCallback(async () => {
if (!token) return;
const list = await apiFetch<SeasonPassPurchase[]>("/api/season-passes/mine", { authToken: token }).catch(() => []);
setMine(list.filter(p => p.pass.series.slug === slug && p.status !== "cancelled"));
}, [token, slug]);
useEffect(() => { loadMine(); }, [loadMine]);
const returned = params.get("pass");
const go = (res: BuyResult | { redirectUrl: string | null; message: string | null }) => {
if (res.redirectUrl) { window.location.href = res.redirectUrl; return; }
setNotice(res.message || "Done.");
loadMine();
};
const buy = async (p: SeasonPassView) => {
setBusy(p.id); setError(null); setNotice(null);
try {
go(await apiFetch<BuyResult>(`/api/season-passes/${p.id}/buy`, { method: "POST", authToken: token || undefined, body: { quantity: qty[p.id] || 1 } }));
} catch (e) {
setError(errorMessage(e) || "Couldn't buy the pass");
} finally {
setBusy(null);
}
};
const pay = async (purchaseId: string) => {
setBusy(purchaseId); setError(null); setNotice(null);
try {
go(await apiFetch<{ redirectUrl: string | null; message: string | null }>(`/api/season-passes/purchases/${purchaseId}/checkout`, { method: "POST", authToken: token || undefined }));
} catch (e) {
setError(errorMessage(e) || "Couldn't start the payment");
} finally {
setBusy(null);
}
};
if (passes.length === 0 && mine.length === 0) return null;
return (
<section className="mb-10" data-testid="season-pass-offers">
<h2 className="text-xl font-semibold text-gray-900 mb-1 flex items-center gap-2"><Ticket className="w-5 h-5 text-brand-600" /> Season passes</h2>
<p className="text-sm text-gray-500 mb-4">One payment, a ticket for every event on the pass.</p>
{returned === "paid" && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm">Thank you! Your tickets are on their way by email once the payment comes through.</div>}
{(returned === "cancelled" || returned === "failed") && <div className="p-3 mb-3 border rounded bg-amber-50 text-amber-800 text-sm">The payment didn&apos;t go through. Your places are kept — use &quot;Pay now&quot; below to try again.</div>}
{notice && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm" role="status">{notice}</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{mine.length > 0 && (
<div className="border rounded-xl bg-white p-4 mb-4" data-testid="my-passes">
<div className="text-sm font-medium text-gray-900 mb-2">Your passes</div>
<ul className="space-y-2">
{mine.map(m => (
<li key={m.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>{m.quantity} × {m.pass.name} · {rand(m.total)} · {m.status === "paid" ? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> paid — tickets in My events</span> : <span className="text-amber-700">not paid yet</span>}</span>
{m.status === "pending" && (
<button type="button" disabled={busy === m.id} onClick={() => pay(m.id)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Pay now</button>
)}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{passes.map(p => (
<div key={p.id} className="border rounded-xl bg-white shadow-sm p-5" data-testid="season-pass-offer">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold text-gray-900">{p.name}</h3>
{p.description && <p className="text-sm text-gray-600 mt-0.5">{p.description}</p>}
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold text-gray-900">{p.price === 0 ? "Free" : rand(p.price)}</div>
{p.saving > 0 && <div className="text-xs text-green-700">save {rand(p.saving)}</div>}
</div>
</div>
<ul className="mt-3 space-y-1 text-sm text-gray-700">
{p.items.map(i => (
<li key={i.id} className="flex justify-between gap-2">
<span className="min-w-0"><span className="font-medium">{i.eventTitle}</span> <span className="text-gray-500 text-xs">· {formatDateTimeRange(i.startDate, i.endDate)} · {i.ticket}</span></span>
</li>
))}
</ul>
<div className="mt-4">
{p.unavailable ? (
<p className="text-sm text-gray-500">{p.unavailable}</p>
) : loading ? null : !user ? (
<a href={`/login?redirect=/series/${slug}`} className="inline-block px-4 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50">Log in to buy</a>
) : (
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 flex items-center gap-1.5">
How many
<select aria-label={`How many of ${p.name}`} className="border rounded px-2 py-1 text-sm" value={qty[p.id] || 1} onChange={e => setQty({ ...qty, [p.id]: Number(e.target.value) })}>
{Array.from({ length: 10 }, (_, k) => k + 1).map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<button type="button" disabled={busy === p.id} onClick={() => buy(p)} className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 font-medium disabled:opacity-50">
{busy === p.id ? "Booking…" : p.price === 0 ? "Get the pass" : `Buy · ${rand(p.price * (qty[p.id] || 1))}`}
</button>
</div>
)}
</div>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,207 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Ticket } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { Event, SeasonPassView } from "@/types";
type ItemDraft = { eventId: string; on: boolean; eventOptionId: string; variantId: string };
type Draft = { id?: string; name: string; description: string; price: string; saleEndsAt: string; isActive: boolean; items: ItemDraft[] };
const rand = (n: number) => `R ${n.toFixed(2)}`;
/** The staff-only kids and volunteer tickets aren't sold on a pass. */
const sellable = (ev: Event) => (ev.eventOptions || []).filter(o => o.id !== ev.kidsOptionId && o.id !== ev.volunteerOptionId && !(ev.attendanceMode === "pick" && o.sessionMode === "pick"));
/**
* A series' season passes on the Event series page: what's on each, sold so far, and a form to
* make or change one (events and price are fixed once someone has bought it).
*/
export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string; seriesSlug: string }) {
const { token } = useAuth();
const [passes, setPasses] = useState<SeasonPassView[]>([]);
const [events, setEvents] = useState<Event[]>([]);
// The series' events have arrived (the pass form needs them before it can be saved).
const [loaded, setLoaded] = useState(false);
const [draft, setDraft] = useState<Draft | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token) return;
const [p, all] = await Promise.all([
apiFetch<SeasonPassView[]>(`/api/season-passes?seriesId=${encodeURIComponent(seriesId)}`, { authToken: token }).catch(() => []),
apiFetch<Event[]>("/api/events/all?includeInactive=true", { authToken: token }).catch(() => []),
]);
setPasses(p);
// Upcoming events in this series that take registrations, not youth events.
setEvents((all || []).filter(e => e.seriesId === seriesId && e.requiresRegistration !== false && !e.requiresParentalConsent).sort((a, b) => a.startDate.localeCompare(b.startDate)));
setLoaded(true);
}, [token, seriesId]);
useEffect(() => { load(); }, [load]);
// A pass's own events; events without a row yet (a new pass, or still loading) take the default:
// ticked on a new pass, with the first ticket that can be sold.
const passItems = (p: SeasonPassView): ItemDraft[] => p.items.map(i => ({ eventId: i.eventId, on: true, eventOptionId: i.eventOptionId, variantId: i.variantId || "" }));
const itemFor = (d: Draft, ev: Event): ItemDraft => d.items.find(i => i.eventId === ev.id)
|| { eventId: ev.id, on: !d.id, eventOptionId: sellable(ev)[0]?.id || "", variantId: "" };
const openNew = () => { setError(null); setDraft({ name: "", description: "", price: "", saleEndsAt: "", isActive: true, items: [] }); };
const openEdit = (p: SeasonPassView) => {
setError(null);
setDraft({ id: p.id, name: p.name, description: p.description || "", price: String(p.price), saleEndsAt: p.saleEndsAt ? p.saleEndsAt.slice(0, 16) : "", isActive: p.isActive, items: passItems(p) });
};
const editing = draft?.id ? passes.find(p => p.id === draft.id) : null;
const locked = !!editing && ((editing.sold || 0) + (editing.unpaid || 0)) > 0;
// What the chosen tickets cost separately, to show the saving while typing the price.
const normalValue = useMemo(() => (draft ? events.map(ev => itemFor(draft, ev)) : []).filter(i => i.on).reduce((s, i) => {
const ev = events.find(e => e.id === i.eventId);
const opt = ev?.eventOptions?.find(o => o.id === i.eventOptionId);
const v = opt?.variants?.find(x => x.id === i.variantId);
return s + Number(v?.price ?? opt?.price ?? 0);
}, 0), [draft, events]);
const save = async () => {
if (!draft) return;
setSaving(true); setError(null);
try {
const body = {
seriesId, name: draft.name, description: draft.description, isActive: draft.isActive,
saleEndsAt: draft.saleEndsAt ? new Date(draft.saleEndsAt).toISOString() : null,
...(locked ? {} : {
price: Number(draft.price),
items: events.map(ev => itemFor(draft, ev)).filter(i => i.on).map(i => ({ eventId: i.eventId, eventOptionId: i.eventOptionId, variantId: i.variantId || null })),
}),
};
await apiFetch(draft.id ? `/api/season-passes/${draft.id}` : "/api/season-passes", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
setDraft(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't save the pass");
} finally {
setSaving(false);
}
};
const remove = async (p: SeasonPassView) => {
if (!window.confirm(`Delete "${p.name}"?`)) return;
try {
await apiFetch(`/api/season-passes/${p.id}`, { method: "DELETE", authToken: token || undefined });
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't delete the pass");
}
};
const updItem = (ev: Event, patch: Partial<ItemDraft>) => setDraft(d => d && ({ ...d, items: [...d.items.filter(i => i.eventId !== ev.id), { ...itemFor(d, ev), ...patch }] }));
return (
<div className="mt-3 border-t pt-3" data-testid="season-passes">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-xs font-medium text-gray-700 flex items-center gap-1"><Ticket className="w-3.5 h-3.5" /> Season passes</div>
{!draft && <button type="button" className="text-xs px-2.5 py-1 rounded border bg-white hover:bg-gray-50" onClick={openNew}>+ New pass</button>}
</div>
{error && <div className="p-2 mb-2 border rounded bg-red-50 text-red-700 text-xs">{error}</div>}
{passes.length === 0 && !draft && <p className="text-xs text-gray-500">No passes. A pass sells a ticket for several events in this series at one price.</p>}
<ul className="space-y-2">
{passes.map(p => (
<li key={p.id} className="border rounded-lg p-3 text-sm bg-gray-50/50" data-testid="season-pass">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<span className="font-medium">{p.name}</span> · {rand(p.price)}
{p.saving > 0 && <span className="text-xs text-green-700"> (saves {rand(p.saving)})</span>}
{!p.isActive && <span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Off</span>}
<div className="text-xs text-gray-500 mt-0.5">{p.sold || 0} sold{p.unpaid ? ` · ${p.unpaid} not paid yet` : ""}{p.unavailable && p.isActive ? ` · ${p.unavailable}` : ""}</div>
</div>
<div className="flex gap-2">
<a href={`/series/${seriesSlug}`} target="_blank" rel="noreferrer" className="text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50">View</a>
<button type="button" className="text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50" onClick={() => openEdit(p)}>Edit</button>
{!(p.sold || p.unpaid) && <button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(p)}>Delete</button>}
</div>
</div>
<ul className="mt-2 text-xs text-gray-600 space-y-0.5">
{p.items.map(i => (
<li key={i.id}>{i.eventTitle} ({new Date(i.startDate).toLocaleDateString()}) — {i.ticket}: normally {rand(i.normalPrice)}, share {rand(i.share)}</li>
))}
</ul>
</li>
))}
</ul>
{draft && (
<div className="border rounded-lg p-3 bg-white space-y-3 mt-2" data-testid="season-pass-form">
<div className="grid sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-name">Pass name</label>
<input id="pass-name" className="w-full border rounded px-2 py-1.5 text-sm" value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} placeholder="e.g. All 2027 breakfasts" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-price">Price (R)</label>
<input id="pass-price" type="number" min="0" step="1" disabled={locked} className="w-full border rounded px-2 py-1.5 text-sm disabled:bg-gray-50" value={draft.price} onChange={e => setDraft({ ...draft, price: e.target.value })} />
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-description">Description (optional)</label>
<input id="pass-description" className="w-full border rounded px-2 py-1.5 text-sm" value={draft.description} onChange={e => setDraft({ ...draft, description: e.target.value })} />
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Events on the pass</div>
{locked && <p className="text-[11px] text-amber-700 mb-1">People have bought this pass, so its events and price can&apos;t change — switch it off and make a new one.</p>}
{!loaded ? <p className="text-xs text-gray-500">Loading events…</p> : events.length === 0 ? <p className="text-xs text-gray-500">This series has no events that take registrations yet.</p> : (
<ul className="space-y-1.5">
{events.map(ev => {
const item = itemFor(draft, ev);
const opts = sellable(ev);
const variants = opts.find(o => o.id === item.eventOptionId)?.variants || [];
return (
<li key={ev.id} className="flex flex-wrap items-center gap-2 text-sm">
<label className="flex items-center gap-1.5 min-w-0 flex-1">
<input type="checkbox" disabled={locked} checked={item.on} onChange={e => updItem(ev, { on: e.target.checked })} />
<span className="truncate">{ev.title}</span>
<span className="text-xs text-gray-500">{new Date(ev.startDate).toLocaleDateString()}</span>
</label>
{item.on && (
<>
<select aria-label={`Ticket for ${ev.title}`} disabled={locked} className="border rounded px-2 py-1 text-xs" value={item.eventOptionId} onChange={e => updItem(ev, { eventOptionId: e.target.value, variantId: "" })}>
{opts.map(o => <option key={o.id} value={o.id}>{o.name} (R{Number(o.price).toFixed(0)})</option>)}
</select>
{variants.length > 0 && (
<select aria-label={`Variant for ${ev.title}`} disabled={locked} className="border rounded px-2 py-1 text-xs" value={item.variantId} onChange={e => updItem(ev, { variantId: e.target.value })}>
<option value="">Any / none</option>
{variants.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
)}
</>
)}
</li>
);
})}
</ul>
)}
{!locked && normalValue > 0 && (
<p className="text-[11px] text-gray-500 mt-1">Bought separately: {rand(normalValue)}{draft.price !== "" && Number(draft.price) < normalValue ? ` — the pass saves ${rand(normalValue - Number(draft.price))}` : ""}. The price is split over the events in proportion to these prices for each event&apos;s cashup.</p>
)}
</div>
<div className="grid sm:grid-cols-2 gap-3 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="pass-sale-ends">Stop selling at (optional)</label>
<input id="pass-sale-ends" type="datetime-local" className="w-full border rounded px-2 py-1.5 text-sm" value={draft.saleEndsAt} onChange={e => setDraft({ ...draft, saleEndsAt: e.target.value })} />
<p className="text-[10px] text-gray-400 mt-0.5">It always stops when the first event on it starts.</p>
</div>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.isActive} onChange={e => setDraft({ ...draft, isActive: e.target.checked })} /> On sale</label>
</div>
<div className="flex gap-2">
<button type="button" onClick={save} disabled={saving || !loaded || !draft.name.trim() || (!locked && draft.price === "")} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : draft.id ? "Save pass" : "Create pass"}
</button>
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
</div>
</div>
)}
</div>
);
}
+4 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Layers, Calendar, Bell, MapPin, Copy, BarChart3 } from "lucide-react";
import { Layers, Calendar, Bell, MapPin, Copy, BarChart3, Ticket } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -31,6 +31,9 @@ export const seriesHelpContent: HelpContent = {
<GuideItem icon={BarChart3} title="Year on year" tone="green">
&quot;Compare&quot; opens Reports → Series: year on year — each event in the series side by side (registrations, tickets, income, how many came) with how much it went up or down from the one before.
</GuideItem>
<GuideItem icon={Ticket} title="Season passes" tone="violet">
&quot;+ New pass&quot; under a series: a name, a price and which events (and which ticket at each) it includes. People buy it on the series page and are registered for every event at once with one payment. The price is shared out over the events in line with their normal ticket prices, so each event&apos;s cashup shows its part. A pass stops selling when its first event starts; once someone has bought it, its events and price can&apos;t change (switch it off and make a new one). Youth events can&apos;t be on a pass.
</GuideItem>
<GuideItem icon={MapPin} title="Campuses" tone="gray">
A series can have events at different campuses. Supervisors limited to some campuses see the series, but only their campuses&apos; events in it.
</GuideItem>
+22
View File
@@ -203,6 +203,28 @@ export type EventSeries = EventSeriesRef & {
events: { id: string; title: string; startDate: string; endDate: string; isActive: boolean; isHidden: boolean; campus?: Event["campus"] }[];
};
/** A season pass (1.47.3): one price for a ticket at each of several events in a series. */
export type SeasonPassItemView = {
id: string; eventId: string; eventOptionId: string; variantId: string | null;
eventTitle: string; startDate: string; endDate: string; ticket: string;
// The ticket's normal price, and this event's share of the pass price.
normalPrice: number; share: number;
};
export type SeasonPassView = {
id: string; seriesId: string; name: string; description: string | null; price: number;
isActive: boolean; saleEndsAt: string | null; normalValue: number; saving: number;
// Why it can't be bought right now, or null.
unavailable: string | null;
items: SeasonPassItemView[];
// Supervisor list only: passes paid for and still unpaid.
sold?: number; unpaid?: number;
};
export type SeasonPassPurchase = {
id: string; quantity: number; total: number; status: "pending" | "paid" | "cancelled"; createdAt: string;
pass: { id: string; name: string; series: { name: string; slug: string } };
registrations: { id: string; status: string; event: { id: string; title: string; startDate: string } }[];
};
/** GET /api/series/:id/compare — one row per event in the series, oldest first. */
export type SeriesComparisonRow = {
eventId: string; title: string; startDate: string; endDate: string; campus?: Event["campus"]; isActive: boolean;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.47.2",
"version": "1.47.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.47.2",
"version": "1.47.3",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.47.2",
"version": "1.47.3",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",