From b51c1360e24e8707afcedb5ebe072d9902dfc3d6 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 27 Sep 2026 23:10:50 +0000 Subject: [PATCH 1/3] 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 --- backend/src/db/migrate.ts | 55 +++++ backend/src/db/schema.ts | 20 +- backend/src/index.ts | 11 +- backend/src/lib/doorPayments.ts | 36 ++- backend/src/lib/email/paymentEmails.ts | 4 +- backend/src/lib/paymentProviders.ts | 6 +- backend/src/lib/walkInPrice.test.ts | 49 ++++ backend/src/lib/walkInPrice.ts | 44 ++++ backend/src/routes/admin.ts | 1 + backend/src/routes/dashboard.ts | 5 +- backend/src/routes/door.integration.test.ts | 226 +++++++++++++++++- backend/src/routes/door.ts | 103 +++++++- backend/src/routes/events.ts | 61 ++++- .../routes/events.walkin.integration.test.ts | 180 ++++++++++++++ backend/src/routes/payment-options.ts | 6 + backend/src/routes/tickets.ts | 2 + backend/src/routes/users.ts | 3 +- .../dashboard/components/PaymentsTab.tsx | 1 + frontend/src/app/admin/bookings/page.tsx | 6 +- .../admin/events/[id]/_tabs/PaymentsTab.tsx | 42 ++++ .../events/_components/EventFormModal.tsx | 56 ++++- .../src/app/admin/payment-options/page.tsx | 47 ++++ frontend/src/app/admin/payments/page.tsx | 10 +- .../admin/scanner/_components/AttendeeRow.tsx | 18 +- .../scanner/_components/PaymentButtons.tsx | 86 +++++-- .../scanner/_components/PosChargePanel.tsx | 70 ++++++ .../scanner/_components/SessionSheet.tsx | 2 + .../admin/scanner/_components/WalkInRow.tsx | 26 +- frontend/src/app/admin/scanner/page.tsx | 50 +++- frontend/src/i18n/locales/en.json | 8 +- frontend/src/i18n/locales/es.json | 8 +- frontend/src/lib/api/door.ts | 31 ++- frontend/src/lib/api/payments.ts | 2 +- frontend/src/lib/api/types.ts | 10 +- 34 files changed, 1192 insertions(+), 93 deletions(-) create mode 100644 backend/src/lib/walkInPrice.test.ts create mode 100644 backend/src/lib/walkInPrice.ts create mode 100644 backend/src/routes/events.walkin.integration.test.ts create mode 100644 frontend/src/app/admin/scanner/_components/PosChargePanel.tsx diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 78d2622..dc15ab2 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -117,6 +117,11 @@ async function migrate() { 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`); @@ -297,6 +302,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 ( @@ -392,6 +412,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, @@ -773,6 +801,11 @@ async function migrate() { 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)`); @@ -913,6 +946,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 ( @@ -1006,6 +1053,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, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 05a87d6..af72576 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -82,6 +82,9 @@ 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'), @@ -121,13 +124,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'), @@ -146,7 +152,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(), }); @@ -194,6 +200,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 @@ -226,6 +234,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(), @@ -476,6 +485,9 @@ 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'), @@ -515,6 +527,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(), }); @@ -578,6 +592,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), @@ -607,6 +622,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(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index f954361..8ff5f02 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -788,7 +788,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 +817,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 +833,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' }, }, }, diff --git a/backend/src/lib/doorPayments.ts b/backend/src/lib/doorPayments.ts index 397736e..b0008b6 100644 --- a/backend/src/lib/doorPayments.ts +++ b/backend/src/lib/doorPayments.ts @@ -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,49 @@ // 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; +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 = { - 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); +} + export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod { return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value); } diff --git a/backend/src/lib/email/paymentEmails.ts b/backend/src/lib/email/paymentEmails.ts index 3654431..f166995 100644 --- a/backend/src/lib/email/paymentEmails.ts +++ b/backend/src/lib/email/paymentEmails.ts @@ -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> = { - 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(); diff --git a/backend/src/lib/paymentProviders.ts b/backend/src/lib/paymentProviders.ts index 83ad086..aa61dc7 100644 --- a/backend/src/lib/paymentProviders.ts +++ b/backend/src/lib/paymentProviders.ts @@ -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 = 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( diff --git a/backend/src/lib/walkInPrice.test.ts b/backend/src/lib/walkInPrice.test.ts new file mode 100644 index 0000000..c380808 --- /dev/null +++ b/backend/src/lib/walkInPrice.test.ts @@ -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); + }); +}); diff --git a/backend/src/lib/walkInPrice.ts b/backend/src/lib/walkInPrice.ts new file mode 100644 index 0000000..b6ba6f9 --- /dev/null +++ b/backend/src/lib/walkInPrice.ts @@ -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 | null | undefined>(event: T): T { + if (!event) return event; + const { walkInPrice: _omitted, ...rest } = event as Record; + return rest as T; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 609713f..06fb721 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -519,6 +519,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, diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 7ca73ee..8ae52f3 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -7,6 +7,7 @@ import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, t 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'; const dashboard = new Hono(); @@ -208,7 +209,7 @@ dashboard.get('/tickets/:id', async (c) => { return c.json({ ticket: { ...ticket, - event, + event: omitWalkInPrice(event as any), payment, invoice, }, @@ -272,7 +273,7 @@ dashboard.get('/next-event', async (c) => { return c.json({ nextEvent: { - event: nextEvent, + event: omitWalkInPrice(nextEvent), ticket: nextTicket, payment: nextPayment, }, diff --git a/backend/src/routes/door.integration.test.ts b/backend/src/routes/door.integration.test.ts index 7846676..6abff81 100644 --- a/backend/src/routes/door.integration.test.ts +++ b/backend/src/routes/door.integration.test.ts @@ -223,7 +223,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); @@ -441,3 +441,227 @@ describe('door-summary', () => { expect(body.door.lines[0]).toHaveProperty('name'); }); }); + +// ==================== 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'); + }); +}); diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts index d532a84..032ea8d 100644 --- a/backend/src/routes/door.ts +++ b/backend/src/routes/door.ts @@ -21,7 +21,8 @@ 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, + paymentOptions, eventPaymentOverrides, } from '../db/index.js'; import { requireAuth } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js'; @@ -29,8 +30,9 @@ 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, enabledDoorMethods, type DoorPaymentMethod, } from '../lib/doorPayments.js'; +import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js'; import emailService from '../lib/email.js'; const doorRouter = new Hono(); @@ -41,6 +43,11 @@ const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const; // 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 ==================== @@ -99,10 +106,24 @@ async function loadEvent(eventId: string | undefined) { return { ...event, price: num(event.price), + walkInPrice: parseWalkInPrice(event.walkInPrice), capacity: Number(event.capacity), }; } +/** Door tenders available for this event (POS can be switched off per event). */ +async function loadDoorMethods(eventId: string): Promise { + const [globalOptions, overrides] = await Promise.all([ + dbGet((db as any).select().from(paymentOptions)), + dbGet( + (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 }); +} + /** Names of the admins/staff referenced by the given check-in rows, in one query. */ async function loadAdminNames(adminIds: string[]): Promise> { const unique = [...new Set(adminIds.filter(Boolean))]; @@ -164,6 +185,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, @@ -181,9 +205,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 }, }); @@ -204,9 +234,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(), @@ -255,19 +292,35 @@ 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 ticket settles the balance at the price it was booked at. + 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) { @@ -326,7 +379,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; @@ -345,7 +398,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 }; } } @@ -429,6 +482,7 @@ doorRouter.post( checkinAt: now, checkedInByAdminId: adminUser?.id || null, adminNote: null, + bookingSource: 'walk_in', createdAt: now, }; ops.push(insertOp(tickets, newTicket)); @@ -448,13 +502,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; @@ -496,6 +572,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); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 1c2ba86..e1d7d43 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -9,6 +9,7 @@ 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'; interface UserContext { id: string; @@ -24,10 +25,14 @@ const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); // `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. -function normalizeEvent(event: any, settings?: any) { +// 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; + const { walkInPrice, ...publicFields } = event; const normalized = { - ...event, + ...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 @@ -114,6 +119,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; @@ -137,6 +165,7 @@ 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, 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'), @@ -219,6 +248,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[] = []; @@ -271,7 +301,7 @@ eventsRouter.get('/', async (c) => { const siteSettingsRow = await getSiteSettingsRow(); const eventsWithCounts = result.map((event: any) => { - const normalized = normalizeEvent(event, siteSettingsRow); + const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }); const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 }; return { ...normalized, @@ -295,16 +325,19 @@ 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, await getSiteSettingsRow()); + const normalized = normalizeEvent(event, await getSiteSettingsRow(), { + includeWalkInPrice: canSeeWalkInPrice(authUser?.role), + }); const counts = await getEventSeatCounts(event.id); return c.json({ event: { @@ -459,6 +492,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const id = generateId(); 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); @@ -483,7 +519,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c revalidateFrontendCache(); // Return normalized event data - return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201); + return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201); }); // Update event (admin/organizer only) @@ -498,6 +534,14 @@ 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 siteSettingsRow = await getSiteSettingsRow(); const tz = siteTimezoneOf(siteSettingsRow); @@ -559,7 +603,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, siteSettingsRow) }); + return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) }); }); // Delete event (admin only) @@ -670,6 +714,7 @@ 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', @@ -684,7 +729,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( 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) diff --git a/backend/src/routes/events.walkin.integration.test.ts b/backend/src/routes/events.walkin.integration.test.ts new file mode 100644 index 0000000..26f7b18 --- /dev/null +++ b/backend/src/routes/events.walkin.integration.test.ts @@ -0,0 +1,180 @@ +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(user: TestUser, fn: () => Promise): Promise { + 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); + } + }); +}); diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 27b4496..fd108d5 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -36,6 +36,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 +81,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 +114,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + posEnabled: true, allowDuplicateBookings: false, }, }); @@ -219,6 +223,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + posEnabled: true, }; const global = globalOptions || defaults; @@ -245,6 +250,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 diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 9590cfe..2791492 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1508,6 +1508,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, + bookingSource: 'admin', createdAt: now, }; @@ -1663,6 +1664,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, }; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index c23e73a..3e9f087 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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), }; }) ); diff --git a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx index 5d497c7..91277f4 100644 --- a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx @@ -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; }; diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index 0de3de6..0f040a0 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -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() { {ticket.payment?.status || 'pending'} -

{getPaymentMethodLabel(getDisplayProvider(ticket))}

+

+ {getPaymentMethodLabel(getDisplayProvider(ticket))} + {ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')} +

{ticket.payment && (

{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}

)} diff --git a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx index 75056dd..2e493d5 100644 --- a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx +++ b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx @@ -25,6 +25,7 @@ const DOOR_METHOD_LABELS: Record 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' }, }; @@ -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 (
{loadingPayments ? ( @@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
+ {/* POS terminal at the door */} + +
+
+
+
+ +
+
+

POS

+

+ {locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'} +

+
+
+
+ {posDisabledGlobally && ( + + {locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'} + + )} + +
+
+
+
+ {/* Summary */}
@@ -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) => (
{method.enabled ? ( diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx index c8c49c5..92d717d 100644 --- a/frontend/src/app/admin/events/_components/EventFormModal.tsx +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -10,7 +10,7 @@ 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, formatDurationWords } from '@/lib/utils'; +import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils'; interface EventFormData { title: string; @@ -25,6 +25,9 @@ interface EventFormData { location: string; locationUrl: 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'; @@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = { title: '', titleEs: '', slug: '', description: '', descriptionEs: '', shortDescription: '', shortDescriptionEs: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '', - price: 0, currency: 'PYG', capacity: 50, status: 'draft', + 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', { @@ -138,6 +149,7 @@ export default function EventFormModal({ endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', location: event.location, locationUrl: event.locationUrl || '', 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 || '', @@ -208,6 +220,11 @@ export default function EventFormModal({ setSaving(false); return; } + if (!isValidWalkInPrice(formData.walkInPrice)) { + toast.error(t('admin.events.form.walkInPriceInvalid')); + setSaving(false); + return; + } const eventData: Partial = { title: formData.title, titleEs: formData.titleEs || undefined, description: formData.description, descriptionEs: formData.descriptionEs || undefined, @@ -216,6 +233,8 @@ export default function EventFormModal({ endDatetime: formData.endDatetime || undefined, location: formData.location, locationUrl: formData.locationUrl || undefined, 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, @@ -342,17 +361,30 @@ export default function EventFormModal({ setFormData({ ...formData, locationUrl: e.target.value })} /> -
- setFormData({ ...formData, price: Number(e.target.value) })} /> -
- - +
+
+ setFormData({ ...formData, price: Number(e.target.value) })} /> + 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')} /> +
+ + +
+

{t('admin.events.form.walkInPriceHelp')}

+
+ +
setFormData({ ...formData, capacity: Number(e.target.value) })} />
diff --git a/frontend/src/app/admin/payment-options/page.tsx b/frontend/src/app/admin/payment-options/page.tsx index fc7b9de..6cb3543 100644 --- a/frontend/src/app/admin/payment-options/page.tsx +++ b/frontend/src/app/admin/payment-options/page.tsx @@ -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 (
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
+ {/* POS terminal at the door */} + +
+
+
+
+ +
+
+

POS

+

+ {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.'} +

+
+
+ +
+
+
+ {/* Booking Settings */}
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() { {locale === 'es' ? 'Efectivo' : 'Cash'}
+
+ {posEnabled ? ( + + ) : ( + + )} + + POS + +
diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 4e5ed84..555d3ce 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -313,6 +313,7 @@ export default function AdminPaymentsPage() { bank_transfer: BuildingLibraryIcon, tpago: CreditCardIcon, bancard: CreditCardIcon, + pos: CreditCardIcon, }; const Icon = icons[provider] || CreditCardIcon; return ; @@ -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 */}

{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}

-
+

{locale === 'es' ? 'Efectivo' : 'Cash'}

{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG

@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {

Bancard

{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG

+
+

POS

+

{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG

+
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() { +
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() { +
diff --git a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx index 0381803..0acd497 100644 --- a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx +++ b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx @@ -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 = { 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.

)} - +
)} diff --git a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx index 7c849a2..3a28f44 100644 --- a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx +++ b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx @@ -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(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(null); const [pressTimer, setPressTimer] = useState | 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 ( + 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
-
+
{[1, 2, 3].map((qty) => ( ))} - + {canOverrideAmount && ( + + )}
- {customOpen && ( + {canOverrideAmount && customOpen && (
+
+ +
+

Amount to charge

+

{formatCurrency(charge.amount, currency)}

+ {charge.override ? ( +

Custom amount

+ ) : charge.quantity > 1 ? ( +

+ {charge.quantity} × {formatCurrency(unitPrice, currency)} +

+ ) : null} +
+ + +

+ Only tap once the terminal has approved the card. +

+
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx index 0916074..5a6248d 100644 --- a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx +++ b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx @@ -43,6 +43,7 @@ const METHOD_LABELS: Record = { cash: 'Cash', bitcoin: 'Bitcoin', transfer: 'Transfer', + pos: 'POS', guest: 'Guest', }; @@ -52,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; diff --git a/frontend/src/app/admin/scanner/_components/WalkInRow.tsx b/frontend/src/app/admin/scanner/_components/WalkInRow.tsx index e282463..9d45e57 100644 --- a/frontend/src/app/admin/scanner/_components/WalkInRow.tsx +++ b/frontend/src/app/admin/scanner/_components/WalkInRow.tsx @@ -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({
)} +
+

{formatCurrency(price, currency)}

+

{PRICE_SOURCE_LABELS[priceSource]}

+
+ diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index 29c057d..3307561 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext'; import { eventsApi, doorApi, + DOOR_PAYMENT_METHODS, type Event, type DoorAttendee, type DoorAttendeesResponse, @@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/ 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'; @@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record = { cash: 'paid cash', bitcoin: 'paid bitcoin', transfer: 'paid by transfer', + pos: 'paid by POS', guest: 'in as guest', }; @@ -75,6 +78,9 @@ export default function AdminDoorPage() { // 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([]); @@ -122,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; @@ -351,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. */ @@ -373,7 +383,7 @@ 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, @@ -424,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, }); @@ -435,7 +454,13 @@ 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, ), ); @@ -508,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, @@ -522,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() @@ -536,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', @@ -764,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)} /> ))} @@ -778,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)); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b982f97..920a6f3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -294,7 +294,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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 5771049..36fcd11 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -294,7 +294,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", diff --git a/frontend/src/lib/api/door.ts b/frontend/src/lib/api/door.ts index e97a955..60464b2 100644 --- a/frontend/src/lib/api/door.ts +++ b/frontend/src/lib/api/door.ts @@ -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; diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index 2da9d9d..740c14a 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -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); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index af0f304..5d9801f 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -12,6 +12,9 @@ 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'; @@ -57,6 +60,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; @@ -124,7 +129,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'; @@ -178,6 +183,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; } @@ -362,6 +369,7 @@ export interface FinancialSummary { cash: number; bank_transfer: number; tpago: number; + pos?: number; }; paidCount: number; pendingCount: number; -- 2.54.0 From e203fb6c747ccb7cd7f25108c106c98a4f216191 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 3 Oct 2026 03:11:46 +0000 Subject: [PATCH 2/3] 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 --- backend/src/db/migrate.ts | 184 +++- backend/src/db/schema.ts | 309 +++++- backend/src/index.ts | 5 + backend/src/lib/capacity.ts | 12 +- backend/src/lib/doorPayments.ts | 16 + backend/src/lib/eventPermissions.ts | 234 +++++ backend/src/lib/finance/audit.ts | 32 + backend/src/lib/finance/calculate.test.ts | 397 ++++++++ backend/src/lib/finance/calculate.ts | 395 ++++++++ backend/src/lib/finance/load.ts | 245 +++++ backend/src/lib/finance/statementPdf.ts | 215 ++++ backend/src/lib/pdf.ts | 28 +- backend/src/lib/salesState.test.ts | 96 ++ backend/src/lib/salesState.ts | 72 ++ backend/src/routes/admin.ts | 7 +- backend/src/routes/dashboard.ts | 37 +- backend/src/routes/door.ts | 26 +- backend/src/routes/emails.ts | 11 +- .../routes/eventFinance.integration.test.ts | 387 ++++++++ backend/src/routes/eventFinance.ts | 936 ++++++++++++++++++ backend/src/routes/events.ts | 97 +- .../routes/events.walkin.integration.test.ts | 65 ++ backend/src/routes/finance.ts | 407 ++++++++ backend/src/routes/payment-options.ts | 7 +- backend/src/routes/tickets.ts | 39 +- frontend/package.json | 7 +- .../dashboard/components/MyEventsTab.tsx | 41 + .../(public)/dashboard/events/[id]/page.tsx | 23 + frontend/src/app/(public)/dashboard/page.tsx | 22 +- .../src/app/(public)/events/EventsClient.tsx | 16 +- .../events/[id]/EventDetailClient.tsx | 126 ++- .../src/app/(public)/events/[id]/page.tsx | 14 +- .../[id]/_components/EventDetailSkeleton.tsx | 141 +++ .../[id]/_components/EventDetailView.tsx | 881 +++++++++++++++++ .../events/[id]/_finance/ExpensesView.tsx | 829 ++++++++++++++++ .../events/[id]/_finance/FinalizeDialog.tsx | 71 ++ .../events/[id]/_finance/FinanceCharts.tsx | 351 +++++++ .../admin/events/[id]/_finance/FinanceTab.tsx | 91 ++ .../events/[id]/_finance/LifecycleStepper.tsx | 107 ++ .../admin/events/[id]/_finance/SplitView.tsx | 530 ++++++++++ .../events/[id]/_finance/SummaryView.tsx | 237 +++++ .../admin/events/[id]/_finance/UserSearch.tsx | 58 ++ .../app/admin/events/[id]/_finance/calc.ts | 20 + .../admin/events/[id]/_finance/derive.test.ts | 246 +++++ .../app/admin/events/[id]/_finance/derive.ts | 189 ++++ .../app/admin/events/[id]/_finance/format.ts | 20 + .../src/app/admin/events/[id]/_finance/ui.tsx | 275 +++++ .../events/[id]/_hooks/useEventDetailData.ts | 70 +- .../admin/events/[id]/_tabs/OverviewTab.tsx | 18 +- .../admin/events/[id]/_tabs/PaymentsTab.tsx | 8 +- .../app/admin/events/[id]/_tabs/TeamTab.tsx | 259 +++++ frontend/src/app/admin/events/[id]/_types.ts | 2 +- .../app/admin/events/[id]/_utils/format.ts | 9 +- .../src/app/admin/events/[id]/loading.tsx | 7 + frontend/src/app/admin/events/[id]/page.tsx | 785 +-------------- .../events/_components/EventFormModal.tsx | 11 +- .../events/_components/EventsListSkeleton.tsx | 83 ++ frontend/src/app/admin/events/loading.tsx | 29 + frontend/src/app/admin/events/page.tsx | 42 +- .../_components/ProfitByEventChart.tsx | 86 ++ .../admin/finance/_components/clamp.test.ts | 60 ++ .../app/admin/finance/_components/clamp.ts | 57 ++ frontend/src/app/admin/finance/page.tsx | 294 ++++++ frontend/src/app/admin/layout.tsx | 2 + .../_components/ExpenseTemplatesSettings.tsx | 396 ++++++++ frontend/src/app/admin/settings/page.tsx | 17 +- frontend/src/i18n/locales/en.json | 508 +++++++++- frontend/src/i18n/locales/es.json | 508 +++++++++- frontend/src/lib/api/emails.ts | 8 +- frontend/src/lib/api/events.ts | 3 +- frontend/src/lib/api/finance.ts | 310 ++++++ frontend/src/lib/api/index.ts | 9 + frontend/src/lib/api/types.ts | 10 + frontend/src/lib/money.test.ts | 35 + frontend/src/lib/money.ts | 40 + frontend/src/lib/salesState.test.ts | 26 + frontend/src/lib/useMoney.ts | 15 + frontend/src/lib/utils.ts | 24 + frontend/tsconfig.json | 4 +- frontend/vitest.config.ts | 8 + 80 files changed, 11326 insertions(+), 971 deletions(-) create mode 100644 backend/src/lib/eventPermissions.ts create mode 100644 backend/src/lib/finance/audit.ts create mode 100644 backend/src/lib/finance/calculate.test.ts create mode 100644 backend/src/lib/finance/calculate.ts create mode 100644 backend/src/lib/finance/load.ts create mode 100644 backend/src/lib/finance/statementPdf.ts create mode 100644 backend/src/lib/salesState.test.ts create mode 100644 backend/src/lib/salesState.ts create mode 100644 backend/src/routes/eventFinance.integration.test.ts create mode 100644 backend/src/routes/eventFinance.ts create mode 100644 backend/src/routes/finance.ts create mode 100644 frontend/src/app/(public)/dashboard/components/MyEventsTab.tsx create mode 100644 frontend/src/app/(public)/dashboard/events/[id]/page.tsx create mode 100644 frontend/src/app/admin/events/[id]/_components/EventDetailSkeleton.tsx create mode 100644 frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/ExpensesView.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/FinalizeDialog.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/FinanceCharts.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/FinanceTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/LifecycleStepper.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/SplitView.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/SummaryView.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/UserSearch.tsx create mode 100644 frontend/src/app/admin/events/[id]/_finance/calc.ts create mode 100644 frontend/src/app/admin/events/[id]/_finance/derive.test.ts create mode 100644 frontend/src/app/admin/events/[id]/_finance/derive.ts create mode 100644 frontend/src/app/admin/events/[id]/_finance/format.ts create mode 100644 frontend/src/app/admin/events/[id]/_finance/ui.tsx create mode 100644 frontend/src/app/admin/events/[id]/_tabs/TeamTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/loading.tsx create mode 100644 frontend/src/app/admin/events/_components/EventsListSkeleton.tsx create mode 100644 frontend/src/app/admin/events/loading.tsx create mode 100644 frontend/src/app/admin/finance/_components/ProfitByEventChart.tsx create mode 100644 frontend/src/app/admin/finance/_components/clamp.test.ts create mode 100644 frontend/src/app/admin/finance/_components/clamp.ts create mode 100644 frontend/src/app/admin/finance/page.tsx create mode 100644 frontend/src/app/admin/settings/_components/ExpenseTemplatesSettings.tsx create mode 100644 frontend/src/lib/api/finance.ts create mode 100644 frontend/src/lib/money.test.ts create mode 100644 frontend/src/lib/money.ts create mode 100644 frontend/src/lib/salesState.test.ts create mode 100644 frontend/src/lib/useMoney.ts create mode 100644 frontend/vitest.config.ts diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index dc15ab2..1c4dc5e 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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}`); @@ -1336,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( + (db as any).select({ count: sql`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)`, @@ -1353,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 { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index af72576..bbd5e20 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -94,6 +94,8 @@ export const sqliteEvents = sqliteTable('events', { // 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(), }); @@ -407,6 +409,152 @@ export const sqliteSiteSettings = sqliteTable('site_settings', { 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(), @@ -497,6 +645,7 @@ export const pgEvents = pgTable('events', { // 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(), }); @@ -794,6 +943,144 @@ export const pgSiteSettings = pgTable('site_settings', { 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; @@ -818,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; @@ -851,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; \ No newline at end of file +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; diff --git a/backend/src/index.ts b/backend/src/index.ts index 8ff5f02..d638b09 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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'; @@ -2025,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); @@ -2040,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) => { diff --git a/backend/src/lib/capacity.ts b/backend/src/lib/capacity.ts index 5c3e69b..67985eb 100644 --- a/backend/src/lib/capacity.ts +++ b/backend/src/lib/capacity.ts @@ -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); } diff --git a/backend/src/lib/doorPayments.ts b/backend/src/lib/doorPayments.ts index b0008b6..8f9bc6a 100644 --- a/backend/src/lib/doorPayments.ts +++ b/backend/src/lib/doorPayments.ts @@ -17,6 +17,9 @@ // (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to // the terminal today. +import { eq } from 'drizzle-orm'; +import { db, dbGet, paymentOptions, eventPaymentOverrides } from '../db/index.js'; + export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const; export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number]; @@ -52,6 +55,19 @@ export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMe 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 { + const [globalOptions, overrides] = await Promise.all([ + dbGet((db as any).select().from(paymentOptions)), + dbGet( + (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); } diff --git a/backend/src/lib/eventPermissions.ts b/backend/src/lib/eventPermissions.ts new file mode 100644 index 0000000..e66aa40 --- /dev/null +++ b/backend/src/lib/eventPermissions.ts @@ -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 = { + 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 = { + 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> { + let obj: any = raw; + if (typeof raw === 'string') { + try { obj = JSON.parse(raw); } catch { obj = {}; } + } + const out: Partial> = {}; + 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 { + const base = PRESET_PERMISSIONS[preset as RolePreset] || []; + const set = new Set(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; + membership: { id: string; rolePreset: RolePreset } | null; +} + +export async function getMembership(userId: string, eventId: string) { + return dbGet( + (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, eventId: string): Promise { + const permissions = new Set(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; + +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( + (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( + (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) => { + 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: T): T { + const { attendeeEmail, attendeePhone, attendeeRuc, ...rest } = t as any; + return { ...rest, attendeeEmail: null, attendeePhone: null, attendeeRuc: null } as T; +} diff --git a/backend/src/lib/finance/audit.ts b/backend/src/lib/finance/audit.ts new file mode 100644 index 0000000..7935764 --- /dev/null +++ b/backend/src/lib/finance/audit.ts @@ -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(), + }); +} diff --git a/backend/src/lib/finance/calculate.test.ts b/backend/src/lib/finance/calculate.test.ts new file mode 100644 index 0000000..4215aba --- /dev/null +++ b/backend/src/lib/finance/calculate.test.ts @@ -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 => ({ + id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over, +}); +const expense = (over: Partial = {}): 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 => ({ + id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0, + lossRule: 'none', lossCapAmount: 0, ...over, +}); +const input = (over: Partial = {}): FinanceInput => ({ + ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over, +}); +const sumShares = (r: ReturnType) => + 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); + }); +}); diff --git a/backend/src/lib/finance/calculate.ts b/backend/src/lib/finance/calculate.ts new file mode 100644 index 0000000..e9b2e3b --- /dev/null +++ b/backend/src/lib/finance/calculate.ts @@ -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, + 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) { + // Pass 1: amounts owed regardless of profit. + const upfront = new Map(); + 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(); + 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(); + const reimbursements = new Map(); + 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(); + 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, + }; +} diff --git a/backend/src/lib/finance/load.ts b/backend/src/lib/finance/load.ts new file mode 100644 index 0000000..1a2b879 --- /dev/null +++ b/backend/src/lib/finance/load.ts @@ -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; +export type SerializedPartner = ReturnType; +export type SerializedIncome = ReturnType; + +export async function getFinanceState(eventId: string) { + const row = await dbGet((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 { + const rows = await dbAll((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(); + if (userIds.length > 0) { + const us = await dbAll( + (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 { + const rows = await dbAll((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 { + const rows = await dbAll((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((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( + (db as any) + .select({ + sold: sql`sum(case when ${(tickets as any).status} in ('confirmed', 'checked_in') and ${(tickets as any).paymentStatus} = 'paid' then 1 else 0 end)`, + checkedIn: sql`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( + (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((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, + }; +} diff --git a/backend/src/lib/finance/statementPdf.ts b/backend/src/lib/finance/statementPdf.ts new file mode 100644 index 0000000..1e25c15 --- /dev/null +++ b/backend/src/lib/finance/statementPdf.ts @@ -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; +} + +export async function generatePartnerStatementPDF(data: StatementData): Promise { + 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; +} diff --git a/backend/src/lib/pdf.ts b/backend/src/lib/pdf.ts index 153afe2..515c792 100644 --- a/backend/src/lib/pdf.ts +++ b/backend/src/lib/pdf.ts @@ -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 { +export function collect(doc: PDFKit.PDFDocument): Promise { 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 { }); } -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 { diff --git a/backend/src/lib/salesState.test.ts b/backend/src/lib/salesState.test.ts new file mode 100644 index 0000000..9bffe6d --- /dev/null +++ b/backend/src/lib/salesState.test.ts @@ -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'); + } + }); +}); diff --git a/backend/src/lib/salesState.ts b/backend/src/lib/salesState.ts new file mode 100644 index 0000000..680ace5 --- /dev/null +++ b/backend/src/lib/salesState.ts @@ -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 }; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 06fb721..2555595 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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'; @@ -264,7 +265,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 +369,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') || ''; diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 8ae52f3..5b479af 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,19 +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( + (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({ diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts index 032ea8d..b6c3e71 100644 --- a/backend/src/routes/door.ts +++ b/backend/src/routes/door.ts @@ -22,15 +22,14 @@ import { z } from 'zod'; import { eq, and, inArray, sql } from 'drizzle-orm'; import { db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs, - paymentOptions, eventPaymentOverrides, } 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, enabledDoorMethods, type DoorPaymentMethod, + paymentStatusForMethod, loadDoorMethods, type DoorPaymentMethod, } from '../lib/doorPayments.js'; import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js'; import emailService from '../lib/email.js'; @@ -111,18 +110,6 @@ async function loadEvent(eventId: string | undefined) { }; } -/** Door tenders available for this event (POS can be switched off per event). */ -async function loadDoorMethods(eventId: string): Promise { - const [globalOptions, overrides] = await Promise.all([ - dbGet((db as any).select().from(paymentOptions)), - dbGet( - (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 }); -} /** Names of the admins/staff referenced by the given check-in rows, in one query. */ async function loadAdminNames(adminIds: string[]): Promise> { @@ -147,7 +134,7 @@ async function seatsHeld(eventId: string): Promise { // 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); @@ -195,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; @@ -280,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'); @@ -595,7 +583,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'); @@ -662,7 +650,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([...REVENUE_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); diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index d94a177..709c30e 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -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( (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; diff --git a/backend/src/routes/eventFinance.integration.test.ts b/backend/src/routes/eventFinance.integration.test.ts new file mode 100644 index 0000000..763a4e8 --- /dev/null +++ b/backend/src/routes/eventFinance.integration.test.ts @@ -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(user: TestUser, fn: () => Promise): Promise { + 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) => + 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([]); + }); +}); diff --git a/backend/src/routes/eventFinance.ts b/backend/src/routes/eventFinance.ts new file mode 100644 index 0000000..349dcc8 --- /dev/null +++ b/backend/src/routes/eventFinance.ts @@ -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( + (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((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>>>(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((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( + (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 = { + 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( + (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((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((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( + (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 = { 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( + (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((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( + (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 = { 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( + (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( + (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( + (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( + (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( + (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( + (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( + (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( + (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 = { 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; diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index e1d7d43..2d8b606 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -4,12 +4,15 @@ 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 { 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'; interface UserContext { id: string; @@ -50,6 +53,26 @@ function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: 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), + }; +} + // Load every slug currently in use (canonical event slugs + historical aliases), // optionally excluding a given event's own canonical slug + aliases. async function getAllSlugsInUse(excludeEventId?: string): Promise { @@ -166,6 +189,8 @@ const baseEventSchema = z.object({ // 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'), @@ -290,7 +315,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(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(eventSeatBreakdownQuery(db, eventIds)) + : []; const countByEvent = new Map(); for (const row of countRows) { countByEvent.set(row.eventId, { @@ -300,16 +329,16 @@ eventsRouter.get('/', async (c) => { } const siteSettingsRow = await getSiteSettingsRow(); - const eventsWithCounts = result.map((event: any) => { - const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }); - 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 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 }) @@ -335,17 +364,21 @@ eventsRouter.get('/:id', async (c) => { } } - const normalized = normalizeEvent(event, await getSiteSettingsRow(), { + 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(), }); }); @@ -393,13 +426,8 @@ async function getNextChronologicalUpcoming(): Promise { } const counts = await getEventSeatCounts(event.id); - const normalized = normalizeEvent(event, await getSiteSettingsRow()); - 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 @@ -462,13 +490,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, settings); 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, }, }); @@ -523,7 +547,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c }); // 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'); @@ -669,17 +693,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( (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) @@ -723,6 +747,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( 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, }; @@ -733,7 +758,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async ( }); // 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( @@ -754,7 +779,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'); diff --git a/backend/src/routes/events.walkin.integration.test.ts b/backend/src/routes/events.walkin.integration.test.ts index 26f7b18..6918f2f 100644 --- a/backend/src/routes/events.walkin.integration.test.ts +++ b/backend/src/routes/events.walkin.integration.test.ts @@ -178,3 +178,68 @@ describe('public event responses', () => { } }); }); + +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) { + 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); + }); +}); diff --git a/backend/src/routes/finance.ts b/backend/src/routes/finance.ts new file mode 100644 index 0000000..27e7949 --- /dev/null +++ b/backend/src/routes/finance.ts @@ -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((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((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 = { ...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((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((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))), + dbGet((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((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((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((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 = { ...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((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((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))), + dbGet((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((db as any).select().from(expenseTemplatePacks)), + dbAll((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((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 = { 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((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) => { + 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( + (db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined) + ); + + const rows: any[] = []; + const seriesOptions = new Set(); + const venueOptions = new Set(); + const partnerOptions = new Map(); + const bySeries = new Map(); + const byVenue = new Map(); + const byPartner = new Map(); + 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 = (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; diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index fd108d5..699ed67 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -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(); @@ -276,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( @@ -287,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(); @@ -339,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) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 2791492..0dead4d 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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,7 +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 { isPresaleClosed } from '../lib/presale.js'; +import { isOnlineSalesClosed } from '../lib/salesState.js'; const ticketsRouter = new Hono(); @@ -113,12 +116,13 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { } // Pre-sale closure: online registration stops N minutes before the event - // starts (per-event override, else the site-wide default). Staff/door and - // admin ticket creation use separate endpoints and are not gated. + // 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( (db as any).select().from(siteSettings).limit(1) ); - if (isPresaleClosed(event, siteSettingsRow)) { + if (isOnlineSalesClosed(event, siteSettingsRow)) { return c.json({ error: 'Registration for this event is closed' }, 400); } @@ -675,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) { @@ -726,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'); @@ -845,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'); @@ -878,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; @@ -937,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; @@ -1042,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'); @@ -1097,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'); @@ -1366,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( @@ -1394,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'); @@ -1419,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 @@ -1558,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 @@ -1756,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'); @@ -1787,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, })); diff --git a/frontend/package.json b/frontend/package.json index 3863ce3..328b8a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/app/(public)/dashboard/components/MyEventsTab.tsx b/frontend/src/app/(public)/dashboard/components/MyEventsTab.tsx new file mode 100644 index 0000000..81b06eb --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/MyEventsTab.tsx @@ -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

{t('dashboard.myEvents.empty')}

; + } + const fmt = (iso: string) => + new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + + return ( +
    + {events.map(({ event, rolePreset }) => ( +
  • + +
    +

    {(locale === 'es' && event.titleEs) || event.title}

    +

    + {fmt(event.startDatetime)} + {event.location} +

    +
    + + {t(`admin.rolePresets.${rolePreset}`)} + + + +
  • + ))} +
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/events/[id]/page.tsx b/frontend/src/app/(public)/dashboard/events/[id]/page.tsx new file mode 100644 index 0000000..18ae973 --- /dev/null +++ b/frontend/src/app/(public)/dashboard/events/[id]/page.tsx @@ -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 ( + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/page.tsx b/frontend/src/app/(public)/dashboard/page.tsx index e181758..7fcdd53 100644 --- a/frontend/src/app/(public)/dashboard/page.tsx +++ b/frontend/src/app/(public)/dashboard/page.tsx @@ -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('overview'); const [nextEvent, setNextEvent] = useState(null); const [tickets, setTickets] = useState([]); const [payments, setPayments] = useState([]); + const [myEvents, setMyEvents] = useState([]); 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' && ( )} + {activeTab === 'events' && } {activeTab === 'account' && } )} diff --git a/frontend/src/app/(public)/events/EventsClient.tsx b/frontend/src/app/(public)/events/EventsClient.tsx index f5dadba..ad32aae 100644 --- a/frontend/src/app/(public)/events/EventsClient.tsx +++ b/frontend/src/app/(public)/events/EventsClient.tsx @@ -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 {t('events.details.cancelled')}; } - if (event.availableSeats === 0) { + if (event.salesState === 'sold_out' || event.availableSeats === 0) { return {t('events.details.soldOut')}; } + if (event.salesState === 'door') { + return ( + + {t('events.door.badge')} + + ); + } return null; }; diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index 050aeae..21207df 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -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, isPresaleClosed, parseDate, formatDurationWords } 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,18 +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 isPastEvent = legacyRules ? (mounted ? new Date(event.startDatetime) < new Date() : false) : hasEnded; // Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent - const presaleClosed = mounted ? isPresaleClosed(event) : false; - const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted'); + 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 ( +
+

+ {t('events.door.onlineClosed')} +

+

+ {t('events.door.headline')} +

+

+ {doorPrice === 0 ? t('events.details.free') : formatPrice(doorPrice, event.currency)} +

+ {doorPrice > 0 && ( +

{t('events.door.perPerson')}

+ )} + {doorPrice > event.price && event.price > 0 && ( +

+ {t('events.door.onlinePriceWas', { price: formatPrice(event.price, event.currency) })} +

+ )} +

{spotsLeftText}

+ {doorMethodsText && ( +

{t('events.door.accepts', { methods: doorMethodsText })}

+ )} +

+ {t('events.door.doorsOpen', { time: fmtTime(event.startDatetime) })} +

+
+ ); + }; + // Booking card content - reused for mobile and desktop positions - const BookingCardContent = () => ( + const BookingCardContent = () => { + if (isDoorSales) return ; + if (hasEnded) { + return ( +
+

{t('events.details.eventHasEnded')}

+
+ ); + } + return ( <>

{t('events.details.price')}

@@ -173,7 +274,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

)} - ); + ); + }; return (
@@ -272,7 +374,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail

{t('events.details.capacity')}

- {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')} + {isDoorSales + ? t('events.door.spotsLeftAtDoor', { n: spotsLeft }) + : `${spotsLeft} / ${event.capacity} ${t('events.details.spotsLeft')}`}

diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 638a674..d824b93 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -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'; @@ -26,6 +27,7 @@ interface Event { availableSeats?: number; bookedCount?: number; presaleClosesAt?: string | null; + salesState?: EventSalesState; createdAt: string; updatedAt: string; } @@ -114,19 +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 ? { validThrough: event.presaleClosesAt } : {}), + ...(event.presaleClosesAt && event.salesState !== 'door' ? { validThrough: event.presaleClosesAt } : {}), }, image: event.bannerUrl ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) diff --git a/frontend/src/app/admin/events/[id]/_components/EventDetailSkeleton.tsx b/frontend/src/app/admin/events/[id]/_components/EventDetailSkeleton.tsx new file mode 100644 index 0000000..5669f96 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_components/EventDetailSkeleton.tsx @@ -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 ( + <> + + + + ); +} + +export function EventMetaChipsSkeleton() { + return ( + + ); +} + +/** Desktop 4-card stats row and the collapsed mobile stats bar. */ +export function EventStatsSkeleton() { + return ( + <> + + + + ); +} + +/** 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 ( + <> + + + + ); +} + +/** Toolbar plus list rows, sized for the attendee/ticket lists inside a tab. */ +export function EventTabBodySkeleton() { + return ( + + + + + ); +} + +/** Wraps a tab body skeleton in the same card the real tab content sits in. */ +export function EventTabContentSkeleton() { + return ( +
+ +
+ ); +} + +/** Back arrow row shared by the loading shell and the real header. */ +export function EventBackButton({ href }: { href: string }) { + return ( + + + + ); +} + +/** Whole-page shell before the event itself has loaded. The back arrow is real. */ +export function EventDetailSkeleton({ backHref }: { backHref: string }) { + return ( +
+
+ + +
+ + + + +
+ ); +} diff --git a/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx b/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx new file mode 100644 index 0000000..0222233 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx @@ -0,0 +1,881 @@ +'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 = { + 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 | 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('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(null); + // Scroll only the strip itself: scrollIntoView would also shift the page sideways. + useEffect(() => { + const strip = mobileTabsRef.current; + const el = strip?.querySelector(`[data-tab="${activeTab}"]`); + if (strip && el) strip.scrollLeft = el.offsetLeft - (strip.clientWidth - el.clientWidth) / 2; + }, [activeTab, eventLoading, permissions]); + + // Email state + const [selectedTemplate, setSelectedTemplate] = useState(''); + const [recipientFilter, setRecipientFilter] = useState('confirmed'); + const [customMessage, setCustomMessage] = useState(''); + const [sending, setSending] = useState(false); + const [previewHtml, setPreviewHtml] = useState(null); + + // Attendees tab state + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const { privacyMode } = usePrivacy(); + const showStats = !privacyMode; + const [showNoteModal, setShowNoteModal] = useState(false); + const [selectedTicket, setSelectedTicket] = useState(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(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('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(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(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 ; + } + + if (!event) { + return ( +
+

Event not found

+ + + +
+ ); + } + + 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; + // Header money follows the UI language's thousands separator. + const money = (amount: number) => formatCurrency(amount, event.currency, locale); + // "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 ( +
+ {/* ============= HEADER ============= */} +
+ +
+

{event.title}

+

{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}

+
+ {/* Desktop header actions */} +
+ + + + {can('edit_event') && ( + + )} +
+ {/* Mobile header overflow menu */} +
+ + + + } + > + { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}> + View Public + + {can('edit_event') && ( + { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}> + Edit Event + + )} + +
+
+ + {/* ============= COMPACT META CHIPS (desktop) ============= */} +
+ + + {formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`} + + + + {event.location} + + + + {event.price === 0 ? t('admin.eventStats.free') : money(event.price)} + + {showStats && !detailsLoading && ( + + + {confirmedCount + checkedInCount}/{event.capacity} + + )} +
+ + {/* ============= STATS ROW ============= */} + {showStats && detailsLoading && } + {!detailsLoading && (<> + {/* Desktop: always-visible compact 4-card row */} +
+ {showStats && ( +
+ {[ + { 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: money(revenue), + icon: CurrencyDollarIcon, + color: 'bg-gray-50 text-gray-600', + detail: doorSummary + ? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) }) + : undefined, + }]), + ].map((stat) => ( +
+
+ +
+
+

{stat.value}

+

{('detail' in stat && stat.detail) || stat.label}

+
+
+ ))} +
+ )} +
+ + {/* Mobile: collapsible stats */} +
+ {showStats && ( +
+ + {mobileStatsExpanded && ( +
+ {[ + { 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: money(revenue), + icon: CurrencyDollarIcon, + color: 'text-gray-600 bg-gray-50', + detail: doorSummary + ? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) }) + : undefined, + }]), + ].map((stat) => ( +
+
+ +
+
+

{stat.value}

+

{('detail' in stat && stat.detail) || stat.label}

+
+
+ ))} +
+ )} +
+ )} +
+ )} + + {/* ============= TAB BAR ============= */} + {!permissions ? : (<> + {/* Desktop: tab bar inside a card top-section */} +
+
+ +
+
+ + {/* Mobile: segmented tab bar */} +
+
+
+ {tabs.map((tab) => ( + + ))} +
+
+
+ )} + + {/* ============= TAB CONTENT ============= */} +
+ {/* 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) ? ( + + ) : (<> + {activeTab === 'overview' && ( + formatCurrency(amount, currency, locale)} + confirmedCount={confirmedCount} + checkedInCount={checkedInCount} + countsLoading={detailsLoading} + /> + )} + + {activeTab === 'attendees' && ( + + )} + + {activeTab === 'tickets' && ( + + )} + + {activeTab === 'email' && ( + + )} + + {activeTab === 'payments' && ( + + )} + + {activeTab === 'finance' && } + + {activeTab === 'team' && } + )} +
+ + {/* ============= MODALS ============= */} + + + setShowAddTicketModal(false)} + form={addTicketForm} + setForm={setAddTicketForm} + onSubmit={handleAddTicket} + submitting={submitting} + eventPriceLabel={event.price === 0 ? 'Free' : money(event.price)} + /> + + setShowEditForm(false)} + onSaved={() => { setShowEditForm(false); loadEventData(); }} + /> + + +
+ ); +} diff --git a/frontend/src/app/admin/events/[id]/_finance/ExpensesView.tsx b/frontend/src/app/admin/events/[id]/_finance/ExpensesView.tsx new file mode 100644 index 0000000..9fbd3f2 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_finance/ExpensesView.tsx @@ -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; +type Fmt = ReturnType; + +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
{children}
; +} + +// ==================== 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(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: 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 ( + + + setDescription(e.target.value)} + onBlur={() => setTouched(true)} + autoFocus + /> + + +
+ + {ct === 'percent_of_revenue' ? ( +
+ set('percent', e.target.value)} placeholder="10" /> + % +
+ ) : ct === 'minimum_spend' ? ( + set('minimumAmount', n)} /> + ) : ( + set('unitAmount', n)} /> + )} +
+ + + + {ct === 'minimum_spend' && ( + + set('unitAmount', n)} /> + + )} +
+ + {/* Live result: what this line costs with today's numbers */} +
+

{previewLine}

+ {isAuto(ct) && ( +
+ + {form.isLocked && ( + + set('computedAmount', n)} /> + + )} +
+ )} +
+ +
+ + + + + + label={t('admin.finance.expenses.status')} + value={form.status} + onChange={setStatus} + options={[ + { key: 'planned', label: t('admin.finance.expenseStatus.planned'), icon: }, + { key: 'paid', label: t('admin.finance.expenseStatus.paid'), icon: }, + ]} + /> + + + {form.status === 'paid' && ( + <> + + set('expenseDate', v)} /> + + 0 ? t('admin.finance.expenses.paidByHint') : undefined}> + + +
+ +
+ set('receiptUrl', e.target.value)} /> + {canUpload && ( + + )} +
+
+
+ + )} +
+ + + + + +
+ ); +} + +// ==================== 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 ( + + + setDescription(e.target.value)} autoFocus /> + + + + + + + + + + ); +} + +// ==================== 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([]); + const [packs, setPacks] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(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 ( + + {loading ?

{t('common.loading')}

: templates.length === 0 && packs.length === 0 ? ( +

{t('admin.finance.expenses.noTemplates')}

+ ) : ( +
+ {packs.length > 0 && ( +
+

{t('admin.finance.expenses.packsHeading')}

+
    + {packs.map((p) => ( +
  • + +
    +

    {p.name}

    +

    {p.templateIds.map(templateName).filter(Boolean).join(' · ')}

    +
    + +
  • + ))} +
+
+ )} + {templates.length > 0 && ( +
+

{t('admin.finance.expenses.templatesHeading')}

+
    + {templates.map((tpl) => ( +
  • +
    +

    {tpl.name}

    +

    {summary(tpl)}

    +
    + +
  • + ))} +
+
+ )} +
+ )} +
+ ); +} + +// ==================== 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(null); + const [formOpen, setFormOpen] = useState(false); + const [income, setIncome] = useState(null); + const [incomeOpen, setIncomeOpen] = useState(false); + const [templatesOpen, setTemplatesOpen] = useState(false); + const [busyId, setBusyId] = useState(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 ( + + + {c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized')} + + ); + }; + + // 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 ( + + ); + }; + + const IncomeTag = () => ( + {t('admin.finance.ledger.income')} + ); + + const LockButton = ({ e }: { e: EventExpense }) => isAuto(e.calcType) ? ( + + ) : null; + + const Meta = ({ e }: { e: EventExpense }) => ( +
+ + {isAuto(e.calcType) && ( + + {e.isLocked ? : } + {t(e.isLocked ? 'admin.finance.expenses.locked' : 'admin.finance.expenses.auto')} + + )} + {e.receiptUrl && ( + + {t('admin.finance.expenses.viewReceipt')} + + )} +
+ ); + + const modals = ( + <> + setFormOpen(false)} eventId={eventId} data={data} expense={editing} onSaved={onChange} /> + setIncomeOpen(false)} eventId={eventId} onSaved={onChange} /> + setTemplatesOpen(false)} eventId={eventId} onApplied={onChange} /> + + ); + + const addButtons = ( + <> + + {canEditIncome && ( + + )} + + + ); + + if (data.expenses.length === 0 && data.otherIncome.length === 0) { + return ( +
+ } title={t('admin.finance.ledger.emptyTitle')} body={t('admin.finance.expenses.empty')}> + {canAdd && addButtons} + + {modals} +
+ ); + } + + const incomeAmount = (i: OtherIncome) => ; + + return ( +
+ {/* Totals + actions */} +
+
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) => ( +
+
{s.label}
+
+ +
+
+ ))} +
+ {canAdd &&
{addButtons}
} +
+ +
+ {usedCategories.length > 1 && ( + + )} + {open && !canEditExpenses && canEditOwnExpenses &&

{t('admin.finance.expenses.ownOnly')}

} + {!open &&

{t('admin.finance.expenses.frozen')}

} +
+ + {/* Desktop table: amount sits on the right, next to the row actions */} +
+ + + + + + + + + + + + + + + + + + + {rows.map((row) => row.kind === 'expense' ? ( + + + + + + + + ) : ( + + + + + + + ))} + + + + + + + {!category && incomeTotal > 0 && ( + + + + + )} + +
{t('admin.finance.expenses.description')}{t('admin.finance.expenses.status')}{t('admin.finance.expenses.paidBy')}{t('admin.finance.expenses.amount')}{t('common.edit')}
+ {canEditRow(row.e) ? ( + + ) :

{row.e.description}

} + +
{row.e.status === 'paid' ? partnerName(row.e.paidByPartnerId) : ''} + + {!privacyMode && formula(row.e, t, m) &&

{formula(row.e, t, m)}

} +
+
+ + {canEditRow(row.e) && ( + <> + + + + )} +
+
+ {canEditIncome ? ( + + ) :

{row.i.description}

} +
+ {incomeAmount(row.i)} + {canEditIncome && ( +
+ + +
+ )} +
+ {t('admin.finance.ledger.costs')} + {category && · {t('admin.finance.expenses.filtered')}} + +
{t('admin.finance.otherIncome.title')} +
+
+ + {/* Mobile cards: tap the card to edit, status toggles in place */} +
    + {rows.map((row) => row.kind === 'expense' ? ( +
  • +
    + +
    + + {!privacyMode && formula(row.e, t, m) &&

    {formula(row.e, t, m)}

    } +
    +
    +
    + + {row.e.status === 'paid' && {partnerName(row.e.paidByPartnerId)}} +
    + + {canEditRow(row.e) && ( + + )} +
    +
    +
  • + ) : ( +
  • +
    + +
    + {incomeAmount(row.i)} + {canEditIncome && ( + + )} +
    +
    +
  • + ))} +
  • + {t('admin.finance.ledger.costs')} + +
  • + {!category && incomeTotal > 0 && ( +
  • + {t('admin.finance.otherIncome.title')} + +
  • + )} +
+ + {modals} +
+ ); +} diff --git a/frontend/src/app/admin/events/[id]/_finance/FinalizeDialog.tsx b/frontend/src/app/admin/events/[id]/_finance/FinalizeDialog.tsx new file mode 100644 index 0000000..50b96ee --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_finance/FinalizeDialog.tsx @@ -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 ( +
  • + + {children} + {value && {value}} +
  • + ); + }; + + return ( + +
      + {!lifecycle.ended && {t('admin.finance.finalizeDialog.notEnded')}} + {lifecycle.expenseCount === 0 + ? {t('admin.finance.finalizeDialog.noExpenses')} + : }>{tCount(t, 'admin.finance.finalizeDialog.expenses', lifecycle.expenseCount)}} + {lifecycle.plannedCount > 0 + ? }>{tCount(t, 'admin.finance.finalizeDialog.planned', lifecycle.plannedCount)} + : lifecycle.expenseCount > 0 && {t('admin.finance.finalizeDialog.allPaid')}} + {s.revenue.otherIncome > 0 && ( + }>{tCount(t, 'admin.finance.finalizeDialog.income', data.otherIncome.length)} + )} + {lifecycle.partnerCount > 0 + ? }>{tCount(t, 'admin.finance.finalizeDialog.partners', lifecycle.partnerCount)} + : {t('admin.finance.finalizeDialog.noPartners')}} +
    + +
    +
    + {t(loss ? 'admin.finance.finalizeDialog.finalLoss' : 'admin.finance.finalizeDialog.finalProfit')} + +
    + {lifecycle.partnerCount > 0 && s.split.organization !== null && ( +
    + {t('admin.finance.split.organization')} + +
    + )} +
    +

    {t('admin.finance.finalizeDialog.hint')}

    + +
    + + +
    +
    + ); +} diff --git a/frontend/src/app/admin/events/[id]/_finance/FinanceCharts.tsx b/frontend/src/app/admin/events/[id]/_finance/FinanceCharts.tsx new file mode 100644 index 0000000..0dbc9eb --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_finance/FinanceCharts.tsx @@ -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 = { in: C.blue, out: C.red, total: C.total }; +const KIND_SYMBOL: Record = { in: '+', out: '−', total: '=' }; + +type T = (key: string, params?: Record) => 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 ( +
    +
    +

    {title}

    + {legend} +
    + {subtitle &&

    {subtitle}

    } +
    {children}
    +
    + ); +} + +function LegendChip({ color, label, symbol }: { color: string; label: string; symbol: string }) { + return ( + + {symbol} + {label} + + ); +} + +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 ( +
    + {label !== undefined &&

    {label}

    } + {items.map((it) => ( +

    + {it.color && } + {it.name} + {formatPyg(it.value, locale)} +

    + ))} +
    + ); +} + +// ==================== 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 => { + const { x, y, width, height, index } = props; + const r = rows[index]; + if (!r) return ; + const top = Math.min(y, y + height); + return ( + + {r.signed} + + ); + }; + const short = (v: number) => formatPygShort(v, locale); + + return ( +
    +
    +

    {t('admin.finance.waterfall.title')}

    + + {kinds.map((k) => ( + + ))} + +
    + {estimated &&

    {t('admin.finance.waterfall.estimatedHint')}

    } +
    + {narrow ? ( + // Step name and signed amount as a two-line axis label; bars run left/right from zero. + + + + + { + const r = rows[props.index]; + return ( + + {r?.name} + {r?.signed} + + ); + }} + /> + + } /> + + {rows.map((r) => )} + + + + ) : ( + + + + + + + } /> + + {rows.map((r) => )} + + + + )} +
    +
    + ); +} + +// ==================== Horizontal single-series bars ==================== + +function HorizontalBars({ data, locale }: { data: { name: string; value: number; detail?: { name: string; value: number }[] }[]; locale: string }) { + return ( + + + + formatPygShort(v, locale)} /> + + r.detail || [{ name: r.name, value: r.value, color: C.blue }]} />} + /> + + labelPyg(v, locale)} style={{ fontSize: 11, fill: C.text, fontWeight: 600 }} /> + + + + ); +} + +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 ( + + + + ); +} + +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 ( + + + + ); +} + +// ==================== 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 ( + 0 && !showLine ? t('admin.finance.charts.breakEvenOffChart', { count: formatNumber(breakEven!, locale) }) : undefined} + > + + + + + formatNumber(v, locale)} + label={{ value: t('admin.finance.charts.tickets'), angle: -90, position: 'insideLeft', fontSize: 11, fill: C.axis, dy: 30 }} /> + { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
    +

    {p.label}

    +

    {t('admin.finance.kpi.ticketsSold', { count: formatNumber(p.tickets, locale) })}

    +

    {formatPyg(p.revenue, locale)}

    +
    + ); + }} + /> + {showLine && ( + + )} + +
    +
    +
    + ); +} + +// ==================== 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 ( + + + + + formatPygShort(v, locale)} /> + + } /> + + {hasPaid && } + {hasPlanned && } + + + + ); +} + +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 ; + const show = detailChartsAvailable(summary); + if (!show.costs && !show.methods && !show.timeline) return null; + return ( +
    + {show.costs && } + {show.methods && } + {show.timeline && } + {show.costs && } +
    + ); +} diff --git a/frontend/src/app/admin/events/[id]/_finance/FinanceTab.tsx b/frontend/src/app/admin/events/[id]/_finance/FinanceTab.tsx new file mode 100644 index 0000000..6deeed5 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_finance/FinanceTab.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [section, setSectionState] = useState('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 ( +
    + + +
    + {Array.from({ length: 4 }).map((_, i) => )} +
    + +
    + ); + } + if (!data || !lifecycle) return

    {t('admin.finance.loadError')}

    ; + + return ( +
    + + + + 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' && } + {section === 'expenses' && } + {section === 'split' && } +
    + ); +} diff --git a/frontend/src/app/admin/events/[id]/_finance/LifecycleStepper.tsx b/frontend/src/app/admin/events/[id]/_finance/LifecycleStepper.tsx new file mode 100644 index 0000000..89e8ece --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_finance/LifecycleStepper.tsx @@ -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 ( +
    +
      + {LIFECYCLE_STEPS.map((step, i) => { + const done = i < current || (step === 'finalized' && !open); + const active = i === current && open; + return ( +
    1. + {i > 0 && ( + + )} + + {done ? : i + 1} + + + {t(`admin.finance.lifecycle.steps.${step}`)} + +
    2. + ); + })} +
    + + {(nudge || lifecycle.todo.length > 0 || !open) && ( +
    + {nudge && ( +
    +

    + {ready ? : } + {nudge} +

    + {data.viewer.canEditExpenses && !ready && ( + + )} +
    + )} + {!open && ( +

    + {t(data.status === 'paid_out' ? 'admin.finance.lifecycle.paidOut' : 'admin.finance.lifecycle.frozen', { date: dateFmt(data.finalizedAt) })} +

    + )} + {lifecycle.todo.length > 0 && ( +
      + {lifecycle.todo.map((item) => ( +
    • + {item.tone === 'warn' + ? + : } + {item.count !== undefined + ? tCount(t, `admin.finance.lifecycle.todo.${item.key}`, item.count) + : t(`admin.finance.lifecycle.todo.${item.key}`)} +
    • + ))} +
    + )} +
    + )} +
    + ); +} diff --git a/frontend/src/app/admin/events/[id]/_finance/SplitView.tsx b/frontend/src/app/admin/events/[id]/_finance/SplitView.tsx new file mode 100644 index 0000000..590c4ef --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_finance/SplitView.tsx @@ -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; +type Fmt = ReturnType; + +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
    {children}
    ; +} + +// ==================== 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(emptyPartner()); + const [touched, setTouched] = useState(false); + const [saving, setSaving] = useState(false); + const set = (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 ( + + +
    + {form.userId ? ( +
    + {form.userLabel} + +
    + ) : ( + setForm((f) => ({ ...f, userId: u.id, userLabel: `${u.name} (${u.email})` }))} + placeholder={t('admin.finance.split.searchUser')} + noResults={t('admin.team.noResults')} + /> + )} + {!form.userId && ( + set('externalName', e.target.value)} /> + )} +
    +
    + + set('roleLabel', e.target.value)} /> + + + + 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}`) }))} + /> + +
    + {hasFixed && ( + + set('fixedAmount', n)} /> + + )} + {hasPercent && ( + +
    + set('percent', e.target.value)} /> + % +
    +
    + )} + {st === 'fixed_plus_percent_above_threshold' && ( + + set('thresholdAmount', n)} /> + + )} +
    + {st === 'percent_profit' && ( + + + 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' && ( +
    + + set('lossCapAmount', n)} /> + +
    + )} +
    + )} + + + + +
    + ); +} + +// ==================== 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 ( + +
    + {t(amount < 0 ? 'admin.finance.split.owes' : 'admin.finance.split.payout')} + +
    + + setMethod(e.target.value)} placeholder={t('admin.finance.split.payoutMethodPlaceholder')} /> + + + + + +