Compare commits

..
15 Commits
Author SHA1 Message Date
Michilis 9087650e4c Merge pull request 'Fix backend build against hono 4.13 in finance routes.' (#39) from dev into main
Reviewed-on: #39
2026-10-03 05:11:37 +00:00
MichilisandClaude Opus 5.5 88e7325099 Fix backend build against hono 4.13 in finance routes.
Newer hono drops path-param typing on routes with a single untyped
middleware, so c.req.param('id') widens to string | undefined. Assert
the always-present :id param in the affected finance handlers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:11:06 +00:00
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
107 changed files with 14919 additions and 1117 deletions
File diff suppressed because it is too large Load Diff
+278 -1
View File
@@ -1,7 +1,8 @@
import 'dotenv/config';
import { db, dbAll, dbGet, events, users } from './index.js';
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`);
@@ -287,6 +303,21 @@ async function migrate() {
)
`);
// 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`
CREATE TABLE IF NOT EXISTS invoices (
@@ -382,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,
@@ -510,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)
)
@@ -520,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 (
@@ -730,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
)
@@ -743,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)`);
@@ -883,6 +947,20 @@ async function migrate() {
)
`);
// 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`
CREATE TABLE IF NOT EXISTS invoices (
@@ -976,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,
@@ -1104,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)
)
@@ -1114,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 (
@@ -1241,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)`,
@@ -1258,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 {
+338 -3
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'),
@@ -143,7 +154,7 @@ export const sqlitePayments = sqliteTable('payments', {
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', 'guest'] }),
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -191,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
@@ -223,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(),
@@ -387,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(),
@@ -470,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(),
});
@@ -506,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(),
});
@@ -569,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),
@@ -598,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(),
});
@@ -761,11 +935,152 @@ 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;
@@ -790,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;
@@ -823,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;
+12 -4
View File
@@ -13,6 +13,8 @@ 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';
@@ -788,7 +790,7 @@ const openApiSpec = {
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. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
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' } },
@@ -817,8 +819,10 @@ const openApiSpec = {
type: 'object',
required: ['method'],
properties: {
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] },
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
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'] },
@@ -831,7 +835,8 @@ const openApiSpec = {
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' },
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' },
},
},
@@ -2022,6 +2027,8 @@ 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);
@@ -2037,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) => {
+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);
}
+45 -7
View File
@@ -1,6 +1,6 @@
// Door payment tenders.
//
// The door check-in screen offers four one-tap tenders. Each maps onto an
// 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.
@@ -9,27 +9,65 @@
// 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.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
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';
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 },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
guest: { provider: 'cash', label: 'guest', isComp: true },
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);
}
+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(
+14 -14
View File
@@ -25,7 +25,7 @@ interface TicketData {
// ==================== Brand ====================
const COLORS = {
export const COLORS = {
navy: '#002F44',
orange: '#F5821F',
cream: '#FDF8F0',
@@ -37,14 +37,14 @@ const COLORS = {
footerMuted: '#7FA3B5',
};
const PAGE_W = 595.28;
const PAGE_H = 841.89;
const MARGIN = 48;
const CONTENT_W = PAGE_W - MARGIN * 2;
const ACCENT_H = 10;
const FOOTER_H = 48;
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;
const LOGO_RATIO = 1158 / 324;
export const LOGO_RATIO = 1158 / 324;
const STRINGS = {
en: {
@@ -82,7 +82,7 @@ function loadLogo(): Buffer | null {
}
let logoCache: Buffer | null | undefined;
function getLogo(): Buffer | null {
export function getLogo(): Buffer | null {
if (logoCache === undefined) logoCache = loadLogo();
return logoCache;
}
@@ -152,7 +152,7 @@ function splitLocation(location: string): { name: string; address?: string } {
// ==================== Drawing helpers ====================
function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
export function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
doc
.font('Helvetica-Bold')
.fontSize(8)
@@ -160,7 +160,7 @@ function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CON
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
}
function drawDivider(doc: PDFKit.PDFDocument, y: number) {
export function drawDivider(doc: PDFKit.PDFDocument, y: number) {
doc
.moveTo(MARGIN, y)
.lineTo(PAGE_W - MARGIN, y)
@@ -347,11 +347,11 @@ function renderTicketPage(
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
}
function createDoc(): PDFKit.PDFDocument {
export function createDoc(): PDFKit.PDFDocument {
return new PDFDocument({ size: 'A4', margin: 0 });
}
function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
export function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
@@ -360,7 +360,7 @@ function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
});
}
function siteUrl(): { base: string; domain: string } {
export function siteUrl(): { base: string; domain: string } {
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
let domain = base;
try {
+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 };
}
+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,
+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,
},
+313 -10
View File
@@ -14,17 +14,36 @@ 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;
// The door endpoints are behind staff auth; the flows under test are the writes,
// not Better Auth, which has its own integration suite.
vi.mock('../lib/auth.js', () => ({
requireAuth: () => async (c: any, next: any) => {
c.set('user', STAFF);
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 () => STAFF,
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 })) },
@@ -60,6 +79,8 @@ function seedTicket(row: {
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(
@@ -81,6 +102,17 @@ function seedTicket(row: {
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(() => {
@@ -115,7 +147,7 @@ beforeAll(() => {
)
.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' });
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' });
@@ -178,7 +210,7 @@ describe('door-checkin: existing ticket', () => {
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 = ?').get('tkt-paid').n).toBe(0);
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 () => {
@@ -204,7 +236,7 @@ describe('door-checkin: existing ticket', () => {
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', amount: PRICE * 2 },
payment: { method: 'transfer', quantity: 2 },
idempotencyKey: 'key-unpaid-2-transfer',
});
expect(body.payment.amount).toBe(PRICE * 2);
@@ -304,7 +336,7 @@ describe('door-checkin: walk-ins', () => {
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' });
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',
@@ -362,9 +394,45 @@ describe('undo', () => {
});
});
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 get(`/api/events/${EVENT_ID}/door-summary`);
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
@@ -385,4 +453,239 @@ describe('door-summary', () => {
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');
});
});
+110 -21
View File
@@ -8,7 +8,8 @@
// 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
// 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
@@ -20,21 +21,32 @@ 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,
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
} from '../db/index.js';
import { requireAuth } from '../lib/auth.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, type DoorPaymentMethod,
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 ====================
@@ -93,10 +105,12 @@ async function loadEvent(eventId: string | undefined) {
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))];
@@ -120,7 +134,7 @@ async function seatsHeld(eventId: string): Promise<number> {
// 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', requireAuth([...STAFF_ROLES]), async (c) => {
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);
@@ -158,6 +172,9 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
);
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,
@@ -165,6 +182,7 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
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;
@@ -175,9 +193,15 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
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 },
});
@@ -198,9 +222,16 @@ const doorCheckinSchema = z.object({
}).optional(),
payment: z.object({
method: z.enum(DOOR_PAYMENT_METHODS),
// Omitted means "one ticket at event price"; a multiple covers someone
// paying for their whole group in one go.
// 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(),
@@ -237,7 +268,7 @@ async function findProcessedKey(key: string) {
doorRouter.post(
'/:eventId/door-checkin',
requireAuth([...STAFF_ROLES]),
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
zValidator('json', doorCheckinSchema),
async (c) => {
const eventId = c.req.param('eventId');
@@ -249,19 +280,36 @@ doorRouter.post(
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 method = data.payment?.method as DoorPaymentMethod | undefined;
const requestedAmount = data.payment?.amount ?? event.price;
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 } | null = null;
let paymentSummary: {
id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean;
} | null = null;
let emailTicketId: string | null = null;
if (data.ticketId) {
@@ -320,7 +368,7 @@ doorRouter.post(
method,
updatedAt: now,
}, eq((payments as any).id, existingPayment.id)));
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency };
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
} else {
const paymentId = generateId();
undo.createdPaymentId = paymentId;
@@ -339,7 +387,7 @@ doorRouter.post(
createdAt: now,
updatedAt: now,
}));
paymentSummary = { id: paymentId, method, amount, currency: event.currency };
paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
}
}
@@ -423,6 +471,7 @@ doorRouter.post(
checkinAt: now,
checkedInByAdminId: adminUser?.id || null,
adminNote: null,
bookingSource: 'walk_in',
createdAt: now,
};
ops.push(insertOp(tickets, newTicket));
@@ -442,13 +491,35 @@ doorRouter.post(
updatedAt: now,
}));
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency };
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;
@@ -490,6 +561,13 @@ doorRouter.post(
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);
@@ -506,7 +584,7 @@ doorRouter.post(
doorRouter.post(
'/:eventId/door-checkin/undo',
requireAuth([...STAFF_ROLES]),
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');
@@ -573,7 +651,7 @@ doorRouter.post(
// 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', requireAuth([...STAFF_ROLES]), async (c) => {
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);
@@ -614,21 +692,32 @@ doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c
doorTotal += amount;
}
// Pre-sale revenue keeps the dashboard's existing definition — settled tickets
// at event price — minus anything that was actually taken at the door.
// 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 })
.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 presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length;
const presaleTotal = presaleCount * event.price;
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,
+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);
});
});
+190 -55
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,7 +255,7 @@ 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) => {
@@ -185,6 +274,7 @@ eventsRouter.get('/', async (c) => {
// 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[] = [];
@@ -226,7 +316,11 @@ eventsRouter.get('/', async (c) => {
// 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, {
@@ -235,16 +329,17 @@ 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 paginated
? c.json({ events: eventsWithCounts, total, page, pageSize })
@@ -260,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';
}
@@ -320,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
@@ -389,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,
},
});
@@ -417,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);
@@ -442,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');
@@ -457,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
@@ -505,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))
);
@@ -517,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)
@@ -583,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)
@@ -628,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>(
@@ -665,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)
+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
+31 -13
View File
@@ -4,6 +4,9 @@ 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 {
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';
@@ -11,6 +14,7 @@ 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>(
@@ -664,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) {
@@ -715,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');
@@ -834,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');
@@ -867,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;
@@ -926,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;
@@ -1031,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');
@@ -1086,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');
@@ -1355,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>(
@@ -1383,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');
@@ -1408,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
@@ -1497,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,
};
@@ -1546,7 +1562,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
// 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', 'door', 'unpaid', 'guest']),
// Door walk-ins can be logged with nothing filled in, so firstName is only
@@ -1652,6 +1668,7 @@ 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,
};
@@ -1743,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');
@@ -1774,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}`)
+5 -1
View File
@@ -177,6 +177,7 @@ export default function AdminBookingsPage() {
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
pos: 'POS',
};
return labels[provider] || provider;
};
@@ -443,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>
)}
@@ -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,43 +1,81 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } 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, door takings) 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, doorRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
const { event } = await eventsApi.getById(eventId);
setEvent(event);
} catch (error) {
toast.error('Failed to load event data');
} finally {
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.
doorApi.summary(eventId).catch(() => null),
can('view_payments') ? doorApi.summary(eventId).catch(() => null) : null,
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) {
toast.error('Failed to load event data');
} finally {
setLoading(false);
setDetailsLoading(false);
}
};
const loadEventData = async () => {
await Promise.all([loadEvent(), loadDetails()]);
};
useEffect(() => {
loadEventData();
loadEvent();
}, [eventId]);
return { loading, event, tickets, templates, doorSummary, loadEventData };
useEffect(() => {
loadDetails();
}, [eventId, permissions]);
return { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData };
}
@@ -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>
@@ -25,6 +25,7 @@ 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' },
};
@@ -50,7 +51,7 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
</p>
</div>
</div>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency)}</p>
<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">
@@ -64,7 +65,7 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
<p className="font-bold text-sm leading-tight">
{method === 'guest'
? `${entry.count}`
: formatCurrency(entry.total, summary.currency)}
: formatCurrency(entry.total, summary.currency, locale)}
</p>
{method !== 'guest' && (
<p className="text-[10px] text-gray-500">
@@ -78,11 +79,11 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
<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)}</strong>
{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)}</strong>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency, locale)}</strong>
</span>
</div>
</div>
@@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: 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 ? (
@@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: 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">
@@ -453,6 +494,7 @@ export function PaymentsTab({ locale, payments, doorSummary }: 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 -1
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';
@@ -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 -783
View File
@@ -1,790 +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, doorSummary, 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 / 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,
});
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;
// Door sales can be taken at a custom amount (someone paying for their whole
// group), so once the door summary is loaded it is the authority on the total:
// pre-sale tickets at face value plus whatever was actually taken on the night.
const presaleRevenue = doorSummary
? doorSummary.presale.total
: (paidConfirmedCount + paidCheckedInCount) * event.price;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
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',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: 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">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',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: 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 ============= */}
{/* 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} doorSummary={doorSummary} />
)}
</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>
);
}
+25 -17
View File
@@ -7,7 +7,6 @@ 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,6 +14,7 @@ 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() {
@@ -26,6 +26,11 @@ export default function AdminEventsPage() {
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);
@@ -36,6 +41,7 @@ export default function AdminEventsPage() {
}, []);
useEffect(() => {
setFetching(true);
loadEvents();
}, [page, pageSize]);
@@ -59,8 +65,12 @@ export default function AdminEventsPage() {
}, [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, 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),
@@ -69,9 +79,13 @@ export default function AdminEventsPage() {
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);
}
}
};
@@ -172,10 +186,6 @@ export default function AdminEventsPage() {
}
};
if (loading) {
return <AdminPageSkeleton cols={5} />;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -198,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>
@@ -319,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>
@@ -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">
@@ -8,7 +8,7 @@ import {
} from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { PaymentButtons } from './PaymentButtons';
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return '';
@@ -23,6 +23,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'cash',
bitcoin: 'bitcoin',
transfer: 'transfer',
pos: 'POS',
guest: 'guest',
};
@@ -49,6 +50,8 @@ export function AttendeeRow({
attendee,
currency,
price,
methods,
canOverrideAmount,
expanded,
flashing,
busy,
@@ -58,11 +61,13 @@ export function AttendeeRow({
attendee: DoorAttendee;
currency: string;
price: number;
methods: readonly DoorPaymentMethod[];
canOverrideAmount: boolean;
expanded: boolean;
flashing: boolean;
busy: boolean;
onTap: () => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
}) {
const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
@@ -141,7 +146,14 @@ export function AttendeeRow({
Reactivate as a walk-in — pick how they are paying.
</p>
)}
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
<PaymentButtons
price={price}
currency={currency}
methods={methods}
canOverrideAmount={canOverrideAmount}
onPay={onPay}
disabled={busy}
/>
</div>
)}
@@ -6,14 +6,29 @@ 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 four tenders staff can take at the door. One tap settles and checks in;
// 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;
@@ -24,6 +39,7 @@ const TENDERS: {
{ 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' },
];
@@ -32,21 +48,32 @@ 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;
onPay: (method: DoorPaymentMethod, amount: number) => void;
/** 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);
@@ -54,6 +81,15 @@ export function PaymentButtons({
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(() => {
@@ -72,7 +108,7 @@ export function PaymentButtons({
return;
}
if (disabled) return;
onPay(method, method === 'guest' ? 0 : price);
charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
};
const cancelPress = () => {
@@ -81,6 +117,19 @@ export function PaymentButtons({
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 (
@@ -94,12 +143,12 @@ export function PaymentButtons({
Back
</button>
</div>
<div className="grid grid-cols-4 gap-2">
<div className={clsx('grid gap-2', canOverrideAmount ? 'grid-cols-4' : 'grid-cols-3')}>
{[1, 2, 3].map((qty) => (
<button
key={qty}
disabled={disabled}
onClick={() => onPay(tender.method, price * qty)}
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,
@@ -111,15 +160,17 @@ export function PaymentButtons({
</span>
</button>
))}
<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>
{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>
{customOpen && (
{canOverrideAmount && customOpen && (
<div className="flex gap-2">
<input
type="number"
@@ -132,7 +183,7 @@ export function PaymentButtons({
/>
<button
disabled={disabled || !customValue || Number(customValue) < 0}
onClick={() => onPay(tender.method, Number(customValue))}
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,
@@ -147,8 +198,11 @@ export function PaymentButtons({
}
return (
<div className="grid grid-cols-4 gap-2">
{TENDERS.map((tender) => (
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${tenders.length}, minmax(0, 1fr))` }}
>
{tenders.map((tender) => (
<button
key={tender.method}
disabled={disabled}
@@ -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,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>
);
}
@@ -21,6 +21,8 @@ export interface SessionEntry {
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;
}
@@ -41,6 +43,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'Cash',
bitcoin: 'Bitcoin',
transfer: 'Transfer',
pos: 'POS',
guest: 'Guest',
};
@@ -50,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) {
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;
@@ -96,6 +100,7 @@ export function SessionSheet({
entries,
summary,
summaryLoading,
showEventTotals,
currency,
onRefresh,
onClose,
@@ -103,6 +108,8 @@ export function SessionSheet({
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;
@@ -118,13 +125,15 @@ export function SessionSheet({
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
<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>
{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"
@@ -145,7 +154,9 @@ export function SessionSheet({
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server — the number to reconcile the cash box against */}
{/* 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>
@@ -175,6 +186,7 @@ export function SessionSheet({
</p>
)}
</section>
)}
{/* Feed */}
<section className="space-y-2">
@@ -4,7 +4,8 @@ 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 { PaymentButtons } from './PaymentButtons';
import { formatCurrency } from '@/lib/utils';
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
export interface WalkInDraft {
firstName: string;
@@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
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 and four tenders. Email, phone and RUC live behind "Add details" so the
* 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({
@@ -32,7 +38,10 @@ export function WalkInRow({
expanded,
draft,
price,
priceSource,
currency,
methods,
canOverrideAmount,
busy,
onExpand,
onChange,
@@ -42,12 +51,16 @@ export function WalkInRow({
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, amount: number) => void;
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
onCancel: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -146,9 +159,16 @@ export function WalkInRow({
</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()}
/>
@@ -1,3 +1,5 @@
import toast from 'react-hot-toast';
// Haptic + audio confirmation. At a loud, dark door the sound and the buzz are
// what staff actually register — the green flash is confirmation for the person
// standing in front of them.
@@ -39,3 +41,18 @@ export function vibrate(pattern: number | number[]) {
if (navigator.vibrate) navigator.vibrate(pattern);
} catch {}
}
/**
* Dismiss a toast after `ms` regardless of react-hot-toast's hover/touch pause.
*
* The <Toaster> pauses every countdown while the pointer is "inside" its
* container, and schedules no dismissal at all for toasts created while paused.
* On a touchscreen, tapping a toast button (the Undo toast) synthesizes a
* mouseenter with no mouseleave until the next tap somewhere else — so the
* "Undone" toast that follows would sit there indefinitely. Callers create the
* toast with `duration: Infinity` and let this timer close it.
*/
export function dismissAfter(id: string, ms: number): string {
window.setTimeout(() => toast.dismiss(id), ms);
return id;
}
+156 -41
View File
@@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext';
import {
eventsApi,
doorApi,
DOOR_PAYMENT_METHODS,
type Event,
type DoorAttendee,
type DoorAttendeesResponse,
@@ -26,11 +27,13 @@ import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { buildIndex, searchAttendees, hasExactMatch } from './_lib/search';
import { newIdempotencyKey, submitDoorAction, undoDoorAction } from './_lib/doorActions';
import { playSuccessSound, playErrorSound, vibrate } from './_lib/feedback';
import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/feedback';
import { QRScannerOverlay } from './_components/QRScannerOverlay';
import { AttendeeRow } from './_components/AttendeeRow';
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
import type { DoorCharge } from './_components/PaymentButtons';
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
// ═══════════════════════════════════════════════════════════════
// Door check-in screen
@@ -47,6 +50,10 @@ import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
const REFRESH_INTERVAL_MS = 30_000;
const FLASH_MS = 900;
const UNDO_WINDOW_MS = 10_000;
// Every toast on this screen is closed by our own timer (dismissAfter) rather
// than react-hot-toast's countdown, which pauses while a toast is touched and
// never resumes on a touchscreen until the next tap elsewhere.
const TOAST_MS = 4000;
const clockTime = (value: Date | string) =>
(typeof value === 'string' ? parseDate(value) : value).toLocaleTimeString([], {
@@ -59,6 +66,7 @@ const METHOD_PAST_TENSE: Record<DoorPaymentMethod, string> = {
cash: 'paid cash',
bitcoin: 'paid bitcoin',
transfer: 'paid by transfer',
pos: 'paid by POS',
guest: 'in as guest',
};
@@ -66,6 +74,13 @@ export default function AdminDoorPage() {
const router = useRouter();
const { user } = useAuth();
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
// Whole-event takings are management information. Door staff reconcile their
// own shift from the session feed below, which is local to this device; the
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
// Typing a custom door amount is an admin decision; the API rejects it for
// anyone else (AMOUNT_OVERRIDE_ROLES in routes/door.ts).
const canOverrideAmount = user?.role === 'admin' || user?.role === 'organizer';
// ── Events ──
const [events, setEvents] = useState<Event[]>([]);
@@ -86,6 +101,14 @@ export default function AdminDoorPage() {
const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(emptyWalkIn());
const [scannerOpen, setScannerOpen] = useState(false);
const [sessionOpen, setSessionOpen] = useState(false);
// Full-screen outcome of the last action. Mirrored in a ref because the
// background write in runAction must read the *current* screen when it fails,
// not the one captured when it started.
const [result, setResult] = useState<DoorResult | null>(null);
const resultRef = useRef<DoorResult | null>(null);
// Undo toast per action, so a write that fails after the toast is up can
// pull it down instead of offering to undo something that never happened.
const undoToastIdsRef = useRef(new Map<string, string>());
// ── Session bookkeeping ──
const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]);
@@ -105,6 +128,10 @@ export default function AdminDoorPage() {
const eventMeta = data?.event;
const price = eventMeta?.price ?? 0;
// Walk-ins pay the walk-in price when the event sets one; the server resolves it.
const walkInPrice = eventMeta?.walkInUnitPrice ?? price;
const walkInPriceSource = eventMeta?.walkInPriceSource ?? 'ticket';
const doorMethods = data?.doorMethods ?? DOOR_PAYMENT_METHODS;
const currency = eventMeta?.currency ?? 'PYG';
const capacity = eventMeta?.capacity ?? 0;
@@ -237,12 +264,18 @@ export default function AdminDoorPage() {
try {
await undoDoorAction(eventId, entry.idempotencyKey);
toast.success(`Undone — ${entry.name}`, { position: 'bottom-center' });
dismissAfter(
toast.success(`Undone — ${entry.name}`, { position: 'bottom-center', duration: Infinity }),
TOAST_MS,
);
} catch (error: any) {
toast.error(`Could not undo ${entry.name}: ${error?.message || 'request failed'}`, {
position: 'bottom-center',
duration: 8000,
});
dismissAfter(
toast.error(`Could not undo ${entry.name}: ${error?.message || 'request failed'}`, {
position: 'bottom-center',
duration: Infinity,
}),
8000,
);
} finally {
// Whatever happened, the server is the truth about who is inside.
loadAttendees(eventId, { silent: true });
@@ -252,8 +285,8 @@ export default function AdminDoorPage() {
);
const showUndoToast = useCallback(
(message: string, entry: SessionEntry) => {
toast.custom(
(message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => {
const id = toast.custom(
(t) => (
<div
className={clsx(
@@ -280,12 +313,46 @@ export default function AdminDoorPage() {
</button>
</div>
),
{ duration: UNDO_WINDOW_MS, position: 'bottom-center' },
{ duration: Infinity, position: 'bottom-center' },
);
dismissAfter(id, durationMs);
undoToastIdsRef.current.set(entry.idempotencyKey, id);
},
[handleUndo],
);
// ─── Full-screen result ──────────────────────────────────────
const showResult = useCallback((next: DoorResult) => {
resultRef.current = next;
setResult(next);
}, []);
// Closing a success hands the undo affordance to the bottom toast for
// whatever remains of the window — never both at once (the toast layer sits
// above the overlay and would show a second Undo on top of the first).
const closeResult = useCallback(
(opts?: { skipUndoToast?: boolean }) => {
const closing = resultRef.current;
resultRef.current = null;
setResult(null);
focusInput();
if (opts?.skipUndoToast || closing?.kind !== 'success' || !closing.entry) return;
const remaining = UNDO_WINDOW_MS - (Date.now() - closing.entry.startedAt);
if (remaining < 500) return;
const suffix = closing.entry.method ? `, ${METHOD_PAST_TENSE[closing.entry.method]}` : '';
showUndoToast(`${closing.entry.name} checked in${suffix}`, closing.entry, remaining);
},
[focusInput, showUndoToast],
);
const undoFromResult = useCallback(() => {
const entry = resultRef.current?.entry;
// handleUndo announces itself with its own toast; don't also queue the undo one.
closeResult({ skipUndoToast: true });
if (entry) handleUndo(entry);
}, [closeResult, handleUndo]);
// ─── The one write path ──────────────────────────────────────
// Every completed action on this screen — scan, tap, collect, walk-in — flows
// through here so the flash, the counter, the session feed and the undo all
@@ -294,7 +361,7 @@ export default function AdminDoorPage() {
async (opts: {
ticketId?: string;
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
payment?: { method: DoorPaymentMethod; amount: number };
payment?: { method: DoorPaymentMethod; charge: DoorCharge };
entry: DoorEntryMethod;
displayName: string;
/** Ticket row to flash and optimistically mark as in. */
@@ -316,7 +383,8 @@ export default function AdminDoorPage() {
at: clockTime(now),
entry: opts.entry,
method: opts.payment?.method ?? null,
amount: opts.payment?.amount ?? 0,
amount: opts.payment?.charge.amount ?? 0,
startedAt: now.getTime(),
undone: false,
failed: false,
};
@@ -351,7 +419,12 @@ export default function AdminDoorPage() {
playSuccessSound();
const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : '';
showUndoToast(`${opts.displayName} checked in${paidSuffix}`, sessionEntry);
showResult({
kind: 'success',
name: opts.displayName,
detail: `Checked in ${clockTime(now)}${paidSuffix}`,
entry: sessionEntry,
});
setQuery('');
setExpandedId(null);
@@ -361,10 +434,19 @@ export default function AdminDoorPage() {
// ── The write itself: background, retried, never blocking the queue ──
try {
const { method, charge } = opts.payment ?? {};
const res = await submitDoorAction(eventId, {
ticketId: opts.ticketId,
attendee: opts.attendee,
payment: opts.payment,
// The server prices the charge from quantity; a typed amount only
// travels as an explicit admin override.
payment: method && charge
? {
method,
quantity: charge.quantity,
...(charge.override ? { amount: charge.amount, amountOverride: true } : {}),
}
: undefined,
entryMethod: opts.entry,
idempotencyKey,
});
@@ -372,13 +454,22 @@ export default function AdminDoorPage() {
setSessionEntries((prev) =>
prev.map((e) =>
e.idempotencyKey === idempotencyKey
? { ...e, ticketId: res.attendee.ticketId, name: res.attendee.fullName }
? {
...e,
ticketId: res.attendee.ticketId,
name: res.attendee.fullName,
// What was actually recorded is the server's number, not ours.
amount: res.payment?.amount ?? e.amount,
}
: e,
),
);
if (res.warnings?.includes('at_capacity') && !capacityWarnedRef.current) {
capacityWarnedRef.current = true;
toast('Event at capacity, added anyway', { icon: '⚠️', position: 'bottom-center', duration: 6000 });
dismissAfter(
toast('Event at capacity, added anyway', { icon: '⚠️', position: 'bottom-center', duration: Infinity }),
6000,
);
}
} catch (error: any) {
// Retries are exhausted: make the failure loud and name the person, so
@@ -391,16 +482,33 @@ export default function AdminDoorPage() {
if (previousRow) upsertAttendee(previousRow);
playErrorSound();
vibrate([100, 50, 100]);
toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), {
duration: 12000,
position: 'bottom-center',
});
// If the green screen for this very action is still up, turn it red in
// place; the toast below covers the case where it has already closed.
const staleUndo = undoToastIdsRef.current.get(idempotencyKey);
if (staleUndo) {
toast.dismiss(staleUndo);
undoToastIdsRef.current.delete(idempotencyKey);
}
if (resultRef.current?.entry?.idempotencyKey === idempotencyKey) {
showResult({
kind: 'failed',
name: opts.displayName,
detail: error?.message || 'The check-in did not reach the server. Try again.',
});
}
dismissAfter(
toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), {
duration: Infinity,
position: 'bottom-center',
}),
12000,
);
loadAttendees(eventId, { silent: true });
} finally {
setBusyId((current) => (current === opts.busyKey ? null : current));
}
},
[data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees],
[data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees],
);
// ─── Row interactions ────────────────────────────────────────
@@ -425,10 +533,10 @@ export default function AdminDoorPage() {
);
const handleRowPay = useCallback(
(attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => {
(attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => {
runAction({
ticketId: attendee.ticketId,
payment: { method, amount },
payment: { method, charge },
entry: 'search',
displayName: attendee.fullName,
optimisticTicketId: attendee.ticketId,
@@ -439,7 +547,7 @@ export default function AdminDoorPage() {
);
const handleWalkInPay = useCallback(
(method: DoorPaymentMethod, amount: number) => {
(method: DoorPaymentMethod, charge: DoorCharge) => {
const firstName = walkInDraft.firstName.trim();
if (!firstName) return;
const displayName = walkInDraft.lastName.trim()
@@ -453,7 +561,7 @@ export default function AdminDoorPage() {
email: walkInDraft.email.trim() || undefined,
ruc: walkInDraft.ruc.trim() || undefined,
},
payment: { method, amount },
payment: { method, charge },
entry: 'walkin',
displayName,
busyKey: 'walk-in',
@@ -484,19 +592,17 @@ export default function AdminDoorPage() {
if (!attendee) {
playErrorSound();
vibrate([100, 50, 100]);
toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 });
focusInput();
showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' });
return;
}
if (attendee.checkedIn) {
playErrorSound();
vibrate([100, 50, 100]);
toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, {
icon: 'ℹ️',
position: 'bottom-center',
duration: 6000,
});
const at = attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : '';
const by = attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : '';
showResult({ kind: 'already_in', name: attendee.fullName, detail: `Already checked in${at}${by}` });
// Their row sits open underneath, so closing lands on the same person.
setQuery(attendee.fullName);
setExpandedId(attendee.ticketId);
return;
@@ -504,13 +610,14 @@ export default function AdminDoorPage() {
if (attendee.paymentStatus === 'unpaid') {
// Checking an unpaid ticket in silently would walk the money out the
// door. Surface them with the tenders open instead.
// door. Surface them with the tenders open instead: "Collect" closes
// the screen straight onto the payment buttons.
playErrorSound();
vibrate(200);
toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, {
icon: '💰',
position: 'bottom-center',
duration: 8000,
showResult({
kind: 'unpaid',
name: attendee.fullName,
detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`,
});
setQuery(attendee.fullName);
setExpandedId(attendee.ticketId);
@@ -525,13 +632,13 @@ export default function AdminDoorPage() {
busyKey: attendee.ticketId,
});
},
[data?.attendees, runAction, focusInput, price, currency],
[data?.attendees, runAction, showResult, price, currency],
);
// ─── Session summary ─────────────────────────────────────────
const loadSummary = useCallback(async () => {
const eventId = selectedEventIdRef.current;
if (!eventId) return;
if (!eventId || !canSeeEventTotals) return;
setSummaryLoading(true);
try {
setSummary(await doorApi.summary(eventId));
@@ -540,7 +647,7 @@ export default function AdminDoorPage() {
} finally {
setSummaryLoading(false);
}
}, []);
}, [canSeeEventTotals]);
useEffect(() => {
if (sessionOpen) loadSummary();
@@ -682,11 +789,13 @@ export default function AdminDoorPage() {
attendee={attendee}
currency={currency}
price={price}
methods={doorMethods}
canOverrideAmount={canOverrideAmount}
expanded={expandedId === attendee.ticketId}
flashing={flashId === attendee.ticketId}
busy={busyId === attendee.ticketId}
onTap={() => handleRowTap(attendee)}
onPay={(method, amount) => handleRowPay(attendee, method, amount)}
onPay={(method, charge) => handleRowPay(attendee, method, charge)}
/>
))}
@@ -696,8 +805,11 @@ export default function AdminDoorPage() {
typedText={trimmedQuery}
expanded={walkInOpen}
draft={walkInDraft}
price={price}
price={walkInPrice}
priceSource={walkInPriceSource}
currency={currency}
methods={doorMethods}
canOverrideAmount={canOverrideAmount}
busy={busyId === 'walk-in'}
onExpand={() => {
setWalkInDraft(emptyWalkIn(trimmedQuery));
@@ -728,11 +840,14 @@ export default function AdminDoorPage() {
/>
)}
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
{sessionOpen && (
<SessionSheet
entries={sessionEntries}
summary={summary}
summaryLoading={summaryLoading}
showEventTotals={canSeeEventTotals}
currency={currency}
onRefresh={loadSummary}
onClose={() => {
@@ -0,0 +1,396 @@
'use client';
// Site Settings → Expense Templates: expense categories, reusable expense
// templates (calc-type aware), template packs applied to events in one click,
// and the per-method payment fees used for net revenue. Each row saves on its
// own; everything written here is recorded in the finance audit log.
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { PlusIcon, PencilIcon, TrashIcon, ArchiveBoxIcon, ArrowUturnLeftIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import {
financeApi, CALC_TYPES,
type CalcType, type ExpenseCategory, type ExpenseTemplate, type ExpenseTemplatePack, type PaymentMethodFee,
} from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Modal, Field, PygInput, Pill, inputClass } from '../../events/[id]/_finance/ui';
import { bpToPercent, formatPercent, formatPyg, percentToBp } from '../../events/[id]/_finance/format';
import { suggestCategory } from '../../events/[id]/_finance/derive';
type T = (key: string, params?: Record<string, string | number>) => string;
function Section({ title, hint, action, children }: { title: string; hint?: string; action?: React.ReactNode; children: React.ReactNode }) {
return (
<Card className="p-4 md:p-6">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{hint && <p className="text-sm text-gray-500">{hint}</p>}
</div>
{action}
</div>
{children}
</Card>
);
}
const saveError = (t: T) => (error: any) =>
toast.error(error?.code === 'IN_USE' ? t('admin.expenseSettings.inUse') : error.message);
// ==================== Categories ====================
function Categories({ categories, reload, t }: { categories: ExpenseCategory[]; reload: () => void; t: T }) {
const [editing, setEditing] = useState<Partial<ExpenseCategory> | null>(null);
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const save = async () => {
if (!editing?.nameEn?.trim() || !editing?.nameEs?.trim()) return;
setSaving(true);
const body = { nameEn: editing.nameEn.trim(), nameEs: editing.nameEs.trim(), color: editing.color || '#6B7280', sortOrder: editing.sortOrder ?? categories.length };
try {
if (editing.id) await financeApi.updateCategory(editing.id, body); else await financeApi.createCategory(body);
toast.success(t('admin.expenseSettings.saved'));
setEditing(null);
reload();
} catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
};
const archive = (c: ExpenseCategory) => financeApi.updateCategory(c.id, { archived: !c.archived }).then(reload).catch(saveError(t));
const remove = (c: ExpenseCategory) => {
if (!confirm(t('admin.expenseSettings.confirmDelete', { name: c.nameEn }))) return;
financeApi.deleteCategory(c.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
};
const visible = categories.filter((c) => showArchived || !c.archived);
return (
<Section
title={t('admin.expenseSettings.categories')}
action={<Button size="sm" onClick={() => setEditing({ color: '#2563EB' })}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.expenseSettings.addCategory')}</Button>}
>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} className="accent-primary-yellow" />
{t('admin.expenseSettings.showArchived')}
</label>
<ul className="divide-y divide-gray-100">
{visible.map((c) => (
<li key={c.id} className={clsx('flex items-center gap-3 py-2', c.archived && 'opacity-60')}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: c.color }} />
<span className="flex-1 min-w-0 text-sm truncate">{c.nameEn} <span className="text-gray-400">/ {c.nameEs}</span></span>
{c.archived && <Pill>{t('admin.expenseSettings.archived')}</Pill>}
<button onClick={() => setEditing(c)} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => archive(c)} className="p-2 rounded-btn hover:bg-gray-100" title={t(c.archived ? 'admin.expenseSettings.unarchive' : 'admin.expenseSettings.archive')}>
{c.archived ? <ArrowUturnLeftIcon className="w-4 h-4" /> : <ArchiveBoxIcon className="w-4 h-4" />}
</button>
<button onClick={() => remove(c)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</li>
))}
</ul>
<Modal open={!!editing} onClose={() => setEditing(null)} title={t('admin.expenseSettings.categories')}>
<Field label={t('admin.expenseSettings.nameEn')}>
<input className={inputClass} value={editing?.nameEn || ''} maxLength={100} onChange={(e) => setEditing((x) => ({ ...x, nameEn: e.target.value }))} />
</Field>
<Field label={t('admin.expenseSettings.nameEs')}>
<input className={inputClass} value={editing?.nameEs || ''} maxLength={100} onChange={(e) => setEditing((x) => ({ ...x, nameEs: e.target.value }))} />
</Field>
<Field label={t('admin.expenseSettings.color')}>
<input type="color" className="h-10 w-16 rounded-btn border border-secondary-light-gray" value={editing?.color || '#6B7280'} onChange={(e) => setEditing((x) => ({ ...x, color: e.target.value }))} />
</Field>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setEditing(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!editing?.nameEn?.trim() || !editing?.nameEs?.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</div>
</Modal>
</Section>
);
}
// ==================== Templates ====================
interface TemplateForm { id?: string; name: string; categoryId: string; description: string; calcType: CalcType; amount: number; percent: string; minimumAmount: number }
function templateSummary(tpl: ExpenseTemplate, t: T, locale: string) {
const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${formatPercent(tpl.percentBp, locale)}`;
if (tpl.calcType === 'minimum_spend') return `${kind} · ${formatPyg(tpl.amount, locale)} / min ${formatPyg(tpl.minimumAmount, locale)}`;
return `${kind} · ${formatPyg(tpl.amount, locale)}`;
}
/** Label for the main amount field, matching the event expense modal. */
const AMOUNT_LABEL: Record<CalcType, string> = {
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',
};
function Templates({ templates, categories, reload, t, locale }: { templates: ExpenseTemplate[]; categories: ExpenseCategory[]; reload: () => void; t: T; locale: string }) {
const [form, setForm] = useState<TemplateForm | null>(null);
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
// Category follows the name until someone picks one by hand (same as the event expense modal).
const [categoryAuto, setCategoryAuto] = useState(true);
const set = <K extends keyof TemplateForm>(k: K, v: TemplateForm[K]) => setForm((f) => (f ? { ...f, [k]: v } : f));
const setName = (name: string) => setForm((f) => (f
? { ...f, name, categoryId: categoryAuto ? suggestCategory(name, categories) || '' : f.categoryId }
: f));
const open = (tpl?: ExpenseTemplate) => {
setCategoryAuto(!tpl?.categoryId);
openForm(tpl);
};
const openForm = (tpl?: ExpenseTemplate) => setForm(tpl
? { id: tpl.id, name: tpl.name, categoryId: tpl.categoryId || '', description: tpl.description || '', calcType: tpl.calcType, amount: tpl.amount, percent: tpl.percentBp ? bpToPercent(tpl.percentBp) : '', minimumAmount: tpl.minimumAmount }
: { name: '', categoryId: '', description: '', calcType: 'fixed', amount: 0, percent: '', minimumAmount: 0 });
const save = async () => {
if (!form?.name.trim()) return;
setSaving(true);
const body: Partial<ExpenseTemplate> = {
name: form.name.trim(),
categoryId: form.categoryId || null,
description: form.description.trim() || null,
calcType: form.calcType,
amount: form.calcType === 'percent_of_revenue' ? 0 : form.amount,
percentBp: form.calcType === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
minimumAmount: form.calcType === 'minimum_spend' ? form.minimumAmount : 0,
};
try {
if (form.id) await financeApi.updateTemplate(form.id, body); else await financeApi.createTemplate(body);
toast.success(t('admin.expenseSettings.saved'));
setForm(null);
reload();
} catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
};
const remove = (tpl: ExpenseTemplate) => {
if (!confirm(t('admin.expenseSettings.confirmDelete', { name: tpl.name }))) return;
financeApi.deleteTemplate(tpl.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
};
const archive = (tpl: ExpenseTemplate) => financeApi.updateTemplate(tpl.id, { archived: !tpl.archived }).then(reload).catch(saveError(t));
const catName = (id: string | null) => {
const c = categories.find((x) => x.id === id);
return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
};
const visible = templates.filter((x) => showArchived || !x.archived);
return (
<Section
title={t('admin.expenseSettings.templates')}
action={<Button size="sm" onClick={() => open()}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.expenseSettings.addTemplate')}</Button>}
>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} className="accent-primary-yellow" />
{t('admin.expenseSettings.showArchived')}
</label>
{visible.length === 0 ? <p className="text-sm text-gray-400">{t('admin.expenseSettings.empty')}</p> : (
<ul className="divide-y divide-gray-100">
{visible.map((tpl) => (
<li key={tpl.id} className={clsx('flex items-center gap-3 py-2', tpl.archived && 'opacity-60')}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{tpl.name}</p>
<p className="text-xs text-gray-500 truncate">{catName(tpl.categoryId)} · {templateSummary(tpl, t, locale)}</p>
</div>
{tpl.archived && <Pill>{t('admin.expenseSettings.archived')}</Pill>}
<button onClick={() => open(tpl)} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => archive(tpl)} className="p-2 rounded-btn hover:bg-gray-100" title={t(tpl.archived ? 'admin.expenseSettings.unarchive' : 'admin.expenseSettings.archive')}>
{tpl.archived ? <ArrowUturnLeftIcon className="w-4 h-4" /> : <ArchiveBoxIcon className="w-4 h-4" />}
</button>
<button onClick={() => remove(tpl)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<Modal open={!!form} onClose={() => setForm(null)} title={t('admin.expenseSettings.templates')} wide>
{form && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<Field label={t('admin.expenseSettings.name')}>
<input className={inputClass} value={form.name} maxLength={200} onChange={(e) => setName(e.target.value)} />
</Field>
</div>
<Field label={t(AMOUNT_LABEL[form.calcType])}>
{form.calcType === 'percent_of_revenue' ? (
<div className="relative">
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} placeholder="10" 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>
) : form.calcType === 'minimum_spend' ? (
<PygInput value={form.minimumAmount} onChange={(n) => set('minimumAmount', n)} />
) : (
<PygInput value={form.amount} onChange={(n) => set('amount', n)} />
)}
</Field>
<Field label={t('admin.finance.expenses.calcType')} hint={t(`admin.finance.calcHints.${form.calcType}`)}>
<select className={inputClass} value={form.calcType} 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>
{form.calcType === 'minimum_spend' && (
<Field label={t('admin.finance.expenses.amountPerGuest')}>
<PygInput value={form.amount} onChange={(n) => set('amount', n)} />
</Field>
)}
<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.filter((c) => !c.archived || c.id === form.categoryId).map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
</select>
</Field>
<div className="md:col-span-2">
<Field label={t('admin.expenseSettings.description')}>
<textarea className={inputClass} rows={2} value={form.description} maxLength={1000} onChange={(e) => set('description', e.target.value)} />
</Field>
</div>
</div>
)}
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setForm(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!form?.name.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</div>
</Modal>
</Section>
);
}
// ==================== Packs ====================
function Packs({ packs, templates, reload, t, locale }: { packs: ExpenseTemplatePack[]; templates: ExpenseTemplate[]; reload: () => void; t: T; locale: string }) {
const [form, setForm] = useState<{ id?: string; name: string; description: string; templateIds: string[] } | null>(null);
const [saving, setSaving] = useState(false);
const name = (id: string) => templates.find((x) => x.id === id)?.name || '-';
const save = async () => {
if (!form?.name.trim()) return;
setSaving(true);
const body = { name: form.name.trim(), description: form.description.trim() || null, templateIds: form.templateIds };
try {
if (form.id) await financeApi.updatePack(form.id, body); else await financeApi.createPack(body);
toast.success(t('admin.expenseSettings.saved'));
setForm(null);
reload();
} catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
};
const remove = (p: ExpenseTemplatePack) => {
if (!confirm(t('admin.expenseSettings.confirmDelete', { name: p.name }))) return;
financeApi.deletePack(p.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
};
const toggle = (id: string) => setForm((f) => f && ({ ...f, templateIds: f.templateIds.includes(id) ? f.templateIds.filter((x) => x !== id) : [...f.templateIds, id] }));
return (
<Section
title={t('admin.expenseSettings.packs')}
action={<Button size="sm" onClick={() => setForm({ name: '', description: '', templateIds: [] })}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.expenseSettings.addPack')}</Button>}
>
{packs.length === 0 ? <p className="text-sm text-gray-400">{t('admin.expenseSettings.empty')}</p> : (
<ul className="divide-y divide-gray-100">
{packs.map((p) => (
<li key={p.id} className="flex items-center gap-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-gray-500 truncate">{p.templateIds.map(name).join(' · ')}</p>
</div>
<button onClick={() => setForm({ id: p.id, name: p.name, description: p.description || '', templateIds: p.templateIds })} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => remove(p)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<Modal open={!!form} onClose={() => setForm(null)} title={t('admin.expenseSettings.packs')}>
{form && (
<>
<Field label={t('admin.expenseSettings.name')}>
<input className={inputClass} value={form.name} maxLength={200} placeholder="Lingo Night pack" onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label={t('admin.expenseSettings.description')}>
<input className={inputClass} value={form.description} maxLength={1000} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<Field label={t('admin.expenseSettings.packTemplates')}>
<ul className="border border-gray-100 rounded-btn divide-y divide-gray-100 max-h-64 overflow-y-auto">
{templates.filter((x) => !x.archived || form.templateIds.includes(x.id)).map((tpl) => (
<li key={tpl.id}>
<label className="flex items-center gap-3 px-3 py-2 text-sm cursor-pointer min-h-[44px]">
<input type="checkbox" checked={form.templateIds.includes(tpl.id)} onChange={() => toggle(tpl.id)} className="w-4 h-4 accent-primary-yellow" />
<span className="flex-1 min-w-0">
<span className="block truncate">{tpl.name}</span>
<span className="block text-xs text-gray-500 truncate">{templateSummary(tpl, t, locale)}</span>
</span>
</label>
</li>
))}
</ul>
</Field>
</>
)}
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setForm(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!form?.name.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</div>
</Modal>
</Section>
);
}
// ==================== Fees ====================
function FeeRow({ fee, t, onSaved }: { fee: PaymentMethodFee; t: T; onSaved: () => void }) {
const [percent, setPercent] = useState(fee.percentBp ? bpToPercent(fee.percentBp) : '');
const [fixed, setFixed] = useState(fee.fixedAmount);
const [saving, setSaving] = useState(false);
const dirty = percentToBp(percent) !== fee.percentBp || fixed !== fee.fixedAmount;
const save = async () => {
setSaving(true);
try {
await financeApi.updateFee(fee.method, { percentBp: percentToBp(percent), fixedAmount: fixed });
toast.success(t('admin.expenseSettings.saved'));
onSaved();
} catch (error: any) { toast.error(error.message); } finally { setSaving(false); }
};
return (
<li className="grid grid-cols-2 md:grid-cols-[1fr_120px_180px_auto] items-end gap-2 py-3">
<p className="col-span-2 md:col-span-1 text-sm font-medium self-center">{t(`admin.finance.methods.${fee.method}`)}</p>
<Field label={t('admin.expenseSettings.percent')}>
<input className={inputClass} inputMode="decimal" value={percent} placeholder="0" onChange={(e) => setPercent(e.target.value)} />
</Field>
<Field label={t('admin.expenseSettings.fixed')}>
<PygInput value={fixed} onChange={setFixed} />
</Field>
<Button size="sm" onClick={save} disabled={!dirty} isLoading={saving} className="col-span-2 md:col-span-1 min-h-[42px]">{t('common.save')}</Button>
</li>
);
}
// ==================== Page section ====================
export default function ExpenseTemplatesSettings() {
const { t, locale } = useLanguage();
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
const [packs, setPacks] = useState<ExpenseTemplatePack[]>([]);
const [fees, setFees] = useState<PaymentMethodFee[]>([]);
const reload = useCallback(() => {
Promise.all([financeApi.getCategories(), financeApi.getTemplates(), financeApi.getPacks(), financeApi.getFees()])
.then(([c, tpl, p, f]) => { setCategories(c.categories); setTemplates(tpl.templates); setPacks(p.packs); setFees(f.fees); })
.catch((error) => toast.error(error.message));
}, []);
useEffect(() => { reload(); }, [reload]);
return (
<div className="space-y-6">
<Templates templates={templates} categories={categories} reload={reload} t={t} locale={locale} />
<Packs packs={packs} templates={templates} reload={reload} t={t} locale={locale} />
<Categories categories={categories} reload={reload} t={t} />
<Section title={t('admin.expenseSettings.fees')} hint={t('admin.expenseSettings.feesHint')}>
<ul className="divide-y divide-gray-100">
{fees.map((f) => <FeeRow key={`${f.method}-${f.percentBp}-${f.fixedAmount}`} fee={f} t={t} onSaved={reload} />)}
</ul>
</Section>
</div>
);
}
+81 -3
View File
@@ -8,8 +8,10 @@ import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import DurationInput from '@/components/admin/DurationInput';
import {
Cog6ToothIcon,
TicketIcon,
GlobeAltIcon,
ClockIcon,
EnvelopeIcon,
@@ -17,10 +19,12 @@ import {
CheckCircleIcon,
StarIcon,
ScaleIcon,
BanknotesIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import ExpenseTemplatesSettings from './_components/ExpenseTemplatesSettings';
type SettingsTab = 'general' | 'legal';
type SettingsTab = 'general' | 'legal' | 'expenses';
export default function AdminSettingsPage() {
const { t, locale } = useLanguage();
@@ -47,6 +51,8 @@ export default function AdminSettingsPage() {
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
presaleClosureEnabled: true,
presaleCloseMinutesBefore: 120,
});
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
@@ -191,6 +197,12 @@ export default function AdminSettingsPage() {
labelEs: 'Configuración Legal',
icon: <ScaleIcon className="w-4 h-4" />,
},
{
id: 'expenses',
label: t('admin.expenseSettings.tab'),
labelEs: t('admin.expenseSettings.tab'),
icon: <BanknotesIcon className="w-4 h-4" />,
},
];
return (
@@ -222,8 +234,8 @@ export default function AdminSettingsPage() {
</div>
{/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6">
<nav className="flex space-x-0" aria-label="Tabs">
<div className="border-b border-secondary-light-gray mb-6 overflow-x-auto scrollbar-hide">
<nav className="flex space-x-0 min-w-max" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -508,6 +520,69 @@ export default function AdminSettingsPage() {
</div>
</Card>
{/* Pre-sale Closure defaults */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<TicketIcon className="w-5 h-5 text-green-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Cierre de Preventa' : 'Pre-sale Closure'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Valor por defecto para los eventos. Cada evento puede cambiarlo en su ventana de edición.'
: 'Default for events. Each event can override this in its edit dialog.'}
</p>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg mb-4">
<div>
<p className="font-medium">
{locale === 'es' ? 'Cerrar la preventa antes del evento' : 'Close pre-sale before the event starts'}
</p>
<p className="text-sm text-gray-500">
{settings.presaleClosureEnabled
? (locale === 'es' ? 'Las inscripciones en línea se cierran antes del inicio' : 'Online registration stops before the start time')
: (locale === 'es' ? 'Las inscripciones siguen abiertas hasta el inicio' : 'Registration stays open until the event starts')}
</p>
</div>
<button
type="button"
onClick={() => updateSetting('presaleClosureEnabled', !settings.presaleClosureEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.presaleClosureEnabled ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.presaleClosureEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{settings.presaleClosureEnabled && (
<div className="max-w-md">
<DurationInput
label={locale === 'es' ? 'Cerrar la preventa este tiempo antes del inicio' : 'Close pre-sale this long before the start'}
valueMinutes={settings.presaleCloseMinutesBefore}
onChange={(minutes) => updateSetting('presaleCloseMinutesBefore', minutes)}
unitLabels={locale === 'es'
? { minutes: 'minutos', hours: 'horas', days: 'días' }
: { minutes: 'minutes', hours: 'hours', days: 'days' }}
helper={locale === 'es'
? 'Se aplica a los eventos que no tienen su propia configuración.'
: 'Applies to events that have not set their own value.'}
/>
</div>
)}
</div>
</Card>
{/* Maintenance Mode */}
<Card>
<div className="p-6">
@@ -602,6 +677,9 @@ export default function AdminSettingsPage() {
</div>
)}
{/* Expense Templates Tab (rows save individually, no page-level Save) */}
{activeTab === 'expenses' && <ExpenseTemplatesSettings />}
{/* Legal Settings Tab */}
{activeTab === 'legal' && (
<div className="space-y-6">
@@ -0,0 +1,91 @@
'use client';
import { useEffect, useState } from 'react';
import Input from '@/components/ui/Input';
import { DurationUnit, durationToMinutes, minutesToDuration } from '@/lib/utils';
interface DurationInputProps {
label?: string;
/** Duration in minutes (the stored unit). */
valueMinutes: number;
onChange: (minutes: number) => void;
disabled?: boolean;
helper?: string;
/** Unit labels, overridable for Spanish admin pages. */
unitLabels?: Record<DurationUnit, string>;
}
const DEFAULT_UNIT_LABELS: Record<DurationUnit, string> = {
minutes: 'minutes',
hours: 'hours',
days: 'days',
};
/**
* Number + unit (minutes / hours / days) picker that always reports minutes.
* The unit is local UI state so switching hours -> minutes keeps the typed
* number rather than the stored value.
*/
export default function DurationInput({
label,
valueMinutes,
onChange,
disabled,
helper,
unitLabels = DEFAULT_UNIT_LABELS,
}: DurationInputProps) {
const initial = minutesToDuration(valueMinutes);
const [unit, setUnit] = useState<DurationUnit>(initial.unit);
const [value, setValue] = useState<string>(String(initial.value));
// Re-sync when the parent swaps in a new stored value (e.g. modal reopened
// for another event) that doesn't match what this input last reported.
useEffect(() => {
if (durationToMinutes(Number(value), unit) === valueMinutes) return;
const next = minutesToDuration(valueMinutes);
setUnit(next.unit);
setValue(String(next.value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueMinutes]);
const emit = (nextValue: string, nextUnit: DurationUnit) => {
const n = Number(nextValue);
onChange(durationToMinutes(Number.isFinite(n) ? n : 0, nextUnit));
};
return (
<div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label={label}
type="number"
min="0"
step="1"
value={value}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value);
emit(e.target.value, unit);
}}
/>
</div>
<select
value={unit}
disabled={disabled}
onChange={(e) => {
const nextUnit = e.target.value as DurationUnit;
setUnit(nextUnit);
emit(value, nextUnit);
}}
className="px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:opacity-50"
>
{(Object.keys(unitLabels) as DurationUnit[]).map((u) => (
<option key={u} value={u}>{unitLabels[u]}</option>
))}
</select>
</div>
{helper && <p className="text-xs text-gray-500 mt-1">{helper}</p>}
</div>
);
}
+515 -4
View File
@@ -88,12 +88,34 @@
"spotsLeft": "spots left",
"soldOut": "Sold Out",
"cancelled": "Cancelled",
"eventEnded": "Event Ended"
"eventEnded": "Event Ended",
"registrationClosed": "Registration Closed",
"presaleClosesBefore": "Pre-sale registration closes {duration} before the event begins.",
"presaleClosesAtStart": "Pre-sale registration closes when the event begins.",
"eventHasEnded": "This event has ended"
},
"booking": {
"join": "Join Event",
"book": "Book Now",
"register": "Register"
},
"door": {
"onlineClosed": "Online sales closed",
"headline": "Tickets available at the door",
"perPerson": "per person, pay at the door",
"onlinePriceWas": "Online price was {price}",
"spotsLeft": "{n} spots left",
"spotsLeftOne": "1 spot left",
"spotsLeftAtDoor": "{n} spots left (at the door)",
"accepts": "Accepted at the door: {methods}",
"doorsOpen": "Doors open at {time}",
"badge": "Tickets at the door",
"methods": {
"cash": "cash",
"pos": "card",
"bitcoin": "Lightning",
"transfer": "bank transfer"
}
}
},
"booking": {
@@ -171,6 +193,13 @@
"receivedConfirmShortly": "Payment received. We will confirm it shortly.",
"receivedConfirmSoon": "Payment received. We will confirm it shortly.",
"confirmForEvent": "We will confirm your {amount} payment for {event} shortly. Nothing else needed."
},
"myEvents": {
"tab": "My Events",
"empty": "You are not on any event team yet.",
"open": "Open",
"back": "Back to my events",
"noAccess": "You do not have access to this event."
}
},
"community": {
@@ -283,7 +312,8 @@
"emails": "Emails",
"gallery": "Gallery",
"photos": "Photos",
"settings": "Settings"
"settings": "Settings",
"finance": "Finance"
},
"events": {
"title": "Manage Events",
@@ -291,7 +321,13 @@
"edit": "Edit Event",
"delete": "Delete Event",
"publish": "Publish",
"unpublish": "Unpublish"
"unpublish": "Unpublish",
"form": {
"walkInPrice": "Walk-in price",
"walkInPricePlaceholder": "Same as ticket price ({price})",
"walkInPriceHelp": "Charged at the door via the Scanner. Not shown on the public event page.",
"walkInPriceInvalid": "Walk-in price must be empty or a whole number of 0 or more"
}
},
"tickets": {
"title": "Manage Tickets",
@@ -325,6 +361,481 @@
"refunded": "Refunded",
"failed": "Failed"
}
},
"eventTabs": {
"overview": "Overview",
"attendees": "Attendees",
"tickets": "Tickets",
"email": "Email",
"payments": "Payments",
"finance": "Finance",
"team": "Team"
},
"finance": {
"subnav": {
"summary": "Summary",
"expenses": "Costs & income",
"split": "Split & Payouts"
},
"status": {
"open": "Open",
"finalized": "Finalized",
"paid_out": "Paid out"
},
"frozenNote": "Numbers frozen at finalize on {date}. Unfinalize to make changes.",
"loadError": "Could not load finance data",
"hiddenStats": "Finance numbers are hidden. Use Show Stats to reveal them.",
"kpi": {
"gross": "Gross revenue",
"fees": "Payment fees",
"expenses": "Expenses",
"profit": "Profit",
"loss": "Loss",
"breakEven": "Break-even",
"breakEvenNever": "Not reachable at this price",
"presaleDoor": "Pre-sale {presale} · Door {door}",
"ticketsSold": "{count} tickets sold",
"ticketRevenue": "Ticket revenue",
"revenueDetail": "{count} paid tickets, after refunds",
"afterFees": "After fees and all expenses",
"breakEvenProgress": "{sold} of {total}",
"beyondCapacity": "Needs more tickets than the capacity ({capacity}). Raise the price or cut costs.",
"estimated": "Estimated",
"estimatedHint": "After fees and all costs so far",
"presale": "Pre-sale",
"door": "Door"
},
"waterfall": {
"title": "From revenue to result",
"gross": "Gross revenue",
"refunds": "Refunds",
"fees": "Payment fees",
"otherIncome": "Other income",
"net": "Net revenue",
"expenses": "Expenses",
"profit": "Profit",
"organization": "Organization keeps",
"partners": "Partner payouts",
"legend": {
"in": "Money in",
"out": "Money out",
"total": "Total"
},
"estimatedHint": "Estimated: numbers follow sales and costs until the books are closed."
},
"charts": {
"byCategory": "Expenses by category",
"byMethod": "Revenue by payment method",
"cumulative": "Cumulative sales",
"breakEvenLine": "Break-even",
"revenue": "Revenue",
"plannedVsActual": "Planned vs paid expenses",
"planned": "Planned",
"paid": "Paid",
"empty": "No data yet",
"uncategorized": "Uncategorized",
"breakEvenOffChart": "Break-even is {count} tickets, far above sales so far.",
"tickets": "Tickets",
"addExpensesHint": "Add expenses to see the cost breakdown."
},
"methods": {
"tpago": "TPago",
"bank_transfer": "Bank transfer",
"lightning": "Lightning",
"cash": "Cash",
"pos": "POS",
"bancard": "Bancard"
},
"otherIncome": {
"title": "Other income",
"add": "Add income",
"hint": "Sponsors, venue kickbacks and other money in that is not a ticket.",
"empty": "No other income",
"confirmDelete": "Delete \"{name}\"?",
"placeholder": "e.g. Sponsor: Café Uno",
"edit": "Edit income"
},
"expenses": {
"add": "Add expense",
"applyTemplate": "Apply template",
"applyPack": "Apply pack",
"allCategories": "All categories",
"empty": "Add costs like the venue, instructor or snacks, or add your usual costs from a template.",
"description": "Description",
"category": "Category",
"calcType": "Calculated as",
"amount": "Amount",
"lockedAmount": "Locked amount (PYG)",
"status": "Status",
"paidBy": "Paid by",
"organization": "Organization",
"otherPartner": "Another partner",
"receipt": "Receipt",
"uploadReceipt": "Upload receipt",
"viewReceipt": "View receipt",
"receiptAdminOnly": "Receipt uploads are available to admins and organizers.",
"date": "Date",
"lock": "Lock amount",
"locked": "Locked",
"auto": "Auto",
"lockHint": "Keep this amount fixed instead of following ticket sales.",
"markPaid": "Mark as paid",
"markPlanned": "Mark as not paid yet",
"confirmDelete": "Delete this expense?",
"created": "Expense added",
"saved": "Expense saved",
"deleted": "Expense deleted",
"applied": "{count} expense(s) added",
"edit": "Edit expense",
"new": "New expense",
"pickTemplate": "Choose a template",
"pickPack": "Choose a pack",
"noTemplates": "No templates yet. Create them in Settings → Expense Templates.",
"ownOnly": "You can edit the expenses you added.",
"total": "Total",
"descriptionRequired": "Add a short description",
"descriptionPlaceholder": "e.g. Studio rental",
"paidByHint": "Pick a partner if they paid it themselves; they get it back in their payout.",
"upload": "Upload",
"useTemplate": "Use a template",
"useTemplateHint": "Add your usual costs in one tap",
"packsHeading": "Packs (several costs at once)",
"templatesHeading": "Single costs",
"addAll": "Add {count}",
"addOne": "Add",
"confirmDeleteNamed": "Delete \"{name}\"?",
"emptyTitle": "No expenses yet",
"unlockHint": "Locked. Unlock to follow ticket sales again.",
"frozen": "Finalized: expenses can't be changed.",
"filtered": "filtered",
"amountPerTicket": "Amount per ticket",
"amountPerGuest": "Amount per guest",
"percentOfRevenue": "Percentage of ticket revenue",
"minimumAmount": "Minimum amount",
"categorySuggested": "Suggested from the description"
},
"calcTypes": {
"fixed": "Fixed",
"per_ticket_sold": "Per ticket sold",
"per_checked_in": "Per checked-in guest",
"percent_of_revenue": "Percent of revenue",
"minimum_spend": "Minimum spend"
},
"calcHints": {
"fixed": "One set amount, e.g. instructor fee.",
"per_ticket_sold": "Grows with every paid ticket, e.g. a drink voucher.",
"per_checked_in": "Grows with every guest who shows up, e.g. mat rental.",
"percent_of_revenue": "A share of ticket sales, e.g. platform commission.",
"minimum_spend": "Venue minimum: the higher of a minimum or per-guest spend."
},
"expenseStatus": {
"planned": "Planned",
"paid": "Paid"
},
"split": {
"partners": "Partners",
"addPartner": "Add partner",
"editPartner": "Edit partner",
"empty": "No partners: the organization keeps the whole result.",
"linkedUser": "Linked user",
"searchUser": "Search users by name or email",
"externalName": "Or type a name",
"roleLabel": "Role",
"shareType": "Deal",
"percent": "Percent (%)",
"fixedAmount": "Fixed amount (PYG)",
"threshold": "Profit threshold (PYG)",
"lossRule": "If there is a loss",
"lossCap": "Loss cap (PYG)",
"share": "Share",
"reimbursement": "Reimbursements",
"payout": "Payout",
"owes": "Owes",
"organization": "Organization keeps",
"distributable": "Left after fixed & revenue deals",
"markPaid": "Mark as paid",
"markUnpaid": "Undo payout",
"payoutMethod": "Payout method",
"payoutNote": "Note",
"paidOn": "Paid {date}",
"pending": "Pending",
"statement": "Statement PDF",
"finalize": "Finalize",
"unfinalize": "Unfinalize",
"unfinalizeConfirm": "Unfinalize? Numbers will follow live sales again. This is logged.",
"finalized": "Event finalized",
"unfinalized": "Event reopened",
"finalizeFirst": "Payouts can be recorded after you close the books.",
"ownShareOnly": "You see your own share. The full split is visible to managers.",
"partnerSaved": "Partner saved",
"partnerRemoved": "Partner removed",
"confirmRemove": "Remove this partner?",
"needsNameOrUser": "Link a user or type a name",
"auditLog": "Activity log",
"auditEmpty": "No activity yet",
"who": "Who",
"whoHint": "Link a user so they can log in and see their statement, or just type a name.",
"rolePlaceholder": "e.g. Venue, Co-host, Instructor",
"payoutMethodPlaceholder": "e.g. Bank transfer",
"confirmRemoveNamed": "Remove {name} from the split?",
"distributableHint": "Profit shares are taken from this",
"closeBooks": "Close the books",
"booksClosed": "Books closed",
"closeBooksHint": "Once all costs are in, finalize to freeze the numbers and pay partners. You can reopen later.",
"payoutsProgress": "{paid} of {total} partner payouts recorded.",
"noPartnersTitle": "No partners on this event",
"noPartnersBody": "Add a venue, co-host or instructor who shares in the result. Their payout is calculated for you.",
"calculation": "How the result is split",
"ofDistributable": "{rule}, taken from {base} left after fixed and revenue deals",
"unpaid": "Unpaid",
"includesReimbursement": "Incl. {amount} reimbursed",
"frozenNoPartners": "Numbers are frozen. There are no partners to pay."
},
"shareTypes": {
"percent_profit": "Percent of profit",
"percent_revenue": "Percent of revenue",
"fixed": "Fixed amount",
"fixed_plus_percent_above_threshold": "Fixed + percent above threshold"
},
"lossRules": {
"proportional": "Shares the loss proportionally",
"none": "Does not share losses",
"capped": "Shares the loss up to a cap"
},
"empty": {
"title": "No money in or out yet",
"body": "Ticket sales appear here automatically. Add the event's costs to see profit and break-even."
},
"statement": {
"title": "Profit & loss",
"ticketSales": "Ticket sales ({count})",
"carriesLoss": "Carries part of the loss"
},
"formula": {
"perTicket": "{count} tickets × {amount}",
"perGuest": "{count} guests × {amount}",
"percent": "{percent} of ticket revenue",
"minimum": "min {minimum} or {count} × {amount}"
},
"shareHints": {
"percent_profit": "A percentage of what's left after costs.",
"percent_revenue": "A percentage of ticket sales, paid even if the event loses money.",
"fixed": "A set amount, whatever the result.",
"fixed_plus_percent_above_threshold": "A set amount plus a bonus percentage of profit above a target."
},
"lossRuleTitles": {
"none": "Org covers losses",
"proportional": "Shares losses",
"capped": "Shares up to a cap"
},
"deal": {
"percentProfit": "{percent} of profit",
"percentRevenue": "{percent} of ticket sales",
"fixed": "Fixed {amount}",
"fixedPlus": "{amount} + {percent} of profit above {threshold}"
},
"audit": {
"create": "added",
"update": "changed",
"delete": "deleted",
"apply_template": "added from template",
"apply_pack": "added from pack",
"mark_paid": "payout recorded",
"mark_unpaid": "payout undone",
"finalize": "books closed",
"unfinalize": "books reopened",
"paid_out": "all partners paid",
"finalized": "back to finalized",
"entity": {
"expense": "Expense",
"other_income": "Other income",
"partner": "Partner",
"finance_state": "Finance",
"member": "Team"
}
},
"close": "Close",
"pickDate": "Pick a date",
"clearDate": "Clear date",
"lifecycle": {
"label": "Books status",
"steps": {
"selling": "Selling",
"costs": "Adding costs",
"ready": "Ready to close",
"finalized": "Finalized"
},
"todo": {
"noCosts": "No costs added yet",
"planned_one": "1 planned expense not marked paid",
"planned_other": "{count} planned expenses not marked paid",
"noPartners": "No partners added",
"payoutsPending_one": "1 partner payout to record",
"payoutsPending_other": "{count} partner payouts to record",
"notEnded": "The event has not ended yet"
},
"nudge_one": "This event ended 1 day ago. Add any remaining costs and close the books.",
"nudge_other": "This event ended {count} days ago. Add any remaining costs and close the books.",
"nudgeToday": "This event ended today. Add any remaining costs and close the books.",
"reviewCosts": "Review costs",
"frozen": "Numbers frozen on {date}.",
"paidOut": "Numbers frozen on {date}. Every partner has been paid.",
"nudgeReady_one": "This event ended 1 day ago and every cost is marked paid. You can close the books in Split & Payouts.",
"nudgeReady_other": "This event ended {count} days ago and every cost is marked paid. You can close the books in Split & Payouts.",
"nudgeReadyToday": "This event ended today and every cost is marked paid. You can close the books in Split & Payouts."
},
"finalizeDialog": {
"title": "Close the books",
"subtitle": "Check everything is in before freezing the numbers.",
"notEnded": "The event has not ended yet",
"noExpenses": "No expenses recorded",
"expenses_one": "1 expense recorded",
"expenses_other": "{count} expenses recorded",
"planned_one": "1 planned expense not marked paid",
"planned_other": "{count} planned expenses not marked paid",
"allPaid": "All expenses marked paid",
"income_one": "1 other income entry",
"income_other": "{count} other income entries",
"partners_one": "Payout to 1 partner",
"partners_other": "Payouts to {count} partners",
"noPartners": "No partners: the organization keeps the result",
"finalProfit": "Final profit",
"finalLoss": "Final loss",
"hint": "Numbers stop following sales and costs. You can record partner payouts afterwards, and an admin can reopen the books if needed.",
"confirm": "Finalize and freeze numbers"
},
"breakEven": {
"noCosts": "No costs yet",
"noCostsHint": "Add costs to see how many tickets cover them.",
"reached": "Break-even reached at {at} of {sold} tickets sold",
"needed": "{count} more tickets to break even",
"coveredWithoutTickets": "Costs already covered by other income"
},
"preview": {
"fixed": "Adds {total} to this event's costs",
"perTicket_one": "{amount} × {count} ticket = {total}",
"perTicket_other": "{amount} × {count} tickets = {total}",
"perGuest_one": "{amount} × {count} checked-in guest = {total}",
"perGuest_other": "{amount} × {count} checked-in guests = {total}",
"percent": "{percent} of {sales} ticket revenue = {total}",
"minimum": "Higher of {minimum} or {count} × {amount} = {total}",
"locked": "Locked at {total}"
},
"ledger": {
"income": "Income",
"costs": "Costs",
"allRows": "All costs and income",
"emptyTitle": "No costs or income yet"
}
},
"team": {
"title": "Team",
"hint": "People added here get access to this event only, with the permissions below. Admins always have full access.",
"add": "Add member",
"search": "Search users by name or email",
"noResults": "No users found",
"empty": "No team members yet",
"role": "Role",
"remove": "Remove",
"confirmRemove": "Remove {name} from this event?",
"added": "Member added",
"saved": "Permissions saved",
"removed": "Member removed",
"override": "Changed from the role default",
"reset": "Reset to role defaults",
"permissions": "Permissions",
"permissionsSummary": "{on} of {total} on"
},
"rolePresets": {
"staff": "Staff",
"collaborator": "Collaborator",
"co_manager": "Co-manager"
},
"permissions": {
"view_overview": "View overview",
"check_in": "Check in guests",
"view_attendees_names": "See attendee names",
"view_attendees_pii": "See attendee contact details",
"email_attendees": "Email attendees",
"view_payments": "View payments",
"view_finance": "View finance",
"edit_expenses": "Edit expenses",
"edit_own_expenses_only": "Add and edit own expenses",
"view_full_split": "See the full split",
"edit_event": "Edit event",
"manage_team": "Manage team"
},
"expenseSettings": {
"tab": "Expense Templates",
"categories": "Categories",
"templates": "Templates",
"packs": "Template packs",
"fees": "Payment method fees",
"feesHint": "Charged per paid payment when computing an event's net revenue.",
"addCategory": "Add category",
"addTemplate": "Add template",
"addPack": "Add pack",
"nameEn": "Name (English)",
"nameEs": "Name (Spanish)",
"color": "Color",
"name": "Name",
"description": "Description",
"archived": "Archived",
"archive": "Archive",
"unarchive": "Restore",
"showArchived": "Show archived",
"packTemplates": "Templates in this pack",
"percent": "Percent (%)",
"fixed": "Fixed (PYG)",
"saved": "Saved",
"deleted": "Deleted",
"inUse": "In use. Archive it instead.",
"confirmDelete": "Delete {name}?",
"empty": "Nothing here yet"
},
"financeOverview": {
"title": "Finance",
"subtitle": "Profit across events. Finalized events use their frozen numbers.",
"from": "From",
"to": "To",
"series": "Series",
"venue": "Venue",
"partner": "Partner",
"all": "All",
"noSeries": "No series",
"events": "Events",
"event": "Event",
"date": "Date",
"perEvent": "Profit per event",
"perSeries": "By series",
"perVenue": "By venue",
"perPartner": "Partner totals",
"paid": "Paid",
"pending": "Pending",
"empty": "No events with finance data in this range",
"filters": "Filters",
"readyCount_one": "1 past event",
"readyCount_other": "{count} past events",
"readyHint": "still have open books. Add any last costs and close them.",
"showReady": "Show ready to close",
"showAll": "Show all events",
"readyTitle": "Ready to close",
"readyBadge": "Ready to close",
"clippedHint": "Bars with a break mark are cut off to keep the others readable. The label shows the real value."
},
"eventForm": {
"series": "Series",
"seriesHint": "Groups recurring events (e.g. Morning Club) in the Finance overview."
},
"eventStats": {
"capacity": "Capacity",
"notCheckedIn": "Not checked in yet",
"checkedIn": "Checked in",
"revenue": "Revenue",
"presaleDoor": "Pre-sale {presale} · Door {door}",
"stats": "Stats",
"inShort": "{count} in",
"free": "Free"
}
},
"footer": {
@@ -383,4 +894,4 @@
"downloading": "Downloading…"
}
}
}
}
+515 -4
View File
@@ -88,12 +88,34 @@
"spotsLeft": "lugares disponibles",
"soldOut": "Agotado",
"cancelled": "Cancelado",
"eventEnded": "Evento Finalizado"
"eventEnded": "Evento Finalizado",
"registrationClosed": "Inscripciones Cerradas",
"presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.",
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento.",
"eventHasEnded": "Este evento ya terminó"
},
"booking": {
"join": "Unirse al Evento",
"book": "Reservar Ahora",
"register": "Registrarse"
},
"door": {
"onlineClosed": "Venta online cerrada",
"headline": "Entradas disponibles en la puerta",
"perPerson": "por persona, pago en la puerta",
"onlinePriceWas": "El precio online era {price}",
"spotsLeft": "{n} lugares disponibles",
"spotsLeftOne": "1 lugar disponible",
"spotsLeftAtDoor": "{n} lugares disponibles (en la puerta)",
"accepts": "Aceptamos en la puerta: {methods}",
"doorsOpen": "Las puertas abren a las {time}",
"badge": "Entradas en la puerta",
"methods": {
"cash": "efectivo",
"pos": "tarjeta",
"bitcoin": "Lightning",
"transfer": "transferencia"
}
}
},
"booking": {
@@ -171,6 +193,13 @@
"receivedConfirmShortly": "Pago recibido. Lo confirmaremos en breve.",
"receivedConfirmSoon": "Pago recibido. Lo confirmaremos pronto.",
"confirmForEvent": "Confirmaremos tu pago de {amount} para {event} en breve. No necesitas hacer nada más."
},
"myEvents": {
"tab": "Mis eventos",
"empty": "Todavía no formás parte del equipo de ningún evento.",
"open": "Abrir",
"back": "Volver a mis eventos",
"noAccess": "No tenés acceso a este evento."
}
},
"community": {
@@ -283,7 +312,8 @@
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
"settings": "Configuración"
"settings": "Configuración",
"finance": "Finanzas"
},
"events": {
"title": "Gestionar Eventos",
@@ -291,7 +321,13 @@
"edit": "Editar Evento",
"delete": "Eliminar Evento",
"publish": "Publicar",
"unpublish": "Despublicar"
"unpublish": "Despublicar",
"form": {
"walkInPrice": "Precio en puerta",
"walkInPricePlaceholder": "Igual al precio de la entrada ({price})",
"walkInPriceHelp": "Se cobra en la puerta desde el Escáner. No se muestra en la página pública del evento.",
"walkInPriceInvalid": "El precio en puerta debe quedar vacío o ser un número entero de 0 o más"
}
},
"tickets": {
"title": "Gestionar Tickets",
@@ -325,6 +361,481 @@
"refunded": "Reembolsado",
"failed": "Fallido"
}
},
"eventTabs": {
"overview": "Resumen",
"attendees": "Asistentes",
"tickets": "Entradas",
"email": "Email",
"payments": "Pagos",
"finance": "Finanzas",
"team": "Equipo"
},
"finance": {
"subnav": {
"summary": "Resumen",
"expenses": "Gastos e ingresos",
"split": "Reparto y pagos"
},
"status": {
"open": "Abierto",
"finalized": "Cerrado",
"paid_out": "Pagado"
},
"frozenNote": "Números congelados al cerrar el {date}. Reabrí para hacer cambios.",
"loadError": "No se pudieron cargar las finanzas",
"hiddenStats": "Los números están ocultos. Usá Mostrar estadísticas para verlos.",
"kpi": {
"gross": "Ingresos brutos",
"fees": "Comisiones",
"expenses": "Gastos",
"profit": "Ganancia",
"loss": "Pérdida",
"breakEven": "Punto de equilibrio",
"breakEvenNever": "No alcanzable a este precio",
"presaleDoor": "Preventa {presale} · Puerta {door}",
"ticketsSold": "{count} entradas vendidas",
"ticketRevenue": "Ingresos por entradas",
"revenueDetail": "{count} entradas pagas, sin reembolsos",
"afterFees": "Después de comisiones y gastos",
"breakEvenProgress": "{sold} de {total}",
"beyondCapacity": "Necesita más entradas que la capacidad ({capacity}). Subí el precio o bajá gastos.",
"estimated": "Estimado",
"estimatedHint": "Después de comisiones y los gastos cargados",
"presale": "Preventa",
"door": "Puerta"
},
"waterfall": {
"title": "De los ingresos al resultado",
"gross": "Ingresos brutos",
"refunds": "Reembolsos",
"fees": "Comisiones de pago",
"otherIncome": "Otros ingresos",
"net": "Ingreso neto",
"expenses": "Gastos",
"profit": "Ganancia",
"organization": "Queda para la organización",
"partners": "Pagos a socios",
"legend": {
"in": "Entra",
"out": "Sale",
"total": "Total"
},
"estimatedHint": "Estimado: los números siguen las ventas y los gastos hasta cerrar las cuentas."
},
"charts": {
"byCategory": "Gastos por categoría",
"byMethod": "Ingresos por método de pago",
"cumulative": "Ventas acumuladas",
"breakEvenLine": "Equilibrio",
"revenue": "Ingresos",
"plannedVsActual": "Gastos previstos vs pagados",
"planned": "Previsto",
"paid": "Pagado",
"empty": "Sin datos todavía",
"uncategorized": "Sin categoría",
"breakEvenOffChart": "El equilibrio es {count} entradas, muy por encima de lo vendido.",
"tickets": "Entradas",
"addExpensesHint": "Agregá gastos para ver el desglose de costos."
},
"methods": {
"tpago": "TPago",
"bank_transfer": "Transferencia",
"lightning": "Lightning",
"cash": "Efectivo",
"pos": "POS",
"bancard": "Bancard"
},
"otherIncome": {
"title": "Otros ingresos",
"add": "Agregar ingreso",
"hint": "Sponsors, comisiones del local y otros ingresos que no son entradas.",
"empty": "Sin otros ingresos",
"confirmDelete": "¿Eliminar \"{name}\"?",
"placeholder": "ej. Sponsor: Café Uno",
"edit": "Editar ingreso"
},
"expenses": {
"add": "Agregar gasto",
"applyTemplate": "Aplicar plantilla",
"applyPack": "Aplicar paquete",
"allCategories": "Todas las categorías",
"empty": "Agregá gastos como el lugar, el instructor o snacks, o tus gastos habituales desde una plantilla.",
"description": "Descripción",
"category": "Categoría",
"calcType": "Se calcula",
"amount": "Monto",
"lockedAmount": "Monto fijado (PYG)",
"status": "Estado",
"paidBy": "Pagado por",
"organization": "Organización",
"otherPartner": "Otro socio",
"receipt": "Comprobante",
"uploadReceipt": "Subir comprobante",
"viewReceipt": "Ver comprobante",
"receiptAdminOnly": "La carga de comprobantes está disponible para admins y organizadores.",
"date": "Fecha",
"lock": "Fijar monto",
"locked": "Fijado",
"auto": "Auto",
"lockHint": "Mantené este monto fijo en vez de seguir las ventas.",
"markPaid": "Marcar como pagado",
"markPlanned": "Marcar como no pagado",
"confirmDelete": "¿Eliminar este gasto?",
"created": "Gasto agregado",
"saved": "Gasto guardado",
"deleted": "Gasto eliminado",
"applied": "{count} gasto(s) agregado(s)",
"edit": "Editar gasto",
"new": "Nuevo gasto",
"pickTemplate": "Elegí una plantilla",
"pickPack": "Elegí un paquete",
"noTemplates": "Todavía no hay plantillas. Crealas en Configuración → Plantillas de gastos.",
"ownOnly": "Podés editar los gastos que agregaste.",
"total": "Total",
"descriptionRequired": "Agregá una descripción",
"descriptionPlaceholder": "ej. Alquiler del estudio",
"paidByHint": "Elegí un socio si lo pagó de su bolsillo; se le devuelve en su pago.",
"upload": "Subir",
"useTemplate": "Usar plantilla",
"useTemplateHint": "Agregá tus gastos habituales de una vez",
"packsHeading": "Paquetes (varios gastos juntos)",
"templatesHeading": "Gastos individuales",
"addAll": "Agregar {count}",
"addOne": "Agregar",
"confirmDeleteNamed": "¿Eliminar \"{name}\"?",
"emptyTitle": "Todavía no hay gastos",
"unlockHint": "Fijado. Desbloqueá para volver a seguir las ventas.",
"frozen": "Cerrado: no se pueden cambiar los gastos.",
"filtered": "filtrado",
"amountPerTicket": "Monto por entrada",
"amountPerGuest": "Monto por asistente",
"percentOfRevenue": "Porcentaje de la venta de entradas",
"minimumAmount": "Monto mínimo",
"categorySuggested": "Sugerida según la descripción"
},
"calcTypes": {
"fixed": "Fijo",
"per_ticket_sold": "Por entrada vendida",
"per_checked_in": "Por asistente ingresado",
"percent_of_revenue": "Porcentaje de ingresos",
"minimum_spend": "Consumo mínimo"
},
"calcHints": {
"fixed": "Un monto fijo, ej. honorario del instructor.",
"per_ticket_sold": "Crece con cada entrada pagada, ej. un vale de bebida.",
"per_checked_in": "Crece con cada asistente que llega, ej. alquiler de mat.",
"percent_of_revenue": "Un porcentaje de las ventas, ej. comisión.",
"minimum_spend": "Consumo mínimo del local: el mayor entre el mínimo y el consumo por persona."
},
"expenseStatus": {
"planned": "Previsto",
"paid": "Pagado"
},
"split": {
"partners": "Socios",
"addPartner": "Agregar socio",
"editPartner": "Editar socio",
"empty": "Sin socios: la organización se queda con todo.",
"linkedUser": "Usuario vinculado",
"searchUser": "Buscar usuarios por nombre o email",
"externalName": "O escribí un nombre",
"roleLabel": "Rol",
"shareType": "Acuerdo",
"percent": "Porcentaje (%)",
"fixedAmount": "Monto fijo (PYG)",
"threshold": "Umbral de ganancia (PYG)",
"lossRule": "Si hay pérdida",
"lossCap": "Tope de pérdida (PYG)",
"share": "Participación",
"reimbursement": "Reembolsos",
"payout": "A pagar",
"owes": "Debe",
"organization": "Queda para la organización",
"distributable": "Resto tras acuerdos fijos y de ingresos",
"markPaid": "Marcar como pagado",
"markUnpaid": "Deshacer pago",
"payoutMethod": "Método de pago",
"payoutNote": "Nota",
"paidOn": "Pagado el {date}",
"pending": "Pendiente",
"statement": "Liquidación PDF",
"finalize": "Cerrar",
"unfinalize": "Reabrir",
"unfinalizeConfirm": "¿Reabrir? Los números vuelven a seguir las ventas. Queda registrado.",
"finalized": "Evento cerrado",
"unfinalized": "Evento reabierto",
"finalizeFirst": "Los pagos se registran después de cerrar las cuentas.",
"ownShareOnly": "Ves tu propia participación. El reparto completo lo ven los responsables.",
"partnerSaved": "Socio guardado",
"partnerRemoved": "Socio eliminado",
"confirmRemove": "¿Quitar este socio?",
"needsNameOrUser": "Vinculá un usuario o escribí un nombre",
"auditLog": "Registro de actividad",
"auditEmpty": "Sin actividad todavía",
"who": "Quién",
"whoHint": "Vinculá un usuario para que vea su liquidación, o escribí un nombre.",
"rolePlaceholder": "ej. Local, Co-anfitrión, Instructor",
"payoutMethodPlaceholder": "ej. Transferencia",
"confirmRemoveNamed": "¿Quitar a {name} del reparto?",
"distributableHint": "Las participaciones en la ganancia salen de acá",
"closeBooks": "Cerrar cuentas",
"booksClosed": "Cuentas cerradas",
"closeBooksHint": "Cuando estén todos los gastos, cerrá para congelar los números y pagar a los socios. Se puede reabrir.",
"payoutsProgress": "{paid} de {total} pagos a socios registrados.",
"noPartnersTitle": "No hay socios en este evento",
"noPartnersBody": "Agregá un local, co-anfitrión o instructor que participe del resultado. Su pago se calcula solo.",
"calculation": "Cómo se reparte el resultado",
"ofDistributable": "{rule}, sobre {base} que quedan tras los acuerdos fijos y de ingresos",
"unpaid": "Sin pagar",
"includesReimbursement": "Incluye {amount} de reembolso",
"frozenNoPartners": "Los números están congelados. No hay socios a quienes pagar."
},
"shareTypes": {
"percent_profit": "Porcentaje de la ganancia",
"percent_revenue": "Porcentaje de ingresos",
"fixed": "Monto fijo",
"fixed_plus_percent_above_threshold": "Fijo + porcentaje sobre umbral"
},
"lossRules": {
"proportional": "Comparte la pérdida proporcionalmente",
"none": "No comparte pérdidas",
"capped": "Comparte la pérdida hasta un tope"
},
"empty": {
"title": "Todavía no hay ingresos ni gastos",
"body": "Las ventas aparecen solas. Agregá los gastos del evento para ver la ganancia y el punto de equilibrio."
},
"statement": {
"title": "Resultado",
"ticketSales": "Venta de entradas ({count})",
"carriesLoss": "Asume parte de la pérdida"
},
"formula": {
"perTicket": "{count} entradas × {amount}",
"perGuest": "{count} asistentes × {amount}",
"percent": "{percent} de los ingresos",
"minimum": "mín {minimum} o {count} × {amount}"
},
"shareHints": {
"percent_profit": "Un porcentaje de lo que queda después de los gastos.",
"percent_revenue": "Un porcentaje de las ventas, se paga aunque haya pérdida.",
"fixed": "Un monto fijo, sea cual sea el resultado.",
"fixed_plus_percent_above_threshold": "Un monto fijo más un porcentaje de la ganancia sobre una meta."
},
"lossRuleTitles": {
"none": "La org cubre",
"proportional": "Comparte pérdidas",
"capped": "Comparte hasta un tope"
},
"deal": {
"percentProfit": "{percent} de la ganancia",
"percentRevenue": "{percent} de las ventas",
"fixed": "Fijo {amount}",
"fixedPlus": "{amount} + {percent} de la ganancia sobre {threshold}"
},
"audit": {
"create": "agregado",
"update": "modificado",
"delete": "eliminado",
"apply_template": "agregado desde plantilla",
"apply_pack": "agregado desde paquete",
"mark_paid": "pago registrado",
"mark_unpaid": "pago deshecho",
"finalize": "cuentas cerradas",
"unfinalize": "cuentas reabiertas",
"paid_out": "todos los socios cobraron",
"finalized": "vuelve a cerrado",
"entity": {
"expense": "Gasto",
"other_income": "Otro ingreso",
"partner": "Socio",
"finance_state": "Finanzas",
"member": "Equipo"
}
},
"close": "Cerrar",
"pickDate": "Elegí una fecha",
"clearDate": "Borrar fecha",
"lifecycle": {
"label": "Estado de las cuentas",
"steps": {
"selling": "Vendiendo",
"costs": "Cargando gastos",
"ready": "Listo para cerrar",
"finalized": "Cerrado"
},
"todo": {
"noCosts": "Todavía no hay gastos",
"planned_one": "1 gasto planificado sin marcar como pagado",
"planned_other": "{count} gastos planificados sin marcar como pagados",
"noPartners": "No hay socios",
"payoutsPending_one": "Falta registrar 1 pago a socio",
"payoutsPending_other": "Faltan registrar {count} pagos a socios",
"notEnded": "El evento todavía no terminó"
},
"nudge_one": "Este evento terminó hace 1 día. Agregá los gastos que falten y cerrá las cuentas.",
"nudge_other": "Este evento terminó hace {count} días. Agregá los gastos que falten y cerrá las cuentas.",
"nudgeToday": "Este evento terminó hoy. Agregá los gastos que falten y cerrá las cuentas.",
"reviewCosts": "Revisar gastos",
"frozen": "Números congelados el {date}.",
"paidOut": "Números congelados el {date}. Todos los socios cobraron.",
"nudgeReady_one": "Este evento terminó hace 1 día y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
"nudgeReady_other": "Este evento terminó hace {count} días y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
"nudgeReadyToday": "Este evento terminó hoy y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos."
},
"finalizeDialog": {
"title": "Cerrar cuentas",
"subtitle": "Revisá que esté todo antes de congelar los números.",
"notEnded": "El evento todavía no terminó",
"noExpenses": "No hay gastos cargados",
"expenses_one": "1 gasto cargado",
"expenses_other": "{count} gastos cargados",
"planned_one": "1 gasto planificado sin marcar como pagado",
"planned_other": "{count} gastos planificados sin marcar como pagados",
"allPaid": "Todos los gastos están pagados",
"income_one": "1 ingreso adicional",
"income_other": "{count} ingresos adicionales",
"partners_one": "Pago a 1 socio",
"partners_other": "Pagos a {count} socios",
"noPartners": "Sin socios: la organización se queda con el resultado",
"finalProfit": "Ganancia final",
"finalLoss": "Pérdida final",
"hint": "Los números dejan de seguir las ventas y los gastos. Después podés registrar los pagos a socios, y un admin puede reabrir las cuentas si hace falta.",
"confirm": "Cerrar y congelar números"
},
"breakEven": {
"noCosts": "Todavía sin gastos",
"noCostsHint": "Agregá gastos para ver cuántas entradas los cubren.",
"reached": "Equilibrio alcanzado con {at} de {sold} entradas vendidas",
"needed": "Faltan {count} entradas para cubrir gastos",
"coveredWithoutTickets": "Los gastos ya están cubiertos por otros ingresos"
},
"preview": {
"fixed": "Suma {total} a los gastos del evento",
"perTicket_one": "{amount} × {count} entrada = {total}",
"perTicket_other": "{amount} × {count} entradas = {total}",
"perGuest_one": "{amount} × {count} asistente ingresado = {total}",
"perGuest_other": "{amount} × {count} asistentes ingresados = {total}",
"percent": "{percent} de {sales} en entradas = {total}",
"minimum": "El mayor entre {minimum} o {count} × {amount} = {total}",
"locked": "Fijado en {total}"
},
"ledger": {
"income": "Ingreso",
"costs": "Gastos",
"allRows": "Todos los gastos e ingresos",
"emptyTitle": "Todavía no hay gastos ni ingresos"
}
},
"team": {
"title": "Equipo",
"hint": "Las personas que agregues acá acceden solo a este evento, con los permisos de abajo. Los admins siempre tienen acceso completo.",
"add": "Agregar miembro",
"search": "Buscar usuarios por nombre o email",
"noResults": "No se encontraron usuarios",
"empty": "Todavía no hay miembros",
"role": "Rol",
"remove": "Quitar",
"confirmRemove": "¿Quitar a {name} de este evento?",
"added": "Miembro agregado",
"saved": "Permisos guardados",
"removed": "Miembro quitado",
"override": "Cambiado respecto al rol",
"reset": "Volver a los permisos del rol",
"permissions": "Permisos",
"permissionsSummary": "{on} de {total} activos"
},
"rolePresets": {
"staff": "Staff",
"collaborator": "Colaborador",
"co_manager": "Co-responsable"
},
"permissions": {
"view_overview": "Ver resumen",
"check_in": "Registrar ingresos",
"view_attendees_names": "Ver nombres de asistentes",
"view_attendees_pii": "Ver datos de contacto",
"email_attendees": "Enviar emails a asistentes",
"view_payments": "Ver pagos",
"view_finance": "Ver finanzas",
"edit_expenses": "Editar gastos",
"edit_own_expenses_only": "Agregar y editar sus gastos",
"view_full_split": "Ver el reparto completo",
"edit_event": "Editar evento",
"manage_team": "Gestionar equipo"
},
"expenseSettings": {
"tab": "Plantillas de gastos",
"categories": "Categorías",
"templates": "Plantillas",
"packs": "Paquetes de plantillas",
"fees": "Comisiones por método de pago",
"feesHint": "Se descuentan por cada pago al calcular el ingreso neto de un evento.",
"addCategory": "Agregar categoría",
"addTemplate": "Agregar plantilla",
"addPack": "Agregar paquete",
"nameEn": "Nombre (inglés)",
"nameEs": "Nombre (español)",
"color": "Color",
"name": "Nombre",
"description": "Descripción",
"archived": "Archivado",
"archive": "Archivar",
"unarchive": "Restaurar",
"showArchived": "Mostrar archivados",
"packTemplates": "Plantillas del paquete",
"percent": "Porcentaje (%)",
"fixed": "Fijo (PYG)",
"saved": "Guardado",
"deleted": "Eliminado",
"inUse": "Está en uso. Archivalo.",
"confirmDelete": "¿Eliminar {name}?",
"empty": "Todavía no hay nada"
},
"financeOverview": {
"title": "Finanzas",
"subtitle": "Resultados de todos los eventos. Los eventos cerrados usan sus números congelados.",
"from": "Desde",
"to": "Hasta",
"series": "Serie",
"venue": "Lugar",
"partner": "Socio",
"all": "Todos",
"noSeries": "Sin serie",
"events": "Eventos",
"event": "Evento",
"date": "Fecha",
"perEvent": "Ganancia por evento",
"perSeries": "Por serie",
"perVenue": "Por lugar",
"perPartner": "Totales por socio",
"paid": "Pagado",
"pending": "Pendiente",
"empty": "No hay eventos con datos financieros en este rango",
"filters": "Filtros",
"readyCount_one": "1 evento pasado",
"readyCount_other": "{count} eventos pasados",
"readyHint": "todavía con cuentas abiertas. Agregá los últimos gastos y cerralas.",
"showReady": "Ver listos para cerrar",
"showAll": "Ver todos los eventos",
"readyTitle": "Listos para cerrar",
"readyBadge": "Listo para cerrar",
"clippedHint": "Las barras con una marca de corte están recortadas para que se lean las demás. La etiqueta muestra el valor real."
},
"eventForm": {
"series": "Serie",
"seriesHint": "Agrupa eventos recurrentes (ej. Morning Club) en el resumen de Finanzas."
},
"eventStats": {
"capacity": "Capacidad",
"notCheckedIn": "Sin ingresar todavía",
"checkedIn": "Ingresados",
"revenue": "Ingresos",
"presaleDoor": "Preventa {presale} · Puerta {door}",
"stats": "Datos",
"inShort": "{count} adentro",
"free": "Gratis"
}
},
"footer": {
@@ -383,4 +894,4 @@
"downloading": "Descargando…"
}
}
}
}
+27 -4
View File
@@ -5,7 +5,7 @@ import { fetchApi } from './client';
// fire actions optimistically and retry on flaky venue wifi without ever
// creating a duplicate ticket, payment or check-in.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
@@ -32,7 +32,20 @@ export interface DoorAttendee {
}
export interface DoorAttendeesResponse {
event: { id: string; title: string; price: number; currency: string; capacity: number };
event: {
id: string;
title: string;
price: number;
/** The event's walk-in price as configured; null = not set. */
walkInPrice: number | null;
/** What one walk-in ticket costs, resolved by the server. */
walkInUnitPrice: number;
walkInPriceSource: 'walk_in' | 'ticket';
currency: string;
capacity: number;
};
/** Tenders enabled for this event (POS can be switched off in payment options). */
doorMethods: DoorPaymentMethod[];
attendees: DoorAttendee[];
stats: { checkedIn: number; totalActive: number; capacity: number };
}
@@ -46,7 +59,11 @@ export interface DoorCheckinRequest {
email?: string;
ruc?: string;
};
payment?: { method: DoorPaymentMethod; amount?: number };
/**
* The server prices the charge (walk-in or ticket price x quantity). `amount`
* is only honoured with `amountOverride`, which is admin/organizer only.
*/
payment?: { method: DoorPaymentMethod; quantity?: number; amount?: number; amountOverride?: boolean };
entryMethod?: DoorEntryMethod;
idempotencyKey: string;
}
@@ -55,7 +72,13 @@ export interface DoorCheckinResponse {
ok: true;
action: 'checkin' | 'walkin';
attendee: DoorAttendee;
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
payment: {
id: string;
method: DoorPaymentMethod;
amount: number;
currency: string;
amountOverridden: boolean;
} | null;
/** 'at_capacity' — the event is full; the attendee was added anyway. */
warnings: string[];
idempotencyKey: string;
+5 -3
View File
@@ -3,7 +3,9 @@ import type { EmailTemplate, EmailVariable, EmailLog, EmailStats, Pagination } f
export const emailsApi = {
// Templates
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
// Team members (non-admin) pass the event they email for, so the server can check their permission there.
getTemplates: (eventId?: string) =>
fetchApi<{ templates: EmailTemplate[] }>(`/api/emails/templates${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`),
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
@@ -56,8 +58,8 @@ export const emailsApi = {
templateSlug: string;
variables?: Record<string, any>;
locale?: string;
}) =>
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
}, eventId?: string) =>
fetchApi<{ subject: string; bodyHtml: string }>(`/api/emails/preview${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`, {
method: 'POST',
body: JSON.stringify(data),
}),
+2 -1
View File
@@ -13,7 +13,8 @@ export const eventsApi = {
return fetchApi<{ events: Event[]; total?: number }>(`/api/events?${query}`);
},
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
// serverTime (ISO) lets the event page time its refresh at the sales-state flip.
getById: (id: string) => fetchApi<{ event: Event; serverTime?: string }>(`/api/events/${id}`),
getNext: () => fetchApi<{ event: Event | null }>('/api/events/next'),
+310
View File
@@ -0,0 +1,310 @@
import { fetchApi, fetchBlob } from './client';
// ==================== Types ====================
export type EventPermission =
| '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';
export const EVENT_PERMISSIONS: EventPermission[] = [
'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',
];
export type RolePreset = 'staff' | 'collaborator' | 'co_manager';
export const ROLE_PRESETS: RolePreset[] = ['staff', 'collaborator', 'co_manager'];
/** Mirrors backend/src/lib/eventPermissions.ts PRESET_PERMISSIONS. */
export const PRESET_PERMISSIONS: Record<RolePreset, EventPermission[]> = {
staff: ['view_overview', 'check_in', 'view_attendees_names'],
collaborator: ['view_overview', 'view_finance'],
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
};
export type CalcType = 'fixed' | 'per_ticket_sold' | 'per_checked_in' | 'percent_of_revenue' | 'minimum_spend';
export const CALC_TYPES: CalcType[] = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'];
export type ShareType = 'percent_profit' | 'percent_revenue' | 'fixed' | 'fixed_plus_percent_above_threshold';
export const SHARE_TYPES: ShareType[] = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'];
export type LossRule = 'proportional' | 'none' | 'capped';
export const LOSS_RULES: LossRule[] = ['proportional', 'none', 'capped'];
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
export interface MyEventPermissions {
eventId: string;
global: boolean;
role: string;
rolePreset: RolePreset | null;
permissions: EventPermission[];
canUnfinalize: boolean;
}
export interface ExpenseCategory {
id: string;
nameEn: string;
nameEs: string;
color: string;
sortOrder: number;
archived: boolean;
}
export interface EventExpense {
id: string;
eventId: string;
categoryId: string | null;
templateId: string | null;
description: string;
calcType: CalcType;
quantity: number;
unitAmount: number;
percentBp: number;
minimumAmount: number;
computedAmount: number;
isLocked: boolean;
status: 'planned' | 'paid';
/** 'other' = fronted by a partner the viewer may not see. */
paidByPartnerId: string | null;
receiptUrl: string | null;
expenseDate: string | null;
createdBy: string | null;
createdAt: string | null;
/** Live values from the calculation (present on GET /finance). */
amount?: number;
liveQuantity?: number;
auto?: boolean;
}
export interface OtherIncome {
id: string;
description: string;
amount: number;
createdAt: string | null;
}
export interface EventPartner {
id: string;
userId: string | null;
externalName: string | null;
name: string;
roleLabel: string | null;
shareType: ShareType;
percentBp: number;
fixedAmount: number;
thresholdAmount: number;
lossRule: LossRule;
lossCapAmount: number;
payoutStatus: 'pending' | 'paid';
payoutDate: string | null;
payoutMethod: string | null;
payoutNote: string | null;
}
export interface PartnerLine {
partnerId: string;
name: string;
shareType: ShareType;
basis: number;
share: number;
reimbursement: number;
payout: number;
}
export interface FinanceSummary {
counts: { ticketsSold: number; checkedIn: number; payments: number };
revenue: {
gross: number; presale: number; door: number; refunds: number; fees: number; otherIncome: number; sales: number; net: number;
byMethod: { method: string; count: number; gross: number; refunds: number; fees: number; net: number }[];
};
expenses: {
lines: { id: string; quantity: number; amount: number; auto: boolean }[];
total: number; planned: number; paid: number;
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
};
profit: number;
breakEven: { tickets: number | null; ticketPrice: number; remaining: number | null };
/** distributable / organization are null for viewers without view_full_split. */
split: { distributable: number | null; partners: PartnerLine[]; organization: number | null };
waterfall: { key: string; label?: string; amount: number }[];
salesTimeline: { date: string; tickets: number; revenue: number }[];
}
export interface EventFinance {
event: { id: string; title: string; titleEs?: string; startDatetime: string; endDatetime: string | null; location: string; series: string | null; price: number; currency: string; capacity: number };
status: FinanceStatus;
finalizedAt: string | null;
live: boolean;
computedAt: string;
summary: FinanceSummary;
expenses: EventExpense[];
otherIncome: OtherIncome[];
partners: EventPartner[];
categories: ExpenseCategory[];
viewer: {
fullSplit: boolean;
canEditExpenses: boolean;
canEditOwnExpenses: boolean;
canManageSplit: boolean;
canUnfinalize: boolean;
userId: string;
};
}
export interface ExpenseTemplate {
id: string;
name: string;
categoryId: string | null;
description: string | null;
calcType: CalcType;
amount: number;
percentBp: number;
minimumAmount: number;
archived: boolean;
}
export interface ExpenseTemplatePack {
id: string;
name: string;
description: string | null;
archived: boolean;
templateIds: string[];
}
export interface PaymentMethodFee {
method: string;
percentBp: number;
fixedAmount: number;
}
export interface EventMember {
id: string;
userId: string;
name: string | null;
email: string | null;
globalRole: string | null;
rolePreset: RolePreset;
permissions: Partial<Record<EventPermission, boolean>>;
effective: EventPermission[];
createdAt: string | null;
}
export interface FinanceAuditEntry {
id: string;
actorUserId: string | null;
actorName: string | null;
entityType: string;
entityId: string | null;
action: string;
before: any;
after: any;
createdAt: string | null;
}
export interface FinanceTotals { events: number; gross: number; fees: number; net: number; expenses: number; profit: number }
export type FinanceOverviewEvent = Omit<FinanceTotals, 'events'> & {
id: string; title: string; titleEs: string | null; startDatetime: string | null; endDatetime: string | null; series: string | null; location: string;
status: string; financeStatus: FinanceStatus; ticketsSold: number; organization: number | null;
};
export interface FinanceOverview {
totals: FinanceTotals;
events: FinanceOverviewEvent[];
/** Past events with open books, including ones with no money recorded yet; longest-waiting first. */
readyToClose: FinanceOverviewEvent[];
bySeries: (FinanceTotals & { series: string | null })[];
byVenue: (FinanceTotals & { venue: string })[];
byPartner: { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }[];
filters: { series: string[]; venues: string[]; partners: { key: string; name: string }[] };
}
export interface MyEvent {
event: { id: string; slug: string; title: string; titleEs?: string | null; startDatetime: string; location: string; status: string; bannerUrl?: string | null };
rolePreset: RolePreset;
permissions: EventPermission[];
}
export type ExpenseInput = Partial<Pick<EventExpense,
'description' | 'categoryId' | 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' |
'isLocked' | 'status' | 'paidByPartnerId' | 'receiptUrl' | 'expenseDate'>>;
export type PartnerInput = Partial<Pick<EventPartner,
'userId' | 'externalName' | 'roleLabel' | 'shareType' | 'percentBp' | 'fixedAmount' | 'thresholdAmount' | 'lossRule' | 'lossCapAmount'>>;
// ==================== Client ====================
const json = (method: string, body?: unknown): RequestInit => ({ method, body: body === undefined ? undefined : JSON.stringify(body) });
const e = (id: string) => `/api/events/${encodeURIComponent(id)}`;
/** Team members pass the event they are working on so the server can check their permission there. */
const scoped = (eventId?: string) => (eventId ? `?eventId=${encodeURIComponent(eventId)}` : '');
export const financeApi = {
myPermissions: (eventId: string) => fetchApi<MyEventPermissions>(`${e(eventId)}/my-permissions`),
get: (eventId: string) => fetchApi<EventFinance>(`${e(eventId)}/finance`),
finalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/finalize`, json('POST', {})),
unfinalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/unfinalize`, json('POST', {})),
createExpense: (eventId: string, data: ExpenseInput) =>
fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses`, json('POST', data)),
updateExpense: (eventId: string, id: string, data: ExpenseInput) =>
fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses/${id}`, json('PUT', data)),
deleteExpense: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/expenses/${id}`, json('DELETE')),
applyTemplate: (eventId: string, data: { templateId?: string; packId?: string }) =>
fetchApi<{ expenses: EventExpense[] }>(`${e(eventId)}/expenses/apply-template`, json('POST', data)),
createIncome: (eventId: string, data: { description: string; amount: number }) =>
fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income`, json('POST', data)),
updateIncome: (eventId: string, id: string, data: { description?: string; amount?: number }) =>
fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income/${id}`, json('PUT', data)),
deleteIncome: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/other-income/${id}`, json('DELETE')),
searchPartnerCandidates: (eventId: string, q: string) =>
fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/partners/candidates?q=${encodeURIComponent(q)}`),
createPartner: (eventId: string, data: PartnerInput) =>
fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners`, json('POST', data)),
updatePartner: (eventId: string, id: string, data: PartnerInput) =>
fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners/${id}`, json('PUT', data)),
deletePartner: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/partners/${id}`, json('DELETE')),
markPartnerPaid: (eventId: string, id: string, data: { paid: boolean; payoutDate?: string | null; payoutMethod?: string | null; payoutNote?: string | null }) =>
fetchApi<{ partner: EventPartner; status: FinanceStatus }>(`${e(eventId)}/partners/${id}/mark-paid`, json('POST', data)),
downloadStatement: (eventId: string, partnerId: string, locale: string) =>
fetchBlob(`${e(eventId)}/partners/${partnerId}/statement?locale=${locale}`, 'statement.pdf'),
auditLog: (eventId: string) => fetchApi<{ entries: FinanceAuditEntry[] }>(`${e(eventId)}/audit-log`),
getMembers: (eventId: string) => fetchApi<{ members: EventMember[] }>(`${e(eventId)}/members`),
searchMemberCandidates: (eventId: string, q: string) =>
fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/members/candidates?q=${encodeURIComponent(q)}`),
addMember: (eventId: string, data: { userId: string; rolePreset: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) =>
fetchApi<{ member: EventMember }>(`${e(eventId)}/members`, json('POST', data)),
updateMember: (eventId: string, id: string, data: { rolePreset?: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) =>
fetchApi<{ member: EventMember }>(`${e(eventId)}/members/${id}`, json('PUT', data)),
removeMember: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/members/${id}`, json('DELETE')),
// Settings (writes are admin only; reads also work for members via eventId)
getCategories: (eventId?: string) => fetchApi<{ categories: ExpenseCategory[] }>(`/api/finance/settings/expense-categories${scoped(eventId)}`),
createCategory: (data: Partial<ExpenseCategory>) => fetchApi<{ category: ExpenseCategory }>('/api/finance/settings/expense-categories', json('POST', data)),
updateCategory: (id: string, data: Partial<ExpenseCategory>) => fetchApi<{ category: ExpenseCategory }>(`/api/finance/settings/expense-categories/${id}`, json('PUT', data)),
deleteCategory: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-categories/${id}`, json('DELETE')),
getTemplates: (eventId?: string) => fetchApi<{ templates: ExpenseTemplate[] }>(`/api/finance/settings/expense-templates${scoped(eventId)}`),
createTemplate: (data: Partial<ExpenseTemplate>) => fetchApi<{ template: ExpenseTemplate }>('/api/finance/settings/expense-templates', json('POST', data)),
updateTemplate: (id: string, data: Partial<ExpenseTemplate>) => fetchApi<{ template: ExpenseTemplate }>(`/api/finance/settings/expense-templates/${id}`, json('PUT', data)),
deleteTemplate: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-templates/${id}`, json('DELETE')),
getPacks: (eventId?: string) => fetchApi<{ packs: ExpenseTemplatePack[] }>(`/api/finance/settings/expense-template-packs${scoped(eventId)}`),
createPack: (data: Partial<ExpenseTemplatePack>) => fetchApi<{ pack: ExpenseTemplatePack }>('/api/finance/settings/expense-template-packs', json('POST', data)),
updatePack: (id: string, data: Partial<ExpenseTemplatePack>) => fetchApi<{ pack: ExpenseTemplatePack }>(`/api/finance/settings/expense-template-packs/${id}`, json('PUT', data)),
deletePack: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-template-packs/${id}`, json('DELETE')),
getFees: () => fetchApi<{ fees: PaymentMethodFee[] }>('/api/finance/settings/payment-fees'),
updateFee: (method: string, data: { percentBp: number; fixedAmount: number }) =>
fetchApi<{ fee: PaymentMethodFee }>(`/api/finance/settings/payment-fees/${method}`, json('PUT', data)),
overview: (params: { from?: string; to?: string; series?: string; venue?: string; partner?: string } = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) q.set(k, v); });
const qs = q.toString();
return fetchApi<FinanceOverview>(`/api/finance/overview${qs ? `?${qs}` : ''}`);
},
myEvents: () => fetchApi<{ events: MyEvent[] }>('/api/dashboard/my-events'),
};
+9
View File
@@ -29,6 +29,15 @@ export { legalSettingsApi } from './legalSettings';
export { legalPagesApi } from './legalPages';
export { faqApi } from './faq';
export { photosApi } from './photos';
export {
financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS, CALC_TYPES, SHARE_TYPES, LOSS_RULES,
} from './finance';
export type {
EventPermission, RolePreset, CalcType, ShareType, LossRule, FinanceStatus, MyEventPermissions, ExpenseCategory,
EventExpense, OtherIncome, EventPartner, PartnerLine, FinanceSummary, EventFinance, ExpenseTemplate,
ExpenseTemplatePack, PaymentMethodFee, EventMember, FinanceAuditEntry, FinanceOverview, FinanceTotals, MyEvent,
ExpenseInput, PartnerInput,
} from './finance';
export type {
Photo,
PhotoGallery,
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Payment, PaymentWithDetails } from './types';
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
// verify the money arrived; automatic ones (lightning) confirm themselves.
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash', 'pos'];
export function isManualProvider(provider: string): boolean {
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
+24 -1
View File
@@ -1,3 +1,6 @@
// online: book online · door: online closed, pay at the door until the event ends
export type EventSalesState = 'online' | 'door' | 'sold_out' | 'ended' | 'external' | 'cancelled';
export interface Event {
id: string;
slug: string;
@@ -12,15 +15,28 @@ export interface Event {
location: string;
locationUrl?: string;
price: number;
// Door price for walk-ins. Only returned to admin/organizer/staff; null = not
// set (walk-ins pay `price`), 0 = free walk-in.
walkInPrice?: number | null;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
presaleClosureEnabled?: boolean | null; // null = inherit the site default
presaleCloseMinutesBefore?: number | null; // null = inherit the site default
presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes
series?: string | null; // groups recurring events in the finance overview
bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
// Public sales state, computed by the server (backend lib/salesState.ts).
salesState?: EventSalesState;
// Resolved door price (walk-in price, else ticket price). Only sent in the `door` state.
doorPrice?: number;
// Door tenders accepted, sent with the single event in the `door` state.
doorPaymentMethods?: Array<'cash' | 'bitcoin' | 'transfer' | 'pos'>;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
@@ -54,6 +70,8 @@ export interface Ticket {
adminNote?: string;
isGuest?: boolean;
paymentStatus?: 'paid' | 'unpaid' | 'comp';
// How the booking was made: public checkout, a door walk-in, or added by an admin
bookingSource?: 'online' | 'walk_in' | 'admin';
createdAt: string;
event?: Event;
payment?: Payment;
@@ -121,7 +139,7 @@ export interface LiveSearchResult {
export interface Payment {
id: string;
ticketId: string;
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos';
amount: number;
currency: string;
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
@@ -175,6 +193,8 @@ export interface PaymentOptionsConfig {
cashEnabled: boolean;
cashInstructions?: string | null;
cashInstructionsEs?: string | null;
// POS card terminal on the door screen (never offered at online checkout)
posEnabled?: boolean;
// Booking settings
allowDuplicateBookings?: boolean;
}
@@ -359,6 +379,7 @@ export interface FinancialSummary {
cash: number;
bank_transfer: number;
tpago: number;
pos?: number;
};
paidCount: number;
pendingCount: number;
@@ -484,6 +505,8 @@ export interface SiteSettings {
maintenanceMode: boolean;
maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null;
presaleClosureEnabled: boolean;
presaleCloseMinutesBefore: number;
updatedAt?: string;
updatedBy?: string;
}

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