diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 78d2622..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}`); @@ -117,6 +118,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 +303,21 @@ async function migrate() { ) `); + // Migration: tickets.booking_source (online | walk_in | admin). On first run, + // backfill walk-ins created on the door screen: each one left an idempotency + // record whose undo_state is {kind: 'created', ticketId}. Nothing else can be + // identified reliably, so all other existing tickets stay 'online'. + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_source TEXT NOT NULL DEFAULT 'online'`); + await (db as any).run(sql` + UPDATE tickets SET booking_source = 'walk_in' + WHERE id IN ( + SELECT json_extract(undo_state, '$.ticketId') FROM idempotency_keys + WHERE scope = 'door-checkin' AND json_extract(undo_state, '$.kind') = 'created' + ) + `); + } catch (e) { /* column may already exist */ } + // Invoices table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS invoices ( @@ -392,6 +413,14 @@ async function migrate() { } catch (e) { /* column may already exist */ } } + // POS card terminal at the door: on globally by default, NULL override = inherit + try { + await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`); + } catch (e) { /* column may already exist */ } + await (db as any).run(sql` CREATE TABLE IF NOT EXISTS contacts ( id TEXT PRIMARY KEY, @@ -773,6 +802,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 +947,20 @@ async function migrate() { ) `); + // Migration: tickets.booking_source (online | walk_in | admin), with the + // same one-time walk-in backfill as the sqlite branch. + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_source VARCHAR(20) NOT NULL DEFAULT 'online'`); + await (db as any).execute(sql` + UPDATE tickets SET booking_source = 'walk_in' + WHERE id::text IN ( + SELECT undo_state::json->>'ticketId' FROM idempotency_keys + WHERE scope = 'door-checkin' AND undo_state IS NOT NULL + AND undo_state::json->>'kind' = 'created' + ) + `); + } catch (e) { /* column may already exist */ } + // Invoices table await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS invoices ( @@ -1006,6 +1054,14 @@ async function migrate() { } catch (e) { /* column may already exist */ } } + // POS card terminal at the door: on globally by default, NULL override = inherit + try { + await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS contacts ( id UUID PRIMARY KEY, @@ -1281,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)`, @@ -1298,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 05a87d6..bbd5e20 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'), @@ -91,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(), }); @@ -121,13 +126,16 @@ export const sqliteTickets = sqliteTable('tickets', { isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'), + // How the booking was made: public checkout, a walk-in on the door screen, or + // added by an admin. Distinct from payments.source, which is where money was taken. + bookingSource: text('booking_source', { enum: ['online', 'walk_in', 'admin'] }).notNull().default('online'), createdAt: text('created_at').notNull(), }); export const sqlitePayments = sqliteTable('payments', { id: text('id').primaryKey(), ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id), - provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(), + provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago', 'pos'] }).notNull(), amount: real('amount').notNull(), currency: text('currency').notNull().default('PYG'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'), @@ -146,7 +154,7 @@ export const sqlitePayments = sqliteTable('payments', { source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'), // Door tender used, for the end-of-night cash-up. Null for pre-sale payments. // 'guest' is a zero-amount comp entry and carries no revenue. - method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }), + method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }); @@ -194,6 +202,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', { cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true), cashInstructions: text('cash_instructions'), cashInstructionsEs: text('cash_instructions_es'), + // POS card terminal at the door (Scanner only, never offered at online checkout) + posEnabled: integer('pos_enabled', { mode: 'boolean' }).notNull().default(true), // Booking settings allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false), // Metadata @@ -226,6 +236,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides' cashEnabled: integer('cash_enabled', { mode: 'boolean' }), cashInstructions: text('cash_instructions'), cashInstructionsEs: text('cash_instructions_es'), + posEnabled: integer('pos_enabled', { mode: 'boolean' }), // Metadata createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), @@ -398,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(), @@ -476,6 +633,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'), @@ -485,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(), }); @@ -515,6 +676,8 @@ export const pgTickets = pgTable('tickets', { isGuest: pgInteger('is_guest').notNull().default(0), // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'), + // online | walk_in | admin — see sqliteTickets.bookingSource + bookingSource: varchar('booking_source', { length: 20 }).notNull().default('online'), createdAt: timestamp('created_at').notNull(), }); @@ -578,6 +741,7 @@ export const pgPaymentOptions = pgTable('payment_options', { cashEnabled: pgInteger('cash_enabled').notNull().default(1), cashInstructions: pgText('cash_instructions'), cashInstructionsEs: pgText('cash_instructions_es'), + posEnabled: pgInteger('pos_enabled').notNull().default(1), allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0), updatedAt: timestamp('updated_at').notNull(), updatedBy: uuid('updated_by').references(() => pgUsers.id), @@ -607,6 +771,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', { cashEnabled: pgInteger('cash_enabled'), cashInstructions: pgText('cash_instructions'), cashInstructionsEs: pgText('cash_instructions_es'), + posEnabled: pgInteger('pos_enabled'), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); @@ -778,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; @@ -802,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; @@ -835,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 f954361..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'; @@ -788,7 +790,7 @@ const openApiSpec = { post: { tags: ['Tickets'], summary: 'Check in, settle payment, or create a walk-in (atomic)', - description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.', + description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. Idempotent on idempotencyKey: replays return the original response instead of writing again.', security: [{ bearerAuth: [] }], parameters: [ { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, @@ -817,8 +819,10 @@ const openApiSpec = { type: 'object', required: ['method'], properties: { - method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] }, - amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' }, + method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }, + quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' }, + amount: { type: 'number', description: 'Ignored unless amountOverride is true.' }, + amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' }, }, }, entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] }, @@ -831,7 +835,8 @@ const openApiSpec = { responses: { 201: { description: 'Attendee checked in; warnings may contain at_capacity' }, 200: { description: 'Replay of an already-processed idempotencyKey' }, - 400: { description: 'Ticket belongs to a different event' }, + 400: { description: 'Ticket belongs to a different event, or the payment method is not enabled for this event' }, + 403: { description: 'amountOverride sent by a role that may not override the door amount' }, 404: { description: 'Event or ticket not found' }, }, }, @@ -2022,6 +2027,8 @@ app.route('/api/auth-ext', authExtRoutes); // Door check-in screen endpoints live under /api/events/:eventId/door-*. // Mounted first so the generic /:id routes below can never shadow them. app.route('/api/events', doorRoutes); +// Per-event finance, partners and team access (/api/events/:id/finance, /expenses, /members, ...) +app.route('/api/events', eventFinanceRoutes); app.route('/api/events', eventsRoutes); app.route('/api/tickets', ticketsRoutes); app.route('/api/users', usersRoutes); @@ -2037,6 +2044,7 @@ app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/legal-pages', legalPagesRoutes); app.route('/api/legal-settings', legalSettingsRoutes); app.route('/api/faq', faqRoutes); +app.route('/api/finance', financeRoutes); // 404 handler app.notFound((c) => { 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 397736e..8f9bc6a 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,65 @@ // already made — the same trust model as cash, no invoice generated. When a real // Lightning flow lands it slots in here: the tender keeps its name and provider, // only the settlement path in routes/door.ts changes. +// +// POS is the physical card terminal. Staff open the POS step, which shows the +// amount to key into the terminal, charge the card, then confirm "Mark as paid"; +// only that confirmation reaches this API, recorded like any other tender. A +// future automatic amount push to the terminal belongs in the POS step itself +// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to +// the terminal today. -export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const; +import { eq } from 'drizzle-orm'; +import { db, dbGet, paymentOptions, eventPaymentOverrides } from '../db/index.js'; + +export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const; export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number]; interface DoorTender { /** Existing payments.provider this tender is stored as. */ - provider: 'cash' | 'lightning' | 'bank_transfer'; + provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos'; /** Human label used in payment references and toasts. */ label: string; /** Comp tenders carry no revenue and always record a zero amount. */ isComp: boolean; + /** + * How the money is confirmed before the door screen records it: + * 'on_tap' staff already hold the money when they tap (cash, …) + * 'staff_confirm' staff charge an external device first, then confirm + */ + confirmation: 'on_tap' | 'staff_confirm'; } export const DOOR_TENDERS: Record = { - cash: { provider: 'cash', label: 'cash', isComp: false }, - bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false }, - transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false }, - guest: { provider: 'cash', label: 'guest', isComp: true }, + cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' }, + bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' }, + transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' }, + pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' }, + guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' }, }; +/** + * Tenders that can be switched off per event through payment options. Only POS + * is configurable: the other door tenders are always available to staff. + */ +export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] { + return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled); +} + +/** Door tenders available for this event (POS can be switched off per event). */ +export async function loadDoorMethods(eventId: string): Promise { + 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/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/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/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/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/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..64d747f 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'; @@ -182,6 +183,17 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { .groupBy((tickets as any).eventId) ); + // Revenue is what was actually paid, not tickets × the current event price + // (which an admin can change after tickets have been sold). + const revenueRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`coalesce(sum(${(payments as any).amount}), 0)` }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .where(eq((payments as any).status, 'paid')) + .groupBy((tickets as any).eventId) + ); + const toMap = (rows: any[]) => { const m = new Map(); for (const r of rows) m.set(r.eventId, Number(r.count) || 0); @@ -190,6 +202,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { const totalMap = toMap(totalRows); const confirmedMap = toMap(confirmedRows); const checkedInMap = toMap(checkedInRows); + const revenueMap = toMap(revenueRows); const eventStats = allEvents.map((event: any) => { const confirmedBookings = confirmedMap.get(event.id) || 0; @@ -201,7 +214,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { totalBookings: totalMap.get(event.id) || 0, confirmedBookings, checkedIn: checkedInMap.get(event.id) || 0, - revenue: confirmedBookings * event.price, + revenue: revenueMap.get(event.id) || 0, }; }); @@ -264,7 +277,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { }); // Export attendees for a specific event (admin) — CSV download -adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => { +adminRouter.get('/events/:eventId/attendees/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => { const eventId = c.req.param('eventId'); const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all const q = c.req.query('q') || ''; @@ -368,14 +381,14 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy }); // Legacy alias — keep old path working -adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => { +adminRouter.get('/events/:eventId/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => { const newUrl = new URL(c.req.url); newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export'); return c.redirect(newUrl.toString(), 301); }); // Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only) -adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => { +adminRouter.get('/events/:eventId/tickets/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => { const eventId = c.req.param('eventId'); const status = c.req.query('status') || 'all'; // confirmed | checked_in | all const q = c.req.query('q') || ''; @@ -519,6 +532,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), + pos: filteredPayments.filter((p: any) => p.provider === 'pos' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0), }, paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length, pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length, diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 7ca73ee..5b479af 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,18 +1,54 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js'; +import { db, dbGet, dbAll, users, tickets, payments, events, invoices, eventMembers } from '../db/index.js'; import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm'; import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js'; import { auth } from '../lib/betterAuth.js'; import { authSessions, authAccounts } from '../db/auth-schema.js'; import { getNow } from '../lib/utils.js'; +import { omitWalkInPrice } from '../lib/walkInPrice.js'; +import { resolveMemberPermissions, EVENT_PERMISSIONS } from '../lib/eventPermissions.js'; const dashboard = new Hono(); // Apply authentication to all routes dashboard.use('*', requireAuth()); +// ==================== My Events (team memberships) ==================== + +// Events the user was added to as staff / collaborator / co-manager, with what +// they may do on each. Opens the scoped event page at /dashboard/events/:id. +dashboard.get('/my-events', async (c) => { + const user = (c as any).get('user') as AuthUser; + const rows = await dbAll( + (db as any) + .select({ m: eventMembers, e: events }) + .from(eventMembers) + .innerJoin(events, eq((eventMembers as any).eventId, (events as any).id)) + .where(eq((eventMembers as any).userId, user.id)) + ); + const toIso = (v: any) => (v instanceof Date ? v.toISOString() : v); + return c.json({ + events: rows + .map((r: any) => ({ + event: { + id: r.e.id, + slug: r.e.slug, + title: r.e.title, + titleEs: r.e.titleEs, + startDatetime: toIso(r.e.startDatetime), + location: r.e.location, + status: r.e.status, + bannerUrl: r.e.bannerUrl, + }, + rolePreset: r.m.rolePreset, + permissions: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(r.m.rolePreset, r.m.permissions).has(p)), + })) + .sort((a: any, b: any) => String(b.event.startDatetime).localeCompare(String(a.event.startDatetime))), + }); +}); + // ==================== Profile Routes ==================== const updateProfileSchema = z.object({ @@ -208,7 +244,7 @@ dashboard.get('/tickets/:id', async (c) => { return c.json({ ticket: { ...ticket, - event, + event: omitWalkInPrice(event as any), payment, invoice, }, @@ -272,7 +308,7 @@ dashboard.get('/next-event', async (c) => { return c.json({ nextEvent: { - event: nextEvent, + event: omitWalkInPrice(nextEvent), ticket: nextTicket, payment: nextPayment, }, diff --git a/backend/src/routes/door.integration.test.ts b/backend/src/routes/door.integration.test.ts index 7846676..5812f2e 100644 --- a/backend/src/routes/door.integration.test.ts +++ b/backend/src/routes/door.integration.test.ts @@ -79,6 +79,8 @@ function seedTicket(row: { phone?: string | null; bookingId?: string | null; qr?: string; + /** Seed the pre-sale payment a paid ticket was bought with, at this amount. */ + paidAmount?: number; }) { sqlite .prepare( @@ -100,6 +102,17 @@ function seedTicket(row: { row.qr ?? `QR-${row.id}`, new Date().toISOString() ); + if (row.paidAmount !== undefined) seedPayment(row.id, row.paidAmount, 'paid'); +} + +function seedPayment(ticketId: string, amount: number, status: string, provider = 'bancard') { + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO payments (id, ticket_id, provider, amount, currency, status, paid_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 'PYG', ?, ?, ?, ?)` + ) + .run(`pay-${ticketId}`, ticketId, provider, amount, status, status === 'paid' ? now : null, now, now); } beforeAll(() => { @@ -134,7 +147,7 @@ beforeAll(() => { ) .run(EVENT_ID, now, PRICE, now, now); - seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567' }); + seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567', paidAmount: PRICE }); seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' }); seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' }); seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' }); @@ -197,7 +210,7 @@ describe('door-checkin: existing ticket', () => { const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at; expect(after).toBe(before); - expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-paid').n).toBe(0); + expect(sqlite.prepare("SELECT COUNT(*) n FROM payments WHERE ticket_id = ? AND source = 'door'").get('tkt-paid').n).toBe(0); }); it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => { @@ -223,7 +236,7 @@ describe('door-checkin: existing ticket', () => { it('takes a group payment at a multiple of the ticket price', async () => { const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, { ticketId: 'tkt-unpaid-2', - payment: { method: 'transfer', amount: PRICE * 2 }, + payment: { method: 'transfer', quantity: 2 }, idempotencyKey: 'key-unpaid-2-transfer', }); expect(body.payment.amount).toBe(PRICE * 2); @@ -323,7 +336,7 @@ describe('door-checkin: walk-ins', () => { describe('undo', () => { it('reverts a plain check-in to its previous state', async () => { - seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid' }); + seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid', paidAmount: PRICE }); await post(`/api/events/${EVENT_ID}/door-checkin`, { ticketId: 'tkt-undo', idempotencyKey: 'key-undo-checkin', @@ -440,4 +453,239 @@ describe('door-summary', () => { expect(body.door.lines.length).toBe(body.door.count); expect(body.door.lines[0]).toHaveProperty('name'); }); + + it('keeps pre-sale revenue at what was paid when the ticket price changes', async () => { + sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(99000, EVENT_ID); + try { + const { body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`)); + expect(body.presale).toEqual({ count: 2, total: PRICE * 2 }); + expect(body.door.total).toBe(PRICE * 6); + } finally { + sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(PRICE, EVENT_ID); + } + }); +}); + +// ==================== Walk-in price & POS ==================== +// Separate events so these cannot drift into the door-summary totals above. + +const WALKIN_EVENT_ID = 'evt-door-walkin'; +const WALKIN_PRICE = 25000; +const WALKIN_TICKET_PRICE = 21000; +const FREE_WALKIN_EVENT_ID = 'evt-door-free-walkin'; +const NO_POS_EVENT_ID = 'evt-door-no-pos'; + +describe('walk-in pricing', () => { + beforeAll(() => { + const now = new Date().toISOString(); + const insertEvent = sqlite.prepare( + `INSERT INTO events (id, title, description, start_datetime, location, price, walk_in_price, currency, capacity, status, created_at, updated_at) + VALUES (?, ?, 'desc', ?, 'Asuncion', ?, ?, 'PYG', 100, 'published', ?, ?)` + ); + insertEvent.run(WALKIN_EVENT_ID, 'Walk-in Night', now, WALKIN_TICKET_PRICE, WALKIN_PRICE, now, now); + insertEvent.run(FREE_WALKIN_EVENT_ID, 'Free Door Night', now, WALKIN_TICKET_PRICE, 0, now, now); + insertEvent.run(NO_POS_EVENT_ID, 'No POS Night', now, WALKIN_TICKET_PRICE, null, now, now); + + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at) + VALUES (?, ?, ?, 'admin', 1, 'active', ?, ?)` + ) + .run(ADMIN.id, 'admin@test.py', ADMIN.name, now, now); + + sqlite + .prepare( + `INSERT INTO event_payment_overrides (id, event_id, pos_enabled, created_at, updated_at) + VALUES ('ovr-no-pos', ?, 0, ?, ?)` + ) + .run(NO_POS_EVENT_ID, now, now); + + sqlite + .prepare( + `INSERT INTO tickets (id, user_id, event_id, attendee_first_name, status, payment_status, is_guest, qr_code, created_at) + VALUES ('tkt-walkin-event-unpaid', 'seed-user', ?, 'Pre', 'confirmed', 'unpaid', 0, 'QR-walkin-unpaid', ?)` + ) + .run(WALKIN_EVENT_ID, now); + }); + + it('resolves the walk-in unit price on the server for the door screen', async () => { + const withPrice = await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`); + expect(withPrice.body.event).toMatchObject({ + price: WALKIN_TICKET_PRICE, + walkInPrice: WALKIN_PRICE, + walkInUnitPrice: WALKIN_PRICE, + walkInPriceSource: 'walk_in', + }); + + const fallback = await get(`/api/events/${EVENT_ID}/door-attendees`); + expect(fallback.body.event).toMatchObject({ + walkInPrice: null, + walkInUnitPrice: PRICE, + walkInPriceSource: 'ticket', + }); + + const free = await get(`/api/events/${FREE_WALKIN_EVENT_ID}/door-attendees`); + expect(free.body.event).toMatchObject({ walkInPrice: 0, walkInUnitPrice: 0, walkInPriceSource: 'walk_in' }); + }); + + it('charges walk-ins the walk-in price and marks them as walk-in bookings', async () => { + const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Door', lastName: 'Buyer' }, + payment: { method: 'cash' }, + entryMethod: 'walkin', + idempotencyKey: 'key-walkin-price-cash', + }); + expect(status).toBe(201); + expect(body.payment).toMatchObject({ method: 'cash', amount: WALKIN_PRICE, currency: 'PYG', amountOverridden: false }); + + const ticket = sqlite.prepare('SELECT booking_source FROM tickets WHERE id = ?').get(body.attendee.ticketId); + expect(ticket.booking_source).toBe('walk_in'); + const payment = sqlite.prepare('SELECT amount, currency, source FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment).toMatchObject({ amount: WALKIN_PRICE, currency: 'PYG', source: 'door' }); + }); + + it('treats a walk-in price of 0 as a free walk-in, not as "unset"', async () => { + const { body } = await post(`/api/events/${FREE_WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Free' }, + payment: { method: 'cash' }, + idempotencyKey: 'key-walkin-free', + }); + expect(body.payment.amount).toBe(0); + }); + + it('multiplies the resolved price by quantity', async () => { + const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Group' }, + payment: { method: 'cash', quantity: 3 }, + idempotencyKey: 'key-walkin-price-group', + }); + expect(body.payment.amount).toBe(WALKIN_PRICE * 3); + }); + + it('ignores a client-sent amount without the override flag', async () => { + const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Cheap' }, + payment: { method: 'cash', amount: 1 }, + idempotencyKey: 'key-walkin-client-amount', + }); + expect(status).toBe(201); + expect(body.payment.amount).toBe(WALKIN_PRICE); + const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment.amount).toBe(WALKIN_PRICE); + }); + + it('refuses an amount override from door staff and writes nothing', async () => { + const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n; + const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Discount' }, + payment: { method: 'cash', amount: 1000, amountOverride: true }, + idempotencyKey: 'key-walkin-staff-override', + }); + expect(status).toBe(403); + expect(body.code).toBe('OVERRIDE_FORBIDDEN'); + expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before); + }); + + it('lets an admin override the amount and records it in the audit log', async () => { + const { status, body } = await as(ADMIN, () => post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Friend' }, + payment: { method: 'cash', amount: 10000, amountOverride: true }, + idempotencyKey: 'key-walkin-admin-override', + })); + expect(status).toBe(201); + expect(body.payment).toMatchObject({ amount: 10000, amountOverridden: true }); + + const log = sqlite + .prepare(`SELECT * FROM audit_logs WHERE action = 'door_amount_override' AND target_id = ?`) + .get(body.payment.id); + expect(log.user_id).toBe(ADMIN.id); + expect(JSON.parse(log.details)).toMatchObject({ + eventId: WALKIN_EVENT_ID, + computedAmount: WALKIN_PRICE, + chargedAmount: 10000, + currency: 'PYG', + }); + }); + + it('settles an existing unpaid ticket at the ticket price, not the walk-in price', async () => { + const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + ticketId: 'tkt-walkin-event-unpaid', + payment: { method: 'cash' }, + idempotencyKey: 'key-walkin-event-existing', + }); + expect(body.payment.amount).toBe(WALKIN_TICKET_PRICE); + }); + + it('keeps the charged amount when the walk-in price is edited afterwards', async () => { + const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Snapshot' }, + payment: { method: 'cash' }, + idempotencyKey: 'key-walkin-snapshot', + }); + sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(99000, WALKIN_EVENT_ID); + try { + const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment.amount).toBe(WALKIN_PRICE); + } finally { + sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(WALKIN_PRICE, WALKIN_EVENT_ID); + } + }); +}); + +describe('POS tender', () => { + it('is offered on the door screen unless switched off for the event', async () => { + expect((await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`)).body.doorMethods) + .toEqual(['cash', 'bitcoin', 'transfer', 'pos', 'guest']); + expect((await get(`/api/events/${NO_POS_EVENT_ID}/door-attendees`)).body.doorMethods) + .toEqual(['cash', 'bitcoin', 'transfer', 'guest']); + }); + + it('records a confirmed POS walk-in as a paid door payment at the walk-in price', async () => { + const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Card', lastName: 'Payer' }, + payment: { method: 'pos' }, + entryMethod: 'walkin', + idempotencyKey: 'key-walkin-pos', + }); + expect(status).toBe(201); + expect(body.attendee).toMatchObject({ checkedIn: true, paymentStatus: 'paid', doorMethod: 'pos' }); + expect(body.payment).toMatchObject({ method: 'pos', amount: WALKIN_PRICE }); + + const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment).toMatchObject({ + provider: 'pos', + method: 'pos', + source: 'door', + status: 'paid', + amount: WALKIN_PRICE, + paid_by_admin_id: STAFF.id, + reference: 'Door — paid by POS', + }); + }); + + it('shows POS takings in the door summary', async () => { + const { body } = await as(ADMIN, () => get(`/api/events/${WALKIN_EVENT_ID}/door-summary`)); + expect(body.door.byMethod.pos).toEqual({ count: 1, total: WALKIN_PRICE }); + }); + + it('can be undone like any other walk-in', async () => { + const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Declined' }, + payment: { method: 'pos' }, + idempotencyKey: 'key-walkin-pos-undo', + }); + await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-walkin-pos-undo' }); + const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId); + expect(payment.status).toBe('cancelled'); + }); + + it('is rejected when POS is disabled for the event', async () => { + const { status, body } = await post(`/api/events/${NO_POS_EVENT_ID}/door-checkin`, { + attendee: { firstName: 'Nope' }, + payment: { method: 'pos' }, + idempotencyKey: 'key-walkin-pos-disabled', + }); + expect(status).toBe(400); + expect(body.code).toBe('METHOD_DISABLED'); + }); }); diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts index d532a84..52a61aa 100644 --- a/backend/src/routes/door.ts +++ b/backend/src/routes/door.ts @@ -21,16 +21,17 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, inArray, sql } from 'drizzle-orm'; import { - db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, + db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs, } from '../db/index.js'; -import { requireAuth } from '../lib/auth.js'; +import { requireEventPermission, eventFromParam, canSeeAttendeePii } from '../lib/eventPermissions.js'; import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js'; import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js'; import { seatHolderCountQuery } from '../lib/capacity.js'; import { DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference, - paymentStatusForMethod, type DoorPaymentMethod, + paymentStatusForMethod, loadDoorMethods, type DoorPaymentMethod, } from '../lib/doorPayments.js'; +import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js'; import emailService from '../lib/email.js'; const doorRouter = new Hono(); @@ -41,6 +42,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 +105,12 @@ async function loadEvent(eventId: string | undefined) { return { ...event, price: num(event.price), + walkInPrice: parseWalkInPrice(event.walkInPrice), capacity: Number(event.capacity), }; } + /** Names of the admins/staff referenced by the given check-in rows, in one query. */ async function loadAdminNames(adminIds: string[]): Promise> { const unique = [...new Set(adminIds.filter(Boolean))]; @@ -126,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); @@ -164,6 +172,9 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async ); for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method); + const enabledMethods = await loadDoorMethods(event.id); + const walkIn = resolveWalkInPrice(event); + const attendees = rows .map((t: any) => toDoorAttendee(t, { price: event.price, @@ -171,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; @@ -181,9 +193,15 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async id: event.id, title: event.title, price: event.price, + // What one walk-in ticket costs, resolved server-side (walk-in price, or + // the ticket price when none is set) so the screen shows what will be charged. + walkInPrice: event.walkInPrice, + walkInUnitPrice: walkIn.unitPrice, + walkInPriceSource: walkIn.source, currency: event.currency, capacity: event.capacity, }, + doorMethods: enabledMethods, attendees, stats: { checkedIn, totalActive, capacity: event.capacity }, }); @@ -204,9 +222,16 @@ const doorCheckinSchema = z.object({ }).optional(), payment: z.object({ method: z.enum(DOOR_PAYMENT_METHODS), - // Omitted means "one ticket at event price"; a multiple covers someone - // paying for their whole group in one go. + // How many tickets' worth is being paid (someone paying for their group). + // The server prices it: walk-in price for walk-ins, ticket price otherwise. + quantity: z.number().int().min(1).max(MAX_DOOR_QUANTITY).optional(), + // A typed amount is ignored unless amountOverride is set, which only + // admin/organizer may do. amount: z.number().min(0).optional(), + amountOverride: z.boolean().optional(), + }).refine((p) => !p.amountOverride || typeof p.amount === 'number', { + message: 'amount is required when amountOverride is set', + path: ['amount'], }).optional(), // How the attendee reached this action, for the session feed. entryMethod: z.enum(['scan', 'search', 'walkin']).optional(), @@ -243,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'); @@ -255,19 +280,36 @@ doorRouter.post( return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt }); } + const amountOverride = !!data.payment?.amountOverride; + if (amountOverride && !(AMOUNT_OVERRIDE_ROLES as readonly string[]).includes(adminUser?.role)) { + return c.json({ error: 'Only an admin can override the door amount', code: 'OVERRIDE_FORBIDDEN' }, 403); + } + const event = await loadEvent(eventId); if (!event) return c.json({ error: 'Event not found' }, 404); + const method = data.payment?.method as DoorPaymentMethod | undefined; + if (method && !(await loadDoorMethods(event.id)).includes(method)) { + return c.json({ error: `${DOOR_TENDERS[method].label} is not enabled for this event`, code: 'METHOD_DISABLED' }, 400); + } + const now = getNow(); const nowIso = new Date().toISOString(); - const method = data.payment?.method as DoorPaymentMethod | undefined; - const requestedAmount = data.payment?.amount ?? event.price; + const quantity = data.payment?.quantity ?? 1; + // Walk-ins pay the walk-in price (falling back to the ticket price); an + // existing unpaid ticket settles at the current ticket price (its pending + // payment is repriced whenever the event price changes). + const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice; + const computedAmount = unitPrice * quantity; + const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount; const ops: TxOp[] = []; let undoState: UndoState; let action: 'checkin' | 'walkin'; let ticketRow: any; - let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null; + let paymentSummary: { + id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean; + } | null = null; let emailTicketId: string | null = null; if (data.ticketId) { @@ -326,7 +368,7 @@ doorRouter.post( method, updatedAt: now, }, eq((payments as any).id, existingPayment.id))); - paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency }; + paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp }; } else { const paymentId = generateId(); undo.createdPaymentId = paymentId; @@ -345,7 +387,7 @@ doorRouter.post( createdAt: now, updatedAt: now, })); - paymentSummary = { id: paymentId, method, amount, currency: event.currency }; + paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp }; } } @@ -429,6 +471,7 @@ doorRouter.post( checkinAt: now, checkedInByAdminId: adminUser?.id || null, adminNote: null, + bookingSource: 'walk_in', createdAt: now, }; ops.push(insertOp(tickets, newTicket)); @@ -448,13 +491,35 @@ doorRouter.post( updatedAt: now, })); - paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency }; + paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp }; undoState = { kind: 'created', ticketId, paymentId }; ticketRow = newTicket; // Only mail people who actually gave an address; no QR for the rest. if (hasEmail) emailTicketId = ticketId; } + // Written in the same transaction as the payment, so the log can never + // disagree with what was recorded. + if (paymentSummary?.amountOverridden) { + ops.push(insertOp(auditLogs, { + id: generateId(), + userId: adminUser?.id || null, + action: 'door_amount_override', + target: 'payment', + targetId: paymentSummary.id, + details: JSON.stringify({ + eventId, + ticketId: ticketRow.id, + method: paymentSummary.method, + quantity, + computedAmount, + chargedAmount: paymentSummary.amount, + currency: event.currency, + }), + timestamp: now, + })); + } + // Staff at the door is the authority: a full event is a warning, never a block. const held = await seatsHeld(eventId); const atCapacity = event.capacity > 0 && held >= event.capacity; @@ -496,6 +561,13 @@ doorRouter.post( throw err; } + if (paymentSummary?.amountOverridden) { + console.info( + `[Door] Amount override by ${adminUser?.id} (${adminUser?.role}) on event ${eventId}: ` + + `${paymentSummary.amount} ${event.currency} instead of ${computedAmount} (${paymentSummary.method})` + ); + } + if (emailTicketId) { emailService.sendBookingConfirmation(emailTicketId).catch((err) => { console.error('[Email] Failed to send door walk-in confirmation:', err); @@ -512,7 +584,7 @@ doorRouter.post( doorRouter.post( '/:eventId/door-checkin/undo', - requireAuth([...STAFF_ROLES]), + requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }), zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })), async (c) => { const { idempotencyKey } = c.req.valid('json'); @@ -579,7 +651,7 @@ doorRouter.post( // End-of-night reconciliation: what was taken at the door, by tender, plus the // pre-sale/door split the event dashboard shows. -doorRouter.get('/:eventId/door-summary', requireAuth([...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); @@ -620,21 +692,32 @@ doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async doorTotal += amount; } - // Pre-sale revenue keeps the dashboard's existing definition — settled tickets - // at event price — minus anything that was actually taken at the door. + // Pre-sale revenue: settled tickets that weren't taken at the door, at the + // amount actually paid — never the current event price, which an admin can + // change after tickets have been sold. const doorTicketIds = new Set(rows.map((r: any) => r.ticketId)); const settled = await dbAll( (db as any) - .select({ id: (tickets as any).id }) + .select({ id: (tickets as any).id, amount: (payments as any).amount }) .from(tickets) + .leftJoin(payments, and( + eq((payments as any).ticketId, (tickets as any).id), + eq((payments as any).status, 'paid') + )) .where(and( eq((tickets as any).eventId, eventId), eq((tickets as any).paymentStatus, 'paid'), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` )) ); - const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length; - const presaleTotal = presaleCount * event.price; + const presaleIds = new Set(); + let presaleTotal = 0; + for (const t of settled) { + if (doorTicketIds.has(t.id)) continue; + presaleIds.add(t.id); + presaleTotal += num(t.amount); + } + const presaleCount = presaleIds.size; return c.json({ eventId, 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.priceChange.integration.test.ts b/backend/src/routes/events.priceChange.integration.test.ts new file mode 100644 index 0000000..d9289a4 --- /dev/null +++ b/backend/src/routes/events.priceChange.integration.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execFileSync } from 'child_process'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +// Changing an event's ticket price must never rewrite what was already paid, +// but anyone who booked and hasn't paid yet owes the new price. These tests pin +// which payments follow a price change and which keep their amount. + +// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres +// URL to run the same suite in a throwaway schema (dropped afterwards). +const PG_URL = process.env.FINANCE_TEST_PG_URL; +const PG_SCHEMA = `pricetest_${Date.now()}`; +if (PG_URL) { + process.env.DB_TYPE = 'postgres'; + process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`; +} else { + const dir = mkdtempSync(join(tmpdir(), 'events-price-test-')); + process.env.DB_TYPE = 'sqlite'; + process.env.DATABASE_URL = join(dir, 'test.db'); +} +process.env.FRONTEND_URL = 'http://localhost:3002'; +process.env.BETTER_AUTH_SECRET = 'events-price-secret-0123456789abcdef'; +delete process.env.REDIS_URL; +delete process.env.REVALIDATE_SECRET; + +const ADMIN = { id: randomUUID(), name: 'The Admin', role: 'admin' }; + +vi.mock('../lib/auth.js', () => ({ + requireAuth: (roles?: string[]) => async (c: any, next: any) => { + if (roles && !roles.includes(ADMIN.role)) return c.json({ error: 'Forbidden' }, 403); + c.set('user', ADMIN); + await next(); + }, + getAuthUser: async () => ADMIN, +})); + +let app: any; +let dbm: any; + +const SEED_USER_ID = randomUUID(); +const EVENT_ID = randomUUID(); +const OTHER_EVENT_ID = randomUUID(); +const OLD_PRICE = 50000; +const NEW_PRICE = 70000; +// Ticket id by label, so assertions read by name. +const ticketIds: Record = {}; + +async function put(path: string, body: unknown) { + const res = await app.request(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +/** One ticket with its payment row, the way a booking leaves them. Seeded through drizzle so it works on both engines. */ +async function seedBooking(label: string, eventId: string, paymentStatus: string, provider = 'bancard') { + const { db, tickets, payments } = dbm; + const now = dbm.getNow(); + const id = randomUUID(); + ticketIds[label] = id; + const paid = paymentStatus === 'paid'; + await db.insert(tickets).values({ + id, userId: SEED_USER_ID, eventId, attendeeFirstName: label, status: paid ? 'confirmed' : 'pending', + paymentStatus: paid ? 'paid' : 'unpaid', isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now, + }); + await db.insert(payments).values({ + id: randomUUID(), ticketId: id, provider, amount: OLD_PRICE, currency: 'PYG', + status: paymentStatus, paidAt: paid ? now : null, createdAt: now, updatedAt: now, + }); +} + +async function amountOf(label: string): Promise { + const { db, dbGet, payments, eq } = dbm; + const row = await dbGet(db.select({ amount: payments.amount }).from(payments).where(eq(payments.ticketId, ticketIds[label]))); + return Number(row.amount); +} + +beforeAll(() => { + if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' }); + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' }); + + return (async () => { + const { Hono } = await import('hono'); + app = new Hono(); + app.route('/api/events', (await import('./events.js')).default); + + dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')), ...(await import('drizzle-orm')) }; + const { db, users, events } = dbm; + const now = dbm.getNow(); + await db.insert(users).values({ + id: SEED_USER_ID, email: 'seed@test.py', name: 'Seed', role: 'user', + isClaimed: dbm.toDbBool(false), accountStatus: 'unclaimed', createdAt: now, updatedAt: now, + }); + for (const id of [EVENT_ID, OTHER_EVENT_ID]) { + await db.insert(events).values({ + id, title: `Event ${id}`, description: 'desc', startDatetime: now, location: 'Asuncion', price: OLD_PRICE, + currency: 'PYG', capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now, + }); + } + + await seedBooking('paid', EVENT_ID, 'paid'); + await seedBooking('refunded', EVENT_ID, 'refunded'); + await seedBooking('pending', EVENT_ID, 'pending'); + await seedBooking('pending-tpago', EVENT_ID, 'pending', 'tpago'); + await seedBooking('claimed', EVENT_ID, 'pending_approval', 'bank_transfer'); + await seedBooking('on-hold', EVENT_ID, 'on_hold'); + await seedBooking('lightning', EVENT_ID, 'pending', 'lightning'); + await seedBooking('other-event', OTHER_EVENT_ID, 'pending'); + })(); +}, 120_000); + +afterAll(() => { + if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' }); +}); + +describe('changing the ticket price', () => { + it('leaves every payment alone when the price is not part of the edit', async () => { + const { status } = await put(`/api/events/${EVENT_ID}`, { location: 'Encarnación' }); + expect(status).toBe(200); + expect(await amountOf('pending')).toBe(OLD_PRICE); + }); + + it('leaves every payment alone when the price is resent unchanged', async () => { + const { status } = await put(`/api/events/${EVENT_ID}`, { price: OLD_PRICE, currency: 'PYG' }); + expect(status).toBe(200); + expect(await amountOf('pending')).toBe(OLD_PRICE); + }); + + it('reprices open payments and keeps what was already paid', async () => { + const { status, body } = await put(`/api/events/${EVENT_ID}`, { price: NEW_PRICE }); + expect(status).toBe(200); + expect(body.event.price).toBe(NEW_PRICE); + + // Not yet paid: owe the current price. + expect(await amountOf('pending')).toBe(NEW_PRICE); + expect(await amountOf('pending-tpago')).toBe(NEW_PRICE); + + // History stays as it happened. + expect(await amountOf('paid')).toBe(OLD_PRICE); + expect(await amountOf('refunded')).toBe(OLD_PRICE); + // The customer already sent the old amount / an admin is reviewing it. + expect(await amountOf('claimed')).toBe(OLD_PRICE); + expect(await amountOf('on-hold')).toBe(OLD_PRICE); + // The Lightning invoice was issued for a fixed amount. + expect(await amountOf('lightning')).toBe(OLD_PRICE); + // Other events are untouched. + expect(await amountOf('other-event')).toBe(OLD_PRICE); + }); +}); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 1c2ba86..0d48d83 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -2,13 +2,18 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js'; -import { eq, desc, and, gte, sql } from 'drizzle-orm'; +import { eq, ne, desc, and, gte, inArray, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; +import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; import { eventSeatBreakdownQuery } from '../lib/capacity.js'; import { resolvePresaleClosure } from '../lib/presale.js'; +import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js'; +import { publicSalesFields } from '../lib/salesState.js'; +import { loadDoorMethods } from '../lib/doorPayments.js'; +import { runOps, updateOp, type TxOp } from '../lib/txOps.js'; interface UserContext { id: string; @@ -24,10 +29,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 @@ -45,6 +54,26 @@ function normalizeEvent(event: any, settings?: any) { 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 { @@ -114,6 +143,29 @@ const parsePrice = (val: unknown): number => { return 0; }; +// Walk-in price: empty/null means "not set" (fall back to price) and must stay +// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input +// fails validation instead of silently becoming 0 the way parsePrice does. +const walkInPriceSchema = z.union([z.number(), z.string(), z.null()]) + .transform((val) => { + if (val === null) return null; + if (typeof val === 'number') return val; + const trimmed = val.trim(); + if (trimmed === '') return null; + return Number(trimmed.replace(',', '.')); + }) + .pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable()) + .optional(); + +// PYG has no minor unit, so a PYG walk-in price must be a whole number. +function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null { + if (walkInPrice == null) return null; + if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) { + return 'walkInPrice: Walk-in price must be a whole number for PYG'; + } + return null; +} + // Helper to normalize boolean (handles true/false and 0/1) const normalizeBoolean = (val: unknown): boolean => { if (typeof val === 'boolean') return val; @@ -137,6 +189,9 @@ const baseEventSchema = z.object({ locationUrl: z.string().url().optional().nullable().or(z.literal('')), // Accept price as number or string (handles "45000" and "41,44" formats) price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0), + walkInPrice: walkInPriceSchema, + // Groups recurring events for the finance overview ("" clears it) + series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(), currency: z.string().default('PYG'), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'), @@ -219,6 +274,7 @@ eventsRouter.get('/', async (c) => { // any client-supplied status filter, so drafts cannot leak. const authUser: any = await getAuthUser(c); const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); + const includeWalkInPrice = canSeeWalkInPrice(authUser?.role); const conditions: any[] = []; @@ -260,7 +316,11 @@ eventsRouter.get('/', async (c) => { // claimedCount = "I've paid" claims awaiting admin verification. Both hold seats, // so availableSeats subtracts them together — the same formula the booking-creation // capacity check enforces (lib/capacity.ts). - const countRows = await dbAll(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, { @@ -270,16 +330,16 @@ eventsRouter.get('/', async (c) => { } const siteSettingsRow = await getSiteSettingsRow(); - const eventsWithCounts = result.map((event: any) => { - const normalized = normalizeEvent(event, siteSettingsRow); - 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 }) @@ -295,24 +355,31 @@ 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 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(), }); }); @@ -360,13 +427,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 @@ -429,13 +491,9 @@ eventsRouter.get('/next/upcoming', async (c) => { // If we have a valid featured event, return it if (featuredEvent) { const counts = await getEventSeatCounts(featuredEvent.id); - const normalized = normalizeEvent(featuredEvent, 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, }, }); @@ -459,6 +517,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,11 +544,11 @@ 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) -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'); @@ -498,6 +559,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); @@ -547,11 +616,31 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateData.slug = newSlug; } - await (db as any) - .update(events) - .set(updateData) - .where(eq((events as any).id, id)); - + const ops: TxOp[] = [updateOp(events, updateData, eq((events as any).id, id))]; + + // Anyone who booked but hasn't paid yet owes the current price, so open + // payments follow a price/currency change. Settled payments keep what was + // actually paid. Excluded: pending_approval (the customer already sent the + // old amount), on_hold (under review) and Lightning (the invoice is fixed). + const newPrice = data.price !== undefined ? data.price : Number(existing.price); + const newCurrency = data.currency ?? existing.currency; + if (newPrice !== Number(existing.price) || newCurrency !== existing.currency) { + ops.push(updateOp( + payments, + { amount: newPrice, currency: newCurrency, updatedAt: now }, + and( + eq((payments as any).status, 'pending'), + ne((payments as any).provider, 'lightning'), + inArray( + (payments as any).ticketId, + (db as any).select({ id: (tickets as any).id }).from(tickets).where(eq((tickets as any).eventId, id)) + ) + ) + )); + } + + await runOps(ops); + const updated = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); @@ -559,7 +648,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', // Revalidate sitemap when an event is updated (status/dates may have changed) revalidateFrontendCache(); - return c.json({ event: normalizeEvent(updated, siteSettingsRow) }); + return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) }); }); // Delete event (admin only) @@ -625,17 +714,17 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { }); // Get event attendees (admin/organizer only) -eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => { +eventsRouter.get('/:id/attendees', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer', 'staff'] }), async (c) => { const id = c.req.param('id'); - const attendees = await dbAll( + const attendees = await dbAll( (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) @@ -670,6 +759,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', @@ -678,17 +768,18 @@ 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, }; await (db as any).insert(events).values(duplicatedEvent); - return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); + return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201); }); // List slug aliases for an event (admin/organizer only) -eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => { +eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => { const id = c.req.param('id'); const existing = await dbGet( @@ -709,7 +800,7 @@ eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async }); // Remove a slug alias from an event (admin/organizer only) -eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => { +eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => { const id = c.req.param('id'); const slug = c.req.param('slug'); 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..6918f2f --- /dev/null +++ b/backend/src/routes/events.walkin.integration.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { execFileSync } from 'child_process'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// The walk-in price is internal: admins set it, door staff charge it, and no +// public event response may carry it. These tests pin both halves. + +// Env must be pinned before the db singleton is imported (dotenv never overrides). +const dir = mkdtempSync(join(tmpdir(), 'events-walkin-test-')); +const dbPath = join(dir, 'test.db'); +process.env.DB_TYPE = 'sqlite'; +process.env.DATABASE_URL = dbPath; +process.env.FRONTEND_URL = 'http://localhost:3002'; +process.env.BETTER_AUTH_SECRET = 'events-test-secret-0123456789abcdef'; +delete process.env.REDIS_URL; +delete process.env.REVALIDATE_SECRET; + +type TestUser = { id: string; name: string; role: string } | null; +const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' }; +const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' }; +const MEMBER = { id: 'member-user-id', name: 'Member', role: 'user' }; + +// Anonymous by default, like the public site and its server-side fetches. +let currentUser: TestUser = null; + +vi.mock('../lib/auth.js', () => ({ + requireAuth: (roles?: string[]) => async (c: any, next: any) => { + if (!currentUser) return c.json({ error: 'Unauthorized' }, 401); + if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403); + c.set('user', currentUser); + await next(); + }, + getAuthUser: async () => currentUser, +})); + +async function as(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); + } + }); +}); + +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 27b4496..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(); @@ -36,6 +37,8 @@ const updatePaymentOptionsSchema = z.object({ cashEnabled: booleanOrNumber.optional(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), + // POS terminal on the door screen + posEnabled: booleanOrNumber.optional(), // Booking settings allowDuplicateBookings: booleanOrNumber.optional(), }); @@ -79,6 +82,7 @@ const updateEventOverridesSchema = z.object({ cashEnabled: booleanOrNumber.optional().nullable(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), + posEnabled: booleanOrNumber.optional().nullable(), }); // Get global payment options @@ -111,6 +115,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + posEnabled: true, allowDuplicateBookings: false, }, }); @@ -219,6 +224,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + posEnabled: true, }; const global = globalOptions || defaults; @@ -245,6 +251,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { cashEnabled: overrides?.cashEnabled ?? global.cashEnabled, cashInstructions: overrides?.cashInstructions ?? global.cashInstructions, cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs, + posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true, }; // Full bank/TPago credentials are only returned when the caller proves they hold @@ -270,7 +277,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { }); // Get event payment overrides (admin only) -paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => { +paymentOptionsRouter.get('/event/:eventId/overrides', requireEventPermission('view_payments', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => { const eventId = c.req.param('eventId'); const overrides = await dbGet( @@ -281,7 +288,7 @@ paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'org }); // Update event payment overrides -paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => { +paymentOptionsRouter.put('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), zValidator('json', updateEventOverridesSchema), async (c) => { const eventId = c.req.param('eventId'); const data = c.req.valid('json'); const now = getNow(); @@ -333,7 +340,7 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org }); // Delete event payment overrides (revert to global) -paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => { +paymentOptionsRouter.delete('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => { const eventId = c.req.param('eventId'); await (db as any) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 9590cfe..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 @@ -1508,6 +1512,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, + bookingSource: 'admin', createdAt: now, }; @@ -1557,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 @@ -1663,6 +1668,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z checkinAt: data.checkinNow ? now : null, checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, adminNote: data.adminNote || null, + bookingSource: 'admin', createdAt: now, }; @@ -1754,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'); @@ -1785,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/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/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/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/(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/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]/_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..0368480 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx @@ -0,0 +1,875 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, financeApi, Ticket, type EventPermission } from '@/lib/api'; +import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; +import { + CalendarIcon, + MapPinIcon, + CurrencyDollarIcon, + UsersIcon, + TicketIcon, + CheckCircleIcon, + EnvelopeIcon, + PencilIcon, + EyeIcon, + UserGroupIcon, + CreditCardIcon, + ChevronDownIcon, + EllipsisVerticalIcon, + ArrowUturnLeftIcon, + BanknotesIcon, + UserPlusIcon, +} from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; +import clsx from 'clsx'; +import { usePrivacy } from '@/context/PrivacyContext'; +import type { + TabType, + AttendeeStatusFilter, + TicketStatusFilter, + RecipientFilter, + AddTicketType, + AddTicketFormState, + PrimaryAction, +} from '../_types'; +import { formatCurrency, downloadBlob } from '../_utils/format'; +import { useEventDetailData } from '../_hooks/useEventDetailData'; +import { usePaymentOverrides } from '../_hooks/usePaymentOverrides'; +import { OverviewTab } from '../_tabs/OverviewTab'; +import { AttendeesTab } from '../_tabs/AttendeesTab'; +import { TicketsTab } from '../_tabs/TicketsTab'; +import { EmailTab } from '../_tabs/EmailTab'; +import { PaymentsTab } from '../_tabs/PaymentsTab'; +import { TeamTab } from '../_tabs/TeamTab'; +import { FinanceTab } from '../_finance/FinanceTab'; +import { EventModals } from '../_modals/EventModals'; +import { AddTicketModal } from '../_modals/AddTicketModal'; +import EventFormModal from '../../_components/EventFormModal'; +import { + EventDetailSkeleton, + EventBackButton, + EventStatsSkeleton, + EventTabBarSkeleton, + EventTabBodySkeleton, +} from './EventDetailSkeleton'; + +/** Which permission each tab needs (see backend lib/eventPermissions.ts). */ +const TAB_PERMISSION: Record = { + 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 comes only from the door summary, which adds up what was actually + // paid. Never derive it from ticket count × event.price: the price can change + // after tickets have been sold. + const presaleRevenue = doorSummary?.presale.total ?? 0; + const doorRevenue = doorSummary?.door.total ?? 0; + const revenue = presaleRevenue + doorRevenue; + // Header money follows the UI language's thousands separator. + const money = (amount: number) => formatCurrency(amount, event.currency, locale); + const revenueLabel = doorSummary ? money(revenue) : '—'; + // "confirmed" tickets become "checked_in" at the door, so this counts the + // guests who have a ticket and have not arrived yet (not all confirmed ones). + const notCheckedInLabel = t('admin.eventStats.notCheckedIn'); + + const allTabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [ + { key: 'overview', label: t('admin.eventTabs.overview'), icon: CalendarIcon }, + { key: 'attendees', label: t('admin.eventTabs.attendees'), icon: UserGroupIcon, count: detailsLoading ? undefined : tickets.length }, + { key: 'tickets', label: t('admin.eventTabs.tickets'), icon: TicketIcon, count: detailsLoading ? undefined : confirmedTickets.length }, + { key: 'email', label: t('admin.eventTabs.email'), icon: EnvelopeIcon }, + { key: 'payments', label: t('admin.eventTabs.payments'), icon: CreditCardIcon }, + { key: 'finance', label: t('admin.eventTabs.finance'), icon: BanknotesIcon }, + { key: 'team', label: t('admin.eventTabs.team'), icon: UserPlusIcon }, + ]; + const tabs = allTabs.filter((tab) => can(TAB_PERMISSION[tab.key])); + // Event-wide money is only shown to viewers who can see payments. + const showRevenue = can('view_payments'); + + // ========== Primary action for a ticket ========== + const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => { + if (ticket.status === 'pending' || ticket.status === 'on_hold') { + return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' }; + } + if (ticket.status === 'confirmed') { + // Unpaid tickets resolve their balance first; check-in stays available via scanner + if (ticket.paymentStatus === 'unpaid') { + return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' }; + } + return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' }; + } + if (ticket.status === 'checked_in') { + return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon }; + } + return null; + }; + + return ( +
+ {/* ============= 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: revenueLabel, + icon: CurrencyDollarIcon, + color: 'bg-gray-50 text-gray-600', + detail: doorSummary + ? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) }) + : undefined, + }]), + ].map((stat) => ( +
+
+ +
+
+

{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: revenueLabel, + icon: CurrencyDollarIcon, + color: 'text-gray-600 bg-gray-50', + detail: doorSummary + ? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) }) + : undefined, + }]), + ].map((stat) => ( +
+
+ +
+
+

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