Compare commits

...
24 Commits
Author SHA1 Message Date
Michilis 29b3d5d4e5 Merge pull request 'Dev' (#38) from dev into main
Reviewed-on: #38
2026-10-03 04:55:15 +00:00
MichilisandClaude Opus 5.5 b3584e6c4d Keep event revenue at what was paid when the ticket price changes.
The event header revenue and the door summary's pre-sale total were
computed as settled tickets × the current event price, so editing the
price rewrote revenue for tickets already sold. They now sum the paid
payment amounts; the header no longer falls back to count × price.
Admin analytics per-event revenue gets the same fix.

Bookings that haven't been paid yet owe the current price, so a price or
currency change now reprices open `pending` payments in the same
transaction as the event update. Paid/refunded history, pending_approval,
on_hold and Lightning invoices keep their amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:38:35 +00:00
MichilisandClaude Opus 5.5 e203fb6c74 Add per-event finance, event team permissions and public door sales state.
Event finance
- Finance tab on the event page: P&L summary with a revenue-to-result
  waterfall, costs and other income in one ledger, and the partner split
  with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close,
  Finalized) with what is left to do; closing the books goes through a
  checklist dialog that freezes the numbers.
- Expense modal shows only the fields each calculation type needs, a live
  preview with the event's real counts, and a category suggested from the
  description. Date inputs follow the UI language.
- Global Finance page: profit per event with outliers clipped and labelled,
  and a "Ready to close" list of past events whose books are still open.
- Calculation service (integer PYG, basis points) with pinned regression
  scenarios; PYG formatting centralised in lib/money with locale-aware
  separators.

Event team permissions
- Per-event members with role presets and requireEventPermission; the
  header stat "Confirmed" is relabelled "Not checked in yet", which is
  what it counts.

Public sales state
- One sales state (online, door, sold out, ended, external, cancelled) for
  the event page, listings and JSON-LD, with the door price and tenders
  shown only while people can still pay at the door.

Frontend unit tests run with vitest (npm test in frontend/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:11:46 +00:00
MichilisandClaude Opus 5.5 b51c1360e2 Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price):
null falls back to the ticket price, 0 is a free walk-in. It is set in
the Create/Edit Event modal (EN/ES) and carried through create, update
and duplicate, but it is internal: public event responses and the
attendee-facing ticket/dashboard endpoints drop it, and only admin,
organizer and staff callers receive it.

The door screen no longer trusts the amount the client sends. It sends
a quantity and the server prices the charge from the event record:
walk-ins pay the walk-in price, existing tickets the ticket price. A
typed amount is only honoured with amountOverride from admin/organizer
and is written to audit_logs in the same transaction. The charged
amount stays snapshotted on the payment row.

POS is a new door tender for the physical card terminal. Staff pick
POS, see the amount to key in, charge the card, then confirm with
"Mark as paid"; it is then recorded like any other door payment
(provider and method 'pos'). It can be switched off globally or per
event via payment options, and shows up in the payments filters,
bookings, revenue summaries and door takings. PosChargePanel is where
an automatic terminal push would go later; nothing calls a terminal
today.

tickets.booking_source (online | walk_in | admin) records how a booking
was made. The migration backfills walk-ins from the door screen's
idempotency records; everything else stays online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:10:50 +00:00
Michilis ef32d5a917 Merge pull request 'Close door-screen toasts on our own timer so they dismiss on touchscreens.' (#37) from dev into main
Reviewed-on: #37
2026-09-19 18:16:40 +00:00
MichilisandClaude Fable 5.1 515707c9e4 Close door-screen toasts on our own timer so they dismiss on touchscreens.
react-hot-toast pauses every countdown while the pointer is inside the
Toaster and schedules no dismissal for toasts created while paused. On a
touchscreen, tapping the Undo button synthesizes a mouseenter with no
mouseleave, so the "Undone" toast never went away. Every toast on the
door screen now uses duration: Infinity and is closed by dismissAfter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 17:54:49 +00:00
Michilis a4945a87cf Merge pull request 'Dev' (#36) from dev into main
Reviewed-on: #36
2026-09-17 15:41:05 +00:00
MichilisandCursor 0b7f0a474e Show pre-sale close time as a duration before the event starts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 15:40:30 +00:00
MichilisandClaude Fable 5.1 a70333f5e4 Add the staff permissions audit report.
A role-by-role audit of who can do what across the backend API, the
admin frontend, Better Auth, the photo API and the deployment config.
It records the effective permission matrix for admin, organizer, staff
and marketing, where the frontend drifts from what the API actually
allows, and a prioritised remediation plan.

Committed as a reference for that remediation work; the findings in
section 11 describe the current state, not fixes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 21:16:21 +00:00
MichilisandClaude Fable 5.1 9cc330030b Show a full-screen result after every door action.
A 900ms row flash and a small bottom toast were too easy to miss at a
loud, dark door: staff could not tell at a glance whether the person in
front of them was in or not. Every action now ends on a screen that is
entirely green (checked in), red (already in, not found, write failed)
or amber (payment due), with the name and the detail staff need to
relay -- the check-in time, who did it, or the amount to collect.

It never holds the queue. Tapping anywhere closes it, it closes itself
after four seconds (six for the red ones, which carry a time or an
amount to read out), and a thin bar counts that down so the screen
vanishing never surprises anyone. Undo and Close are also explicit
buttons for staff who want them.

The undo affordance survives the close: the bottom Undo toast now
appears *after* the screen, for whatever remains of the ten-second
window, rather than alongside it -- react-hot-toast renders above the
overlay and would have floated a second Undo over the first. A write
that fails while the green screen is still up turns it red in place;
one that fails after the toast is up dismisses that toast, so staff are
never offered to undo something that never happened.

The old scanner's safe-area-top, pb-safe and animate-in classes were
undefined in this Tailwind config and silently ignored; the new screen
uses env(safe-area-inset-*) and a real transition instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 21:01:22 +00:00
MichilisandClaude Fable 5.1 59acc32a46 Close pre-sale registration a configurable time before events start.
Events gain nullable presale_closure_enabled / presale_close_minutes_before
overrides; null inherits the new site_settings defaults (enabled, 120 min).
A shared resolver computes the effective cutoff, which the public event API
exposes as presaleClosesAt and the public booking endpoint enforces. Door and
admin ticket creation are not gated.

Admin: toggle + duration picker in the event modal (pre-filled from the site
default) and a matching default card on Settings › General. Public: the event
page shows "Registration Closed" after the cutoff and the checkout page
redirects back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 20:52:09 +00:00
Michilis 183a2c99ed Merge pull request 'Restrict whole-event door takings to admin and organizer.' (#35) from dev into main
Reviewed-on: #35
2026-08-23 06:38:57 +00:00
MichilisandClaude Opus 5 745af4184f Restrict whole-event door takings to admin and organizer.
The door session sheet showed every staff member what the event had
taken overall, by tender and against pre-sale. That is management
information, not door information, and it matches the convention
already applied to the other revenue aggregates (admin/analytics,
admin/export/financial are both admin-only).

Door staff keep their own shift cash-up: the "This session" totals are
computed on the device from its own action log, so nothing they need to
reconcile at the end of the night is lost.

The gate is on GET /api/events/:eventId/door-summary, not only on the
section that renders it -- hiding the panel while the endpoint still
returned the figures would leave them one network response away. The
client skips the request entirely for staff rather than provoking a 403.

The test auth mock previously waved every role through, so it could not
have caught a wrong gate; it now honours the role list, which also puts
several already-written assertions onto real code paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:35:57 +00:00
Michilis 279cd19308 Merge pull request 'Fix the backend build against hono's narrowed route param type.' (#34) from dev into main
Reviewed-on: #34
2026-08-23 06:16:27 +00:00
MichilisandClaude Opus 5 02a12ee9e0 Fix the backend build against hono's narrowed route param type.
npm audit fix floated hono from 4.4 to 4.11 within the ^4.4.7 range and
tsc stopped compiling. c.req.param(key) has two overloads: it returns
string only when the route path's literal type survives inference, and
string | undefined otherwise. requireAuth() returns a handler annotated
with a bare Context, which erases the path generic, so every param read
behind it is now string | undefined.

Only four sites failed. Everywhere else the value lands in
eq((table as any).id, ...), where the any swallows it. Routes that also
run zValidator kept their typing, since that middleware is generic and
restores the inference -- door.ts:251 compiles while the two plain
requireAuth routes beside it do not.

loadEvent now accepts string | undefined and returns null for a missing
id, which both door callers already handle with their 404 branch. The
payments and tickets handlers pass the id off the row they just fetched
and null-checked rather than the raw param.

This leaves the underlying erasure in place. Typing requireAuth to
preserve the path generic would restore string on every authed route,
but it touches all of them and belongs in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:15:28 +00:00
Michilis de791fd17d Merge pull request 'Dev' (#33) from dev into main
Reviewed-on: #33
2026-08-23 06:04:26 +00:00
MichilisandClaude Opus 5 ed1d3a8c12 Paginate the bookings page and the event attendee/ticket tabs.
Extract the pagination controls shared by the events and users lists into
components/admin/Pagination, along with a usePaginatedList helper that slices a
list and keeps the page in range when the list shrinks under it.

Bookings, the Attendees tab and the Tickets tab paginate client-side rather than
on the server: each already loads its full set for figures a slice would break —
the bookings stat cards, the group-booking totals and the sibling payment-method
lookup, and the per-status counts the two tabs share with the event's other tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:01:27 +00:00
MichilisandClaude Opus 5 87bf9a6151 Fix Google sign-in for existing email and ticket-buyer accounts.
Google sign-in only worked for people who already had a linked google
row in auth_accounts. Anyone who first appeared another way -- a guest
ticket purchase, or an email/password signup made after the Better Auth
migration -- got a 401 "account not linked".

trustedProviders: ['google'] defeats only one of better-auth's two
linking gates. The second, requireLocalEmailVerified, defaults to true
and refuses the link whenever the LOCAL users.email_verified is false,
independently of whether the provider is trusted. That flag is false for
every guest-booking row and for every post-migration signup, since
requireEmailVerification is off and no verification mail is sent.

Turn that gate off: the Google id_token is signature-verified against
Google's JWKS with issuer/audience/max-age checks and carries its own
email_verified, so the local column proves nothing extra here.

Linking alone was not enough. getAuthUser() rejects any session whose
user is not 'active', so a ticket buyer would link Google, receive a
cookie, and still look logged out. A databaseHooks.account.create.after
hook now promotes unclaimed rows to claimed/active when a google account
is attached, scoped in the WHERE clause so a suspended account is never
reactivated this way.

Also normalize users.email. The unique index is case-sensitive while
better-auth lowercases every lookup, so someone who booked as
John@Gmail.com was invisible to sign-in and Google minted a SECOND user
row, stranding their tickets on the first. normalizeEmail() covers the
find-or-create sites in tickets.ts and door.ts plus the claim-eligibility
lookup, and an idempotent migration lowercases existing rows -- skipping
any that would collide and reporting those for manual merge, since
merging two people's tickets and payments is not a migration's call.
tickets.attendeeEmail still stores the address exactly as typed.

Tests drive the real signInSocial id-token path with Google stubbed by
signing tokens with a throwaway RS256 key and serving our own JWKS, so
the actual verification runs without network or credentials. That also
makes the deprecation risk loud: requireLocalEmailVerified is marked for
removal upstream, and an upgrade that drops it now fails CI instead of
silently locking ticket buyers out again.

Frontend carries error.code through so OAUTH_LINK_ERROR renders an
actionable message in both locales rather than a bare "account not
linked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:31:02 +00:00
MichilisandClaude Opus 5 3f7b2d51db Redesign the PDF ticket around the branded card layout.
The old ticket was a centred stack of Helvetica on white that read as a
receipt: the QR sat in open space, the event, attendee and code lines
were indistinguishable at a glance, and nothing on the page identified
Spanglish beyond a text heading. The page is now a full-bleed card --
orange rule, cream field, navy footer -- with the logo and event title
in the header, the QR raised into a white rounded panel above its code,
and labelled venue and ticket holder blocks below it, so door staff can
find the code and the name without reading the page.

The ticket is bilingual, driven by the ticket's preferredLanguage: the
labels, the terms line, the Spanish event title and the date format all
follow it, with 24h time and day-first ordering in Spanish. Events
store the venue as one string, so the text before the first comma is
treated as the venue name and the remainder as its address.

Layout adapts rather than overflowing: long titles wrap to two lines at
a smaller size, and the QR panel flexes so the detail block always
lands just above the footer. The single and combined generators were
copies of each other and now share one page renderer, with multi-ticket
bookings marked by a small counter in the panel.

The logo ships as backend/assets/logo-spanglish.png, resolved from both
src/lib and dist/lib, falling back to the frontend copy and then to a
text wordmark, so a deployment that misses the asset still produces a
ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:16:41 +00:00
MichilisandClaude Opus 5 5970d707af Paginate the admin events list.
The admin events page fetched every event on each load and rendered
them all, which grows without bound as the archive fills up. GET
/api/events now takes optional page and pageSize parameters and returns
total alongside the rows; pagination is opt-in, so the public pages and
the admin filter dropdowns that pass neither still get the full list
and the untouched response shape.

Page size is selectable (10/25/50/100) and deleting the last event on a
page falls back to the new last page instead of showing an empty table.
The ?edit=<id> deep link no longer depends on the target being in the
current page: when it is missing from the loaded rows the event is
fetched directly, guarded by a ref so the modal opens once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:16:28 +00:00
MichilisandClaude Opus 5 e296e80e48 Rebuild the Scanner page into a unified door check-in screen.
Most attendees arrive without their QR open, and taking money at the
door meant leaving the scanner for the event dashboard, where the Add
Ticket modal demanded an email and recorded no payment method. The
screen now leads with manual name search, keeps the camera one tap away
behind a fullscreen overlay, and creates and charges walk-ins inline.

Check-in and payment are one action: anything done here is born
confirmed, paid (or comp) and checked in through a single endpoint,
POST /api/events/:eventId/door-checkin. There are no confirm dialogs
anywhere, because they stall the queue; a ten-second Undo replaces
them, reversing exactly what the action changed via the undo state
recorded alongside its idempotency key. Writes fire in the background
with retries, so venue wifi never blocks the person at the door, and a
capacity limit only warns, since staff at the door are the authority.

Every write carries a client-generated idempotency key, inserted in the
same transaction as the writes it guards, so a double tap or a retry
after a timeout cannot produce a second ticket, payment or check-in.
Search runs entirely in memory over one preloaded list: names are
matched accent- and case-insensitively in both directions, per word,
prefix before substring, with a mostly-numeric query searching phone
digits so two people with the same name can be told apart.

Door money is recorded as payments.source 'door' plus payments.method
(cash, bitcoin, transfer or guest) while provider keeps its existing
value, so capacity counting, the stale-booking sweeps and the admin
payment lists are unaffected and revenue can still be split pre-sale
versus door. Bitcoin records the payment as made, on the same trust
model as cash, with no invoice generated; lib/doorPayments.ts is where
a real Lightning flow slots in later.

Also fixes the SQLite tickets DDL, which still created the pre-split
attendee_name column with NOT NULL email and phone. Only fresh
databases were affected -- existing ones were relaxed by later ALTERs
-- but on those, door walk-ins (and any other ticket) could not be
inserted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:09:55 +00:00
MichilisandClaude Opus 5 a0161a67d2 Add "paid at the door" ticket type to the Add Ticket modal.
Door walk-ins were only expressible as an unpaid ticket, which left the
cash out of revenue. The new type records the cash payment as paid and
makes every field optional, since a walk-in often gives no details:
a blank name is logged as "Walk-in", and a confirmation email only goes
out when an email is entered.

Door tickets reuse paymentStatus 'paid' (the column enum is capped at
paid/unpaid/comp) so badges and revenue totals pick them up with no
migration; the cash payment row is referenced "Paid at door" to keep
them distinguishable from emailed manual tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:39:54 +00:00
Michilis d06f80e572 Merge pull request 'Add mobile save sheet with same-origin photo downloads.' (#32) from dev into main
Reviewed-on: #32
2026-08-06 20:20:15 +00:00
MichilisandCursor be4dd5b47f Add mobile save sheet with same-origin photo downloads.
Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 20:14:31 +00:00
145 changed files with 20248 additions and 2250 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+381 -5
View File
@@ -1,7 +1,8 @@
import 'dotenv/config';
import { db, dbAll, events } from './index.js';
import { sql, eq } from 'drizzle-orm';
import { db, dbAll, dbGet, events, users, expenseCategories } from './index.js';
import { sql, eq, ne } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js';
import { generateId, getNow } from '../lib/utils.js';
const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`);
@@ -94,6 +95,8 @@ async function migrate() {
banner_url TEXT,
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url TEXT,
presale_closure_enabled INTEGER,
presale_close_minutes_before INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
@@ -107,6 +110,19 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
} catch (e) { /* column may already exist */ }
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN walk_in_price REAL`);
} catch (e) { /* column may already exist */ }
// Add short description columns to events
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
@@ -133,17 +149,25 @@ async function migrate() {
`);
await (db as any).run(sql`
-- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone
-- shape only survives in databases created before the split into
-- first/last name, where the ALTERs below relaxed it; a fresh database
-- must not recreate constraints the app no longer satisfies (door
-- walk-ins have neither an email nor a phone).
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
event_id TEXT NOT NULL REFERENCES events(id),
attendee_name TEXT NOT NULL,
attendee_email TEXT NOT NULL,
attendee_phone TEXT NOT NULL,
attendee_first_name TEXT NOT NULL,
attendee_last_name TEXT,
attendee_email TEXT,
attendee_phone TEXT,
attendee_ruc TEXT,
preferred_language TEXT,
status TEXT NOT NULL DEFAULT 'pending',
checkin_at TEXT,
qr_code TEXT,
admin_note TEXT,
created_at TEXT NOT NULL
)
`);
@@ -259,6 +283,40 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
scope TEXT NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TEXT,
created_at TEXT NOT NULL
)
`);
// Migration: tickets.booking_source (online | walk_in | admin). On first run,
// backfill walk-ins created on the door screen: each one left an idempotency
// record whose undo_state is {kind: 'created', ticketId}. Nothing else can be
// identified reliably, so all other existing tickets stay 'online'.
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_source TEXT NOT NULL DEFAULT 'online'`);
await (db as any).run(sql`
UPDATE tickets SET booking_source = 'walk_in'
WHERE id IN (
SELECT json_extract(undo_state, '$.ticketId') FROM idempotency_keys
WHERE scope = 'door-checkin' AND json_extract(undo_state, '$.kind') = 'created'
)
`);
} catch (e) { /* column may already exist */ }
// Invoices table
await (db as any).run(sql`
@@ -355,6 +413,14 @@ async function migrate() {
} catch (e) { /* column may already exist */ }
}
// POS card terminal at the door: on globally by default, NULL override = inherit
try {
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
@@ -483,6 +549,8 @@ async function migrate() {
maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT,
maintenance_message_es TEXT,
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
)
@@ -493,6 +561,14 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure site-wide defaults
try {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
@@ -703,6 +779,8 @@ async function migrate() {
banner_url VARCHAR(500),
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url VARCHAR(500),
presale_closure_enabled INTEGER,
presale_close_minutes_before INTEGER,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
@@ -716,6 +794,19 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
} catch (e) { /* column may already exist */ }
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN walk_in_price DECIMAL(10, 2)`);
} catch (e) { /* column may already exist */ }
// Add short description columns to events
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
@@ -836,6 +927,39 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
)
`);
// Migration: tickets.booking_source (online | walk_in | admin), with the
// same one-time walk-in backfill as the sqlite branch.
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_source VARCHAR(20) NOT NULL DEFAULT 'online'`);
await (db as any).execute(sql`
UPDATE tickets SET booking_source = 'walk_in'
WHERE id::text IN (
SELECT undo_state::json->>'ticketId' FROM idempotency_keys
WHERE scope = 'door-checkin' AND undo_state IS NOT NULL
AND undo_state::json->>'kind' = 'created'
)
`);
} catch (e) { /* column may already exist */ }
// Invoices table
await (db as any).execute(sql`
@@ -930,6 +1054,14 @@ async function migrate() {
} catch (e) { /* column may already exist */ }
}
// POS card terminal at the door: on globally by default, NULL override = inherit
try {
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS contacts (
id UUID PRIMARY KEY,
@@ -1058,6 +1190,8 @@ async function migrate() {
maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT,
maintenance_message_es TEXT,
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id)
)
@@ -1068,6 +1202,14 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Pre-sale closure site-wide defaults
try {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
@@ -1195,6 +1337,178 @@ async function migrate() {
`);
}
// ==================== Event finance, partners & team access ====================
// New tables only, so one engine-neutral block with a type map instead of a
// copy per branch. Money is whole PYG (INTEGER), percentages are basis points.
const T = dbType === 'sqlite'
? { id: 'TEXT', ts: 'TEXT', str: 'TEXT', text: 'TEXT' }
: { id: 'UUID', ts: 'TIMESTAMP', str: 'VARCHAR(300)', text: 'TEXT' };
const run = (stmt: string) =>
dbType === 'sqlite' ? (db as any).run(sql.raw(stmt)) : (db as any).execute(sql.raw(stmt));
try {
await run(`ALTER TABLE events ADD COLUMN series ${dbType === 'sqlite' ? 'TEXT' : 'VARCHAR(100)'}`);
} catch (e) { /* column may already exist */ }
const financeTables = [
`CREATE TABLE IF NOT EXISTS expense_categories (
id ${T.id} PRIMARY KEY,
name_en ${T.str} NOT NULL,
name_es ${T.str} NOT NULL,
color ${T.str} NOT NULL DEFAULT '#6B7280',
sort_order INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS expense_templates (
id ${T.id} PRIMARY KEY,
name ${T.str} NOT NULL,
category_id ${T.id} REFERENCES expense_categories(id),
description ${T.text},
calc_type ${T.str} NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
percent_bp INTEGER NOT NULL DEFAULT 0,
minimum_amount INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS expense_template_packs (
id ${T.id} PRIMARY KEY,
name ${T.str} NOT NULL,
description ${T.text},
archived INTEGER NOT NULL DEFAULT 0,
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS expense_template_pack_items (
id ${T.id} PRIMARY KEY,
pack_id ${T.id} NOT NULL REFERENCES expense_template_packs(id),
template_id ${T.id} NOT NULL REFERENCES expense_templates(id),
sort_order INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS event_partners (
id ${T.id} PRIMARY KEY,
event_id ${T.id} NOT NULL REFERENCES events(id),
user_id ${T.id} REFERENCES users(id),
external_name ${T.str},
role_label ${T.str},
share_type ${T.str} NOT NULL,
percent_bp INTEGER NOT NULL DEFAULT 0,
fixed_amount INTEGER NOT NULL DEFAULT 0,
threshold_amount INTEGER NOT NULL DEFAULT 0,
loss_rule ${T.str} NOT NULL DEFAULT 'none',
loss_cap_amount INTEGER NOT NULL DEFAULT 0,
payout_status ${T.str} NOT NULL DEFAULT 'pending',
payout_date ${T.ts},
payout_method ${T.str},
payout_note ${T.text},
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS event_expenses (
id ${T.id} PRIMARY KEY,
event_id ${T.id} NOT NULL REFERENCES events(id),
category_id ${T.id} REFERENCES expense_categories(id),
template_id ${T.id} REFERENCES expense_templates(id),
description ${T.str} NOT NULL,
calc_type ${T.str} NOT NULL DEFAULT 'fixed',
quantity INTEGER NOT NULL DEFAULT 1,
unit_amount INTEGER NOT NULL DEFAULT 0,
percent_bp INTEGER NOT NULL DEFAULT 0,
minimum_amount INTEGER NOT NULL DEFAULT 0,
computed_amount INTEGER NOT NULL DEFAULT 0,
is_locked INTEGER NOT NULL DEFAULT 0,
status ${T.str} NOT NULL DEFAULT 'planned',
paid_by_partner_id ${T.id} REFERENCES event_partners(id),
receipt_url ${T.str},
expense_date ${T.ts},
created_by ${T.id} REFERENCES users(id),
updated_by ${T.id} REFERENCES users(id),
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS event_other_income (
id ${T.id} PRIMARY KEY,
event_id ${T.id} NOT NULL REFERENCES events(id),
description ${T.str} NOT NULL,
amount INTEGER NOT NULL,
created_by ${T.id} REFERENCES users(id),
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS payment_method_fees (
method ${T.str} PRIMARY KEY,
percent_bp INTEGER NOT NULL DEFAULT 0,
fixed_amount INTEGER NOT NULL DEFAULT 0,
updated_at ${T.ts} NOT NULL,
updated_by ${T.id} REFERENCES users(id)
)`,
`CREATE TABLE IF NOT EXISTS event_finance_state (
event_id ${T.id} PRIMARY KEY REFERENCES events(id),
status ${T.str} NOT NULL DEFAULT 'open',
finalized_at ${T.ts},
finalized_by ${T.id} REFERENCES users(id),
snapshot_json ${T.text},
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS event_members (
id ${T.id} PRIMARY KEY,
event_id ${T.id} NOT NULL REFERENCES events(id),
user_id ${T.id} NOT NULL REFERENCES users(id),
role_preset ${T.str} NOT NULL,
permissions ${T.text} NOT NULL DEFAULT '{}',
created_by ${T.id} REFERENCES users(id),
created_at ${T.ts} NOT NULL,
updated_at ${T.ts} NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS finance_audit_log (
id ${T.id} PRIMARY KEY,
event_id ${T.id},
actor_user_id ${T.id} REFERENCES users(id),
entity_type ${T.str} NOT NULL,
entity_id ${T.str},
action ${T.str} NOT NULL,
before_json ${T.text},
after_json ${T.text},
created_at ${T.ts} NOT NULL
)`,
];
for (const stmt of financeTables) {
await run(stmt);
}
// Defaults, only when the tables are still empty so archived/deleted rows stay gone.
const now = getNow();
const categoryCount = await dbGet<any>(
(db as any).select({ count: sql<number>`count(*)` }).from(sql`expense_categories`)
);
if (Number(categoryCount?.count || 0) === 0) {
const defaults: [string, string, string][] = [
['Venue', 'Lugar', '#2563EB'],
['Instructor / host', 'Instructor / anfitrión', '#7C3AED'],
['Food & drinks', 'Comida y bebidas', '#EA580C'],
['Staff', 'Personal', '#0D9488'],
['Marketing', 'Marketing', '#DB2777'],
['Supplies', 'Materiales', '#CA8A04'],
['Other', 'Otros', '#6B7280'],
];
for (const [i, [en, es, color]] of defaults.entries()) {
await (db as any).insert(expenseCategories).values({
id: generateId(), nameEn: en, nameEs: es, color, sortOrder: i, archived: dbType === 'sqlite' ? false : 0,
createdAt: now, updatedAt: now,
});
}
console.log('Seeded default expense categories.');
}
// One fee row per payments.provider in use; 0% until an admin sets them.
for (const method of ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos']) {
await run(`INSERT INTO payment_method_fees (method, percent_bp, fixed_amount, updated_at)
VALUES ('${method}', 0, 0, ${dbType === 'sqlite' ? `'${new Date().toISOString()}'` : 'NOW()'})
ON CONFLICT (method) DO NOTHING`);
}
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
const indexStatements = [
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
@@ -1203,6 +1517,8 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`,
`CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
@@ -1210,6 +1526,15 @@ async function migrate() {
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
`CREATE INDEX IF NOT EXISTS event_expenses_event_id_idx ON event_expenses(event_id)`,
`CREATE INDEX IF NOT EXISTS event_other_income_event_id_idx ON event_other_income(event_id)`,
`CREATE INDEX IF NOT EXISTS event_partners_event_id_idx ON event_partners(event_id)`,
`CREATE INDEX IF NOT EXISTS event_partners_user_id_idx ON event_partners(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS event_members_event_user_idx ON event_members(event_id, user_id)`,
`CREATE INDEX IF NOT EXISTS event_members_user_id_idx ON event_members(user_id)`,
`CREATE INDEX IF NOT EXISTS expense_template_pack_items_pack_id_idx ON expense_template_pack_items(pack_id)`,
`CREATE INDEX IF NOT EXISTS finance_audit_log_event_id_idx ON finance_audit_log(event_id, created_at)`,
`CREATE INDEX IF NOT EXISTS events_series_idx ON events(series)`,
];
for (const stmt of indexStatements) {
try {
@@ -1296,6 +1621,57 @@ async function migrate() {
`);
}
// ==================== users.email normalization ====================
// Better Auth lowercases the address on every lookup and write it performs,
// but the users.email unique index is case-sensitive on both dialects. Rows
// written outside Better Auth (guest bookings, door sales, admin-added
// tickets) used to keep the address exactly as typed, so a buyer who entered
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
// signing in with Google minted a SECOND user row and left their tickets
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
// fixes the rows already in the table.
//
// Idempotent, and deliberately conservative: a row is only lowercased when
// nothing already occupies the lowercase address. A genuine collision means
// two user rows for the same person, each with its own tickets, invoices and
// payments — merging those is a judgement call, not a migration, so they are
// reported for manual review instead.
const lowercaseEmailsSql = `
UPDATE users SET email = LOWER(email)
WHERE email <> LOWER(email)
AND NOT EXISTS (
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
)
`;
if (dbType === 'sqlite') {
await (db as any).run(sql.raw(lowercaseEmailsSql));
} else {
await (db as any).execute(sql.raw(lowercaseEmailsSql));
}
// Whatever still differs from its own lowercase form is exactly the set the
// UPDATE refused to touch, i.e. the collisions.
const collisions = await dbAll<{ id: string; email: string }>(
(db as any)
.select({ id: (users as any).id, email: (users as any).email })
.from(users)
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
);
if (collisions.length > 0) {
console.warn(
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
`the lowercase row, so these need a manual merge:`
);
for (const row of collisions) {
const canonical = row.email.toLowerCase();
const existing = await dbGet<{ id: string }>(
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
);
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
}
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+376 -2
View File
@@ -82,12 +82,20 @@ export const sqliteEvents = sqliteTable('events', {
location: text('location').notNull(),
locationUrl: text('location_url'),
price: real('price').notNull().default(0),
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
// Admin/staff only: never serialized to public event responses.
walkInPrice: real('walk_in_price'),
currency: text('currency').notNull().default('PYG'),
capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'),
// Pre-sale closure: null = inherit the site_settings default
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }),
presaleCloseMinutesBefore: integer('presale_close_minutes_before'),
// Groups recurring events (e.g. "Morning Club") for the cross-event finance overview
series: text('series'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -118,13 +126,16 @@ export const sqliteTickets = sqliteTable('tickets', {
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
// How the booking was made: public checkout, a walk-in on the door screen, or
// added by an admin. Distinct from payments.source, which is where money was taken.
bookingSource: text('booking_source', { enum: ['online', 'walk_in', 'admin'] }).notNull().default('online'),
createdAt: text('created_at').notNull(),
});
export const sqlitePayments = sqliteTable('payments', {
id: text('id').primaryKey(),
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago', 'pos'] }).notNull(),
amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
@@ -138,10 +149,32 @@ export const sqlitePayments = sqliteTable('payments', {
paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Idempotency records for door check-in actions.
//
// The door screen fires check-ins / walk-in creations optimistically and retries
// on flaky venue wifi, so every action carries a client-generated key. The first
// request stores its response here; replays return that stored response instead
// of creating a second ticket, payment or check-in. `undoState` holds exactly
// what the action changed so the 10-second Undo can reverse it precisely.
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
key: text('key').primaryKey(),
scope: text('scope').notNull(),
result: text('result').notNull(), // JSON response body of the original request
undoState: text('undo_state'), // JSON describing how to reverse the action
undoneAt: text('undone_at'),
createdAt: text('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const sqlitePaymentOptions = sqliteTable('payment_options', {
id: text('id').primaryKey(),
@@ -169,6 +202,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'),
// POS card terminal at the door (Scanner only, never offered at online checkout)
posEnabled: integer('pos_enabled', { mode: 'boolean' }).notNull().default(true),
// Booking settings
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
// Metadata
@@ -201,6 +236,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'),
posEnabled: integer('pos_enabled', { mode: 'boolean' }),
// Metadata
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
@@ -365,11 +401,160 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
maintenanceMessage: text('maintenance_message'),
maintenanceMessageEs: text('maintenance_message_es'),
// Pre-sale closure defaults (events inherit these unless they override)
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }).notNull().default(true),
presaleCloseMinutesBefore: integer('presale_close_minutes_before').notNull().default(120),
// Metadata
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// ==================== Event finance (SQLite) ====================
// All money columns are whole PYG (integer). Percentages are stored in basis
// points (hundredths of a percent): 290 = 2.90%. JSON columns are text.
export const sqliteExpenseCategories = sqliteTable('expense_categories', {
id: text('id').primaryKey(),
nameEn: text('name_en').notNull(),
nameEs: text('name_es').notNull(),
color: text('color').notNull().default('#6B7280'),
sortOrder: integer('sort_order').notNull().default(0),
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteExpenseTemplates = sqliteTable('expense_templates', {
id: text('id').primaryKey(),
name: text('name').notNull(),
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
description: text('description'),
// fixed | per_ticket_sold | per_checked_in | percent_of_revenue | minimum_spend
calcType: text('calc_type').notNull(),
amount: integer('amount').notNull().default(0),
percentBp: integer('percent_bp').notNull().default(0),
minimumAmount: integer('minimum_amount').notNull().default(0),
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteExpenseTemplatePacks = sqliteTable('expense_template_packs', {
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteExpenseTemplatePackItems = sqliteTable('expense_template_pack_items', {
id: text('id').primaryKey(),
packId: text('pack_id').notNull().references(() => sqliteExpenseTemplatePacks.id),
templateId: text('template_id').notNull().references(() => sqliteExpenseTemplates.id),
sortOrder: integer('sort_order').notNull().default(0),
});
export const sqliteEventPartners = sqliteTable('event_partners', {
id: text('id').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
userId: text('user_id').references(() => sqliteUsers.id),
externalName: text('external_name'),
roleLabel: text('role_label'),
// percent_profit | percent_revenue | fixed | fixed_plus_percent_above_threshold
shareType: text('share_type').notNull(),
percentBp: integer('percent_bp').notNull().default(0),
fixedAmount: integer('fixed_amount').notNull().default(0),
thresholdAmount: integer('threshold_amount').notNull().default(0),
// proportional | none | capped
lossRule: text('loss_rule').notNull().default('none'),
lossCapAmount: integer('loss_cap_amount').notNull().default(0),
payoutStatus: text('payout_status').notNull().default('pending'),
payoutDate: text('payout_date'),
payoutMethod: text('payout_method'),
payoutNote: text('payout_note'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteEventExpenses = sqliteTable('event_expenses', {
id: text('id').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
templateId: text('template_id').references(() => sqliteExpenseTemplates.id),
description: text('description').notNull(),
calcType: text('calc_type').notNull().default('fixed'),
// fixed: units bought; auto types: the count used at the last calculation
quantity: integer('quantity').notNull().default(1),
unitAmount: integer('unit_amount').notNull().default(0),
percentBp: integer('percent_bp').notNull().default(0),
minimumAmount: integer('minimum_amount').notNull().default(0),
computedAmount: integer('computed_amount').notNull().default(0),
// Locked rows keep computed_amount instead of following ticket counts
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
status: text('status').notNull().default('planned'), // planned | paid
// NULL = the organization paid; otherwise the partner who fronted it
paidByPartnerId: text('paid_by_partner_id').references(() => sqliteEventPartners.id),
receiptUrl: text('receipt_url'),
expenseDate: text('expense_date'),
createdBy: text('created_by').references(() => sqliteUsers.id),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteEventOtherIncome = sqliteTable('event_other_income', {
id: text('id').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
description: text('description').notNull(),
amount: integer('amount').notNull(),
createdBy: text('created_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Keyed by payments.provider (door payments carry the legacy provider too)
export const sqlitePaymentMethodFees = sqliteTable('payment_method_fees', {
method: text('method').primaryKey(),
percentBp: integer('percent_bp').notNull().default(0),
fixedAmount: integer('fixed_amount').notNull().default(0),
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
export const sqliteEventFinanceState = sqliteTable('event_finance_state', {
eventId: text('event_id').primaryKey().references(() => sqliteEvents.id),
status: text('status').notNull().default('open'), // open | finalized | paid_out
finalizedAt: text('finalized_at'),
finalizedBy: text('finalized_by').references(() => sqliteUsers.id),
snapshotJson: text('snapshot_json'),
updatedAt: text('updated_at').notNull(),
});
export const sqliteEventMembers = sqliteTable('event_members', {
id: text('id').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
userId: text('user_id').notNull().references(() => sqliteUsers.id),
rolePreset: text('role_preset').notNull(), // staff | collaborator | co_manager
// JSON {permissionKey: boolean} applied on top of the preset
permissions: text('permissions').notNull().default('{}'),
createdBy: text('created_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteFinanceAuditLog = sqliteTable('finance_audit_log', {
id: text('id').primaryKey(),
eventId: text('event_id'), // NULL for global finance settings
actorUserId: text('actor_user_id').references(() => sqliteUsers.id),
entityType: text('entity_type').notNull(),
entityId: text('entity_id'),
action: text('action').notNull(),
beforeJson: text('before_json'),
afterJson: text('after_json'),
createdAt: text('created_at').notNull(),
});
// ==================== PostgreSQL Schema ====================
export const pgUsers = pgTable('users', {
id: uuid('id').primaryKey(),
@@ -448,12 +633,19 @@ export const pgEvents = pgTable('events', {
location: varchar('location', { length: 500 }).notNull(),
locationUrl: varchar('location_url', { length: 500 }),
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
// Admin/staff only: never serialized to public event responses.
walkInPrice: decimal('walk_in_price', { precision: 10, scale: 2 }),
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
capacity: pgInteger('capacity').notNull().default(50),
status: varchar('status', { length: 20 }).notNull().default('draft'),
bannerUrl: varchar('banner_url', { length: 500 }),
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
// Pre-sale closure: null = inherit the site_settings default
presaleClosureEnabled: pgInteger('presale_closure_enabled'),
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'),
series: varchar('series', { length: 100 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
@@ -484,6 +676,8 @@ export const pgTickets = pgTable('tickets', {
isGuest: pgInteger('is_guest').notNull().default(0),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
// online | walk_in | admin — see sqliteTickets.bookingSource
bookingSource: varchar('booking_source', { length: 20 }).notNull().default('online'),
createdAt: timestamp('created_at').notNull(),
});
@@ -504,10 +698,26 @@ export const pgPayments = pgTable('payments', {
paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'),
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: varchar('source', { length: 20 }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: varchar('method', { length: 20 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
key: varchar('key', { length: 128 }).primaryKey(),
scope: varchar('scope', { length: 64 }).notNull(),
result: pgText('result').notNull(),
undoState: pgText('undo_state'),
undoneAt: timestamp('undone_at'),
createdAt: timestamp('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(),
@@ -531,6 +741,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'),
posEnabled: pgInteger('pos_enabled').notNull().default(1),
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
@@ -560,6 +771,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
cashEnabled: pgInteger('cash_enabled'),
cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'),
posEnabled: pgInteger('pos_enabled'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
@@ -723,17 +935,159 @@ export const pgSiteSettings = pgTable('site_settings', {
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
maintenanceMessage: pgText('maintenance_message'),
maintenanceMessageEs: pgText('maintenance_message_es'),
// Pre-sale closure defaults (events inherit these unless they override)
presaleClosureEnabled: pgInteger('presale_closure_enabled').notNull().default(1),
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before').notNull().default(120),
// Metadata
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// ==================== Event finance (PostgreSQL) ====================
// See the SQLite block: integer PYG, basis-point percentages, JSON as text.
export const pgExpenseCategories = pgTable('expense_categories', {
id: uuid('id').primaryKey(),
nameEn: varchar('name_en', { length: 100 }).notNull(),
nameEs: varchar('name_es', { length: 100 }).notNull(),
color: varchar('color', { length: 20 }).notNull().default('#6B7280'),
sortOrder: pgInteger('sort_order').notNull().default(0),
archived: pgInteger('archived').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgExpenseTemplates = pgTable('expense_templates', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull(),
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
description: pgText('description'),
calcType: varchar('calc_type', { length: 40 }).notNull(),
amount: pgInteger('amount').notNull().default(0),
percentBp: pgInteger('percent_bp').notNull().default(0),
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
archived: pgInteger('archived').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgExpenseTemplatePacks = pgTable('expense_template_packs', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull(),
description: pgText('description'),
archived: pgInteger('archived').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgExpenseTemplatePackItems = pgTable('expense_template_pack_items', {
id: uuid('id').primaryKey(),
packId: uuid('pack_id').notNull().references(() => pgExpenseTemplatePacks.id),
templateId: uuid('template_id').notNull().references(() => pgExpenseTemplates.id),
sortOrder: pgInteger('sort_order').notNull().default(0),
});
export const pgEventPartners = pgTable('event_partners', {
id: uuid('id').primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
userId: uuid('user_id').references(() => pgUsers.id),
externalName: varchar('external_name', { length: 200 }),
roleLabel: varchar('role_label', { length: 100 }),
shareType: varchar('share_type', { length: 40 }).notNull(),
percentBp: pgInteger('percent_bp').notNull().default(0),
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
thresholdAmount: pgInteger('threshold_amount').notNull().default(0),
lossRule: varchar('loss_rule', { length: 20 }).notNull().default('none'),
lossCapAmount: pgInteger('loss_cap_amount').notNull().default(0),
payoutStatus: varchar('payout_status', { length: 20 }).notNull().default('pending'),
payoutDate: timestamp('payout_date'),
payoutMethod: varchar('payout_method', { length: 50 }),
payoutNote: pgText('payout_note'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgEventExpenses = pgTable('event_expenses', {
id: uuid('id').primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
templateId: uuid('template_id').references(() => pgExpenseTemplates.id),
description: varchar('description', { length: 300 }).notNull(),
calcType: varchar('calc_type', { length: 40 }).notNull().default('fixed'),
quantity: pgInteger('quantity').notNull().default(1),
unitAmount: pgInteger('unit_amount').notNull().default(0),
percentBp: pgInteger('percent_bp').notNull().default(0),
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
computedAmount: pgInteger('computed_amount').notNull().default(0),
isLocked: pgInteger('is_locked').notNull().default(0),
status: varchar('status', { length: 20 }).notNull().default('planned'),
paidByPartnerId: uuid('paid_by_partner_id').references(() => pgEventPartners.id),
receiptUrl: varchar('receipt_url', { length: 500 }),
expenseDate: timestamp('expense_date'),
createdBy: uuid('created_by').references(() => pgUsers.id),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgEventOtherIncome = pgTable('event_other_income', {
id: uuid('id').primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
description: varchar('description', { length: 300 }).notNull(),
amount: pgInteger('amount').notNull(),
createdBy: uuid('created_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgPaymentMethodFees = pgTable('payment_method_fees', {
method: varchar('method', { length: 50 }).primaryKey(),
percentBp: pgInteger('percent_bp').notNull().default(0),
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
export const pgEventFinanceState = pgTable('event_finance_state', {
eventId: uuid('event_id').primaryKey().references(() => pgEvents.id),
status: varchar('status', { length: 20 }).notNull().default('open'),
finalizedAt: timestamp('finalized_at'),
finalizedBy: uuid('finalized_by').references(() => pgUsers.id),
snapshotJson: pgText('snapshot_json'),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgEventMembers = pgTable('event_members', {
id: uuid('id').primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
userId: uuid('user_id').notNull().references(() => pgUsers.id),
rolePreset: varchar('role_preset', { length: 20 }).notNull(),
permissions: pgText('permissions').notNull().default('{}'),
createdBy: uuid('created_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgFinanceAuditLog = pgTable('finance_audit_log', {
id: uuid('id').primaryKey(),
eventId: uuid('event_id'),
actorUserId: uuid('actor_user_id').references(() => pgUsers.id),
entityType: varchar('entity_type', { length: 50 }).notNull(),
// Text, not uuid: settings rows are keyed by method name
entityId: varchar('entity_id', { length: 100 }),
action: varchar('action', { length: 50 }).notNull(),
beforeJson: pgText('before_json'),
afterJson: pgText('after_json'),
createdAt: timestamp('created_at').notNull(),
});
// Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
@@ -751,6 +1105,17 @@ export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLeg
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
export const expenseCategories = dbType === 'postgres' ? pgExpenseCategories : sqliteExpenseCategories;
export const expenseTemplates = dbType === 'postgres' ? pgExpenseTemplates : sqliteExpenseTemplates;
export const expenseTemplatePacks = dbType === 'postgres' ? pgExpenseTemplatePacks : sqliteExpenseTemplatePacks;
export const expenseTemplatePackItems = dbType === 'postgres' ? pgExpenseTemplatePackItems : sqliteExpenseTemplatePackItems;
export const eventPartners = dbType === 'postgres' ? pgEventPartners : sqliteEventPartners;
export const eventExpenses = dbType === 'postgres' ? pgEventExpenses : sqliteEventExpenses;
export const eventOtherIncome = dbType === 'postgres' ? pgEventOtherIncome : sqliteEventOtherIncome;
export const paymentMethodFees = dbType === 'postgres' ? pgPaymentMethodFees : sqlitePaymentMethodFees;
export const eventFinanceState = dbType === 'postgres' ? pgEventFinanceState : sqliteEventFinanceState;
export const eventMembers = dbType === 'postgres' ? pgEventMembers : sqliteEventMembers;
export const financeAuditLog = dbType === 'postgres' ? pgFinanceAuditLog : sqliteFinanceAuditLog;
// Type exports
export type User = typeof sqliteUsers.$inferSelect;
@@ -784,4 +1149,13 @@ export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
export type ExpenseCategory = typeof sqliteExpenseCategories.$inferSelect;
export type ExpenseTemplate = typeof sqliteExpenseTemplates.$inferSelect;
export type ExpenseTemplatePack = typeof sqliteExpenseTemplatePacks.$inferSelect;
export type EventPartner = typeof sqliteEventPartners.$inferSelect;
export type EventExpense = typeof sqliteEventExpenses.$inferSelect;
export type EventOtherIncome = typeof sqliteEventOtherIncome.$inferSelect;
export type PaymentMethodFee = typeof sqlitePaymentMethodFees.$inferSelect;
export type EventFinanceState = typeof sqliteEventFinanceState.$inferSelect;
export type EventMember = typeof sqliteEventMembers.$inferSelect;
+122
View File
@@ -12,6 +12,9 @@ import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.js';
import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js';
import doorRoutes from './routes/door.js';
import eventFinanceRoutes from './routes/eventFinance.js';
import financeRoutes from './routes/finance.js';
import usersRoutes from './routes/users.js';
import contactsRoutes from './routes/contacts.js';
import paymentsRoutes from './routes/payments.js';
@@ -767,6 +770,119 @@ const openApiSpec = {
},
},
},
// ==================== Door Check-in Screen ====================
'/api/events/{eventId}/door-attendees': {
get: {
tags: ['Tickets'],
summary: 'Full attendee list for the door check-in screen',
description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event, attendees and check-in stats' },
404: { description: 'Event not found' },
},
},
},
'/api/events/{eventId}/door-checkin': {
post: {
tags: ['Tickets'],
summary: 'Check in, settle payment, or create a walk-in (atomic)',
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: {
ticketId: { type: 'string' },
attendee: {
type: 'object',
required: ['firstName'],
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
phone: { type: 'string' },
email: { type: 'string', format: 'email' },
ruc: { type: 'string' },
},
},
payment: {
type: 'object',
required: ['method'],
properties: {
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] },
quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' },
amount: { type: 'number', description: 'Ignored unless amountOverride is true.' },
amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' },
},
},
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
idempotencyKey: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
200: { description: 'Replay of an already-processed idempotencyKey' },
400: { description: 'Ticket belongs to a different event, or the payment method is not enabled for this event' },
403: { description: 'amountOverride sent by a role that may not override the door amount' },
404: { description: 'Event or ticket not found' },
},
},
},
'/api/events/{eventId}/door-checkin/undo': {
post: {
tags: ['Tickets'],
summary: 'Reverse one door check-in action',
description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: { idempotencyKey: { type: 'string' } },
},
},
},
},
responses: {
200: { description: 'Action reversed (or already undone)' },
404: { description: 'No action recorded for this key' },
},
},
},
'/api/events/{eventId}/door-summary': {
get: {
tags: ['Payments'],
summary: 'Door cash-up and pre-sale/door revenue split',
description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Door totals by method, door lines, and pre-sale totals' },
404: { description: 'Event not found' },
},
},
},
'/api/tickets/{id}/checkin': {
post: {
tags: ['Tickets'],
@@ -1908,6 +2024,11 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => {
);
});
app.route('/api/auth-ext', authExtRoutes);
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
// Mounted first so the generic /:id routes below can never shadow them.
app.route('/api/events', doorRoutes);
// Per-event finance, partners and team access (/api/events/:id/finance, /expenses, /members, ...)
app.route('/api/events', eventFinanceRoutes);
app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes);
@@ -1923,6 +2044,7 @@ app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/legal-settings', legalSettingsRoutes);
app.route('/api/faq', faqRoutes);
app.route('/api/finance', financeRoutes);
// 404 handler
app.notFound((c) => {
+227 -1
View File
@@ -3,6 +3,8 @@ import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { normalizeEmail } from './utils.js';
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
@@ -13,7 +15,13 @@ process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
delete process.env.GOOGLE_CLIENT_ID;
// Google IS configured here: account linking is the whole point of the tests at
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
// when this is unset. No real credentials are involved — the id tokens are
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
@@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string {
return decodeURIComponent(match![1]);
}
// ---- Google Identity Services stub -------------------------------------
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
// Google's published JWKS; its only network call is that JWKS fetch. Signing
// with our own key and serving our own JWKS exercises the real verification
// path without touching the network or needing OAuth credentials.
const GOOGLE_KID = 'spanglish-test-key';
let googlePrivateKey: CryptoKey;
async function installGoogleStub() {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
googlePrivateKey = privateKey as CryptoKey;
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
return new Response(JSON.stringify({ keys: [jwk] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return realFetch(input, init);
}) as typeof fetch;
}
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
return new SignJWT({
email: opts.email,
email_verified: opts.emailVerified ?? true,
name: opts.name ?? 'Google User',
picture: 'https://example.test/avatar.png',
})
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience(GOOGLE_CLIENT_ID)
.setSubject(opts.sub)
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
}
async function signInWithGoogle(
opts: Parameters<typeof googleIdToken>[0],
returnHeaders = false
): Promise<any> {
const token = await googleIdToken(opts);
return auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
...(returnHeaders ? { returnHeaders: true } : {}),
} as any);
}
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
* unclaimed, unverified, and with no auth_accounts row at all. */
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
)
.run(id, email, name, now, now);
return id;
}
function userRow(email: string) {
return sqlite
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
.get(email);
}
function googleAccountsFor(userId: string) {
return sqlite
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
.all(userId);
}
function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/)
@@ -60,6 +147,7 @@ beforeAll(() => {
({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
await installGoogleStub();
})();
}, 120_000);
@@ -291,3 +379,141 @@ describe('Better Auth integration', () => {
expect(days).toBeLessThan(7.5);
});
});
describe('Google sign-in and account linking', () => {
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
expect(userRow('buyer@test.py').email_verified).toBe(0);
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
});
it('claims the booking account so the resulting session is actually accepted', async () => {
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
// 'active', so linking alone would leave the user looking logged out.
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
const { headers, response } = await signInWithGoogle(
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
true
);
expect(response.user.id).toBe(id);
const row = userRow('buyer2@test.py');
expect(row.account_status).toBe('active');
expect(row.is_claimed).toBe(1);
expect(row.email_verified).toBe(1);
const session = await auth.api.getSession({
headers: cookieHeaders(headers.get('set-cookie')),
});
expect(session?.user.id).toBe(id);
expect((session?.user as any).accountStatus).toBe('active');
});
it('never reactivates a suspended account through a Google link', async () => {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
)
.run('suspended-google', 'suspended-google@test.py', now, now);
await expect(
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
).rejects.toThrow();
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
});
it('links Google onto an email/password account created after the Better Auth migration', async () => {
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
// is off), which used to be enough to block linking on its own.
await auth.api.signUpEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
});
expect(userRow('pwuser@test.py').email_verified).toBe(0);
const id = userRow('pwuser@test.py').id;
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
// The credential account survives: they can still sign in with a password.
const after = await auth.api.signInEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
});
expect(after.user.id).toBe(id);
});
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
const row = userRow('fresh@test.py');
expect(row.id).toBe(first.user.id);
expect(row.email_verified).toBe(1);
expect(row.account_status).toBe('active');
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
expect(second.user.id).toBe(first.user.id);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
});
it('rejects an id token minted for a different client id', async () => {
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience('some-other-app.apps.googleusercontent.com')
.setSubject('google-sub-forged')
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
await expect(
auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
} as any)
).rejects.toThrow();
expect(userRow('forged@test.py')).toBeUndefined();
});
});
describe('users.email normalization', () => {
it('normalizes an address to its canonical stored form', () => {
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
});
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
const now = new Date().toISOString();
const insert = (id: string, email: string, status = 'unclaimed') =>
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
)
.run(id, email, status, now, now);
insert('legacy-mixed', 'John@Example.com');
// A pair that genuinely collides: the migration must leave both alone.
insert('legacy-dup-lower', 'dup@example.com');
insert('legacy-dup-mixed', 'Dup@Example.com');
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
expect(userRow('john@example.com').id).toBe('legacy-mixed');
expect(userRow('John@Example.com')).toBeUndefined();
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
// ...and the lowercased row is now reachable by Google sign-in.
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
expect(res.user.id).toBe('legacy-mixed');
}, 120_000);
});
+58
View File
@@ -209,6 +209,25 @@ export const auth = betterAuth({
// Google verifies email ownership, so linking by email is safe — this
// matches the legacy /api/auth/google auto-link behavior.
trustedProviders: ['google'],
// `trustedProviders` alone is NOT enough: better-auth ORs a second,
// independent gate — `requireLocalEmailVerified` (default true) — which
// refuses the link whenever the LOCAL users.email_verified is false.
// That is the state of every guest-booking user (routes/tickets.ts,
// routes/door.ts insert email_verified = false) and of every
// email/password signup made after the Better Auth migration, so Google
// sign-in failed for them with "account not linked".
//
// The local flag adds nothing here: the Google ID token is signature-
// verified against Google's JWKS with issuer/audience/max-age checks and
// carries its own `email_verified`, so Google — not our column — is what
// proves ownership of the address.
//
// NOTE: upstream marks this option deprecated ("the gate will become
// unconditional"). better-auth is pinned exactly at 1.6.25 in both
// workspaces, and betterAuth.integration.test.ts covers this path, so an
// upgrade that drops the option fails CI rather than silently locking
// ticket buyers out again.
requireLocalEmailVerified: false,
},
},
@@ -246,6 +265,45 @@ export const auth = betterAuth({
},
},
},
account: {
create: {
// Fires for both branches of the OAuth path: createOAuthUser (new user)
// and linkAccount (existing user), since both go through the adapter's
// createWithHooks(..., 'account').
after: async (account) => {
if (account.providerId !== 'google') return;
// Attaching a Google account proves ownership of the address, so a
// row created during guest booking is now a real, claimed account.
// Without this, getAuthUser() (lib/auth.ts) rejects the brand-new
// session because accountStatus is still 'unclaimed' — the user gets
// a cookie and still looks logged out. Mirrors the tail of the
// magic-link claim flow in routes/authExt.ts.
//
// Scoped to 'unclaimed' in the WHERE clause so a suspended account is
// never silently reactivated by linking Google to it.
try {
await (db as any)
.update(authUsers)
// `emailVerified` is deliberately left alone: better-auth's link
// branch sets it right after this hook, but only when Google's
// id_token actually asserted email_verified.
.set({
isClaimed: true,
accountStatus: 'active',
updatedAt: new Date(),
})
.where(
and(
eq((authUsers as any).id, account.userId),
eq((authUsers as any).accountStatus, 'unclaimed')
)
);
} catch (err: any) {
console.error('[auth] Failed to claim account on Google link:', err?.message || err);
}
},
},
},
},
hooks: {
+8 -4
View File
@@ -54,9 +54,11 @@ export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
/**
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
* Pass `eventId` to restrict to one event (still returns a grouped row).
* Pass `eventId` to restrict to one event (still returns a grouped row), or an
* array of ids to restrict to those events (e.g. one page of a listing). Callers
* must skip the query for an empty array.
*/
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
export function eventSeatBreakdownQuery(executor: any, eventId?: string | string[]) {
const query = executor
.select({
eventId: (tickets as any).eventId,
@@ -65,6 +67,8 @@ export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
})
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
.groupBy((tickets as any).eventId);
const scoped = Array.isArray(eventId)
? query.where(inArray((tickets as any).eventId, eventId))
: eventId ? query.where(eq((tickets as any).eventId, eventId)) : query;
return scoped.groupBy((tickets as any).eventId);
}
+89
View File
@@ -0,0 +1,89 @@
// Door payment tenders.
//
// The door check-in screen offers five tenders. Each maps onto an
// existing payments.provider so the rest of the app (capacity, sweeps, admin
// payment lists, receipts) keeps working unchanged, while payments.method
// records which tender was actually used for the end-of-night cash-up.
//
// Bitcoin currently maps to the 'lightning' provider but records the payment as
// already made — the same trust model as cash, no invoice generated. When a real
// Lightning flow lands it slots in here: the tender keeps its name and provider,
// only the settlement path in routes/door.ts changes.
//
// POS is the physical card terminal. Staff open the POS step, which shows the
// amount to key into the terminal, charge the card, then confirm "Mark as paid";
// only that confirmation reaches this API, recorded like any other tender. A
// future automatic amount push to the terminal belongs in the POS step itself
// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to
// the terminal today.
import { eq } from 'drizzle-orm';
import { db, dbGet, paymentOptions, eventPaymentOverrides } from '../db/index.js';
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
interface DoorTender {
/** Existing payments.provider this tender is stored as. */
provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos';
/** Human label used in payment references and toasts. */
label: string;
/** Comp tenders carry no revenue and always record a zero amount. */
isComp: boolean;
/**
* How the money is confirmed before the door screen records it:
* 'on_tap' staff already hold the money when they tap (cash, …)
* 'staff_confirm' staff charge an external device first, then confirm
*/
confirmation: 'on_tap' | 'staff_confirm';
}
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' },
guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' },
};
/**
* Tenders that can be switched off per event through payment options. Only POS
* is configurable: the other door tenders are always available to staff.
*/
export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] {
return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled);
}
/** Door tenders available for this event (POS can be switched off per event). */
export async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
const [globalOptions, overrides] = await Promise.all([
dbGet<any>((db as any).select().from(paymentOptions)),
dbGet<any>(
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
),
]);
// Override wins when set; POS defaults to on when nothing is configured.
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
}
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
}
/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */
export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' {
return DOOR_TENDERS[method].isComp ? 'comp' : 'paid';
}
/** Amount actually recorded: comps are always zero regardless of what was requested. */
export function amountForMethod(method: DoorPaymentMethod, requested: number): number {
return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested);
}
export function doorReference(method: DoorPaymentMethod): string {
return DOOR_TENDERS[method].isComp
? 'Door — guest (comp)'
: `Door — paid by ${DOOR_TENDERS[method].label}`;
}
+2 -2
View File
@@ -74,8 +74,8 @@ export async function sendPaymentReceipt(paymentId: string): Promise<{ success:
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago', pos: 'POS' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
};
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
+234
View File
@@ -0,0 +1,234 @@
// Per-event access control.
//
// Global roles still work exactly as before: every route passes the roles it
// always allowed (`globalRoles`). On top of that, a user linked to one event
// through event_members gets the permissions of their role preset, adjusted by
// per-member overrides — for that event only. A collaborator with role 'user'
// therefore reaches the routes of their own events and gets 403 everywhere
// else.
//
// Finance and team management are deliberately NOT part of any global role
// except admin: organizers only see them on events where an admin granted it.
import type { Context } from 'hono';
import { and, eq } from 'drizzle-orm';
import { db, dbGet, eventMembers, tickets, payments } from '../db/index.js';
import { getAuthUser, type AuthUser } from './auth.js';
export const EVENT_PERMISSIONS = [
'view_overview',
'check_in',
'view_attendees_names',
'view_attendees_pii',
'email_attendees',
'view_payments',
'view_finance',
'edit_expenses',
'edit_own_expenses_only',
'view_full_split',
'edit_event',
'manage_team',
] as const;
export type EventPermission = (typeof EVENT_PERMISSIONS)[number];
export const ROLE_PRESETS = ['staff', 'collaborator', 'co_manager'] as const;
export type RolePreset = (typeof ROLE_PRESETS)[number];
export const PRESET_PERMISSIONS: Record<RolePreset, readonly EventPermission[]> = {
staff: ['view_overview', 'check_in', 'view_attendees_names'],
// Sees the event's P&L and only their own share, not the full split.
collaborator: ['view_overview', 'view_finance'],
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
};
/**
* What a global role can already do on every event, mirroring the existing
* route allowlists. Used for the UI (which tabs to show) and for new routes.
*/
export const GLOBAL_ROLE_PERMISSIONS: Record<string, readonly EventPermission[]> = {
admin: EVENT_PERMISSIONS,
organizer: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments', 'edit_event'],
staff: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii'],
};
export function isEventPermission(key: string): key is EventPermission {
return (EVENT_PERMISSIONS as readonly string[]).includes(key);
}
export function parseOverrides(raw: unknown): Partial<Record<EventPermission, boolean>> {
let obj: any = raw;
if (typeof raw === 'string') {
try { obj = JSON.parse(raw); } catch { obj = {}; }
}
const out: Partial<Record<EventPermission, boolean>> = {};
if (obj && typeof obj === 'object') {
for (const [k, v] of Object.entries(obj)) {
if (isEventPermission(k) && typeof v === 'boolean') out[k] = v;
}
}
return out;
}
/** Preset permissions with the member's overrides applied in either direction. */
export function resolveMemberPermissions(preset: string, overrides: unknown): Set<EventPermission> {
const base = PRESET_PERMISSIONS[preset as RolePreset] || [];
const set = new Set<EventPermission>(base);
for (const [k, v] of Object.entries(parseOverrides(overrides))) {
if (v) set.add(k as EventPermission); else set.delete(k as EventPermission);
}
return set;
}
export interface EventAccess {
eventId: string | null;
/** True when the user's global role passed the route's allowlist. */
global: boolean;
role: string;
permissions: Set<EventPermission>;
membership: { id: string; rolePreset: RolePreset } | null;
}
export async function getMembership(userId: string, eventId: string) {
return dbGet<any>(
(db as any)
.select()
.from(eventMembers)
.where(and(eq((eventMembers as any).eventId, eventId), eq((eventMembers as any).userId, userId)))
);
}
/** Union of what the user's global role and their membership (if any) grant on this event. */
export async function getEffectivePermissions(user: Pick<AuthUser, 'id' | 'role'>, eventId: string): Promise<EventAccess> {
const permissions = new Set<EventPermission>(GLOBAL_ROLE_PERMISSIONS[user.role] || []);
const member = await getMembership(user.id, eventId);
if (member) {
for (const p of resolveMemberPermissions(member.rolePreset, member.permissions)) permissions.add(p);
}
return {
eventId,
global: user.role === 'admin',
role: user.role,
permissions,
membership: member ? { id: member.id, rolePreset: member.rolePreset } : null,
};
}
export function canUnfinalize(access: EventAccess): boolean {
return access.role === 'admin' || access.membership?.rolePreset === 'co_manager';
}
// ==================== Event id resolvers ====================
type EventIdResolver = (c: Context) => Promise<string | null> | string | null;
export const eventFromParam = (name = 'id'): EventIdResolver => (c) => c.req.param(name) || null;
export const eventFromQuery = (name = 'eventId'): EventIdResolver => (c) => c.req.query(name) || null;
/** Reads eventId from a JSON body. Hono caches the parsed body, so validators can read it again. */
export const eventFromBody = (name = 'eventId'): EventIdResolver => async (c) => {
try {
const body = await c.req.json();
return typeof body?.[name] === 'string' ? body[name] : null;
} catch {
return null;
}
};
/** Query string first, then the JSON body (routes that accept both). */
export const eventFromQueryOrBody = (name = 'eventId'): EventIdResolver => async (c) =>
eventFromQuery(name)(c) || (c.req.method === 'GET' ? null : await eventFromBody(name)(c));
export const eventFromTicketParam = (name = 'id'): EventIdResolver => async (c) => {
const id = c.req.param(name);
if (!id) return null;
const row = await dbGet<any>(
(db as any).select({ eventId: (tickets as any).eventId }).from(tickets).where(eq((tickets as any).id, id))
);
return row?.eventId ?? null;
};
export const eventFromPaymentParam = (name = 'id'): EventIdResolver => async (c) => {
const id = c.req.param(name);
if (!id) return null;
const row = await dbGet<any>(
(db as any)
.select({ eventId: (tickets as any).eventId })
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(eq((payments as any).id, id))
);
return row?.eventId ?? null;
};
// ==================== Middleware ====================
export interface RequireEventPermissionOptions {
/** Global roles that pass on every event (the route's existing allowlist). Default: admin only. */
globalRoles?: readonly string[];
/** Where the event id comes from. Default: the :id path param. */
eventId?: EventIdResolver;
}
/**
* Allow the request when the user's global role is in `globalRoles`, or when
* their membership on the resolved event grants any of `keys`. Sets
* c.get('user') like requireAuth, and c.get('eventAccess') for handlers that
* shape their response (e.g. hide attendee contact details).
*/
export function requireEventPermission(
keys: EventPermission | readonly EventPermission[],
opts: RequireEventPermissionOptions = {},
) {
const wanted = (Array.isArray(keys) ? keys : [keys]) as readonly EventPermission[];
const globalRoles = opts.globalRoles || ['admin'];
const resolve = opts.eventId || eventFromParam('id');
return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
c.set('user', user);
if (globalRoles.includes(user.role)) {
const eventId = await resolve(c);
c.set('eventAccess', {
eventId,
global: true,
role: user.role,
permissions: new Set(GLOBAL_ROLE_PERMISSIONS[user.role] || EVENT_PERMISSIONS),
membership: null,
} satisfies EventAccess);
await next();
return;
}
const eventId = await resolve(c);
if (!eventId) {
return c.json({ error: 'Forbidden' }, 403);
}
const access = await getEffectivePermissions(user, eventId);
if (!wanted.some((k) => access.permissions.has(k))) {
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: wanted[0] }, 403);
}
c.set('eventAccess', access);
await next();
};
}
export function getEventAccess(c: Context): EventAccess | null {
return ((c as any).get('eventAccess') as EventAccess | undefined) || null;
}
/** True when the request may see attendee contact details (email, phone, RUC). */
export function canSeeAttendeePii(c: Context): boolean {
const access = getEventAccess(c);
return !access || access.global || access.permissions.has('view_attendees_pii');
}
/** Drop attendee contact details for members without view_attendees_pii. */
export function redactAttendee<T extends Record<string, any>>(t: T): T {
const { attendeeEmail, attendeePhone, attendeeRuc, ...rest } = t as any;
return { ...rest, attendeeEmail: null, attendeePhone: null, attendeeRuc: null } as T;
}
+32
View File
@@ -0,0 +1,32 @@
// finance_audit_log rows. Returned as TxOps so every change and its audit row
// commit (or roll back) together.
import { financeAuditLog } from '../../db/index.js';
import { generateId, getNow } from '../utils.js';
import { insertOp, type TxOp } from '../txOps.js';
export type FinanceEntity =
| 'expense' | 'other_income' | 'partner' | 'finance_state' | 'member'
| 'expense_category' | 'expense_template' | 'expense_template_pack' | 'payment_fee';
export function financeAuditOp(entry: {
eventId: string | null;
actorUserId: string;
entityType: FinanceEntity;
entityId: string | null;
action: string;
before?: unknown;
after?: unknown;
}): TxOp {
return insertOp(financeAuditLog, {
id: generateId(),
eventId: entry.eventId,
actorUserId: entry.actorUserId,
entityType: entry.entityType,
entityId: entry.entityId,
action: entry.action,
beforeJson: entry.before === undefined || entry.before === null ? null : JSON.stringify(entry.before),
afterJson: entry.after === undefined || entry.after === null ? null : JSON.stringify(entry.after),
createdAt: getNow(),
});
}
+397
View File
@@ -0,0 +1,397 @@
import { describe, it, expect } from 'vitest';
import {
calculateEventFinance,
expenseAmount,
paymentFee,
roundPyg,
type FinanceExpense,
type FinanceInput,
type FinancePartner,
type FinancePayment,
} from './calculate.js';
let seq = 0;
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
});
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
});
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
lossRule: 'none', lossCapAmount: 0, ...over,
});
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
});
const sumShares = (r: ReturnType<typeof calculateEventFinance>) =>
r.split.partners.reduce((s, p) => s + p.share, 0) + r.split.organization;
describe('calculateEventFinance: profit case', () => {
const studio = partner({ name: 'Studio', percentBp: 3000 });
const r = calculateEventFinance(input({
ticketsSold: 20,
checkedIn: 18,
payments: [
...Array.from({ length: 15 }, () => pay(100000)),
...Array.from({ length: 3 }, () => pay(100000, { provider: 'lightning' })),
pay(120000, { provider: 'cash', source: 'door', paidAt: '2026-09-05T20:00:00Z' }),
pay(120000, { provider: 'pos', source: 'door', paidAt: '2026-09-05T20:10:00Z' }),
],
fees: [
{ method: 'tpago', percentBp: 350, fixedAmount: 0 }, // 3.5%
{ method: 'pos', percentBp: 290, fixedAmount: 500 },
],
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 150000 }],
expenses: [expense({ unitAmount: 800000, status: 'paid' }), expense({ calcType: 'per_checked_in', unitAmount: 15000 })],
partners: [studio],
}));
it('splits gross into pre-sale and door and by method', () => {
expect(r.revenue.gross).toBe(2040000);
expect(r.revenue.presale).toBe(1800000);
expect(r.revenue.door).toBe(240000);
expect(r.revenue.byMethod.map((m) => [m.method, m.gross])).toEqual([
['tpago', 1500000], ['lightning', 300000], ['cash', 120000], ['pos', 120000],
]);
});
it('charges fees per payment from the method rules', () => {
// tpago: 15 x 3500; pos: 3480 + 500; lightning and cash have no rule
expect(r.revenue.fees).toBe(15 * 3500 + 3980);
expect(r.revenue.byMethod.find((m) => m.method === 'pos')!.fees).toBe(3980);
});
it('adds other income into net revenue and subtracts expenses', () => {
expect(r.revenue.net).toBe(2040000 - 56480 + 150000);
expect(r.expenses.total).toBe(800000 + 18 * 15000);
expect(r.expenses.paid).toBe(800000);
expect(r.expenses.planned).toBe(270000);
expect(r.profit).toBe(2133520 - 1070000);
});
it('gives the partner its percentage of profit and the organization the rest', () => {
expect(r.split.partners[0].share).toBe(roundPyg(1063520 * 0.3));
expect(r.split.organization).toBe(1063520 - roundPyg(1063520 * 0.3));
expect(sumShares(r)).toBe(r.profit);
});
it('builds a cumulative sales timeline and a waterfall ending in the split', () => {
expect(r.salesTimeline).toEqual([
{ date: '2026-09-01', tickets: 18, revenue: 1800000 },
{ date: '2026-09-05', tickets: 20, revenue: 2040000 },
]);
expect(r.waterfall.map((w) => w.key)).toEqual([
'gross', 'refunds', 'fees', 'otherIncome', 'net', 'expenses', 'profit', `partner:${studio.id}`, 'organization',
]);
});
});
describe('calculateEventFinance: refunds', () => {
it('subtracts refunded payments and charges no fee on them', () => {
const r = calculateEventFinance(input({
payments: [pay(100000), pay(100000), pay(100000, { status: 'refunded' })],
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
}));
expect(r.revenue.gross).toBe(300000);
expect(r.revenue.refunds).toBe(100000);
expect(r.revenue.sales).toBe(200000);
expect(r.revenue.fees).toBe(20000);
expect(r.revenue.net).toBe(180000);
expect(r.salesTimeline.at(-1)).toEqual({ date: '2026-09-01', tickets: 2, revenue: 200000 });
});
it('uses sales after refunds as the base for revenue percentages', () => {
const r = calculateEventFinance(input({
payments: [pay(100000), pay(100000, { status: 'refunded' })],
expenses: [expense({ calcType: 'percent_of_revenue', percentBp: 1000 })],
}));
expect(r.expenses.total).toBe(10000);
});
});
describe('expense calc types', () => {
const ctx = { ticketsSold: 30, checkedIn: 25, sales: 3000000 };
it('fixed multiplies units by the unit amount', () => {
expect(expenseAmount(expense({ quantity: 3, unitAmount: 50000 }), ctx)).toEqual({ quantity: 3, amount: 150000 });
});
it('per_ticket_sold follows tickets sold', () => {
expect(expenseAmount(expense({ calcType: 'per_ticket_sold', unitAmount: 10000 }), ctx)).toEqual({ quantity: 30, amount: 300000 });
});
it('per_checked_in follows check-ins', () => {
expect(expenseAmount(expense({ calcType: 'per_checked_in', unitAmount: 10000 }), ctx)).toEqual({ quantity: 25, amount: 250000 });
});
it('percent_of_revenue takes basis points of sales, rounded to the guaraní', () => {
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 1250 }), ctx).amount).toBe(375000);
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 333 }), { ...ctx, sales: 1001 }).amount).toBe(33);
});
it('minimum_spend charges the minimum until per-head spend passes it', () => {
const e = expense({ calcType: 'minimum_spend', unitAmount: 40000, minimumAmount: 1500000 });
expect(expenseAmount(e, { ...ctx, checkedIn: 20 }).amount).toBe(1500000); // 800k < minimum
expect(expenseAmount(e, { ...ctx, checkedIn: 50 }).amount).toBe(2000000); // 2.0M > minimum
expect(expenseAmount(e, { ...ctx, checkedIn: 0 }).amount).toBe(1500000);
});
it('locked rows keep their stored amount regardless of counts', () => {
const e = expense({ calcType: 'per_checked_in', unitAmount: 10000, isLocked: true, computedAmount: 123000, quantity: 12 });
expect(expenseAmount(e, ctx)).toEqual({ quantity: 12, amount: 123000 });
const r = calculateEventFinance(input({ checkedIn: 99, expenses: [e] }));
expect(r.expenses.lines[0]).toMatchObject({ amount: 123000, auto: false });
});
it('marks unlocked non-fixed rows as auto-calculated', () => {
const r = calculateEventFinance(input({ expenses: [expense(), expense({ calcType: 'per_ticket_sold' })] }));
expect(r.expenses.lines.map((l) => l.auto)).toEqual([false, true]);
});
});
describe('calculateEventFinance: loss rules', () => {
// Net revenue 500k, expenses 1.5M => profit -1,000,000
const lossInput = (p: FinancePartner) => input({
payments: [pay(500000)],
expenses: [expense({ unitAmount: 1500000 })],
partners: [p],
});
it('proportional: the partner carries its percentage of the loss', () => {
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'proportional' })));
expect(r.profit).toBe(-1000000);
expect(r.split.partners[0].share).toBe(-400000);
expect(r.split.organization).toBe(-600000);
expect(sumShares(r)).toBe(r.profit);
});
it('none: the organization carries the whole loss', () => {
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'none' })));
expect(r.split.partners[0].share).toBe(0);
expect(r.split.organization).toBe(-1000000);
});
it('capped: the partner carries its percentage up to the cap', () => {
const capped = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 250000 })));
expect(capped.split.partners[0].share).toBe(-250000);
expect(capped.split.organization).toBe(-750000);
// A cap larger than the proportional share does not increase it
const loose = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 900000 })));
expect(loose.split.partners[0].share).toBe(-400000);
});
it('loss rules do not change a profitable split', () => {
const r = calculateEventFinance(input({
payments: [pay(1000000)],
partners: [partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 1 })],
}));
expect(r.split.partners[0].share).toBe(400000);
});
});
describe('calculateEventFinance: reimbursements', () => {
it('adds paid costs a partner fronted to their payout without counting them twice', () => {
const host = partner({ name: 'Host', percentBp: 5000 });
const r = calculateEventFinance(input({
payments: [pay(1000000)],
expenses: [
expense({ unitAmount: 200000, status: 'paid', paidByPartnerId: host.id }),
expense({ unitAmount: 100000, status: 'planned', paidByPartnerId: host.id }), // not fronted yet
expense({ unitAmount: 100000, status: 'paid' }),
],
partners: [host],
}));
expect(r.profit).toBe(600000);
const h = r.split.partners[0];
expect(h.reimbursement).toBe(200000);
expect(h.share).toBe(300000);
expect(h.payout).toBe(500000);
expect(r.split.organization).toBe(300000);
expect(sumShares(r)).toBe(r.profit);
});
it('nets a loss share against a reimbursement', () => {
const host = partner({ percentBp: 5000, lossRule: 'proportional' });
const r = calculateEventFinance(input({
payments: [pay(100000)],
expenses: [expense({ unitAmount: 300000, status: 'paid', paidByPartnerId: host.id })],
partners: [host],
}));
expect(r.profit).toBe(-200000);
expect(r.split.partners[0]).toMatchObject({ share: -100000, reimbursement: 300000, payout: 200000 });
});
});
describe('calculateEventFinance: partner share types', () => {
it('percent_revenue is paid on sales after refunds, before profit shares', () => {
const venue = partner({ name: 'Venue', shareType: 'percent_revenue', percentBp: 2000 });
const cohost = partner({ name: 'Co-host', shareType: 'percent_profit', percentBp: 5000 });
const r = calculateEventFinance(input({
payments: [pay(600000), pay(400000), pay(100000, { status: 'refunded' })],
expenses: [expense({ unitAmount: 300000 })],
partners: [venue, cohost],
}));
expect(r.profit).toBe(700000);
expect(r.split.partners[0].share).toBe(200000); // 20% of 1,000,000
expect(r.split.distributable).toBe(500000);
expect(r.split.partners[1].share).toBe(250000);
expect(r.split.organization).toBe(250000);
expect(sumShares(r)).toBe(r.profit);
});
it('percent_revenue is still owed when the event loses money', () => {
const r = calculateEventFinance(input({
payments: [pay(500000)],
expenses: [expense({ unitAmount: 800000 })],
partners: [partner({ shareType: 'percent_revenue', percentBp: 1000 })],
}));
expect(r.split.partners[0].share).toBe(50000);
expect(r.split.organization).toBe(-350000);
});
it('fixed is owed regardless of profit', () => {
const r = calculateEventFinance(input({ partners: [partner({ shareType: 'fixed', fixedAmount: 300000 })] }));
expect(r.split.partners[0].share).toBe(300000);
expect(r.split.organization).toBe(-300000);
});
it('fixed_plus_percent_above_threshold pays the fixed part plus a percentage above the threshold', () => {
const p = partner({ shareType: 'fixed_plus_percent_above_threshold', fixedAmount: 100000, percentBp: 1000, thresholdAmount: 500000 });
const high = calculateEventFinance(input({ payments: [pay(1600000)], partners: [p] }));
// distributable = 1.6M - 100k fixed = 1.5M; 10% of (1.5M - 500k) = 100k
expect(high.split.partners[0].share).toBe(200000);
expect(sumShares(high)).toBe(high.profit);
const low = calculateEventFinance(input({ payments: [pay(400000)], partners: [p] }));
expect(low.split.partners[0].share).toBe(100000);
});
});
describe('calculateEventFinance: break-even', () => {
it('finds the smallest ticket count that covers all expenses', () => {
const r = calculateEventFinance(input({
ticketPrice: 100000,
ticketsSold: 4,
expenses: [expense({ unitAmount: 1000000 }), expense({ calcType: 'per_ticket_sold', unitAmount: 20000 })],
}));
// 80k contribution per ticket => ceil(1,000,000 / 80,000) = 13
expect(r.breakEven).toEqual({ tickets: 13, ticketPrice: 100000, remaining: 9 });
});
it('accounts for minimum spend, other income and fees', () => {
const r = calculateEventFinance(input({
ticketPrice: 100000,
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
otherIncome: [{ id: 'i', description: 's', amount: 90000 }],
expenses: [expense({ calcType: 'minimum_spend', unitAmount: 30000, minimumAmount: 900000 })],
}));
// 90k net per ticket, 90k income: 9 tickets => 810k + 90k = 900k = minimum
expect(r.breakEven.tickets).toBe(9);
});
it('is null when a ticket cannot cover its own variable cost, or the price is 0', () => {
expect(calculateEventFinance(input({ expenses: [expense({ calcType: 'per_ticket_sold', unitAmount: 150000 }), expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
expect(calculateEventFinance(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
});
it('is 0 when there is nothing to cover', () => {
expect(calculateEventFinance(input()).breakEven.tickets).toBe(0);
});
});
describe('paymentFee', () => {
it('adds the fixed part and skips free payments', () => {
expect(paymentFee(100000, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(3400);
expect(paymentFee(0, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(0);
expect(paymentFee(100000, undefined)).toBe(0);
});
});
// Regression scenarios pinned while reworking the Finance tab UI: the UI now
// derives its waterfall, break-even and lifecycle from these results, so the
// numbers themselves must not move.
describe('calculateEventFinance: pinned scenarios', () => {
const tpagoFee = { method: 'tpago', percentBp: 290, fixedAmount: 0 };
it('no expenses: profit is revenue after fees and break-even is 0', () => {
const r = calculateEventFinance(input({
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
fees: [tpagoFee],
}));
expect(r.revenue).toMatchObject({ gross: 200000, presale: 200000, door: 0, fees: 5800, sales: 200000, net: 194200 });
expect(r.expenses.total).toBe(0);
expect(r.profit).toBe(194200);
expect(r.breakEven).toEqual({ tickets: 0, ticketPrice: 50000, remaining: 0 });
expect(r.split).toEqual({ distributable: 194200, partners: [], organization: 194200 });
});
it('a loss: planned and paid costs above revenue, break-even beyond sales', () => {
const r = calculateEventFinance(input({
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
fees: [tpagoFee],
expenses: [
expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000, status: 'paid' }),
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
],
}));
expect(r.revenue).toMatchObject({ gross: 200000, presale: 150000, door: 50000, fees: 4350, net: 195650 });
expect(r.expenses).toMatchObject({ total: 340000, paid: 300000, planned: 40000 });
expect(r.profit).toBe(-144350);
// 218 bp fee mix: each extra ticket adds 50000 - 1090 - 10000 = 38910 against 300000 fixed.
expect(r.breakEven).toEqual({ tickets: 8, ticketPrice: 50000, remaining: 4 });
expect(r.split.organization).toBe(-144350);
});
it('partners: fixed deal first, then percent of what is left, reimbursement on top', () => {
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
const venue = partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 });
const r = calculateEventFinance(input({
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
expenses: [
expense({ calcType: 'fixed', quantity: 1, unitAmount: 200000, status: 'paid' }),
expense({ calcType: 'fixed', quantity: 1, unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
],
partners: [ana, venue],
}));
expect(r.revenue).toMatchObject({ gross: 1000000, fees: 0, otherIncome: 50000, net: 1050000 });
expect(r.profit).toBe(700000);
expect(r.split.distributable).toBe(600000);
const byName = Object.fromEntries(r.split.partners.map((p) => [p.name, p]));
expect(byName.Ana).toMatchObject({ share: 180000, reimbursement: 150000, payout: 330000 });
expect(byName['Venue Co']).toMatchObject({ share: 100000, reimbursement: 0, payout: 100000 });
expect(r.split.organization).toBe(420000);
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
});
});
describe('calculateEventFinance: break-even edges', () => {
it('lands exactly on the ticket where profit reaches 0', () => {
const r = calculateEventFinance(input({
ticketPrice: 100000, ticketsSold: 1,
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
}));
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 2 });
});
it('reports 0 remaining once sales pass break-even', () => {
const r = calculateEventFinance(input({
ticketPrice: 100000, ticketsSold: 5,
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 250000 })],
}));
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
});
it('other income can cover costs before any ticket', () => {
const r = calculateEventFinance(input({
ticketPrice: 100000,
otherIncome: [{ id: 'i', description: 'Sponsor', amount: 500000 }],
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
}));
expect(r.breakEven.tickets).toBe(0);
});
});
+395
View File
@@ -0,0 +1,395 @@
/**
* Event P&L: a pure function from an event's money facts to the numbers the
* Finance tab, the partner statements and the finalize snapshot show.
*
* Everything is whole PYG. Percentages arrive in basis points (290 = 2.90%)
* and every percentage product is rounded to the nearest guaraní once, at the
* line it applies to, so totals always equal the sum of the lines shown.
*
* The waterfall:
* gross (paid + later-refunded payments)
* - refunds
* - payment fees (per payment, from payment_method_fees by provider)
* + other income
* = net revenue
* - expenses (planned and paid; auto rows follow the current counts unless locked)
* = profit / loss
* then the split:
* 1. fixed amounts and revenue shares are owed regardless of profit, so they
* come off first and leave the distributable profit;
* 2. profit shares are taken from the distributable profit, applying each
* partner's loss rule when it is negative;
* 3. the organization keeps the remainder.
* Reimbursements for costs a partner fronted are already inside expenses (so
* they are not deducted twice); they are added to that partner's payout.
*/
export const CALC_TYPES = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'] as const;
export type CalcType = (typeof CALC_TYPES)[number];
export const SHARE_TYPES = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'] as const;
export type ShareType = (typeof SHARE_TYPES)[number];
export const LOSS_RULES = ['proportional', 'none', 'capped'] as const;
export type LossRule = (typeof LOSS_RULES)[number];
export interface FinancePayment {
id: string;
/** PYG recorded at payment time (Lightning included). */
amount: number;
/** payments.provider: tpago | bank_transfer | lightning | cash | pos | bancard */
provider: string;
source: 'presale' | 'door';
status: 'paid' | 'refunded';
paidAt: string | null;
}
export interface FinanceExpense {
id: string;
description: string;
categoryId: string | null;
calcType: CalcType;
quantity: number;
unitAmount: number;
percentBp: number;
minimumAmount: number;
/** Stored amount; authoritative for locked rows. */
computedAmount: number;
isLocked: boolean;
status: 'planned' | 'paid';
/** null = paid by the organization. */
paidByPartnerId: string | null;
}
export interface FinanceOtherIncome {
id: string;
description: string;
amount: number;
}
export interface FeeRule {
method: string;
percentBp: number;
fixedAmount: number;
}
export interface FinancePartner {
id: string;
name: string;
shareType: ShareType;
percentBp: number;
fixedAmount: number;
thresholdAmount: number;
lossRule: LossRule;
lossCapAmount: number;
}
export interface FinanceInput {
/** Current online ticket price, used for break-even. */
ticketPrice: number;
/** Paid, non-cancelled tickets (comps excluded). */
ticketsSold: number;
/** Checked-in tickets, comps included (they still eat and drink). */
checkedIn: number;
payments: FinancePayment[];
expenses: FinanceExpense[];
otherIncome: FinanceOtherIncome[];
fees: FeeRule[];
partners: FinancePartner[];
}
export interface ExpenseLine {
id: string;
/** Count the amount was derived from (units, tickets or check-ins). */
quantity: number;
amount: number;
/** True when the amount follows ticket counts / revenue. */
auto: boolean;
}
export interface MethodTotals {
method: string;
count: number;
gross: number;
refunds: number;
fees: number;
net: number;
}
export interface PartnerResult {
partnerId: string;
name: string;
shareType: ShareType;
/** What the base amount was for the percentage part (profit or revenue). */
basis: number;
/** Positive = paid to the partner; negative = the partner carries part of a loss. */
share: number;
reimbursement: number;
/** share + reimbursement. Negative means the partner owes the organization. */
payout: number;
}
export interface FinanceResult {
counts: { ticketsSold: number; checkedIn: number; payments: number };
revenue: {
gross: number;
presale: number;
door: number;
refunds: number;
fees: number;
otherIncome: number;
/** gross - refunds: the base for revenue percentages. */
sales: number;
net: number;
byMethod: MethodTotals[];
};
expenses: {
lines: ExpenseLine[];
total: number;
planned: number;
paid: number;
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
};
profit: number;
breakEven: {
/** Tickets needed at ticketPrice for profit >= 0; null when unreachable. */
tickets: number | null;
ticketPrice: number;
/** Tickets still needed beyond those already sold. */
remaining: number | null;
};
split: {
/** Profit left after fixed amounts and revenue shares. */
distributable: number;
partners: PartnerResult[];
organization: number;
};
waterfall: { key: string; label?: string; amount: number }[];
/** Cumulative paid sales per day (YYYY-MM-DD, UTC). */
salesTimeline: { date: string; tickets: number; revenue: number }[];
}
/** Round half away from zero so a loss and a profit of the same size split symmetrically. */
export function roundPyg(value: number): number {
return Math.sign(value) * Math.round(Math.abs(value));
}
export function applyBp(base: number, bp: number): number {
return roundPyg((base * bp) / 10000);
}
export function paymentFee(amount: number, rule: FeeRule | undefined): number {
if (!rule || amount <= 0) return 0;
return applyBp(amount, rule.percentBp) + rule.fixedAmount;
}
/** Amount for one expense row at the given counts. Locked rows keep their stored amount. */
export function expenseAmount(
e: Pick<FinanceExpense, 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' | 'isLocked'>,
ctx: { ticketsSold: number; checkedIn: number; sales: number },
): { quantity: number; amount: number } {
if (e.isLocked) return { quantity: e.quantity, amount: e.computedAmount };
switch (e.calcType) {
case 'per_ticket_sold':
return { quantity: ctx.ticketsSold, amount: e.unitAmount * ctx.ticketsSold };
case 'per_checked_in':
return { quantity: ctx.checkedIn, amount: e.unitAmount * ctx.checkedIn };
case 'percent_of_revenue':
return { quantity: 1, amount: applyBp(Math.max(0, ctx.sales), e.percentBp) };
case 'minimum_spend':
return { quantity: ctx.checkedIn, amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn) };
case 'fixed':
default:
return { quantity: e.quantity, amount: e.unitAmount * e.quantity };
}
}
export function isAutoCalc(calcType: CalcType): boolean {
return calcType !== 'fixed';
}
function computePartners(profit: number, sales: number, partners: FinancePartner[], reimbursements: Map<string, number>) {
// Pass 1: amounts owed regardless of profit.
const upfront = new Map<string, number>();
for (const p of partners) {
let owed = 0;
if (p.shareType === 'fixed' || p.shareType === 'fixed_plus_percent_above_threshold') owed = p.fixedAmount;
else if (p.shareType === 'percent_revenue') owed = applyBp(Math.max(0, sales), p.percentBp);
upfront.set(p.id, owed);
}
const distributable = profit - [...upfront.values()].reduce((a, b) => a + b, 0);
// Pass 2: profit-based parts, taken from the distributable profit.
let profitParts = 0;
const results: PartnerResult[] = partners.map((p) => {
let basis = p.shareType === 'percent_revenue' ? sales : distributable;
let profitPart = 0;
if (p.shareType === 'percent_profit') {
const raw = applyBp(distributable, p.percentBp);
if (distributable >= 0 || p.lossRule === 'proportional') profitPart = raw;
else if (p.lossRule === 'capped') profitPart = Math.max(raw, -Math.abs(p.lossCapAmount));
} else if (p.shareType === 'fixed_plus_percent_above_threshold') {
basis = Math.max(0, distributable - p.thresholdAmount);
profitPart = applyBp(basis, p.percentBp);
}
profitParts += profitPart;
const share = (upfront.get(p.id) || 0) + profitPart;
const reimbursement = reimbursements.get(p.id) || 0;
return { partnerId: p.id, name: p.name, shareType: p.shareType, basis, share, reimbursement, payout: share + reimbursement };
});
return { distributable, partners: results, organization: distributable - profitParts };
}
/** Profit at a hypothetical ticket count, for break-even. Assumes every sold ticket checks in. */
function projectedProfit(n: number, input: FinanceInput, feeRate: { bp: number; fixed: number }, otherIncome: number): number {
const sales = n * input.ticketPrice;
const fees = n > 0 && input.ticketPrice > 0 ? n * (applyBp(input.ticketPrice, feeRate.bp) + feeRate.fixed) : 0;
const ctx = { ticketsSold: n, checkedIn: n, sales };
const expenses = input.expenses.reduce((sum, e) => sum + expenseAmount(e, ctx).amount, 0);
return sales - fees + otherIncome - expenses;
}
const BREAK_EVEN_SEARCH_LIMIT = 100000;
export function calculateEventFinance(input: FinanceInput): FinanceResult {
const feeByMethod = new Map(input.fees.map((f) => [f.method, f]));
// ---- Revenue
const methods = new Map<string, MethodTotals>();
let gross = 0, presale = 0, door = 0, refunds = 0, fees = 0;
for (const p of input.payments) {
const m = methods.get(p.provider) || { method: p.provider, count: 0, gross: 0, refunds: 0, fees: 0, net: 0 };
const fee = p.status === 'paid' ? paymentFee(p.amount, feeByMethod.get(p.provider)) : 0;
m.count += 1;
m.gross += p.amount;
gross += p.amount;
if (p.source === 'door') door += p.amount; else presale += p.amount;
if (p.status === 'refunded') {
// Refunds are whole-payment; the processor fee on a refunded payment is
// not tracked, so it is treated as returned too.
m.refunds += p.amount;
refunds += p.amount;
}
m.fees += fee;
fees += fee;
m.net = m.gross - m.refunds - m.fees;
methods.set(p.provider, m);
}
const otherIncome = input.otherIncome.reduce((sum, i) => sum + i.amount, 0);
const sales = gross - refunds;
const net = sales - fees + otherIncome;
// ---- Expenses
const ctx = { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, sales };
const lines: ExpenseLine[] = [];
const byCategory = new Map<string | null, { categoryId: string | null; planned: number; paid: number; total: number }>();
const reimbursements = new Map<string, number>();
let planned = 0, paid = 0;
for (const e of input.expenses) {
const { quantity, amount } = expenseAmount(e, ctx);
lines.push({ id: e.id, quantity, amount, auto: isAutoCalc(e.calcType) && !e.isLocked });
const cat = byCategory.get(e.categoryId) || { categoryId: e.categoryId, planned: 0, paid: 0, total: 0 };
if (e.status === 'paid') {
paid += amount;
cat.paid += amount;
if (e.paidByPartnerId) reimbursements.set(e.paidByPartnerId, (reimbursements.get(e.paidByPartnerId) || 0) + amount);
} else {
planned += amount;
cat.planned += amount;
}
cat.total += amount;
byCategory.set(e.categoryId, cat);
}
const expenseTotal = planned + paid;
const profit = net - expenseTotal;
// ---- Break-even at the current price, using the fee mix seen so far
// (or the most expensive configured method before any sales).
const paidPayments = input.payments.filter((p) => p.status === 'paid' && p.amount > 0);
const feeRate = paidPayments.length > 0 && gross > 0
? { bp: Math.round((fees / Math.max(1, sales)) * 10000), fixed: 0 }
: input.fees.reduce((worst, f) => (f.percentBp > worst.bp ? { bp: f.percentBp, fixed: f.fixedAmount } : worst), { bp: 0, fixed: 0 });
const profitAt = (n: number) => projectedProfit(n, input, feeRate, otherIncome);
let breakEvenTickets: number | null = null;
if (input.ticketPrice > 0) {
// Profit is non-decreasing in n for every calc type, so a doubling search
// followed by bisection finds the smallest n with profit >= 0.
if (profitAt(0) >= 0) {
breakEvenTickets = 0;
} else {
let lo = 0; // profitAt(lo) < 0
let hi = 1;
while (hi < BREAK_EVEN_SEARCH_LIMIT && profitAt(hi) < 0) {
lo = hi;
hi = Math.min(hi * 2, BREAK_EVEN_SEARCH_LIMIT);
}
if (profitAt(hi) >= 0) {
while (lo + 1 < hi) {
const mid = Math.floor((lo + hi) / 2);
if (profitAt(mid) >= 0) hi = mid; else lo = mid;
}
breakEvenTickets = hi;
}
}
}
// ---- Split
const split = computePartners(profit, sales, input.partners, reimbursements);
// ---- Waterfall (signed amounts, in display order)
const waterfall: FinanceResult['waterfall'] = [
{ key: 'gross', amount: gross },
{ key: 'refunds', amount: -refunds },
{ key: 'fees', amount: -fees },
{ key: 'otherIncome', amount: otherIncome },
{ key: 'net', amount: net },
{ key: 'expenses', amount: -expenseTotal },
{ key: 'profit', amount: profit },
...split.partners.map((p) => ({ key: `partner:${p.partnerId}`, label: p.name, amount: -p.share })),
{ key: 'organization', amount: split.organization },
];
// ---- Cumulative sales per day
const byDay = new Map<string, { tickets: number; revenue: number }>();
for (const p of input.payments) {
if (p.status !== 'paid' || !p.paidAt) continue;
const day = new Date(p.paidAt).toISOString().slice(0, 10);
const d = byDay.get(day) || { tickets: 0, revenue: 0 };
d.tickets += 1;
d.revenue += p.amount;
byDay.set(day, d);
}
let runTickets = 0, runRevenue = 0;
const salesTimeline = [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => {
runTickets += d.tickets;
runRevenue += d.revenue;
return { date, tickets: runTickets, revenue: runRevenue };
});
return {
counts: { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, payments: input.payments.length },
revenue: {
gross, presale, door, refunds, fees, otherIncome, sales, net,
byMethod: [...methods.values()].sort((a, b) => b.gross - a.gross),
},
expenses: {
lines,
total: expenseTotal,
planned,
paid,
byCategory: [...byCategory.values()].sort((a, b) => b.total - a.total),
},
profit,
breakEven: {
tickets: breakEvenTickets,
ticketPrice: input.ticketPrice,
remaining: breakEvenTickets === null ? null : Math.max(0, breakEvenTickets - input.ticketsSold),
},
split,
waterfall,
salesTimeline,
};
}
+245
View File
@@ -0,0 +1,245 @@
// Reads an event's money facts from the database and runs calculateEventFinance.
// Once an event is finalized, the frozen snapshot is returned instead so the
// numbers partners were paid on never drift with later ticket changes.
import { and, eq, inArray, sql } from 'drizzle-orm';
import {
db, dbAll, dbGet, events, tickets, payments, eventExpenses, eventOtherIncome, eventPartners,
paymentMethodFees, eventFinanceState, users,
} from '../../db/index.js';
import { calculateEventFinance, type FinanceInput, type FinanceResult, type CalcType, type ShareType, type LossRule } from './calculate.js';
export const num = (v: any): number => {
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
return Number.isFinite(n) ? n : 0;
};
export const pyg = (v: any): number => Math.round(num(v));
export const iso = (v: any): string | null => {
if (!v) return null;
const d = v instanceof Date ? v : new Date(v);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
};
export const bool = (v: any): boolean => v === true || v === 1 || v === '1';
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
export interface FinanceSnapshot {
version: 1;
computedAt: string;
result: FinanceResult;
}
export function serializeExpense(e: any) {
return {
id: e.id,
eventId: e.eventId,
categoryId: e.categoryId ?? null,
templateId: e.templateId ?? null,
description: e.description,
calcType: e.calcType as CalcType,
quantity: pyg(e.quantity),
unitAmount: pyg(e.unitAmount),
percentBp: pyg(e.percentBp),
minimumAmount: pyg(e.minimumAmount),
computedAmount: pyg(e.computedAmount),
isLocked: bool(e.isLocked),
status: e.status as 'planned' | 'paid',
paidByPartnerId: e.paidByPartnerId ?? null,
receiptUrl: e.receiptUrl ?? null,
expenseDate: iso(e.expenseDate),
createdBy: e.createdBy ?? null,
updatedBy: e.updatedBy ?? null,
createdAt: iso(e.createdAt),
updatedAt: iso(e.updatedAt),
};
}
export function serializePartner(p: any, userName?: string | null) {
return {
id: p.id,
eventId: p.eventId,
userId: p.userId ?? null,
externalName: p.externalName ?? null,
name: p.externalName || userName || 'Partner',
roleLabel: p.roleLabel ?? null,
shareType: p.shareType as ShareType,
percentBp: pyg(p.percentBp),
fixedAmount: pyg(p.fixedAmount),
thresholdAmount: pyg(p.thresholdAmount),
lossRule: p.lossRule as LossRule,
lossCapAmount: pyg(p.lossCapAmount),
payoutStatus: p.payoutStatus as 'pending' | 'paid',
payoutDate: iso(p.payoutDate),
payoutMethod: p.payoutMethod ?? null,
payoutNote: p.payoutNote ?? null,
createdAt: iso(p.createdAt),
updatedAt: iso(p.updatedAt),
};
}
export function serializeIncome(i: any) {
return {
id: i.id,
eventId: i.eventId,
description: i.description,
amount: pyg(i.amount),
createdBy: i.createdBy ?? null,
createdAt: iso(i.createdAt),
updatedAt: iso(i.updatedAt),
};
}
export type SerializedExpense = ReturnType<typeof serializeExpense>;
export type SerializedPartner = ReturnType<typeof serializePartner>;
export type SerializedIncome = ReturnType<typeof serializeIncome>;
export async function getFinanceState(eventId: string) {
const row = await dbGet<any>((db as any).select().from(eventFinanceState).where(eq((eventFinanceState as any).eventId, eventId)));
let snapshot: FinanceSnapshot | null = null;
if (row?.snapshotJson) {
try { snapshot = JSON.parse(row.snapshotJson); } catch { snapshot = null; }
}
return {
exists: !!row,
status: (row?.status || 'open') as FinanceStatus,
finalizedAt: iso(row?.finalizedAt),
finalizedBy: row?.finalizedBy ?? null,
snapshot,
};
}
export async function loadPartners(eventId: string): Promise<SerializedPartner[]> {
const rows = await dbAll<any>((db as any).select().from(eventPartners).where(eq((eventPartners as any).eventId, eventId)));
const userIds = [...new Set(rows.map((r: any) => r.userId).filter(Boolean))] as string[];
const names = new Map<string, string>();
if (userIds.length > 0) {
const us = await dbAll<any>(
(db as any).select({ id: (users as any).id, name: (users as any).name }).from(users).where(inArray((users as any).id, userIds))
);
for (const u of us) names.set(u.id, u.name);
}
return rows
.map((r: any) => serializePartner(r, r.userId ? names.get(r.userId) : null))
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
}
export async function loadExpenses(eventId: string): Promise<SerializedExpense[]> {
const rows = await dbAll<any>((db as any).select().from(eventExpenses).where(eq((eventExpenses as any).eventId, eventId)));
return rows
.map(serializeExpense)
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
}
export async function loadOtherIncome(eventId: string): Promise<SerializedIncome[]> {
const rows = await dbAll<any>((db as any).select().from(eventOtherIncome).where(eq((eventOtherIncome as any).eventId, eventId)));
return rows
.map(serializeIncome)
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
}
export async function loadFeeRules() {
const rows = await dbAll<any>((db as any).select().from(paymentMethodFees));
return rows.map((r: any) => ({
method: r.method as string,
percentBp: pyg(r.percentBp),
fixedAmount: pyg(r.fixedAmount),
updatedAt: iso(r.updatedAt),
}));
}
/** Ticket counts the auto-calculated expenses follow. */
export async function loadTicketCounts(eventId: string) {
const row = await dbGet<any>(
(db as any)
.select({
sold: sql<number>`sum(case when ${(tickets as any).status} in ('confirmed', 'checked_in') and ${(tickets as any).paymentStatus} = 'paid' then 1 else 0 end)`,
checkedIn: sql<number>`sum(case when ${(tickets as any).status} = 'checked_in' then 1 else 0 end)`,
})
.from(tickets)
.where(eq((tickets as any).eventId, eventId))
);
return { ticketsSold: Number(row?.sold || 0), checkedIn: Number(row?.checkedIn || 0) };
}
export async function buildFinanceInput(eventId: string, event: any) {
const [counts, payRows, expenses, otherIncome, partners, fees] = await Promise.all([
loadTicketCounts(eventId),
dbAll<any>(
(db as any)
.select({
id: (payments as any).id,
amount: (payments as any).amount,
provider: (payments as any).provider,
source: (payments as any).source,
status: (payments as any).status,
paidAt: (payments as any).paidAt,
createdAt: (payments as any).createdAt,
})
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
inArray((payments as any).status, ['paid', 'refunded'])
))
),
loadExpenses(eventId),
loadOtherIncome(eventId),
loadPartners(eventId),
loadFeeRules(),
]);
const input: FinanceInput = {
ticketPrice: pyg(event.price),
ticketsSold: counts.ticketsSold,
checkedIn: counts.checkedIn,
payments: payRows
.map((p: any) => ({
id: p.id,
amount: pyg(p.amount),
provider: p.provider,
source: p.source === 'door' ? 'door' as const : 'presale' as const,
status: p.status as 'paid' | 'refunded',
paidAt: iso(p.paidAt) || iso(p.createdAt),
}))
// Comps are recorded as 0 PYG payments; they are not sales.
.filter((p) => p.amount > 0),
expenses: expenses.map((e) => ({
id: e.id, description: e.description, categoryId: e.categoryId, calcType: e.calcType, quantity: e.quantity,
unitAmount: e.unitAmount, percentBp: e.percentBp, minimumAmount: e.minimumAmount, computedAmount: e.computedAmount,
isLocked: e.isLocked, status: e.status, paidByPartnerId: e.paidByPartnerId,
})),
otherIncome: otherIncome.map((i) => ({ id: i.id, description: i.description, amount: i.amount })),
fees,
partners: partners.map((p) => ({
id: p.id, name: p.name, shareType: p.shareType, percentBp: p.percentBp, fixedAmount: p.fixedAmount,
thresholdAmount: p.thresholdAmount, lossRule: p.lossRule, lossCapAmount: p.lossCapAmount,
})),
};
return { input, expenses, otherIncome, partners, counts };
}
export async function getEvent(eventId: string) {
return dbGet<any>((db as any).select().from(events).where(eq((events as any).id, eventId)));
}
/**
* The event's finance numbers plus the rows behind them. Uses the finalize
* snapshot when there is one; otherwise calculates live.
*/
export async function getEventFinance(eventId: string, event?: any) {
const ev = event || await getEvent(eventId);
if (!ev) return null;
const [state, built] = await Promise.all([getFinanceState(eventId), buildFinanceInput(eventId, ev)]);
const frozen = state.status !== 'open' && state.snapshot;
const result = frozen ? state.snapshot!.result : calculateEventFinance(built.input);
return {
event: ev,
state,
live: !frozen,
computedAt: frozen ? state.snapshot!.computedAt : new Date().toISOString(),
result,
expenses: built.expenses,
otherIncome: built.otherIncome,
partners: built.partners,
};
}
+215
View File
@@ -0,0 +1,215 @@
// Partner statement PDF: the event P&L summary, the partner's deal, what they
// fronted, and the resulting payout. Uses the ticket PDF's brand helpers.
import {
COLORS, PAGE_W, PAGE_H, MARGIN, CONTENT_W, ACCENT_H, FOOTER_H, LOGO_RATIO,
getLogo, drawLabel, drawDivider, createDoc, collect, siteUrl,
} from '../pdf.js';
import type { FinanceResult, PartnerResult } from './calculate.js';
import type { SerializedExpense, SerializedPartner } from './load.js';
export function formatPygAmount(amount: number): string {
const sign = amount < 0 ? '-' : '';
return `${sign}${Math.abs(Math.round(amount)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')} PYG`;
}
const pct = (bp: number) => `${(bp / 100).toLocaleString('es-PY', { maximumFractionDigits: 2 })}%`;
const STRINGS = {
en: {
title: 'Partner statement',
draft: 'DRAFT: the event is not finalized, numbers may still change.',
event: 'Event',
partner: 'Partner',
summary: 'Event summary',
gross: 'Gross ticket revenue',
refunds: 'Refunds',
fees: 'Payment fees',
otherIncome: 'Other income',
net: 'Net revenue',
expenses: 'Expenses',
profit: 'Profit / loss',
deal: 'Agreement',
share: 'Share',
reimbursements: 'Reimbursements (costs you paid)',
none: 'None',
payout: 'Total payout',
owes: 'Amount owed to the organization',
status: 'Payout status',
paid: 'Paid',
pending: 'Pending',
generated: 'Generated',
lossNote: { proportional: 'shares losses proportionally', none: 'does not share losses', capped: 'shares losses up to' },
shareTypes: {
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} of profit`,
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} of ticket revenue`,
fixed: (p: SerializedPartner) => `Fixed ${formatPygAmount(p.fixedAmount)}`,
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} of profit above ${formatPygAmount(p.thresholdAmount)}`,
},
},
es: {
title: 'Liquidación de socio',
draft: 'BORRADOR: el evento no está cerrado, los números pueden cambiar.',
event: 'Evento',
partner: 'Socio',
summary: 'Resumen del evento',
gross: 'Ingresos brutos por entradas',
refunds: 'Reembolsos',
fees: 'Comisiones de pago',
otherIncome: 'Otros ingresos',
net: 'Ingresos netos',
expenses: 'Gastos',
profit: 'Ganancia / pérdida',
deal: 'Acuerdo',
share: 'Participación',
reimbursements: 'Reembolsos (gastos que pagaste)',
none: 'Ninguno',
payout: 'Total a pagar',
owes: 'Monto adeudado a la organización',
status: 'Estado del pago',
paid: 'Pagado',
pending: 'Pendiente',
generated: 'Generado',
lossNote: { proportional: 'comparte pérdidas proporcionalmente', none: 'no comparte pérdidas', capped: 'comparte pérdidas hasta' },
shareTypes: {
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} de la ganancia`,
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} de los ingresos por entradas`,
fixed: (p: SerializedPartner) => `Fijo ${formatPygAmount(p.fixedAmount)}`,
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} de la ganancia sobre ${formatPygAmount(p.thresholdAmount)}`,
},
},
} as const;
export interface StatementData {
locale: 'en' | 'es';
event: { title: string; startDatetime: string | Date; location: string };
finalized: boolean;
timezone?: string;
result: FinanceResult;
partner: SerializedPartner;
line: PartnerResult | undefined;
frontedExpenses: SerializedExpense[];
/** Amount per expense id, from the same result. */
expenseAmounts: Map<string, number>;
}
export async function generatePartnerStatementPDF(data: StatementData): Promise<Buffer> {
const t = STRINGS[data.locale];
const doc = createDoc();
const done = collect(doc);
const tz = data.timezone || 'America/Asuncion';
const dateFmt = new Intl.DateTimeFormat(data.locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'long', timeZone: tz });
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
let y = MARGIN + 6;
const logo = getLogo();
if (logo) {
const w = 120;
doc.image(logo, MARGIN, y, { width: w });
y += w / LOGO_RATIO + 16;
} else {
doc.font('Helvetica-Bold').fontSize(18).fillColor(COLORS.navy).text('spanglish social', MARGIN, y);
y += 32;
}
doc.font('Helvetica-Bold').fontSize(22).fillColor(COLORS.navy).text(t.title, MARGIN, y, { width: CONTENT_W });
y += 32;
if (!data.finalized) {
doc.font('Helvetica-Bold').fontSize(9).fillColor(COLORS.orange).text(t.draft, MARGIN, y, { width: CONTENT_W });
y += 20;
}
const col = CONTENT_W / 2;
drawLabel(doc, t.event, y, col - 12);
drawLabel(doc, t.partner, y, col, MARGIN + col);
y += 14;
doc.font('Helvetica-Bold').fontSize(12).fillColor(COLORS.navy);
doc.text(data.event.title, MARGIN, y, { width: col - 12 });
doc.text(data.partner.name, MARGIN + col, y, { width: col });
y += 16;
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
doc.text(`${dateFmt.format(new Date(data.event.startDatetime))} · ${data.event.location}`, MARGIN, y, { width: col - 12 });
if (data.partner.roleLabel) doc.text(data.partner.roleLabel, MARGIN + col, y, { width: col });
y += 34;
const row = (label: string, amount: number, opts: { bold?: boolean } = {}) => {
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.bold ? 12 : 10.5).fillColor(COLORS.navy);
doc.text(label, MARGIN, y, { width: CONTENT_W - 160 });
doc.text(formatPygAmount(amount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
y += opts.bold ? 20 : 17;
};
drawLabel(doc, t.summary, y);
y += 16;
const r = data.result;
row(t.gross, r.revenue.gross);
if (r.revenue.refunds) row(t.refunds, -r.revenue.refunds);
row(t.fees, -r.revenue.fees);
if (r.revenue.otherIncome) row(t.otherIncome, r.revenue.otherIncome);
drawDivider(doc, y); y += 8;
row(t.net, r.revenue.net, { bold: true });
row(t.expenses, -r.expenses.total);
drawDivider(doc, y); y += 8;
row(t.profit, r.profit, { bold: true });
y += 18;
drawLabel(doc, t.deal, y);
y += 16;
let deal = t.shareTypes[data.partner.shareType](data.partner);
if (data.partner.shareType === 'percent_profit') {
deal += data.partner.lossRule === 'capped'
? ` · ${t.lossNote.capped} ${formatPygAmount(data.partner.lossCapAmount)}`
: ` · ${t.lossNote[data.partner.lossRule]}`;
}
doc.font('Helvetica').fontSize(10.5).fillColor(COLORS.navy).text(deal, MARGIN, y, { width: CONTENT_W });
y += 26;
const share = data.line?.share ?? 0;
const reimbursement = data.line?.reimbursement ?? 0;
const payout = data.line?.payout ?? 0;
row(t.share, share);
drawLabel(doc, t.reimbursements, y + 4);
y += 20;
if (data.frontedExpenses.length === 0) {
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted).text(t.none, MARGIN, y);
y += 16;
} else {
for (const e of data.frontedExpenses) {
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
doc.text(e.description, MARGIN + 10, y, { width: CONTENT_W - 170 });
doc.text(formatPygAmount(data.expenseAmounts.get(e.id) ?? e.computedAmount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
y += 15;
}
row(t.reimbursements, reimbursement);
}
y += 6;
drawDivider(doc, y); y += 10;
row(payout < 0 ? t.owes : t.payout, Math.abs(payout), { bold: true });
y += 6;
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
const status = data.partner.payoutStatus === 'paid'
? `${t.paid}${data.partner.payoutDate ? ` · ${dateFmt.format(new Date(data.partner.payoutDate))}` : ''}${data.partner.payoutMethod ? ` · ${data.partner.payoutMethod}` : ''}`
: t.pending;
doc.text(`${t.status}: ${status}`, MARGIN, y, { width: CONTENT_W });
if (data.partner.payoutNote) {
y += 15;
doc.text(data.partner.payoutNote, MARGIN, y, { width: CONTENT_W });
}
const footerY = PAGE_H - FOOTER_H;
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
doc.font('Helvetica').fontSize(9).fillColor(COLORS.footerMuted)
.text(`${t.generated} ${dateFmt.format(new Date())}`, MARGIN, footerY + FOOTER_H / 2 - 5, { width: CONTENT_W / 2 });
doc.font('Helvetica-Bold').fontSize(10).fillColor('#FFFFFF')
.text(siteUrl().domain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
doc.end();
return done;
}
+4 -2
View File
@@ -5,8 +5,8 @@
// settlement) and the booking is auto-approved on success. No admin involved.
// Currently Lightning; future online gateways (e.g. Stripe) go here.
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
// card handled offline, cash at the door). These are never auto-confirmed
// and never auto-failed; an admin settles them by hand. Bank transfer and
// card handled offline, cash or the POS terminal at the door). These are
// never auto-confirmed and never auto-failed; an admin settles them by hand. Bank transfer and
// TPago additionally expose an online "I've paid" step that moves the
// payment to 'pending_approval'.
//
@@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> =
bank_transfer: { kind: 'manual' },
card: { kind: 'manual' },
cash: { kind: 'manual' },
// Card on the physical POS terminal at the door; staff confirm it by hand
pos: { kind: 'manual' },
};
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
+354 -197
View File
@@ -1,6 +1,8 @@
// PDF Ticket Generation Service
import PDFDocument from 'pdfkit';
import QRCode from 'qrcode';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
interface TicketData {
id: string;
@@ -15,235 +17,390 @@ interface TicketData {
locationUrl?: string;
};
timezone?: string;
/** 'en' | 'es' - drives the labels and the date/time format on the ticket */
locale?: string;
/** Optional perk line shown under the ticket holder (falls back to the terms line) */
note?: string;
}
// ==================== Brand ====================
export const COLORS = {
navy: '#002F44',
orange: '#F5821F',
cream: '#FDF8F0',
card: '#FFFFFF',
cardBorder: '#EFE6D8',
divider: '#E7DFD1',
label: '#9AA3AC',
muted: '#6B7580',
footerMuted: '#7FA3B5',
};
export const PAGE_W = 595.28;
export const PAGE_H = 841.89;
export const MARGIN = 48;
export const CONTENT_W = PAGE_W - MARGIN * 2;
export const ACCENT_H = 10;
export const FOOTER_H = 48;
export const LOGO_RATIO = 1158 / 324;
const STRINGS = {
en: {
scan: 'SCAN AT THE ENTRANCE',
venue: 'VENUE',
holder: 'TICKET HOLDER',
terms: 'This ticket is non-transferable. One scan per entry.',
},
es: {
scan: 'ESCANEÁ AL INGRESAR',
venue: 'LUGAR',
holder: 'TITULAR',
terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.',
},
} as const;
function strings(locale?: string) {
return locale === 'es' ? STRINGS.es : STRINGS.en;
}
/**
* Generate a QR code as a data URL
* Locate the logo. `../../assets` resolves to backend/assets from both
* src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback.
*/
function loadLogo(): Buffer | null {
const candidates = [
new URL('../../assets/logo-spanglish.png', import.meta.url),
new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url),
].map((u) => fileURLToPath(u));
for (const path of candidates) {
if (existsSync(path)) return readFileSync(path);
}
return null;
}
let logoCache: Buffer | null | undefined;
export function getLogo(): Buffer | null {
if (logoCache === undefined) logoCache = loadLogo();
return logoCache;
}
/**
* Generate a QR code as a PNG buffer
*/
async function generateQRCode(data: string): Promise<Buffer> {
return QRCode.toBuffer(data, {
type: 'png',
width: 200,
margin: 2,
width: 600,
margin: 1,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#FFFFFF' },
});
}
/**
* Format date for display using site timezone
* Short date + time as shown in the ticket header:
* en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30"
*/
function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: timezone,
});
function formatWhen(
startStr: string,
endStr: string | undefined,
timezone: string,
locale: string
): string {
const isEs = locale === 'es';
const start = new Date(startStr);
const tag = isEs ? 'es-ES' : 'en-US';
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
const month = start
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
.replace(/\.$/, '')
.toUpperCase();
const time = (d: Date) =>
d
.toLocaleTimeString(tag, {
hour: isEs ? '2-digit' : 'numeric',
minute: '2-digit',
hour12: !isEs,
timeZone: timezone,
})
.toUpperCase();
const date = isEs ? `${day} ${month}` : `${month} ${day}`;
const end = endStr ? new Date(endStr) : null;
const when = end ? `${time(start)} – ${time(end)}` : time(start);
return `${date} · ${when}`;
}
/**
* Format time for display using site timezone
* Events store the venue as a single string; the part before the first comma
* reads as the venue name and the remainder as its address.
*/
function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: timezone,
function splitLocation(location: string): { name: string; address?: string } {
const idx = location.indexOf(',');
if (idx === -1) return { name: location.trim() };
return {
name: location.slice(0, idx).trim(),
address: location.slice(idx + 1).trim() || undefined,
};
}
// ==================== Drawing helpers ====================
export function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
doc
.font('Helvetica-Bold')
.fontSize(8)
.fillColor(COLORS.label)
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
}
export function drawDivider(doc: PDFKit.PDFDocument, y: number) {
doc
.moveTo(MARGIN, y)
.lineTo(PAGE_W - MARGIN, y)
.lineWidth(1)
.strokeColor(COLORS.divider)
.stroke();
}
/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */
function drawSpacedCentered(
doc: PDFKit.PDFDocument,
text: string,
x: number,
y: number,
width: number,
spacing: number
) {
doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing });
}
interface DetailBlock {
label: string;
value: string;
sub?: string;
}
/**
* Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note
* block. Returns its total height so the caller can anchor it above the footer.
*/
function renderDetails(
doc: PDFKit.PDFDocument,
blocks: DetailBlock[],
note: string,
yStart: number,
measureOnly: boolean
): number {
let y = yStart;
blocks.forEach((block, i) => {
if (i > 0) {
y += 14;
if (!measureOnly) drawDivider(doc, y);
y += 18;
}
if (!measureOnly) drawLabel(doc, block.label, y);
y += 15;
doc.font('Helvetica-Bold').fontSize(13);
if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3;
if (block.sub) {
doc.font('Helvetica').fontSize(10.5);
if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3;
}
});
y += 16;
doc.font('Helvetica').fontSize(10.5);
if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W });
y += doc.heightOfString(note, { width: CONTENT_W });
return y - yStart;
}
/**
* Render one full-page ticket. Assumes the page is already added.
*/
function renderTicketPage(
doc: PDFKit.PDFDocument,
ticket: TicketData,
qrBuffer: Buffer,
siteDomain: string,
index = 0,
total = 1
) {
const locale = ticket.locale === 'es' ? 'es' : 'en';
const t = strings(locale);
const tz = ticket.timezone || 'America/Asuncion';
const footerY = PAGE_H - FOOTER_H;
// ==================== Background ====================
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
// ==================== Logo ====================
const logo = getLogo();
let headerY = MARGIN + 6;
if (logo) {
const logoW = 158;
doc.image(logo, MARGIN, headerY, { width: logoW });
headerY += logoW / LOGO_RATIO;
} else {
doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY);
headerY += 26;
}
// ==================== Title + date ====================
const titleY = headerY + 30;
const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale);
doc.font('Helvetica-Bold').fontSize(11.5);
const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5);
const titleW = CONTENT_W - whenW - 20;
doc.font('Helvetica-Bold').fontSize(26);
if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20);
doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW });
const titleBottom = doc.y;
doc
.font('Helvetica-Bold')
.fontSize(11.5)
.fillColor(COLORS.orange)
.text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' });
// ==================== Layout: card fills what the detail block leaves ====================
const venue = splitLocation(ticket.event.location);
const note = ticket.note || t.terms;
const blocks: DetailBlock[] = [
{ label: t.venue, value: venue.name, sub: venue.address },
{ label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail },
];
const detailsH = renderDetails(doc, blocks, note, 0, true);
const detailsY = footerY - 46 - detailsH;
const cardY = Math.max(titleBottom, titleY + 36) + 24;
const cardX = MARGIN;
const cardW = CONTENT_W;
const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430));
doc
.roundedRect(cardX, cardY, cardW, cardH, 14)
.lineWidth(1)
.fillAndStroke(COLORS.card, COLORS.cardBorder);
// ==================== QR card contents ====================
const labelH = 12;
const codeH = 24;
const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44));
const stackH = labelH + 20 + qrSize + 22 + codeH;
let inner = cardY + (cardH - stackH) / 2;
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label);
drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2);
if (total > 1) {
doc
.font('Helvetica-Bold')
.fontSize(8.5)
.fillColor(COLORS.label)
.text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 });
}
inner += labelH + 20;
doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize });
inner += qrSize + 22;
const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase();
doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy);
drawSpacedCentered(doc, code, cardX, inner, cardW, 3);
// ==================== Venue / ticket holder / note ====================
renderDetails(doc, blocks, note, detailsY, false);
// ==================== Footer ====================
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
doc
.font('Courier')
.fontSize(7.5)
.fillColor(COLORS.footerMuted)
.text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false });
doc
.font('Helvetica')
.fontSize(10)
.fillColor('#FFFFFF')
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
}
export function createDoc(): PDFKit.PDFDocument {
return new PDFDocument({ size: 'A4', margin: 0 });
}
export function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
}
export function siteUrl(): { base: string; domain: string } {
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
let domain = base;
try {
domain = new URL(base).host.replace(/^www\./, '');
} catch {
domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '');
}
return { base, domain };
}
/**
* Generate a PDF ticket for a single ticket
*/
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
return new Promise(async (resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
// Generate QR code with ticket URL
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
// Center the QR code
const qrSize = 180;
const pageWidth = 595; // A4 width in points
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
doc.end();
} catch (error) {
reject(error);
}
});
return generateCombinedTicketsPDF([ticket]);
}
/**
* Generate a combined PDF with multiple tickets
* Generate a combined PDF with multiple tickets (one page each)
*/
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
return new Promise(async (resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
const doc = createDoc();
const done = collect(doc);
const { base, domain } = siteUrl();
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
try {
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (i > 0) doc.addPage();
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (i > 0) {
doc.addPage();
}
// Generate QR code
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
const qrSize = 180;
const pageWidth = 595;
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// Ticket number for multi-ticket bookings
if (tickets.length > 1) {
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
}
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
}
doc.end();
} catch (error) {
reject(error);
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
}
});
doc.end();
} catch (error) {
doc.end();
throw error;
}
return done;
}
export default {
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import {
resolvePresaleClosure,
isPresaleClosed,
DEFAULT_PRESALE_CLOSE_MINUTES,
} from './presale.js';
const START = '2030-01-01T20:00:00.000Z';
const startMs = new Date(START).getTime();
const minutes = (n: number) => n * 60_000;
describe('resolvePresaleClosure', () => {
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
const r = resolvePresaleClosure({ startDatetime: START }, null);
expect(r.enabled).toBe(true);
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
});
it('inherits from site settings when the event has null overrides', () => {
const r = resolvePresaleClosure(
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
);
expect(r.minutesBefore).toBe(30);
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
});
it('lets the event override the site settings', () => {
const r = resolvePresaleClosure(
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
);
expect(r.enabled).toBe(true);
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
});
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
expect(
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
).toBeNull();
});
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
const r = resolvePresaleClosure(
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
);
expect(r.enabled).toBe(true);
expect(r.minutesBefore).toBe(45);
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
expect(off.enabled).toBe(false);
});
it('closes at the event start when minutes is 0', () => {
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
expect(r.closesAt?.getTime()).toBe(startMs);
});
});
describe('isPresaleClosed', () => {
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
it('is open before the cutoff and closed from the cutoff onwards', () => {
const cutoff = startMs - minutes(60);
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
});
it('never closes when closure is disabled', () => {
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Pre-sale closure: online registration for an event closes a configurable
* number of minutes before the event starts.
*
* Each event may override the site-wide default; a `null`/`undefined` value on
* the event means "inherit from site_settings". Values arrive as booleans on
* SQLite and as 0/1 integers on PostgreSQL, so both are accepted here.
*/
export const DEFAULT_PRESALE_CLOSURE_ENABLED = true;
export const DEFAULT_PRESALE_CLOSE_MINUTES = 120;
export interface PresaleEventLike {
startDatetime: string | Date;
presaleClosureEnabled?: boolean | number | null;
presaleCloseMinutesBefore?: number | string | null;
}
export interface PresaleSettingsLike {
presaleClosureEnabled?: boolean | number | null;
presaleCloseMinutesBefore?: number | string | null;
}
export interface ResolvedPresaleClosure {
enabled: boolean;
minutesBefore: number;
/** When online registration closes, or null when closure is disabled. */
closesAt: Date | null;
}
function toBool(value: boolean | number | null | undefined): boolean | null {
if (value === null || value === undefined) return null;
return typeof value === 'number' ? value !== 0 : Boolean(value);
}
function toMinutes(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === 'string' ? parseInt(value, 10) : value;
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
}
export function resolvePresaleClosure(
event: PresaleEventLike,
settings?: PresaleSettingsLike | null
): ResolvedPresaleClosure {
const enabled =
toBool(event.presaleClosureEnabled) ??
toBool(settings?.presaleClosureEnabled) ??
DEFAULT_PRESALE_CLOSURE_ENABLED;
const minutesBefore =
toMinutes(event.presaleCloseMinutesBefore) ??
toMinutes(settings?.presaleCloseMinutesBefore) ??
DEFAULT_PRESALE_CLOSE_MINUTES;
if (!enabled) return { enabled, minutesBefore, closesAt: null };
const startMs = new Date(event.startDatetime).getTime();
if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null };
return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) };
}
export function isPresaleClosed(
event: PresaleEventLike,
settings?: PresaleSettingsLike | null,
nowMs: number = Date.now()
): boolean {
const { closesAt } = resolvePresaleClosure(event, settings);
return closesAt !== null && closesAt.getTime() <= nowMs;
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { resolveSalesState, publicSalesFields, isOnlineSalesClosed } from './salesState.js';
const START = '2030-01-01T23:00:00.000Z'; // 20:00 in Asunción
const END = '2030-01-02T02:00:00.000Z';
const startMs = new Date(START).getTime();
const minutes = (n: number) => n * 60_000;
// Pre-sale closes 120 minutes before the start (the site default).
const event = {
status: 'published',
startDatetime: START,
endDatetime: END,
externalBookingEnabled: false,
price: 21000,
walkInPrice: 30000 as number | null,
};
const settings = { presaleClosureEnabled: true, presaleCloseMinutesBefore: 120 };
const beforeClose = startMs - minutes(121);
const afterClose = startMs - minutes(60);
describe('resolveSalesState', () => {
it('is online while pre-sale is open and seats are left', () => {
expect(resolveSalesState(event, settings, 10, beforeClose)).toBe('online');
});
it('is door after pre-sale closes, until the event ends', () => {
expect(resolveSalesState(event, settings, 10, afterClose)).toBe('door');
expect(resolveSalesState(event, settings, 10, startMs + minutes(30))).toBe('door');
expect(resolveSalesState(event, settings, 10, new Date(END).getTime() - 1)).toBe('door');
});
it('is sold_out with no seats left, online or at the door', () => {
expect(resolveSalesState(event, settings, 0, beforeClose)).toBe('sold_out');
expect(resolveSalesState(event, settings, 0, afterClose)).toBe('sold_out');
});
it('is ended once the end time passes, or the start time when there is no end', () => {
expect(resolveSalesState(event, settings, 10, new Date(END).getTime())).toBe('ended');
expect(resolveSalesState(event, settings, 0, new Date(END).getTime())).toBe('ended');
expect(resolveSalesState({ ...event, endDatetime: null }, settings, 10, startMs)).toBe('ended');
expect(resolveSalesState({ ...event, status: 'completed' }, settings, 10, beforeClose)).toBe('ended');
});
it('is external for external booking events, with no door state', () => {
const external = { ...event, externalBookingEnabled: true };
expect(resolveSalesState(external, settings, 10, beforeClose)).toBe('external');
expect(resolveSalesState(external, settings, 10, afterClose)).toBe('external');
expect(resolveSalesState({ ...event, externalBookingEnabled: 1 }, settings, 10, afterClose)).toBe('external');
});
it('keeps cancelled events cancelled', () => {
expect(resolveSalesState({ ...event, status: 'cancelled' }, settings, 10, afterClose)).toBe('cancelled');
});
it('closes online sales at the start when pre-sale closure is off', () => {
const noClosure = { ...event, presaleClosureEnabled: false };
expect(resolveSalesState(noClosure, settings, 10, startMs - 1)).toBe('online');
expect(resolveSalesState(noClosure, settings, 10, startMs)).toBe('door');
expect(isOnlineSalesClosed(noClosure, settings, startMs - 1)).toBe(false);
expect(isOnlineSalesClosed(noClosure, settings, startMs)).toBe(true);
});
});
describe('publicSalesFields', () => {
it('door with walk_in_price set: doorPrice is the walk-in price', () => {
expect(publicSalesFields(event, settings, 48, afterClose)).toEqual({ salesState: 'door', doorPrice: 30000 });
});
it('door with walk_in_price null: doorPrice falls back to the ticket price', () => {
expect(publicSalesFields({ ...event, walkInPrice: null }, settings, 48, afterClose))
.toEqual({ salesState: 'door', doorPrice: 21000 });
// Postgres decimals arrive as strings
expect(publicSalesFields({ ...event, price: '21000.00', walkInPrice: null }, settings, 48, afterClose).doorPrice)
.toBe(21000);
});
it('door with a free walk-in keeps 0, not the ticket price', () => {
expect(publicSalesFields({ ...event, walkInPrice: 0 }, settings, 48, afterClose).doorPrice).toBe(0);
});
it('omits doorPrice in every other state', () => {
const cases: Array<[number, number, any]> = [
[10, beforeClose, event], // online
[0, afterClose, event], // sold_out
[10, new Date(END).getTime(), event], // ended
[10, afterClose, { ...event, externalBookingEnabled: true }], // external
[10, afterClose, { ...event, status: 'cancelled' }], // cancelled
];
for (const [spots, now, e] of cases) {
const fields = publicSalesFields(e, settings, spots, now);
expect(fields.salesState).not.toBe('door');
expect(fields).not.toHaveProperty('doorPrice');
}
});
});
+72
View File
@@ -0,0 +1,72 @@
// Public sales state of an event: what the event page offers a visitor right now.
//
// online pre-sale open and seats left (book online)
// door online sales closed, the event has not ended and seats are left:
// people can still come and pay at the door until the event ends
// sold_out no seats left, online or at the door
// ended the event is over (end time passed, or status completed/archived)
// external bookings happen on an external site; no door state
// cancelled the event was cancelled
//
// This is the single source of truth for the public page, listings and JSON-LD.
// Online sales close at the pre-sale cutoff (lib/presale.ts) or, when pre-sale
// closure is off, when the event starts — the booking API enforces the same rule
// through isOnlineSalesClosed. The state flips on the clock without any edit to
// the event, so cached copies must be refreshed around presaleClosesAt.
import { isPresaleClosed, type PresaleEventLike, type PresaleSettingsLike } from './presale.js';
import { resolveWalkInPrice } from './walkInPrice.js';
export type SalesState = 'online' | 'door' | 'sold_out' | 'ended' | 'external' | 'cancelled';
export interface SalesStateEventLike extends PresaleEventLike {
status: string;
endDatetime?: string | Date | null;
externalBookingEnabled?: boolean | number | null;
}
/** When the event ends: its end time, or its start time when no end is set (as in eventEndSweep). */
export function eventEndMs(event: { startDatetime: string | Date; endDatetime?: string | Date | null }): number {
return new Date(event.endDatetime || event.startDatetime).getTime();
}
/** True once online booking is closed: pre-sale cutoff passed, or the event has started. */
export function isOnlineSalesClosed(
event: PresaleEventLike,
settings?: PresaleSettingsLike | null,
nowMs: number = Date.now()
): boolean {
return isPresaleClosed(event, settings, nowMs) || new Date(event.startDatetime).getTime() <= nowMs;
}
export function resolveSalesState(
event: SalesStateEventLike,
settings: PresaleSettingsLike | null | undefined,
spotsLeft: number,
nowMs: number = Date.now()
): SalesState {
if (event.status === 'cancelled') return 'cancelled';
if (event.status === 'completed' || event.status === 'archived') return 'ended';
if (Boolean(event.externalBookingEnabled)) return 'external';
if (eventEndMs(event) <= nowMs) return 'ended';
if (spotsLeft <= 0) return 'sold_out';
if (isOnlineSalesClosed(event, settings, nowMs)) return 'door';
return 'online';
}
/**
* Sales fields for a public event response. `doorPrice` (the resolved walk-in
* price: walk_in_price, else the ticket price) is present only in the `door`
* state, so the internal walk-in price never leaks while online sales are open.
* `event` is the raw row (it still carries walkInPrice).
*/
export function publicSalesFields(
event: SalesStateEventLike & { price: unknown; walkInPrice?: unknown },
settings: PresaleSettingsLike | null | undefined,
spotsLeft: number,
nowMs: number = Date.now()
): { salesState: SalesState; doorPrice?: number } {
const salesState = resolveSalesState(event, settings, spotsLeft, nowMs);
if (salesState !== 'door') return { salesState };
return { salesState, doorPrice: resolveWalkInPrice(event).unitPrice };
}
+42
View File
@@ -0,0 +1,42 @@
// Engine-neutral transactional writes.
//
// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one
// silently breaks atomicity), while node-postgres takes an async one. Rather than
// fork every multi-write route into two near-identical branches, callers build a
// plain list of operations and hand it here: the business logic stays in one
// place and only the six lines below know which driver is underneath.
import { db, isSqlite } from '../db/index.js';
export type TxOp =
| { kind: 'insert'; table: any; values: any }
| { kind: 'update'; table: any; values: any; where: any }
| { kind: 'delete'; table: any; where: any };
export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values });
export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where });
export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where });
/** Apply every op inside a single transaction; any throw rolls back all of them. */
export async function runOps(ops: TxOp[]): Promise<void> {
if (ops.length === 0) return;
if (isSqlite()) {
(db as any).transaction((tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') tx.insert(op.table).values(op.values).run();
else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run();
else tx.delete(op.table).where(op.where).run();
}
});
return;
}
await (db as any).transaction(async (tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') await tx.insert(op.table).values(op.values);
else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where);
else await tx.delete(op.table).where(op.where);
}
});
}
+13
View File
@@ -22,6 +22,19 @@ export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`;
}
/**
* Canonical form for `users.email`.
*
* Better Auth lowercases the address on every lookup and write it performs,
* but the `users.email` unique index is case-sensitive on both dialects. Any
* row written outside Better Auth (guest bookings, door sales, admin-added
* tickets) must therefore be normalized the same way, or the row becomes
* invisible to sign-in / Google linking and a duplicate person gets created.
*/
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* Get current timestamp in the format appropriate for the database type.
* - SQLite: returns ISO string
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { resolveWalkInPrice, omitWalkInPrice, parseWalkInPrice, canSeeWalkInPrice } from './walkInPrice.js';
describe('resolveWalkInPrice', () => {
it('falls back to the ticket price when no walk-in price is set', () => {
expect(resolveWalkInPrice({ price: 21000, walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
expect(resolveWalkInPrice({ price: 21000 })).toEqual({ unitPrice: 21000, source: 'ticket' });
});
it('treats 0 as a free walk-in, not as unset', () => {
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 0 })).toEqual({ unitPrice: 0, source: 'walk_in' });
});
it('uses a set walk-in price', () => {
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 25000 })).toEqual({ unitPrice: 25000, source: 'walk_in' });
});
it('reads Postgres decimal strings', () => {
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: '25000.00' })).toEqual({ unitPrice: 25000, source: 'walk_in' });
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
expect(parseWalkInPrice('0.00')).toBe(0);
expect(parseWalkInPrice('')).toBeNull();
});
});
describe('omitWalkInPrice', () => {
it('drops the walk-in price and keeps everything else', () => {
const event = { id: 'e1', price: 21000, walkInPrice: 25000, currency: 'PYG' };
const result = omitWalkInPrice(event);
expect(result).not.toHaveProperty('walkInPrice');
expect(result).toEqual({ id: 'e1', price: 21000, currency: 'PYG' });
expect(event.walkInPrice).toBe(25000);
});
it('passes null through', () => {
expect(omitWalkInPrice(null)).toBeNull();
});
});
describe('canSeeWalkInPrice', () => {
it('is limited to event managers and door staff', () => {
expect(canSeeWalkInPrice('admin')).toBe(true);
expect(canSeeWalkInPrice('organizer')).toBe(true);
expect(canSeeWalkInPrice('staff')).toBe(true);
expect(canSeeWalkInPrice('marketing')).toBe(false);
expect(canSeeWalkInPrice('user')).toBe(false);
expect(canSeeWalkInPrice(null)).toBe(false);
});
});
+44
View File
@@ -0,0 +1,44 @@
// Walk-in (door) pricing.
//
// An event may set a separate price for people who buy at the door. It is an
// internal number: staff charge it on the door screen, but it never appears on
// the public event page, listings or JSON-LD, so every public serializer must
// drop it (see omitWalkInPrice).
//
// walkInPrice null -> not set, walk-ins pay the regular ticket price
// walkInPrice 0 -> free walk-in (a real value, distinct from null)
// walkInPrice n -> walk-ins pay n, in the event's currency
export type WalkInPriceSource = 'walk_in' | 'ticket';
/** Roles that may see an event's walk-in price: event managers and door staff. */
export const WALK_IN_PRICE_ROLES = ['admin', 'organizer', 'staff'] as const;
export function canSeeWalkInPrice(role: string | null | undefined): boolean {
return !!role && (WALK_IN_PRICE_ROLES as readonly string[]).includes(role);
}
/** Postgres decimals arrive as strings; null/undefined/garbage stay null. */
export function parseWalkInPrice(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const n = typeof value === 'string' ? parseFloat(value) : Number(value);
return Number.isFinite(n) ? n : null;
}
/** The per-ticket price a walk-in is charged, and where it came from. */
export function resolveWalkInPrice(event: { price: unknown; walkInPrice?: unknown }): {
unitPrice: number;
source: WalkInPriceSource;
} {
const walkIn = parseWalkInPrice(event.walkInPrice);
if (walkIn !== null) return { unitPrice: walkIn, source: 'walk_in' };
const price = typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price);
return { unitPrice: Number.isFinite(price) ? price : 0, source: 'ticket' };
}
/** Copy of an event row without its walk-in price, for anything a non-staff caller can read. */
export function omitWalkInPrice<T extends Record<string, any> | null | undefined>(event: T): T {
if (!event) return event;
const { walkInPrice: _omitted, ...rest } = event as Record<string, any>;
return rest as T;
}
+18 -4
View File
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
import { getNow } from '../lib/utils.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
@@ -182,6 +183,17 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
.groupBy((tickets as any).eventId)
);
// Revenue is what was actually paid, not tickets × the current event price
// (which an admin can change after tickets have been sold).
const revenueRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`coalesce(sum(${(payments as any).amount}), 0)` })
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(eq((payments as any).status, 'paid'))
.groupBy((tickets as any).eventId)
);
const toMap = (rows: any[]) => {
const m = new Map<string, number>();
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
@@ -190,6 +202,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
const totalMap = toMap(totalRows);
const confirmedMap = toMap(confirmedRows);
const checkedInMap = toMap(checkedInRows);
const revenueMap = toMap(revenueRows);
const eventStats = allEvents.map((event: any) => {
const confirmedBookings = confirmedMap.get(event.id) || 0;
@@ -201,7 +214,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
totalBookings: totalMap.get(event.id) || 0,
confirmedBookings,
checkedIn: checkedInMap.get(event.id) || 0,
revenue: confirmedBookings * event.price,
revenue: revenueMap.get(event.id) || 0,
};
});
@@ -264,7 +277,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
});
// Export attendees for a specific event (admin) — CSV download
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
adminRouter.get('/events/:eventId/attendees/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
const q = c.req.query('q') || '';
@@ -368,14 +381,14 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
});
// Legacy alias — keep old path working
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
adminRouter.get('/events/:eventId/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
const newUrl = new URL(c.req.url);
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
return c.redirect(newUrl.toString(), 301);
});
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
adminRouter.get('/events/:eventId/tickets/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
const q = c.req.query('q') || '';
@@ -519,6 +532,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
pos: filteredPayments.filter((p: any) => p.provider === 'pos' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
},
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
+3 -2
View File
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool } from '../lib/utils.js';
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
@@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
return c.json({ canClaim: false });
}
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
+39 -3
View File
@@ -1,18 +1,54 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, eventMembers } from '../db/index.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
import { auth } from '../lib/betterAuth.js';
import { authSessions, authAccounts } from '../db/auth-schema.js';
import { getNow } from '../lib/utils.js';
import { omitWalkInPrice } from '../lib/walkInPrice.js';
import { resolveMemberPermissions, EVENT_PERMISSIONS } from '../lib/eventPermissions.js';
const dashboard = new Hono();
// Apply authentication to all routes
dashboard.use('*', requireAuth());
// ==================== My Events (team memberships) ====================
// Events the user was added to as staff / collaborator / co-manager, with what
// they may do on each. Opens the scoped event page at /dashboard/events/:id.
dashboard.get('/my-events', async (c) => {
const user = (c as any).get('user') as AuthUser;
const rows = await dbAll<any>(
(db as any)
.select({ m: eventMembers, e: events })
.from(eventMembers)
.innerJoin(events, eq((eventMembers as any).eventId, (events as any).id))
.where(eq((eventMembers as any).userId, user.id))
);
const toIso = (v: any) => (v instanceof Date ? v.toISOString() : v);
return c.json({
events: rows
.map((r: any) => ({
event: {
id: r.e.id,
slug: r.e.slug,
title: r.e.title,
titleEs: r.e.titleEs,
startDatetime: toIso(r.e.startDatetime),
location: r.e.location,
status: r.e.status,
bannerUrl: r.e.bannerUrl,
},
rolePreset: r.m.rolePreset,
permissions: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(r.m.rolePreset, r.m.permissions).has(p)),
}))
.sort((a: any, b: any) => String(b.event.startDatetime).localeCompare(String(a.event.startDatetime))),
});
});
// ==================== Profile Routes ====================
const updateProfileSchema = z.object({
@@ -208,7 +244,7 @@ dashboard.get('/tickets/:id', async (c) => {
return c.json({
ticket: {
...ticket,
event,
event: omitWalkInPrice(event as any),
payment,
invoice,
},
@@ -272,7 +308,7 @@ dashboard.get('/next-event', async (c) => {
return c.json({
nextEvent: {
event: nextEvent,
event: omitWalkInPrice(nextEvent),
ticket: nextTicket,
payment: nextPayment,
},
+691
View File
@@ -0,0 +1,691 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
// Who the next request is from. Session auth itself is Better Auth's concern and
// has its own integration suite; this mock keeps the *role* check real so the
// tests can prove which endpoints door staff may reach.
let currentUser: { id: string; name: string; role: string } = STAFF;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(currentUser.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
/** Run one request as a given role, always restoring the default afterwards. */
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
}));
let app: any;
let sqlite: any;
const EVENT_ID = 'evt-door-1';
const PRICE = 60000;
/** POST helper that mirrors how the door screen calls the API. */
async function post(path: string, body: unknown) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
async function get(path: string) {
const res = await app.request(path);
return { status: res.status, body: await res.json() };
}
function seedTicket(row: {
id: string;
first: string;
last?: string | null;
status: string;
paymentStatus: string;
phone?: string | null;
bookingId?: string | null;
qr?: string;
/** Seed the pre-sale payment a paid ticket was bought with, at this amount. */
paidAmount?: number;
}) {
sqlite
.prepare(
`INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name,
attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
)
.run(
row.id,
row.bookingId ?? null,
'seed-user',
EVENT_ID,
row.first,
row.last ?? null,
`${row.id}@test.py`,
row.phone ?? null,
row.status,
row.paymentStatus,
row.qr ?? `QR-${row.id}`,
new Date().toISOString()
);
if (row.paidAmount !== undefined) seedPayment(row.id, row.paidAmount, 'paid');
}
function seedPayment(ticketId: string, amount: number, status: string, provider = 'bancard') {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO payments (id, ticket_id, provider, amount, currency, status, paid_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'PYG', ?, ?, ?, ?)`
)
.run(`pay-${ticketId}`, ticketId, provider, amount, status, status === 'paid' ? now : null, now, now);
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
const doorRoutes = (await import('./door.js')).default;
app = new Hono();
app.route('/api/events', doorRoutes);
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)`
)
.run('seed-user', 'seed@test.py', 'Seed User', now, now);
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)`
)
.run(STAFF.id, 'staff@test.py', STAFF.name, now, now);
sqlite
.prepare(
`INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at)
VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)`
)
.run(EVENT_ID, now, PRICE, now, now);
seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567', paidAmount: PRICE });
seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' });
})();
}, 120_000);
describe('door-attendees', () => {
it('returns everyone including cancelled, with group bookings flagged', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
expect(status).toBe(200);
expect(body.event.price).toBe(PRICE);
expect(body.attendees).toHaveLength(4);
const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled');
expect(cancelled.status).toBe('cancelled');
const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid');
expect(grouped.isGroupBooking).toBe(true);
expect(grouped.amountDue).toBe(PRICE);
const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid');
expect(solo.isGroupBooking).toBe(false);
expect(solo.amountDue).toBe(0);
});
it('is sorted alphabetically so an empty search is scrollable', async () => {
const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
const names = body.attendees.map((a: any) => a.fullName);
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })));
});
});
describe('door-checkin: existing ticket', () => {
it('checks in a paid attendee with no payment record touched', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
entryMethod: 'search',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(201);
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.checkinAt).toBeTruthy();
expect(body.attendee.checkedInBy).toBe(STAFF.name);
expect(body.payment).toBeNull();
const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid');
expect(row.status).toBe('checked_in');
expect(row.checked_in_by_admin_id).toBe(STAFF.id);
});
it('replays an already-processed key instead of checking in twice', async () => {
const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
expect(after).toBe(before);
expect(sqlite.prepare("SELECT COUNT(*) n FROM payments WHERE ticket_id = ? AND source = 'door'").get('tkt-paid').n).toBe(0);
});
it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid',
payment: { method: 'cash', amount: PRICE },
entryMethod: 'search',
idempotencyKey: 'key-unpaid-cash',
});
expect(status).toBe(201);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.checkedIn).toBe(true);
expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE });
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid');
expect(payment.source).toBe('door');
expect(payment.method).toBe('cash');
expect(payment.provider).toBe('cash');
expect(payment.status).toBe('paid');
expect(payment.paid_by_admin_id).toBe(STAFF.id);
});
it('takes a group payment at a multiple of the ticket price', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid-2',
payment: { method: 'transfer', quantity: 2 },
idempotencyKey: 'key-unpaid-2-transfer',
});
expect(body.payment.amount).toBe(PRICE * 2);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2');
expect(payment.provider).toBe('bank_transfer');
expect(payment.method).toBe('transfer');
expect(payment.amount).toBe(PRICE * 2);
});
it('reactivates a cancelled ticket through the same payment flow', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-cancelled',
payment: { method: 'bitcoin' },
idempotencyKey: 'key-cancelled-reactivate',
});
expect(body.attendee.status).toBe('checked_in');
expect(body.attendee.paymentStatus).toBe('paid');
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled');
// Bitcoin is recorded as already-paid Lightning: same trust model as cash,
// no invoice generated (see lib/doorPayments.ts).
expect(payment.provider).toBe('lightning');
expect(payment.method).toBe('bitcoin');
expect(payment.amount).toBe(PRICE);
});
it('rejects a ticket from another event', async () => {
const { status, body } = await post('/api/events/other-event/door-checkin', {
ticketId: 'tkt-paid',
idempotencyKey: 'key-wrong-event',
});
expect(status).toBe(404);
expect(body.error).toMatch(/Event not found/);
});
});
describe('door-checkin: walk-ins', () => {
it('creates a cash walk-in confirmed, paid and checked in with no email', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(201);
expect(body.action).toBe('walkin');
expect(body.attendee.fullName).toBe('Walk');
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.email).toBeNull();
const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('checked_in');
expect(ticket.qr_code).toBeTruthy();
// A placeholder account keeps users.email unique without mailing anyone.
const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id);
expect(account.email).toMatch(/@doorentry\.local$/);
});
it('records a guest walk-in as a zero-amount comp', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Free', lastName: 'Guest' },
payment: { method: 'guest', amount: PRICE },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-guest',
});
expect(body.attendee.paymentStatus).toBe('comp');
expect(body.attendee.isGuest).toBe(true);
expect(body.payment.amount).toBe(0);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(0);
expect(payment.method).toBe('guest');
});
it('does not create a second ticket when the same walk-in key is retried', async () => {
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
});
it('warns rather than blocks once the event is over capacity', async () => {
// Capacity is 2 and several tickets already hold seats.
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Overflow' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-overflow',
});
expect(body.ok).toBe(true);
expect(body.warnings).toContain('at_capacity');
});
});
describe('undo', () => {
it('reverts a plain check-in to its previous state', async () => {
seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid', paidAmount: PRICE });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo',
idempotencyKey: 'key-undo-checkin',
});
expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in');
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-undo-checkin',
});
expect(status).toBe(200);
expect(body.reverted).toBe('existing');
const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo');
expect(row.status).toBe('confirmed');
expect(row.checkin_at).toBeNull();
});
it('removes the payment it created and restores the unpaid balance', async () => {
seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo-pay',
payment: { method: 'cash' },
idempotencyKey: 'key-undo-pay',
});
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1);
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' });
const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay');
expect(row.status).toBe('confirmed');
expect(row.payment_status).toBe('unpaid');
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0);
});
it('cancels a walk-in it created', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Mistake' },
payment: { method: 'cash' },
idempotencyKey: 'key-undo-walkin',
});
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('cancelled');
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.status).toBe('cancelled');
});
it('is safe to call twice and rejects an unknown key', async () => {
const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
expect(repeat.body.alreadyUndone).toBe(true);
const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' });
expect(unknown.status).toBe(404);
});
});
describe('door-summary access', () => {
it('is hidden from door staff — whole-event takings are not door information', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(403);
// The numbers must not leak in the body either: hiding the section in the UI
// alone would still expose them to anyone reading the network response.
expect(body).not.toHaveProperty('door');
expect(body).not.toHaveProperty('presale');
});
it('is available to admin and organizer', async () => {
for (const role of [ADMIN, ORGANIZER]) {
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status, `${role.role} should see door takings`).toBe(200);
}
});
it('still lets door staff do their job — list, check in and undo', async () => {
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
// Comp, so this ticket stays out of the revenue totals asserted below and
// the two tests cannot drift into each other through the shared database.
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-role',
idempotencyKey: 'key-role-check',
});
expect(checkin.status).toBe(201);
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-role-check',
});
expect(undo.status).toBe(200);
});
});
describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
// cancelled and no longer count).
expect(body.door.byMethod.cash.count).toBe(3);
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
expect(body.door.total).toBe(PRICE * 6);
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
// whose door check-in was undone and which is a pre-paid ticket again.
expect(body.presale.count).toBe(2);
expect(body.presale.total).toBe(PRICE * 2);
expect(body.total).toBe(PRICE * 8);
expect(body.door.lines.length).toBe(body.door.count);
expect(body.door.lines[0]).toHaveProperty('name');
});
it('keeps pre-sale revenue at what was paid when the ticket price changes', async () => {
sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(99000, EVENT_ID);
try {
const { body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(body.presale).toEqual({ count: 2, total: PRICE * 2 });
expect(body.door.total).toBe(PRICE * 6);
} finally {
sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(PRICE, EVENT_ID);
}
});
});
// ==================== Walk-in price & POS ====================
// Separate events so these cannot drift into the door-summary totals above.
const WALKIN_EVENT_ID = 'evt-door-walkin';
const WALKIN_PRICE = 25000;
const WALKIN_TICKET_PRICE = 21000;
const FREE_WALKIN_EVENT_ID = 'evt-door-free-walkin';
const NO_POS_EVENT_ID = 'evt-door-no-pos';
describe('walk-in pricing', () => {
beforeAll(() => {
const now = new Date().toISOString();
const insertEvent = sqlite.prepare(
`INSERT INTO events (id, title, description, start_datetime, location, price, walk_in_price, currency, capacity, status, created_at, updated_at)
VALUES (?, ?, 'desc', ?, 'Asuncion', ?, ?, 'PYG', 100, 'published', ?, ?)`
);
insertEvent.run(WALKIN_EVENT_ID, 'Walk-in Night', now, WALKIN_TICKET_PRICE, WALKIN_PRICE, now, now);
insertEvent.run(FREE_WALKIN_EVENT_ID, 'Free Door Night', now, WALKIN_TICKET_PRICE, 0, now, now);
insertEvent.run(NO_POS_EVENT_ID, 'No POS Night', now, WALKIN_TICKET_PRICE, null, now, now);
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'admin', 1, 'active', ?, ?)`
)
.run(ADMIN.id, 'admin@test.py', ADMIN.name, now, now);
sqlite
.prepare(
`INSERT INTO event_payment_overrides (id, event_id, pos_enabled, created_at, updated_at)
VALUES ('ovr-no-pos', ?, 0, ?, ?)`
)
.run(NO_POS_EVENT_ID, now, now);
sqlite
.prepare(
`INSERT INTO tickets (id, user_id, event_id, attendee_first_name, status, payment_status, is_guest, qr_code, created_at)
VALUES ('tkt-walkin-event-unpaid', 'seed-user', ?, 'Pre', 'confirmed', 'unpaid', 0, 'QR-walkin-unpaid', ?)`
)
.run(WALKIN_EVENT_ID, now);
});
it('resolves the walk-in unit price on the server for the door screen', async () => {
const withPrice = await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`);
expect(withPrice.body.event).toMatchObject({
price: WALKIN_TICKET_PRICE,
walkInPrice: WALKIN_PRICE,
walkInUnitPrice: WALKIN_PRICE,
walkInPriceSource: 'walk_in',
});
const fallback = await get(`/api/events/${EVENT_ID}/door-attendees`);
expect(fallback.body.event).toMatchObject({
walkInPrice: null,
walkInUnitPrice: PRICE,
walkInPriceSource: 'ticket',
});
const free = await get(`/api/events/${FREE_WALKIN_EVENT_ID}/door-attendees`);
expect(free.body.event).toMatchObject({ walkInPrice: 0, walkInUnitPrice: 0, walkInPriceSource: 'walk_in' });
});
it('charges walk-ins the walk-in price and marks them as walk-in bookings', async () => {
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Door', lastName: 'Buyer' },
payment: { method: 'cash' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-price-cash',
});
expect(status).toBe(201);
expect(body.payment).toMatchObject({ method: 'cash', amount: WALKIN_PRICE, currency: 'PYG', amountOverridden: false });
const ticket = sqlite.prepare('SELECT booking_source FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.booking_source).toBe('walk_in');
const payment = sqlite.prepare('SELECT amount, currency, source FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment).toMatchObject({ amount: WALKIN_PRICE, currency: 'PYG', source: 'door' });
});
it('treats a walk-in price of 0 as a free walk-in, not as "unset"', async () => {
const { body } = await post(`/api/events/${FREE_WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Free' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-free',
});
expect(body.payment.amount).toBe(0);
});
it('multiplies the resolved price by quantity', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Group' },
payment: { method: 'cash', quantity: 3 },
idempotencyKey: 'key-walkin-price-group',
});
expect(body.payment.amount).toBe(WALKIN_PRICE * 3);
});
it('ignores a client-sent amount without the override flag', async () => {
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Cheap' },
payment: { method: 'cash', amount: 1 },
idempotencyKey: 'key-walkin-client-amount',
});
expect(status).toBe(201);
expect(body.payment.amount).toBe(WALKIN_PRICE);
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(WALKIN_PRICE);
});
it('refuses an amount override from door staff and writes nothing', async () => {
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Discount' },
payment: { method: 'cash', amount: 1000, amountOverride: true },
idempotencyKey: 'key-walkin-staff-override',
});
expect(status).toBe(403);
expect(body.code).toBe('OVERRIDE_FORBIDDEN');
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
});
it('lets an admin override the amount and records it in the audit log', async () => {
const { status, body } = await as(ADMIN, () => post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Friend' },
payment: { method: 'cash', amount: 10000, amountOverride: true },
idempotencyKey: 'key-walkin-admin-override',
}));
expect(status).toBe(201);
expect(body.payment).toMatchObject({ amount: 10000, amountOverridden: true });
const log = sqlite
.prepare(`SELECT * FROM audit_logs WHERE action = 'door_amount_override' AND target_id = ?`)
.get(body.payment.id);
expect(log.user_id).toBe(ADMIN.id);
expect(JSON.parse(log.details)).toMatchObject({
eventId: WALKIN_EVENT_ID,
computedAmount: WALKIN_PRICE,
chargedAmount: 10000,
currency: 'PYG',
});
});
it('settles an existing unpaid ticket at the ticket price, not the walk-in price', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
ticketId: 'tkt-walkin-event-unpaid',
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-event-existing',
});
expect(body.payment.amount).toBe(WALKIN_TICKET_PRICE);
});
it('keeps the charged amount when the walk-in price is edited afterwards', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Snapshot' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-snapshot',
});
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(99000, WALKIN_EVENT_ID);
try {
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(WALKIN_PRICE);
} finally {
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(WALKIN_PRICE, WALKIN_EVENT_ID);
}
});
});
describe('POS tender', () => {
it('is offered on the door screen unless switched off for the event', async () => {
expect((await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`)).body.doorMethods)
.toEqual(['cash', 'bitcoin', 'transfer', 'pos', 'guest']);
expect((await get(`/api/events/${NO_POS_EVENT_ID}/door-attendees`)).body.doorMethods)
.toEqual(['cash', 'bitcoin', 'transfer', 'guest']);
});
it('records a confirmed POS walk-in as a paid door payment at the walk-in price', async () => {
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Card', lastName: 'Payer' },
payment: { method: 'pos' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-pos',
});
expect(status).toBe(201);
expect(body.attendee).toMatchObject({ checkedIn: true, paymentStatus: 'paid', doorMethod: 'pos' });
expect(body.payment).toMatchObject({ method: 'pos', amount: WALKIN_PRICE });
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment).toMatchObject({
provider: 'pos',
method: 'pos',
source: 'door',
status: 'paid',
amount: WALKIN_PRICE,
paid_by_admin_id: STAFF.id,
reference: 'Door — paid by POS',
});
});
it('shows POS takings in the door summary', async () => {
const { body } = await as(ADMIN, () => get(`/api/events/${WALKIN_EVENT_ID}/door-summary`));
expect(body.door.byMethod.pos).toEqual({ count: 1, total: WALKIN_PRICE });
});
it('can be undone like any other walk-in', async () => {
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Declined' },
payment: { method: 'pos' },
idempotencyKey: 'key-walkin-pos-undo',
});
await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-walkin-pos-undo' });
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.status).toBe('cancelled');
});
it('is rejected when POS is disabled for the event', async () => {
const { status, body } = await post(`/api/events/${NO_POS_EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Nope' },
payment: { method: 'pos' },
idempotencyKey: 'key-walkin-pos-disabled',
});
expect(status).toBe(400);
expect(body.code).toBe('METHOD_DISABLED');
});
});
+746
View File
@@ -0,0 +1,746 @@
// Door check-in screen (admin/scanner) API.
//
// At the door, check-in and ticket creation are the same action, so everything
// here is written for one-tap speed on a phone with unreliable venue wifi:
//
// GET /:eventId/door-attendees full attendee list, fetched once and searched
// client-side so typing never hits the network
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
// payment, or creates a walk-in, atomically
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
// split (admin/organizer only)
//
// Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout
// can never produce a second ticket, a second payment or a double check-in — the
// replay returns the original response instead.
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and, inArray, sql } from 'drizzle-orm';
import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
} from '../db/index.js';
import { requireEventPermission, eventFromParam, canSeeAttendeePii } from '../lib/eventPermissions.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import {
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
paymentStatusForMethod, loadDoorMethods, type DoorPaymentMethod,
} from '../lib/doorPayments.js';
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
import emailService from '../lib/email.js';
const doorRouter = new Hono();
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
// Whole-event money is management information, not door information: door staff
// reconcile their own shift from the session feed the client keeps locally, and
// never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const;
// The server prices every door charge from the event record. Only the roles the
// app treats as administrators may override that with a typed amount, and every
// override is written to audit_logs.
const AMOUNT_OVERRIDE_ROLES = ['admin', 'organizer'] as const;
const MAX_DOOR_QUANTITY = 50;
const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ====================
const num = (v: any): number => {
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
return Number.isFinite(n) ? n : 0;
};
const iso = (v: any): string | null => {
if (!v) return null;
return v instanceof Date ? v.toISOString() : String(v);
};
function fullName(ticket: any): string {
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
}
/**
* The row shape the door screen renders. Returned both by the preload list and
* by every write, so the client can splice an updated attendee straight back
* into its in-memory list without a refetch.
*/
function toDoorAttendee(
ticket: any,
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
) {
return {
ticketId: ticket.id,
firstName: ticket.attendeeFirstName,
lastName: ticket.attendeeLastName || null,
fullName: fullName(ticket),
email: ticket.attendeeEmail || null,
phone: ticket.attendeePhone || null,
status: ticket.status,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
checkedIn: ticket.status === 'checked_in',
checkinAt: iso(ticket.checkinAt),
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
bookingId: ticket.bookingId || null,
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
doorMethod: opts.doorMethod ?? null,
qrCode: ticket.qrCode || null,
createdAt: iso(ticket.createdAt),
};
}
async function loadEvent(eventId: string | undefined) {
if (!eventId) return null;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) return null;
return {
...event,
price: num(event.price),
walkInPrice: parseWalkInPrice(event.walkInPrice),
capacity: Number(event.capacity),
};
}
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
const unique = [...new Set(adminIds.filter(Boolean))];
if (unique.length === 0) return new Map();
const rows = await dbAll<any>(
(db as any)
.select({ id: (users as any).id, name: (users as any).name })
.from(users)
.where(inArray((users as any).id, unique))
);
return new Map(rows.map((r: any) => [r.id, r.name]));
}
/** Seats currently held for an event, used only to warn (never to block) at the door. */
async function seatsHeld(eventId: string): Promise<number> {
const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
return Number(row?.count || 0);
}
// ==================== GET /:eventId/door-attendees ====================
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
// tickets are included on purpose: staff must be able to see and reactivate them.
doorRouter.get('/:eventId/door-attendees', requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const rows = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
);
// A booking id shared by more than one ticket marks a group booking, which is
// the usual reason an otherwise-confirmed attendee still shows as unpaid.
const bookingCounts = new Map<string, number>();
for (const t of rows) {
if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
}
const groupBookingIds = new Set(
[...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
);
const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));
// Door tender per ticket, so a row already settled at the door shows how.
// Joined on the event rather than on a list of ticket ids: the id list would
// grow with the guest list and eventually blow the statement parameter limit.
const doorMethods = new Map<string, string>();
const doorPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
eq((payments as any).source, 'door')
))
);
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
const enabledMethods = await loadDoorMethods(event.id);
const walkIn = resolveWalkInPrice(event);
const attendees = rows
.map((t: any) => toDoorAttendee(t, {
price: event.price,
groupBookingIds,
adminNames,
doorMethod: doorMethods.get(t.id) || null,
}))
.map((a) => (canSeeAttendeePii(c) ? a : { ...a, email: null, phone: null }))
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
const checkedIn = attendees.filter((a) => a.checkedIn).length;
const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;
return c.json({
event: {
id: event.id,
title: event.title,
price: event.price,
// What one walk-in ticket costs, resolved server-side (walk-in price, or
// the ticket price when none is set) so the screen shows what will be charged.
walkInPrice: event.walkInPrice,
walkInUnitPrice: walkIn.unitPrice,
walkInPriceSource: walkIn.source,
currency: event.currency,
capacity: event.capacity,
},
doorMethods: enabledMethods,
attendees,
stats: { checkedIn, totalActive, capacity: event.capacity },
});
});
// ==================== POST /:eventId/door-checkin ====================
const doorCheckinSchema = z.object({
// Existing ticket to check in (and optionally settle), or…
ticketId: z.string().optional(),
// …a walk-in to create. Only a first name is ever required.
attendee: z.object({
firstName: z.string().trim().min(1).max(255),
lastName: z.string().trim().max(255).optional().or(z.literal('')),
phone: z.string().trim().max(50).optional().or(z.literal('')),
email: z.string().trim().email().optional().or(z.literal('')),
ruc: z.string().trim().max(15).optional().or(z.literal('')),
}).optional(),
payment: z.object({
method: z.enum(DOOR_PAYMENT_METHODS),
// How many tickets' worth is being paid (someone paying for their group).
// The server prices it: walk-in price for walk-ins, ticket price otherwise.
quantity: z.number().int().min(1).max(MAX_DOOR_QUANTITY).optional(),
// A typed amount is ignored unless amountOverride is set, which only
// admin/organizer may do.
amount: z.number().min(0).optional(),
amountOverride: z.boolean().optional(),
}).refine((p) => !p.amountOverride || typeof p.amount === 'number', {
message: 'amount is required when amountOverride is set',
path: ['amount'],
}).optional(),
// How the attendee reached this action, for the session feed.
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
idempotencyKey: z.string().min(8).max(128),
}).refine((d) => !!d.ticketId || !!d.attendee, {
message: 'Either ticketId or attendee is required',
path: ['ticketId'],
});
/** Undo instructions recorded alongside each processed idempotency key. */
type UndoState =
| {
kind: 'created';
ticketId: string;
paymentId: string;
}
| {
kind: 'existing';
ticketId: string;
prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
createdPaymentId?: string;
prevPayment?: {
id: string; provider: string; amount: number; status: string; reference: string | null;
paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
};
};
/** A replay of a key we already processed returns the original response verbatim. */
async function findProcessedKey(key: string) {
return dbGet<any>(
(db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
);
}
doorRouter.post(
'/:eventId/door-checkin',
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
zValidator('json', doorCheckinSchema),
async (c) => {
const eventId = c.req.param('eventId');
const data = c.req.valid('json');
const adminUser = (c as any).get('user');
const existingKey = await findProcessedKey(data.idempotencyKey);
if (existingKey) {
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
}
const amountOverride = !!data.payment?.amountOverride;
if (amountOverride && !(AMOUNT_OVERRIDE_ROLES as readonly string[]).includes(adminUser?.role)) {
return c.json({ error: 'Only an admin can override the door amount', code: 'OVERRIDE_FORBIDDEN' }, 403);
}
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const method = data.payment?.method as DoorPaymentMethod | undefined;
if (method && !(await loadDoorMethods(event.id)).includes(method)) {
return c.json({ error: `${DOOR_TENDERS[method].label} is not enabled for this event`, code: 'METHOD_DISABLED' }, 400);
}
const now = getNow();
const nowIso = new Date().toISOString();
const quantity = data.payment?.quantity ?? 1;
// Walk-ins pay the walk-in price (falling back to the ticket price); an
// existing unpaid ticket settles at the current ticket price (its pending
// payment is repriced whenever the event price changes).
const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice;
const computedAmount = unitPrice * quantity;
const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount;
const ops: TxOp[] = [];
let undoState: UndoState;
let action: 'checkin' | 'walkin';
let ticketRow: any;
let paymentSummary: {
id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean;
} | null = null;
let emailTicketId: string | null = null;
if (data.ticketId) {
// ---- Existing ticket: settle (optionally) and check in ----
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
);
if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
if (ticket.eventId !== eventId) {
return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
}
action = 'checkin';
const prevTicket = {
status: ticket.status,
checkinAt: iso(ticket.checkinAt),
checkedInByAdminId: ticket.checkedInByAdminId || null,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
};
const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };
const ticketUpdate: Record<string, any> = {};
if (method) {
const amount = amountForMethod(method, requestedAmount);
const tender = DOOR_TENDERS[method];
ticketUpdate.paymentStatus = paymentStatusForMethod(method);
if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);
const existingPayment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
);
if (existingPayment) {
undo.prevPayment = {
id: existingPayment.id,
provider: existingPayment.provider,
amount: num(existingPayment.amount),
status: existingPayment.status,
reference: existingPayment.reference || null,
paidAt: iso(existingPayment.paidAt),
paidByAdminId: existingPayment.paidByAdminId || null,
source: existingPayment.source || 'presale',
method: existingPayment.method || null,
};
ops.push(updateOp(payments, {
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(method),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method,
updatedAt: now,
}, eq((payments as any).id, existingPayment.id)));
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
} else {
const paymentId = generateId();
undo.createdPaymentId = paymentId;
ops.push(insertOp(payments, {
id: paymentId,
ticketId: ticket.id,
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(method),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method,
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
}
}
// Check in. An already-checked-in ticket keeps its original timestamp so
// staff can still tell the person when they actually entered.
if (ticket.status !== 'checked_in') {
ticketUpdate.status = 'checked_in';
ticketUpdate.checkinAt = now;
ticketUpdate.checkedInByAdminId = adminUser?.id || null;
}
if (Object.keys(ticketUpdate).length > 0) {
ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
}
undoState = undo;
ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
} else {
// ---- Walk-in: born confirmed, settled and checked in, in one write ----
const attendee = data.attendee!;
action = 'walkin';
const tenderMethod: DoorPaymentMethod = method || 'cash';
const tender = DOOR_TENDERS[tenderMethod];
const amount = amountForMethod(tenderMethod, requestedAmount);
const hasEmail = !!(attendee.email && attendee.email.trim());
const firstNameValue = attendee.firstName.trim();
const lastNameValue = attendee.lastName?.trim() || null;
const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;
// No email is the fast path; a placeholder keeps the users.email unique
// constraint satisfied without ever mailing anyone.
// Normalized: Better Auth lowercases every lookup it makes, and the
// users.email unique index is case-sensitive, so a mixed-case address
// written here would be invisible to sign-in and Google linking.
const accountEmail = normalizeEmail(
hasEmail
? attendee.email!
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
);
let user = hasEmail
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
: null;
if (!user) {
const userId = generateId();
user = { id: userId, email: accountEmail };
ops.push(insertOp(users, {
id: userId,
email: accountEmail,
password: null,
name: displayName,
phone: attendee.phone?.trim() || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
}));
}
const ticketId = generateId();
const paymentId = generateId();
const newTicket = {
id: ticketId,
bookingId: null,
userId: user.id,
eventId,
attendeeFirstName: firstNameValue,
attendeeLastName: lastNameValue,
attendeeEmail: hasEmail ? attendee.email!.trim() : null,
attendeePhone: attendee.phone?.trim() || null,
attendeeRuc: attendee.ruc?.trim() || null,
preferredLanguage: null,
status: 'checked_in',
paymentStatus: paymentStatusForMethod(tenderMethod),
isGuest: toDbBool(tenderMethod === 'guest'),
qrCode: generateTicketCode(),
checkinAt: now,
checkedInByAdminId: adminUser?.id || null,
adminNote: null,
bookingSource: 'walk_in',
createdAt: now,
};
ops.push(insertOp(tickets, newTicket));
ops.push(insertOp(payments, {
id: paymentId,
ticketId,
provider: tender.provider,
amount,
currency: event.currency,
status: 'paid',
reference: doorReference(tenderMethod),
paidAt: now,
paidByAdminId: adminUser?.id || null,
source: 'door',
method: tenderMethod,
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
undoState = { kind: 'created', ticketId, paymentId };
ticketRow = newTicket;
// Only mail people who actually gave an address; no QR for the rest.
if (hasEmail) emailTicketId = ticketId;
}
// Written in the same transaction as the payment, so the log can never
// disagree with what was recorded.
if (paymentSummary?.amountOverridden) {
ops.push(insertOp(auditLogs, {
id: generateId(),
userId: adminUser?.id || null,
action: 'door_amount_override',
target: 'payment',
targetId: paymentSummary.id,
details: JSON.stringify({
eventId,
ticketId: ticketRow.id,
method: paymentSummary.method,
quantity,
computedAmount,
chargedAmount: paymentSummary.amount,
currency: event.currency,
}),
timestamp: now,
}));
}
// Staff at the door is the authority: a full event is a warning, never a block.
const held = await seatsHeld(eventId);
const atCapacity = event.capacity > 0 && held >= event.capacity;
const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
const responseBody = {
ok: true,
action,
attendee: toDoorAttendee(ticketRow, {
price: event.price,
groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
adminNames,
doorMethod: paymentSummary?.method || null,
}),
payment: paymentSummary,
warnings: atCapacity ? ['at_capacity'] : [],
idempotencyKey: data.idempotencyKey,
processedAt: nowIso,
};
// The key row goes in with the writes, so two concurrent replays of the same
// key cannot both commit — the loser hits the primary-key conflict below.
ops.unshift(insertOp(idempotencyKeys, {
key: data.idempotencyKey,
scope: IDEMPOTENCY_SCOPE,
result: JSON.stringify(responseBody),
undoState: JSON.stringify(undoState),
undoneAt: null,
createdAt: now,
}));
try {
await runOps(ops);
} catch (err: any) {
const replay = await findProcessedKey(data.idempotencyKey);
if (replay) {
return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
}
throw err;
}
if (paymentSummary?.amountOverridden) {
console.info(
`[Door] Amount override by ${adminUser?.id} (${adminUser?.role}) on event ${eventId}: ` +
`${paymentSummary.amount} ${event.currency} instead of ${computedAmount} (${paymentSummary.method})`
);
}
if (emailTicketId) {
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
console.error('[Email] Failed to send door walk-in confirmation:', err);
});
}
return c.json(responseBody, 201);
}
);
// ==================== POST /:eventId/door-checkin/undo ====================
// Reverses exactly what the keyed action did — nothing more. This is what makes
// the door screen safe to run without a single confirm dialog.
doorRouter.post(
'/:eventId/door-checkin/undo',
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
async (c) => {
const { idempotencyKey } = c.req.valid('json');
const record = await findProcessedKey(idempotencyKey);
if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });
const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);
const now = getNow();
const ops: TxOp[] = [];
if (undo.kind === 'created') {
// Walk-ins created here are cancelled, not deleted: the row stays as an
// audit trail and can be reactivated from the same screen.
ops.push(updateOp(tickets, {
status: 'cancelled',
checkinAt: null,
checkedInByAdminId: null,
}, eq((tickets as any).id, undo.ticketId)));
ops.push(updateOp(payments, {
status: 'cancelled',
paidAt: null,
updatedAt: now,
}, eq((payments as any).id, undo.paymentId)));
} else {
ops.push(updateOp(tickets, {
status: undo.prevTicket.status,
checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
paymentStatus: undo.prevTicket.paymentStatus,
isGuest: toDbBool(undo.prevTicket.isGuest),
}, eq((tickets as any).id, undo.ticketId)));
if (undo.createdPaymentId) {
ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
} else if (undo.prevPayment) {
const prev = undo.prevPayment;
ops.push(updateOp(payments, {
provider: prev.provider,
amount: prev.amount,
status: prev.status,
reference: prev.reference,
paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
paidByAdminId: prev.paidByAdminId,
source: prev.source,
method: prev.method,
updatedAt: now,
}, eq((payments as any).id, prev.id)));
}
}
ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));
await runOps(ops);
return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
}
);
// ==================== GET /:eventId/door-summary ====================
// End-of-night reconciliation: what was taken at the door, by tender, plus the
// pre-sale/door split the event dashboard shows.
doorRouter.get('/:eventId/door-summary', requireEventPermission('view_payments', { globalRoles: REVENUE_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
// Door payments settled for this event, with the attendee attached so the
// session feed can show who each line belongs to.
const rows = await dbAll<any>(
(db as any)
.select({
paymentId: (payments as any).id,
ticketId: (tickets as any).id,
method: (payments as any).method,
amount: (payments as any).amount,
paidAt: (payments as any).paidAt,
firstName: (tickets as any).attendeeFirstName,
lastName: (tickets as any).attendeeLastName,
ticketStatus: (tickets as any).status,
})
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.where(and(
eq((tickets as any).eventId, eventId),
eq((payments as any).source, 'door'),
eq((payments as any).status, 'paid')
))
);
const byMethod: Record<string, { count: number; total: number }> = {};
for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };
let doorTotal = 0;
for (const r of rows) {
const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
const amount = num(r.amount);
byMethod[key].count += 1;
byMethod[key].total += amount;
doorTotal += amount;
}
// Pre-sale revenue: settled tickets that weren't taken at the door, at the
// amount actually paid — never the current event price, which an admin can
// change after tickets have been sold.
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
const settled = await dbAll<any>(
(db as any)
.select({ id: (tickets as any).id, amount: (payments as any).amount })
.from(tickets)
.leftJoin(payments, and(
eq((payments as any).ticketId, (tickets as any).id),
eq((payments as any).status, 'paid')
))
.where(and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).paymentStatus, 'paid'),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
))
);
const presaleIds = new Set<string>();
let presaleTotal = 0;
for (const t of settled) {
if (doorTicketIds.has(t.id)) continue;
presaleIds.add(t.id);
presaleTotal += num(t.amount);
}
const presaleCount = presaleIds.size;
return c.json({
eventId,
currency: event.currency,
price: event.price,
door: {
count: rows.length,
total: doorTotal,
byMethod,
lines: rows
.map((r: any) => ({
paymentId: r.paymentId,
ticketId: r.ticketId,
name: `${r.firstName} ${r.lastName || ''}`.trim(),
method: r.method || 'cash',
amount: num(r.amount),
paidAt: iso(r.paidAt),
}))
.sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
},
presale: { count: presaleCount, total: presaleTotal },
total: presaleTotal + doorTotal,
});
});
export default doorRouter;
+6 -5
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { requireEventPermission, eventFromParam, eventFromQuery } from '../lib/eventPermissions.js';
import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
@@ -58,7 +59,7 @@ function safeParseVariables(raw: any): any[] {
// ==================== Template Routes ====================
// Get all email templates
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.get('/templates', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
const templates = await dbAll<any>(
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
);
@@ -239,7 +240,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
// ==================== Email Sending Routes ====================
// Send email using template to event attendees (non-blocking, queued)
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.post('/send/event/:eventId', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
const { eventId } = c.req.param();
const user = (c as any).get('user');
const body = await c.req.json();
@@ -286,7 +287,7 @@ emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidato
});
// Preview email (render template without sending)
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.post('/preview', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
const body = await c.req.json();
const { templateSlug, variables, locale } = body;
@@ -327,7 +328,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
// ==================== Email Logs Routes ====================
// Get email logs
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.get('/logs', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
const search = c.req.query('search');
@@ -420,7 +421,7 @@ emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async
});
// Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.get('/stats', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
const eventId = c.req.query('eventId');
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
@@ -0,0 +1,387 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomUUID } from 'crypto';
// Env must be pinned before the db singleton is imported (dotenv never overrides).
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
// URL to run the same suite in a throwaway schema (dropped afterwards).
const PG_URL = process.env.FINANCE_TEST_PG_URL;
const PG_SCHEMA = `fintest_${Date.now()}`;
if (PG_URL) {
process.env.DB_TYPE = 'postgres';
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
} else {
const dir = mkdtempSync(join(tmpdir(), 'finance-test-'));
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = join(dir, 'test.db');
}
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'finance-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
type TestUser = { id: string; name: string; role: string; languagePreference?: string | null };
const ADMIN: TestUser = { id: randomUUID(), name: 'The Admin', role: 'admin' };
const ORGANIZER: TestUser = { id: randomUUID(), name: 'The Organizer', role: 'organizer' };
const COLLAB: TestUser = { id: randomUUID(), name: 'Pilates Studio', role: 'user' };
const COMANAGER: TestUser = { id: randomUUID(), name: 'Co Manager', role: 'user' };
const DOOR: TestUser = { id: randomUUID(), name: 'Door Helper', role: 'user' };
const STRANGER: TestUser = { id: randomUUID(), name: 'Stranger', role: 'user' };
// Session auth is Better Auth's concern and has its own suite; this keeps the
// role and membership checks real.
let currentUser: TestUser = ADMIN;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
vi.mock('../lib/email.js', () => ({ default: {} }));
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
let app: any;
let dbm: any;
const EVENT_ID = randomUUID();
const OTHER_EVENT_ID = randomUUID();
const SEED_USER_ID = randomUUID();
const PRICE = 100000;
async function call(method: string, path: string, body?: unknown) {
const res = await app.request(path, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const type = res.headers.get('content-type') || '';
return { status: res.status, type, body: type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer()) };
}
const get = (p: string) => call('GET', p);
const post = (p: string, b: unknown = {}) => call('POST', p, b);
const put = (p: string, b: unknown) => call('PUT', p, b);
// Seeded through drizzle so the same rows work on both engines.
async function seedTicket(label: string, eventId: string, opts: { status: string; paymentStatus: string; pay?: { amount: number; provider: string; source?: string; status?: string } }) {
const { db, tickets, payments } = dbm;
const now = dbm.getNow();
const id = randomUUID();
await db.insert(tickets).values({
id, userId: SEED_USER_ID, eventId, attendeeFirstName: `Guest ${label}`, attendeeLastName: 'Test',
attendeeEmail: `${label}@test.py`, attendeePhone: '+595 981 000 000', status: opts.status,
paymentStatus: opts.paymentStatus, isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
});
if (opts.pay) {
await db.insert(payments).values({
id: randomUUID(), ticketId: id, provider: opts.pay.provider, amount: opts.pay.amount, currency: 'PYG',
status: opts.pay.status || 'paid', source: opts.pay.source || 'presale', paidAt: now, createdAt: now, updatedAt: now,
});
}
}
beforeAll(() => {
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
app = new Hono();
app.route('/api/events', (await import('./door.js')).default);
app.route('/api/events', (await import('./eventFinance.js')).default);
app.route('/api/events', (await import('./events.js')).default);
app.route('/api/finance', (await import('./finance.js')).default);
app.route('/api/dashboard', (await import('./dashboard.js')).default);
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')) };
const { db, users, events } = dbm;
const now = dbm.getNow();
for (const u of [ADMIN, ORGANIZER, COLLAB, COMANAGER, DOOR, STRANGER, { id: SEED_USER_ID, name: 'Seed', role: 'user' }]) {
await db.insert(users).values({
id: u.id, email: `${u.name.toLowerCase().replace(/ /g, '.')}@test.py`, name: u.name, role: u.role,
isClaimed: dbm.toDbBool(true), accountStatus: 'active', createdAt: now, updatedAt: now,
});
}
for (const [id, title] of [[EVENT_ID, 'Morning Club: Pilates Edition'], [OTHER_EVENT_ID, 'Some Other Event']]) {
await db.insert(events).values({
id, title, description: 'desc', startDatetime: now, location: 'Studio Uno', price: PRICE, currency: 'PYG',
capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
});
}
// 10 pre-sale TPago tickets (8 checked in), 2 cash walk-ins at the door, 1 refund.
for (let i = 0; i < 10; i++) {
await seedTicket(`t${i}`, EVENT_ID, { status: i < 8 ? 'checked_in' : 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
}
await seedTicket('door1', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
await seedTicket('door2', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
await seedTicket('refunded', EVENT_ID, { status: 'cancelled', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago', status: 'refunded' } });
await seedTicket('other1', OTHER_EVENT_ID, { status: 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
})();
}, 120_000);
afterAll(() => {
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
});
describe('event finance access', () => {
it('lets admins in and keeps organizers out until they are granted access', async () => {
expect((await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(403);
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
const perms = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/my-permissions`));
expect(perms.body.permissions).toContain('view_payments');
expect(perms.body.permissions).not.toContain('view_finance');
});
it('lets an admin add team members, and writes the audit log', async () => {
const add = (userId: string, rolePreset: string, permissions?: Record<string, boolean>) =>
as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId, rolePreset, permissions }));
expect((await add(COLLAB.id, 'collaborator')).status).toBe(201);
expect((await add(COMANAGER.id, 'co_manager')).status).toBe(201);
expect((await add(DOOR.id, 'staff')).status).toBe(201);
expect((await add(COLLAB.id, 'staff')).status).toBe(409);
const candidates = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members/candidates?q=stran`));
expect(candidates.body.users.map((u: any) => u.id)).toEqual([STRANGER.id]);
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
expect(log.body.entries.filter((e: any) => e.entityType === 'member' && e.action === 'create')).toHaveLength(3);
});
it('gives a collaborator only their events and permitted routes', async () => {
const mine = await as(COLLAB, () => get('/api/dashboard/my-events'));
expect(mine.status).toBe(200);
expect(mine.body.events.map((e: any) => e.event.id)).toEqual([EVENT_ID]);
expect(mine.body.events[0].permissions).toEqual(['view_overview', 'view_finance']);
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
// Other events' data
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/attendees`))).status).toBe(403);
// Routes on their own event that the preset does not grant
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/attendees`))).status).toBe(403);
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-summary`))).status).toBe(403);
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/audit-log`))).status).toBe(403);
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'x', unitAmount: 1 }))).status).toBe(403);
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}`, { title: 'Hacked' }))).status).toBe(403);
expect((await as(COLLAB, () => get('/api/finance/overview'))).status).toBe(403);
expect((await as(COLLAB, () => get('/api/finance/settings/expense-templates'))).status).toBe(403);
});
it('shows staff members attendee names without contact details', async () => {
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
expect(res.status).toBe(200);
expect(res.body.attendees.length).toBeGreaterThan(0);
expect(res.body.attendees.every((a: any) => a.attendeeEmail === null && a.attendeePhone === null)).toBe(true);
const door = await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`));
expect(door.status).toBe(200);
expect(door.body.attendees.every((a: any) => a.email === null)).toBe(true);
// Admins still get everything
const full = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/attendees`));
expect(full.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
});
it('applies per-member overrides in both directions', async () => {
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
const door = list.body.members.find((m: any) => m.userId === DOOR.id);
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${door.id}`, { permissions: { view_attendees_pii: true, check_in: false } }));
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
expect(res.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
});
});
describe('Morning Club: Pilates Edition finance flow', () => {
let packId = '';
let partnerId = '';
it('builds a template pack in settings', async () => {
const cats = await as(ADMIN, () => get('/api/finance/settings/expense-categories'));
const venue = cats.body.categories.find((c: any) => c.nameEn === 'Venue');
const make = (body: any) => as(ADMIN, () => post('/api/finance/settings/expense-templates', body));
const studio = await make({ name: 'Studio minimum spend', categoryId: venue.id, calcType: 'minimum_spend', amount: 30000, minimumAmount: 500000 });
const mats = await make({ name: 'Mat rental', calcType: 'per_checked_in', amount: 5000 });
const instructor = await make({ name: 'Instructor', calcType: 'fixed', amount: 300000 });
const promo = await make({ name: 'Promo share', calcType: 'percent_of_revenue', percentBp: 500 });
expect([studio, mats, instructor, promo].map((r) => r.status)).toEqual([201, 201, 201, 201]);
const pack = await as(ADMIN, () => post('/api/finance/settings/expense-template-packs', {
name: 'Morning Club pack', templateIds: [studio.body.template.id, mats.body.template.id, instructor.body.template.id, promo.body.template.id],
}));
expect(pack.status).toBe(201);
packId = pack.body.pack.id;
expect(pack.body.pack.templateIds).toHaveLength(4);
});
it('applies the pack to the event with amounts from current counts', async () => {
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses/apply-template`, { packId }));
expect(res.status).toBe(201);
expect(res.body.expenses.map((e: any) => [e.description, e.computedAmount])).toEqual([
['Studio minimum spend', 500000], // 10 checked in x 30k = 300k < 500k minimum
['Mat rental', 50000],
['Instructor', 300000],
['Promo share', 62000], // 5% of 1,240,000 sales after the refund
]);
});
it('adds a partner at a percent of profit and computes the split', async () => {
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, {
userId: COLLAB.id, roleLabel: 'Studio', shareType: 'percent_profit', percentBp: 3000, lossRule: 'proportional',
}));
expect(res.status).toBe(201);
partnerId = res.body.partner.id;
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
const s = fin.body.summary;
expect(s.revenue.gross).toBe(1340000);
expect(s.revenue.refunds).toBe(100000);
expect(s.revenue.presale).toBe(1100000);
expect(s.revenue.door).toBe(240000);
expect(s.expenses.total).toBe(912000);
expect(s.profit).toBe(1240000 - 912000);
expect(s.split.partners[0].share).toBe(Math.round(328000 * 0.3));
expect(s.split.organization).toBe(328000 - 98400);
expect(s.breakEven.tickets).toBeGreaterThan(0);
});
it('shows the collaborator their own share but not the full split', async () => {
const fin = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`));
expect(fin.body.viewer.fullSplit).toBe(false);
expect(fin.body.summary.split.organization).toBeNull();
expect(fin.body.summary.split.partners.map((p: any) => p.partnerId)).toEqual([partnerId]);
expect(fin.body.summary.waterfall.some((w: any) => w.key === 'organization')).toBe(false);
});
it('lets an organizer in once they are granted finance on this event', async () => {
await as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId: ORGANIZER.id, rolePreset: 'collaborator', permissions: { view_full_split: true } }));
const fin = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`));
expect(fin.status).toBe(200);
expect(fin.body.viewer.fullSplit).toBe(true);
expect((await as(ORGANIZER, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
});
it('finalizes, freezes the numbers and blocks edits', async () => {
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/finance/finalize`))).status).toBe(403);
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/finance/finalize`));
expect(res.status).toBe(200);
// A late walk-in no longer moves the finalized numbers
await seedTicket('late', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
expect(fin.body.status).toBe('finalized');
expect(fin.body.live).toBe(false);
expect(fin.body.summary.revenue.gross).toBe(1340000);
const blocked = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Late cost', unitAmount: 1000 }));
expect(blocked.status).toBe(409);
expect(blocked.body.code).toBe('FINANCE_FINALIZED');
});
it('exports the partner statement to the partner and not to others', async () => {
const pdf = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement?locale=es`));
expect(pdf.status).toBe(200);
expect(pdf.type).toBe('application/pdf');
expect((pdf.body as Buffer).subarray(0, 4).toString()).toBe('%PDF');
const other = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, { externalName: 'Someone', shareType: 'fixed', fixedAmount: 1 }));
expect(other.status).toBe(409); // finalized
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement`))).status).toBe(403);
});
it('marks the payout and moves the event to paid out', async () => {
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners/${partnerId}/mark-paid`, { paid: true, payoutMethod: 'transfer' }));
expect(res.status).toBe(200);
expect(res.body.status).toBe('paid_out');
expect(res.body.partner.payoutStatus).toBe('paid');
});
it('only lets admins and co-managers unfinalize, and logs it', async () => {
expect((await as(ORGANIZER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(403);
expect((await as(COMANAGER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(200);
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
expect(fin.body.status).toBe('open');
expect(fin.body.summary.revenue.gross).toBe(1460000); // the late walk-in now counts
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
const actions = log.body.entries.filter((e: any) => e.entityType === 'finance_state').map((e: any) => e.action);
expect(actions).toEqual(expect.arrayContaining(['finalize', 'paid_out', 'unfinalize']));
});
it('limits edit_own_expenses_only members to their own rows', async () => {
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
const collab = list.body.members.find((m: any) => m.userId === COLLAB.id);
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${collab.id}`, { permissions: { edit_own_expenses_only: true } }));
const own = await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Flowers', unitAmount: 40000, status: 'paid', paidByPartnerId: partnerId }));
expect(own.status).toBe(201);
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${own.body.expense.id}`, { unitAmount: 45000 }))).status).toBe(200);
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
const adminRow = fin.body.expenses.find((e: any) => e.createdBy === ADMIN.id);
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${adminRow.id}`, { unitAmount: 1 }))).status).toBe(403);
// The reimbursement shows up on the partner line
expect(fin.body.summary.split.partners[0].reimbursement).toBe(45000);
});
it('includes the event in the cross-event overview', async () => {
await as(ADMIN, () => put(`/api/events/${EVENT_ID}`, { series: 'Morning Club' }));
const res = await as(ADMIN, () => get('/api/finance/overview?series=Morning%20Club'));
expect(res.status).toBe(200);
expect(res.body.events.map((e: any) => e.id)).toEqual([EVENT_ID]);
expect(res.body.bySeries[0].series).toBe('Morning Club');
expect(res.body.byPartner[0].name).toBe('Pilates Studio');
expect(res.body.filters.series).toEqual(['Morning Club']);
});
});
describe('cross-event overview: ready to close', () => {
const PAST_QUIET = 'evt-past-quiet';
const FUTURE = 'evt-future';
const PAST_DRAFT = 'evt-past-draft';
beforeAll(async () => {
const { db, events } = dbm;
const now = dbm.getNow();
const at = (iso: string) => dbm.toDbDate(iso);
for (const [id, title, start, status] of [
[PAST_QUIET, 'Quiet past event', '2026-01-10T20:00:00Z', 'published'],
[FUTURE, 'Future event', '2099-01-10T20:00:00Z', 'published'],
[PAST_DRAFT, 'Past draft', '2026-01-11T20:00:00Z', 'draft'],
] as const) {
await db.insert(events).values({
id, title, description: 'desc', startDatetime: at(start), location: 'Studio Uno', price: PRICE, currency: 'PYG',
capacity: 40, status, externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
});
}
});
it('lists past events with open books even with no money, and keeps them out of the totals', async () => {
const res = await as(ADMIN, () => get('/api/finance/overview'));
expect(res.status).toBe(200);
const ready = res.body.readyToClose.map((e: any) => e.id);
expect(ready).toContain(PAST_QUIET);
expect(ready).not.toContain(FUTURE);
expect(ready).not.toContain(PAST_DRAFT);
expect(res.body.events.map((e: any) => e.id)).not.toContain(PAST_QUIET);
// Longest-waiting first
const starts = res.body.readyToClose.map((e: any) => e.startDatetime);
expect([...starts].sort()).toEqual(starts);
});
it('respects the venue filter', async () => {
const res = await as(ADMIN, () => get('/api/finance/overview?venue=Nowhere'));
expect(res.body.readyToClose).toEqual([]);
});
});
+936
View File
@@ -0,0 +1,936 @@
// Event finance, partners and team access, all scoped to one event and
// mounted under /api/events:
//
// GET /:id/my-permissions what the current user may do here
// GET /:id/finance P&L summary, chart data and the rows behind it
// POST /:id/finance/finalize | unfinalize freeze / reopen the numbers
// CRUD /:id/expenses (+ /apply-template) costs, manual or from templates / packs
// CRUD /:id/other-income sponsors, venue kickbacks, ...
// CRUD /:id/partners (+ /:pid/mark-paid, /:pid/statement PDF)
// CRUD /:id/members (+ /candidates) per-event team access
// GET /:id/audit-log
//
// Every route is guarded by requireEventPermission; only global admins pass on
// every event. Every write commits together with its finance_audit_log row.
import { Hono, type Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, desc, eq, inArray, notInArray, or, sql } from 'drizzle-orm';
import {
db, dbAll, dbGet, users, eventExpenses, eventOtherIncome, eventPartners, eventFinanceState, eventMembers,
expenseCategories, expenseTemplates, expenseTemplatePackItems, financeAuditLog,
} from '../db/index.js';
import { requireAuth, type AuthUser } from '../lib/auth.js';
import {
requireEventPermission, getEventAccess, getEffectivePermissions, canUnfinalize, resolveMemberPermissions,
parseOverrides, EVENT_PERMISSIONS, ROLE_PRESETS, type EventPermission,
} from '../lib/eventPermissions.js';
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { financeAuditOp } from '../lib/finance/audit.js';
import {
getEventFinance, getEvent, getFinanceState, serializeExpense, serializeIncome, serializePartner, iso, pyg, bool,
loadPartners, type SerializedExpense,
} from '../lib/finance/load.js';
import { expenseAmount, isAutoCalc, CALC_TYPES, SHARE_TYPES, LOSS_RULES } from '../lib/finance/calculate.js';
import { generatePartnerStatementPDF } from '../lib/finance/statementPdf.js';
import { omitWalkInPrice } from '../lib/walkInPrice.js';
const financeRouter = new Hono();
const validationHook = (result: any, c: any) => {
if (!result.success) {
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
return c.json({ error: errors }, 400);
}
};
const MAX_PYG = 2_000_000_000;
const money = z.number().int().min(0).max(MAX_PYG);
const bp = z.number().int().min(0).max(10000);
// Receipts come from the media upload (/uploads/...) or an external link; never javascript: etc.
const receiptUrl = z.string().max(500).regex(/^(https?:\/\/|\/uploads\/)/, 'must be an http(s) or /uploads/ URL');
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
const can = (c: Context, key: EventPermission) => !!getEventAccess(c)?.permissions.has(key);
async function requireEvent(c: Context) {
const event = await getEvent(c.req.param('id')!);
return event || null;
}
/** 409 while the numbers are frozen. */
async function assertOpen(c: Context, eventId: string) {
const state = await getFinanceState(eventId);
if (state.status !== 'open') {
return c.json({ error: 'Finance is finalized for this event. Unfinalize it to make changes.', code: 'FINANCE_FINALIZED' }, 409);
}
return null;
}
/** Counts and sales the auto-calculated expenses follow right now. */
async function liveContext(eventId: string, event: any) {
const fin = await getEventFinance(eventId, event);
return {
ticketsSold: fin!.result.counts.ticketsSold,
checkedIn: fin!.result.counts.checkedIn,
sales: fin!.result.revenue.sales,
};
}
async function partnerBelongsToEvent(partnerId: string, eventId: string) {
const row = await dbGet<any>(
(db as any).select({ id: (eventPartners as any).id }).from(eventPartners)
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
);
return !!row;
}
async function categoryExists(categoryId: string) {
const row = await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId)));
return !!row;
}
// ==================== Permissions for the UI ====================
financeRouter.get('/:id/my-permissions', requireAuth(), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const access = await getEffectivePermissions(currentUser(c), event.id);
return c.json({
eventId: event.id,
global: access.global,
role: access.role,
rolePreset: access.membership?.rolePreset ?? null,
permissions: EVENT_PERMISSIONS.filter((p) => access.permissions.has(p)),
canUnfinalize: canUnfinalize(access),
});
});
// ==================== Summary ====================
/**
* Collaborators without view_full_split see the event's P&L and their own
* partner line only: other partners and the organization's remainder are removed.
*/
function scopeToOwnShare<T extends NonNullable<Awaited<ReturnType<typeof getEventFinance>>>>(fin: T, userId: string) {
const own = new Set(fin.partners.filter((p) => p.userId === userId).map((p) => p.id));
return {
...fin,
partners: fin.partners.filter((p) => own.has(p.id)),
expenses: fin.expenses.map((e) => (e.paidByPartnerId && !own.has(e.paidByPartnerId) ? { ...e, paidByPartnerId: 'other' } : e)),
result: {
...fin.result,
split: {
distributable: null,
organization: null,
partners: fin.result.split.partners.filter((p) => own.has(p.partnerId)),
},
waterfall: fin.result.waterfall.filter((w) =>
w.key === 'organization' ? false : w.key.startsWith('partner:') ? own.has(w.key.slice(8)) : true),
},
};
}
financeRouter.get('/:id/finance', requireEventPermission('view_finance'), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const fin = await getEventFinance(event.id, event);
if (!fin) return c.json({ error: 'Event not found' }, 404);
const lines = new Map(fin.result.expenses.lines.map((l) => [l.id, l]));
const withAmounts = {
...fin,
expenses: fin.expenses.map((e) => ({
...e,
amount: lines.get(e.id)?.amount ?? e.computedAmount,
liveQuantity: lines.get(e.id)?.quantity ?? e.quantity,
auto: lines.get(e.id)?.auto ?? false,
})),
};
const fullSplit = can(c, 'view_full_split');
const scoped = fullSplit ? withAmounts : scopeToOwnShare(withAmounts, currentUser(c).id);
const categories = await dbAll<any>((db as any).select().from(expenseCategories));
const access = getEventAccess(c)!;
return c.json({
event: omitWalkInPrice({
id: event.id, title: event.title, titleEs: event.titleEs, startDatetime: iso(event.startDatetime),
endDatetime: event.endDatetime ? iso(event.endDatetime) : null,
location: event.location, series: event.series ?? null, price: pyg(event.price), currency: event.currency,
capacity: event.capacity,
}),
status: fin.state.status,
finalizedAt: fin.state.finalizedAt,
live: fin.live,
computedAt: fin.computedAt,
summary: scoped.result,
expenses: scoped.expenses,
otherIncome: scoped.otherIncome,
partners: scoped.partners,
categories: categories
.map((cat: any) => ({ id: cat.id, nameEn: cat.nameEn, nameEs: cat.nameEs, color: cat.color, sortOrder: pyg(cat.sortOrder), archived: bool(cat.archived) }))
.sort((a: any, b: any) => a.sortOrder - b.sortOrder),
viewer: {
fullSplit,
canEditExpenses: access.permissions.has('edit_expenses'),
canEditOwnExpenses: access.permissions.has('edit_own_expenses_only'),
canManageSplit: fullSplit && access.permissions.has('edit_expenses'),
canUnfinalize: canUnfinalize(access),
userId: currentUser(c).id,
},
});
});
// ==================== Expenses ====================
const expenseFields = {
description: z.string().trim().min(1).max(300),
categoryId: z.string().nullable().optional(),
calcType: z.enum(CALC_TYPES),
quantity: z.number().int().min(0).max(1_000_000),
unitAmount: money,
percentBp: bp,
minimumAmount: money,
computedAmount: money.optional(),
isLocked: z.boolean(),
status: z.enum(['planned', 'paid']),
paidByPartnerId: z.string().nullable().optional(),
receiptUrl: receiptUrl.nullable().optional(),
expenseDate: z.string().nullable().optional(),
};
const createExpenseSchema = z.object({
...expenseFields,
calcType: expenseFields.calcType.default('fixed'),
quantity: expenseFields.quantity.default(1),
unitAmount: money.default(0),
percentBp: bp.default(0),
minimumAmount: money.default(0),
isLocked: z.boolean().default(false),
status: expenseFields.status.default('planned'),
});
const updateExpenseSchema = z.object(expenseFields).partial();
const EXPENSE_EDITORS = ['edit_expenses', 'edit_own_expenses_only'] as const;
/** Members limited to their own expenses may only touch rows they created. */
function canEditRow(c: Context, row: any) {
return can(c, 'edit_expenses') || row.createdBy === currentUser(c).id;
}
type LiveContext = { ticketsSold: number; checkedIn: number; sales: number };
/**
* Quantity and amount to store for a row. Unlocked rows follow the live counts.
* A locked row keeps its frozen amount; locking freezes the amount it shows at
* that moment unless one is typed in (`typed`).
*/
function storedAmount(row: any, ctx: LiveContext, opts: { typed?: number; wasLocked?: boolean; frozen?: { quantity: number; amount: number } } = {}) {
const live = expenseAmount({ ...row, isLocked: false }, ctx);
const quantity = isAutoCalc(row.calcType) ? live.quantity : row.quantity;
if (!row.isLocked) return { quantity, computedAmount: live.amount };
if (opts.typed !== undefined) return { quantity, computedAmount: opts.typed };
if (opts.wasLocked && opts.frozen) return { quantity: opts.frozen.quantity, computedAmount: opts.frozen.amount };
return { quantity, computedAmount: live.amount };
}
async function validateExpenseRefs(c: Context, eventId: string, data: { categoryId?: string | null; paidByPartnerId?: string | null }) {
if (data.categoryId && !(await categoryExists(data.categoryId))) {
return c.json({ error: 'Unknown expense category' }, 400);
}
if (data.paidByPartnerId && !(await partnerBelongsToEvent(data.paidByPartnerId, eventId))) {
return c.json({ error: 'paidByPartnerId must be a partner of this event' }, 400);
}
return null;
}
financeRouter.post('/:id/expenses', requireEventPermission(EXPENSE_EDITORS), zValidator('json', createExpenseSchema, validationHook), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const closed = await assertOpen(c, event.id);
if (closed) return closed;
const data = c.req.valid('json');
const bad = await validateExpenseRefs(c, event.id, data);
if (bad) return bad;
const user = currentUser(c);
const now = getNow();
const ctx = await liveContext(event.id, event);
const amounts = storedAmount(data, ctx, { typed: data.computedAmount });
const values = {
id: generateId(),
eventId: event.id,
categoryId: data.categoryId || null,
templateId: null,
description: data.description,
calcType: data.calcType,
quantity: amounts.quantity,
unitAmount: data.unitAmount,
percentBp: data.percentBp,
minimumAmount: data.minimumAmount,
computedAmount: amounts.computedAmount,
isLocked: toDbBool(data.isLocked),
status: data.status,
paidByPartnerId: data.paidByPartnerId || null,
receiptUrl: data.receiptUrl || null,
expenseDate: data.expenseDate ? toDbDate(data.expenseDate) : null,
createdBy: user.id,
updatedBy: user.id,
createdAt: now,
updatedAt: now,
};
await runOps([
insertOp(eventExpenses, values),
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: values.id, action: 'create', after: serializeExpense(values) }),
]);
return c.json({ expense: serializeExpense(values) }, 201);
});
financeRouter.put('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), zValidator('json', updateExpenseSchema, validationHook), async (c) => {
const eventId = c.req.param('id');
const event = await getEvent(eventId);
if (!event) return c.json({ error: 'Event not found' }, 404);
const closed = await assertOpen(c, eventId);
if (closed) return closed;
const existing = await dbGet<any>(
(db as any).select().from(eventExpenses)
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
);
if (!existing) return c.json({ error: 'Expense not found' }, 404);
if (!canEditRow(c, existing)) return c.json({ error: 'You can only edit expenses you added', code: 'EVENT_PERMISSION' }, 403);
const data = c.req.valid('json');
const bad = await validateExpenseRefs(c, eventId, data);
if (bad) return bad;
const before = serializeExpense(existing);
const merged = { ...before, ...Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)) } as any;
const ctx = await liveContext(eventId, event);
const amounts = storedAmount(merged, ctx, {
typed: data.computedAmount,
wasLocked: before.isLocked,
frozen: { quantity: before.quantity, amount: before.computedAmount },
});
const user = currentUser(c);
const updates: Record<string, any> = {
description: merged.description,
categoryId: merged.categoryId || null,
calcType: merged.calcType,
quantity: amounts.quantity,
unitAmount: merged.unitAmount,
percentBp: merged.percentBp,
minimumAmount: merged.minimumAmount,
computedAmount: amounts.computedAmount,
isLocked: toDbBool(!!merged.isLocked),
status: merged.status,
paidByPartnerId: merged.paidByPartnerId || null,
receiptUrl: merged.receiptUrl || null,
expenseDate: merged.expenseDate ? toDbDate(merged.expenseDate) : null,
updatedBy: user.id,
updatedAt: getNow(),
};
const after = serializeExpense({ ...existing, ...updates });
await runOps([
updateOp(eventExpenses, updates, eq((eventExpenses as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'update', before, after }),
]);
return c.json({ expense: after });
});
financeRouter.delete('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), async (c) => {
const eventId = c.req.param('id');
const closed = await assertOpen(c, eventId);
if (closed) return closed;
const existing = await dbGet<any>(
(db as any).select().from(eventExpenses)
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
);
if (!existing) return c.json({ error: 'Expense not found' }, 404);
if (!canEditRow(c, existing)) return c.json({ error: 'You can only delete expenses you added', code: 'EVENT_PERMISSION' }, 403);
const user = currentUser(c);
await runOps([
deleteOp(eventExpenses, eq((eventExpenses as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'delete', before: serializeExpense(existing) }),
]);
return c.json({ message: 'Expense deleted' });
});
const applyTemplateSchema = z.object({
templateId: z.string().optional(),
packId: z.string().optional(),
}).refine((d) => !!d.templateId !== !!d.packId, { message: 'Provide exactly one of templateId or packId' });
financeRouter.post('/:id/expenses/apply-template', requireEventPermission(EXPENSE_EDITORS), zValidator('json', applyTemplateSchema, validationHook), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const closed = await assertOpen(c, event.id);
if (closed) return closed;
const { templateId, packId } = c.req.valid('json');
let templateIds: string[];
if (packId) {
const items = await dbAll<any>((db as any).select().from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).packId, packId)));
if (items.length === 0) return c.json({ error: 'Template pack not found or empty' }, 404);
templateIds = items.sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId);
} else {
templateIds = [templateId!];
}
const templates = await dbAll<any>((db as any).select().from(expenseTemplates).where(inArray((expenseTemplates as any).id, templateIds)));
const byId = new Map(templates.filter((t: any) => !bool(t.archived)).map((t: any) => [t.id, t]));
const ordered = templateIds.map((id) => byId.get(id)).filter(Boolean) as any[];
if (ordered.length === 0) return c.json({ error: 'Template not found' }, 404);
const user = currentUser(c);
const now = getNow();
const ctx = await liveContext(event.id, event);
const ops: TxOp[] = [];
const created: SerializedExpense[] = [];
for (const t of ordered) {
const row: any = {
id: generateId(),
eventId: event.id,
categoryId: t.categoryId || null,
templateId: t.id,
description: t.name,
calcType: t.calcType,
quantity: 1,
unitAmount: pyg(t.amount),
percentBp: pyg(t.percentBp),
minimumAmount: pyg(t.minimumAmount),
computedAmount: 0,
isLocked: toDbBool(false),
status: 'planned',
paidByPartnerId: null,
receiptUrl: null,
expenseDate: null,
createdBy: user.id,
updatedBy: user.id,
createdAt: now,
updatedAt: now,
};
const amounts = storedAmount(row, ctx);
row.quantity = amounts.quantity;
row.computedAmount = amounts.computedAmount;
ops.push(insertOp(eventExpenses, row));
const serialized = serializeExpense(row);
created.push(serialized);
ops.push(financeAuditOp({
eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: row.id,
action: packId ? 'apply_pack' : 'apply_template', after: { ...serialized, packId: packId ?? null },
}));
}
await runOps(ops);
return c.json({ expenses: created }, 201);
});
// ==================== Other income ====================
const incomeSchema = z.object({ description: z.string().trim().min(1).max(300), amount: money });
financeRouter.post('/:id/other-income', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema, validationHook), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const closed = await assertOpen(c, event.id);
if (closed) return closed;
const data = c.req.valid('json');
const user = currentUser(c);
const now = getNow();
const values = { id: generateId(), eventId: event.id, description: data.description, amount: data.amount, createdBy: user.id, createdAt: now, updatedAt: now };
await runOps([
insertOp(eventOtherIncome, values),
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'other_income', entityId: values.id, action: 'create', after: serializeIncome(values) }),
]);
return c.json({ income: serializeIncome(values) }, 201);
});
financeRouter.put('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema.partial(), validationHook), async (c) => {
const eventId = c.req.param('id');
const closed = await assertOpen(c, eventId);
if (closed) return closed;
const existing = await dbGet<any>(
(db as any).select().from(eventOtherIncome)
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
);
if (!existing) return c.json({ error: 'Income not found' }, 404);
const data = c.req.valid('json');
const updates: Record<string, any> = { updatedAt: getNow() };
if (data.description !== undefined) updates.description = data.description;
if (data.amount !== undefined) updates.amount = data.amount;
const user = currentUser(c);
const after = serializeIncome({ ...existing, ...updates });
await runOps([
updateOp(eventOtherIncome, updates, eq((eventOtherIncome as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'update', before: serializeIncome(existing), after }),
]);
return c.json({ income: after });
});
financeRouter.delete('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), async (c) => {
const eventId = c.req.param('id');
const closed = await assertOpen(c, eventId);
if (closed) return closed;
const existing = await dbGet<any>(
(db as any).select().from(eventOtherIncome)
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
);
if (!existing) return c.json({ error: 'Income not found' }, 404);
const user = currentUser(c);
await runOps([
deleteOp(eventOtherIncome, eq((eventOtherIncome as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'delete', before: serializeIncome(existing) }),
]);
return c.json({ message: 'Income deleted' });
});
// ==================== Partners ====================
const partnerFields = {
userId: z.string().nullable().optional(),
externalName: z.string().trim().max(200).nullable().optional(),
roleLabel: z.string().trim().max(100).nullable().optional(),
shareType: z.enum(SHARE_TYPES),
percentBp: bp,
fixedAmount: money,
thresholdAmount: money,
lossRule: z.enum(LOSS_RULES),
lossCapAmount: money,
};
const createPartnerSchema = z.object({
...partnerFields,
percentBp: bp.default(0),
fixedAmount: money.default(0),
thresholdAmount: money.default(0),
lossRule: partnerFields.lossRule.default('none'),
lossCapAmount: money.default(0),
}).refine((d) => !!d.userId || !!d.externalName, { message: 'A partner needs a linked user or a name' });
const updatePartnerSchema = z.object(partnerFields).partial();
/** Editing the split needs both the expense editor and the full-split view. */
function canManageSplit(c: Context) {
return can(c, 'edit_expenses') && can(c, 'view_full_split');
}
const splitForbidden = (c: Context) =>
c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
async function userExists(userId: string) {
return !!(await dbGet<any>((db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).id, userId))));
}
async function loadPartner(eventId: string, partnerId: string) {
return dbGet<any>(
(db as any).select().from(eventPartners)
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
);
}
// Users that can be linked to a partner (so the partner can log in and see their statement).
financeRouter.get('/:id/partners/candidates', requireEventPermission('edit_expenses'), async (c) => {
if (!canManageSplit(c)) return splitForbidden(c);
const q = (c.req.query('q') || '').trim().toLowerCase();
if (q.length < 2) return c.json({ users: [] });
return c.json({ users: await searchUsers(q, []) });
});
financeRouter.post('/:id/partners', requireEventPermission('edit_expenses'), zValidator('json', createPartnerSchema, validationHook), async (c) => {
if (!canManageSplit(c)) return splitForbidden(c);
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const closed = await assertOpen(c, event.id);
if (closed) return closed;
const data = c.req.valid('json');
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
const user = currentUser(c);
const now = getNow();
const values = {
id: generateId(),
eventId: event.id,
userId: data.userId || null,
externalName: data.externalName || null,
roleLabel: data.roleLabel || null,
shareType: data.shareType,
percentBp: data.percentBp,
fixedAmount: data.fixedAmount,
thresholdAmount: data.thresholdAmount,
lossRule: data.lossRule,
lossCapAmount: data.lossCapAmount,
payoutStatus: 'pending',
payoutDate: null,
payoutMethod: null,
payoutNote: null,
createdAt: now,
updatedAt: now,
};
await runOps([
insertOp(eventPartners, values),
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'partner', entityId: values.id, action: 'create', after: serializePartner(values) }),
]);
const partner = (await loadPartners(event.id)).find((p) => p.id === values.id);
return c.json({ partner }, 201);
});
financeRouter.put('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), zValidator('json', updatePartnerSchema, validationHook), async (c) => {
if (!canManageSplit(c)) return splitForbidden(c);
const eventId = c.req.param('id');
const closed = await assertOpen(c, eventId);
if (closed) return closed;
const existing = await loadPartner(eventId, c.req.param('partnerId'));
if (!existing) return c.json({ error: 'Partner not found' }, 404);
const data = c.req.valid('json');
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
const updates: Record<string, any> = { updatedAt: getNow() };
for (const [k, v] of Object.entries(data)) if (v !== undefined) updates[k] = v === '' ? null : v;
const merged = { ...existing, ...updates };
if (!merged.userId && !merged.externalName) return c.json({ error: 'A partner needs a linked user or a name' }, 400);
const user = currentUser(c);
await runOps([
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'update', before: serializePartner(existing), after: serializePartner(merged) }),
]);
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
return c.json({ partner });
});
financeRouter.delete('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), async (c) => {
if (!canManageSplit(c)) return splitForbidden(c);
const eventId = c.req.param('id');
const closed = await assertOpen(c, eventId);
if (closed) return closed;
const existing = await loadPartner(eventId, c.req.param('partnerId'));
if (!existing) return c.json({ error: 'Partner not found' }, 404);
const fronted = await dbGet<any>(
(db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).paidByPartnerId, existing.id))
);
if (fronted) {
return c.json({ error: 'This partner paid for expenses. Change who paid those expenses first.', code: 'PARTNER_HAS_EXPENSES' }, 409);
}
const user = currentUser(c);
await runOps([
deleteOp(eventPartners, eq((eventPartners as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'delete', before: serializePartner(existing) }),
]);
return c.json({ message: 'Partner removed' });
});
const markPaidSchema = z.object({
paid: z.boolean().default(true),
payoutDate: z.string().nullable().optional(),
payoutMethod: z.string().trim().max(50).nullable().optional(),
payoutNote: z.string().trim().max(1000).nullable().optional(),
});
financeRouter.post('/:id/partners/:partnerId/mark-paid', requireEventPermission('edit_expenses'), zValidator('json', markPaidSchema, validationHook), async (c) => {
if (!canManageSplit(c)) return splitForbidden(c);
const eventId = c.req.param('id');
const existing = await loadPartner(eventId, c.req.param('partnerId'));
if (!existing) return c.json({ error: 'Partner not found' }, 404);
const state = await getFinanceState(eventId);
if (state.status === 'open') {
return c.json({ error: 'Finalize the event before recording payouts, so they match the frozen numbers.', code: 'FINANCE_NOT_FINALIZED' }, 409);
}
const data = c.req.valid('json');
const now = getNow();
const updates = data.paid
? {
payoutStatus: 'paid',
payoutDate: data.payoutDate ? toDbDate(data.payoutDate) : now,
payoutMethod: data.payoutMethod || null,
payoutNote: data.payoutNote || null,
updatedAt: now,
}
: { payoutStatus: 'pending', payoutDate: null, payoutMethod: null, payoutNote: data.payoutNote ?? existing.payoutNote ?? null, updatedAt: now };
const user = currentUser(c);
const ops: TxOp[] = [
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
financeAuditOp({
eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: data.paid ? 'mark_paid' : 'mark_unpaid',
before: serializePartner(existing), after: serializePartner({ ...existing, ...updates }),
}),
];
// The event is paid out once every partner is.
const all = await loadPartners(eventId);
const allPaid = all.every((p) => (p.id === existing.id ? data.paid : p.payoutStatus === 'paid'));
const nextStatus = allPaid ? 'paid_out' : 'finalized';
if (nextStatus !== state.status) {
ops.push(updateOp(eventFinanceState, { status: nextStatus, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)));
ops.push(financeAuditOp({ eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: nextStatus, before: { status: state.status }, after: { status: nextStatus } }));
}
await runOps(ops);
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
return c.json({ partner, status: nextStatus });
});
financeRouter.get('/:id/partners/:partnerId/statement', requireEventPermission('view_finance'), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const fin = await getEventFinance(event.id, event);
const partner = fin!.partners.find((p) => p.id === c.req.param('partnerId'));
if (!partner) return c.json({ error: 'Partner not found' }, 404);
if (!can(c, 'view_full_split') && partner.userId !== currentUser(c).id) {
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
}
const localeParam = c.req.query('locale');
const locale = localeParam === 'es' || (!localeParam && currentUser(c).languagePreference === 'es') ? 'es' : 'en';
const amounts = new Map(fin!.result.expenses.lines.map((l) => [l.id, l.amount]));
const pdf = await generatePartnerStatementPDF({
locale,
event: { title: (locale === 'es' && event.titleEs) || event.title, startDatetime: event.startDatetime, location: event.location },
finalized: fin!.state.status !== 'open',
result: fin!.result,
partner,
line: fin!.result.split.partners.find((p) => p.partnerId === partner.id),
frontedExpenses: fin!.expenses.filter((e) => e.paidByPartnerId === partner.id && e.status === 'paid'),
expenseAmounts: amounts,
});
const safeName = partner.name.replace(/[^a-zA-Z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'partner';
const slug = (event.slug || event.id).replace(/[^a-zA-Z0-9-_]+/g, '-');
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="statement-${slug}-${safeName}.pdf"`,
},
});
});
// ==================== Finalize ====================
financeRouter.post('/:id/finance/finalize', requireEventPermission('edit_expenses'), async (c) => {
if (!canManageSplit(c)) return splitForbidden(c);
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const fin = await getEventFinance(event.id, event);
if (fin!.state.status !== 'open') return c.json({ error: 'Already finalized', code: 'FINANCE_FINALIZED' }, 409);
const user = currentUser(c);
const now = getNow();
const snapshot = { version: 1 as const, computedAt: new Date().toISOString(), result: fin!.result };
const ops: TxOp[] = [];
// Persist the amounts auto rows had at finalize, so the rows read the same as the snapshot.
const lines = new Map(fin!.result.expenses.lines.map((l) => [l.id, l]));
for (const e of fin!.expenses) {
const line = lines.get(e.id);
if (line && (line.amount !== e.computedAmount || line.quantity !== e.quantity)) {
ops.push(updateOp(eventExpenses, { computedAmount: line.amount, quantity: line.quantity }, eq((eventExpenses as any).id, e.id)));
}
}
const stateValues = { status: 'finalized', finalizedAt: now, finalizedBy: user.id, snapshotJson: JSON.stringify(snapshot), updatedAt: now };
ops.push(fin!.state.exists
? updateOp(eventFinanceState, stateValues, eq((eventFinanceState as any).eventId, event.id))
: insertOp(eventFinanceState, { eventId: event.id, ...stateValues }));
ops.push(financeAuditOp({
eventId: event.id, actorUserId: user.id, entityType: 'finance_state', entityId: event.id, action: 'finalize',
before: { status: 'open' }, after: { status: 'finalized', profit: snapshot.result.profit, split: snapshot.result.split },
}));
await runOps(ops);
return c.json({ status: 'finalized', finalizedAt: iso(now) });
});
financeRouter.post('/:id/finance/unfinalize', requireEventPermission('view_finance'), async (c) => {
const access = getEventAccess(c)!;
if (!canUnfinalize(access)) {
return c.json({ error: 'Only admins and co-managers can unfinalize', code: 'EVENT_PERMISSION' }, 403);
}
const eventId = c.req.param('id');
const state = await getFinanceState(eventId);
if (state.status === 'open') return c.json({ error: 'Not finalized' }, 409);
const user = currentUser(c);
const now = getNow();
await runOps([
updateOp(eventFinanceState, { status: 'open', finalizedAt: null, finalizedBy: null, snapshotJson: null, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)),
financeAuditOp({
eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: 'unfinalize',
before: { status: state.status, finalizedAt: state.finalizedAt, finalizedBy: state.finalizedBy, snapshot: state.snapshot },
after: { status: 'open' },
}),
]);
return c.json({ status: 'open' });
});
// ==================== Audit log ====================
financeRouter.get('/:id/audit-log', requireEventPermission('view_full_split'), async (c) => {
const eventId = c.req.param('id');
const limit = Math.min(200, Math.max(1, parseInt(c.req.query('limit') || '100', 10) || 100));
const offset = Math.max(0, parseInt(c.req.query('offset') || '0', 10) || 0);
const rows = await dbAll<any>(
(db as any)
.select({
id: (financeAuditLog as any).id,
actorUserId: (financeAuditLog as any).actorUserId,
actorName: (users as any).name,
entityType: (financeAuditLog as any).entityType,
entityId: (financeAuditLog as any).entityId,
action: (financeAuditLog as any).action,
beforeJson: (financeAuditLog as any).beforeJson,
afterJson: (financeAuditLog as any).afterJson,
createdAt: (financeAuditLog as any).createdAt,
})
.from(financeAuditLog)
.leftJoin(users, eq((financeAuditLog as any).actorUserId, (users as any).id))
.where(eq((financeAuditLog as any).eventId, eventId))
.orderBy(desc((financeAuditLog as any).createdAt))
.limit(limit)
.offset(offset)
);
const parse = (s: string | null) => { if (!s) return null; try { return JSON.parse(s); } catch { return null; } };
return c.json({
entries: rows.map((r: any) => ({
id: r.id, actorUserId: r.actorUserId, actorName: r.actorName ?? null, entityType: r.entityType, entityId: r.entityId,
action: r.action, before: parse(r.beforeJson), after: parse(r.afterJson), createdAt: iso(r.createdAt),
})),
});
});
// ==================== Team members ====================
const permissionOverrides = z.record(z.enum(EVENT_PERMISSIONS), z.boolean());
const createMemberSchema = z.object({
userId: z.string().min(1),
rolePreset: z.enum(ROLE_PRESETS),
permissions: permissionOverrides.optional(),
});
const updateMemberSchema = z.object({
rolePreset: z.enum(ROLE_PRESETS).optional(),
permissions: permissionOverrides.optional(),
});
function serializeMember(m: any, u: any) {
const overrides = parseOverrides(m.permissions);
return {
id: m.id,
eventId: m.eventId,
userId: m.userId,
name: u?.name ?? null,
email: u?.email ?? null,
globalRole: u?.role ?? null,
rolePreset: m.rolePreset,
permissions: overrides,
effective: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(m.rolePreset, overrides).has(p)),
createdAt: iso(m.createdAt),
updatedAt: iso(m.updatedAt),
};
}
async function loadMember(eventId: string, memberId: string) {
return dbGet<any>(
(db as any).select().from(eventMembers)
.where(and(eq((eventMembers as any).id, memberId), eq((eventMembers as any).eventId, eventId)))
);
}
async function loadUser(userId: string) {
return dbGet<any>(
(db as any).select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
.from(users).where(eq((users as any).id, userId))
);
}
financeRouter.get('/:id/members', requireEventPermission('manage_team'), async (c) => {
const eventId = c.req.param('id');
const rows = await dbAll<any>(
(db as any)
.select({ m: eventMembers, name: (users as any).name, email: (users as any).email, role: (users as any).role })
.from(eventMembers)
.leftJoin(users, eq((eventMembers as any).userId, (users as any).id))
.where(eq((eventMembers as any).eventId, eventId))
);
return c.json({
members: rows
.map((r: any) => serializeMember(r.m, { name: r.name, email: r.email, role: r.role }))
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
});
});
/** Active users matching q by name or email, excluding some ids. */
async function searchUsers(q: string, excludeIds: string[]) {
const like = `%${q.replace(/[%_]/g, '')}%`;
const conditions: any[] = [
or(sql`lower(${(users as any).name}) like ${like}`, sql`lower(${(users as any).email}) like ${like}`),
eq((users as any).accountStatus, 'active'),
];
if (excludeIds.length > 0) conditions.push(notInArray((users as any).id, excludeIds));
return dbAll<any>(
(db as any)
.select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
.from(users)
.where(and(...conditions))
.limit(10)
);
}
financeRouter.get('/:id/members/candidates', requireEventPermission('manage_team'), async (c) => {
const eventId = c.req.param('id');
const q = (c.req.query('q') || '').trim().toLowerCase();
if (q.length < 2) return c.json({ users: [] });
const existing = await dbAll<any>(
(db as any).select({ userId: (eventMembers as any).userId }).from(eventMembers).where(eq((eventMembers as any).eventId, eventId))
);
return c.json({ users: await searchUsers(q, existing.map((e: any) => e.userId)) });
});
financeRouter.post('/:id/members', requireEventPermission('manage_team'), zValidator('json', createMemberSchema, validationHook), async (c) => {
const event = await requireEvent(c);
if (!event) return c.json({ error: 'Event not found' }, 404);
const data = c.req.valid('json');
const target = await loadUser(data.userId);
if (!target) return c.json({ error: 'User not found' }, 400);
const dupe = await dbGet<any>(
(db as any).select({ id: (eventMembers as any).id }).from(eventMembers)
.where(and(eq((eventMembers as any).eventId, event.id), eq((eventMembers as any).userId, data.userId)))
);
if (dupe) return c.json({ error: 'This user is already on the team', code: 'ALREADY_MEMBER' }, 409);
const user = currentUser(c);
const now = getNow();
const values = {
id: generateId(),
eventId: event.id,
userId: data.userId,
rolePreset: data.rolePreset,
permissions: JSON.stringify(data.permissions || {}),
createdBy: user.id,
createdAt: now,
updatedAt: now,
};
const member = serializeMember(values, target);
await runOps([
insertOp(eventMembers, values),
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'member', entityId: values.id, action: 'create', after: member }),
]);
return c.json({ member }, 201);
});
financeRouter.put('/:id/members/:memberId', requireEventPermission('manage_team'), zValidator('json', updateMemberSchema, validationHook), async (c) => {
const eventId = c.req.param('id');
const existing = await loadMember(eventId, c.req.param('memberId'));
if (!existing) return c.json({ error: 'Member not found' }, 404);
const data = c.req.valid('json');
const updates: Record<string, any> = { updatedAt: getNow() };
if (data.rolePreset) updates.rolePreset = data.rolePreset;
if (data.permissions) updates.permissions = JSON.stringify(data.permissions);
const target = await loadUser(existing.userId);
const before = serializeMember(existing, target);
const after = serializeMember({ ...existing, ...updates }, target);
const user = currentUser(c);
await runOps([
updateOp(eventMembers, updates, eq((eventMembers as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'update', before, after }),
]);
return c.json({ member: after });
});
financeRouter.delete('/:id/members/:memberId', requireEventPermission('manage_team'), async (c) => {
const eventId = c.req.param('id');
const existing = await loadMember(eventId, c.req.param('memberId'));
if (!existing) return c.json({ error: 'Member not found' }, 404);
const user = currentUser(c);
const target = await loadUser(existing.userId);
await runOps([
deleteOp(eventMembers, eq((eventMembers as any).id, existing.id)),
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'delete', before: serializeMember(existing, target) }),
]);
return c.json({ message: 'Member removed' });
});
export default financeRouter;
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomUUID } from 'crypto';
// Changing an event's ticket price must never rewrite what was already paid,
// but anyone who booked and hasn't paid yet owes the new price. These tests pin
// which payments follow a price change and which keep their amount.
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
// URL to run the same suite in a throwaway schema (dropped afterwards).
const PG_URL = process.env.FINANCE_TEST_PG_URL;
const PG_SCHEMA = `pricetest_${Date.now()}`;
if (PG_URL) {
process.env.DB_TYPE = 'postgres';
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
} else {
const dir = mkdtempSync(join(tmpdir(), 'events-price-test-'));
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = join(dir, 'test.db');
}
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'events-price-secret-0123456789abcdef';
delete process.env.REDIS_URL;
delete process.env.REVALIDATE_SECRET;
const ADMIN = { id: randomUUID(), name: 'The Admin', role: 'admin' };
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(ADMIN.role)) return c.json({ error: 'Forbidden' }, 403);
c.set('user', ADMIN);
await next();
},
getAuthUser: async () => ADMIN,
}));
let app: any;
let dbm: any;
const SEED_USER_ID = randomUUID();
const EVENT_ID = randomUUID();
const OTHER_EVENT_ID = randomUUID();
const OLD_PRICE = 50000;
const NEW_PRICE = 70000;
// Ticket id by label, so assertions read by name.
const ticketIds: Record<string, string> = {};
async function put(path: string, body: unknown) {
const res = await app.request(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
/** One ticket with its payment row, the way a booking leaves them. Seeded through drizzle so it works on both engines. */
async function seedBooking(label: string, eventId: string, paymentStatus: string, provider = 'bancard') {
const { db, tickets, payments } = dbm;
const now = dbm.getNow();
const id = randomUUID();
ticketIds[label] = id;
const paid = paymentStatus === 'paid';
await db.insert(tickets).values({
id, userId: SEED_USER_ID, eventId, attendeeFirstName: label, status: paid ? 'confirmed' : 'pending',
paymentStatus: paid ? 'paid' : 'unpaid', isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
});
await db.insert(payments).values({
id: randomUUID(), ticketId: id, provider, amount: OLD_PRICE, currency: 'PYG',
status: paymentStatus, paidAt: paid ? now : null, createdAt: now, updatedAt: now,
});
}
async function amountOf(label: string): Promise<number> {
const { db, dbGet, payments, eq } = dbm;
const row = await dbGet(db.select({ amount: payments.amount }).from(payments).where(eq(payments.ticketId, ticketIds[label])));
return Number(row.amount);
}
beforeAll(() => {
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
app = new Hono();
app.route('/api/events', (await import('./events.js')).default);
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')), ...(await import('drizzle-orm')) };
const { db, users, events } = dbm;
const now = dbm.getNow();
await db.insert(users).values({
id: SEED_USER_ID, email: 'seed@test.py', name: 'Seed', role: 'user',
isClaimed: dbm.toDbBool(false), accountStatus: 'unclaimed', createdAt: now, updatedAt: now,
});
for (const id of [EVENT_ID, OTHER_EVENT_ID]) {
await db.insert(events).values({
id, title: `Event ${id}`, description: 'desc', startDatetime: now, location: 'Asuncion', price: OLD_PRICE,
currency: 'PYG', capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
});
}
await seedBooking('paid', EVENT_ID, 'paid');
await seedBooking('refunded', EVENT_ID, 'refunded');
await seedBooking('pending', EVENT_ID, 'pending');
await seedBooking('pending-tpago', EVENT_ID, 'pending', 'tpago');
await seedBooking('claimed', EVENT_ID, 'pending_approval', 'bank_transfer');
await seedBooking('on-hold', EVENT_ID, 'on_hold');
await seedBooking('lightning', EVENT_ID, 'pending', 'lightning');
await seedBooking('other-event', OTHER_EVENT_ID, 'pending');
})();
}, 120_000);
afterAll(() => {
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
});
describe('changing the ticket price', () => {
it('leaves every payment alone when the price is not part of the edit', async () => {
const { status } = await put(`/api/events/${EVENT_ID}`, { location: 'Encarnación' });
expect(status).toBe(200);
expect(await amountOf('pending')).toBe(OLD_PRICE);
});
it('leaves every payment alone when the price is resent unchanged', async () => {
const { status } = await put(`/api/events/${EVENT_ID}`, { price: OLD_PRICE, currency: 'PYG' });
expect(status).toBe(200);
expect(await amountOf('pending')).toBe(OLD_PRICE);
});
it('reprices open payments and keeps what was already paid', async () => {
const { status, body } = await put(`/api/events/${EVENT_ID}`, { price: NEW_PRICE });
expect(status).toBe(200);
expect(body.event.price).toBe(NEW_PRICE);
// Not yet paid: owe the current price.
expect(await amountOf('pending')).toBe(NEW_PRICE);
expect(await amountOf('pending-tpago')).toBe(NEW_PRICE);
// History stays as it happened.
expect(await amountOf('paid')).toBe(OLD_PRICE);
expect(await amountOf('refunded')).toBe(OLD_PRICE);
// The customer already sent the old amount / an admin is reviewing it.
expect(await amountOf('claimed')).toBe(OLD_PRICE);
expect(await amountOf('on-hold')).toBe(OLD_PRICE);
// The Lightning invoice was issued for a fixed amount.
expect(await amountOf('lightning')).toBe(OLD_PRICE);
// Other events are untouched.
expect(await amountOf('other-event')).toBe(OLD_PRICE);
});
});
+216 -60
View File
@@ -2,12 +2,18 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { eq, ne, desc, and, gte, inArray, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
import { resolvePresaleClosure } from '../lib/presale.js';
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
import { publicSalesFields } from '../lib/salesState.js';
import { loadDoorMethods } from '../lib/doorPayments.js';
import { runOps, updateOp, type TxOp } from '../lib/txOps.js';
interface UserContext {
id: string;
@@ -19,17 +25,52 @@ interface UserContext {
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Helper to normalize event data for API response
// PostgreSQL decimal returns strings, booleans are stored as integers
function normalizeEvent(event: any) {
// PostgreSQL decimal returns strings, booleans are stored as integers.
// `settings` is the site_settings row; when given, the effective pre-sale
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
// booking API agree on when registration closes.
// The walk-in (door) price is internal: it is dropped unless the caller is
// admin/organizer/staff and `includeWalkInPrice` is set.
function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) {
if (!event) return event;
return {
...event,
const { walkInPrice, ...publicFields } = event;
const normalized = {
...publicFields,
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
// Convert price from string/decimal to clean number
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
// Convert capacity from string to number if needed
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
// Convert boolean integers to actual booleans for frontend
externalBookingEnabled: Boolean(event.externalBookingEnabled),
// Pre-sale overrides: null means "inherit the site default"
presaleClosureEnabled: event.presaleClosureEnabled == null ? null : Boolean(event.presaleClosureEnabled),
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore == null ? null : Number(event.presaleCloseMinutesBefore),
};
if (settings !== undefined) {
const { closesAt } = resolvePresaleClosure(normalized, settings);
return { ...normalized, presaleClosesAt: closesAt ? closesAt.toISOString() : null };
}
return normalized;
}
// Seat counts plus the public sales state for a normalized event. `raw` is the
// DB row: doorPrice is resolved from its walk-in price, and only in the `door`
// state (see lib/salesState.ts).
function withSeatsAndSales(
raw: any,
normalized: any,
settings: any,
counts: { paid: number; claimed: number },
nowMs: number = Date.now()
) {
const availableSeats = calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed);
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats,
...publicSalesFields(raw, settings, availableSeats, nowMs),
};
}
@@ -102,6 +143,29 @@ const parsePrice = (val: unknown): number => {
return 0;
};
// Walk-in price: empty/null means "not set" (fall back to price) and must stay
// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input
// fails validation instead of silently becoming 0 the way parsePrice does.
const walkInPriceSchema = z.union([z.number(), z.string(), z.null()])
.transform((val) => {
if (val === null) return null;
if (typeof val === 'number') return val;
const trimmed = val.trim();
if (trimmed === '') return null;
return Number(trimmed.replace(',', '.'));
})
.pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable())
.optional();
// PYG has no minor unit, so a PYG walk-in price must be a whole number.
function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null {
if (walkInPrice == null) return null;
if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) {
return 'walkInPrice: Walk-in price must be a whole number for PYG';
}
return null;
}
// Helper to normalize boolean (handles true/false and 0/1)
const normalizeBoolean = (val: unknown): boolean => {
if (typeof val === 'boolean') return val;
@@ -125,6 +189,9 @@ const baseEventSchema = z.object({
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
// Accept price as number or string (handles "45000" and "41,44" formats)
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
walkInPrice: walkInPriceSchema,
// Groups recurring events for the finance overview ("" clears it)
series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(),
currency: z.string().default('PYG'),
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
@@ -138,8 +205,30 @@ const baseEventSchema = z.object({
// External booking support - accept boolean or number (0/1 from DB)
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
// Pre-sale closure overrides - null/omitted means "inherit the site default"
presaleClosureEnabled: z.union([z.boolean(), z.number(), z.null()])
.transform((v) => (v === null ? null : normalizeBoolean(v)))
.optional(),
presaleCloseMinutesBefore: z.union([z.number(), z.string(), z.null()])
.transform((v) => {
if (v === null) return null;
const n = typeof v === 'string' ? parseInt(v, 10) : v;
return Number.isFinite(n) ? Math.floor(n) : NaN;
})
.pipe(z.number().int().min(0, 'Pre-sale closure time cannot be negative').nullable())
.optional(),
});
// When pre-sale closure is explicitly enabled on an event, the cutoff must be set too.
const presaleRefine = {
check: (data: { presaleClosureEnabled?: boolean | null; presaleCloseMinutesBefore?: number | null }) =>
data.presaleClosureEnabled !== true || typeof data.presaleCloseMinutesBefore === 'number',
options: {
message: 'Pre-sale closure time is required when pre-sale closure is enabled',
path: ['presaleCloseMinutesBefore'],
},
};
const createEventSchema = baseEventSchema.refine(
(data) => {
// If external booking is enabled, URL must be provided and must start with https://
@@ -152,7 +241,7 @@ const createEventSchema = baseEventSchema.refine(
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
path: ['externalBookingUrl'],
}
);
).refine(presaleRefine.check, presaleRefine.options);
const updateEventSchema = baseEventSchema.partial().refine(
(data) => {
@@ -166,18 +255,26 @@ const updateEventSchema = baseEventSchema.partial().refine(
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
path: ['externalBookingUrl'],
}
);
).refine(presaleRefine.check, presaleRefine.options);
// Get all events (public)
eventsRouter.get('/', async (c) => {
const status = c.req.query('status');
const upcoming = c.req.query('upcoming');
// Pagination is opt-in: callers that pass neither page nor pageSize (public
// pages, admin filter dropdowns) still get the full list.
const pageParam = c.req.query('page');
const pageSizeParam = c.req.query('pageSize');
const paginated = pageParam !== undefined || pageSizeParam !== undefined;
const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200);
// Only privileged users may see non-public events (drafts, archived, etc.).
// Anonymous/regular callers are restricted to published events regardless of
// any client-supplied status filter, so drafts cannot leak.
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
const conditions: any[] = [];
@@ -195,19 +292,35 @@ eventsRouter.get('/', async (c) => {
conditions.push(eq((events as any).status, 'published'));
}
const whereClause = conditions.length === 0
? undefined
: conditions.length === 1 ? conditions[0] : and(...conditions);
let query = (db as any).select().from(events);
if (conditions.length > 0) {
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
if (whereClause) query = query.where(whereClause);
query = query.orderBy(desc((events as any).startDatetime));
let total: number | undefined;
if (paginated) {
let countQuery = (db as any).select({ count: sql`count(*)` }).from(events);
if (whereClause) countQuery = countQuery.where(whereClause);
const totalRow = await dbGet<any>(countQuery);
total = Number(totalRow?.count || 0);
query = query.limit(pageSize).offset((page - 1) * pageSize);
}
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
const result = await dbAll<any>(query);
// Single grouped query for seat counts across all events (avoids N+1: previously
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
// so availableSeats subtracts them together — the same formula the booking-creation
// capacity check enforces (lib/capacity.ts).
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
// Scoped to the returned events so a page of 25 does not scan every ticket.
const eventIds = result.map((event: any) => event.id);
const countRows = eventIds.length > 0
? await dbAll<any>(eventSeatBreakdownQuery(db, eventIds))
: [];
const countByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of countRows) {
countByEvent.set(row.eventId, {
@@ -216,18 +329,21 @@ eventsRouter.get('/', async (c) => {
});
}
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event);
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
});
const siteSettingsRow = await getSiteSettingsRow();
const nowMs = Date.now();
const eventsWithCounts = result.map((event: any) =>
withSeatsAndSales(
event,
normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }),
siteSettingsRow,
countByEvent.get(event.id) || { paid: 0, claimed: 0 },
nowMs,
)
);
return c.json({ events: eventsWithCounts });
return paginated
? c.json({ events: eventsWithCounts, total, page, pageSize })
: c.json({ events: eventsWithCounts });
});
// Get single event (public) - resolves by id, canonical slug, or historical alias
@@ -239,31 +355,43 @@ eventsRouter.get('/:id', async (c) => {
return c.json({ error: 'Event not found' }, 404);
}
const authUser: any = await getAuthUser(c);
// Draft events are only visible to privileged users (admin preview); hide from public.
if ((event as any).status === 'draft') {
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
if (!isPrivileged) {
return c.json({ error: 'Event not found' }, 404);
}
}
const normalized = normalizeEvent(event);
const settings = await getSiteSettingsRow();
const normalized = normalizeEvent(event, settings, {
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
});
const counts = await getEventSeatCounts(event.id);
const publicEvent = withSeatsAndSales(event, normalized, settings, counts);
// Door tenders (never the comp "guest" one) for the page's "pay at the door" line.
const doorPaymentMethods = publicEvent.salesState === 'door'
? (await loadDoorMethods(event.id)).filter((m) => m !== 'guest')
: undefined;
// serverTime lets the page schedule its refresh at presaleClosesAt even when
// the visitor's clock is off.
return c.json({
event: {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
},
event: { ...publicEvent, ...(doorPaymentMethods ? { doorPaymentMethods } : {}) },
serverTime: new Date().toISOString(),
});
});
async function getSiteTimezone(): Promise<string> {
// Single site_settings row (or null when none has been created yet)
async function getSiteSettingsRow(): Promise<any | null> {
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
return settings || null;
}
function siteTimezoneOf(settings: any | null): string {
return settings?.timezone || 'America/Asuncion';
}
@@ -299,13 +427,8 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
}
const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event);
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
const settings = await getSiteSettingsRow();
return withSeatsAndSales(event, normalizeEvent(event, settings), settings, counts);
}
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
@@ -368,13 +491,9 @@ eventsRouter.get('/next/upcoming', async (c) => {
// If we have a valid featured event, return it
if (featuredEvent) {
const counts = await getEventSeatCounts(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
...withSeatsAndSales(featuredEvent, normalizeEvent(featuredEvent, settings), settings, counts),
isFeatured: true,
},
});
@@ -396,7 +515,11 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const user = c.get('user');
const now = getNow();
const id = generateId();
const tz = await getSiteTimezone();
const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow);
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
if (walkInError) return c.json({ error: walkInError }, 400);
// Convert data for database compatibility
const dbData = convertBooleansForDb(data);
@@ -421,11 +544,11 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
revalidateFrontendCache();
// Return normalized event data
return c.json({ event: normalizeEvent(newEvent) }, 201);
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201);
});
// Update event (admin/organizer only)
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), zValidator('json', updateEventSchema, validationHook), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
@@ -436,8 +559,17 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
return c.json({ error: 'Event not found' }, 404);
}
if (data.walkInPrice !== undefined || data.currency !== undefined) {
const walkInError = walkInPriceError(
data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice),
data.currency ?? existing.currency,
);
if (walkInError) return c.json({ error: walkInError }, 400);
}
const now = getNow();
const tz = await getSiteTimezone();
const siteSettingsRow = await getSiteSettingsRow();
const tz = siteTimezoneOf(siteSettingsRow);
// Convert data for database compatibility
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
// Slug changes are handled explicitly below to manage aliases
@@ -484,11 +616,31 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
updateData.slug = newSlug;
}
await (db as any)
.update(events)
.set(updateData)
.where(eq((events as any).id, id));
const ops: TxOp[] = [updateOp(events, updateData, eq((events as any).id, id))];
// Anyone who booked but hasn't paid yet owes the current price, so open
// payments follow a price/currency change. Settled payments keep what was
// actually paid. Excluded: pending_approval (the customer already sent the
// old amount), on_hold (under review) and Lightning (the invoice is fixed).
const newPrice = data.price !== undefined ? data.price : Number(existing.price);
const newCurrency = data.currency ?? existing.currency;
if (newPrice !== Number(existing.price) || newCurrency !== existing.currency) {
ops.push(updateOp(
payments,
{ amount: newPrice, currency: newCurrency, updatedAt: now },
and(
eq((payments as any).status, 'pending'),
ne((payments as any).provider, 'lightning'),
inArray(
(payments as any).ticketId,
(db as any).select({ id: (tickets as any).id }).from(tickets).where(eq((tickets as any).eventId, id))
)
)
));
}
await runOps(ops);
const updated = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
@@ -496,7 +648,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
// Revalidate sitemap when an event is updated (status/dates may have changed)
revalidateFrontendCache();
return c.json({ event: normalizeEvent(updated) });
return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
});
// Delete event (admin only)
@@ -562,17 +714,17 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
});
// Get event attendees (admin/organizer only)
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
eventsRouter.get('/:id/attendees', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer', 'staff'] }), async (c) => {
const id = c.req.param('id');
const attendees = await dbAll(
const attendees = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, id))
);
return c.json({ attendees });
return c.json({ attendees: canSeeAttendeePii(c) ? attendees : attendees.map(redactAttendee) });
});
// Duplicate event (admin/organizer only)
@@ -607,23 +759,27 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
location: existing.location,
locationUrl: existing.locationUrl,
price: existing.price,
walkInPrice: existing.walkInPrice ?? null,
currency: existing.currency,
capacity: existing.capacity,
status: 'draft',
bannerUrl: existing.bannerUrl,
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
externalBookingUrl: existing.externalBookingUrl,
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
series: existing.series ?? null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(events).values(duplicatedEvent);
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201);
});
// List slug aliases for an event (admin/organizer only)
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>(
@@ -644,7 +800,7 @@ eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async
});
// Remove a slug alias from an event (admin/organizer only)
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
const id = c.req.param('id');
const slug = c.req.param('slug');
@@ -0,0 +1,245 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// The walk-in price is internal: admins set it, door staff charge it, and no
// public event response may carry it. These tests pin both halves.
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'events-walkin-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'events-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
delete process.env.REVALIDATE_SECRET;
type TestUser = { id: string; name: string; role: string } | null;
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const MEMBER = { id: 'member-user-id', name: 'Member', role: 'user' };
// Anonymous by default, like the public site and its server-side fetches.
let currentUser: TestUser = null;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (!currentUser) return c.json({ error: 'Unauthorized' }, 401);
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
let app: any;
let sqlite: any;
async function request(method: string, path: string, body?: unknown) {
const res = await app.request(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
const baseEvent = {
title: 'Walk-in Test',
description: 'desc',
startDatetime: '2099-01-10T20:00',
location: 'Asuncion',
price: 21000,
currency: 'PYG',
capacity: 40,
status: 'published',
};
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
const eventsRoutes = (await import('./events.js')).default;
app = new Hono();
app.route('/api/events', eventsRoutes);
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
})();
}, 120_000);
describe('admin event form: walk-in price', () => {
it('saves an empty walk-in price as null, not 0', async () => {
const { status, body } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Empty walk-in', walkInPrice: '' })
);
expect(status).toBe(201);
expect(body.event.walkInPrice).toBeNull();
expect(sqlite.prepare('SELECT walk_in_price FROM events WHERE id = ?').get(body.event.id).walk_in_price).toBeNull();
});
it('saves 0 as a free walk-in and a number as-is', async () => {
const free = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Free walk-in', walkInPrice: 0 })
);
expect(free.body.event.walkInPrice).toBe(0);
const priced = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Priced walk-in', walkInPrice: '25000' })
);
expect(priced.body.event.walkInPrice).toBe(25000);
});
it('rejects negative, non-numeric and fractional PYG walk-in prices', async () => {
for (const walkInPrice of [-1, 'abc', 25000.5]) {
const { status } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Bad walk-in', walkInPrice })
);
expect(status, `walkInPrice ${walkInPrice}`).toBe(400);
}
});
it('updates and clears the walk-in price, and duplicates carry it over', async () => {
const created = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Editable walk-in', walkInPrice: 25000 })
);
const id = created.body.event.id;
const updated = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 30000 }));
expect(updated.body.event.walkInPrice).toBe(30000);
const copy = await as(ADMIN, () => request('POST', `/api/events/${id}/duplicate`));
expect(copy.status).toBe(201);
expect(copy.body.event.walkInPrice).toBe(30000);
const cleared = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: null }));
expect(cleared.body.event.walkInPrice).toBeNull();
// Omitting the field leaves it untouched.
await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 0 }));
const untouched = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { capacity: 45 }));
expect(untouched.body.event.walkInPrice).toBe(0);
});
});
describe('public event responses', () => {
let slug: string;
let id: string;
beforeAll(async () => {
const { body } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, title: 'Secret Door Price', walkInPrice: 25000 })
);
slug = body.event.slug;
id = body.event.id;
});
it('never include the walk-in price for anonymous or regular users', async () => {
for (const user of [null, MEMBER]) {
await as(user, async () => {
const single = await request('GET', `/api/events/${slug}`);
expect(single.status).toBe(200);
expect(single.body.event).not.toHaveProperty('walkInPrice');
expect(single.body.event.price).toBe(21000);
const list = await request('GET', '/api/events');
const listed = list.body.events.find((e: any) => e.id === id);
expect(listed).toBeTruthy();
expect(listed).not.toHaveProperty('walkInPrice');
const next = await request('GET', '/api/events/next/upcoming');
expect(next.body.event).not.toHaveProperty('walkInPrice');
// Belt and braces: the value must not appear anywhere in the payload.
expect(JSON.stringify(single.body)).not.toContain('25000');
expect(JSON.stringify(list.body)).not.toContain('25000');
});
}
});
it('includes it for admin and door staff', async () => {
for (const user of [ADMIN, STAFF]) {
const single = await as(user, () => request('GET', `/api/events/${id}`));
expect(single.body.event.walkInPrice).toBe(25000);
}
});
});
describe('public sales state and door price', () => {
const hours = (n: number) => new Date(Date.now() + n * 3_600_000).toISOString();
async function createEvent(fields: Record<string, unknown>) {
const { status, body } = await as(ADMIN, () =>
request('POST', '/api/events', { ...baseEvent, ...fields })
);
expect(status).toBe(201);
return body.event as { id: string; slug: string };
}
it('adds doorPrice only in the door state and never exposes the raw walk-in price', async () => {
// Starts in 1h, pre-sale closed 2h before start (site default): door state.
const door = await createEvent({
title: 'Door State', walkInPrice: 31000, startDatetime: hours(1), endDatetime: hours(4),
});
// Starts in 3 days: still online.
const online = await createEvent({
title: 'Online State', walkInPrice: 31000, startDatetime: hours(72), endDatetime: hours(75),
});
// Already over.
const ended = await createEvent({
title: 'Ended State', walkInPrice: 31000, startDatetime: hours(-4), endDatetime: hours(-1),
});
for (const user of [null, MEMBER]) {
await as(user, async () => {
const single = await request('GET', `/api/events/${door.slug}`);
expect(single.body.event.salesState).toBe('door');
expect(single.body.event.doorPrice).toBe(31000);
expect(single.body.event.presaleClosesAt).toEqual(expect.any(String));
expect(single.body.event.availableSeats).toBe(40);
expect(single.body.event.doorPaymentMethods).toEqual(['cash', 'bitcoin', 'transfer', 'pos']);
expect(single.body.event).not.toHaveProperty('walkInPrice');
expect(typeof single.body.serverTime).toBe('string');
for (const other of [online, ended]) {
const res = await request('GET', `/api/events/${other.slug}`);
expect(res.body.event.salesState).toBe(other === online ? 'online' : 'ended');
expect(res.body.event).not.toHaveProperty('doorPrice');
expect(res.body.event).not.toHaveProperty('doorPaymentMethods');
expect(res.body.event).not.toHaveProperty('walkInPrice');
expect(JSON.stringify(res.body)).not.toContain('31000');
}
const list = await request('GET', '/api/events');
const byId = new Map(list.body.events.map((e: any) => [e.id, e]));
expect((byId.get(door.id) as any).salesState).toBe('door');
expect((byId.get(door.id) as any).doorPrice).toBe(31000);
expect((byId.get(online.id) as any)).not.toHaveProperty('doorPrice');
for (const e of list.body.events) expect(e).not.toHaveProperty('walkInPrice');
});
}
});
it('falls back to the ticket price when no walk-in price is set', async () => {
const door = await createEvent({
title: 'Door Fallback', walkInPrice: null, startDatetime: hours(1), endDatetime: hours(4),
});
const res = await request('GET', `/api/events/${door.slug}`);
expect(res.body.event.salesState).toBe('door');
expect(res.body.event.doorPrice).toBe(21000);
});
});
+407
View File
@@ -0,0 +1,407 @@
// Global finance: expense categories, templates, template packs, payment
// method fees (Site Settings → Expense Templates) and the cross-event overview.
// Mounted at /api/finance.
//
// Writes are admin only. The read endpoints for categories/templates/packs are
// also open to members who can add expenses on the event given as ?eventId=,
// so the "Apply template" pickers work for them.
import { Hono, type Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
import {
db, dbAll, dbGet, events, expenseCategories, expenseTemplates, expenseTemplatePacks, expenseTemplatePackItems,
eventExpenses, paymentMethodFees,
} from '../db/index.js';
import { requireAuth, type AuthUser } from '../lib/auth.js';
import { requireEventPermission, eventFromQuery } from '../lib/eventPermissions.js';
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { financeAuditOp } from '../lib/finance/audit.js';
import { getEventFinance, iso, pyg, bool, loadFeeRules } from '../lib/finance/load.js';
import { CALC_TYPES } from '../lib/finance/calculate.js';
const financeGlobalRouter = new Hono();
const validationHook = (result: any, c: any) => {
if (!result.success) {
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
return c.json({ error: errors }, 400);
}
};
const money = z.number().int().min(0).max(2_000_000_000);
const bp = z.number().int().min(0).max(10000);
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
const ADMIN = requireAuth(['admin']);
const TEMPLATE_READERS = requireEventPermission(['edit_expenses', 'edit_own_expenses_only'], { eventId: eventFromQuery() });
// ==================== Categories ====================
const serializeCategory = (r: any) => ({
id: r.id, nameEn: r.nameEn, nameEs: r.nameEs, color: r.color, sortOrder: pyg(r.sortOrder), archived: bool(r.archived),
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
});
const categorySchema = z.object({
nameEn: z.string().trim().min(1).max(100),
nameEs: z.string().trim().min(1).max(100),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#6B7280'),
sortOrder: z.number().int().min(0).max(10000).default(0),
archived: z.boolean().default(false),
});
financeGlobalRouter.get('/settings/expense-categories', TEMPLATE_READERS, async (c) => {
const rows = await dbAll<any>((db as any).select().from(expenseCategories));
return c.json({ categories: rows.map(serializeCategory).sort((a, b) => a.sortOrder - b.sortOrder) });
});
financeGlobalRouter.post('/settings/expense-categories', ADMIN, zValidator('json', categorySchema, validationHook), async (c) => {
const data = c.req.valid('json');
const now = getNow();
const values = { id: generateId(), ...data, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
await runOps([
insertOp(expenseCategories, values),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: values.id, action: 'create', after: serializeCategory(values) }),
]);
return c.json({ category: serializeCategory(values) }, 201);
});
financeGlobalRouter.put('/settings/expense-categories/:id', ADMIN, zValidator('json', categorySchema.partial(), validationHook), async (c) => {
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, c.req.param('id'))));
if (!existing) return c.json({ error: 'Category not found' }, 404);
const data = c.req.valid('json');
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
const after = serializeCategory({ ...existing, ...updates });
await runOps([
updateOp(expenseCategories, updates, eq((expenseCategories as any).id, existing.id)),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: existing.id, action: 'update', before: serializeCategory(existing), after }),
]);
return c.json({ category: after });
});
financeGlobalRouter.delete('/settings/expense-categories/:id', ADMIN, async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, id)));
if (!existing) return c.json({ error: 'Category not found' }, 404);
const [usedByExpense, usedByTemplate] = await Promise.all([
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))),
dbGet<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(eq((expenseTemplates as any).categoryId, id))),
]);
if (usedByExpense || usedByTemplate) {
return c.json({ error: 'This category is in use. Archive it instead.', code: 'IN_USE' }, 409);
}
await runOps([
deleteOp(expenseCategories, eq((expenseCategories as any).id, id)),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: id, action: 'delete', before: serializeCategory(existing) }),
]);
return c.json({ message: 'Category deleted' });
});
// ==================== Templates ====================
const serializeTemplate = (r: any) => ({
id: r.id, name: r.name, categoryId: r.categoryId ?? null, description: r.description ?? null, calcType: r.calcType,
amount: pyg(r.amount), percentBp: pyg(r.percentBp), minimumAmount: pyg(r.minimumAmount), archived: bool(r.archived),
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
});
const templateSchema = z.object({
name: z.string().trim().min(1).max(200),
categoryId: z.string().nullable().optional(),
description: z.string().trim().max(1000).nullable().optional(),
calcType: z.enum(CALC_TYPES),
amount: money.default(0),
percentBp: bp.default(0),
minimumAmount: money.default(0),
archived: z.boolean().default(false),
});
async function validCategory(categoryId: string | null | undefined) {
if (!categoryId) return true;
return !!(await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId))));
}
financeGlobalRouter.get('/settings/expense-templates', TEMPLATE_READERS, async (c) => {
const rows = await dbAll<any>((db as any).select().from(expenseTemplates));
return c.json({ templates: rows.map(serializeTemplate).sort((a, b) => a.name.localeCompare(b.name)) });
});
financeGlobalRouter.post('/settings/expense-templates', ADMIN, zValidator('json', templateSchema, validationHook), async (c) => {
const data = c.req.valid('json');
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
const now = getNow();
const values = {
id: generateId(), ...data, categoryId: data.categoryId || null, description: data.description || null,
archived: toDbBool(data.archived), createdAt: now, updatedAt: now,
};
await runOps([
insertOp(expenseTemplates, values),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: values.id, action: 'create', after: serializeTemplate(values) }),
]);
return c.json({ template: serializeTemplate(values) }, 201);
});
financeGlobalRouter.put('/settings/expense-templates/:id', ADMIN, zValidator('json', templateSchema.partial(), validationHook), async (c) => {
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, c.req.param('id'))));
if (!existing) return c.json({ error: 'Template not found' }, 404);
const data = c.req.valid('json');
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
if (data.categoryId === '') updates.categoryId = null;
const after = serializeTemplate({ ...existing, ...updates });
await runOps([
updateOp(expenseTemplates, updates, eq((expenseTemplates as any).id, existing.id)),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: existing.id, action: 'update', before: serializeTemplate(existing), after }),
]);
return c.json({ template: after });
});
financeGlobalRouter.delete('/settings/expense-templates/:id', ADMIN, async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, id)));
if (!existing) return c.json({ error: 'Template not found' }, 404);
const [usedByExpense, usedByPack] = await Promise.all([
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))),
dbGet<any>((db as any).select({ id: (expenseTemplatePackItems as any).id }).from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).templateId, id))),
]);
if (usedByExpense || usedByPack) {
return c.json({ error: 'This template has been used or is in a pack. Archive it instead.', code: 'IN_USE' }, 409);
}
await runOps([
deleteOp(expenseTemplates, eq((expenseTemplates as any).id, id)),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: id, action: 'delete', before: serializeTemplate(existing) }),
]);
return c.json({ message: 'Template deleted' });
});
// ==================== Template packs ====================
const packSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(1000).nullable().optional(),
archived: z.boolean().default(false),
templateIds: z.array(z.string()).max(50).default([]),
});
async function loadPacks() {
const [packs, items] = await Promise.all([
dbAll<any>((db as any).select().from(expenseTemplatePacks)),
dbAll<any>((db as any).select().from(expenseTemplatePackItems)),
]);
return packs
.map((p: any) => ({
id: p.id, name: p.name, description: p.description ?? null, archived: bool(p.archived),
templateIds: items.filter((i: any) => i.packId === p.id).sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId),
createdAt: iso(p.createdAt), updatedAt: iso(p.updatedAt),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
async function validTemplates(ids: string[]) {
if (ids.length === 0) return true;
const rows = await dbAll<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(inArray((expenseTemplates as any).id, ids)));
return rows.length === new Set(ids).size;
}
const itemOps = (packId: string, templateIds: string[]): TxOp[] =>
[...new Set(templateIds)].map((templateId, i) => insertOp(expenseTemplatePackItems, { id: generateId(), packId, templateId, sortOrder: i }));
financeGlobalRouter.get('/settings/expense-template-packs', TEMPLATE_READERS, async (c) => {
return c.json({ packs: await loadPacks() });
});
financeGlobalRouter.post('/settings/expense-template-packs', ADMIN, zValidator('json', packSchema, validationHook), async (c) => {
const data = c.req.valid('json');
if (!(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
const now = getNow();
const values = { id: generateId(), name: data.name, description: data.description || null, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
await runOps([
insertOp(expenseTemplatePacks, values),
...itemOps(values.id, data.templateIds),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: values.id, action: 'create', after: { ...data } }),
]);
const pack = (await loadPacks()).find((p) => p.id === values.id);
return c.json({ pack }, 201);
});
financeGlobalRouter.put('/settings/expense-template-packs/:id', ADMIN, zValidator('json', packSchema.partial(), validationHook), async (c) => {
const id = c.req.param('id');
const before = (await loadPacks()).find((p) => p.id === id);
if (!before) return c.json({ error: 'Pack not found' }, 404);
const data = c.req.valid('json');
if (data.templateIds && !(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
const updates: Record<string, any> = { updatedAt: getNow() };
if (data.name !== undefined) updates.name = data.name;
if (data.description !== undefined) updates.description = data.description || null;
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
const ops: TxOp[] = [updateOp(expenseTemplatePacks, updates, eq((expenseTemplatePacks as any).id, id))];
if (data.templateIds) {
ops.push(deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)));
ops.push(...itemOps(id, data.templateIds));
}
ops.push(financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'update', before, after: { ...before, ...data } }));
await runOps(ops);
const pack = (await loadPacks()).find((p) => p.id === id);
return c.json({ pack });
});
financeGlobalRouter.delete('/settings/expense-template-packs/:id', ADMIN, async (c) => {
const id = c.req.param('id');
const before = (await loadPacks()).find((p) => p.id === id);
if (!before) return c.json({ error: 'Pack not found' }, 404);
await runOps([
deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)),
deleteOp(expenseTemplatePacks, eq((expenseTemplatePacks as any).id, id)),
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'delete', before }),
]);
return c.json({ message: 'Pack deleted' });
});
// ==================== Payment method fees ====================
financeGlobalRouter.get('/settings/payment-fees', ADMIN, async (c) => {
return c.json({ fees: await loadFeeRules() });
});
const feeSchema = z.object({ percentBp: bp, fixedAmount: money });
const FEE_METHODS = ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos', 'bancard'] as const;
financeGlobalRouter.put('/settings/payment-fees/:method', ADMIN, zValidator('json', feeSchema, validationHook), async (c) => {
const method = c.req.param('method');
if (!(FEE_METHODS as readonly string[]).includes(method)) return c.json({ error: 'Unknown payment method' }, 400);
const data = c.req.valid('json');
const existing = await dbGet<any>((db as any).select().from(paymentMethodFees).where(eq((paymentMethodFees as any).method, method)));
const user = currentUser(c);
const values = { percentBp: data.percentBp, fixedAmount: data.fixedAmount, updatedAt: getNow(), updatedBy: user.id };
await runOps([
existing
? updateOp(paymentMethodFees, values, eq((paymentMethodFees as any).method, method))
: insertOp(paymentMethodFees, { method, ...values }),
financeAuditOp({
eventId: null, actorUserId: user.id, entityType: 'payment_fee', entityId: method, action: existing ? 'update' : 'create',
before: existing ? { percentBp: pyg(existing.percentBp), fixedAmount: pyg(existing.fixedAmount) } : null, after: data,
}),
]);
return c.json({ fee: { method, ...data } });
});
// ==================== Cross-event overview ====================
type Totals = { events: number; gross: number; fees: number; net: number; expenses: number; profit: number };
const emptyTotals = (): Totals => ({ events: 0, gross: 0, fees: 0, net: 0, expenses: 0, profit: 0 });
const addTo = (t: Totals, row: Omit<Totals, 'events'>) => {
t.events += 1; t.gross += row.gross; t.fees += row.fees; t.net += row.net; t.expenses += row.expenses; t.profit += row.profit;
};
/**
* Profit per event, per series, per venue and payouts per partner. Finalized
* events use their snapshot. `readyToClose` lists past events whose books are
* still open (with or without any money recorded), longest-waiting first. Filters: from / to (ISO dates on the event start),
* series, venue (exact location text), partner (user id or external name).
*/
financeGlobalRouter.get('/overview', ADMIN, async (c) => {
const isDate = (v?: string) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined);
const from = isDate(c.req.query('from'));
const to = isDate(c.req.query('to'));
const seriesFilter = c.req.query('series');
const venueFilter = c.req.query('venue');
const partnerFilter = c.req.query('partner');
const conditions: any[] = [];
if (from) conditions.push(gte((events as any).startDatetime, toDbDate(from)));
if (to) conditions.push(lte((events as any).startDatetime, toDbDate(`${to}T23:59:59.999Z`)));
const allEvents = await dbAll<any>(
(db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined)
);
const rows: any[] = [];
const seriesOptions = new Set<string>();
const venueOptions = new Set<string>();
const partnerOptions = new Map<string, string>();
const bySeries = new Map<string, Totals>();
const byVenue = new Map<string, Totals>();
const byPartner = new Map<string, { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }>();
const totals = emptyTotals();
// Past events whose books are still open, including ones with no money in
// or out yet (those are left out of `events` and the totals).
const readyToClose: any[] = [];
const now = Date.now();
for (const ev of allEvents) {
if (ev.status === 'draft') continue;
const fin = await getEventFinance(ev.id, ev);
if (!fin) continue;
const r = fin.result;
const hasMoney = !(r.revenue.gross === 0 && r.expenses.total === 0 && r.revenue.otherIncome === 0 && fin.partners.length === 0);
const ended = new Date(ev.endDatetime || ev.startDatetime).getTime() <= now;
const ready = fin.state.status === 'open' && ended;
// Nothing to report for events with no money in or out, unless they still need closing.
if (!hasMoney && !ready) continue;
const series = ev.series || null;
const venue = (ev.location || '').trim();
const partnerKeys = fin.partners.map((p) => p.userId || `name:${p.name}`);
if (hasMoney) {
if (series) seriesOptions.add(series);
if (venue) venueOptions.add(venue);
fin.partners.forEach((p, i) => partnerOptions.set(partnerKeys[i], p.name));
}
if (seriesFilter && (seriesFilter === '__none__' ? series !== null : series !== seriesFilter)) continue;
if (venueFilter && venue !== venueFilter) continue;
if (partnerFilter && !partnerKeys.includes(partnerFilter)) continue;
const row = { gross: r.revenue.gross, fees: r.revenue.fees, net: r.revenue.net, expenses: r.expenses.total, profit: r.profit };
const eventRow = {
id: ev.id, title: ev.title, titleEs: ev.titleEs ?? null, startDatetime: iso(ev.startDatetime),
endDatetime: ev.endDatetime ? iso(ev.endDatetime) : null, series, location: venue,
status: ev.status, financeStatus: fin.state.status, ticketsSold: r.counts.ticketsSold, ...row,
organization: r.split.organization,
};
if (ready) readyToClose.push(eventRow);
if (!hasMoney) continue;
rows.push(eventRow);
addTo(totals, row);
const sKey = series || '';
if (!bySeries.has(sKey)) bySeries.set(sKey, emptyTotals());
addTo(bySeries.get(sKey)!, row);
if (!byVenue.has(venue)) byVenue.set(venue, emptyTotals());
addTo(byVenue.get(venue)!, row);
fin.partners.forEach((p, i) => {
const key = partnerKeys[i];
const line = r.split.partners.find((x) => x.partnerId === p.id);
const agg = byPartner.get(key) || { key, name: p.name, userId: p.userId, events: 0, share: 0, reimbursement: 0, payout: 0, paid: 0, pending: 0 };
agg.events += 1;
agg.share += line?.share ?? 0;
agg.reimbursement += line?.reimbursement ?? 0;
agg.payout += line?.payout ?? 0;
if (p.payoutStatus === 'paid') agg.paid += line?.payout ?? 0; else agg.pending += line?.payout ?? 0;
byPartner.set(key, agg);
});
}
rows.sort((a, b) => (b.startDatetime || '').localeCompare(a.startDatetime || ''));
// Longest-waiting first.
readyToClose.sort((a, b) => (a.startDatetime || '').localeCompare(b.startDatetime || ''));
const sortByProfit = <T extends { profit: number }>(xs: T[]) => xs.sort((a, b) => b.profit - a.profit);
return c.json({
totals,
events: rows,
readyToClose,
bySeries: sortByProfit([...bySeries.entries()].map(([series, t]) => ({ series: series || null, ...t }))),
byVenue: sortByProfit([...byVenue.entries()].map(([venue, t]) => ({ venue, ...t }))),
byPartner: [...byPartner.values()].sort((a, b) => b.payout - a.payout),
filters: {
series: [...seriesOptions].sort(),
venues: [...venueOptions].sort(),
partners: [...partnerOptions.entries()].map(([key, name]) => ({ key, name })).sort((a, b) => a.name.localeCompare(b.name)),
},
});
});
export default financeGlobalRouter;
+10 -3
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
const paymentOptionsRouter = new Hono();
@@ -36,6 +37,8 @@ const updatePaymentOptionsSchema = z.object({
cashEnabled: booleanOrNumber.optional(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
// POS terminal on the door screen
posEnabled: booleanOrNumber.optional(),
// Booking settings
allowDuplicateBookings: booleanOrNumber.optional(),
});
@@ -79,6 +82,7 @@ const updateEventOverridesSchema = z.object({
cashEnabled: booleanOrNumber.optional().nullable(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
posEnabled: booleanOrNumber.optional().nullable(),
});
// Get global payment options
@@ -111,6 +115,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
posEnabled: true,
allowDuplicateBookings: false,
},
});
@@ -219,6 +224,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
posEnabled: true,
};
const global = globalOptions || defaults;
@@ -245,6 +251,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true,
};
// Full bank/TPago credentials are only returned when the caller proves they hold
@@ -270,7 +277,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
});
// Get event payment overrides (admin only)
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
paymentOptionsRouter.get('/event/:eventId/overrides', requireEventPermission('view_payments', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
const eventId = c.req.param('eventId');
const overrides = await dbGet<any>(
@@ -281,7 +288,7 @@ paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'org
});
// Update event payment overrides
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
paymentOptionsRouter.put('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), zValidator('json', updateEventOverridesSchema), async (c) => {
const eventId = c.req.param('eventId');
const data = c.req.valid('json');
const now = getNow();
@@ -333,7 +340,7 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
});
// Delete event payment overrides (revert to global)
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
paymentOptionsRouter.delete('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
const eventId = c.req.param('eventId');
await (db as any)
+1 -1
View File
@@ -667,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
}
try {
const result = await emailService.sendPaymentReminder(id);
const result = await emailService.sendPaymentReminder(payment.id);
if (result.success) {
const now = getNow();
+36 -5
View File
@@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js';
interface UserContext {
id: string;
@@ -43,8 +44,26 @@ const updateSiteSettingsSchema = z.object({
maintenanceMode: z.boolean().optional(),
maintenanceMessage: z.string().optional().nullable(),
maintenanceMessageEs: z.string().optional().nullable(),
// Pre-sale closure defaults inherited by events that don't override them
presaleClosureEnabled: z.boolean().optional(),
presaleCloseMinutesBefore: z.number().int().min(0).optional(),
});
// Booleans are stored as 0/1 integers on PostgreSQL; hand the frontend real booleans.
function normalizeSettings(row: any) {
if (!row) return row;
return {
...row,
maintenanceMode: Boolean(row.maintenanceMode),
presaleClosureEnabled: row.presaleClosureEnabled == null
? DEFAULT_PRESALE_CLOSURE_ENABLED
: Boolean(row.presaleClosureEnabled),
presaleCloseMinutesBefore: row.presaleCloseMinutesBefore == null
? DEFAULT_PRESALE_CLOSE_MINUTES
: Number(row.presaleCloseMinutesBefore),
};
}
// Get site settings (public - needed for frontend timezone)
siteSettingsRouter.get('/', async (c) => {
const settings = await dbGet(
@@ -69,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => {
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED,
presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES,
},
});
}
return c.json({ settings });
return c.json({ settings: normalizeSettings(settings) });
});
// Get available timezones
@@ -145,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
maintenanceMode: toDbBool(data.maintenanceMode || false),
maintenanceMessage: data.maintenanceMessage || null,
maintenanceMessageEs: data.maintenanceMessageEs || null,
presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED),
presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES,
updatedAt: now,
updatedBy: user.id,
};
await (db as any).insert(siteSettings).values(newSettings);
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
return c.json({ settings: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201);
}
// Validate featured event if provided
@@ -174,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
if (typeof data.maintenanceMode === 'boolean') {
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
}
if (typeof data.presaleClosureEnabled === 'boolean') {
updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled);
}
await (db as any)
.update(siteSettings)
@@ -184,12 +210,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
);
// Revalidate frontend cache if featured event changed
if (data.featuredEventId !== undefined) {
// Revalidate frontend cache if featured event changed or the pre-sale
// defaults changed (public event pages embed the computed cutoff).
if (
data.featuredEventId !== undefined ||
data.presaleClosureEnabled !== undefined ||
data.presaleCloseMinutesBefore !== undefined
) {
revalidateFrontendCache();
}
return c.json({ settings: updated, message: 'Settings updated successfully' });
return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' });
});
// Set featured event (admin only) - convenience endpoint for event editor
+95 -49
View File
@@ -4,13 +4,17 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import {
requireEventPermission, eventFromQuery, eventFromBody, eventFromTicketParam, canSeeAttendeePii, redactAttendee,
} from '../lib/eventPermissions.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import { isOnlineSalesClosed } from '../lib/salesState.js';
const ticketsRouter = new Hono();
@@ -111,6 +115,17 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
return c.json({ error: 'Event is not available for booking' }, 400);
}
// Pre-sale closure: online registration stops N minutes before the event
// starts (per-event override, else the site-wide default), and at the latest
// when it starts — the same rule as the public salesState (lib/salesState.ts).
// Staff/door and admin ticket creation use separate endpoints and are not gated.
const siteSettingsRow = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
if (isOnlineSalesClosed(event, siteSettingsRow)) {
return c.json({ error: 'Registration for this event is closed' }, 400);
}
// Validate the requested payment method is actually enabled for this event
// (merge global options with any event-level overrides; override wins when not null)
const globalPaymentOptions = await dbGet<any>(
@@ -148,9 +163,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 400);
}
// Find or create user
// Find or create user. The account row is keyed on the normalized address so
// it stays reachable from Better Auth (which lowercases every lookup) —
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
const accountEmail = normalizeEmail(data.email);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const now = getNow();
@@ -163,7 +181,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const userId = generateId();
user = {
id: userId,
email: data.email,
email: accountEmail,
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
name: fullName,
phone: data.phone || null,
@@ -548,20 +566,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
);
const timezone = settings?.timezone || 'America/Asuncion';
const ticketsData = confirmedTickets.map((ticket: any) => ({
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
}));
const ticketsData = confirmedTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
return {
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
locale,
};
});
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
@@ -625,19 +647,22 @@ ticketsRouter.get('/:id/pdf', async (c) => {
);
const timezone = settings?.timezone || 'America/Asuncion';
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
const pdfBuffer = await generateTicketPDF({
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: event.title,
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
locale,
});
// Set response headers for PDF download
@@ -654,7 +679,7 @@ ticketsRouter.get('/:id/pdf', async (c) => {
});
// Get event check-in stats for scanner (lightweight endpoint for staff)
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.get('/stats/checkin', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromQuery() }), async (c) => {
const eventId = c.req.query('eventId');
if (!eventId) {
@@ -705,7 +730,7 @@ ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff'])
});
// Live search tickets (GET - for scanner live search)
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.get('/search', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromQuery() }), async (c) => {
const q = c.req.query('q')?.trim() || '';
const eventId = c.req.query('eventId');
@@ -824,7 +849,7 @@ ticketsRouter.get('/:id', async (c) => {
});
// Update ticket status (admin/organizer)
ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => {
ticketsRouter.put('/:id', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), zValidator('json', updateTicketSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
@@ -857,7 +882,7 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
});
// Search tickets by name/email (for scanner manual search)
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.post('/search', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), async (c) => {
const body = await c.req.json().catch(() => ({}));
const { query, eventId } = body;
@@ -916,7 +941,7 @@ ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), asyn
});
// Validate ticket by QR code (for scanner)
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.post('/validate', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), async (c) => {
const body = await c.req.json().catch(() => ({}));
const { code, eventId } = body;
@@ -1021,7 +1046,7 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
});
// Check-in ticket
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.post('/:id/checkin', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), async (c) => {
const id = c.req.param('id');
const adminUser = (c as any).get('user');
@@ -1076,7 +1101,7 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
// Mark payment as received (for cash payments - admin only)
// Supports multi-ticket bookings - confirms all tickets in the booking
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.post('/:id/mark-paid', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), async (c) => {
const id = c.req.param('id');
const user = (c as any).get('user');
@@ -1164,7 +1189,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
// Send confirmation emails asynchronously (don't block the response)
Promise.all([
emailService.sendBookingConfirmation(id),
emailService.sendBookingConfirmation(ticket.id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
@@ -1345,7 +1370,7 @@ ticketsRouter.post('/:id/cancel', async (c) => {
});
// Remove check-in (reset to confirmed)
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
ticketsRouter.post('/:id/remove-checkin', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), async (c) => {
const id = c.req.param('id');
const ticket = await dbGet<any>(
@@ -1373,7 +1398,7 @@ ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'st
});
// Update admin note
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
ticketsRouter.post('/:id/note', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), zValidator('json', updateNoteSchema), async (c) => {
const id = c.req.param('id');
const { note } = c.req.valid('json');
@@ -1398,7 +1423,7 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
});
// Admin create ticket (at the door)
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
ticketsRouter.post('/admin/create', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), zValidator('json', adminCreateTicketSchema), async (c) => {
const data = c.req.valid('json');
// Get event
@@ -1418,9 +1443,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
? data.email.trim()
: `door-${generateId()}@doorentry.local`;
// Find or create user
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const adminFullName = data.lastName && data.lastName.trim()
@@ -1431,7 +1457,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
email: accountEmail,
password: null,
name: adminFullName,
phone: data.phone || null,
@@ -1486,6 +1512,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
qrCode,
checkinAt: data.autoCheckin ? now : null,
adminNote: data.adminNote || null,
bookingSource: 'admin',
createdAt: now,
};
@@ -1529,14 +1556,18 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent
// door — paid in cash at the door; all fields optional; counts toward revenue;
// confirmation email only when an email is provided
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
ticketsRouter.post('/admin/add', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), zValidator('json', z.object({
eventId: z.string(),
type: z.enum(['paid', 'unpaid', 'guest']),
firstName: z.string().min(1),
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
// Door walk-ins can be logged with nothing filled in, so firstName is only
// required for the other types
firstName: z.string().optional().or(z.literal('')),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
@@ -1546,6 +1577,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets',
path: ['email'],
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
message: 'First name is required',
path: ['firstName'],
})), async (c) => {
const data = c.req.valid('json');
@@ -1565,20 +1599,23 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
// Nameless door walk-ins still need a display name on the ticket
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
? `${firstName} ${data.lastName.trim()}`
: firstName;
// Find or create user
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
email: accountEmail,
password: null,
name: fullName,
phone: data.phone || null,
@@ -1613,13 +1650,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const ticketId = generateId();
const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeFirstName: firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
@@ -1631,12 +1668,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
checkinAt: data.checkinNow ? now : null,
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
adminNote: data.adminNote || null,
bookingSource: 'admin',
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId();
const newPayment = data.type === 'unpaid'
? {
@@ -1659,7 +1697,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
reference: data.type === 'guest'
? 'Guest invite'
: data.type === 'door'
? 'Paid at door'
: 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
@@ -1668,8 +1710,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
await (db as any).insert(payments).values(newPayment);
// Emails (asynchronous): paid always confirms; guest confirms when an email
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
// Emails (asynchronous): paid always confirms; door/guest confirm only when an
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
@@ -1692,6 +1734,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent',
door: hasEmail
? 'Ticket created — paid at the door, confirmation email sent'
: 'Ticket created — paid at the door',
unpaid: hasEmail
? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door',
@@ -1715,7 +1760,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
});
// Get all tickets (admin) - includes payment for each ticket
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
ticketsRouter.get('/', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
@@ -1746,8 +1791,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
}
}
const showPii = canSeeAttendeePii(c);
const ticketsWithPayment = ticketsList.map((t: any) => ({
...t,
...(showPii ? t : redactAttendee(t)),
payment: paymentByTicketId[t.id] || null,
}));
+2 -1
View File
@@ -6,6 +6,7 @@ import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js';
import { omitWalkInPrice } from '../lib/walkInPrice.js';
interface UserContext {
id: string;
@@ -249,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
return {
...ticket,
event,
event: omitWalkInPrice(event as any),
};
})
);
+5 -2
View File
@@ -6,7 +6,8 @@
"dev": "dotenv -e .env -- next dev",
"build": "next build",
"start": "dotenv -e .env -- next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@heroicons/react": "^2.1.4",
@@ -23,6 +24,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@@ -33,6 +35,7 @@
"dotenv-cli": "^11.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.2"
"typescript": "^5.5.2",
"vitest": "^4.1.10"
}
}
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils';
import { isSafeExternalUrl } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
import type {
@@ -108,6 +108,14 @@ export default function BookingPage() {
return;
}
// Pre-sale closure: the booking API rejects after the cutoff, so
// bounce back to the event page instead of showing a dead form.
if (isPresaleClosed(eventRes.event)) {
toast.error(t('events.details.registrationClosed'));
router.push(`/events/${eventRes.event.slug}`);
return;
}
// Server-authoritative availability — same formula the booking API
// enforces, so a sold-out event is caught here, not at submit time.
if (isEventSoldOut(eventRes.event)) {
@@ -364,11 +372,18 @@ export default function BookingPage() {
toast.success(t('booking.success.message'));
}
} catch (error: any) {
const message = String(error?.message || '');
// Pre-sale closed while the form was open: send the user back to the
// event page, which now shows the "Registration Closed" state.
if (/registration .*closed/i.test(message)) {
toast.error(t('events.details.registrationClosed'));
if (event?.slug) router.push(`/events/${event.slug}`);
return;
}
toast.error(error.message || t('booking.form.errors.bookingFailed'));
// Capacity race on the last seats: refresh availability so the page
// reflects reality (sold-out block / lower quantity cap) instead of the
// stale counts loaded when the form was opened.
const message = String(error?.message || '');
if (/sold out|seats available/i.test(message)) {
try {
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
@@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { CalendarIcon, MapPinIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import type { MyEvent } from '@/lib/api';
/** Events the user was added to as staff, collaborator or co-manager. */
export default function MyEventsTab({ events }: { events: MyEvent[] }) {
const { t, locale } = useLanguage();
if (events.length === 0) {
return <p className="text-sm text-gray-500 py-8 text-center">{t('dashboard.myEvents.empty')}</p>;
}
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
return (
<ul className="space-y-3">
{events.map(({ event, rolePreset }) => (
<li key={event.id}>
<Link
href={`/dashboard/events/${event.id}`}
className="flex items-center gap-4 rounded-card bg-white p-4 shadow-card transition-shadow hover:shadow-card-hover"
>
<div className="min-w-0 flex-1">
<p className="font-semibold text-primary-dark truncate">{(locale === 'es' && event.titleEs) || event.title}</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span className="inline-flex items-center gap-1"><CalendarIcon className="h-3.5 w-3.5" />{fmt(event.startDatetime)}</span>
<span className="inline-flex items-center gap-1 min-w-0"><MapPinIcon className="h-3.5 w-3.5 flex-shrink-0" /><span className="truncate">{event.location}</span></span>
</p>
</div>
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 whitespace-nowrap">
{t(`admin.rolePresets.${rolePreset}`)}
</span>
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-gray-400" />
</Link>
</li>
))}
</ul>
);
}
@@ -44,6 +44,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
cash: { en: 'Cash', es: 'Efectivo' },
bancard: { en: 'Card', es: 'Tarjeta' },
pos: { en: 'POS', es: 'POS' },
};
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
};
@@ -0,0 +1,23 @@
'use client';
import { useParams } from 'next/navigation';
import { PrivacyProvider } from '@/context/PrivacyContext';
import { EventDetailView } from '@/app/admin/events/[id]/_components/EventDetailView';
/**
* The single event page for team members (staff / collaborators / co-managers
* added in the event's Team tab). Same view as the admin page; tabs and data
* follow the member's permissions on this event only.
*/
export default function MemberEventPage() {
const params = useParams();
return (
<PrivacyProvider>
<div className="section-padding min-h-[70vh]">
<div className="container-page">
<EventDetailView eventId={params.id as string} backHref="/dashboard?tab=events" />
</div>
</div>
</PrivacyProvider>
);
}
+19 -3
View File
@@ -6,9 +6,11 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import {
dashboardApi,
financeApi,
NextEventInfo,
UserTicket,
UserPayment,
type MyEvent,
} from '@/lib/api';
import toast from 'react-hot-toast';
import { CardListSkeleton } from '@/components/ui/Skeleton';
@@ -17,20 +19,28 @@ import OverviewTab from './components/OverviewTab';
import TicketsTab from './components/TicketsTab';
import PaymentsTab from './components/PaymentsTab';
import AccountTab from './components/AccountTab';
import MyEventsTab from './components/MyEventsTab';
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
type Tab = 'overview' | 'tickets' | 'payments' | 'events' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { locale } = useLanguage();
const { locale, t } = useLanguage();
const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
const [tickets, setTickets] = useState<UserTicket[]>([]);
const [payments, setPayments] = useState<UserPayment[]>([]);
const [myEvents, setMyEvents] = useState<MyEvent[]>([]);
const [loading, setLoading] = useState(true);
// ?tab=events (the back link from a team event page) opens My Events.
useEffect(() => {
const tab = new URLSearchParams(window.location.search).get('tab');
if (tab === 'events') setActiveTab('events');
}, []);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
@@ -47,14 +57,17 @@ export default function DashboardPage() {
const loadDashboardData = async () => {
setLoading(true);
try {
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
const [nextEventRes, ticketsRes, paymentsRes, myEventsRes] = await Promise.all([
dashboardApi.getNextEvent(),
dashboardApi.getTickets(),
dashboardApi.getPayments(),
// Team memberships are optional extra; never fail the dashboard over them.
financeApi.myEvents().catch(() => ({ events: [] as MyEvent[] })),
]);
setNextEvent(nextEventRes.nextEvent);
setTickets(ticketsRes.tickets);
setPayments(paymentsRes.payments);
setMyEvents(myEventsRes.events);
} catch (error) {
console.error('Failed to load dashboard:', error);
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
@@ -67,6 +80,8 @@ export default function DashboardPage() {
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
// Only for people on at least one event team
...(myEvents.length > 0 ? [{ id: 'events' as Tab, label: { en: t('dashboard.myEvents.tab'), es: t('dashboard.myEvents.tab') } }] : []),
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
];
@@ -125,6 +140,7 @@ export default function DashboardPage() {
{activeTab === 'payments' && (
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
)}
{activeTab === 'events' && <MyEventsTab events={myEvents} />}
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
</>
)}
@@ -18,11 +18,14 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
const now = new Date();
// An event that has started but still sells at the door stays under Upcoming.
const isUpcoming = (e: Event) =>
new Date(e.startDatetime) >= now || e.salesState === 'door';
const upcomingEvents = initialEvents.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
e.status === 'published' && isUpcoming(e)
);
const pastEvents = initialEvents.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
e.status === 'completed' || (e.status === 'published' && !isUpcoming(e))
);
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
@@ -34,9 +37,16 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
if (event.salesState === 'sold_out' || event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
if (event.salesState === 'door') {
return (
<span className="badge bg-primary-yellow/20 text-primary-dark whitespace-nowrap">
{t('events.door.badge')}
</span>
);
}
return null;
};
@@ -1,11 +1,11 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords, nextSalesStateChangeMs } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -24,6 +24,10 @@ interface EventDetailClientProps {
}
const MAX_TICKETS_PER_PERSON = 5;
// setTimeout overflows past ~24.8 days; farther flips are caught on a later visit.
const MAX_TIMER_MS = 2_147_483_647;
// Display order of door tenders ("card" is the POS terminal; never a provider name).
const DOOR_METHOD_ORDER = ['cash', 'pos', 'bitcoin', 'transfer'] as const;
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
const { t, locale } = useLanguage();
@@ -36,13 +40,50 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
setMounted(true);
}, []);
// Refresh event data on client for real-time availability
useEffect(() => {
// Server clock minus ours, so the refresh below fires on the server's schedule.
const [clockOffsetMs, setClockOffsetMs] = useState(0);
const refreshEvent = useCallback(() => {
const sentAt = Date.now();
eventsApi.getById(eventId)
.then(({ event }) => setEvent(event))
.then(({ event, serverTime }) => {
setEvent(event);
if (serverTime) {
const receivedAt = Date.now();
setClockOffsetMs(parseDate(serverTime).getTime() - (sentAt + receivedAt) / 2);
}
})
.catch(console.error);
}, [eventId]);
// Refresh event data on client for real-time availability. The server-rendered
// copy can be up to a minute old (fetch revalidate), so this also corrects a
// stale sales state right after load.
useEffect(() => {
refreshEvent();
}, [refreshEvent]);
// salesState flips on the clock (online → door at presaleClosesAt, door →
// ended at the end time) without any edit to the event: refetch at that moment
// so an open page stops offering online booking. Overdue flips retry after 3s.
useEffect(() => {
const changeAt = nextSalesStateChangeMs(event);
if (changeAt === null) return;
const delay = Math.max(changeAt - (Date.now() + clockOffsetMs) + 1000, 3000);
if (delay > MAX_TIMER_MS) return;
const timer = setTimeout(refreshEvent, delay);
return () => clearTimeout(timer);
}, [event, clockOffsetMs, refreshEvent]);
// Phones suspend timers in background tabs; catch up when the page is shown again.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') refreshEvent();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [refreshEvent]);
// Server-authoritative availability (paid + claimed seats count; abandoned
// pending bookings don't) — matches the booking API's sold-out check exactly.
const spotsLeft = eventSpotsLeft(event);
@@ -67,12 +108,78 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const isCancelled = event.status === 'cancelled';
// Server-computed sales state (backend lib/salesState.ts). External booking
// events, and any cached copy from before salesState existed, keep the
// original client-side rules below.
const salesState = event.salesState;
const legacyRules = !salesState || salesState === 'external';
const isDoorSales = salesState === 'door' && typeof event.doorPrice === 'number';
const hasEnded = salesState === 'ended';
// Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
const isPastEvent = legacyRules ? (mounted ? new Date(event.startDatetime) < new Date() : false) : hasEnded;
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
const presaleClosed = legacyRules ? (mounted ? isPresaleClosed(event) : false) : salesState === 'door';
const canBook = legacyRules
? !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted')
: salesState === 'online';
// Effective lead time (event override or site default), derived from the server cutoff
const presaleLeadMinutes = event.presaleClosesAt
? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000))
: null;
const spotsLeftText = spotsLeft === 1
? t('events.door.spotsLeftOne')
: t('events.door.spotsLeft', { n: spotsLeft });
const doorMethodsText = DOOR_METHOD_ORDER
.filter((method) => event.doorPaymentMethods?.includes(method))
.map((method) => t(`events.door.methods.${method}`))
.join(', ');
// Online sales closed but seats left: people can still come and pay at the
// door. Informational only — there is nothing to click.
const DoorSalesBlock = () => {
const doorPrice = event.doorPrice ?? event.price;
return (
<div className="rounded-card bg-primary-yellow/15 border border-primary-yellow/40 px-5 py-6 text-center">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{t('events.door.onlineClosed')}
</p>
<p className="mt-1 text-lg font-semibold text-primary-dark">
{t('events.door.headline')}
</p>
<p className="mt-4 text-4xl font-bold text-primary-dark">
{doorPrice === 0 ? t('events.details.free') : formatPrice(doorPrice, event.currency)}
</p>
{doorPrice > 0 && (
<p className="text-xs text-gray-500 mt-1">{t('events.door.perPerson')}</p>
)}
{doorPrice > event.price && event.price > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t('events.door.onlinePriceWas', { price: formatPrice(event.price, event.currency) })}
</p>
)}
<p className="mt-4 text-sm font-medium text-primary-dark">{spotsLeftText}</p>
{doorMethodsText && (
<p className="mt-1 text-xs text-gray-500">{t('events.door.accepts', { methods: doorMethodsText })}</p>
)}
<p className="mt-1 text-xs text-gray-500" suppressHydrationWarning>
{t('events.door.doorsOpen', { time: fmtTime(event.startDatetime) })}
</p>
</div>
);
};
// Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => (
const BookingCardContent = () => {
if (isDoorSales) return <DoorSalesBlock />;
if (hasEnded) {
return (
<div className="rounded-card bg-secondary-gray px-5 py-6 text-center">
<p className="text-lg font-semibold text-primary-dark">{t('events.details.eventHasEnded')}</p>
</div>
);
}
return (
<>
<div className="text-center mb-4">
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
@@ -143,11 +250,23 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<Button className="w-full" size="lg" disabled>
{isPastEvent
? t('events.details.eventEnded')
: presaleClosed
? t('events.details.registrationClosed')
: isSoldOut
? t('events.details.soldOut')
: t('events.details.cancelled')}
</Button>
)}
{canBook && !event.externalBookingEnabled && presaleLeadMinutes !== null && (
<p className="mt-3 text-center text-xs text-gray-400">
{presaleLeadMinutes > 0
? t('events.details.presaleClosesBefore', {
duration: formatDurationWords(presaleLeadMinutes, locale as 'en' | 'es'),
})
: t('events.details.presaleClosesAtStart')}
</p>
)}
{!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500">
@@ -155,7 +274,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</p>
)}
</>
);
);
};
return (
<div className="section-padding">
@@ -254,7 +374,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<div>
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
{isDoorSales
? t('events.door.spotsLeftAtDoor', { n: spotsLeft })
: `${spotsLeft} / ${event.capacity} ${t('events.details.spotsLeft')}`}
</p>
</div>
</div>
+11 -3
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { notFound, permanentRedirect } from 'next/navigation';
import EventDetailClient from './EventDetailClient';
import type { EventSalesState } from '@/lib/api';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -25,6 +26,8 @@ interface Event {
bannerUrl?: string;
availableSeats?: number;
bookedCount?: number;
presaleClosesAt?: string | null;
salesState?: EventSalesState;
createdAt: string;
updatedAt: string;
}
@@ -113,18 +116,23 @@ function generateEventJsonLd(event: Event) {
name: 'Spanglish',
url: siteUrl,
},
// The offer is always the online price; the door price is never published
// here. In the `door` state seats can still be bought (at the door), so it
// stays InStock and drops the pre-sale validThrough, which is in the past.
offers: {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability:
event.salesState === 'sold_out' ||
(typeof event.availableSeats === 'number'
? event.availableSeats
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) <= 0
? 'https://schema.org/SoldOut'
: 'https://schema.org/InStock',
url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(),
...(event.presaleClosesAt && event.salesState !== 'door' ? { validThrough: event.presaleClosesAt } : {}),
},
image: event.bannerUrl
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
@@ -15,6 +15,7 @@ import {
MasonryGrid,
} from '@/components/gallery/GalleryLayout';
import { useDownloads } from '@/components/gallery/useDownloads';
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
import Lightbox from '@/components/Lightbox';
import LoginModal from '@/components/LoginModal';
import {
@@ -53,12 +54,30 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
download: es ? 'Descargar' : 'Download',
downloading: es ? 'Descargando…' : 'Downloading…',
};
const downloadPhoto = (photo: Photo) =>
// On a phone a download cannot reach the photo library, so the button
// opens a sheet offering the shareable preview instead (SaveSheet).
// Desktop keeps downloading the original straight away.
const mobileSave = useMobileSave();
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
const downloadPhoto = (photo: Photo) => {
if (mobileSave) {
setSavePhoto({
id: photo.id,
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
previewSize: photo.previewSizeBytes,
originalSize: photo.sizeBytes,
});
return;
}
downloads.start({
id: photo.id,
url: photo.urls.original,
filename: photo.originalFilename,
});
};
// Server-rendered public galleries need no client fetch. Everything else
// (link/ticket/private) is fetched here with the share token and/or the
@@ -235,10 +254,12 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
}
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
// previewUrl is the download endpoint's preview: rendering and saving from
// the same URL is what lets "Save photo" be answered by the HTTP cache.
const lightboxItems = readyPhotos.map((p) => ({
id: p.id,
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
downloadUrl: p.urls.downloadOriginal || p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
@@ -263,7 +284,11 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
// first photo. Falls back to a navy gradient with no image.
const coverPhoto =
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
// Same preview URL the lightbox uses, so the cover photo's bytes are
// fetched once for both.
const heroUrl = coverPhoto
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
: null;
return (
<div>
@@ -338,15 +363,20 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
onDownload={(it) =>
downloads.start({ id: it.id, url: it.downloadUrl, filename: it.filename })
}
onDownload={(it) => {
const photo = readyPhotos.find((p) => p.id === it.id);
if (photo) downloadPhoto(photo);
}}
downloadingId={
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
}
downloadLabels={downloadLabels}
/>
)}
{/* Shared by both entry points: the tile button and the lightbox's.
Fetches nothing until it is open. */}
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
</GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to
+33 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import {
TicketIcon,
CheckCircleIcon,
@@ -51,6 +52,8 @@ export default function AdminBookingsPage() {
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
useEffect(() => {
loadData();
@@ -174,6 +177,7 @@ export default function AdminBookingsPage() {
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
pos: 'POS',
};
return labels[provider] || provider;
};
@@ -203,6 +207,19 @@ export default function AdminBookingsPage() {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Bookings are paginated client-side: the page already loads every ticket so
// that the stat cards, the group-booking totals and the sibling payment-method
// lookup can see the whole set, and those would break on a server-side slice.
const filterKey = JSON.stringify([selectedEvent, selectedStatus, selectedPaymentStatus, searchQuery]);
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(sortedTickets, page, pageSize, setPage);
const stats = {
total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length,
@@ -408,7 +425,7 @@ export default function AdminBookingsPage() {
</td>
</tr>
) : (
sortedTickets.map((ticket) => {
pagedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50">
@@ -427,7 +444,10 @@ export default function AdminBookingsPage() {
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'}
</span>
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
<p className="text-xs text-gray-500 mt-0.5">
{getPaymentMethodLabel(getDisplayProvider(ticket))}
{ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')}
</p>
{ticket.payment && (
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
)}
@@ -502,7 +522,7 @@ export default function AdminBookingsPage() {
No bookings found.
</div>
) : (
sortedTickets.map((ticket) => {
pagedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
const primary = getPrimaryAction(ticket);
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
@@ -580,6 +600,15 @@ export default function AdminBookingsPage() {
)}
</div>
<Pagination
id="bookings"
page={page}
pageSize={pageSize}
total={sortedTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
@@ -0,0 +1,141 @@
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
// Loading placeholders for the single event page, one per region, so each part
// of the page can show as soon as its own data is in: the header once the event
// loads, the tab bar once permissions load, stats and tab bodies once tickets load.
/** Title, date line and action buttons; sits beside the real back arrow. */
export function EventHeaderSkeleton() {
return (
<>
<div className="flex-1 min-w-0 pt-1" aria-hidden="true">
<Skeleton className="h-7 w-2/3 md:w-80" />
<Skeleton className="mt-2 h-4 w-40" />
</div>
<div className="hidden md:flex items-center gap-2 flex-shrink-0" aria-hidden="true">
<Skeleton className="h-9 w-28 rounded-btn" />
<Skeleton className="h-9 w-20 rounded-btn" />
</div>
</>
);
}
export function EventMetaChipsSkeleton() {
return (
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]" aria-hidden="true">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="h-6 w-32 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
);
}
/** Desktop 4-card stats row and the collapsed mobile stats bar. */
export function EventStatsSkeleton() {
return (
<>
<div className="hidden md:grid grid-cols-4 gap-3 mb-4" aria-hidden="true">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<Skeleton className="w-8 h-8 rounded-full flex-shrink-0" />
<div className="flex-1">
<Skeleton className="h-5 w-16" />
<Skeleton className="mt-1 h-3 w-20" />
</div>
</div>
))}
</div>
<div className="md:hidden mb-3" aria-hidden="true">
<div className="flex items-center gap-3 px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-4 w-14" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</>
);
}
/** Desktop tab strip (top of the content card) and the mobile segmented bar. */
export function EventTabBarSkeleton() {
const widths = ['w-20', 'w-24', 'w-16', 'w-14', 'w-20'];
return (
<>
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200 px-4" aria-hidden="true">
<div className="flex gap-4 py-3">
{widths.map((w, i) => <Skeleton key={i} className={`h-5 ${w}`} />)}
</div>
</div>
<div className="md:hidden -mx-4 px-3 py-1.5 bg-gray-100" aria-hidden="true">
<div className="flex gap-1.5 overflow-hidden">
{widths.map((w, i) => <Skeleton key={i} className={`h-[44px] ${w} flex-shrink-0 bg-white/80`} />)}
</div>
</div>
</>
);
}
/** Toolbar plus list rows, sized for the attendee/ticket lists inside a tab. */
export function EventTabBodySkeleton() {
return (
<SkeletonGroup>
<div className="flex items-center gap-2 mb-4" aria-hidden="true">
<Skeleton className="h-10 flex-1 md:max-w-xs rounded-btn" />
<Skeleton className="h-10 w-24 rounded-btn hidden md:block" />
<Skeleton className="h-10 w-28 rounded-btn hidden md:block" />
</div>
<div className="divide-y divide-gray-100" aria-hidden="true">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<Skeleton className="w-9 h-9 rounded-full flex-shrink-0" />
<div className="flex-1">
<Skeleton className={i % 2 === 0 ? 'h-4 w-1/3' : 'h-4 w-1/4'} />
<Skeleton className="mt-1.5 h-3 w-1/2 md:w-1/3" />
</div>
<Skeleton className="h-5 w-20 rounded-full hidden sm:block" />
<Skeleton className="h-8 w-20 rounded-btn" />
</div>
))}
</div>
</SkeletonGroup>
);
}
/** Wraps a tab body skeleton in the same card the real tab content sits in. */
export function EventTabContentSkeleton() {
return (
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
<EventTabBodySkeleton />
</div>
);
}
/** Back arrow row shared by the loading shell and the real header. */
export function EventBackButton({ href }: { href: string }) {
return (
<Link href={href} className="mt-1 flex-shrink-0">
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<ArrowLeftIcon className="w-5 h-5" />
</button>
</Link>
);
}
/** Whole-page shell before the event itself has loaded. The back arrow is real. */
export function EventDetailSkeleton({ backHref }: { backHref: string }) {
return (
<div className="max-w-full overflow-x-hidden">
<div className="flex items-start gap-3 mb-3">
<EventBackButton href={backHref} />
<EventHeaderSkeleton />
</div>
<EventMetaChipsSkeleton />
<EventStatsSkeleton />
<EventTabBarSkeleton />
<EventTabContentSkeleton />
</div>
);
}
@@ -0,0 +1,875 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, financeApi, Ticket, type EventPermission } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
CalendarIcon,
MapPinIcon,
CurrencyDollarIcon,
UsersIcon,
TicketIcon,
CheckCircleIcon,
EnvelopeIcon,
PencilIcon,
EyeIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
ArrowUturnLeftIcon,
BanknotesIcon,
UserPlusIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from '../_types';
import { formatCurrency, downloadBlob } from '../_utils/format';
import { useEventDetailData } from '../_hooks/useEventDetailData';
import { usePaymentOverrides } from '../_hooks/usePaymentOverrides';
import { OverviewTab } from '../_tabs/OverviewTab';
import { AttendeesTab } from '../_tabs/AttendeesTab';
import { TicketsTab } from '../_tabs/TicketsTab';
import { EmailTab } from '../_tabs/EmailTab';
import { PaymentsTab } from '../_tabs/PaymentsTab';
import { TeamTab } from '../_tabs/TeamTab';
import { FinanceTab } from '../_finance/FinanceTab';
import { EventModals } from '../_modals/EventModals';
import { AddTicketModal } from '../_modals/AddTicketModal';
import EventFormModal from '../../_components/EventFormModal';
import {
EventDetailSkeleton,
EventBackButton,
EventStatsSkeleton,
EventTabBarSkeleton,
EventTabBodySkeleton,
} from './EventDetailSkeleton';
/** Which permission each tab needs (see backend lib/eventPermissions.ts). */
const TAB_PERMISSION: Record<TabType, EventPermission> = {
overview: 'view_overview',
attendees: 'view_attendees_names',
tickets: 'view_attendees_names',
email: 'email_attendees',
payments: 'view_payments',
finance: 'view_finance',
team: 'manage_team',
};
const TAB_ORDER: TabType[] = ['overview', 'attendees', 'tickets', 'email', 'payments', 'finance', 'team'];
/** Tabs rendered from the tickets/templates/door data loaded by useEventDetailData
* (Overview only needs the event, and skeletons its own seat counts). */
const TABS_NEEDING_DETAILS: TabType[] = ['attendees', 'tickets', 'email', 'payments'];
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
/**
* The single event page. Admin and organizer see it at /admin/events/[id];
* team members (event_members) see the same view at /dashboard/events/[id].
* Tabs, header actions and data loads follow the viewer's permissions on this
* event; the server enforces the same permissions on every call.
*/
export function EventDetailView({ eventId, backHref }: { eventId: string; backHref: string }) {
const { locale, t } = useLanguage();
const [permissions, setPermissions] = useState<Set<EventPermission> | null>(null);
useEffect(() => {
financeApi
.myPermissions(eventId)
.then((res) => setPermissions(new Set(res.permissions)))
.catch(() => setPermissions(new Set()));
}, [eventId]);
const can = (p: EventPermission) => !!permissions?.has(p);
const { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId, permissions);
const [activeTab, setActiveTabState] = useState<TabType>('overview');
// The open tab lives in the URL (?tab=finance) so reloads, the back button
// and shared links land on it. Switching tabs drops sub-tab params (?fin=).
const setActiveTab = (tab: TabType) => {
setActiveTabState(tab);
const url = new URL(window.location.href);
if (tab === 'overview') url.searchParams.delete('tab'); else url.searchParams.set('tab', tab);
if (tab !== 'finance') url.searchParams.delete('fin');
window.history.replaceState(window.history.state, '', url);
};
// Land on the requested tab if allowed, otherwise the first tab the viewer may open.
useEffect(() => {
if (!permissions) return;
const requested = new URLSearchParams(window.location.search).get('tab') as TabType | null;
if (requested && TAB_ORDER.includes(requested) && permissions.has(TAB_PERMISSION[requested])) {
setActiveTabState(requested);
} else if (!permissions.has(TAB_PERMISSION[activeTab])) {
const first = TAB_ORDER.find((k) => permissions.has(TAB_PERMISSION[k]));
if (first) setActiveTabState(first);
}
}, [permissions]);
// Keep the active tab visible in the horizontally scrolling mobile strip.
const mobileTabsRef = useRef<HTMLDivElement>(null);
// Scroll only the strip itself: scrollIntoView would also shift the page sideways.
useEffect(() => {
const strip = mobileTabsRef.current;
const el = strip?.querySelector<HTMLElement>(`[data-tab="${activeTab}"]`);
if (strip && el) strip.scrollLeft = el.offsetLeft - (strip.clientWidth - el.clientWidth) / 2;
}, [activeTab, eventLoading, permissions]);
// Email state
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
const [customMessage, setCustomMessage] = useState('');
const [sending, setSending] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
const [exporting, setExporting] = useState(false);
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
// Tickets tab state
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>('all');
// Payment options state + handlers
const payments = usePaymentOverrides(eventId, locale);
// Edit event modal (opens in place instead of redirecting to the list page)
const [showEditForm, setShowEditForm] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
useEffect(() => {
siteSettingsApi
.get()
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
.catch(() => {});
}, []);
// Mobile-specific state
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
// Tab bar ref for sticky
const tabBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeTab === 'payments') {
payments.loadPaymentOptions();
}
}, [activeTab]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getTicketsByStatus = (status: string) => {
return tickets.filter(t => t.status === status);
};
const getFilteredRecipientCount = () => {
if (recipientFilter === 'all') return tickets.length;
return getTicketsByStatus(recipientFilter).length;
};
const handleMarkPaid = async (ticketId: string) => {
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
}
};
const handleCheckin = async (ticketId: string) => {
try {
await ticketsApi.checkin(ticketId);
toast.success('Attendee checked in');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
}
};
const handleReactivate = async (ticket: Ticket) => {
if (!ticket.payment?.id) return;
try {
await paymentsApi.reactivate(ticket.payment.id);
toast.success('Booking reactivated');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to reactivate booking');
}
};
const handleRemoveCheckin = async (ticketId: string) => {
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
try {
await ticketsApi.removeCheckin(ticketId);
toast.success('Check-in removed');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to remove check-in');
}
};
const handleOpenNoteModal = (ticket: Ticket) => {
setSelectedTicket(ticket);
setNoteText(ticket.adminNote || '');
setShowNoteModal(true);
};
const handleSaveNote = async () => {
if (!selectedTicket) return;
setSubmitting(true);
try {
await ticketsApi.updateNote(selectedTicket.id, noteText);
toast.success('Note saved');
setShowNoteModal(false);
setSelectedTicket(null);
setNoteText('');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to save note');
} finally {
setSubmitting(false);
}
};
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
const res = await ticketsApi.adminAdd({
eventId: event.id,
type: addTicketForm.type,
firstName: addTicketForm.firstName || undefined,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return;
setExporting(true);
setShowExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export attendees');
} finally {
setExporting(false);
}
};
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
if (!event) return;
setExporting(true);
setShowTicketExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export tickets');
} finally {
setExporting(false);
}
};
// Filtered tickets for attendees tab
const filteredTickets = tickets.filter((ticket) => {
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
if (ticketSearchQuery) {
const query = ticketSearchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
const handlePreviewEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
try {
const res = await emailsApi.preview({
templateSlug: selectedTemplate,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? fmtTime(event.startDatetime) : '',
eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
customMessage: customMessage || 'Your custom message will appear here.',
},
locale,
}, eventId);
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview email');
}
};
const handleSendEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
const recipientCount = getFilteredRecipientCount();
if (recipientCount === 0) {
toast.error('No recipients match the selected filter');
return;
}
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
return;
}
setSending(true);
try {
const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate,
recipientFilter,
customVariables: customMessage ? { customMessage } : undefined,
});
if (res.success) {
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else {
toast.error(res.error || 'Failed to queue emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
}
};
// The header renders as soon as the event is in; the tab bar waits for
// permissions and the ticket-derived parts (stats, counts, tab bodies) wait
// for the details, each behind its own skeleton.
if (eventLoading) {
return <EventDetailSkeleton backHref={backHref} />;
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Event not found</p>
<Link href={backHref}>
<Button variant="outline" className="mt-4">
Back to Events
</Button>
</Link>
</div>
);
}
const confirmedCount = getTicketsByStatus('confirmed').length;
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
// Revenue comes only from the door summary, which adds up what was actually
// paid. Never derive it from ticket count × event.price: the price can change
// after tickets have been sold.
const presaleRevenue = doorSummary?.presale.total ?? 0;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
// Header money follows the UI language's thousands separator.
const money = (amount: number) => formatCurrency(amount, event.currency, locale);
const revenueLabel = doorSummary ? money(revenue) : '—';
// "confirmed" tickets become "checked_in" at the door, so this counts the
// guests who have a ticket and have not arrived yet (not all confirmed ones).
const notCheckedInLabel = t('admin.eventStats.notCheckedIn');
const allTabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: t('admin.eventTabs.overview'), icon: CalendarIcon },
{ key: 'attendees', label: t('admin.eventTabs.attendees'), icon: UserGroupIcon, count: detailsLoading ? undefined : tickets.length },
{ key: 'tickets', label: t('admin.eventTabs.tickets'), icon: TicketIcon, count: detailsLoading ? undefined : confirmedTickets.length },
{ key: 'email', label: t('admin.eventTabs.email'), icon: EnvelopeIcon },
{ key: 'payments', label: t('admin.eventTabs.payments'), icon: CreditCardIcon },
{ key: 'finance', label: t('admin.eventTabs.finance'), icon: BanknotesIcon },
{ key: 'team', label: t('admin.eventTabs.team'), icon: UserPlusIcon },
];
const tabs = allTabs.filter((tab) => can(TAB_PERMISSION[tab.key]));
// Event-wide money is only shown to viewers who can see payments.
const showRevenue = can('view_payments');
// ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
}
return null;
};
return (
<div className="max-w-full overflow-x-hidden">
{/* ============= HEADER ============= */}
<div className="flex items-start gap-3 mb-3">
<EventBackButton href={backHref} />
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(event.startDatetime)}</p>
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
</Button>
</Link>
{can('edit_event') && (
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
<PencilIcon className="w-4 h-4 mr-1.5" />
Edit
</Button>
)}
</div>
{/* Mobile header overflow menu */}
<div className="md:hidden flex-shrink-0">
<Dropdown
open={mobileHeaderMenuOpen}
onOpenChange={setMobileHeaderMenuOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
{can('edit_event') && (
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
)}
</Dropdown>
</div>
</div>
{/* ============= COMPACT META CHIPS (desktop) ============= */}
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" />
{event.location}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? t('admin.eventStats.free') : money(event.price)}
</span>
{showStats && !detailsLoading && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<UsersIcon className="w-3.5 h-3.5" />
{confirmedCount + checkedInCount}/{event.capacity}
</span>
)}
</div>
{/* ============= STATS ROW ============= */}
{showStats && detailsLoading && <EventStatsSkeleton />}
{!detailsLoading && (<>
{/* Desktop: always-visible compact 4-card row */}
<div className="hidden md:block mb-4">
{showStats && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: t('admin.eventStats.capacity'), value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: notCheckedInLabel, value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
...(!showRevenue ? [] : [{
label: t('admin.eventStats.revenue'),
value: revenueLabel,
icon: CurrencyDollarIcon,
color: 'bg-gray-50 text-gray-600',
detail: doorSummary
? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) })
: undefined,
}]),
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Mobile: collapsible stats */}
<div className="md:hidden mb-3">
{showStats && (
<div>
<button
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
>
<div className="flex items-center gap-3 overflow-x-auto">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">{t('admin.eventStats.stats')}</span>
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{t('admin.eventStats.inShort', { count: checkedInCount })}</span>
{showRevenue && (
<>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{revenueLabel}</span>
</>
)}
</div>
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
</button>
{mobileStatsExpanded && (
<div className="grid grid-cols-2 gap-2">
{[
{ label: t('admin.eventStats.capacity'), value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: notCheckedInLabel, value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
...(!showRevenue ? [] : [{
label: t('admin.eventStats.revenue'),
value: revenueLabel,
icon: CurrencyDollarIcon,
color: 'text-gray-600 bg-gray-50',
detail: doorSummary
? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) })
: undefined,
}]),
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</>)}
{/* ============= TAB BAR ============= */}
{!permissions ? <EventTabBarSkeleton /> : (<>
{/* Desktop: tab bar inside a card top-section */}
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
<nav className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
activeTab === tab.key
? 'text-primary-dark'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
{activeTab === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
</div>
{/* Mobile: segmented tab bar */}
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
<div ref={mobileTabsRef} className="overflow-x-auto scrollbar-hide">
<div className="flex gap-0.5 min-w-max">
{tabs.map((tab) => (
<button
key={tab.key}
data-tab={tab.key}
aria-current={activeTab === tab.key ? 'page' : undefined}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap min-h-[44px] flex items-center gap-1',
activeTab === tab.key
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
</button>
))}
</div>
</div>
</div>
</>)}
{/* ============= TAB CONTENT ============= */}
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
{/* Overview, Finance and Team render straight away; the rest read
tickets, templates or door takings, so they wait for the details. */}
{detailsLoading && TABS_NEEDING_DETAILS.includes(activeTab) ? (
<EventTabBodySkeleton />
) : (<>
{activeTab === 'overview' && (
<OverviewTab
event={event}
formatDate={formatDate}
fmtTime={fmtTime}
formatCurrency={(amount, currency) => formatCurrency(amount, currency, locale)}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
countsLoading={detailsLoading}
/>
)}
{activeTab === 'attendees' && (
<AttendeesTab
locale={locale}
tickets={tickets}
filteredTickets={filteredTickets}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting}
showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown}
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab
locale={locale}
confirmedTickets={confirmedTickets}
filteredConfirmedTickets={filteredConfirmedTickets}
ticketSearchQuery={ticketSearchQuery}
setTicketSearchQuery={setTicketSearchQuery}
ticketStatusFilter={ticketStatusFilter}
setTicketStatusFilter={setTicketStatusFilter}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
exporting={exporting}
showTicketExportDropdown={showTicketExportDropdown}
setShowTicketExportDropdown={setShowTicketExportDropdown}
handleExportTickets={handleExportTickets}
handleCheckin={handleCheckin}
handleRemoveCheckin={handleRemoveCheckin}
setShowTicketExportSheet={setShowTicketExportSheet}
/>
)}
{activeTab === 'email' && (
<EmailTab
templates={templates}
selectedTemplate={selectedTemplate}
setSelectedTemplate={setSelectedTemplate}
recipientFilter={recipientFilter}
setRecipientFilter={setRecipientFilter}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
sending={sending}
handlePreviewEmail={handlePreviewEmail}
handleSendEmail={handleSendEmail}
getFilteredRecipientCount={getFilteredRecipientCount}
ticketsCount={tickets.length}
confirmedCount={confirmedCount}
pendingCount={pendingCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
/>
)}
{activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
)}
{activeTab === 'finance' && <FinanceTab eventId={eventId} />}
{activeTab === 'team' && <TeamTab eventId={eventId} />}
</>)}
</div>
{/* ============= MODALS ============= */}
<EventModals
ticketsCount={tickets.length}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen}
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
selectedTicket={selectedTicket}
setSelectedTicket={setSelectedTicket}
noteText={noteText}
setNoteText={setNoteText}
handleSaveNote={handleSaveNote}
previewHtml={previewHtml}
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : money(event.price)}
/>
<EventFormModal
open={showEditForm}
event={event}
featuredEventId={featuredEventId}
onFeaturedChange={setFeaturedEventId}
onClose={() => setShowEditForm(false)}
onSaved={() => { setShowEditForm(false); loadEventData(); }}
/>
<AdminMobileStyles />
</div>
);
}
@@ -0,0 +1,829 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
PlusIcon, PencilIcon, TrashIcon, LockClosedIcon, LockOpenIcon, RectangleStackIcon,
PaperClipIcon, ArrowUpTrayIcon, CheckCircleIcon, ClockIcon, FunnelIcon, ShoppingBagIcon, BoltIcon, SparklesIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import {
financeApi, mediaApi, CALC_TYPES,
type CalcType, type EventExpense, type EventFinance, type ExpenseInput, type ExpenseTemplate, type ExpenseTemplatePack, type OtherIncome,
} from '@/lib/api';
import Button from '@/components/ui/Button';
import { Modal, Field, PygInput, DateInput, Money, Pill, Segmented, EmptyState, inputClass, iconButtonClass, todayIso } from './ui';
import { bpToPercent, percentToBp } from './format';
import { previewExpense } from './calc';
import { suggestCategory, tCount } from './derive';
type T = (key: string, params?: Record<string, string | number>) => string;
type Fmt = ReturnType<typeof useMoney>;
const isAuto = (c: CalcType) => c !== 'fixed';
/** One-line explanation of how a row's amount came about ("7 tickets × 5.000 PYG"). */
function formula(e: EventExpense, t: T, m: Fmt): string {
const q = e.liveQuantity ?? e.quantity;
switch (e.calcType) {
case 'fixed': return e.quantity === 1 ? '' : `${m.num(e.quantity)} × ${m.pyg(e.unitAmount)}`;
case 'per_ticket_sold': return t('admin.finance.formula.perTicket', { count: m.num(q), amount: m.pyg(e.unitAmount) });
case 'per_checked_in': return t('admin.finance.formula.perGuest', { count: m.num(q), amount: m.pyg(e.unitAmount) });
case 'percent_of_revenue': return t('admin.finance.formula.percent', { percent: m.pct(e.percentBp) });
case 'minimum_spend': return t('admin.finance.formula.minimum', { minimum: m.pyg(e.minimumAmount), count: m.num(q), amount: m.pyg(e.unitAmount) });
default: return '';
}
}
/** Sticky action bar at the bottom of a modal, so Save stays reachable on phones. */
function ModalActions({ children }: { children: React.ReactNode }) {
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
}
// ==================== Expense form ====================
interface ExpenseFormState {
description: string;
categoryId: string;
calcType: CalcType;
/** Fixed: the whole amount. Per ticket / per guest / minimum spend: the amount per unit. */
unitAmount: number;
percent: string;
minimumAmount: number;
isLocked: boolean;
computedAmount: number;
status: 'planned' | 'paid';
paidByPartnerId: string;
receiptUrl: string;
expenseDate: string;
}
const emptyForm = (): ExpenseFormState => ({
description: '', categoryId: '', calcType: 'fixed', unitAmount: 0, percent: '', minimumAmount: 0,
isLocked: false, computedAmount: 0, status: 'planned', paidByPartnerId: '', receiptUrl: '', expenseDate: '',
});
function fromExpense(e: EventExpense): ExpenseFormState {
return {
description: e.description, categoryId: e.categoryId || '', calcType: e.calcType,
// Older fixed rows may carry a quantity; the form edits their total, which saves as quantity 1.
unitAmount: e.calcType === 'fixed' ? e.unitAmount * e.quantity : e.unitAmount,
percent: e.percentBp ? bpToPercent(e.percentBp) : '', minimumAmount: e.minimumAmount,
isLocked: e.isLocked, computedAmount: e.amount ?? e.computedAmount, status: e.status,
paidByPartnerId: e.paidByPartnerId && e.paidByPartnerId !== 'other' ? e.paidByPartnerId : '',
receiptUrl: e.receiptUrl || '', expenseDate: e.expenseDate ? e.expenseDate.slice(0, 10) : '',
};
}
function ExpenseModal({ open, onClose, eventId, data, expense, onSaved }: {
open: boolean; onClose: () => void; eventId: string; data: EventFinance; expense: EventExpense | null; onSaved: () => void;
}) {
const { t, locale } = useLanguage();
const m = useMoney();
const { user } = useAuth();
const [form, setForm] = useState<ExpenseFormState>(emptyForm());
const [touched, setTouched] = useState(false);
// Category follows the description until someone picks one by hand.
const [categoryAuto, setCategoryAuto] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
// The media upload is admin/organizer only; others can paste a link.
const canUpload = user?.role === 'admin' || user?.role === 'organizer';
useEffect(() => {
if (open) {
setForm(expense ? fromExpense(expense) : emptyForm());
setTouched(false);
setCategoryAuto(!expense || !expense.categoryId);
}
}, [open, expense]);
const set = <K extends keyof ExpenseFormState>(k: K, v: ExpenseFormState[K]) => setForm((f) => ({ ...f, [k]: v }));
const categories = data.categories.filter((c) => !c.archived || c.id === form.categoryId);
const ct = form.calcType;
const counts = data.summary.counts;
const sales = data.summary.revenue.sales;
const preview = previewExpense(
{ calcType: ct, quantity: 1, unitAmount: form.unitAmount, percentBp: percentToBp(form.percent), minimumAmount: form.minimumAmount },
{ ticketsSold: counts.ticketsSold, checkedIn: counts.checkedIn, sales },
);
const locked = isAuto(ct) && form.isLocked;
const total = locked ? form.computedAmount : preview.amount;
const descriptionError = touched && !form.description.trim() ? t('admin.finance.expenses.descriptionRequired') : undefined;
const setDescription = (v: string) => {
setForm((f) => {
if (!categoryAuto) return { ...f, description: v };
return { ...f, description: v, categoryId: suggestCategory(v, data.categories) || '' };
});
};
const setStatus = (v: 'planned' | 'paid') => {
setForm((f) => ({ ...f, status: v, expenseDate: v === 'paid' && !f.expenseDate ? todayIso() : f.expenseDate }));
};
const upload = async (file: File) => {
setUploading(true);
try {
const res = await mediaApi.upload(file, expense?.id, 'expense');
set('receiptUrl', res.url);
} catch (error: any) {
toast.error(error.message);
} finally {
setUploading(false);
}
};
const submit = async () => {
setTouched(true);
if (!form.description.trim()) return;
setSaving(true);
const payload: ExpenseInput = {
description: form.description.trim(),
categoryId: form.categoryId || null,
calcType: ct,
quantity: ct === 'fixed' ? 1 : undefined,
unitAmount: ct === 'percent_of_revenue' ? 0 : form.unitAmount,
percentBp: ct === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
minimumAmount: ct === 'minimum_spend' ? form.minimumAmount : 0,
isLocked: locked,
computedAmount: locked ? form.computedAmount : undefined,
status: form.status,
paidByPartnerId: form.paidByPartnerId || null,
receiptUrl: form.receiptUrl || null,
expenseDate: form.expenseDate || null,
};
try {
if (expense) await financeApi.updateExpense(eventId, expense.id, payload);
else await financeApi.createExpense(eventId, payload);
toast.success(t(expense ? 'admin.finance.expenses.saved' : 'admin.finance.expenses.created'));
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
// "5.000 PYG × 4 tickets = 20.000 PYG" with this event's real numbers.
const previewLine = (() => {
if (locked) return t('admin.finance.preview.locked', { total: m.pyg(total) });
const totalText = m.pyg(total);
switch (ct) {
case 'per_ticket_sold': return tCount(t, 'admin.finance.preview.perTicket', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
case 'per_checked_in': return tCount(t, 'admin.finance.preview.perGuest', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
case 'percent_of_revenue': return t('admin.finance.preview.percent', { percent: m.pct(percentToBp(form.percent)), sales: m.pyg(Math.max(0, sales)), total: totalText });
case 'minimum_spend': return t('admin.finance.preview.minimum', { minimum: m.pyg(form.minimumAmount), count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
default: return t('admin.finance.preview.fixed', { total: totalText });
}
})();
const amountLabel = {
fixed: 'admin.finance.expenses.amount',
per_ticket_sold: 'admin.finance.expenses.amountPerTicket',
per_checked_in: 'admin.finance.expenses.amountPerGuest',
percent_of_revenue: 'admin.finance.expenses.percentOfRevenue',
minimum_spend: 'admin.finance.expenses.minimumAmount',
}[ct];
return (
<Modal open={open} onClose={onClose} title={t(expense ? 'admin.finance.expenses.edit' : 'admin.finance.expenses.new')} wide>
<Field label={t('admin.finance.expenses.description')} required error={descriptionError}>
<input
className={clsx(inputClass, descriptionError && 'border-red-400')}
value={form.description}
maxLength={300}
placeholder={t('admin.finance.expenses.descriptionPlaceholder')}
onChange={(e) => setDescription(e.target.value)}
onBlur={() => setTouched(true)}
autoFocus
/>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t(amountLabel)}>
{ct === 'percent_of_revenue' ? (
<div className="relative">
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} onChange={(e) => set('percent', e.target.value)} placeholder="10" />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
</div>
) : ct === 'minimum_spend' ? (
<PygInput value={form.minimumAmount} onChange={(n) => set('minimumAmount', n)} />
) : (
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
)}
</Field>
<Field label={t('admin.finance.expenses.calcType')} hint={t(`admin.finance.calcHints.${ct}`)}>
<select className={inputClass} value={ct} onChange={(e) => set('calcType', e.target.value as CalcType)}>
{CALC_TYPES.map((c) => <option key={c} value={c}>{t(`admin.finance.calcTypes.${c}`)}</option>)}
</select>
</Field>
{ct === 'minimum_spend' && (
<Field label={t('admin.finance.expenses.amountPerGuest')}>
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
</Field>
)}
</div>
{/* Live result: what this line costs with today's numbers */}
<div className="rounded-btn bg-gray-50 border border-gray-100 px-3 py-2.5">
<p className="text-sm text-gray-700 tabular-nums">{previewLine}</p>
{isAuto(ct) && (
<div className="mt-2 pt-2 border-t border-gray-200 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={form.isLocked} onChange={(e) => { set('isLocked', e.target.checked); if (e.target.checked) set('computedAmount', preview.amount); }} className="w-4 h-4 mt-0.5 accent-primary-yellow" />
<span>
<span className="font-medium">{t('admin.finance.expenses.lock')}</span>
<span className="block text-xs text-gray-500">{t('admin.finance.expenses.lockHint')}</span>
</span>
</label>
{form.isLocked && (
<Field label={t('admin.finance.expenses.lockedAmount')}>
<PygInput value={form.computedAmount} onChange={(n) => set('computedAmount', n)} />
</Field>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field
label={t('admin.finance.expenses.category')}
hint={categoryAuto && form.categoryId ? t('admin.finance.expenses.categorySuggested') : undefined}
>
<select className={inputClass} value={form.categoryId} onChange={(e) => { setCategoryAuto(false); set('categoryId', e.target.value); }}>
<option value="">{t('admin.finance.charts.uncategorized')}</option>
{categories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
</select>
</Field>
<Field label={t('admin.finance.expenses.status')}>
<Segmented<'planned' | 'paid'>
label={t('admin.finance.expenses.status')}
value={form.status}
onChange={setStatus}
options={[
{ key: 'planned', label: t('admin.finance.expenseStatus.planned'), icon: <ClockIcon className="w-4 h-4" aria-hidden /> },
{ key: 'paid', label: t('admin.finance.expenseStatus.paid'), icon: <CheckCircleIcon className="w-4 h-4" aria-hidden /> },
]}
/>
</Field>
{form.status === 'paid' && (
<>
<Field label={t('admin.finance.expenses.date')}>
<DateInput value={form.expenseDate} onChange={(v) => set('expenseDate', v)} />
</Field>
<Field label={t('admin.finance.expenses.paidBy')} hint={data.partners.length > 0 ? t('admin.finance.expenses.paidByHint') : undefined}>
<select className={inputClass} value={form.paidByPartnerId} onChange={(e) => set('paidByPartnerId', e.target.value)}>
<option value="">{t('admin.finance.expenses.organization')}</option>
{data.partners.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Field>
<div className="sm:col-span-2">
<Field label={t('admin.finance.expenses.receipt')} hint={canUpload ? undefined : t('admin.finance.expenses.receiptAdminOnly')}>
<div className="flex gap-2">
<input className={inputClass} type="url" inputMode="url" value={form.receiptUrl} placeholder="https://" onChange={(e) => set('receiptUrl', e.target.value)} />
{canUpload && (
<label className={clsx('flex-shrink-0 inline-flex items-center gap-1.5 px-3 rounded-btn border border-secondary-light-gray cursor-pointer hover:bg-gray-50 min-h-[44px] text-sm font-medium focus-within:ring-2 focus-within:ring-primary-yellow', uploading && 'opacity-50 pointer-events-none')}>
<ArrowUpTrayIcon className="w-4 h-4" aria-hidden />
{uploading ? t('common.loading') : t('admin.finance.expenses.upload')}
<input type="file" accept="image/*" className="sr-only" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
</label>
)}
</div>
</Field>
</div>
</>
)}
</div>
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Income form ====================
function IncomeModal({ open, income, onClose, eventId, onSaved }: {
open: boolean; income: OtherIncome | null; onClose: () => void; eventId: string; onSaved: () => void;
}) {
const { t } = useLanguage();
const [description, setDescription] = useState('');
const [amount, setAmount] = useState(0);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) { setDescription(income?.description || ''); setAmount(income?.amount || 0); }
}, [open, income]);
const save = async () => {
setSaving(true);
try {
if (income) await financeApi.updateIncome(eventId, income.id, { description, amount });
else await financeApi.createIncome(eventId, { description, amount });
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={t(income ? 'admin.finance.otherIncome.edit' : 'admin.finance.otherIncome.add')}
subtitle={t('admin.finance.otherIncome.hint')}
>
<Field label={t('admin.finance.expenses.description')} required>
<input className={inputClass} value={description} maxLength={300} placeholder={t('admin.finance.otherIncome.placeholder')} onChange={(e) => setDescription(e.target.value)} autoFocus />
</Field>
<Field label={t('admin.finance.expenses.amount')} required>
<PygInput value={amount} onChange={setAmount} />
</Field>
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!description.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Use a template (packs + single templates) ====================
function TemplateModal({ open, onClose, eventId, onApplied }: { open: boolean; onClose: () => void; eventId: string; onApplied: () => void }) {
const { t } = useLanguage();
const m = useMoney();
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
const [packs, setPacks] = useState<ExpenseTemplatePack[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setLoading(true);
Promise.all([financeApi.getTemplates(eventId), financeApi.getPacks(eventId)])
.then(([tpl, pk]) => { setTemplates(tpl.templates.filter((x) => !x.archived)); setPacks(pk.packs.filter((x) => !x.archived)); })
.catch((error) => toast.error(error.message))
.finally(() => setLoading(false));
}, [open, eventId]);
const apply = async (body: { templateId?: string; packId?: string }, key: string) => {
setBusy(key);
try {
const res = await financeApi.applyTemplate(eventId, body);
toast.success(t('admin.finance.expenses.applied', { count: res.expenses.length }));
onApplied();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const templateName = (id: string) => templates.find((x) => x.id === id)?.name;
const summary = (tpl: ExpenseTemplate) => {
const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${m.pct(tpl.percentBp)}`;
if (tpl.calcType === 'minimum_spend') return `${kind} · ${m.pyg(tpl.amount)} · min ${m.pyg(tpl.minimumAmount)}`;
return `${kind} · ${m.pyg(tpl.amount)}`;
};
return (
<Modal open={open} onClose={onClose} title={t('admin.finance.expenses.useTemplate')} subtitle={t('admin.finance.expenses.useTemplateHint')}>
{loading ? <p className="text-sm text-gray-500">{t('common.loading')}</p> : templates.length === 0 && packs.length === 0 ? (
<p className="text-sm text-gray-500">{t('admin.finance.expenses.noTemplates')}</p>
) : (
<div className="space-y-4">
{packs.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.packsHeading')}</h3>
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
{packs.map((p) => (
<li key={p.id} className="px-3 py-2.5 flex items-center gap-3">
<RectangleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-hidden />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{p.name}</p>
<p className="text-xs text-gray-500">{p.templateIds.map(templateName).filter(Boolean).join(' · ')}</p>
</div>
<Button size="sm" isLoading={busy === p.id} onClick={() => apply({ packId: p.id }, p.id)} className="min-h-[40px]">
{t('admin.finance.expenses.addAll', { count: p.templateIds.length })}
</Button>
</li>
))}
</ul>
</section>
)}
{templates.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.templatesHeading')}</h3>
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
{templates.map((tpl) => (
<li key={tpl.id} className="px-3 py-2.5 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{tpl.name}</p>
<p className="text-xs text-gray-500">{summary(tpl)}</p>
</div>
<Button size="sm" variant="outline" isLoading={busy === tpl.id} onClick={() => apply({ templateId: tpl.id }, tpl.id)} className="min-h-[40px]">
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.expenses.addOne')}
</Button>
</li>
))}
</ul>
</section>
)}
</div>
)}
</Modal>
);
}
// ==================== View ====================
type LedgerRow = { kind: 'expense'; e: EventExpense } | { kind: 'income'; i: OtherIncome };
/** Costs and other income in one ledger: income rows read +amount in green. */
export function ExpensesView({ eventId, data, onChange }: { eventId: string; data: EventFinance; onChange: () => void }) {
const { t, locale } = useLanguage();
const m = useMoney();
const { privacyMode } = usePrivacy();
const [category, setCategory] = useState('');
const [editing, setEditing] = useState<EventExpense | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [income, setIncome] = useState<OtherIncome | null>(null);
const [incomeOpen, setIncomeOpen] = useState(false);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const open = data.status === 'open';
const { canEditExpenses, canEditOwnExpenses, userId } = data.viewer;
const canAdd = open && (canEditExpenses || canEditOwnExpenses);
const canEditIncome = open && canEditExpenses;
const canEditRow = (e: EventExpense) => open && (canEditExpenses || (canEditOwnExpenses && e.createdBy === userId));
const catById = useMemo(() => new Map(data.categories.map((c) => [c.id, c])), [data.categories]);
const usedCategories = data.categories.filter((c) => data.expenses.some((e) => e.categoryId === c.id));
const partnerName = (id: string | null) => {
if (!id) return t('admin.finance.expenses.organization');
if (id === 'other') return t('admin.finance.expenses.otherPartner');
return data.partners.find((p) => p.id === id)?.name || t('admin.finance.expenses.otherPartner');
};
const expenses = data.expenses.filter((e) => !category || (category === '__none__' ? !e.categoryId : e.categoryId === category));
// A category filter narrows to costs; income has no category.
const rows: LedgerRow[] = [
...expenses.map((e) => ({ kind: 'expense' as const, e })),
...(category ? [] : data.otherIncome.map((i) => ({ kind: 'income' as const, i }))),
];
const amountOf = (e: EventExpense) => e.amount ?? e.computedAmount;
const costTotal = expenses.reduce((s, e) => s + amountOf(e), 0);
const paid = expenses.filter((e) => e.status === 'paid').reduce((s, e) => s + amountOf(e), 0);
const incomeTotal = data.otherIncome.reduce((s, r) => s + r.amount, 0);
const patch = async (e: EventExpense, body: ExpenseInput) => {
setBusyId(e.id);
try {
await financeApi.updateExpense(eventId, e.id, body);
onChange();
} catch (error: any) {
toast.error(error.message);
} finally {
setBusyId(null);
}
};
const remove = async (e: EventExpense) => {
if (!confirm(t('admin.finance.expenses.confirmDeleteNamed', { name: e.description }))) return;
try {
await financeApi.deleteExpense(eventId, e.id);
toast.success(t('admin.finance.expenses.deleted'));
onChange();
} catch (error: any) {
toast.error(error.message);
}
};
const removeIncome = async (row: OtherIncome) => {
if (!confirm(t('admin.finance.otherIncome.confirmDelete', { name: row.description }))) return;
try {
await financeApi.deleteIncome(eventId, row.id);
onChange();
} catch (error: any) {
toast.error(error.message);
}
};
const openNew = () => { setEditing(null); setFormOpen(true); };
const openEdit = (e: EventExpense) => { setEditing(e); setFormOpen(true); };
const openIncome = (row: OtherIncome | null) => { setIncome(row); setIncomeOpen(true); };
const CategoryTag = ({ id }: { id: string | null }) => {
const c = id ? catById.get(id) : null;
return (
<span className="inline-flex items-center gap-1 text-xs text-gray-500">
<span className="w-2 h-2 rounded-full" style={{ background: c?.color || '#D1D5DB' }} aria-hidden />
{c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized')}
</span>
);
};
// Status is a labeled toggle with an icon, not a color-only badge.
const StatusToggle = ({ e }: { e: EventExpense }) => {
const isPaid = e.status === 'paid';
const editable = canEditRow(e);
return (
<button
disabled={!editable || busyId === e.id}
onClick={() => patch(e, isPaid ? { status: 'planned' } : { status: 'paid', expenseDate: e.expenseDate || todayIso() })}
title={editable ? t(isPaid ? 'admin.finance.expenses.markPlanned' : 'admin.finance.expenses.markPaid') : undefined}
className={clsx(
'inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 min-h-[32px] text-xs font-medium whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800',
editable ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-300' : 'cursor-default',
)}
>
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
{t(`admin.finance.expenseStatus.${e.status}`)}
</button>
);
};
const IncomeTag = () => (
<Pill tone="green"><SparklesIcon className="w-3 h-3 mr-0.5" aria-hidden />{t('admin.finance.ledger.income')}</Pill>
);
const LockButton = ({ e }: { e: EventExpense }) => isAuto(e.calcType) ? (
<button
disabled={!canEditRow(e) || busyId === e.id}
onClick={() => patch(e, { isLocked: !e.isLocked })}
title={t(e.isLocked ? 'admin.finance.expenses.unlockHint' : 'admin.finance.expenses.lockHint')}
className={clsx(iconButtonClass, e.isLocked ? 'text-primary-dark' : 'text-gray-400')}
aria-label={t('admin.finance.expenses.lock')}
aria-pressed={e.isLocked}
>
{e.isLocked ? <LockClosedIcon className="w-4 h-4" /> : <LockOpenIcon className="w-4 h-4" />}
</button>
) : null;
const Meta = ({ e }: { e: EventExpense }) => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1">
<CategoryTag id={e.categoryId} />
{isAuto(e.calcType) && (
<Pill tone={e.isLocked ? 'gray' : 'blue'}>
{e.isLocked ? <LockClosedIcon className="w-3 h-3 mr-0.5" aria-hidden /> : <BoltIcon className="w-3 h-3 mr-0.5" aria-hidden />}
{t(e.isLocked ? 'admin.finance.expenses.locked' : 'admin.finance.expenses.auto')}
</Pill>
)}
{e.receiptUrl && (
<a href={e.receiptUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-xs text-blue-700 hover:underline">
<PaperClipIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.viewReceipt')}
</a>
)}
</div>
);
const modals = (
<>
<ExpenseModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} data={data} expense={editing} onSaved={onChange} />
<IncomeModal open={incomeOpen} income={income} onClose={() => setIncomeOpen(false)} eventId={eventId} onSaved={onChange} />
<TemplateModal open={templatesOpen} onClose={() => setTemplatesOpen(false)} eventId={eventId} onApplied={onChange} />
</>
);
const addButtons = (
<>
<Button variant="outline" onClick={() => setTemplatesOpen(true)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
<RectangleStackIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.useTemplate')}
</Button>
{canEditIncome && (
<Button variant="outline" onClick={() => openIncome(null)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.otherIncome.add')}
</Button>
)}
<Button onClick={openNew} className="min-h-[44px] col-span-2 lg:col-span-1">
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.add')}
</Button>
</>
);
if (data.expenses.length === 0 && data.otherIncome.length === 0) {
return (
<div className="space-y-4">
<EmptyState icon={<ShoppingBagIcon className="w-5 h-5" />} title={t('admin.finance.ledger.emptyTitle')} body={t('admin.finance.expenses.empty')}>
{canAdd && addButtons}
</EmptyState>
{modals}
</div>
);
}
const incomeAmount = (i: OtherIncome) => <Money value={i.amount} signed className={clsx('font-semibold whitespace-nowrap', !privacyMode && 'text-green-700')} />;
return (
<div className="space-y-4">
{/* Totals + actions */}
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
<dl className={clsx('grid gap-2 flex-1 lg:max-w-2xl grid-cols-2', incomeTotal > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
{[
{ label: t('admin.finance.ledger.costs'), value: costTotal, strong: true },
{ label: t('admin.finance.expenseStatus.paid'), value: paid },
{ label: t('admin.finance.expenseStatus.planned'), value: costTotal - paid },
...(incomeTotal > 0 ? [{ label: t('admin.finance.otherIncome.title'), value: incomeTotal, income: true }] : []),
].map((s) => (
<div key={s.label} className={clsx('bg-white rounded-card shadow-card px-3 py-2 min-w-0', incomeTotal === 0 && 'strong' in s && 'col-span-2 sm:col-span-1')}>
<dt className="text-xs text-gray-500 truncate">{s.label}</dt>
<dd>
<Money
value={s.value}
signed={'income' in s}
className={clsx('text-sm md:text-base whitespace-nowrap', 'strong' in s && s.strong ? 'font-bold' : 'font-semibold', 'income' in s && !privacyMode && 'text-green-700')}
/>
</dd>
</div>
))}
</dl>
{canAdd && <div className="grid grid-cols-2 lg:flex gap-2 lg:ml-auto">{addButtons}</div>}
</div>
<div className="flex flex-wrap items-center gap-2">
{usedCategories.length > 1 && (
<label className="inline-flex items-center gap-2 text-sm text-gray-600">
<FunnelIcon className="w-4 h-4" aria-hidden />
<span className="sr-only">{t('admin.finance.expenses.category')}</span>
<select
className="px-3 min-h-[40px] text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow cursor-pointer"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">{t('admin.finance.ledger.allRows')}</option>
{usedCategories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
{data.expenses.some((e) => !e.categoryId) && <option value="__none__">{t('admin.finance.charts.uncategorized')}</option>}
</select>
</label>
)}
{open && !canEditExpenses && canEditOwnExpenses && <p className="text-xs text-gray-500">{t('admin.finance.expenses.ownOnly')}</p>}
{!open && <p className="text-xs text-gray-500 inline-flex items-center gap-1"><LockClosedIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.frozen')}</p>}
</div>
{/* Desktop table: amount sits on the right, next to the row actions */}
<div className="hidden md:block bg-white rounded-card shadow-card overflow-hidden">
<table className="w-full text-sm table-fixed">
<colgroup>
<col />
<col className="w-36" />
<col className="w-44" />
<col className="w-44" />
<col className="w-32" />
</colgroup>
<thead className="text-xs text-gray-500 bg-gray-50">
<tr>
<th scope="col" className="text-left font-medium px-4 py-2.5">{t('admin.finance.expenses.description')}</th>
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.status')}</th>
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.paidBy')}</th>
<th scope="col" className="text-right font-medium px-3 py-2.5">{t('admin.finance.expenses.amount')}</th>
<th scope="col" className="px-3 py-2.5"><span className="sr-only">{t('common.edit')}</span></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => row.kind === 'expense' ? (
<tr key={row.e.id} className="align-top hover:bg-gray-50/60">
<td className="px-4 py-3 min-w-0">
{canEditRow(row.e) ? (
<button onClick={() => openEdit(row.e)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
{row.e.description}
</button>
) : <p className="font-medium text-primary-dark">{row.e.description}</p>}
<Meta e={row.e} />
</td>
<td className="px-3 py-3"><StatusToggle e={row.e} /></td>
<td className="px-3 py-3 text-sm text-gray-600 truncate">{row.e.status === 'paid' ? partnerName(row.e.paidByPartnerId) : ''}</td>
<td className="px-3 py-3 text-right">
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
{!privacyMode && formula(row.e, t, m) && <p className="text-xs text-gray-400 mt-0.5">{formula(row.e, t, m)}</p>}
</td>
<td className="px-2 py-2">
<div className="flex justify-end items-center">
<LockButton e={row.e} />
{canEditRow(row.e) && (
<>
<button onClick={() => openEdit(row.e)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</>
)}
</div>
</td>
</tr>
) : (
<tr key={row.i.id} className="align-top hover:bg-gray-50/60">
<td className="px-4 py-3 min-w-0">
{canEditIncome ? (
<button onClick={() => openIncome(row.i)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
{row.i.description}
</button>
) : <p className="font-medium text-primary-dark">{row.i.description}</p>}
</td>
<td className="px-3 py-3"><IncomeTag /></td>
<td className="px-3 py-3" />
<td className="px-3 py-3 text-right">{incomeAmount(row.i)}</td>
<td className="px-2 py-2">
{canEditIncome && (
<div className="flex justify-end items-center">
<button onClick={() => openIncome(row.i)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</div>
)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<td className="px-4 py-2.5 font-semibold" colSpan={3}>
{t('admin.finance.ledger.costs')}
{category && <span className="font-normal text-gray-500"> · {t('admin.finance.expenses.filtered')}</span>}
</td>
<td className="px-3 py-2.5 text-right"><Money value={costTotal} className="font-bold whitespace-nowrap" /></td>
<td />
</tr>
{!category && incomeTotal > 0 && (
<tr>
<td className="px-4 pb-2.5 font-semibold" colSpan={3}>{t('admin.finance.otherIncome.title')}</td>
<td className="px-3 pb-2.5 text-right"><Money value={incomeTotal} signed className={clsx('font-bold whitespace-nowrap', !privacyMode && 'text-green-700')} /></td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Mobile cards: tap the card to edit, status toggles in place */}
<ul className="md:hidden space-y-2">
{rows.map((row) => row.kind === 'expense' ? (
<li key={row.e.id} className="bg-white rounded-card shadow-card">
<div className="flex items-start gap-3 p-3">
<button
onClick={() => canEditRow(row.e) && openEdit(row.e)}
disabled={!canEditRow(row.e)}
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
>
<p className="font-medium text-primary-dark">{row.e.description}</p>
<Meta e={row.e} />
</button>
<div className="text-right">
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
{!privacyMode && formula(row.e, t, m) && <p className="text-[11px] text-gray-400">{formula(row.e, t, m)}</p>}
</div>
</div>
<div className="flex items-center gap-2 px-3 pb-2">
<StatusToggle e={row.e} />
{row.e.status === 'paid' && <span className="text-xs text-gray-500 truncate">{partnerName(row.e.paidByPartnerId)}</span>}
<div className="ml-auto flex items-center">
<LockButton e={row.e} />
{canEditRow(row.e) && (
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
)}
</div>
</div>
</li>
) : (
<li key={row.i.id} className="bg-white rounded-card shadow-card">
<div className="flex items-start gap-3 p-3">
<button
onClick={() => canEditIncome && openIncome(row.i)}
disabled={!canEditIncome}
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
>
<p className="font-medium text-primary-dark">{row.i.description}</p>
<div className="mt-1"><IncomeTag /></div>
</button>
<div className="flex items-center gap-1">
{incomeAmount(row.i)}
{canEditIncome && (
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
)}
</div>
</div>
</li>
))}
<li className="flex justify-between px-3 py-2 text-sm font-semibold">
<span>{t('admin.finance.ledger.costs')}</span>
<Money value={costTotal} />
</li>
{!category && incomeTotal > 0 && (
<li className="flex justify-between px-3 pb-2 text-sm font-semibold">
<span>{t('admin.finance.otherIncome.title')}</span>
<Money value={incomeTotal} signed className={clsx(!privacyMode && 'text-green-700')} />
</li>
)}
</ul>
{modals}
</div>
);
}
@@ -0,0 +1,71 @@
'use client';
import clsx from 'clsx';
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, LockClosedIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import type { EventFinance } from '@/lib/api';
import Button from '@/components/ui/Button';
import { Modal, Money } from './ui';
import { tCount, type Lifecycle } from './derive';
/** Last look before freezing the numbers: what is in, what is missing, and the result. */
export function FinalizeDialog({ open, onClose, onConfirm, busy, data, lifecycle }: {
open: boolean; onClose: () => void; onConfirm: () => void; busy: boolean; data: EventFinance; lifecycle: Lifecycle;
}) {
const { t } = useLanguage();
const s = data.summary;
const partnersTotal = s.split.partners.reduce((sum, p) => sum + p.share, 0);
const loss = s.profit < 0;
const Row = ({ tone, children, value }: { tone: 'ok' | 'warn' | 'info'; children: React.ReactNode; value?: React.ReactNode }) => {
const Icon = tone === 'ok' ? CheckCircleIcon : tone === 'warn' ? ExclamationTriangleIcon : InformationCircleIcon;
return (
<li className={clsx('flex items-start gap-2 py-2', tone === 'warn' && 'text-amber-900')}>
<Icon className={clsx('w-5 h-5 flex-shrink-0', tone === 'ok' ? 'text-green-600' : tone === 'warn' ? 'text-amber-600' : 'text-gray-400')} aria-hidden />
<span className="flex-1 min-w-0">{children}</span>
{value && <span className="font-semibold whitespace-nowrap">{value}</span>}
</li>
);
};
return (
<Modal open={open} onClose={onClose} title={t('admin.finance.finalizeDialog.title')} subtitle={t('admin.finance.finalizeDialog.subtitle')}>
<ul className="divide-y divide-gray-100 text-sm">
{!lifecycle.ended && <Row tone="warn">{t('admin.finance.finalizeDialog.notEnded')}</Row>}
{lifecycle.expenseCount === 0
? <Row tone="warn">{t('admin.finance.finalizeDialog.noExpenses')}</Row>
: <Row tone="ok" value={<Money value={s.expenses.total} />}>{tCount(t, 'admin.finance.finalizeDialog.expenses', lifecycle.expenseCount)}</Row>}
{lifecycle.plannedCount > 0
? <Row tone="warn" value={<Money value={s.expenses.planned} />}>{tCount(t, 'admin.finance.finalizeDialog.planned', lifecycle.plannedCount)}</Row>
: lifecycle.expenseCount > 0 && <Row tone="ok">{t('admin.finance.finalizeDialog.allPaid')}</Row>}
{s.revenue.otherIncome > 0 && (
<Row tone="ok" value={<Money value={s.revenue.otherIncome} signed />}>{tCount(t, 'admin.finance.finalizeDialog.income', data.otherIncome.length)}</Row>
)}
{lifecycle.partnerCount > 0
? <Row tone="info" value={<Money value={partnersTotal} />}>{tCount(t, 'admin.finance.finalizeDialog.partners', lifecycle.partnerCount)}</Row>
: <Row tone="info">{t('admin.finance.finalizeDialog.noPartners')}</Row>}
</ul>
<div className="rounded-btn bg-gray-50 border border-gray-100 p-3 space-y-1">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-gray-600">{t(loss ? 'admin.finance.finalizeDialog.finalLoss' : 'admin.finance.finalizeDialog.finalProfit')}</span>
<Money value={s.profit} className={clsx('text-lg font-bold', loss && 'text-red-600')} />
</div>
{lifecycle.partnerCount > 0 && s.split.organization !== null && (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-gray-600">{t('admin.finance.split.organization')}</span>
<Money value={s.split.organization} className="font-semibold" />
</div>
)}
</div>
<p className="text-xs text-gray-500">{t('admin.finance.finalizeDialog.hint')}</p>
<div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex flex-col-reverse sm:flex-row gap-3">
<Button variant="outline" onClick={onClose} className="min-h-[44px] sm:flex-none">{t('common.cancel')}</Button>
<Button onClick={onConfirm} isLoading={busy} className="flex-1 min-h-[44px] whitespace-nowrap">
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.finalizeDialog.confirm')}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,351 @@
'use client';
// Finance charts (Recharts). Loaded with next/dynamic from SummaryView so the
// chart library only ships when the Finance tab opens.
//
// Colors: one blue for single-series charts (category/method names sit on the
// axis, so identity never relies on color); on the waterfall blue = money in,
// red = money out and gray = totals, with signed value labels and a legend
// that lists only the kinds on screen; blue = paid / orange = planned with a
// legend. Validated with the dataviz palette checks.
import { useEffect, useState } from 'react';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine,
LineChart, Line, Legend, LabelList,
} from 'recharts';
import type { FinanceSummary, ExpenseCategory } from '@/lib/api';
import { formatNumber, formatPyg, formatPygShort } from '@/lib/money';
import { buildWaterfall, detailChartsAvailable, type WaterfallKind } from './derive';
const C = {
blue: '#2a78d6',
orange: '#eb6834',
red: '#e34948',
total: '#52514e',
grid: '#eeeeec',
axis: '#8a8984',
text: '#3f3e3b',
};
const KIND_COLOR: Record<WaterfallKind, string> = { in: C.blue, out: C.red, total: C.total };
const KIND_SYMBOL: Record<WaterfallKind, string> = { in: '+', out: '−', total: '=' };
type T = (key: string, params?: Record<string, string | number>) => string;
const axisProps = { stroke: C.axis, fontSize: 12, tickLine: false, axisLine: false } as const;
/** Bar labels: exact under 10.000 PYG, abbreviated above, always with the currency. */
const labelPyg = (v: number, locale: string) => (Math.abs(v) < 10000 ? formatPyg(v, locale) : `${formatPygShort(v, locale)} PYG`);
export function ChartCard({ title, children, subtitle, legend, className }: {
title: string; subtitle?: string; legend?: React.ReactNode; children: React.ReactNode; className?: string;
}) {
return (
<div className={`bg-white rounded-card shadow-card p-4 min-w-0 ${className || ''}`}>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-sm font-semibold text-primary-dark">{title}</h3>
{legend}
</div>
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
<div className="mt-3">{children}</div>
</div>
);
}
function LegendChip({ color, label, symbol }: { color: string; label: string; symbol: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<span className="w-3 h-3 rounded-sm inline-flex items-center justify-center text-[9px] text-white font-bold" style={{ background: color }} aria-hidden>{symbol}</span>
{label}
</span>
);
}
function PygTooltip({ active, payload, label, rows, locale }: any) {
if (!active || !payload?.length) return null;
const items: { name: string; value: number; color?: string }[] = rows ? rows(payload[0].payload) : payload.map((p: any) => ({ name: p.name, value: p.value, color: p.color }));
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
{label !== undefined && <p className="font-semibold text-primary-dark mb-1">{label}</p>}
{items.map((it) => (
<p key={it.name} className="flex items-center gap-2 text-gray-600">
{it.color && <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: it.color }} />}
<span>{it.name}</span>
<span className="ml-auto pl-3 font-medium text-primary-dark tabular-nums">{formatPyg(it.value, locale)}</span>
</p>
))}
</div>
);
}
// ==================== Waterfall ====================
/** Phones get a horizontal waterfall: six step names never fit side by side. */
function useNarrow(maxWidth = 640) {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
const update = () => setNarrow(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return narrow;
}
/** The waterfall bars only; the card around it (with the breakdown) lives in SummaryView. */
export function WaterfallChart({ summary, locale, t, estimated }: { summary: FinanceSummary; locale: string; t: T; estimated: boolean }) {
const narrow = useNarrow();
const steps = buildWaterfall(summary);
let running = 0;
const rows = steps.map((w) => {
const isTotal = w.kind === 'total';
const start = isTotal ? 0 : running;
const end = isTotal ? w.amount : running + w.amount;
running = end;
const name = w.key === 'profit'
? t(w.amount < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')
: t(`admin.finance.waterfall.${w.key}`);
return {
key: w.key,
kind: w.kind,
name,
amount: w.amount,
range: [Math.min(start, end), Math.max(start, end)] as [number, number],
color: KIND_COLOR[w.kind],
signed: isTotal ? labelPyg(w.amount, locale) : `${w.amount >= 0 ? '+' : '−'}${labelPyg(Math.abs(w.amount), locale)}`,
};
});
const partnerRows = summary.split.partners.map((p) => ({ name: p.name, value: -p.share, color: C.red }));
const tooltipRows = (r: any) => (r.key === 'partners' ? partnerRows : [{ name: r.name, value: r.amount, color: r.color }]);
const kinds = (['in', 'out', 'total'] as WaterfallKind[]).filter((k) => rows.some((r) => r.kind === k));
// Value label above each bar's top edge. Bars under zero hang from the zero
// line, so their label sits just above it and never runs into the axis labels.
const renderLabel = (props: any): React.ReactElement<SVGElement> => {
const { x, y, width, height, index } = props;
const r = rows[index];
if (!r) return <g />;
const top = Math.min(y, y + height);
return (
<text x={x + width / 2} y={top - 6} textAnchor="middle" fontSize={11} fill={C.text} fontWeight={600}>
{r.signed}
</text>
);
};
const short = (v: number) => formatPygShort(v, locale);
return (
<div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-sm font-semibold text-primary-dark">{t('admin.finance.waterfall.title')}</h3>
<span className="flex flex-wrap gap-3">
{kinds.map((k) => (
<LegendChip key={k} color={KIND_COLOR[k]} symbol={KIND_SYMBOL[k]} label={t(`admin.finance.waterfall.legend.${k}`)} />
))}
</span>
</div>
{estimated && <p className="text-xs text-gray-500 mt-0.5">{t('admin.finance.waterfall.estimatedHint')}</p>}
<div className="mt-3">
{narrow ? (
// Step name and signed amount as a two-line axis label; bars run left/right from zero.
<ResponsiveContainer width="100%" height={rows.length * 48 + 24}>
<BarChart data={rows} layout="vertical" margin={{ top: 0, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={short} fontSize={11} />
<YAxis
type="category"
dataKey="name"
{...axisProps}
width={136}
interval={0}
tick={(props: any) => {
const r = rows[props.index];
return (
<g transform={`translate(${props.x},${props.y})`}>
<text x={-6} y={-3} textAnchor="end" fontSize={11} fill={C.text}>{r?.name}</text>
<text x={-6} y={11} textAnchor="end" fontSize={11} fontWeight={600} fill={C.text}>{r?.signed}</text>
</g>
);
}}
/>
<ReferenceLine x={0} stroke={C.axis} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
<Bar dataKey="range" radius={4} barSize={20} minPointSize={3} isAnimationActive={false}>
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={rows} margin={{ top: 24, right: 8, left: 0, bottom: 8 }}>
<CartesianGrid vertical={false} stroke={C.grid} />
<XAxis dataKey="name" {...axisProps} interval={0} tick={{ fontSize: 11, fill: C.text }} height={40} />
<YAxis {...axisProps} tickFormatter={short} width={48} />
<ReferenceLine y={0} stroke={C.axis} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
<Bar dataKey="range" radius={4} maxBarSize={56} minPointSize={3} isAnimationActive={false} label={renderLabel}>
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ==================== Horizontal single-series bars ====================
function HorizontalBars({ data, locale }: { data: { name: string; value: number; detail?: { name: string; value: number }[] }[]; locale: string }) {
return (
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 40 + 28)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 84, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
content={<PygTooltip locale={locale} rows={(r: any) => r.detail || [{ name: r.name, value: r.value, color: C.blue }]} />}
/>
<Bar dataKey="value" fill={C.blue} radius={[0, 4, 4, 0]} barSize={18} minPointSize={2} isAnimationActive={false}>
<LabelList dataKey="value" position="right" formatter={(v: number) => labelPyg(v, locale)} style={{ fontSize: 11, fill: C.text, fontWeight: 600 }} />
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function categoryName(id: string | null, categories: ExpenseCategory[], locale: string, t: T) {
const c = categories.find((x) => x.id === id);
if (!c) return t('admin.finance.charts.uncategorized');
return locale === 'es' ? c.nameEs : c.nameEn;
}
export function ExpensesByCategoryChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
const data = summary.expenses.byCategory
.filter((c) => c.total > 0)
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), value: c.total }));
return (
<ChartCard title={t('admin.finance.charts.byCategory')}>
<HorizontalBars data={data} locale={locale} />
</ChartCard>
);
}
export function RevenueByMethodChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
const data = summary.revenue.byMethod
.filter((m) => m.gross > 0)
.map((m) => ({
name: t(`admin.finance.methods.${m.method}`),
value: m.gross,
detail: [
{ name: t('admin.finance.waterfall.gross'), value: m.gross },
{ name: t('admin.finance.waterfall.refunds'), value: -m.refunds },
{ name: t('admin.finance.waterfall.fees'), value: -m.fees },
{ name: t('admin.finance.waterfall.net'), value: m.net },
],
}));
return (
<ChartCard title={t('admin.finance.charts.byMethod')}>
<HorizontalBars data={data} locale={locale} />
</ChartCard>
);
}
// ==================== Cumulative sales ====================
export function CumulativeSalesChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
const fmtDay = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short' });
const data = summary.salesTimeline.map((p) => ({ ...p, label: fmtDay(p.date) }));
const breakEven = summary.expenses.total > 0 ? summary.breakEven.tickets : null;
const maxSold = Math.max(1, ...data.map((d) => d.tickets));
// Only draw the break-even line when it fits a readable scale; otherwise a
// far-away target would flatten the sales line to the floor.
const showLine = breakEven !== null && breakEven > 0 && breakEven <= maxSold * 3;
const top = Math.ceil(Math.max(maxSold, showLine ? breakEven! : 0) * 1.15);
return (
<ChartCard
title={t('admin.finance.charts.cumulative')}
subtitle={breakEven !== null && breakEven > 0 && !showLine ? t('admin.finance.charts.breakEvenOffChart', { count: formatNumber(breakEven!, locale) }) : undefined}
>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data} margin={{ top: 16, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} stroke={C.grid} />
<XAxis dataKey="label" {...axisProps} minTickGap={16} />
<YAxis {...axisProps} allowDecimals={false} domain={[0, top]} width={40} tickFormatter={(v: number) => formatNumber(v, locale)}
label={{ value: t('admin.finance.charts.tickets'), angle: -90, position: 'insideLeft', fontSize: 11, fill: C.axis, dy: 30 }} />
<Tooltip
content={({ active, payload }: any) => {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
<p className="font-semibold text-primary-dark mb-1">{p.label}</p>
<p className="text-gray-600">{t('admin.finance.kpi.ticketsSold', { count: formatNumber(p.tickets, locale) })}</p>
<p className="text-gray-600 tabular-nums">{formatPyg(p.revenue, locale)}</p>
</div>
);
}}
/>
{showLine && (
<ReferenceLine
y={breakEven!}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: `${t('admin.finance.charts.breakEvenLine')}: ${formatNumber(breakEven!, locale)}`, position: 'insideTopRight', fontSize: 11, fill: C.text }}
/>
)}
<Line type="monotone" dataKey="tickets" stroke={C.blue} strokeWidth={2} dot={{ r: 4, fill: C.blue, stroke: '#fff', strokeWidth: 2 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
);
}
// ==================== Planned vs paid ====================
export function PlannedVsPaidChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
const data = summary.expenses.byCategory
.filter((c) => c.total > 0)
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), paid: c.paid, planned: c.planned }));
const paidLabel = t('admin.finance.charts.paid');
const plannedLabel = t('admin.finance.charts.planned');
const hasPaid = summary.expenses.paid > 0;
const hasPlanned = summary.expenses.planned > 0;
return (
<ChartCard
title={t('admin.finance.charts.plannedVsActual')}
subtitle={`${paidLabel} ${formatPyg(summary.expenses.paid, locale)} · ${plannedLabel} ${formatPyg(summary.expenses.planned, locale)}`}
>
<ResponsiveContainer width="100%" height={Math.max(140, data.length * 40 + 56)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} />} />
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12, color: C.text }} />
{hasPaid && <Bar dataKey="paid" name={paidLabel} stackId="s" fill={C.blue} stroke="#fff" strokeWidth={2} barSize={18} isAnimationActive={false} />}
{hasPlanned && <Bar dataKey="planned" name={plannedLabel} stackId="s" fill={C.orange} stroke="#fff" strokeWidth={2} radius={[0, 4, 4, 0]} barSize={18} isAnimationActive={false} />}
</BarChart>
</ResponsiveContainer>
</ChartCard>
);
}
export default function FinanceCharts({ summary, categories, locale, t, part, estimated = false }: {
summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T; part: 'waterfall' | 'details'; estimated?: boolean;
}) {
if (part === 'waterfall') return <WaterfallChart summary={summary} locale={locale} t={t} estimated={estimated} />;
const show = detailChartsAvailable(summary);
if (!show.costs && !show.methods && !show.timeline) return null;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{show.costs && <ExpensesByCategoryChart summary={summary} categories={categories} locale={locale} t={t} />}
{show.methods && <RevenueByMethodChart summary={summary} locale={locale} t={t} />}
{show.timeline && <CumulativeSalesChart summary={summary} locale={locale} t={t} />}
{show.costs && <PlannedVsPaidChart summary={summary} categories={categories} locale={locale} t={t} />}
</div>
);
}
@@ -0,0 +1,91 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
import { financeApi, type EventFinance } from '@/lib/api';
import { Skeleton } from '@/components/ui/Skeleton';
import { SubNav } from './ui';
import { SummaryView } from './SummaryView';
import { ExpensesView } from './ExpensesView';
import { SplitView } from './SplitView';
import { LifecycleStepper } from './LifecycleStepper';
import { deriveLifecycle } from './derive';
export type FinanceSection = 'summary' | 'expenses' | 'split';
const SECTIONS: FinanceSection[] = ['summary', 'expenses', 'split'];
/** Keep the open section in the URL (?fin=expenses) so reloads and shared links land on it. */
function readSection(): FinanceSection {
if (typeof window === 'undefined') return 'summary';
const v = new URLSearchParams(window.location.search).get('fin');
return SECTIONS.includes(v as FinanceSection) ? (v as FinanceSection) : 'summary';
}
function writeSection(section: FinanceSection) {
const url = new URL(window.location.href);
if (section === 'summary') url.searchParams.delete('fin'); else url.searchParams.set('fin', section);
window.history.replaceState(window.history.state, '', url);
}
/**
* Finance tab: P&L summary with charts, expenses and other income, and the
* partner split with payouts. The server already scopes what comes back (e.g.
* collaborators only get their own share), so this renders what it receives.
*/
export function FinanceTab({ eventId }: { eventId: string }) {
const { t } = useLanguage();
const [data, setData] = useState<EventFinance | null>(null);
const [loading, setLoading] = useState(true);
const [section, setSectionState] = useState<FinanceSection>('summary');
useEffect(() => { setSectionState(readSection()); }, []);
const setSection = useCallback((s: FinanceSection) => { setSectionState(s); writeSection(s); }, []);
const load = useCallback(async () => {
try {
setData(await financeApi.get(eventId));
} catch (error: any) {
toast.error(error.message || t('admin.finance.loadError'));
} finally {
setLoading(false);
}
}, [eventId, t]);
useEffect(() => { load(); }, [load]);
// Recomputed with every reload, so "ended X days ago" stays current.
const lifecycle = useMemo(() => (data ? deriveLifecycle(data) : null), [data]);
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-24" />
<Skeleton className="h-11 w-full md:w-96" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
<Skeleton className="h-64" />
</div>
);
}
if (!data || !lifecycle) return <p className="text-sm text-gray-500 py-8 text-center">{t('admin.finance.loadError')}</p>;
return (
<div className="space-y-4">
<LifecycleStepper data={data} lifecycle={lifecycle} goTo={setSection} />
<SubNav<FinanceSection>
items={[
{ key: 'summary', label: t('admin.finance.subnav.summary') },
{ key: 'expenses', label: t('admin.finance.subnav.expenses'), count: data.expenses.length + data.otherIncome.length },
{ key: 'split', label: t('admin.finance.subnav.split'), count: data.partners.length },
]}
active={section}
onChange={setSection}
/>
{section === 'summary' && <SummaryView data={data} goTo={setSection} />}
{section === 'expenses' && <ExpensesView eventId={eventId} data={data} onChange={load} />}
{section === 'split' && <SplitView eventId={eventId} data={data} lifecycle={lifecycle} onChange={load} />}
</div>
);
}
@@ -0,0 +1,107 @@
'use client';
import clsx from 'clsx';
import { CheckIcon, ExclamationTriangleIcon, InformationCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import type { EventFinance } from '@/lib/api';
import { LIFECYCLE_STEPS, tCount, type Lifecycle } from './derive';
import type { FinanceSection } from './FinanceTab';
/**
* Where the event's books stand: Selling -> Adding costs -> Ready to close ->
* Finalized, with what is still missing underneath. Closing the books itself
* happens in Split & Payouts; this only points the way.
*/
export function LifecycleStepper({ data, lifecycle, goTo }: { data: EventFinance; lifecycle: Lifecycle; goTo: (s: FinanceSection) => void }) {
const { t, locale } = useLanguage();
const current = LIFECYCLE_STEPS.indexOf(lifecycle.step);
const open = data.status === 'open';
const dateFmt = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '';
// Once every cost is in, the nudge says so instead of asking for more.
const nudgeKey = lifecycle.step === 'ready' ? 'admin.finance.lifecycle.nudgeReady' : 'admin.finance.lifecycle.nudge';
const nudge = open && lifecycle.ended && lifecycle.daysSinceEnd !== null
? lifecycle.daysSinceEnd === 0
? t(`${nudgeKey}Today`)
: tCount(t, nudgeKey, lifecycle.daysSinceEnd)
: null;
const ready = lifecycle.step === 'ready';
return (
<section className="bg-white rounded-card shadow-card p-3 md:p-4" aria-label={t('admin.finance.lifecycle.label')}>
<ol className="grid grid-cols-4 gap-1">
{LIFECYCLE_STEPS.map((step, i) => {
const done = i < current || (step === 'finalized' && !open);
const active = i === current && open;
return (
<li key={step} className="flex flex-col items-center text-center min-w-0 relative" aria-current={i === current ? 'step' : undefined}>
{i > 0 && (
<span
className={clsx('absolute top-3 h-0.5', i <= current ? 'bg-primary-yellow' : 'bg-gray-200')}
// From the previous circle's edge to this one's (circles are 1.5rem, columns 0.25rem apart).
style={{ right: 'calc(50% + 0.875rem)', width: 'calc(100% - 1.5rem)' }}
aria-hidden
/>
)}
<span
className={clsx(
'relative z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold border-2',
done && 'bg-primary-yellow border-primary-yellow text-primary-dark',
active && 'bg-white border-primary-yellow text-primary-dark',
!done && !active && 'bg-white border-gray-200 text-gray-400',
)}
aria-hidden
>
{done ? <CheckIcon className="w-3.5 h-3.5" strokeWidth={3} /> : i + 1}
</span>
<span className={clsx('mt-1 text-[11px] sm:text-xs leading-tight', i === current ? 'font-semibold text-primary-dark' : 'text-gray-500')}>
{t(`admin.finance.lifecycle.steps.${step}`)}
</span>
</li>
);
})}
</ol>
{(nudge || lifecycle.todo.length > 0 || !open) && (
<div className="mt-3 pt-3 border-t border-gray-100 space-y-2 text-sm">
{nudge && (
<div className={clsx(
'flex flex-col sm:flex-row sm:items-center gap-2 rounded-btn border px-3 py-2',
ready ? 'bg-green-50 border-green-200 text-green-900' : 'bg-amber-50 border-amber-200 text-amber-900',
)}>
<p className="flex items-start gap-2 flex-1">
{ready ? <CheckIcon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden /> : <ClockIcon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />}
{nudge}
</p>
{data.viewer.canEditExpenses && !ready && (
<button onClick={() => goTo('expenses')} className="self-start sm:self-auto text-sm font-medium underline whitespace-nowrap cursor-pointer min-h-[32px]">
{t('admin.finance.lifecycle.reviewCosts')}
</button>
)}
</div>
)}
{!open && (
<p className="text-gray-600">
{t(data.status === 'paid_out' ? 'admin.finance.lifecycle.paidOut' : 'admin.finance.lifecycle.frozen', { date: dateFmt(data.finalizedAt) })}
</p>
)}
{lifecycle.todo.length > 0 && (
<ul className="flex flex-wrap gap-x-4 gap-y-1">
{lifecycle.todo.map((item) => (
<li key={item.key} className={clsx('inline-flex items-center gap-1.5 text-xs sm:text-sm', item.tone === 'warn' ? 'text-amber-800' : 'text-gray-500')}>
{item.tone === 'warn'
? <ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />
: <InformationCircleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />}
{item.count !== undefined
? tCount(t, `admin.finance.lifecycle.todo.${item.key}`, item.count)
: t(`admin.finance.lifecycle.todo.${item.key}`)}
</li>
))}
</ul>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,530 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
PlusIcon, PencilIcon, TrashIcon, DocumentArrowDownIcon, CheckCircleIcon, LockClosedIcon, LockOpenIcon, ClockIcon,
XMarkIcon, ChevronDownIcon, UserGroupIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import {
financeApi, SHARE_TYPES, LOSS_RULES,
type EventFinance, type EventPartner, type FinanceAuditEntry, type LossRule, type PartnerInput, type ShareType,
} from '@/lib/api';
import Button from '@/components/ui/Button';
import { downloadBlob } from '../_utils/format';
import { Modal, Field, PygInput, DateInput, Money, ChoiceCards, EmptyState, Pill, inputClass, iconButtonClass, todayIso } from './ui';
import { UserSearch } from './UserSearch';
import { bpToPercent, percentToBp } from './format';
import { FinalizeDialog } from './FinalizeDialog';
import type { Lifecycle } from './derive';
type T = (key: string, params?: Record<string, string | number>) => string;
type Fmt = ReturnType<typeof useMoney>;
function dealText(p: EventPartner, t: T, m: Fmt): string {
switch (p.shareType) {
case 'percent_profit': return t('admin.finance.deal.percentProfit', { percent: m.pct(p.percentBp) });
case 'percent_revenue': return t('admin.finance.deal.percentRevenue', { percent: m.pct(p.percentBp) });
case 'fixed': return t('admin.finance.deal.fixed', { amount: m.pyg(p.fixedAmount) });
case 'fixed_plus_percent_above_threshold':
return t('admin.finance.deal.fixedPlus', { amount: m.pyg(p.fixedAmount), percent: m.pct(p.percentBp), threshold: m.pyg(p.thresholdAmount) });
}
}
function ModalActions({ children }: { children: React.ReactNode }) {
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
}
// ==================== Partner form ====================
interface PartnerForm {
userId: string | null;
userLabel: string;
externalName: string;
roleLabel: string;
shareType: ShareType;
percent: string;
fixedAmount: number;
thresholdAmount: number;
lossRule: LossRule;
lossCapAmount: number;
}
const emptyPartner = (): PartnerForm => ({
userId: null, userLabel: '', externalName: '', roleLabel: '', shareType: 'percent_profit', percent: '',
fixedAmount: 0, thresholdAmount: 0, lossRule: 'none', lossCapAmount: 0,
});
function PartnerModal({ open, onClose, eventId, partner, onSaved }: {
open: boolean; onClose: () => void; eventId: string; partner: EventPartner | null; onSaved: () => void;
}) {
const { t } = useLanguage();
const [form, setForm] = useState<PartnerForm>(emptyPartner());
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const set = <K extends keyof PartnerForm>(k: K, v: PartnerForm[K]) => setForm((f) => ({ ...f, [k]: v }));
const search = useCallback((q: string) => financeApi.searchPartnerCandidates(eventId, q), [eventId]);
useEffect(() => {
if (!open) return;
setTouched(false);
setForm(partner ? {
userId: partner.userId, userLabel: partner.userId ? partner.name : '', externalName: partner.externalName || '',
roleLabel: partner.roleLabel || '', shareType: partner.shareType, percent: partner.percentBp ? bpToPercent(partner.percentBp) : '',
fixedAmount: partner.fixedAmount, thresholdAmount: partner.thresholdAmount, lossRule: partner.lossRule, lossCapAmount: partner.lossCapAmount,
} : emptyPartner());
}, [open, partner]);
const st = form.shareType;
const hasPercent = st !== 'fixed';
const hasFixed = st === 'fixed' || st === 'fixed_plus_percent_above_threshold';
const whoError = touched && !form.userId && !form.externalName.trim() ? t('admin.finance.split.needsNameOrUser') : undefined;
const submit = async () => {
setTouched(true);
if (!form.userId && !form.externalName.trim()) return;
setSaving(true);
const payload: PartnerInput = {
userId: form.userId,
externalName: form.externalName.trim() || null,
roleLabel: form.roleLabel.trim() || null,
shareType: st,
percentBp: hasPercent ? percentToBp(form.percent) : 0,
fixedAmount: hasFixed ? form.fixedAmount : 0,
thresholdAmount: st === 'fixed_plus_percent_above_threshold' ? form.thresholdAmount : 0,
lossRule: st === 'percent_profit' ? form.lossRule : 'none',
lossCapAmount: st === 'percent_profit' && form.lossRule === 'capped' ? form.lossCapAmount : 0,
};
try {
if (partner) await financeApi.updatePartner(eventId, partner.id, payload);
else await financeApi.createPartner(eventId, payload);
toast.success(t('admin.finance.split.partnerSaved'));
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title={t(partner ? 'admin.finance.split.editPartner' : 'admin.finance.split.addPartner')} wide>
<Field label={t('admin.finance.split.who')} required error={whoError} hint={t('admin.finance.split.whoHint')}>
<div className="space-y-2">
{form.userId ? (
<div className="flex items-center gap-2 pl-3 rounded-btn bg-gray-50 text-sm min-h-[44px]">
<span className="flex-1 truncate">{form.userLabel}</span>
<button type="button" onClick={() => setForm((f) => ({ ...f, userId: null, userLabel: '' }))} className={iconButtonClass} aria-label={t('admin.team.remove')}>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
) : (
<UserSearch
search={search}
onPick={(u) => setForm((f) => ({ ...f, userId: u.id, userLabel: `${u.name} (${u.email})` }))}
placeholder={t('admin.finance.split.searchUser')}
noResults={t('admin.team.noResults')}
/>
)}
{!form.userId && (
<input className={inputClass} value={form.externalName} maxLength={200} placeholder={t('admin.finance.split.externalName')} onChange={(e) => set('externalName', e.target.value)} />
)}
</div>
</Field>
<Field label={t('admin.finance.split.roleLabel')}>
<input className={inputClass} value={form.roleLabel} maxLength={100} placeholder={t('admin.finance.split.rolePlaceholder')} onChange={(e) => set('roleLabel', e.target.value)} />
</Field>
<Field label={t('admin.finance.split.shareType')}>
<ChoiceCards<ShareType>
name="shareType"
value={st}
onChange={(v) => set('shareType', v)}
options={SHARE_TYPES.map((s) => ({ key: s, title: t(`admin.finance.shareTypes.${s}`), description: t(`admin.finance.shareHints.${s}`) }))}
/>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{hasFixed && (
<Field label={t('admin.finance.split.fixedAmount')}>
<PygInput value={form.fixedAmount} onChange={(n) => set('fixedAmount', n)} />
</Field>
)}
{hasPercent && (
<Field label={t('admin.finance.split.percent')}>
<div className="relative">
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} placeholder="30" onChange={(e) => set('percent', e.target.value)} />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
</div>
</Field>
)}
{st === 'fixed_plus_percent_above_threshold' && (
<Field label={t('admin.finance.split.threshold')}>
<PygInput value={form.thresholdAmount} onChange={(n) => set('thresholdAmount', n)} />
</Field>
)}
</div>
{st === 'percent_profit' && (
<Field label={t('admin.finance.split.lossRule')}>
<ChoiceCards<LossRule>
name="lossRule"
columns={3}
value={form.lossRule}
onChange={(v) => set('lossRule', v)}
options={LOSS_RULES.map((r) => ({ key: r, title: t(`admin.finance.lossRuleTitles.${r}`), description: t(`admin.finance.lossRules.${r}`) }))}
/>
{form.lossRule === 'capped' && (
<div className="mt-2">
<Field label={t('admin.finance.split.lossCap')}>
<PygInput value={form.lossCapAmount} onChange={(n) => set('lossCapAmount', n)} />
</Field>
</div>
)}
</Field>
)}
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Payout ====================
function PayoutModal({ partner, amount, onClose, eventId, onSaved }: { partner: EventPartner | null; amount: number; onClose: () => void; eventId: string; onSaved: () => void }) {
const { t } = useLanguage();
const [method, setMethod] = useState('');
const [date, setDate] = useState('');
const [note, setNote] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (partner) { setMethod(''); setDate(todayIso()); setNote(''); }
}, [partner]);
const submit = async () => {
if (!partner) return;
setSaving(true);
try {
await financeApi.markPartnerPaid(eventId, partner.id, { paid: true, payoutMethod: method || null, payoutDate: date || null, payoutNote: note || null });
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
return (
<Modal open={!!partner} onClose={onClose} title={t('admin.finance.split.markPaid')} subtitle={partner?.name}>
<div className="rounded-btn bg-gray-50 p-3 flex justify-between items-baseline">
<span className="text-sm text-gray-600">{t(amount < 0 ? 'admin.finance.split.owes' : 'admin.finance.split.payout')}</span>
<Money value={Math.abs(amount)} className="text-lg font-bold" />
</div>
<Field label={t('admin.finance.split.payoutMethod')}>
<input className={inputClass} value={method} maxLength={50} onChange={(e) => setMethod(e.target.value)} placeholder={t('admin.finance.split.payoutMethodPlaceholder')} />
</Field>
<Field label={t('admin.finance.expenses.date')}>
<DateInput value={date} onChange={setDate} />
</Field>
<Field label={t('admin.finance.split.payoutNote')}>
<textarea className={inputClass} rows={2} value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} />
</Field>
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('admin.finance.split.markPaid')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Audit log ====================
function AuditLog({ eventId }: { eventId: string }) {
const { t, locale } = useLanguage();
const [entries, setEntries] = useState<FinanceAuditEntry[] | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (open && entries === null) financeApi.auditLog(eventId).then((r) => setEntries(r.entries)).catch(() => setEntries([]));
}, [open, entries, eventId]);
const describe = (e: FinanceAuditEntry) => {
const name = e.after?.description || e.before?.description || e.after?.name || e.before?.name || '';
const action = t(`admin.finance.audit.${e.action}`);
const entity = t(`admin.finance.audit.entity.${e.entityType}`);
return `${entity} · ${action.startsWith('admin.') ? e.action.replace(/_/g, ' ') : action}${name ? ` · ${name}` : ''}`;
};
return (
<div className="bg-white rounded-card shadow-card">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-4 min-h-[48px] text-sm font-semibold cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded-card"
>
<ClockIcon className="w-4 h-4 text-gray-500" aria-hidden />
{t('admin.finance.split.auditLog')}
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto text-gray-400 transition-transform', open && 'rotate-180')} aria-hidden />
</button>
{open && (
<div className="px-4 pb-4">
{entries === null ? <p className="text-sm text-gray-400">{t('common.loading')}</p> : entries.length === 0 ? (
<p className="text-sm text-gray-400">{t('admin.finance.split.auditEmpty')}</p>
) : (
<ul className="divide-y divide-gray-100 text-sm">
{entries.map((e) => (
<li key={e.id} className="py-2 flex flex-col sm:flex-row sm:gap-3">
<span className="text-xs text-gray-400 tabular-nums sm:w-36 flex-shrink-0">
{e.createdAt ? new Date(e.createdAt).toLocaleString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'short', timeStyle: 'short' }) : ''}
</span>
<span><span className="font-medium">{e.actorName || '-'}</span> <span className="text-gray-600">{describe(e)}</span></span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
// ==================== View ====================
export function SplitView({ eventId, data, lifecycle, onChange }: { eventId: string; data: EventFinance; lifecycle: Lifecycle; onChange: () => void }) {
const { t, locale } = useLanguage();
const m = useMoney();
const { privacyMode } = usePrivacy();
const [editing, setEditing] = useState<EventPartner | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [paying, setPaying] = useState<EventPartner | null>(null);
const [finalizing, setFinalizing] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const { viewer, summary } = data;
const open = data.status === 'open';
const manage = viewer.canManageSplit;
const lines = new Map(summary.split.partners.map((p) => [p.partnerId, p]));
const hasPartners = data.partners.length > 0;
const paidCount = data.partners.filter((p) => p.payoutStatus === 'paid').length;
const run = async (key: string, fn: () => Promise<unknown>, success?: string) => {
setBusy(key);
try {
await fn();
if (success) toast.success(success);
onChange();
return true;
} catch (error: any) {
toast.error(error.message);
return false;
} finally {
setBusy(null);
}
};
const finalize = async () => {
if (await run('finalize', () => financeApi.finalize(eventId), t('admin.finance.split.finalized'))) setFinalizing(false);
};
const unfinalize = () => {
if (!confirm(t('admin.finance.split.unfinalizeConfirm'))) return;
run('unfinalize', () => financeApi.unfinalize(eventId), t('admin.finance.split.unfinalized'));
};
const remove = (p: EventPartner) => {
if (!confirm(t('admin.finance.split.confirmRemoveNamed', { name: p.name }))) return;
run(`rm-${p.id}`, () => financeApi.deletePartner(eventId, p.id), t('admin.finance.split.partnerRemoved'));
};
const statement = async (p: EventPartner) => {
setBusy(`pdf-${p.id}`);
try {
const { blob, filename } = await financeApi.downloadStatement(eventId, p.id, locale);
downloadBlob(blob, filename);
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
const openNew = () => { setEditing(null); setFormOpen(true); };
const loss = summary.profit < 0;
const distributable = summary.split.distributable;
// Percent-of-profit deals apply to what is left after fixed and revenue deals; say so when that differs from the profit.
const profitBase = distributable !== null && distributable !== summary.profit ? distributable : null;
const resultTone = (v: number | null) => (!open && !privacyMode && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
const BreakdownRow = ({ label, detail, value, sign, strong, className }: {
label: React.ReactNode; detail?: string; value: number | null; sign?: '+' | '−'; strong?: boolean; className?: string;
}) => (
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong ? 'font-semibold' : 'text-gray-600')}>
<span className="min-w-0">
{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}
{label}
{detail && <span className="block text-xs text-gray-400 pl-4">{detail}</span>}
</span>
<Money value={value} className={clsx('whitespace-nowrap', className)} />
</div>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Result, as a short calculation ending in what the organization keeps */}
<div className="bg-white rounded-card shadow-card p-4 lg:col-span-2 text-sm">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-primary-dark">{t('admin.finance.split.calculation')}</h3>
{open && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
</div>
<div className="mt-2">
<BreakdownRow label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={summary.profit} strong className={resultTone(summary.profit)} />
{summary.split.partners.map((line) => {
const p = data.partners.find((x) => x.id === line.partnerId);
const rule = p ? dealText(p, t, m) : '';
const detail = p?.shareType === 'percent_profit' && profitBase !== null && !privacyMode
? t('admin.finance.split.ofDistributable', { rule, base: m.pyg(profitBase) })
: rule;
return (
<BreakdownRow
key={line.partnerId}
sign={line.share < 0 ? '+' : '−'}
label={line.name}
detail={detail}
value={Math.abs(line.share)}
/>
);
})}
{viewer.fullSplit && (
<>
<div className="border-t border-gray-200 mt-1" />
<BreakdownRow label={t('admin.finance.split.organization')} value={summary.split.organization} strong className={clsx('text-base', resultTone(summary.split.organization))} />
{!hasPartners && <p className="text-xs text-gray-400">{t('admin.finance.split.empty')}</p>}
</>
)}
</div>
{!viewer.fullSplit && <p className="text-xs text-gray-500 mt-2">{t('admin.finance.split.ownShareOnly')}</p>}
</div>
{/* Close the books: the one place to finalize */}
{(manage || !open) && (
<div className={clsx('rounded-card shadow-card p-4 flex flex-col gap-3', open ? 'bg-white' : 'bg-blue-50')}>
<div>
<h3 className="font-semibold text-primary-dark flex items-center gap-2">
{open ? <LockOpenIcon className="w-5 h-5" aria-hidden /> : <LockClosedIcon className="w-5 h-5" aria-hidden />}
{t(open ? 'admin.finance.split.closeBooks' : 'admin.finance.split.booksClosed')}
</h3>
<p className="text-sm text-gray-600 mt-1">
{open
? t('admin.finance.split.closeBooksHint')
: hasPartners
? t('admin.finance.split.payoutsProgress', { paid: paidCount, total: data.partners.length })
: t('admin.finance.split.frozenNoPartners')}
</p>
</div>
<div className="mt-auto">
{open && manage && (
<Button onClick={() => setFinalizing(true)} className="w-full min-h-[44px]">
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.closeBooks')}
</Button>
)}
{!open && viewer.canUnfinalize && (
<Button variant="outline" onClick={unfinalize} isLoading={busy === 'unfinalize'} className="w-full min-h-[44px]">
<LockOpenIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.unfinalize')}
</Button>
)}
</div>
</div>
)}
</div>
{/* Partners */}
{!hasPartners ? (
viewer.fullSplit && (
<EmptyState icon={<UserGroupIcon className="w-5 h-5" />} title={t('admin.finance.split.noPartnersTitle')} body={t('admin.finance.split.noPartnersBody')}>
{manage && open && <Button onClick={openNew} className="min-h-[44px]"><PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}</Button>}
</EmptyState>
)
) : (
<div className="bg-white rounded-card shadow-card">
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold">{t('admin.finance.split.partners')}</h3>
{manage && open && (
<Button size="sm" onClick={openNew} className="min-h-[44px] md:min-h-0">
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}
</Button>
)}
</div>
<ul className="divide-y divide-gray-100">
{data.partners.map((p) => {
const line = lines.get(p.id);
const payout = line?.payout ?? 0;
const owes = payout < 0;
const isPaid = p.payoutStatus === 'paid';
const reimbursement = line?.reimbursement ?? 0;
return (
<li key={p.id} className="px-4 py-3 grid grid-cols-[1fr_auto] md:grid-cols-[minmax(0,1fr)_10rem_9rem_auto] gap-x-4 gap-y-2 items-center">
{/* Who and the deal */}
<div className="min-w-0">
<p className="font-medium text-primary-dark truncate">{p.name}</p>
<p className="text-xs text-gray-500">{[p.roleLabel, dealText(p, t, m)].filter(Boolean).join(' · ')}</p>
{p.shareType === 'percent_profit' && loss && (
<p className="text-xs text-gray-400">{t(`admin.finance.lossRules.${p.lossRule}`)}{p.lossRule === 'capped' ? ` (${m.pyg(p.lossCapAmount)})` : ''}</p>
)}
</div>
{/* Amount */}
<div className="text-right">
<Money value={Math.abs(payout)} className={clsx('font-semibold whitespace-nowrap', owes && !privacyMode && 'text-red-600')} />
<p className="text-xs text-gray-400">
{owes ? t('admin.finance.split.owes') : reimbursement !== 0 && !privacyMode
? t('admin.finance.split.includesReimbursement', { amount: m.pyg(reimbursement) })
: t('admin.finance.split.payout')}
</p>
</div>
{/* Status */}
<div className="col-span-2 md:col-span-1 flex items-center gap-2">
<span className={clsx('inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 py-0.5 text-xs font-medium whitespace-nowrap', isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800')}>
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
{isPaid ? t('admin.finance.split.paidOn', { date: fmtDate(p.payoutDate) }) : t('admin.finance.split.unpaid')}
</span>
{isPaid && (p.payoutMethod || p.payoutNote) && (
<span className="text-xs text-gray-500 truncate md:hidden">{[p.payoutMethod, p.payoutNote].filter(Boolean).join(' · ')}</span>
)}
</div>
{/* Actions */}
<div className="col-span-2 md:col-span-1 flex flex-wrap items-center gap-1 md:justify-end">
{manage && !open && !isPaid && (
<Button size="sm" onClick={() => setPaying(p)} className="min-h-[44px] md:min-h-[36px]">
<CheckCircleIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.markPaid')}
</Button>
)}
{manage && !open && isPaid && (
<Button size="sm" variant="ghost" isLoading={busy === `unpay-${p.id}`} className="min-h-[44px] md:min-h-[36px]"
onClick={() => run(`unpay-${p.id}`, () => financeApi.markPartnerPaid(eventId, p.id, { paid: false }))}>
{t('admin.finance.split.markUnpaid')}
</Button>
)}
<button onClick={() => statement(p)} disabled={busy === `pdf-${p.id}`} className={iconButtonClass} aria-label={`${t('admin.finance.split.statement')} ${p.name}`} title={t('admin.finance.split.statement')}>
<DocumentArrowDownIcon className="w-4 h-4" />
</button>
{manage && open && (
<>
<button onClick={() => { setEditing(p); setFormOpen(true); }} className={iconButtonClass} aria-label={`${t('common.edit')} ${p.name}`}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => remove(p)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={`${t('common.delete')} ${p.name}`}><TrashIcon className="w-4 h-4" /></button>
</>
)}
</div>
</li>
);
})}
</ul>
{manage && open && <p className="px-4 py-2.5 border-t border-gray-100 text-xs text-gray-500">{t('admin.finance.split.finalizeFirst')}</p>}
</div>
)}
{viewer.fullSplit && <AuditLog key={`${data.status}-${data.computedAt}`} eventId={eventId} />}
<PartnerModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} partner={editing} onSaved={onChange} />
<PayoutModal partner={paying} amount={paying ? lines.get(paying.id)?.payout ?? 0 : 0} onClose={() => setPaying(null)} eventId={eventId} onSaved={onChange} />
<FinalizeDialog open={finalizing} onClose={() => setFinalizing(false)} onConfirm={finalize} busy={busy === 'finalize'} data={data} lifecycle={lifecycle} />
</div>
);
}
@@ -0,0 +1,237 @@
'use client';
import dynamic from 'next/dynamic';
import clsx from 'clsx';
import {
BanknotesIcon, ShoppingBagIcon, ArrowTrendingUpIcon, ArrowTrendingDownIcon, ScaleIcon, EyeSlashIcon,
ExclamationTriangleIcon, CheckCircleIcon, ChevronDownIcon, PlusIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import type { EventFinance } from '@/lib/api';
import Button from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { Money, EmptyState, Pill } from './ui';
import { breakEvenState, detailChartsAvailable } from './derive';
import type { FinanceSection } from './FinanceTab';
// Recharts only loads when someone opens the Finance tab.
const FinanceCharts = dynamic(() => import('./FinanceCharts'), {
ssr: false,
loading: () => <Skeleton className="h-64" />,
});
function Kpi({ label, tag, value, detail, icon: Icon, tone, children }: {
label: string; tag?: React.ReactNode; value: React.ReactNode; detail?: React.ReactNode; icon: typeof BanknotesIcon;
tone: 'blue' | 'orange' | 'green' | 'red' | 'purple' | 'gray'; children?: React.ReactNode;
}) {
const tones = {
blue: 'bg-blue-50 text-blue-600', orange: 'bg-orange-50 text-orange-600', green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600', purple: 'bg-purple-50 text-purple-600', gray: 'bg-gray-100 text-gray-600',
};
return (
<div className="bg-white rounded-card shadow-card p-3 md:p-4 min-w-0 flex flex-col">
<div className="flex items-center gap-2 min-w-0">
<span className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', tones[tone])}><Icon className="w-4 h-4" aria-hidden /></span>
<p className="text-xs md:text-sm text-gray-500 truncate">{label}</p>
{tag}
</div>
<div className="mt-2 text-lg md:text-2xl font-bold leading-tight break-words">{value}</div>
{detail && <div className="mt-1 text-xs text-gray-500 space-y-0.5">{detail}</div>}
{children}
</div>
);
}
/** The P&L as a compact statement under the waterfall, in the same order as its bars. */
function Breakdown({ data, finalized }: { data: EventFinance; finalized: boolean }) {
const { t, locale } = useLanguage();
const { num } = useMoney();
const s = data.summary;
const catName = (id: string | null) => {
const c = data.categories.find((x) => x.id === id);
return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
};
const resultTone = (v: number | null) => (finalized && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
const Row = ({ label, value, sign, strong, muted, className }: {
label: React.ReactNode; value: number | null; sign?: '+' | '−'; strong?: boolean; muted?: boolean; className?: string;
}) => (
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong && 'font-semibold', muted && 'text-gray-500')}>
<span className="min-w-0">{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}{label}</span>
<Money value={value} className={clsx('whitespace-nowrap', className)} />
</div>
);
const partners = s.split.partners;
const showSplit = partners.length > 0 && s.split.organization !== null;
return (
<div className="text-sm">
<h3 className="text-sm font-semibold text-primary-dark mb-1">{t('admin.finance.statement.title')}</h3>
<Row label={t('admin.finance.statement.ticketSales', { count: num(s.counts.ticketsSold) })} value={s.revenue.gross} />
{s.revenue.refunds > 0 && <Row sign="−" label={t('admin.finance.waterfall.refunds')} value={s.revenue.refunds} muted />}
{s.revenue.fees > 0 && <Row sign="−" label={t('admin.finance.waterfall.fees')} value={s.revenue.fees} muted />}
{s.expenses.total > 0 && (
<details className="group">
<summary className="list-none cursor-pointer flex items-baseline justify-between gap-3 py-1 text-gray-500 [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-4 text-gray-400" aria-hidden>−</span>
{t('admin.finance.waterfall.expenses')}
<ChevronDownIcon className="w-3.5 h-3.5 transition-transform group-open:rotate-180" aria-hidden />
</span>
<Money value={s.expenses.total} className="whitespace-nowrap" />
</summary>
<div className="pl-5 pb-1">
{s.expenses.byCategory.map((c) => (
<div key={c.categoryId || 'none'} className="flex justify-between gap-3 py-0.5 text-xs text-gray-500">
<span className="truncate">{catName(c.categoryId)}</span>
<Money value={c.total} className="whitespace-nowrap" />
</div>
))}
</div>
</details>
)}
{s.revenue.otherIncome > 0 && <Row sign="+" label={t('admin.finance.waterfall.otherIncome')} value={s.revenue.otherIncome} muted />}
<div className="border-t border-gray-200 mt-1" />
<Row label={t(s.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={s.profit} strong className={resultTone(s.profit)} />
{showSplit && (
<>
{partners.map((p) => (
<Row
key={p.partnerId}
sign={p.share < 0 ? '+' : '−'}
label={<>{p.name}{p.share < 0 && <span className="block text-xs text-gray-400 pl-4">{t('admin.finance.statement.carriesLoss')}</span>}</>}
value={Math.abs(p.share)}
muted
/>
))}
<div className="border-t border-gray-200 mt-1" />
<Row label={t('admin.finance.split.organization')} value={s.split.organization} strong className={resultTone(s.split.organization)} />
</>
)}
</div>
);
}
export function SummaryView({ data, goTo }: { data: EventFinance; goTo: (s: FinanceSection) => void }) {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const { num } = useMoney();
const s = data.summary;
const finalized = data.status !== 'open';
const loss = s.profit < 0;
const capacity = data.event.capacity;
const canAdd = data.status === 'open' && (data.viewer.canEditExpenses || data.viewer.canEditOwnExpenses);
const nothingYet = s.revenue.gross === 0 && s.expenses.total === 0 && s.revenue.otherIncome === 0;
if (nothingYet) {
return (
<EmptyState icon={<BanknotesIcon className="w-5 h-5" />} title={t('admin.finance.empty.title')} body={t('admin.finance.empty.body')}>
{canAdd && <Button onClick={() => goTo('expenses')}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.finance.expenses.add')}</Button>}
</EmptyState>
);
}
const sold = s.counts.ticketsSold;
const be = breakEvenState(s);
let breakEvenValue: React.ReactNode;
let breakEvenDetail: React.ReactNode;
let breakEvenBar: number | null = null;
if (be.kind === 'noCosts') {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug text-gray-500">{t('admin.finance.breakEven.noCosts')}</span>;
breakEvenDetail = t('admin.finance.breakEven.noCostsHint');
} else if (be.kind === 'unreachable') {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.kpi.breakEvenNever')}</span>;
} else if (privacyMode) {
breakEvenValue = '••••';
} else if (be.kind === 'reached') {
breakEvenValue = (
<span className="text-sm md:text-base font-semibold leading-snug inline-flex items-start gap-1.5 text-green-700">
<CheckCircleIcon className="w-5 h-5 flex-shrink-0 mt-0.5" aria-hidden />
{be.at === 0
? t('admin.finance.breakEven.coveredWithoutTickets')
: t('admin.finance.breakEven.reached', { at: num(be.at), sold: num(be.sold) })}
</span>
);
} else {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.breakEven.needed', { count: num(be.remaining) })}</span>;
breakEvenBar = be.pct;
breakEvenDetail = be.needed > capacity
? <span className="inline-flex items-start gap-1 text-amber-700"><ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />{t('admin.finance.kpi.beyondCapacity', { capacity: num(capacity) })}</span>
: t('admin.finance.kpi.breakEvenProgress', { sold: num(be.sold), total: num(be.needed) });
}
const show = detailChartsAvailable(s);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 min-[420px]:grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi
label={t('admin.finance.kpi.ticketRevenue')}
value={<Money value={s.revenue.sales} />}
detail={privacyMode ? undefined : (
<>
<p>{t('admin.finance.kpi.revenueDetail', { count: num(sold) })}</p>
{s.revenue.gross > 0 && (
<p>{t('admin.finance.kpi.presale')} <Money value={s.revenue.presale} /> · {t('admin.finance.kpi.door')} <Money value={s.revenue.door} /></p>
)}
</>
)}
icon={BanknotesIcon}
tone="blue"
/>
<Kpi
label={t('admin.finance.kpi.expenses')}
value={<Money value={s.expenses.total} />}
detail={privacyMode || s.expenses.total === 0 ? undefined : (
<span>{t('admin.finance.charts.paid')} <Money value={s.expenses.paid} /> · {t('admin.finance.charts.planned')} <Money value={s.expenses.planned} /></span>
)}
icon={ShoppingBagIcon}
tone="orange"
/>
<Kpi
label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')}
tag={!finalized && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
value={<Money value={s.profit} className={clsx(finalized && !privacyMode && (loss ? 'text-red-600' : 'text-green-700'))} />}
detail={t(finalized ? 'admin.finance.kpi.afterFees' : 'admin.finance.kpi.estimatedHint')}
icon={loss ? ArrowTrendingDownIcon : ArrowTrendingUpIcon}
tone={finalized ? (loss ? 'red' : 'green') : 'gray'}
/>
<Kpi label={t('admin.finance.kpi.breakEven')} value={breakEvenValue} detail={breakEvenDetail} icon={ScaleIcon} tone="purple">
{breakEvenBar !== null && (
<div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuenow={breakEvenBar} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full rounded-full bg-purple-500" style={{ width: `${Math.max(breakEvenBar, 2)}%` }} />
</div>
)}
</Kpi>
</div>
{privacyMode ? (
<div className="bg-white rounded-card shadow-card p-8 text-center text-sm text-gray-500 flex flex-col items-center gap-2">
<EyeSlashIcon className="w-6 h-6 text-gray-400" aria-hidden />
{t('admin.finance.hiddenStats')}
</div>
) : (
<>
<div className="bg-white rounded-card shadow-card p-4 grid grid-cols-1 lg:grid-cols-5 gap-4 lg:gap-6">
<div className="lg:col-span-3 min-w-0">
<FinanceCharts part="waterfall" summary={s} categories={data.categories} locale={locale} t={t} estimated={!finalized} />
</div>
<div className="lg:col-span-2 min-w-0 border-t lg:border-t-0 lg:border-l border-gray-100 pt-4 lg:pt-0 lg:pl-6">
<Breakdown data={data} finalized={finalized} />
</div>
</div>
<FinanceCharts part="details" summary={s} categories={data.categories} locale={locale} t={t} />
{!show.costs && (
<p className="text-sm text-gray-400 text-center py-2">
{canAdd ? (
<button onClick={() => goTo('expenses')} className="hover:underline cursor-pointer">{t('admin.finance.charts.addExpensesHint')}</button>
) : t('admin.finance.charts.addExpensesHint')}
</p>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,58 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { inputClass } from './ui';
export interface UserHit { id: string; name: string; email: string; role: string }
/** Debounced search box with a result list; `search` decides who is eligible. */
export function UserSearch({ search, onPick, placeholder, noResults }: {
search: (q: string) => Promise<{ users: UserHit[] }>;
onPick: (u: UserHit) => void;
placeholder: string;
noResults: string;
}) {
const [q, setQ] = useState('');
const [hits, setHits] = useState<UserHit[]>([]);
const [searched, setSearched] = useState(false);
const seq = useRef(0);
useEffect(() => {
const term = q.trim();
if (term.length < 2) { setHits([]); setSearched(false); return; }
const mine = ++seq.current;
const timer = setTimeout(() => {
search(term)
.then((res) => { if (mine === seq.current) { setHits(res.users); setSearched(true); } })
.catch(() => { if (mine === seq.current) { setHits([]); setSearched(true); } });
}, 250);
return () => clearTimeout(timer);
}, [q, search]);
return (
<div>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input className={`${inputClass} pl-9`} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} />
</div>
{hits.length > 0 && (
<ul className="mt-1 border border-gray-100 rounded-btn divide-y divide-gray-100 max-h-56 overflow-y-auto">
{hits.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => { onPick(u); setQ(''); setHits([]); setSearched(false); }}
className="w-full text-left px-3 py-2 hover:bg-gray-50 min-h-[44px]"
>
<p className="text-sm font-medium truncate">{u.name}</p>
<p className="text-xs text-gray-500 truncate">{u.email} · {u.role}</p>
</button>
</li>
))}
</ul>
)}
{searched && hits.length === 0 && <p className="text-xs text-gray-400 mt-1">{noResults}</p>}
</div>
);
}
@@ -0,0 +1,20 @@
// Client-side preview of an expense amount, mirroring
// backend/src/lib/finance/calculate.ts expenseAmount(). Only used to show
// "this will be X PYG right now" while typing; the server stays authoritative.
import type { CalcType } from '@/lib/api';
export interface PreviewContext { ticketsSold: number; checkedIn: number; sales: number }
export function previewExpense(
e: { calcType: CalcType; quantity: number; unitAmount: number; percentBp: number; minimumAmount: number },
ctx: PreviewContext,
): { amount: number; count: number } {
switch (e.calcType) {
case 'per_ticket_sold': return { amount: e.unitAmount * ctx.ticketsSold, count: ctx.ticketsSold };
case 'per_checked_in': return { amount: e.unitAmount * ctx.checkedIn, count: ctx.checkedIn };
case 'percent_of_revenue': return { amount: Math.round((Math.max(0, ctx.sales) * e.percentBp) / 10000), count: 0 };
case 'minimum_spend': return { amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn), count: ctx.checkedIn };
default: return { amount: e.unitAmount * e.quantity, count: e.quantity };
}
}
@@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest';
import {
calculateEventFinance, expenseAmount, CALC_TYPES,
type FinanceExpense, type FinanceInput, type FinancePartner, type FinancePayment,
} from '../../../../../../../backend/src/lib/finance/calculate';
import type { EventExpense, EventFinance, EventPartner, FinanceSummary } from '@/lib/api';
import { breakEvenState, buildWaterfall, deriveLifecycle, detailChartsAvailable, suggestCategory, tCount } from './derive';
import { previewExpense } from './calc';
// The frontend derivations run on the backend's real calculation, so these
// tests also check the UI reads the same numbers the server computes.
let seq = 0;
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
});
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
});
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
lossRule: 'none', lossCapAmount: 0, ...over,
});
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
});
const summaryOf = (i: FinanceInput) => calculateEventFinance(i) as unknown as FinanceSummary;
// ---- Scenarios (same as the backend's pinned ones)
const noExpenses = summaryOf(input({
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
}));
const loss = summaryOf(input({
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
expenses: [
expense({ unitAmount: 300000, status: 'paid' }),
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
],
}));
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
const withPartners = summaryOf(input({
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
expenses: [
expense({ unitAmount: 200000, status: 'paid' }),
expense({ unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
],
partners: [ana, partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 })],
}));
describe('buildWaterfall', () => {
const sumTo = (steps: ReturnType<typeof buildWaterfall>) =>
steps.slice(1, -1).reduce((s, w) => s + w.amount, steps[0].amount);
it('no expenses: gross, fees, profit; nothing else shown', () => {
const steps = buildWaterfall(noExpenses);
expect(steps.map((s) => [s.key, s.amount])).toEqual([['gross', 200000], ['fees', -5800], ['profit', 194200]]);
expect(sumTo(steps)).toBe(steps[steps.length - 1].amount);
});
it('a loss: ends at the (negative) profit', () => {
const steps = buildWaterfall(loss);
expect(steps.map((s) => s.key)).toEqual(['gross', 'fees', 'expenses', 'profit']);
expect(steps[steps.length - 1].amount).toBe(-144350);
expect(sumTo(steps)).toBe(-144350);
});
it('partners: other income in, partner payouts out, ends at what the organization keeps', () => {
const steps = buildWaterfall(withPartners);
expect(steps.map((s) => [s.key, s.kind, s.amount])).toEqual([
['gross', 'total', 1000000],
['expenses', 'out', -350000],
['otherIncome', 'in', 50000],
['partners', 'out', -280000],
['organization', 'total', 420000],
]);
expect(sumTo(steps)).toBe(420000);
});
it('viewers without the full split end at the profit', () => {
const scoped = { ...withPartners, split: { ...withPartners.split, organization: null, distributable: null } };
const steps = buildWaterfall(scoped);
expect(steps[steps.length - 1]).toEqual({ key: 'profit', kind: 'total', amount: 700000 });
expect(steps.some((s) => s.key === 'partners')).toBe(false);
});
});
describe('breakEvenState', () => {
it('no costs: neutral, no progress', () => {
expect(breakEvenState(noExpenses)).toEqual({ kind: 'noCosts' });
});
it('not covered: tickets still needed and progress sold / needed', () => {
expect(breakEvenState(loss)).toEqual({ kind: 'needed', remaining: 4, sold: 4, needed: 8, pct: 50 });
});
it('covered: reached at N of M sold', () => {
expect(breakEvenState(withPartners)).toEqual({ kind: 'reached', at: 3, sold: 10 });
});
it('unreachable at this price', () => {
const s = summaryOf(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1000 })] }));
expect(breakEvenState(s)).toEqual({ kind: 'unreachable' });
});
});
describe('detailChartsAvailable', () => {
it('hides cost charts without expenses and the timeline with fewer than 2 days', () => {
expect(detailChartsAvailable(noExpenses)).toEqual({ costs: false, methods: true, timeline: false });
});
it('shows the timeline once there are two sales days', () => {
const s = summaryOf(input({ payments: [pay(1, { paidAt: '2026-09-01T10:00:00Z' }), pay(1, { paidAt: '2026-09-02T10:00:00Z' })] }));
expect(detailChartsAvailable(s).timeline).toBe(true);
});
});
// ---- Lifecycle
const NOW = Date.parse('2026-09-28T12:00:00Z');
const exp = (status: 'planned' | 'paid') => ({ status } as EventExpense);
const prt = (payoutStatus: 'pending' | 'paid') => ({ payoutStatus } as EventPartner);
const fin = (over: Partial<Pick<EventFinance, 'status' | 'expenses' | 'partners'>> & { start?: string; end?: string | null } = {}) => ({
status: over.status ?? 'open',
expenses: over.expenses ?? [],
partners: over.partners ?? [],
event: { startDatetime: over.start ?? '2026-10-10T20:00:00Z', endDatetime: over.end ?? null },
} as Parameters<typeof deriveLifecycle>[0]);
describe('deriveLifecycle', () => {
it('upcoming with no costs: selling', () => {
const l = deriveLifecycle(fin(), NOW);
expect(l.step).toBe('selling');
expect(l.ended).toBe(false);
expect(l.todo.map((t) => t.key)).toEqual(['noCosts', 'noPartners', 'notEnded']);
});
it('upcoming with costs: adding costs', () => {
expect(deriveLifecycle(fin({ expenses: [exp('paid')] }), NOW).step).toBe('costs');
});
it('ended with planned costs: adding costs, warns about them, counts days since the end', () => {
const l = deriveLifecycle(fin({ start: '2026-09-20T20:00:00Z', end: '2026-09-20T23:00:00Z', expenses: [exp('paid'), exp('planned'), exp('planned')] }), NOW);
expect(l).toMatchObject({ step: 'costs', ended: true, daysSinceEnd: 7, plannedCount: 2 });
expect(l.todo).toContainEqual({ key: 'planned', count: 2, tone: 'warn' });
});
it('ended with no costs: still adding costs, and that is a warning', () => {
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z' }), NOW);
expect(l.step).toBe('costs');
expect(l.todo).toContainEqual({ key: 'noCosts', tone: 'warn' });
});
it('ended with every cost paid: ready to close', () => {
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z', expenses: [exp('paid')], partners: [prt('pending')] }), NOW);
expect(l.step).toBe('ready');
expect(l.todo).toEqual([]);
});
it('uses the end time, not the start, to decide the event is over', () => {
const l = deriveLifecycle(fin({ start: '2026-09-28T10:00:00Z', end: '2026-09-28T14:00:00Z' }), NOW);
expect(l.ended).toBe(false);
});
it('finalized: lists partner payouts still to record', () => {
const l = deriveLifecycle(fin({ status: 'finalized', start: '2026-09-01T20:00:00Z', expenses: [exp('planned')], partners: [prt('paid'), prt('pending')] }), NOW);
expect(l.step).toBe('finalized');
expect(l.todo).toEqual([{ key: 'payoutsPending', count: 1, tone: 'info' }]);
});
it('does not nag collaborators about partners they cannot manage', () => {
const l = deriveLifecycle({ ...fin(), viewer: { fullSplit: false } } as any, NOW);
expect(l.todo.map((t) => t.key)).not.toContain('noPartners');
});
});
// ---- Category suggestion
const categories = [
{ id: 'venue', nameEn: 'Venue', nameEs: 'Lugar', archived: false },
{ id: 'host', nameEn: 'Instructor / host', nameEs: 'Instructor / anfitrión', archived: false },
{ id: 'food', nameEn: 'Food & drinks', nameEs: 'Comida y bebidas', archived: false },
{ id: 'staff', nameEn: 'Staff', nameEs: 'Personal', archived: false },
{ id: 'mkt', nameEn: 'Marketing', nameEs: 'Marketing', archived: false },
{ id: 'sup', nameEn: 'Supplies', nameEs: 'Materiales', archived: false },
];
describe('suggestCategory', () => {
it.each([
['Studio rental', 'venue'],
['Alquiler del salón', 'venue'],
['Teacher fee', 'host'],
['Pago a la profe', 'host'],
['Snacks and drinks', 'food'],
['Cerveza', 'food'],
['Security', 'staff'],
['Instagram ads', 'mkt'],
['Name badges', 'sup'],
])('%s -> %s', (description, id) => {
expect(suggestCategory(description, categories)).toBe(id);
});
it('returns null when nothing matches', () => {
expect(suggestCategory('Miscellaneous', categories)).toBeNull();
expect(suggestCategory('', categories)).toBeNull();
});
it('skips archived categories', () => {
expect(suggestCategory('venue', [{ ...categories[0], archived: true }])).toBeNull();
});
it('matches whole words only', () => {
// "ad" is a marketing keyword, but "headphones" should not match it.
expect(suggestCategory('headphones', categories)).toBeNull();
});
});
describe('tCount', () => {
const t = (key: string, p?: Record<string, string | number>) => `${key}:${p?.count}`;
it('picks _one for 1 and _other otherwise', () => {
expect(tCount(t, 'k', 1)).toBe('k_one:1');
expect(tCount(t, 'k', 0)).toBe('k_other:0');
expect(tCount(t, 'k', 3)).toBe('k_other:3');
});
});
// ---- Modal preview mirrors the server
describe('previewExpense matches the backend expenseAmount', () => {
const ctx = { ticketsSold: 4, checkedIn: 3, sales: 200000 };
it.each(CALC_TYPES)('%s', (calcType) => {
const e = { calcType, quantity: 1, unitAmount: 50000, percentBp: 1250, minimumAmount: 300000 };
const server = expenseAmount({ ...e, computedAmount: 0, isLocked: false }, ctx);
expect(previewExpense(e, ctx).amount).toBe(server.amount);
});
it('minimum spend switches to per-guest once it passes the minimum', () => {
const e = { calcType: 'minimum_spend' as const, quantity: 1, unitAmount: 50000, percentBp: 0, minimumAmount: 300000 };
const big = { ticketsSold: 10, checkedIn: 8, sales: 0 };
expect(previewExpense(e, big).amount).toBe(expenseAmount({ ...e, computedAmount: 0, isLocked: false }, big).amount);
expect(previewExpense(e, big).amount).toBe(400000);
});
});
@@ -0,0 +1,189 @@
// Pure derivations for the Finance tab: where the event is in its money
// lifecycle, what is left to do, how break-even reads, and a category guess
// for a new expense. No React here so it can be unit tested.
import type { EventFinance, ExpenseCategory, FinanceSummary } from '@/lib/api';
export type LifecycleStep = 'selling' | 'costs' | 'ready' | 'finalized';
export const LIFECYCLE_STEPS: LifecycleStep[] = ['selling', 'costs', 'ready', 'finalized'];
export interface TodoItem {
/** i18n key under admin.finance.lifecycle.todo (pluralized with _one / _other when count is set). */
key: 'noCosts' | 'planned' | 'noPartners' | 'payoutsPending' | 'notEnded';
count?: number;
tone: 'warn' | 'info';
}
export interface Lifecycle {
step: LifecycleStep;
ended: boolean;
/** Whole days since the event ended; null while it has not ended. */
daysSinceEnd: number | null;
expenseCount: number;
plannedCount: number;
partnerCount: number;
todo: TodoItem[];
}
type LifecycleInput = Pick<EventFinance, 'status' | 'expenses' | 'partners'> & {
event: { startDatetime: string; endDatetime?: string | null };
viewer?: { fullSplit: boolean };
};
const DAY = 86_400_000;
/** The moment an event counts as over: its end time, or its start when no end is set. */
export function eventEndTime(event: { startDatetime: string; endDatetime?: string | null }): number {
return new Date(event.endDatetime || event.startDatetime).getTime();
}
export function deriveLifecycle(data: LifecycleInput, now: number = Date.now()): Lifecycle {
const end = eventEndTime(data.event);
const ended = Number.isFinite(end) && now >= end;
const daysSinceEnd = ended ? Math.floor((now - end) / DAY) : null;
const expenseCount = data.expenses.length;
const plannedCount = data.expenses.filter((e) => e.status === 'planned').length;
const partnerCount = data.partners.length;
const fullSplit = data.viewer?.fullSplit ?? true;
const todo: TodoItem[] = [];
let step: LifecycleStep;
if (data.status !== 'open') {
step = 'finalized';
const unpaid = data.partners.filter((p) => p.payoutStatus !== 'paid').length;
if (unpaid > 0) todo.push({ key: 'payoutsPending', count: unpaid, tone: 'info' });
} else {
if (!ended) step = expenseCount === 0 ? 'selling' : 'costs';
else step = expenseCount > 0 && plannedCount === 0 ? 'ready' : 'costs';
if (expenseCount === 0) todo.push({ key: 'noCosts', tone: ended ? 'warn' : 'info' });
if (plannedCount > 0) todo.push({ key: 'planned', count: plannedCount, tone: 'warn' });
if (partnerCount === 0 && fullSplit) todo.push({ key: 'noPartners', tone: 'info' });
if (!ended) todo.push({ key: 'notEnded', tone: 'info' });
}
return { step, ended, daysSinceEnd, expenseCount, plannedCount, partnerCount, todo };
}
// ==================== Break-even ====================
export type BreakEvenState =
| { kind: 'noCosts' }
| { kind: 'unreachable' }
| { kind: 'reached'; at: number; sold: number }
| { kind: 'needed'; remaining: number; sold: number; needed: number; pct: number };
export function breakEvenState(summary: Pick<FinanceSummary, 'breakEven' | 'expenses' | 'counts'>): BreakEvenState {
const be = summary.breakEven;
const sold = summary.counts.ticketsSold;
if (summary.expenses.total === 0) return { kind: 'noCosts' };
if (be.tickets === null) return { kind: 'unreachable' };
if (sold >= be.tickets) return { kind: 'reached', at: be.tickets, sold };
const needed = be.tickets;
return {
kind: 'needed',
remaining: be.remaining ?? needed - sold,
sold,
needed,
pct: needed > 0 ? Math.min(100, Math.round((sold / needed) * 100)) : 100,
};
}
// ==================== Category suggestion ====================
/** Words in an expense description, and the words that identify the matching category by name. */
const CATEGORY_HINTS: { words: string[]; category: string[] }[] = [
{
words: ['venue', 'rent', 'rental', 'room', 'hall', 'space', 'studio', 'alquiler', 'local', 'sala', 'lugar', 'espacio', 'salon', 'estudio'],
category: ['venue', 'lugar'],
},
{
words: ['instructor', 'teacher', 'host', 'tutor', 'coach', 'facilitator', 'profesor', 'profesora', 'profe', 'anfitrion', 'anfitriona', 'moderador', 'moderadora'],
category: ['instructor', 'host', 'anfitrion'],
},
{
words: ['snack', 'snacks', 'drink', 'drinks', 'food', 'beer', 'coffee', 'water', 'pizza', 'catering', 'wine', 'soda',
'comida', 'bebida', 'bebidas', 'cerveza', 'cafe', 'agua', 'merienda', 'bocaditos', 'vino', 'gaseosa'],
category: ['food', 'drink', 'comida', 'bebida'],
},
{
words: ['staff', 'helper', 'volunteer', 'security', 'cleaning', 'waiter', 'personal', 'limpieza', 'seguridad', 'ayudante', 'mozo', 'voluntario'],
category: ['staff', 'personal'],
},
{
words: ['ads', 'ad', 'marketing', 'instagram', 'facebook', 'flyer', 'flyers', 'promo', 'poster', 'publicidad', 'anuncio', 'anuncios', 'volante', 'volantes'],
category: ['marketing'],
},
{
words: ['supplies', 'material', 'materials', 'print', 'printing', 'paper', 'markers', 'badges', 'stickers',
'materiales', 'impresion', 'impresiones', 'papel', 'marcadores', 'etiquetas'],
category: ['suppl', 'material'],
},
];
const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
/** Best-guess category id for a description, or null when nothing matches. */
export function suggestCategory(description: string, categories: Pick<ExpenseCategory, 'id' | 'nameEn' | 'nameEs' | 'archived'>[]): string | null {
const tokens = new Set(normalize(description).split(/[^a-z0-9]+/).filter(Boolean));
if (tokens.size === 0) return null;
for (const hint of CATEGORY_HINTS) {
if (!hint.words.some((w) => tokens.has(w))) continue;
const match = categories.find((c) => !c.archived && hint.category.some((k) => normalize(`${c.nameEn} ${c.nameEs}`).includes(k)));
if (match) return match.id;
}
return null;
}
// ==================== Copy helpers ====================
type T = (key: string, params?: Record<string, string | number>) => string;
/** Picks `${key}_one` or `${key}_other` so counts read naturally in both languages. */
export function tCount(t: T, key: string, count: number, params: Record<string, string | number> = {}): string {
return t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...params });
}
// ==================== Waterfall ====================
export type WaterfallKind = 'total' | 'in' | 'out';
export interface WaterfallStep {
key: 'gross' | 'refunds' | 'fees' | 'expenses' | 'otherIncome' | 'partners' | 'profit' | 'organization';
kind: WaterfallKind;
/** Signed: positive adds to the running total, negative takes from it. Totals are the level itself. */
amount: number;
}
/**
* Gross revenue -> fees -> expenses -> other income -> partner payouts ->
* what the organization keeps. Zero steps are left out; the steps always add
* up to the final bar. Viewers without the full split end at the profit.
*/
export function buildWaterfall(summary: Pick<FinanceSummary, 'revenue' | 'expenses' | 'profit' | 'split'>): WaterfallStep[] {
const r = summary.revenue;
const steps: WaterfallStep[] = [{ key: 'gross', kind: 'total', amount: r.gross }];
const add = (key: WaterfallStep['key'], amount: number) => {
if (amount !== 0) steps.push({ key, kind: amount > 0 ? 'in' : 'out', amount });
};
add('refunds', -r.refunds);
add('fees', -r.fees);
add('expenses', -summary.expenses.total);
add('otherIncome', r.otherIncome);
const withSplit = summary.split.organization !== null && summary.split.partners.length > 0;
if (withSplit) {
add('partners', -summary.split.partners.reduce((sum, p) => sum + p.share, 0));
steps.push({ key: 'organization', kind: 'total', amount: summary.split.organization! });
} else {
steps.push({ key: 'profit', kind: 'total', amount: summary.profit });
}
return steps;
}
/** Which detail charts have something to show (cumulative sales needs at least two days). */
export function detailChartsAvailable(summary: Pick<FinanceSummary, 'expenses' | 'revenue' | 'salesTimeline'>) {
return {
costs: summary.expenses.byCategory.some((c) => c.total > 0),
methods: summary.revenue.byMethod.some((m) => m.gross > 0),
timeline: summary.salesTimeline.length >= 2,
};
}
@@ -0,0 +1,20 @@
// Input parsing for finance forms. Display formatting lives in @/lib/money.
export { formatNumber, formatPyg, formatPygShort, formatPercent } from '@/lib/money';
/** Basis points to a percent string for inputs (290 -> "2.9"). */
export function bpToPercent(bp: number): string {
return String(Math.round(bp) / 100);
}
/** Percent input ("2,9" or "2.9") to basis points. */
export function percentToBp(value: string): number {
const n = parseFloat(value.replace(',', '.'));
return Number.isFinite(n) ? Math.max(0, Math.min(10000, Math.round(n * 100))) : 0;
}
/** Whole-PYG input ("1.500.000", "1,500,000" or "1500000") to an integer. */
export function parsePygInput(value: string): number {
const digits = value.replace(/[^\d]/g, '');
return digits ? Math.min(2_000_000_000, parseInt(digits, 10)) : 0;
}
@@ -0,0 +1,275 @@
'use client';
// Small shared pieces for the Finance / Team tabs, following the event page's
// existing modal (bottom sheet on mobile, centered card on desktop), compact
// input and badge styles.
import { ReactNode, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { XMarkIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
import Card from '@/components/ui/Card';
import { usePrivacy } from '@/context/PrivacyContext';
import { useLanguage } from '@/context/LanguageContext';
import { PRIVACY_MASK } from '@/components/admin/SensitiveValue';
import type { FinanceStatus } from '@/lib/api';
import { formatNumber } from '@/lib/money';
import { useMoney } from '@/lib/useMoney';
import { parsePygInput } from './format';
export const inputClass =
'w-full px-3 py-2.5 min-h-[44px] text-base md:text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:bg-gray-50 disabled:text-gray-400';
/** Icon-only buttons: 44px touch target on mobile, visible keyboard focus. */
export const iconButtonClass =
'inline-flex items-center justify-center min-h-[44px] min-w-[44px] md:min-h-[36px] md:min-w-[36px] rounded-btn cursor-pointer hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow disabled:opacity-40 disabled:cursor-default';
export function Modal({ open, title, subtitle, onClose, children, wide }: {
open: boolean; title: string; subtitle?: string; onClose: () => void; children: ReactNode; wide?: boolean;
}) {
const { t } = useLanguage();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4" onClick={onClose}>
<Card
className={clsx('w-full rounded-t-2xl md:rounded-card max-h-[92vh] overflow-y-auto', wide ? 'md:max-w-2xl' : 'md:max-w-md')}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray sticky top-0 bg-white z-10">
<div className="min-w-0">
<h2 className="text-base font-bold truncate">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" aria-label={t('admin.finance.close')}>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3">{children}</div>
</Card>
</div>
);
}
export function Field({ label, hint, required, error, children }: { label: string; hint?: string; required?: boolean; error?: string; children: ReactNode }) {
return (
<div>
<label className="block text-sm font-medium mb-1 text-primary-dark">
{label}{required && <span className="text-red-600 ml-0.5" aria-hidden>*</span>}
</label>
{children}
{error ? <p className="text-xs text-red-600 mt-1">{error}</p> : hint && <p className="text-xs text-gray-500 mt-1">{hint}</p>}
</div>
);
}
/** Radio group rendered as selectable cards: each option explains itself. */
export function ChoiceCards<K extends string>({ options, value, onChange, columns = 2, name }: {
options: { key: K; title: string; description?: string }[];
value: K;
onChange: (k: K) => void;
columns?: 2 | 3;
name: string;
}) {
return (
<div role="radiogroup" className={clsx('grid grid-cols-1 gap-2', columns === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
{options.map((o) => {
const active = o.key === value;
return (
<button
key={o.key}
type="button"
role="radio"
aria-checked={active}
name={name}
onClick={() => onChange(o.key)}
className={clsx(
'text-left rounded-btn border px-3 py-2.5 min-h-[44px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
active ? 'border-primary-yellow bg-yellow-50' : 'border-secondary-light-gray hover:border-gray-300 bg-white',
)}
>
<span className="flex items-center gap-2 text-sm font-medium text-primary-dark">
<span className={clsx('w-4 h-4 rounded-full border-2 flex-shrink-0', active ? 'border-primary-yellow bg-primary-yellow shadow-[inset_0_0_0_2px_white]' : 'border-gray-300')} />
{o.title}
</span>
{o.description && <span className="block text-xs text-gray-500 mt-0.5 pl-6">{o.description}</span>}
</button>
);
})}
</div>
);
}
/** Two or three mutually exclusive options as one control (e.g. Planned / Paid). */
export function Segmented<K extends string>({ options, value, onChange, label }: {
options: { key: K; label: string; icon?: ReactNode }[];
value: K;
onChange: (k: K) => void;
label: string;
}) {
return (
<div role="radiogroup" aria-label={label} className="grid grid-flow-col auto-cols-fr gap-1 p-1 bg-gray-100 rounded-btn">
{options.map((o) => (
<button
key={o.key}
type="button"
role="radio"
aria-checked={o.key === value}
onClick={() => onChange(o.key)}
className={clsx(
'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium min-h-[40px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
o.key === value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
)}
>
{o.icon}{o.label}
</button>
))}
</div>
);
}
/** Explains why a list is empty and offers the next step. */
export function EmptyState({ icon, title, body, children }: { icon?: ReactNode; title: string; body?: string; children?: ReactNode }) {
return (
<div className="bg-white rounded-card shadow-card px-6 py-10 text-center flex flex-col items-center gap-2">
{icon && <div className="w-11 h-11 rounded-full bg-yellow-50 text-yellow-700 flex items-center justify-center">{icon}</div>}
<p className="font-semibold text-primary-dark">{title}</p>
{body && <p className="text-sm text-gray-500 max-w-md">{body}</p>}
{children && <div className="flex flex-wrap justify-center gap-2 mt-2">{children}</div>}
</div>
);
}
/** Whole-PYG input that shows the UI language's thousands separator while typing. */
export function PygInput({ value, onChange, disabled, id }: { value: number; onChange: (n: number) => void; disabled?: boolean; id?: string }) {
const { locale } = useLanguage();
const [text, setText] = useState(value ? formatNumber(value, locale) : '');
useEffect(() => { setText(value ? formatNumber(value, locale) : ''); }, [value, locale]);
return (
<div className="relative">
<input
id={id}
inputMode="numeric"
className={clsx(inputClass, 'pr-12 tabular-nums')}
value={text}
disabled={disabled}
placeholder="0"
onChange={(e) => {
const n = parsePygInput(e.target.value);
setText(n ? formatNumber(n, locale) : '');
onChange(n);
}}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">PYG</span>
</div>
);
}
/** Today as YYYY-MM-DD in the viewer's time zone. */
export function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* Date field that reads in the active UI language. A native <input type="date">
* formats itself with the browser's locale (e.g. "dd-mm-jjjj" on a Dutch
* system), so the visible text is ours and the native input only supplies the
* picker, laid transparently on top.
*/
export function DateInput({ value, onChange, clearable, id, className }: {
value: string; onChange: (v: string) => void; clearable?: boolean; id?: string; className?: string;
}) {
const { t, locale } = useLanguage();
const ref = useRef<HTMLInputElement>(null);
const label = value
? new Date(`${value}T12:00:00`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
: '';
return (
<div className={clsx('relative', className)}>
<div className={clsx(inputClass, 'flex items-center gap-2 pr-2 focus-within:ring-2 focus-within:ring-primary-yellow')} aria-hidden>
<CalendarDaysIcon className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className={clsx('flex-1 truncate', !label && 'text-gray-400')}>{label || t('admin.finance.pickDate')}</span>
</div>
<input
ref={ref}
id={id}
type="date"
lang={locale === 'es' ? 'es-PY' : 'en-US'}
value={value}
aria-label={label || t('admin.finance.pickDate')}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => { try { e.currentTarget.showPicker?.(); } catch { /* picker opens natively */ } }}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
{clearable && value && (
<button
type="button"
onClick={() => onChange('')}
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-btn text-gray-400 hover:text-gray-700 hover:bg-gray-100 cursor-pointer"
aria-label={t('admin.finance.clearDate')}
>
<XMarkIcon className="w-4 h-4" />
</button>
)}
</div>
);
}
/** A PYG amount that respects Hide Stats. */
export function Money({ value, className, signed }: { value: number | null | undefined; className?: string; signed?: boolean }) {
const { privacyMode } = usePrivacy();
const { pyg } = useMoney();
if (value === null || value === undefined) return <span className={className}>-</span>;
const text = signed && value > 0 ? `+${pyg(value)}` : pyg(value);
return <span className={clsx('tabular-nums', className)}>{privacyMode ? PRIVACY_MASK : text}</span>;
}
const STATUS_STYLES: Record<FinanceStatus, string> = {
open: 'bg-gray-100 text-gray-700',
finalized: 'bg-blue-100 text-blue-800',
paid_out: 'bg-green-100 text-green-800',
};
export function FinanceStatusBadge({ status, label }: { status: FinanceStatus; label: string }) {
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLES[status])}>{label}</span>;
}
export function Pill({ children, tone = 'gray' }: { children: ReactNode; tone?: 'gray' | 'green' | 'yellow' | 'blue' | 'red' }) {
const tones = {
gray: 'bg-gray-100 text-gray-700',
green: 'bg-green-100 text-green-800',
yellow: 'bg-yellow-100 text-yellow-800',
blue: 'bg-blue-100 text-blue-800',
red: 'bg-red-100 text-red-800',
};
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap', tones[tone])}>{children}</span>;
}
/** Segmented sub-navigation matching the event page's mobile tab pills. */
export function SubNav<K extends string>({ items, active, onChange }: { items: { key: K; label: string; count?: number }[]; active: K; onChange: (k: K) => void }) {
return (
<div role="tablist" className="grid grid-flow-col auto-cols-fr md:inline-grid md:auto-cols-auto gap-1 p-1 bg-gray-100 rounded-lg w-full md:w-auto">
{items.map((it) => (
<button
key={it.key}
role="tab"
aria-selected={active === it.key}
onClick={() => onChange(it.key)}
className={clsx(
// Phones: long labels wrap to two lines instead of running into the next tab.
'px-2 md:px-3 py-1.5 md:py-2 rounded-md text-sm font-medium leading-tight md:whitespace-nowrap min-h-[40px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
active === it.key ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
)}
>
{it.label}
{it.count !== undefined && it.count > 0 && <span className="ml-1.5 text-xs text-gray-400">{it.count}</span>}
</button>
))}
</div>
);
}
@@ -1,37 +1,81 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary, type EventPermission } from '@/lib/api';
/** A 403 means "not for this viewer": render without that data instead of failing the page. */
const forbidden = (error: any) => error?.status === 403;
/**
* Loads the core data for the admin event detail page (event, tickets, active
* email templates) and exposes a reload function used after mutations.
* Loads the core data for the event detail page and exposes a reload function
* used after mutations. The event itself is fetched straight away so the
* header can render while the rest loads; tickets, active email templates and
* door takings wait for the viewer's permissions on the event and only request
* what they may see, so team members get a working page with fewer tabs.
*
* `eventLoading` and `detailsLoading` cover the first load only: reloads keep
* the current data on screen until the new data replaces it.
*/
export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true);
export function useEventDetailData(eventId: string, permissions: Set<EventPermission> | null) {
const [eventLoading, setEventLoading] = useState(true);
const [detailsLoading, setDetailsLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
const loadEventData = async () => {
const loadEvent = async () => {
try {
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
const { event } = await eventsApi.getById(eventId);
setEvent(event);
} catch (error) {
toast.error('Failed to load event data');
} finally {
setLoading(false);
setEventLoading(false);
}
};
const loadDetails = async () => {
if (!permissions) return;
const can = (p: EventPermission) => permissions.has(p);
try {
const [ticketsRes, templatesRes, doorRes] = await Promise.all([
can('view_attendees_names')
? ticketsApi.getAll({ eventId }).catch((error) => {
if (forbidden(error)) return { tickets: [] as Ticket[] };
throw error;
})
: { tickets: [] as Ticket[] },
can('email_attendees')
? emailsApi.getTemplates(eventId).catch((error) => {
if (forbidden(error)) return { templates: [] as EmailTemplate[] };
throw error;
})
: { templates: [] as EmailTemplate[] },
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
// night. It is supporting detail, so a failure here must not blank the page.
can('view_payments') ? doorApi.summary(eventId).catch(() => null) : null,
]);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) {
toast.error('Failed to load event data');
} finally {
setDetailsLoading(false);
}
};
const loadEventData = async () => {
await Promise.all([loadEvent(), loadDetails()]);
};
useEffect(() => {
loadEventData();
loadEvent();
}, [eventId]);
return { loading, event, tickets, templates, loadEventData };
useEffect(() => {
loadDetails();
}, [eventId, permissions]);
return { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData };
}
@@ -24,18 +24,21 @@ interface AddTicketModalProps {
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
{ value: 'paid', label: 'Paid' },
{ value: 'door', label: 'At Door' },
{ value: 'unpaid', label: 'Unpaid' },
{ value: 'guest', label: 'Guest' },
];
const SUBMIT_LABELS: Record<AddTicketType, string> = {
paid: 'Create & send ticket',
door: 'Record door payment',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: EnvelopeIcon,
door: BanknotesIcon,
unpaid: LinkIcon,
guest: StarIcon,
};
@@ -47,6 +50,15 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
if (form.type === 'paid') {
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
lines.push('Confirmation email with QR ticket sent');
} else if (form.type === 'door') {
lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`);
lines.push('QR code issued');
if (!form.firstName.trim()) {
lines.push('No name — the ticket is logged as a "Walk-in"');
}
lines.push(hasEmail
? 'Confirmation email with QR ticket sent'
: 'No email — nothing is sent, walk-in kept on the list only');
} else if (form.type === 'unpaid') {
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
lines.push('QR code issued, flagged "unpaid" for door staff');
@@ -66,12 +78,14 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' },
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
};
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: CheckCircleIcon,
door: BanknotesIcon,
unpaid: BanknotesIcon,
guest: StarIcon,
};
@@ -88,6 +102,8 @@ export function AddTicketModal({
if (!open) return null;
const emailRequired = form.type === 'paid';
// Door walk-ins can be logged with nothing filled in
const nameRequired = form.type !== 'door';
const style = PREVIEW_STYLES[form.type];
const PreviewIcon = PREVIEW_ICONS[form.type];
const SubmitIcon = SUBMIT_ICONS[form.type];
@@ -120,7 +136,7 @@ export function AddTicketModal({
type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx(
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors',
form.type === option.value
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
@@ -133,11 +149,11 @@ export function AddTicketModal({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={form.firstName}
<label className="block text-xs font-medium mb-1">First Name {nameRequired && '*'}</label>
<input type="text" required={nameRequired} value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
@@ -155,6 +171,7 @@ export function AddTicketModal({
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1">
{form.type === 'paid' && 'Ticket will be sent to this email'}
{form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'}
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
</p>
@@ -133,6 +133,16 @@ export function EventModals(props: EventModalsProps) {
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Paid at Door</p>
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
</div>
</button>
<button
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import clsx from 'clsx';
import {
MagnifyingGlassIcon,
@@ -79,6 +81,20 @@ export function AttendeesTab({
handleMarkPaid,
handleCheckin,
}: AttendeesTabProps) {
// Paginated client-side: the parent already holds every ticket for the event
// so the status counts and the other tabs keep seeing the full set.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const filterKey = `${searchQuery}|${statusFilter}`;
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(filteredTickets, page, pageSize, setPage);
return (
<div className="space-y-3">
{/* Desktop toolbar */}
@@ -148,6 +164,9 @@ export function AttendeesTab({
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('door'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Paid at Door
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem>
@@ -234,7 +253,7 @@ export function AttendeesTab({
</td>
</tr>
) : (
filteredTickets.map((ticket) => {
pagedTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50/50">
@@ -320,7 +339,7 @@ export function AttendeesTab({
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</div>
) : (
filteredTickets.map((ticket) => {
pagedTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<Card key={ticket.id} className="p-3">
@@ -377,6 +396,16 @@ export function AttendeesTab({
)}
</div>
<Pagination
id="attendees"
page={page}
pageSize={pageSize}
total={filteredTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button
@@ -1,6 +1,7 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import { Skeleton } from '@/components/ui/Skeleton';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
@@ -10,9 +11,11 @@ interface OverviewTabProps {
formatCurrency: (amount: number, currency: string) => string;
confirmedCount: number;
checkedInCount: number;
/** Tickets are still loading, so the seat counts are not known yet. */
countsLoading?: boolean;
}
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount, countsLoading = false }: OverviewTabProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-5">
@@ -49,8 +52,17 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Capacity</p>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
{countsLoading ? (
<div aria-hidden="true">
<Skeleton className="mt-1 h-4 w-28" />
<Skeleton className="mt-1.5 h-3 w-24" />
</div>
) : (
<>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
</>
)}
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import { PaymentOptionsConfig } from '@/lib/api';
import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
@@ -12,13 +12,86 @@ import {
XCircleIcon,
} from '@heroicons/react/24/outline';
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
import { formatCurrency } from '../_utils/format';
interface PaymentsTabProps {
locale: string;
payments: PaymentOverridesController;
/** Takings recorded on the door check-in screen; null while loading or unavailable. */
doorSummary: DoorSummary | null;
}
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }> = {
cash: { en: 'Cash', es: 'Efectivo' },
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
transfer: { en: 'Transfer', es: 'Transferencia' },
pos: { en: 'POS', es: 'POS' },
guest: { en: 'Guests', es: 'Invitados' },
};
/**
* End-of-night reconciliation for this event: what staff took on the door, split
* by tender, next to the pre-sale total. Guests are counted, not totalled — they
* are free and carry no revenue.
*/
function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) {
const es = locale === 'es';
return (
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
<BanknotesIcon className="w-4 h-4 text-emerald-600" />
</div>
<div>
<h4 className="font-semibold text-sm">{es ? 'Ventas en Puerta' : 'Door Sales'}</h4>
<p className="text-[10px] text-gray-500">
{es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'}
</p>
</div>
</div>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency, locale)}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
{DOOR_PAYMENT_METHODS.map((method) => {
const entry = summary.door.byMethod[method];
return (
<div key={method} className="bg-gray-50 rounded-lg px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-gray-500">
{es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en}
</p>
<p className="font-bold text-sm leading-tight">
{method === 'guest'
? `${entry.count}`
: formatCurrency(entry.total, summary.currency, locale)}
</p>
{method !== 'guest' && (
<p className="text-[10px] text-gray-500">
{entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')}
</p>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
<span>
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency, locale)}</strong>
{' '}({summary.presale.count})
</span>
<span>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency, locale)}</strong>
</span>
</div>
</div>
</Card>
);
}
export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) {
const {
loadingPayments,
hasPaymentOverrides,
@@ -31,6 +104,10 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
handleSavePaymentOptions,
} = payments;
// Stored as 0/1 on Postgres; POS is on unless explicitly switched off.
const posEnabled = !!(getEffectivePaymentOption('posEnabled') ?? true);
const posDisabledGlobally = !!globalPaymentOptions && !(globalPaymentOptions.posEnabled ?? true);
return (
<div className="space-y-4">
{loadingPayments ? (
@@ -39,6 +116,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
</div>
) : (
<>
{/* Door takings — reconciliation first, configuration below */}
{doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && (
<DoorTakings locale={locale} summary={doorSummary} />
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
@@ -364,6 +446,42 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
</div>
</Card>
{/* POS terminal at the door */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-violet-100 rounded-full flex items-center justify-center flex-shrink-0">
<CreditCardIcon className="w-4 h-4 text-violet-600" />
</div>
<div>
<h4 className="font-semibold text-sm">POS</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{posDisabledGlobally && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('posEnabled', !posEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
posEnabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
</Card>
{/* Summary */}
<Card>
<div className="p-4 md:p-5">
@@ -376,6 +494,7 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
{ label: 'POS', enabled: posEnabled },
].map((method) => (
<div key={method.label} className="flex items-center gap-1.5">
{method.enabled ? (
@@ -0,0 +1,259 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { TrashIcon, ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import {
financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS,
type EventMember, type EventPermission, type RolePreset,
} from '@/lib/api';
import Button from '@/components/ui/Button';
import { TableSkeleton } from '@/components/ui/Skeleton';
import { Modal, Field, inputClass } from '../_finance/ui';
import { UserSearch, type UserHit } from '../_finance/UserSearch';
/** Overrides after flipping one permission: drop the key when it matches the preset again. */
function toggleOverride(member: EventMember, key: EventPermission): Partial<Record<EventPermission, boolean>> {
const next = { ...member.permissions };
const want = !member.effective.includes(key);
const presetHas = PRESET_PERMISSIONS[member.rolePreset].includes(key);
if (want === presetHas) delete next[key]; else next[key] = want;
return next;
}
function Toggle({ on, overridden, disabled, onClick, label }: { on: boolean; overridden: boolean; disabled?: boolean; onClick: () => void; label: string }) {
return (
<button
role="switch"
aria-checked={on}
aria-label={label}
title={overridden ? label : undefined}
disabled={disabled}
onClick={onClick}
className={clsx(
'relative inline-flex h-6 w-11 md:h-5 md:w-9 flex-shrink-0 rounded-full transition-colors cursor-pointer disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
on ? 'bg-primary-yellow' : 'bg-gray-200',
overridden && 'ring-2 ring-offset-1 ring-blue-400',
)}
>
<span className={clsx('inline-block h-5 w-5 md:h-4 md:w-4 mt-0.5 rounded-full bg-white shadow transition-transform', on ? 'translate-x-[22px] md:translate-x-[18px]' : 'translate-x-0.5')} />
</button>
);
}
export function TeamTab({ eventId }: { eventId: string }) {
const { t } = useLanguage();
const [members, setMembers] = useState<EventMember[] | null>(null);
const [adding, setAdding] = useState(false);
const [picked, setPicked] = useState<UserHit | null>(null);
const [preset, setPreset] = useState<RolePreset>('collaborator');
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setMembers((await financeApi.getMembers(eventId)).members);
} catch (error: any) {
toast.error(error.message);
setMembers([]);
}
}, [eventId]);
useEffect(() => { load(); }, [load]);
const search = useCallback((q: string) => financeApi.searchMemberCandidates(eventId, q), [eventId]);
const update = async (m: EventMember, body: { rolePreset?: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) => {
setBusy(m.id);
try {
const { member } = await financeApi.updateMember(eventId, m.id, body);
setMembers((list) => list?.map((x) => (x.id === m.id ? member : x)) ?? null);
toast.success(t('admin.team.saved'));
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const remove = async (m: EventMember) => {
if (!confirm(t('admin.team.confirmRemove', { name: m.name || m.email || '' }))) return;
setBusy(m.id);
try {
await financeApi.removeMember(eventId, m.id);
setMembers((list) => list?.filter((x) => x.id !== m.id) ?? null);
toast.success(t('admin.team.removed'));
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const add = async () => {
if (!picked) return;
setBusy('add');
try {
await financeApi.addMember(eventId, { userId: picked.id, rolePreset: preset });
toast.success(t('admin.team.added'));
setAdding(false);
setPicked(null);
load();
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
if (members === null) return <TableSkeleton rows={3} cols={4} />;
const PresetSelect = ({ m }: { m: EventMember }) => (
<select
className={clsx(inputClass, 'py-1.5 w-auto')}
value={m.rolePreset}
disabled={busy === m.id}
onChange={(e) => update(m, { rolePreset: e.target.value as RolePreset, permissions: m.permissions })}
>
{ROLE_PRESETS.map((p) => <option key={p} value={p}>{t(`admin.rolePresets.${p}`)}</option>)}
</select>
);
const hasOverrides = (m: EventMember) => Object.keys(m.permissions).length > 0;
return (
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
<p className="text-xs text-gray-500 max-w-2xl">{t('admin.team.hint')}</p>
<Button size="sm" onClick={() => { setPicked(null); setPreset('collaborator'); setAdding(true); }} className="self-start md:self-auto">
{t('admin.team.add')}
</Button>
</div>
{members.length === 0 ? (
<p className="text-sm text-gray-400 py-8 text-center bg-white rounded-card shadow-card">{t('admin.team.empty')}</p>
) : (
<>
{/* Desktop: permission matrix */}
<div className="hidden lg:block bg-white rounded-card shadow-card overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left font-medium text-xs text-gray-500 px-4 py-2.5 sticky left-0 bg-white">{t('admin.finance.split.linkedUser')}</th>
<th className="text-left font-medium text-xs text-gray-500 px-2 py-2.5">{t('admin.team.role')}</th>
{EVENT_PERMISSIONS.map((p) => (
<th key={p} className="px-1 py-2.5 align-bottom">
<span className="block text-[10px] font-medium text-gray-500 leading-tight w-16 mx-auto text-center">{t(`admin.permissions.${p}`)}</span>
</th>
))}
<th />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{members.map((m) => (
<tr key={m.id}>
<td className="px-4 py-3 sticky left-0 bg-white">
<p className="font-medium truncate max-w-[180px]">{m.name}</p>
<p className="text-xs text-gray-500 truncate max-w-[180px]">{m.email}</p>
</td>
<td className="px-2 py-3"><PresetSelect m={m} /></td>
{EVENT_PERMISSIONS.map((p) => (
<td key={p} className="px-1 py-3 text-center">
<Toggle
on={m.effective.includes(p)}
overridden={p in m.permissions}
disabled={busy === m.id}
label={`${t(`admin.permissions.${p}`)}${p in m.permissions ? ` · ${t('admin.team.override')}` : ''}`}
onClick={() => update(m, { permissions: toggleOverride(m, p) })}
/>
</td>
))}
<td className="px-3 py-3 whitespace-nowrap text-right">
{hasOverrides(m) && (
<button onClick={() => update(m, { permissions: {} })} className="p-1.5 rounded-btn hover:bg-gray-100 text-gray-500" title={t('admin.team.reset')} aria-label={t('admin.team.reset')}>
<ArrowPathIcon className="w-4 h-4" />
</button>
)}
<button onClick={() => remove(m)} disabled={busy === m.id} className="p-1.5 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
<TrashIcon className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-gray-500 px-4 py-2 flex items-center gap-2 border-t border-gray-100">
<span className="inline-block w-3 h-3 rounded-full ring-2 ring-blue-400" /> {t('admin.team.override')}
</p>
</div>
{/* Mobile / tablet: one card per member */}
<div className="lg:hidden space-y-3">
{members.map((m) => (
<div key={m.id} className="bg-white rounded-card shadow-card p-4 space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.name}</p>
<p className="text-xs text-gray-500 truncate">{m.email}</p>
</div>
<button onClick={() => remove(m)} disabled={busy === m.id} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
<TrashIcon className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2">
<PresetSelect m={m} />
{hasOverrides(m) && (
<button onClick={() => update(m, { permissions: {} })} className="text-xs text-gray-500 underline">{t('admin.team.reset')}</button>
)}
</div>
<details className="group rounded-btn border border-gray-100">
<summary className="list-none cursor-pointer flex items-center justify-between px-3 min-h-[44px] text-sm [&::-webkit-details-marker]:hidden">
<span className="font-medium">{t('admin.team.permissions')}</span>
<span className="flex items-center gap-1 text-gray-500">
{t('admin.team.permissionsSummary', { on: m.effective.length, total: EVENT_PERMISSIONS.length })}
<ChevronDownIcon className="w-4 h-4 transition-transform group-open:rotate-180" aria-hidden />
</span>
</summary>
<ul className="divide-y divide-gray-100 px-3">
{EVENT_PERMISSIONS.map((p) => (
<li key={p} className="flex items-center justify-between gap-3 text-sm min-h-[48px]">
<span className={clsx(p in m.permissions && 'text-blue-700')}>{t(`admin.permissions.${p}`)}</span>
<Toggle
on={m.effective.includes(p)}
overridden={p in m.permissions}
disabled={busy === m.id}
label={t(`admin.permissions.${p}`)}
onClick={() => update(m, { permissions: toggleOverride(m, p) })}
/>
</li>
))}
</ul>
</details>
</div>
))}
</div>
</>
)}
<Modal open={adding} onClose={() => setAdding(false)} title={t('admin.team.add')}>
{picked ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-btn bg-gray-50 text-sm">
<span className="flex-1 truncate">{picked.name} ({picked.email})</span>
<button onClick={() => setPicked(null)} className="text-xs underline text-gray-500">{t('common.edit')}</button>
</div>
) : (
<UserSearch search={search} onPick={setPicked} placeholder={t('admin.team.search')} noResults={t('admin.team.noResults')} />
)}
<Field label={t('admin.team.role')}>
<select className={inputClass} value={preset} onChange={(e) => setPreset(e.target.value as RolePreset)}>
{ROLE_PRESETS.map((p) => <option key={p} value={p}>{t(`admin.rolePresets.${p}`)}</option>)}
</select>
</Field>
<p className="text-xs text-gray-500">{PRESET_PERMISSIONS[preset].map((p) => t(`admin.permissions.${p}`)).join(' · ')}</p>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setAdding(false)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={add} disabled={!picked} isLoading={busy === 'add'} className="flex-1 min-h-[44px]">{t('admin.team.add')}</Button>
</div>
</Modal>
</div>
);
}
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
import {
MagnifyingGlassIcon,
ChevronDownIcon,
@@ -48,6 +50,20 @@ export function TicketsTab({
handleRemoveCheckin,
setShowTicketExportSheet,
}: TicketsTabProps) {
// Paginated client-side, same as the Attendees tab: the parent keeps the full
// ticket list for the header counts and the export actions.
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const filterKey = `${ticketSearchQuery}|${ticketStatusFilter}`;
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
setPage(1);
}
}, [filterKey]);
const pagedTickets = usePaginatedList(filteredConfirmedTickets, page, pageSize, setPage);
return (
<div className="space-y-3">
{/* Desktop toolbar */}
@@ -152,7 +168,7 @@ export function TicketsTab({
</td>
</tr>
) : (
filteredConfirmedTickets.map((ticket) => (
pagedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
@@ -216,7 +232,7 @@ export function TicketsTab({
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
</div>
) : (
filteredConfirmedTickets.map((ticket) => (
pagedTickets.map((ticket) => (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
@@ -253,6 +269,15 @@ export function TicketsTab({
))
)}
</div>
<Pagination
id="tickets"
page={page}
pageSize={pageSize}
total={filteredConfirmedTickets.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
);
}
+3 -2
View File
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments' | 'finance' | 'team';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
@@ -15,9 +15,10 @@ export interface PrimaryAction {
// Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue
// door = already paid in cash at the door, counts toward revenue, every field optional
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest';
export interface AddTicketFormState {
type: AddTicketType;
@@ -1,9 +1,10 @@
// Pure formatting/IO helpers for the admin event detail page.
export function formatCurrency(amount: number, currency: string): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
import { formatPyg } from '@/lib/money';
/** PYG goes through the shared money helper; `locale` is the UI language (emails keep the default). */
export function formatCurrency(amount: number, currency: string, locale: string = 'es'): string {
if (currency === 'PYG') return formatPyg(amount, locale);
return `$${amount.toFixed(2)} ${currency}`;
}
@@ -0,0 +1,7 @@
import { EventDetailSkeleton } from './_components/EventDetailSkeleton';
// Route-level shell shown the moment an admin opens an event, while the page
// chunk loads. Mirrors the page's own first-load state.
export default function AdminEventDetailLoading() {
return <EventDetailSkeleton backHref="/admin/events" />;
}
+2 -760
View File
@@ -1,767 +1,9 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
ArrowLeftIcon,
CalendarIcon,
MapPinIcon,
CurrencyDollarIcon,
UsersIcon,
TicketIcon,
CheckCircleIcon,
EnvelopeIcon,
PencilIcon,
EyeIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
ArrowUturnLeftIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from './_types';
import { formatCurrency, downloadBlob } from './_utils/format';
import { useEventDetailData } from './_hooks/useEventDetailData';
import { usePaymentOverrides } from './_hooks/usePaymentOverrides';
import { OverviewTab } from './_tabs/OverviewTab';
import { AttendeesTab } from './_tabs/AttendeesTab';
import { TicketsTab } from './_tabs/TicketsTab';
import { EmailTab } from './_tabs/EmailTab';
import { PaymentsTab } from './_tabs/PaymentsTab';
import { EventModals } from './_modals/EventModals';
import { AddTicketModal } from './_modals/AddTicketModal';
import EventFormModal from '../_components/EventFormModal';
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
import { EventDetailView } from './_components/EventDetailView';
export default function AdminEventDetailPage() {
const params = useParams();
const eventId = params.id as string;
const { locale } = useLanguage();
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Email state
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
const [customMessage, setCustomMessage] = useState('');
const [sending, setSending] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
const [exporting, setExporting] = useState(false);
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
// Tickets tab state
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>('all');
// Payment options state + handlers
const payments = usePaymentOverrides(eventId, locale);
// Edit event modal (opens in place instead of redirecting to the list page)
const [showEditForm, setShowEditForm] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
useEffect(() => {
siteSettingsApi
.get()
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
.catch(() => {});
}, []);
// Mobile-specific state
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
// Tab bar ref for sticky
const tabBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeTab === 'payments') {
payments.loadPaymentOptions();
}
}, [activeTab]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getTicketsByStatus = (status: string) => {
return tickets.filter(t => t.status === status);
};
const getFilteredRecipientCount = () => {
if (recipientFilter === 'all') return tickets.length;
return getTicketsByStatus(recipientFilter).length;
};
const handleMarkPaid = async (ticketId: string) => {
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
}
};
const handleCheckin = async (ticketId: string) => {
try {
await ticketsApi.checkin(ticketId);
toast.success('Attendee checked in');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
}
};
const handleReactivate = async (ticket: Ticket) => {
if (!ticket.payment?.id) return;
try {
await paymentsApi.reactivate(ticket.payment.id);
toast.success('Booking reactivated');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to reactivate booking');
}
};
const handleRemoveCheckin = async (ticketId: string) => {
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
try {
await ticketsApi.removeCheckin(ticketId);
toast.success('Check-in removed');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to remove check-in');
}
};
const handleOpenNoteModal = (ticket: Ticket) => {
setSelectedTicket(ticket);
setNoteText(ticket.adminNote || '');
setShowNoteModal(true);
};
const handleSaveNote = async () => {
if (!selectedTicket) return;
setSubmitting(true);
try {
await ticketsApi.updateNote(selectedTicket.id, noteText);
toast.success('Note saved');
setShowNoteModal(false);
setSelectedTicket(null);
setNoteText('');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to save note');
} finally {
setSubmitting(false);
}
};
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
const res = await ticketsApi.adminAdd({
eventId: event.id,
type: addTicketForm.type,
firstName: addTicketForm.firstName,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return;
setExporting(true);
setShowExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export attendees');
} finally {
setExporting(false);
}
};
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
if (!event) return;
setExporting(true);
setShowTicketExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export tickets');
} finally {
setExporting(false);
}
};
// Filtered tickets for attendees tab
const filteredTickets = tickets.filter((ticket) => {
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
if (ticketSearchQuery) {
const query = ticketSearchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
const handlePreviewEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
try {
const res = await emailsApi.preview({
templateSlug: selectedTemplate,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? fmtTime(event.startDatetime) : '',
eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
customMessage: customMessage || 'Your custom message will appear here.',
},
locale,
});
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview email');
}
};
const handleSendEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
const recipientCount = getFilteredRecipientCount();
if (recipientCount === 0) {
toast.error('No recipients match the selected filter');
return;
}
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
return;
}
setSending(true);
try {
const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate,
recipientFilter,
customVariables: customMessage ? { customMessage } : undefined,
});
if (res.success) {
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else {
toast.error(res.error || 'Failed to queue emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
}
};
if (loading) {
return (
<div>
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<TableSkeleton rows={5} cols={4} />
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Event not found</p>
<Link href="/admin/events">
<Button variant="outline" className="mt-4">
Back to Events
</Button>
</Link>
</div>
);
}
const confirmedCount = getTicketsByStatus('confirmed').length;
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
{ key: 'attendees', label: 'Attendees', icon: UserGroupIcon, count: tickets.length },
{ key: 'tickets', label: 'Tickets', icon: TicketIcon, count: confirmedTickets.length },
{ key: 'email', label: 'Email', icon: EnvelopeIcon },
{ key: 'payments', label: locale === 'es' ? 'Pagos' : 'Payments', icon: CreditCardIcon },
];
// ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
}
return null;
};
return (
<div className="max-w-full overflow-x-hidden">
{/* ============= HEADER ============= */}
<div className="flex items-start gap-3 mb-3">
<Link href="/admin/events" className="mt-1 flex-shrink-0">
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<ArrowLeftIcon className="w-5 h-5" />
</button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(event.startDatetime)}</p>
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
<PencilIcon className="w-4 h-4 mr-1.5" />
Edit
</Button>
</div>
{/* Mobile header overflow menu */}
<div className="md:hidden flex-shrink-0">
<Dropdown
open={mobileHeaderMenuOpen}
onOpenChange={setMobileHeaderMenuOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
</Dropdown>
</div>
</div>
{/* ============= COMPACT META CHIPS (desktop) ============= */}
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" />
{event.location}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
</span>
{showStats && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<UsersIcon className="w-3.5 h-3.5" />
{confirmedCount + checkedInCount}/{event.capacity}
</span>
)}
</div>
{/* ============= STATS ROW ============= */}
{/* Desktop: always-visible compact 4-card row */}
<div className="hidden md:block mb-4">
{showStats && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' },
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Mobile: collapsible stats */}
<div className="md:hidden mb-3">
{showStats && (
<div>
<button
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
>
<div className="flex items-center gap-3 overflow-x-auto">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Stats</span>
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{checkedInCount} in</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{formatCurrency(revenue, event.currency)}</span>
</div>
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
</button>
{mobileStatsExpanded && (
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' },
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ============= TAB BAR ============= */}
{/* Desktop: tab bar inside a card top-section */}
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
<nav className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
activeTab === tab.key
? 'text-primary-dark'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
{activeTab === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
</div>
{/* Mobile: segmented tab bar */}
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
<div className="overflow-x-auto scrollbar-hide">
<div className="flex gap-0.5 min-w-max">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap min-h-[36px] flex items-center gap-1',
activeTab === tab.key
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
</button>
))}
</div>
</div>
</div>
{/* ============= TAB CONTENT ============= */}
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
{activeTab === 'overview' && (
<OverviewTab
event={event}
formatDate={formatDate}
fmtTime={fmtTime}
formatCurrency={formatCurrency}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
/>
)}
{activeTab === 'attendees' && (
<AttendeesTab
locale={locale}
tickets={tickets}
filteredTickets={filteredTickets}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting}
showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown}
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab
locale={locale}
confirmedTickets={confirmedTickets}
filteredConfirmedTickets={filteredConfirmedTickets}
ticketSearchQuery={ticketSearchQuery}
setTicketSearchQuery={setTicketSearchQuery}
ticketStatusFilter={ticketStatusFilter}
setTicketStatusFilter={setTicketStatusFilter}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
exporting={exporting}
showTicketExportDropdown={showTicketExportDropdown}
setShowTicketExportDropdown={setShowTicketExportDropdown}
handleExportTickets={handleExportTickets}
handleCheckin={handleCheckin}
handleRemoveCheckin={handleRemoveCheckin}
setShowTicketExportSheet={setShowTicketExportSheet}
/>
)}
{activeTab === 'email' && (
<EmailTab
templates={templates}
selectedTemplate={selectedTemplate}
setSelectedTemplate={setSelectedTemplate}
recipientFilter={recipientFilter}
setRecipientFilter={setRecipientFilter}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
sending={sending}
handlePreviewEmail={handlePreviewEmail}
handleSendEmail={handleSendEmail}
getFilteredRecipientCount={getFilteredRecipientCount}
ticketsCount={tickets.length}
confirmedCount={confirmedCount}
pendingCount={pendingCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
/>
)}
{activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} />
)}
</div>
{/* ============= MODALS ============= */}
<EventModals
ticketsCount={tickets.length}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen}
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
selectedTicket={selectedTicket}
setSelectedTicket={setSelectedTicket}
noteText={noteText}
setNoteText={setNoteText}
handleSaveNote={handleSaveNote}
previewHtml={previewHtml}
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
/>
<EventFormModal
open={showEditForm}
event={event}
featuredEventId={featuredEventId}
onFeaturedChange={setFeaturedEventId}
onClose={() => setShowEditForm(false)}
onSaved={() => { setShowEditForm(false); loadEventData(); }}
/>
<AdminMobileStyles />
</div>
);
return <EventDetailView eventId={params.id as string} backHref="/admin/events" />;
}
@@ -6,10 +6,11 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker';
import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
interface EventFormData {
title: string;
@@ -23,23 +24,47 @@ interface EventFormData {
endDatetime: string;
location: string;
locationUrl: string;
series: string;
price: number;
// Kept as the raw input so "empty" (not set -> falls back to price) stays
// distinguishable from 0 (a free walk-in).
walkInPrice: string;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string;
externalBookingEnabled: boolean;
externalBookingUrl: string;
presaleClosureEnabled: boolean;
presaleCloseMinutesBefore: number;
}
// Site-wide pre-sale defaults, used to pre-fill events that haven't overridden them
interface PresaleDefaults {
enabled: boolean;
minutesBefore: number;
}
const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 };
const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', series: '',
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
function isValidWalkInPrice(value: string): boolean {
const trimmed = value.trim();
if (trimmed === '') return true;
const n = Number(trimmed);
return Number.isInteger(n) && n >= 0;
}
function isoToLocalDatetime(isoString: string): string {
const date = parseDate(isoString);
const parts = new Intl.DateTimeFormat('en-US', {
@@ -89,6 +114,30 @@ export default function EventFormModal({
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [saving, setSaving] = useState(false);
const [settingFeatured, setSettingFeatured] = useState(false);
const [presaleDefaults, setPresaleDefaults] = useState<PresaleDefaults>(FALLBACK_PRESALE_DEFAULTS);
useEffect(() => {
if (!open) return;
let cancelled = false;
// Site defaults fill in whatever the event hasn't overridden (null = inherit)
siteSettingsApi.get()
.then(({ settings }) => {
if (cancelled) return;
const defaults: PresaleDefaults = {
enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled,
minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
setPresaleDefaults(defaults);
setFormData((prev) => ({
...prev,
presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled,
presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore,
}));
})
.catch(() => { /* keep fallback defaults */ });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, event]);
useEffect(() => {
if (!open) return;
@@ -99,11 +148,14 @@ export default function EventFormModal({
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '',
location: event.location, locationUrl: event.locationUrl || '', series: event.series || '',
price: event.price, currency: event.currency, capacity: event.capacity,
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled,
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore,
});
loadSlugAliases(event.id);
} else {
@@ -161,6 +213,19 @@ export default function EventFormModal({
setSaving(false);
return;
}
if (
formData.presaleClosureEnabled &&
(!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0)
) {
toast.error('Pre-sale closure time must be zero or more');
setSaving(false);
return;
}
if (!isValidWalkInPrice(formData.walkInPrice)) {
toast.error(t('admin.events.form.walkInPriceInvalid'));
setSaving(false);
return;
}
const eventData: Partial<Event> = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
@@ -168,10 +233,16 @@ export default function EventFormModal({
startDatetime: formData.startDatetime,
endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
series: formData.series.trim() || null,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
// Empty means "not set" and must be saved as null, never 0
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
// Saving from the modal always writes explicit values (overrides the site default)
presaleClosureEnabled: formData.presaleClosureEnabled,
presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore,
};
if (event) {
// Only send slug when editing so creates still auto-generate from title
@@ -291,18 +362,36 @@ export default function EventFormModal({
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
<div>
<Input label={t('admin.eventForm.series')} value={formData.series} maxLength={100} placeholder="Morning Club"
onChange={(e) => setFormData({ ...formData, series: e.target.value })} />
<p className="text-xs text-gray-500 mt-1">{t('admin.eventForm.seriesHint')}</p>
</div>
<div className="grid grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
value={formData.walkInPrice}
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
placeholder={t('admin.events.form.walkInPricePlaceholder', {
price: formatPrice(formData.price || 0, formData.currency),
})}
error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} />
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">{t('admin.events.form.walkInPriceHelp')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input label="Capacity" type="number" min="1" value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
</div>
@@ -346,6 +435,35 @@ export default function EventFormModal({
)}
</div>
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">Pre-sale Closure</label>
<p className="text-xs text-gray-500">Stop online registration before the event starts</p>
</div>
<button type="button"
onClick={() => setFormData({ ...formData, presaleClosureEnabled: !formData.presaleClosureEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
formData.presaleClosureEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`}>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
formData.presaleClosureEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
{formData.presaleClosureEnabled && (
<DurationInput label="Close pre-sale this long before the start"
valueMinutes={formData.presaleCloseMinutesBefore}
onChange={(minutes) => setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} />
)}
<p className="text-xs text-gray-500">
Site default: {presaleDefaults.enabled
? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start`
: 'off'}
{' '}(Admin › Settings › General).
</p>
</div>
<MediaPicker value={formData.bannerUrl}
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
relatedId={event?.id} relatedType="event" />
@@ -0,0 +1,83 @@
import Card from '@/components/ui/Card';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
// Loading placeholders for the admin events list. The page keeps its title,
// Create button and table header on screen and swaps only the rows for these,
// so nothing jumps when the data lands. loading.tsx composes the same pieces
// for the instant route-transition shell.
const TH = 'px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider';
export function EventsTableHead() {
return (
<thead className="bg-secondary-gray">
<tr>
<th className={`text-left ${TH}`}>Event</th>
<th className={`text-left ${TH}`}>Date</th>
<th className={`text-left ${TH}`}>Capacity</th>
<th className={`text-left ${TH}`}>Status</th>
<th className={`text-right ${TH}`}>Actions</th>
</tr>
</thead>
);
}
/** Desktop table body rows: thumbnail + title/location, date, capacity, badge, actions. */
export function EventRowsSkeleton({ rows = 8 }: { rows?: number }) {
return (
<tbody className="divide-y divide-secondary-light-gray" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Skeleton className="w-10 h-10 flex-shrink-0" />
<div className="flex-1">
<Skeleton className={i % 2 === 0 ? 'h-4 w-48' : 'h-4 w-36'} />
<Skeleton className="mt-1.5 h-3 w-28" />
</div>
</div>
</td>
<td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-14" /></td>
<td className="px-4 py-3"><Skeleton className="h-5 w-20 rounded-full" /></td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Skeleton className="w-8 h-8 rounded-btn" />
<Skeleton className="w-8 h-8 rounded-btn" />
<Skeleton className="w-8 h-8 rounded-btn" />
</div>
</td>
</tr>
))}
</tbody>
);
}
/** Mobile card list: thumbnail, three text lines, status badge, footer row. */
export function EventCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SkeletonGroup className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<Card key={i} className="p-3" aria-hidden="true">
<div className="flex items-start gap-3">
<Skeleton className="w-14 h-14 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="mt-1.5 h-3 w-1/3" />
<Skeleton className="mt-1.5 h-3 w-1/2" />
</div>
<Skeleton className="h-5 w-16 rounded-full flex-shrink-0" />
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<Skeleton className="h-3 w-20" />
<Skeleton className="w-9 h-9 rounded-btn" />
</div>
</div>
</div>
</Card>
))}
</SkeletonGroup>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useLanguage } from '@/context/LanguageContext';
import Card from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
// Route-level shell shown the moment an admin navigates here, while the page
// chunk loads. The title and table header are real; only the rows are placeholders.
export default function AdminEventsLoading() {
const { t } = useLanguage();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<Card className="overflow-hidden hidden md:block">
<table className="w-full" aria-busy="true">
<EventsTableHead />
<EventRowsSkeleton />
</table>
</Card>
<div className="md:hidden">
<EventCardsSkeleton />
</div>
</div>
);
}
+66 -24
View File
@@ -1,13 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
@@ -15,39 +14,78 @@ import toast from 'react-hot-toast';
import clsx from 'clsx';
import { parseDate } from '@/lib/utils';
import EventFormModal from './_components/EventFormModal';
import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
import Pagination from '@/components/admin/Pagination';
export default function AdminEventsPage() {
const router = useRouter();
const { t, locale } = useLanguage();
const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [loading, setLoading] = useState(true);
// True while the rows on screen are not the rows for the current page (first
// load, page or page-size change). Reloads after a mutation keep the old rows
// up instead, so the table does not flash after every action.
const [fetching, setFetching] = useState(true);
const requestId = useRef(0);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
useEffect(() => {
loadEvents();
loadFeaturedEvent();
}, []);
useEffect(() => {
setFetching(true);
loadEvents();
}, [page, pageSize]);
// The ?edit=<id> deep link may point at an event that is not on the current
// page, so fall back to fetching it directly instead of only scanning the page.
const handledEditId = useRef<string | null>(null);
useEffect(() => {
const editId = searchParams.get('edit');
if (editId && events.length > 0) {
const event = events.find(e => e.id === editId);
if (event) handleEdit(event);
if (!editId || handledEditId.current === editId) return;
const event = events.find(e => e.id === editId);
if (event) {
handledEditId.current = editId;
handleEdit(event);
return;
}
}, [searchParams, events]);
if (loading) return;
handledEditId.current = editId;
eventsApi.getById(editId)
.then(({ event }) => handleEdit(event))
.catch(() => toast.error('Event not found'));
}, [searchParams, events, loading]);
const loadEvents = async () => {
// Drop responses that arrive after a newer request (e.g. a slow page 1
// landing after the user has already moved to page 2).
const id = ++requestId.current;
try {
const { events } = await eventsApi.getAll();
const { events, total } = await eventsApi.getAll({ page, pageSize });
if (id !== requestId.current) return;
setEvents(events);
setTotal(total ?? events.length);
// If the current page emptied out (e.g. after deleting its last event),
// fall back to the new last page.
if (events.length === 0 && (total ?? 0) > 0 && page > 1) {
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
}
} catch (error) {
if (id !== requestId.current) return;
toast.error('Failed to load events');
} finally {
setLoading(false);
if (id === requestId.current) {
setLoading(false);
setFetching(false);
}
}
};
@@ -148,10 +186,6 @@ export default function AdminEventsPage() {
}
};
if (loading) {
return <AdminPageSkeleton cols={5} />;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -174,16 +208,11 @@ export default function AdminEventsPage() {
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<table className="w-full" aria-busy={fetching}>
<EventsTableHead />
{fetching ? (
<EventRowsSkeleton rows={Math.min(pageSize, 8)} />
) : (
<tbody className="divide-y divide-secondary-light-gray">
{events.length === 0 ? (
<tr>
@@ -295,13 +324,16 @@ export default function AdminEventsPage() {
))
)}
</tbody>
)}
</table>
</div>
</Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{events.length === 0 ? (
{fetching ? (
<EventCardsSkeleton />
) : events.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No events found. Create your first event!
</div>
@@ -401,6 +433,16 @@ export default function AdminEventsPage() {
)}
</div>
<Pagination
id="events"
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
@@ -0,0 +1,86 @@
'use client';
// Profit per event. Diverging by sign: blue = profit, red = loss, one axis,
// event names on the category axis so color is never the only cue. A bar far
// larger than the rest is clipped at the axis limit, marked with a break and
// labelled with its real value, so the other events stay readable.
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine, LabelList } from 'recharts';
import { formatPyg, formatPygShort } from '@/lib/money';
import { clampRows, clampedAxis } from './clamp';
export default function ProfitByEventChart({ rows, locale }: { rows: { id: string; name: string; profit: number }[]; locale: string }) {
const { rows: clamped, limit } = clampRows(rows);
const axis = limit !== null ? clampedAxis(clamped.map((c) => c.shown)) : null;
// Clipped bars run to the (rounded) end of the axis so the cut is obvious.
const data = clamped.map((c) => ({
...c.row,
clipped: c.clipped,
shown: c.clipped && axis ? (c.row.profit > 0 ? axis.domain[1] : axis.domain[0]) : c.shown,
}));
// Break mark plus the real value inside the clipped end of the bar.
const renderClipLabel = (props: any) => {
const { x, y, width, height, index } = props;
const d = data[index];
if (!d?.clipped) return null;
const left = Math.min(x, x + width);
const right = Math.max(x, x + width);
const positive = d.profit > 0;
const edge = positive ? right : left;
const breakX = positive ? edge - 10 : edge + 10;
const text = `${formatPygShort(d.profit, locale)} PYG`;
return (
<g pointerEvents="none">
<path d={`M${breakX - 3},${y + height + 2} L${breakX + 1},${y - 2} M${breakX + 2},${y + height + 2} L${breakX + 6},${y - 2}`} stroke="#fff" strokeWidth={2} />
<text
x={positive ? breakX - 6 : breakX + 10}
y={y + height / 2}
dy={4}
textAnchor={positive ? 'end' : 'start'}
fontSize={11}
fontWeight={600}
fill="#fff"
>
{text}
</text>
</g>
);
};
return (
<div>
<ResponsiveContainer width="100%" height={Math.max(160, rows.length * 30 + 32)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke="#eeeeec" />
<XAxis
type="number"
domain={axis ? axis.domain : ['auto', 'auto']}
ticks={axis?.ticks}
allowDataOverflow={axis !== null}
stroke="#8a8984"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => formatPygShort(v, locale)}
/>
<YAxis type="category" dataKey="name" stroke="#8a8984" fontSize={11} tickLine={false} axisLine={false} width={150} tick={{ fill: '#52514e', fontSize: 11 }} />
<ReferenceLine x={0} stroke="#8a8984" />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
content={({ active, payload }: any) => active && payload?.length ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
<p className="font-semibold text-primary-dark">{payload[0].payload.name}</p>
<p className="tabular-nums text-gray-600">{formatPyg(payload[0].payload.profit, locale)}</p>
</div>
) : null}
/>
<Bar dataKey="shown" barSize={14} radius={4} isAnimationActive={false}>
{data.map((r) => <Cell key={r.id} fill={r.profit >= 0 ? '#2a78d6' : '#e34948'} />)}
{limit !== null && <LabelList dataKey="shown" content={renderClipLabel} />}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { clampLimit, clampRows, clampedAxis, niceCeil } from './clamp';
describe('clampLimit', () => {
it('does nothing when values are comparable', () => {
expect(clampLimit([100000, 250000, -80000, 190000])).toBeNull();
});
it('clips a single large outlier just above the next bar', () => {
expect(clampLimit([12000000, 300000, 250000, -100000])).toBe(375000);
});
it('treats a large loss as an outlier too', () => {
expect(clampLimit([-9000000, 200000, 150000])).toBe(250000);
});
it('clips up to three outliers above the first big gap', () => {
expect(clampLimit([50e6, 40e6, 30e6, 200000, 150000])).toBe(250000);
expect(clampLimit([8e6, 3e6, 1.1e6, 400000, 150000])).toBe(3750000);
});
it('leaves a steadily spread-out series alone', () => {
expect(clampLimit([8e6, 4e6, 2e6, 1e6, 500000])).toBeNull();
});
it('does not clip when the gap is below the fourth bar', () => {
expect(clampLimit([4e6, 3e6, 2e6, 1.5e6, 100000])).toBeNull();
});
it('needs at least three non-zero bars', () => {
expect(clampLimit([12000000, 1000])).toBeNull();
expect(clampLimit([12000000, 0, 0, 1000])).toBeNull();
});
});
describe('clampRows', () => {
it('keeps the real value and marks clipped rows', () => {
const { rows, limit } = clampRows([
{ id: 'a', profit: 12000000 }, { id: 'b', profit: 300000 }, { id: 'c', profit: -250000 },
]);
expect(limit).toBe(375000);
expect(rows[0]).toMatchObject({ shown: 375000, clipped: true, row: { profit: 12000000 } });
expect(rows[1]).toMatchObject({ shown: 300000, clipped: false });
expect(rows[2]).toMatchObject({ shown: -250000, clipped: false });
});
});
describe('clampedAxis', () => {
it('rounds the ends up to round numbers', () => {
expect(niceCeil(375000)).toBe(500000);
expect(niceCeil(250000)).toBe(250000);
expect(niceCeil(105)).toBe(200);
expect(niceCeil(0)).toBe(0);
});
it('puts ticks at the ends, halves and zero', () => {
expect(clampedAxis([-375000, 120000, 375000])).toEqual({ domain: [-500000, 500000], ticks: [-500000, -250000, 0, 250000, 500000] });
expect(clampedAxis([10, 375])).toEqual({ domain: [0, 500], ticks: [0, 250, 500] });
});
});
@@ -0,0 +1,57 @@
// Axis clamping for the profit-per-event chart: one huge event would otherwise
// flatten every other bar. Pure so it can be unit tested.
/** How much bigger than the next bar a bar must be to count as an outlier. */
const OUTLIER_RATIO = 2.5;
/** Never clip more than this many bars; past that the data is just spread out. */
const MAX_CLIPPED = 3;
/**
* The axis limit (absolute PYG) to clip at, or null when nothing stands out.
* Walks the largest magnitudes from the top and clips above the first big gap.
*/
export function clampLimit(values: number[]): number | null {
const mags = values.map((v) => Math.abs(v)).filter((v) => v > 0).sort((a, b) => b - a);
if (mags.length < 3) return null;
for (let k = 1; k <= Math.min(MAX_CLIPPED, mags.length - 2); k++) {
if (mags[k - 1] > OUTLIER_RATIO * mags[k]) return Math.round(mags[k] * 1.25);
}
return null;
}
export interface ClampedRow<R> {
row: R;
/** Value drawn on the chart (clipped to the limit). */
shown: number;
/** True when the bar is cut off; its real value is labelled on it. */
clipped: boolean;
}
export function clampRows<R extends { profit: number }>(rows: R[]): { rows: ClampedRow<R>[]; limit: number | null } {
const limit = clampLimit(rows.map((r) => r.profit));
return {
limit,
rows: rows.map((row) => {
const clipped = limit !== null && Math.abs(row.profit) > limit;
return { row, shown: clipped ? Math.sign(row.profit) * limit! : row.profit, clipped };
}),
};
}
/** Smallest "round" number (1, 2, 2.5 or 5 times a power of ten) at or above n, for axis ends. */
export function niceCeil(n: number): number {
if (n <= 0) return 0;
const pow = 10 ** Math.floor(Math.log10(n));
const step = [1, 2, 2.5, 5, 10].find((s) => s * pow >= n)!;
return step * pow;
}
/** Axis domain and ticks for a clipped chart: round ends, ticks at halves. */
export function clampedAxis(shown: number[]): { domain: [number, number]; ticks: number[] } {
const max = Math.max(0, ...shown);
const min = Math.min(0, ...shown);
const hi = niceCeil(max);
const lo = min < 0 ? -niceCeil(-min) : 0;
const ticks = [lo, lo / 2, 0, hi / 2, hi].filter((v, i, a) => a.indexOf(v) === i);
return { domain: [lo, hi], ticks };
}
+294
View File
@@ -0,0 +1,294 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { ChartBarIcon, EyeSlashIcon, FunnelIcon, ChevronDownIcon, ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { financeApi, type FinanceOverview, type FinanceTotals } from '@/lib/api';
import Card from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { Money, FinanceStatusBadge, DateInput, inputClass } from '../events/[id]/_finance/ui';
import { tCount } from '../events/[id]/_finance/derive';
import { clampLimit } from './_components/clamp';
const ProfitByEventChart = dynamic(() => import('./_components/ProfitByEventChart'), { ssr: false, loading: () => <Skeleton className="h-40" /> });
type Filters = { from: string; to: string; series: string; venue: string; partner: string };
function TotalsTable({ rows, label, t }: { rows: (FinanceTotals & { key: string; name: string })[]; label: string; t: (k: string) => string }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 border-b border-gray-100">
<tr>
<th className="text-left font-medium py-2 pr-3">{label}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.events')}</th>
{/* Phones: gross and expenses fold away so the table fits without scrolling */}
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.kpi.gross')}</th>
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.kpi.expenses')}</th>
<th className="text-right font-medium py-2 pl-3">{t('admin.finance.kpi.profit')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((r) => (
<tr key={r.key}>
<td className="py-2 pr-3 truncate max-w-[220px]">{r.name}</td>
<td className="py-2 px-3 text-right tabular-nums">{r.events}</td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={r.gross} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={r.expenses} /></td>
<td className="py-2 pl-3 text-right whitespace-nowrap"><Money value={r.profit} className={clsx('font-semibold', r.profit < 0 && 'text-red-600')} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function AdminFinancePage() {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const [filters, setFilters] = useState<Filters>({ from: '', to: '', series: '', venue: '', partner: '' });
const [data, setData] = useState<FinanceOverview | null>(null);
const [loading, setLoading] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [readyOnly, setReadyOnly] = useState(false);
const activeFilters = Object.values(filters).filter(Boolean).length;
useEffect(() => {
setLoading(true);
financeApi.overview(filters)
.then(setData)
.catch((error) => toast.error(error.message))
.finally(() => setLoading(false));
}, [filters]);
const set = (k: keyof Filters, v: string) => setFilters((f) => ({ ...f, [k]: v }));
const title = (e: { title: string; titleEs: string | null }) => (locale === 'es' && e.titleEs) || e.title;
// Past events whose books are still open: the server lists them, including
// ones with no money recorded yet (which the main list leaves out).
const readyIds = new Set(data?.readyToClose.map((e) => e.id) ?? []);
const isReady = (e: { id: string }) => readyIds.has(e.id);
const readyCount = readyIds.size;
const showReady = readyOnly && readyCount > 0;
const tableEvents = data ? (showReady ? data.readyToClose : data.events) : [];
const chartRows = data ? data.events.slice(0, 30).map((e) => ({ id: e.id, name: title(e), profit: e.profit })) : [];
const clipped = clampLimit(chartRows.map((r) => r.profit)) !== null;
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-3">
<ChartBarIcon className="w-7 h-7" />
{t('admin.financeOverview.title')}
</h1>
<p className="text-gray-500 mt-1 text-sm">{t('admin.financeOverview.subtitle')}</p>
</div>
{/* Filters: one row above everything they affect */}
<Card className="p-4">
{/* Phones: filters fold away behind one button */}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
aria-expanded={filtersOpen}
className="md:hidden w-full flex items-center gap-2 min-h-[44px] text-sm font-medium cursor-pointer"
>
<FunnelIcon className="w-4 h-4" aria-hidden />
{t('admin.financeOverview.filters')}
{activeFilters > 0 && <span className="rounded-full bg-primary-yellow px-2 text-xs">{activeFilters}</span>}
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto transition-transform', filtersOpen && 'rotate-180')} aria-hidden />
</button>
<div className={clsx('grid-cols-2 md:grid-cols-5 gap-3 md:grid', filtersOpen ? 'grid mt-3 md:mt-0' : 'hidden')}>
<label className="text-xs font-medium">
{t('admin.financeOverview.from')}
<DateInput className="mt-1" clearable value={filters.from} onChange={(v) => set('from', v)} />
</label>
<label className="text-xs font-medium">
{t('admin.financeOverview.to')}
<DateInput className="mt-1" clearable value={filters.to} onChange={(v) => set('to', v)} />
</label>
<label className="text-xs font-medium">
{t('admin.financeOverview.series')}
<select className={clsx(inputClass, 'mt-1')} value={filters.series} onChange={(e) => set('series', e.target.value)}>
<option value="">{t('admin.financeOverview.all')}</option>
{data?.filters.series.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="__none__">{t('admin.financeOverview.noSeries')}</option>
</select>
</label>
<label className="text-xs font-medium">
{t('admin.financeOverview.venue')}
<select className={clsx(inputClass, 'mt-1')} value={filters.venue} onChange={(e) => set('venue', e.target.value)}>
<option value="">{t('admin.financeOverview.all')}</option>
{data?.filters.venues.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</label>
<label className="text-xs font-medium col-span-2 md:col-span-1">
{t('admin.financeOverview.partner')}
<select className={clsx(inputClass, 'mt-1')} value={filters.partner} onChange={(e) => set('partner', e.target.value)}>
<option value="">{t('admin.financeOverview.all')}</option>
{data?.filters.partners.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
</select>
</label>
</div>
</Card>
{loading && !data ? <Skeleton className="h-64" /> : !data || (data.events.length === 0 && data.readyToClose.length === 0) ? (
<Card className="p-8 text-center text-sm text-gray-500">{t('admin.financeOverview.empty')}</Card>
) : (
<div className={clsx('space-y-6', loading && 'opacity-60')}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: t('admin.financeOverview.events'), value: <span>{data.totals.events}</span> },
{ label: t('admin.finance.kpi.gross'), value: <Money value={data.totals.gross} /> },
{ label: t('admin.finance.kpi.expenses'), value: <Money value={data.totals.expenses} /> },
{ label: t(data.totals.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit'), value: <Money value={data.totals.profit} className={clsx(data.totals.profit < 0 && !privacyMode && 'text-red-600')} /> },
].map((k) => (
<div key={k.label} className="bg-white rounded-card shadow-card px-4 py-3">
<p className="text-lg font-bold">{k.value}</p>
<p className="text-xs text-gray-500">{k.label}</p>
</div>
))}
</div>
{readyCount > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 rounded-card border border-amber-200 bg-amber-50 text-amber-900 px-4 py-3">
<p className="flex items-center gap-2 text-sm flex-1">
<ClockIcon className="w-5 h-5 flex-shrink-0" aria-hidden />
<span><span className="font-semibold">{tCount(t, 'admin.financeOverview.readyCount', readyCount)}</span> {t('admin.financeOverview.readyHint')}</span>
</p>
<button
onClick={() => setReadyOnly(!readyOnly)}
aria-pressed={readyOnly}
className={clsx(
'self-start sm:self-auto inline-flex items-center gap-1.5 rounded-full px-3 min-h-[36px] text-sm font-medium cursor-pointer border',
readyOnly ? 'bg-amber-900 text-white border-amber-900' : 'bg-white border-amber-300 hover:bg-amber-100',
)}
>
{readyOnly && <XMarkIcon className="w-4 h-4" aria-hidden />}
{t(readyOnly ? 'admin.financeOverview.showAll' : 'admin.financeOverview.showReady')}
</button>
</div>
)}
<Card className="p-4">
<h2 className="text-sm font-semibold mb-3">{t(showReady ? 'admin.financeOverview.readyTitle' : 'admin.financeOverview.perEvent')}</h2>
{showReady || chartRows.length === 0 ? null : privacyMode ? (
<p className="text-sm text-gray-500 flex items-center gap-2 py-6 justify-center"><EyeSlashIcon className="w-5 h-5" />{t('admin.finance.hiddenStats')}</p>
) : (
<>
<ProfitByEventChart rows={chartRows} locale={locale} />
{clipped && <p className="text-xs text-gray-500 mt-1">{t('admin.financeOverview.clippedHint')}</p>}
</>
)}
{/* Phones: one card per event, no sideways scrolling */}
<ul className="md:hidden mt-4 divide-y divide-gray-100">
{tableEvents.map((e) => (
<li key={e.id}>
<Link href={`/admin/events/${e.id}?tab=finance`} className="block py-3 active:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium truncate">{title(e)}</p>
<p className="text-xs text-gray-500 truncate">{fmtDate(e.startDatetime)} · {e.location}</p>
</div>
<Money value={e.profit} className={clsx('font-semibold whitespace-nowrap', e.profit < 0 && 'text-red-600')} />
</div>
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-gray-500">
<span>
{t('admin.finance.kpi.gross')} <Money value={e.gross} /> · {t('admin.finance.kpi.expenses')} <Money value={e.expenses} />
</span>
{isReady(e)
? <span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-amber-100 text-amber-900 whitespace-nowrap">{t('admin.financeOverview.readyBadge')}</span>
: <FinanceStatusBadge status={e.financeStatus} label={t(`admin.finance.status.${e.financeStatus}`)} />}
</div>
</Link>
</li>
))}
</ul>
<div className="hidden md:block mt-4">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 border-b border-gray-100">
<tr>
<th className="text-left font-medium py-2 pr-3">{t('admin.financeOverview.event')}</th>
<th className="text-left font-medium py-2 px-3">{t('admin.financeOverview.series')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.gross')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.expenses')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.profit')}</th>
<th className="text-left font-medium py-2 pl-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tableEvents.map((e) => (
<tr key={e.id}>
<td className="py-2 pr-3">
<Link href={`/admin/events/${e.id}?tab=finance`} className="font-medium hover:underline">{title(e)}</Link>
<p className="text-xs text-gray-500">{fmtDate(e.startDatetime)} · {e.location}</p>
</td>
<td className="py-2 px-3 text-xs text-gray-600">{e.series || '-'}</td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.gross} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.expenses} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.profit} className={clsx('font-semibold', e.profit < 0 && 'text-red-600')} /></td>
<td className="py-2 pl-3">
{isReady(e)
? <span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-900 whitespace-nowrap">{t('admin.financeOverview.readyBadge')}</span>
: <FinanceStatusBadge status={e.financeStatus} label={t(`admin.finance.status.${e.financeStatus}`)} />}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perSeries')}</h2>
<TotalsTable t={t} label={t('admin.financeOverview.series')}
rows={data.bySeries.map((s) => ({ ...s, key: s.series || '__none__', name: s.series || t('admin.financeOverview.noSeries') }))} />
</Card>
<Card className="p-4">
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perVenue')}</h2>
<TotalsTable t={t} label={t('admin.financeOverview.venue')} rows={data.byVenue.map((v) => ({ ...v, key: v.venue, name: v.venue || '-' }))} />
</Card>
</div>
<Card className="p-4">
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perPartner')}</h2>
{data.byPartner.length === 0 ? <p className="text-sm text-gray-400">{t('admin.finance.split.empty')}</p> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 border-b border-gray-100">
<tr>
<th className="text-left font-medium py-2 pr-3">{t('admin.financeOverview.partner')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.events')}</th>
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.split.share')}</th>
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.split.reimbursement')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.paid')}</th>
<th className="text-right font-medium py-2 pl-3">{t('admin.financeOverview.pending')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.byPartner.map((p) => (
<tr key={p.key}>
<td className="py-2 pr-3">{p.name}</td>
<td className="py-2 px-3 text-right tabular-nums">{p.events}</td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={p.share} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={p.reimbursement} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={p.paid} /></td>
<td className="py-2 pl-3 text-right whitespace-nowrap"><Money value={p.pending} className="font-semibold" /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
)}
</div>
);
}
+2
View File
@@ -29,6 +29,7 @@ import {
QuestionMarkCircleIcon,
EyeIcon,
EyeSlashIcon,
ChartBarIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -67,6 +68,7 @@ function AdminLayoutInner({
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.finance'), href: '/admin/finance', icon: ChartBarIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
posEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
allowDuplicateBookings: false,
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
setOptions((prev) => ({ ...prev, [key]: value }));
};
// Stored as 0/1 on Postgres; on unless explicitly switched off.
const posEnabled = !!(options.posEnabled ?? true);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
</div>
</Card>
{/* POS terminal at the door */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-violet-100 rounded-full flex items-center justify-center">
<CreditCardIcon className="w-5 h-5 text-violet-600" />
</div>
<div>
<h3 className="font-semibold text-lg">POS</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
: 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
</p>
</div>
</div>
<button
onClick={() => updateOption('posEnabled', !posEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
posEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</Card>
{/* Booking Settings */}
<Card className="mb-6">
<div className="p-6">
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
{locale === 'es' ? 'Efectivo' : 'Cash'}
</span>
</div>
<div className="flex items-center gap-2">
{posEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={posEnabled ? 'text-gray-900' : 'text-gray-400'}>
POS
</span>
</div>
</div>
</div>
</Card>
+9 -1
View File
@@ -313,6 +313,7 @@ export default function AdminPaymentsPage() {
bank_transfer: BuildingLibraryIcon,
tpago: CreditCardIcon,
bancard: CreditCardIcon,
pos: CreditCardIcon,
};
const Icon = icons[provider] || CreditCardIcon;
return <Icon className="w-4 h-4" />;
@@ -325,6 +326,7 @@ export default function AdminPaymentsPage() {
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
pos: 'POS',
};
return labels[provider] || provider;
};
@@ -736,7 +738,7 @@ export default function AdminPaymentsPage() {
{/* By Provider */}
<div className="bg-secondary-gray rounded-btn p-4">
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {
<p className="text-gray-500">Bancard</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">POS</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG</p>
</div>
</div>
</div>
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() {
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option>
<option value="pos">POS</option>
</select>
</div>
<div className="flex-1 min-w-[200px]">
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() {
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option>
<option value="pos">POS</option>
</select>
</div>
<div className="flex gap-3 pt-2">
@@ -0,0 +1,171 @@
'use client';
import clsx from 'clsx';
import {
CheckCircleIcon,
ArrowUturnLeftIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return '';
return parseDate(checkinAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'cash',
bitcoin: 'bitcoin',
transfer: 'transfer',
pos: 'POS',
guest: 'guest',
};
/** The second line of a row: everything staff needs to decide in one glance. */
function statusLine(attendee: DoorAttendee, currency: string): string {
if (attendee.status === 'cancelled') return 'Cancelled';
if (attendee.checkedIn) {
const time = checkinTime(attendee.checkinAt);
const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : '';
return time ? `Checked in ${time}${how}` : `Checked in${how}`;
}
const parts: string[] = [];
if (attendee.paymentStatus === 'comp') parts.push('Guest');
else if (attendee.paymentStatus === 'paid') parts.push('Paid');
else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`);
if (attendee.isGroupBooking) parts.push('group booking');
if (attendee.status === 'pending') parts.push('pending');
return parts.join(' · ');
}
export function AttendeeRow({
attendee,
currency,
price,
methods,
canOverrideAmount,
expanded,
flashing,
busy,
onTap,
onPay,
}: {
attendee: DoorAttendee;
currency: string;
price: number;
methods: readonly DoorPaymentMethod[];
canOverrideAmount: boolean;
expanded: boolean;
flashing: boolean;
busy: boolean;
onTap: () => void;
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
}) {
const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
// A settled, not-yet-arrived attendee is the one-tap case: the whole row checks
// them in. Everyone else opens the tenders inline instead.
const isOneTap = !isCancelled && !attendee.checkedIn && settled;
return (
<div
className={clsx(
'rounded-2xl border transition-colors',
flashing
? 'bg-emerald-600 border-emerald-400'
: attendee.checkedIn || isCancelled
? 'bg-gray-900 border-gray-800'
: 'bg-gray-800 border-gray-700',
)}
>
<button
onClick={onTap}
disabled={busy}
className="w-full text-left px-4 py-3 min-h-[64px] flex items-center gap-3 active:scale-[0.99] transition-transform disabled:opacity-60"
>
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-bold text-lg truncate',
flashing ? 'text-white' : attendee.checkedIn || isCancelled ? 'text-gray-400' : 'text-white',
)}
>
{attendee.fullName}
</p>
<p
className={clsx(
'text-sm truncate flex items-center gap-1.5',
flashing
? 'text-emerald-50'
: isCancelled
? 'text-red-400'
: attendee.checkedIn
? 'text-gray-500'
: attendee.paymentStatus === 'unpaid'
? 'text-amber-400'
: 'text-gray-400',
)}
>
{attendee.isGroupBooking && !attendee.checkedIn && <UserGroupIcon className="w-4 h-4 flex-shrink-0" />}
{statusLine(attendee, currency)}
</p>
</div>
{flashing ? (
<CheckCircleIcon className="w-8 h-8 text-white flex-shrink-0" />
) : attendee.checkedIn ? (
<CheckCircleIcon className="w-7 h-7 text-emerald-500/60 flex-shrink-0" />
) : isCancelled ? (
<span className="flex-shrink-0 text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded-full bg-red-950 text-red-400">
Cancelled
</span>
) : isOneTap ? (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-primary-yellow">
Check in
</span>
) : (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-amber-400">
Collect
</span>
)}
</button>
{expanded && !attendee.checkedIn && (
<div className="px-3 pb-3 pt-1 space-y-2">
{isCancelled && (
<p className="text-xs text-gray-400 px-1 flex items-center gap-1.5">
<ArrowUturnLeftIcon className="w-4 h-4" />
Reactivate as a walk-in — pick how they are paying.
</p>
)}
<PaymentButtons
price={price}
currency={currency}
methods={methods}
canOverrideAmount={canOverrideAmount}
onPay={onPay}
disabled={busy}
/>
</div>
)}
{expanded && attendee.checkedIn && (
<div className="px-4 pb-3 -mt-1">
<p className="text-sm text-gray-400">
Already checked in
{attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''}
{attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,239 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import {
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
CreditCardIcon,
GiftIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
import { PosChargePanel } from './PosChargePanel';
// The tenders staff can take at the door. One tap settles and checks in;
// long-press (or the chevron) opens multiples for someone paying for their group.
// POS is the exception: it opens a confirm step, because the card is charged on
// the terminal first (see PosChargePanel).
/**
* What staff chose to charge. The server prices it from quantity; `amount` is
* what the screen expects that to come to, and is only sent as a charge when
* `override` is set (admin/organizer "Custom").
*/
export interface DoorCharge {
quantity: number;
amount: number;
override?: boolean;
}
const TENDERS: {
method: DoorPaymentMethod;
label: string;
icon: typeof BanknotesIcon;
className: string;
}[] = [
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
{ method: 'pos', label: 'POS', icon: CreditCardIcon, className: 'bg-violet-600 active:bg-violet-700' },
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
];
const LONG_PRESS_MS = 450;
export function PaymentButtons({
price,
currency,
methods,
canOverrideAmount,
onPay,
disabled,
}: {
/** Unit price for one ticket, as the server will charge it. */
price: number;
currency: string;
/** Tenders enabled for this event. */
methods: readonly DoorPaymentMethod[];
/** Admin/organizer may type a custom amount; the server enforces the same rule. */
canOverrideAmount: boolean;
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
disabled?: boolean;
}) {
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customValue, setCustomValue] = useState('');
// POS charge waiting for staff to confirm the terminal approved it.
const [posCharge, setPosCharge] = useState<DoorCharge | null>(null);
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [longPressed, setLongPressed] = useState(false);
const tenders = TENDERS.filter((t) => methods.includes(t.method));
const openAmounts = (method: DoorPaymentMethod) => {
if (method === 'guest') return;
setAmountsFor(method);
setCustomOpen(false);
setCustomValue('');
};
// Every tender settles immediately except POS, which stops at its confirm step.
const charge = (method: DoorPaymentMethod, next: DoorCharge) => {
if (method === 'pos') {
setPosCharge(next);
return;
}
onPay(method, next);
};
const startPress = (method: DoorPaymentMethod) => {
setLongPressed(false);
const timer = setTimeout(() => {
setLongPressed(true);
openAmounts(method);
}, LONG_PRESS_MS);
setPressTimer(timer);
};
const endPress = (method: DoorPaymentMethod) => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
// A long press already opened the multiples; don't also charge 1x on release.
if (longPressed) {
setLongPressed(false);
return;
}
if (disabled) return;
charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
};
const cancelPress = () => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
setLongPressed(false);
};
if (posCharge) {
return (
<PosChargePanel
charge={posCharge}
unitPrice={price}
currency={currency}
disabled={disabled}
onConfirm={() => onPay('pos', posCharge)}
onBack={() => setPosCharge(null)}
/>
);
}
if (amountsFor) {
const tender = TENDERS.find((t) => t.method === amountsFor)!;
return (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<p className="text-sm font-semibold text-white">{tender.label} — how many?</p>
<button
onClick={() => { setAmountsFor(null); setCustomOpen(false); }}
className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white"
>
Back
</button>
</div>
<div className={clsx('grid gap-2', canOverrideAmount ? 'grid-cols-4' : 'grid-cols-3')}>
{[1, 2, 3].map((qty) => (
<button
key={qty}
disabled={disabled}
onClick={() => charge(tender.method, { quantity: qty, amount: price * qty })}
className={clsx(
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
{qty}x
<span className="text-[10px] font-medium opacity-80">
{formatCurrency(price * qty, currency)}
</span>
</button>
))}
{canOverrideAmount && (
<button
disabled={disabled}
onClick={() => setCustomOpen((open) => !open)}
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
>
Custom
</button>
)}
</div>
{canOverrideAmount && customOpen && (
<div className="flex gap-2">
<input
type="number"
inputMode="numeric"
autoFocus
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={`Amount in ${currency}`}
className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
disabled={disabled || !customValue || Number(customValue) < 0}
onClick={() => charge(tender.method, { quantity: 1, amount: Number(customValue), override: true })}
className={clsx(
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
Take
</button>
</div>
)}
</div>
);
}
return (
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${tenders.length}, minmax(0, 1fr))` }}
>
{tenders.map((tender) => (
<button
key={tender.method}
disabled={disabled}
onPointerDown={() => startPress(tender.method)}
onPointerUp={() => endPress(tender.method)}
onPointerLeave={cancelPress}
onPointerCancel={cancelPress}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
'relative min-h-[64px] rounded-2xl text-white font-bold flex flex-col items-center justify-center gap-1 select-none disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
<tender.icon className="w-6 h-6" />
<span className="text-xs">{tender.label}</span>
{tender.method !== 'guest' && (
// Visible affordance for the same thing long-press does: staff who
// never discover the hold still find the multiples.
<span
role="button"
aria-label={`${tender.label} quick amounts`}
onPointerDown={(e) => { e.stopPropagation(); cancelPress(); }}
onPointerUp={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); openAmounts(tender.method); }}
className="absolute top-0.5 right-0.5 w-7 h-7 flex items-center justify-center rounded-full text-white/70"
>
<ChevronDownIcon className="w-4 h-4" />
</span>
)}
</button>
))}
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { CreditCardIcon } from '@heroicons/react/24/outline';
import { formatCurrency } from '@/lib/utils';
import type { DoorCharge } from './PaymentButtons';
// The POS step of the door screen.
//
// Unlike cash, the money is not in staff's hand when they pick POS: they first
// key the amount into the physical card terminal, wait for approval, and only
// then confirm here. So POS is two taps: pick POS (this panel opens with the
// amount to type), then "Mark as paid", which records it through the same
// door-checkin call as every other tender.
//
// Nothing talks to the terminal today. If an automatic amount push is added
// later, it starts when this panel opens and its approval replaces (or pre-fills)
// the manual confirmation below; the rest of the door flow stays unchanged.
export function PosChargePanel({
charge,
unitPrice,
currency,
disabled,
onConfirm,
onBack,
}: {
charge: DoorCharge;
unitPrice: number;
currency: string;
disabled?: boolean;
onConfirm: () => void;
onBack: () => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<p className="text-sm font-semibold text-white flex items-center gap-1.5">
<CreditCardIcon className="w-5 h-5" />
POS — charge on the terminal
</p>
<button onClick={onBack} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
Back
</button>
</div>
<div className="rounded-2xl bg-gray-900 border border-violet-500/40 px-4 py-3 text-center">
<p className="text-[11px] uppercase tracking-wide text-gray-400">Amount to charge</p>
<p className="text-3xl font-bold text-white tabular-nums">{formatCurrency(charge.amount, currency)}</p>
{charge.override ? (
<p className="text-xs text-amber-400 mt-0.5">Custom amount</p>
) : charge.quantity > 1 ? (
<p className="text-xs text-gray-400 mt-0.5">
{charge.quantity} × {formatCurrency(unitPrice, currency)}
</p>
) : null}
</div>
<button
disabled={disabled}
onClick={onConfirm}
className="w-full min-h-[56px] rounded-2xl bg-violet-600 active:bg-violet-700 text-white text-lg font-bold disabled:opacity-50 active:scale-[0.98] transition-transform"
>
Mark as paid
</button>
<p className="text-xs text-gray-500 text-center px-2">
Only tap once the terminal has approved the card.
</p>
</div>
);
}
@@ -0,0 +1,179 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
// The camera is a fullscreen overlay opened from the search row, not a tab. It
// only exists while a scan is happening, so it never holds the camera (or the
// screen) while staff are typing a name.
/** Release any camera stream html5-qrcode left attached to a <video> element. */
function stopAllTracks() {
try {
document.querySelectorAll('video').forEach((video) => {
const stream = video.srcObject as MediaStream | null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
video.srcObject = null;
}
});
} catch {}
}
export function QRScannerOverlay({
onScan,
onClose,
}: {
onScan: (code: string) => void;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const scannerRef = useRef<any>(null);
const mountedRef = useRef(true);
const elementId = useRef(`qr-scanner-${Date.now()}`);
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
const [ready, setReady] = useState(false);
const destroyScanner = useCallback(async () => {
if (scannerRef.current) {
try { await scannerRef.current.stop(); } catch {}
try { scannerRef.current.clear(); } catch {}
scannerRef.current = null;
}
stopAllTracks();
}, []);
useEffect(() => {
mountedRef.current = true;
let cancelled = false;
const init = async () => {
const container = containerRef.current;
if (!container) return;
const id = elementId.current;
container.innerHTML = '';
const div = document.createElement('div');
div.id = id;
div.style.width = '100%';
div.style.height = '100%';
container.appendChild(div);
try {
const { Html5Qrcode } = await import('html5-qrcode');
if (cancelled) return;
const scanner = new Html5Qrcode(id);
scannerRef.current = scanner;
await scanner.start(
{ facingMode },
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
(decodedText: string) => {
if (mountedRef.current) onScan(decodedText);
},
() => {}
);
if (cancelled) {
await destroyScanner();
return;
}
// Force a layout pass: some browsers leave the video mis-sized until reflow.
requestAnimationFrame(() => {
if (container) {
container.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
container.offsetHeight;
container.style.display = '';
}
if (mountedRef.current) setReady(true);
});
} catch (error) {
console.error('Scanner error:', error);
if (!cancelled && mountedRef.current) {
toast.error('Failed to start camera. Check permissions.');
onClose();
}
}
};
init();
return () => {
cancelled = true;
mountedRef.current = false;
destroyScanner();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [facingMode]);
// Backgrounding the browser suspends the camera; drop it and rebuild on return.
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
destroyScanner();
} else if (document.visibilityState === 'visible' && mountedRef.current) {
setFacingMode((prev) => {
const temp = prev === 'environment' ? 'user' : 'environment';
setTimeout(() => {
if (mountedRef.current) setFacingMode(prev);
}, 100);
return temp;
});
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [destroyScanner]);
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 safe-area-top">
<p className="text-white font-semibold">Scan ticket</p>
<div className="flex items-center gap-2">
{ready && (
<button
onClick={() => setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Switch camera"
>
<VideoCameraIcon className="w-6 h-6" />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Close scanner"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
<p className="text-sm opacity-60">Starting camera...</p>
</div>
</div>
)}
</div>
<div className="flex-shrink-0 px-6 py-5 pb-safe">
<p className="text-center text-gray-400 text-sm">
Point at the ticket QR — it checks in and closes automatically.
</p>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState, type SyntheticEvent } from 'react';
import clsx from 'clsx';
import { CheckCircleIcon, XCircleIcon, BanknotesIcon } from '@heroicons/react/24/outline';
import type { SessionEntry } from './SessionSheet';
// Full-screen outcome after a door action. At a loud, dark door a row flash and
// a small toast are too easy to miss; a screen that is entirely green or
// entirely red answers "is this person in or not?" from across the table.
//
// It never traps the queue: tapping anywhere closes it, and it closes itself
// after a few seconds. The undo affordance survives the close — the page shows
// the bottom Undo toast for whatever is left of the window.
export type DoorResultKind = 'success' | 'already_in' | 'not_found' | 'unpaid' | 'failed';
export interface DoorResult {
kind: DoorResultKind;
/** Headline: the attendee's name, or a short verdict when there is no one. */
name: string;
/** Second line: "Checked in 19:42 · paid cash", "Already in at 19:42", "Collect ₲60.000". */
detail?: string;
/** Success only — drives Undo and the post-close toast. */
entry?: SessionEntry;
}
// Success needs no reading; the red ones carry a time or an amount the staff
// member has to relay to the person, so they get a little longer.
export const RESULT_AUTO_CLOSE_MS: Record<DoorResultKind, number> = {
success: 4000,
already_in: 6000,
not_found: 6000,
unpaid: 6000,
failed: 6000,
};
const SURFACE: Record<DoorResultKind, { bg: string; text: string; icon: typeof CheckCircleIcon; title: string }> = {
success: { bg: 'bg-emerald-600', text: 'text-emerald-700', icon: CheckCircleIcon, title: 'Checked in' },
already_in: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Already checked in' },
not_found: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Not found' },
unpaid: { bg: 'bg-amber-600', text: 'text-amber-700', icon: BanknotesIcon, title: 'Payment due' },
failed: { bg: 'bg-red-700', text: 'text-red-800', icon: XCircleIcon, title: 'NOT checked in' },
};
export function ResultScreen({
result,
onClose,
onUndo,
}: {
result: DoorResult;
onClose: () => void;
onUndo: () => void;
}) {
const surface = SURFACE[result.kind];
const duration = RESULT_AUTO_CLOSE_MS[result.kind];
// Toggled after mount so the fade-in and the countdown bar both animate from
// their starting state instead of appearing already finished.
const [shown, setShown] = useState(false);
// Keyed on the result identity: a success that flips to failed restarts both
// the fade and the countdown for the new state.
const identity = `${result.kind}:${result.entry?.idempotencyKey ?? result.name}`;
useEffect(() => {
setShown(false);
const raf = requestAnimationFrame(() => setShown(true));
const timer = setTimeout(onClose, duration);
return () => {
cancelAnimationFrame(raf);
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity]);
const stop = (e: SyntheticEvent) => e.stopPropagation();
const Icon = surface.icon;
return (
<div
role="dialog"
aria-live="assertive"
onClick={onClose}
className={clsx(
'fixed inset-0 z-50 flex flex-col text-white select-none transition-opacity duration-150',
surface.bg,
shown ? 'opacity-100' : 'opacity-0',
)}
style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
<Icon className="w-16 h-16" />
</div>
<p className="text-sm uppercase tracking-widest text-white/70 mb-2">{surface.title}</p>
<h2 className="text-3xl font-bold leading-tight break-words max-w-full">{result.name}</h2>
{result.detail && <p className="text-white/85 text-xl mt-3">{result.detail}</p>}
<p className="text-white/50 text-sm mt-8">Tap anywhere to continue</p>
</div>
<div className="px-6 pb-6 flex gap-3">
{result.kind === 'success' && (
<button
onClick={(e) => { stop(e); onUndo(); }}
className="flex-1 min-h-[56px] rounded-2xl bg-white/20 text-white text-xl font-bold active:scale-[0.98] transition-transform"
>
Undo
</button>
)}
<button
onClick={(e) => { stop(e); onClose(); }}
className={clsx(
'flex-1 min-h-[56px] rounded-2xl bg-white text-xl font-bold active:scale-[0.98] transition-transform',
surface.text,
)}
>
{result.kind === 'unpaid' ? 'Collect' : 'Close'}
</button>
</div>
{/* Countdown to auto-close, so the screen vanishing never surprises anyone. */}
<div className="h-1 bg-white/20">
<div
key={identity}
className="h-full bg-white/70"
style={{
width: shown ? '0%' : '100%',
transition: shown ? `width ${duration}ms linear` : 'none',
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,244 @@
'use client';
import clsx from 'clsx';
import {
XMarkIcon,
ClockIcon,
QrCodeIcon,
MagnifyingGlassIcon,
UserPlusIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod, DoorSummary } from '@/lib/api';
import { DOOR_PAYMENT_METHODS } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export interface SessionEntry {
idempotencyKey: string;
ticketId: string;
name: string;
at: string;
entry: 'scan' | 'search' | 'walkin';
method: DoorPaymentMethod | null;
amount: number;
/** Epoch ms the action was fired; the undo window is measured from here. */
startedAt: number;
undone: boolean;
failed: boolean;
}
const ENTRY_ICONS = {
scan: QrCodeIcon,
search: MagnifyingGlassIcon,
walkin: UserPlusIcon,
};
const ENTRY_LABELS = {
scan: 'Scanned',
search: 'Search',
walkin: 'Walk-in',
};
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'Cash',
bitcoin: 'Bitcoin',
transfer: 'Transfer',
pos: 'POS',
guest: 'Guest',
};
/** Totals for the current shift, computed from this session's own entries. */
function sessionTotals(entries: SessionEntry[]) {
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
cash: { count: 0, total: 0 },
bitcoin: { count: 0, total: 0 },
transfer: { count: 0, total: 0 },
pos: { count: 0, total: 0 },
guest: { count: 0, total: 0 },
};
let grand = 0;
for (const entry of entries) {
if (entry.undone || entry.failed || !entry.method) continue;
totals[entry.method].count += 1;
totals[entry.method].total += entry.amount;
grand += entry.amount;
}
return { totals, grand };
}
function CashUpGrid({
totals,
currency,
}: {
totals: Record<DoorPaymentMethod, { count: number; total: number }>;
currency: string;
}) {
return (
<div className="grid grid-cols-2 gap-2">
{DOOR_PAYMENT_METHODS.map((method) => (
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
<p className="font-bold text-white text-base leading-tight">
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
</p>
{method !== 'guest' && (
<p className="text-[11px] text-gray-500">
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
</p>
)}
</div>
))}
</div>
);
}
/**
* The end-of-night view: what this shift took, what the whole event day took,
* and the feed of who came in and how.
*/
export function SessionSheet({
entries,
summary,
summaryLoading,
showEventTotals,
currency,
onRefresh,
onClose,
}: {
entries: SessionEntry[];
summary: DoorSummary | null;
summaryLoading: boolean;
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
showEventTotals: boolean;
currency: string;
onRefresh: () => void;
onClose: () => void;
}) {
const { totals, grand } = sessionTotals(entries);
const liveEntries = entries.filter((e) => !e.undone);
return (
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
<div>
<p className="font-bold text-white text-lg">Session</p>
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
{showEventTotals && (
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Close session view"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
{/* This shift */}
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
</div>
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server — the number to reconcile the cash box
against. Admin/organizer only; the API enforces the same split. */}
{showEventTotals && (
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
<p className="text-primary-yellow font-bold">
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
</p>
</div>
{summary ? (
<>
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<div>
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
<p className="font-bold text-white">
{formatCurrency(summary.presale.total, summary.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500">
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
</p>
)}
</section>
)}
{/* Feed */}
<section className="space-y-2">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
{entries.length === 0 ? (
<div className="text-center text-gray-500 py-10">
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No check-ins yet</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const Icon = ENTRY_ICONS[entry.entry];
return (
<div
key={entry.idempotencyKey}
className={clsx(
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
entry.failed
? 'bg-red-950/40 border-red-900'
: entry.undone
? 'bg-gray-900 border-gray-800 opacity-50'
: 'bg-gray-800 border-gray-700',
)}
>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-medium truncate',
entry.undone ? 'text-gray-500 line-through' : 'text-white',
)}
>
{entry.name}
</p>
<p className="text-xs text-gray-500 truncate">
{ENTRY_LABELS[entry.entry]}
{entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''}
{entry.method && entry.method !== 'guest'
? ` ${formatCurrency(entry.amount, currency)}`
: ''}
{entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}
</p>
</div>
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
</div>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,177 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
export interface WalkInDraft {
firstName: string;
lastName: string;
phone: string;
email: string;
ruc: string;
}
export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
firstName,
lastName: '',
phone: '',
email: '',
ruc: '',
});
const PRICE_SOURCE_LABELS: Record<'walk_in' | 'ticket', string> = {
walk_in: 'Walk-in price',
ticket: 'Ticket price (no walk-in price set)',
};
/**
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
* name, the walk-in price and the tenders. Email, phone and RUC live behind "Add details" so the
* rare person who wants a receipt never slows down the queue behind them.
*/
export function WalkInRow({
typedText,
expanded,
draft,
price,
priceSource,
currency,
methods,
canOverrideAmount,
busy,
onExpand,
onChange,
onPay,
onCancel,
}: {
typedText: string;
expanded: boolean;
draft: WalkInDraft;
/** Walk-in unit price as resolved by the server. */
price: number;
priceSource: 'walk_in' | 'ticket';
currency: string;
methods: readonly DoorPaymentMethod[];
canOverrideAmount: boolean;
busy: boolean;
onExpand: () => void;
onChange: (draft: WalkInDraft) => void;
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
onCancel: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const firstNameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (expanded) firstNameRef.current?.focus();
}, [expanded]);
useEffect(() => {
if (!expanded) setDetailsOpen(false);
}, [expanded]);
if (!expanded) {
return (
<button
onClick={onExpand}
className="w-full min-h-[64px] px-4 py-3 rounded-2xl border-2 border-dashed border-primary-yellow/50 bg-primary-yellow/5 text-left flex items-center gap-3 active:scale-[0.99] transition-transform"
>
<UserPlusIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<span className="font-bold text-primary-yellow truncate">
{typedText ? `Add "${typedText}" as walk-in` : 'Add a walk-in'}
</span>
</button>
);
}
const field = (key: keyof WalkInDraft, value: string) => onChange({ ...draft, [key]: value });
return (
<div className="rounded-2xl border-2 border-primary-yellow/50 bg-gray-800 p-3 space-y-2">
<div className="flex items-center justify-between px-1">
<p className="font-bold text-primary-yellow">New walk-in</p>
<button onClick={onCancel} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
Cancel
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
ref={firstNameRef}
value={draft.firstName}
onChange={(e) => field('firstName', e.target.value)}
placeholder="First name"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.lastName}
onChange={(e) => field('lastName', e.target.value)}
placeholder="Last name (optional)"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<input
value={draft.phone}
onChange={(e) => field('phone', e.target.value)}
placeholder="Phone (optional)"
inputMode="tel"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
onClick={() => setDetailsOpen((open) => !open)}
className="w-full min-h-[48px] flex items-center justify-between px-2 text-sm text-gray-400 active:text-white"
>
Add details (email, RUC)
<ChevronDownIcon className={clsx('w-4 h-4 transition-transform', detailsOpen && 'rotate-180')} />
</button>
{detailsOpen && (
<div className="space-y-2">
<input
value={draft.email}
onChange={(e) => field('email', e.target.value)}
placeholder="Email — sends the usual confirmation"
inputMode="email"
autoComplete="off"
autoCapitalize="none"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.ruc}
onChange={(e) => field('ruc', e.target.value)}
placeholder="RUC (for factura)"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
)}
<div className="px-1 pt-1">
<p className="text-2xl font-bold text-white tabular-nums">{formatCurrency(price, currency)}</p>
<p className="text-xs text-gray-400">{PRICE_SOURCE_LABELS[priceSource]}</p>
</div>
<PaymentButtons
price={price}
currency={currency}
methods={methods}
canOverrideAmount={canOverrideAmount}
onPay={onPay}
disabled={busy || !draft.firstName.trim()}
/>
</div>
);
}
@@ -0,0 +1,56 @@
// Firing door actions without ever blocking the queue of people at the door.
//
// The UI flashes green and clears the input the moment staff taps; the write
// happens here, in the background, with retries. Venue wifi drops constantly, so
// every action carries an idempotency key: a retry that actually succeeded the
// first time returns the original result instead of double-charging anyone.
import { doorApi, type DoorCheckinRequest, type DoorCheckinResponse } from '@/lib/api';
/** UUID per action. crypto.randomUUID needs a secure context; fall back when absent. */
export function newIdempotencyKey(): string {
const cryptoRef = typeof crypto !== 'undefined' ? crypto : undefined;
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
return `door-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
// Roughly 5 seconds of retrying in total — long enough to ride out a wifi blip,
// short enough that staff learn about a real failure while the person is still
// in front of them.
const RETRY_DELAYS_MS = [400, 1200, 3000];
/**
* Errors worth retrying are the ones a retry can fix: network failures, gateway
* errors, rate limits. A 400 "ticket belongs to a different event" will fail
* identically forever, so it surfaces immediately.
*/
function isRetryable(error: any): boolean {
const status = error?.status;
if (typeof status === 'number') return status >= 500 || status === 408 || status === 429;
// No status at all means the request never reached the server (fetch rejects
// with a TypeError when the connection drops) — exactly the case to retry.
return true;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function submitDoorAction(
eventId: string,
body: DoorCheckinRequest,
): Promise<DoorCheckinResponse> {
let lastError: any;
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
try {
return await doorApi.checkin(eventId, body);
} catch (error: any) {
lastError = error;
if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) break;
await sleep(RETRY_DELAYS_MS[attempt]);
}
}
throw lastError;
}
export async function undoDoorAction(eventId: string, idempotencyKey: string): Promise<void> {
await doorApi.undo(eventId, idempotencyKey);
}

Some files were not shown because too many files have changed in this diff Show More