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 (
+ <>
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ >
+ );
+}
+
+/** 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 (
+ <>
+
+
+ {widths.map((w, i) => )}
+
+
+
+
+ {widths.map((w, i) => )}
+
+
+ >
+ );
+}
+
+/** Toolbar plus list rows, sized for the attendee/ticket lists inside a tab. */
+export function EventTabBodySkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+/** 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
+
+
+ Back to Events
+
+
+
+ );
+ }
+
+ 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 */}
+
+
+
+
+ View Public
+
+
+ {can('edit_event') && (
+
setShowEditForm(true)}>
+
+ Edit
+
+ )}
+
+ {/* 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 && (
+
+
setMobileStatsExpanded(!mobileStatsExpanded)}
+ className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
+ >
+
+ {t('admin.eventStats.stats')}
+ {confirmedCount + checkedInCount}/{event.capacity}
+ |
+ {t('admin.eventStats.inShort', { count: checkedInCount })}
+ {showRevenue && (
+ <>
+ |
+ {revenueLabel}
+ >
+ )}
+
+
+
+ {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 */}
+
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.key)}
+ className={clsx(
+ 'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
+ activeTab === tab.key
+ ? 'text-primary-dark'
+ : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
+ )}
+ >
+
+ {tab.label}
+ {tab.count !== undefined && ({tab.count}) }
+ {activeTab === tab.key && (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Mobile: segmented tab bar */}
+
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.key)}
+ className={clsx(
+ 'px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap min-h-[44px] flex items-center gap-1',
+ activeTab === tab.key
+ ? 'bg-white shadow-sm text-primary-dark'
+ : 'text-gray-500 hover:text-gray-700'
+ )}
+ >
+
+ {tab.label}
+ {tab.count !== undefined && ({tab.count}) }
+
+ ))}
+
+
+
+ >)}
+
+ {/* ============= 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)} />
+ )}
+
+
+ set('calcType', e.target.value as CalcType)}>
+ {CALC_TYPES.map((c) => {t(`admin.finance.calcTypes.${c}`)} )}
+
+
+ {ct === 'minimum_spend' && (
+
+ set('unitAmount', n)} />
+
+ )}
+
+
+ {/* Live result: what this line costs with today's numbers */}
+
+
{previewLine}
+ {isAuto(ct) && (
+
+
+ { set('isLocked', e.target.checked); if (e.target.checked) set('computedAmount', preview.amount); }} className="w-4 h-4 mt-0.5 accent-primary-yellow" />
+
+ {t('admin.finance.expenses.lock')}
+ {t('admin.finance.expenses.lockHint')}
+
+
+ {form.isLocked && (
+
+ set('computedAmount', n)} />
+
+ )}
+
+ )}
+
+
+
+
+ { setCategoryAuto(false); set('categoryId', e.target.value); }}>
+ {t('admin.finance.charts.uncategorized')}
+ {categories.map((c) => {locale === 'es' ? c.nameEs : c.nameEn} )}
+
+
+
+
+ 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('paidByPartnerId', e.target.value)}>
+ {t('admin.finance.expenses.organization')}
+ {data.partners.map((p) => {p.name} )}
+
+
+
+ >
+ )}
+
+
+
+ {t('common.cancel')}
+ {t('common.save')}
+
+
+ );
+}
+
+// ==================== 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 />
+
+
+
+
+
+ {t('common.cancel')}
+ {t('common.save')}
+
+
+ );
+}
+
+// ==================== 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(' · ')}
+
+ apply({ packId: p.id }, p.id)} className="min-h-[40px]">
+ {t('admin.finance.expenses.addAll', { count: p.templateIds.length })}
+
+
+ ))}
+
+
+ )}
+ {templates.length > 0 && (
+
+ {t('admin.finance.expenses.templatesHeading')}
+
+ {templates.map((tpl) => (
+
+
+
{tpl.name}
+
{summary(tpl)}
+
+ apply({ templateId: tpl.id }, tpl.id)} className="min-h-[40px]">
+ {t('admin.finance.expenses.addOne')}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
+
+// ==================== 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 (
+ patch(e, isPaid ? { status: 'planned' } : { status: 'paid', expenseDate: e.expenseDate || todayIso() })}
+ title={editable ? t(isPaid ? 'admin.finance.expenses.markPlanned' : 'admin.finance.expenses.markPaid') : undefined}
+ className={clsx(
+ 'inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 min-h-[32px] text-xs font-medium whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
+ isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800',
+ editable ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-300' : 'cursor-default',
+ )}
+ >
+ {isPaid ? : }
+ {t(`admin.finance.expenseStatus.${e.status}`)}
+
+ );
+ };
+
+ const IncomeTag = () => (
+ {t('admin.finance.ledger.income')}
+ );
+
+ const LockButton = ({ e }: { e: EventExpense }) => isAuto(e.calcType) ? (
+ patch(e, { isLocked: !e.isLocked })}
+ title={t(e.isLocked ? 'admin.finance.expenses.unlockHint' : 'admin.finance.expenses.lockHint')}
+ className={clsx(iconButtonClass, e.isLocked ? 'text-primary-dark' : 'text-gray-400')}
+ aria-label={t('admin.finance.expenses.lock')}
+ aria-pressed={e.isLocked}
+ >
+ {e.isLocked ? : }
+
+ ) : null;
+
+ const Meta = ({ e }: { e: EventExpense }) => (
+
+ );
+
+ 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 = (
+ <>
+ setTemplatesOpen(true)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
+ {t('admin.finance.expenses.useTemplate')}
+
+ {canEditIncome && (
+ openIncome(null)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
+ {t('admin.finance.otherIncome.add')}
+
+ )}
+
+ {t('admin.finance.expenses.add')}
+
+ >
+ );
+
+ 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 && (
+
+
+ {t('admin.finance.expenses.category')}
+ setCategory(e.target.value)}
+ >
+ {t('admin.finance.ledger.allRows')}
+ {usedCategories.map((c) => {locale === 'es' ? c.nameEs : c.nameEn} )}
+ {data.expenses.some((e) => !e.categoryId) && {t('admin.finance.charts.uncategorized')} }
+
+
+ )}
+ {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 */}
+
+
+
+
+
+
+
+
+
+
+
+ {t('admin.finance.expenses.description')}
+ {t('admin.finance.expenses.status')}
+ {t('admin.finance.expenses.paidBy')}
+ {t('admin.finance.expenses.amount')}
+ {t('common.edit')}
+
+
+
+ {rows.map((row) => row.kind === 'expense' ? (
+
+
+ {canEditRow(row.e) ? (
+ openEdit(row.e)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
+ {row.e.description}
+
+ ) : {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) && (
+ <>
+
openEdit(row.e)} className={iconButtonClass} aria-label={t('common.edit')}>
+
remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}>
+ >
+ )}
+
+
+
+ ) : (
+
+
+ {canEditIncome ? (
+ openIncome(row.i)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
+ {row.i.description}
+
+ ) : {row.i.description}
}
+
+
+
+ {incomeAmount(row.i)}
+
+ {canEditIncome && (
+
+
openIncome(row.i)} className={iconButtonClass} aria-label={t('common.edit')}>
+
removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}>
+
+ )}
+
+
+ ))}
+
+
+
+
+ {t('admin.finance.ledger.costs')}
+ {category && · {t('admin.finance.expenses.filtered')} }
+
+
+
+
+ {!category && incomeTotal > 0 && (
+
+ {t('admin.finance.otherIncome.title')}
+
+
+
+ )}
+
+
+
+
+ {/* Mobile cards: tap the card to edit, status toggles in place */}
+
+ {rows.map((row) => row.kind === 'expense' ? (
+
+
+
canEditRow(row.e) && openEdit(row.e)}
+ disabled={!canEditRow(row.e)}
+ className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
+ >
+ {row.e.description}
+
+
+
+
+ {!privacyMode && formula(row.e, t, m) &&
{formula(row.e, t, m)}
}
+
+
+
+
+ {row.e.status === 'paid' &&
{partnerName(row.e.paidByPartnerId)} }
+
+
+ {canEditRow(row.e) && (
+ remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}>
+ )}
+
+
+
+ ) : (
+
+
+
canEditIncome && openIncome(row.i)}
+ disabled={!canEditIncome}
+ className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
+ >
+ {row.i.description}
+
+
+
+ {incomeAmount(row.i)}
+ {canEditIncome && (
+ removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}>
+ )}
+
+
+
+ ))}
+
+ {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')}
+
+
+ {t('common.cancel')}
+
+ {t('admin.finance.finalizeDialog.confirm')}
+
+
+
+ );
+}
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 (
+
+ {i > 0 && (
+
+ )}
+
+ {done ? : i + 1}
+
+
+ {t(`admin.finance.lifecycle.steps.${step}`)}
+
+
+ );
+ })}
+
+
+ {(nudge || lifecycle.todo.length > 0 || !open) && (
+
+ {nudge && (
+
+
+ {ready ? : }
+ {nudge}
+
+ {data.viewer.canEditExpenses && !ready && (
+
goTo('expenses')} className="self-start sm:self-auto text-sm font-medium underline whitespace-nowrap cursor-pointer min-h-[32px]">
+ {t('admin.finance.lifecycle.reviewCosts')}
+
+ )}
+
+ )}
+ {!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: null, userLabel: '' }))} className={iconButtonClass} aria-label={t('admin.team.remove')}>
+
+
+
+ ) : (
+
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)} />
+
+
+ )}
+
+ )}
+
+ {t('common.cancel')}
+ {t('common.save')}
+
+
+ );
+}
+
+// ==================== 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')} />
+
+
+
+
+
+
+
+ {t('common.cancel')}
+ {t('admin.finance.split.markPaid')}
+
+
+ );
+}
+
+// ==================== Audit log ====================
+
+function AuditLog({ eventId }: { eventId: string }) {
+ const { t, locale } = useLanguage();
+ const [entries, setEntries] = useState(null);
+ const [open, setOpen] = useState(false);
+ useEffect(() => {
+ if (open && entries === null) financeApi.auditLog(eventId).then((r) => setEntries(r.entries)).catch(() => setEntries([]));
+ }, [open, entries, eventId]);
+ const describe = (e: FinanceAuditEntry) => {
+ const name = e.after?.description || e.before?.description || e.after?.name || e.before?.name || '';
+ const action = t(`admin.finance.audit.${e.action}`);
+ const entity = t(`admin.finance.audit.entity.${e.entityType}`);
+ return `${entity} · ${action.startsWith('admin.') ? e.action.replace(/_/g, ' ') : action}${name ? ` · ${name}` : ''}`;
+ };
+ return (
+
+
setOpen(!open)}
+ aria-expanded={open}
+ className="w-full flex items-center gap-2 px-4 min-h-[48px] text-sm font-semibold cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded-card"
+ >
+
+ {t('admin.finance.split.auditLog')}
+
+
+ {open && (
+
+ {entries === null ?
{t('common.loading')}
: entries.length === 0 ? (
+
{t('admin.finance.split.auditEmpty')}
+ ) : (
+
+ {entries.map((e) => (
+
+
+ {e.createdAt ? new Date(e.createdAt).toLocaleString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'short', timeStyle: 'short' }) : ''}
+
+ {e.actorName || '-'} {describe(e)}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+// ==================== View ====================
+
+export function SplitView({ eventId, data, lifecycle, onChange }: { eventId: string; data: EventFinance; lifecycle: Lifecycle; onChange: () => void }) {
+ const { t, locale } = useLanguage();
+ const m = useMoney();
+ const { privacyMode } = usePrivacy();
+ const [editing, setEditing] = useState(null);
+ const [formOpen, setFormOpen] = useState(false);
+ const [paying, setPaying] = useState(null);
+ const [finalizing, setFinalizing] = useState(false);
+ const [busy, setBusy] = useState(null);
+
+ const { viewer, summary } = data;
+ const open = data.status === 'open';
+ const manage = viewer.canManageSplit;
+ const lines = new Map(summary.split.partners.map((p) => [p.partnerId, p]));
+ const hasPartners = data.partners.length > 0;
+ const paidCount = data.partners.filter((p) => p.payoutStatus === 'paid').length;
+
+ const run = async (key: string, fn: () => Promise, success?: string) => {
+ setBusy(key);
+ try {
+ await fn();
+ if (success) toast.success(success);
+ onChange();
+ return true;
+ } catch (error: any) {
+ toast.error(error.message);
+ return false;
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const finalize = async () => {
+ if (await run('finalize', () => financeApi.finalize(eventId), t('admin.finance.split.finalized'))) setFinalizing(false);
+ };
+ const unfinalize = () => {
+ if (!confirm(t('admin.finance.split.unfinalizeConfirm'))) return;
+ run('unfinalize', () => financeApi.unfinalize(eventId), t('admin.finance.split.unfinalized'));
+ };
+ const remove = (p: EventPartner) => {
+ if (!confirm(t('admin.finance.split.confirmRemoveNamed', { name: p.name }))) return;
+ run(`rm-${p.id}`, () => financeApi.deletePartner(eventId, p.id), t('admin.finance.split.partnerRemoved'));
+ };
+ const statement = async (p: EventPartner) => {
+ setBusy(`pdf-${p.id}`);
+ try {
+ const { blob, filename } = await financeApi.downloadStatement(eventId, p.id, locale);
+ downloadBlob(blob, filename);
+ } catch (error: any) {
+ toast.error(error.message);
+ } finally {
+ setBusy(null);
+ }
+ };
+ const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
+ const openNew = () => { setEditing(null); setFormOpen(true); };
+
+ const loss = summary.profit < 0;
+ const distributable = summary.split.distributable;
+ // Percent-of-profit deals apply to what is left after fixed and revenue deals; say so when that differs from the profit.
+ const profitBase = distributable !== null && distributable !== summary.profit ? distributable : null;
+ const resultTone = (v: number | null) => (!open && !privacyMode && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
+
+ const BreakdownRow = ({ label, detail, value, sign, strong, className }: {
+ label: React.ReactNode; detail?: string; value: number | null; sign?: '+' | '−'; strong?: boolean; className?: string;
+ }) => (
+
+
+ {sign && {sign} }
+ {label}
+ {detail && {detail} }
+
+
+
+ );
+
+ return (
+
+
+ {/* Result, as a short calculation ending in what the organization keeps */}
+
+
+
{t('admin.finance.split.calculation')}
+ {open &&
{t('admin.finance.kpi.estimated')} }
+
+
+
+ {summary.split.partners.map((line) => {
+ const p = data.partners.find((x) => x.id === line.partnerId);
+ const rule = p ? dealText(p, t, m) : '';
+ const detail = p?.shareType === 'percent_profit' && profitBase !== null && !privacyMode
+ ? t('admin.finance.split.ofDistributable', { rule, base: m.pyg(profitBase) })
+ : rule;
+ return (
+
+ );
+ })}
+ {viewer.fullSplit && (
+ <>
+
+
+ {!hasPartners &&
{t('admin.finance.split.empty')}
}
+ >
+ )}
+
+ {!viewer.fullSplit &&
{t('admin.finance.split.ownShareOnly')}
}
+
+
+ {/* Close the books: the one place to finalize */}
+ {(manage || !open) && (
+
+
+
+ {open ? : }
+ {t(open ? 'admin.finance.split.closeBooks' : 'admin.finance.split.booksClosed')}
+
+
+ {open
+ ? t('admin.finance.split.closeBooksHint')
+ : hasPartners
+ ? t('admin.finance.split.payoutsProgress', { paid: paidCount, total: data.partners.length })
+ : t('admin.finance.split.frozenNoPartners')}
+
+
+
+ {open && manage && (
+ setFinalizing(true)} className="w-full min-h-[44px]">
+ {t('admin.finance.split.closeBooks')}
+
+ )}
+ {!open && viewer.canUnfinalize && (
+
+ {t('admin.finance.split.unfinalize')}
+
+ )}
+
+
+ )}
+
+
+ {/* Partners */}
+ {!hasPartners ? (
+ viewer.fullSplit && (
+
} title={t('admin.finance.split.noPartnersTitle')} body={t('admin.finance.split.noPartnersBody')}>
+ {manage && open &&
{t('admin.finance.split.addPartner')}}
+
+ )
+ ) : (
+
+
+
{t('admin.finance.split.partners')}
+ {manage && open && (
+
+ {t('admin.finance.split.addPartner')}
+
+ )}
+
+
+ {data.partners.map((p) => {
+ const line = lines.get(p.id);
+ const payout = line?.payout ?? 0;
+ const owes = payout < 0;
+ const isPaid = p.payoutStatus === 'paid';
+ const reimbursement = line?.reimbursement ?? 0;
+ return (
+
+ {/* Who and the deal */}
+
+
{p.name}
+
{[p.roleLabel, dealText(p, t, m)].filter(Boolean).join(' · ')}
+ {p.shareType === 'percent_profit' && loss && (
+
{t(`admin.finance.lossRules.${p.lossRule}`)}{p.lossRule === 'capped' ? ` (${m.pyg(p.lossCapAmount)})` : ''}
+ )}
+
+ {/* Amount */}
+
+
+
+ {owes ? t('admin.finance.split.owes') : reimbursement !== 0 && !privacyMode
+ ? t('admin.finance.split.includesReimbursement', { amount: m.pyg(reimbursement) })
+ : t('admin.finance.split.payout')}
+
+
+ {/* Status */}
+
+
+ {isPaid ? : }
+ {isPaid ? t('admin.finance.split.paidOn', { date: fmtDate(p.payoutDate) }) : t('admin.finance.split.unpaid')}
+
+ {isPaid && (p.payoutMethod || p.payoutNote) && (
+ {[p.payoutMethod, p.payoutNote].filter(Boolean).join(' · ')}
+ )}
+
+ {/* Actions */}
+
+ {manage && !open && !isPaid && (
+
setPaying(p)} className="min-h-[44px] md:min-h-[36px]">
+ {t('admin.finance.split.markPaid')}
+
+ )}
+ {manage && !open && isPaid && (
+
run(`unpay-${p.id}`, () => financeApi.markPartnerPaid(eventId, p.id, { paid: false }))}>
+ {t('admin.finance.split.markUnpaid')}
+
+ )}
+
statement(p)} disabled={busy === `pdf-${p.id}`} className={iconButtonClass} aria-label={`${t('admin.finance.split.statement')} ${p.name}`} title={t('admin.finance.split.statement')}>
+
+
+ {manage && open && (
+ <>
+
{ setEditing(p); setFormOpen(true); }} className={iconButtonClass} aria-label={`${t('common.edit')} ${p.name}`}>
+
remove(p)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={`${t('common.delete')} ${p.name}`}>
+ >
+ )}
+
+
+ );
+ })}
+
+ {manage && open &&
{t('admin.finance.split.finalizeFirst')}
}
+
+ )}
+
+ {viewer.fullSplit &&
}
+
+
setFormOpen(false)} eventId={eventId} partner={editing} onSaved={onChange} />
+ setPaying(null)} eventId={eventId} onSaved={onChange} />
+ setFinalizing(false)} onConfirm={finalize} busy={busy === 'finalize'} data={data} lifecycle={lifecycle} />
+
+ );
+}
diff --git a/frontend/src/app/admin/events/[id]/_finance/SummaryView.tsx b/frontend/src/app/admin/events/[id]/_finance/SummaryView.tsx
new file mode 100644
index 0000000..33af400
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/SummaryView.tsx
@@ -0,0 +1,237 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import clsx from 'clsx';
+import {
+ BanknotesIcon, ShoppingBagIcon, ArrowTrendingUpIcon, ArrowTrendingDownIcon, ScaleIcon, EyeSlashIcon,
+ ExclamationTriangleIcon, CheckCircleIcon, ChevronDownIcon, PlusIcon,
+} from '@heroicons/react/24/outline';
+import { useLanguage } from '@/context/LanguageContext';
+import { usePrivacy } from '@/context/PrivacyContext';
+import { useMoney } from '@/lib/useMoney';
+import type { EventFinance } from '@/lib/api';
+import Button from '@/components/ui/Button';
+import { Skeleton } from '@/components/ui/Skeleton';
+import { Money, EmptyState, Pill } from './ui';
+import { breakEvenState, detailChartsAvailable } from './derive';
+import type { FinanceSection } from './FinanceTab';
+
+// Recharts only loads when someone opens the Finance tab.
+const FinanceCharts = dynamic(() => import('./FinanceCharts'), {
+ ssr: false,
+ loading: () => ,
+});
+
+function Kpi({ label, tag, value, detail, icon: Icon, tone, children }: {
+ label: string; tag?: React.ReactNode; value: React.ReactNode; detail?: React.ReactNode; icon: typeof BanknotesIcon;
+ tone: 'blue' | 'orange' | 'green' | 'red' | 'purple' | 'gray'; children?: React.ReactNode;
+}) {
+ const tones = {
+ blue: 'bg-blue-50 text-blue-600', orange: 'bg-orange-50 text-orange-600', green: 'bg-green-50 text-green-600',
+ red: 'bg-red-50 text-red-600', purple: 'bg-purple-50 text-purple-600', gray: 'bg-gray-100 text-gray-600',
+ };
+ return (
+
+
+
{value}
+ {detail &&
{detail}
}
+ {children}
+
+ );
+}
+
+/** The P&L as a compact statement under the waterfall, in the same order as its bars. */
+function Breakdown({ data, finalized }: { data: EventFinance; finalized: boolean }) {
+ const { t, locale } = useLanguage();
+ const { num } = useMoney();
+ const s = data.summary;
+ const catName = (id: string | null) => {
+ const c = data.categories.find((x) => x.id === id);
+ return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
+ };
+ const resultTone = (v: number | null) => (finalized && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
+ const Row = ({ label, value, sign, strong, muted, className }: {
+ label: React.ReactNode; value: number | null; sign?: '+' | '−'; strong?: boolean; muted?: boolean; className?: string;
+ }) => (
+
+ {sign && {sign} }{label}
+
+
+ );
+ const partners = s.split.partners;
+ const showSplit = partners.length > 0 && s.split.organization !== null;
+
+ return (
+
+
{t('admin.finance.statement.title')}
+
|
+ {s.revenue.refunds > 0 &&
|
}
+ {s.revenue.fees > 0 &&
|
}
+ {s.expenses.total > 0 && (
+
+
+
+ −
+ {t('admin.finance.waterfall.expenses')}
+
+
+
+
+
+ {s.expenses.byCategory.map((c) => (
+
+ {catName(c.categoryId)}
+
+
+ ))}
+
+
+ )}
+ {s.revenue.otherIncome > 0 &&
|
}
+
+
|
+
+ {showSplit && (
+ <>
+ {partners.map((p) => (
+
{p.name}{p.share < 0 && {t('admin.finance.statement.carriesLoss')} }>}
+ value={Math.abs(p.share)}
+ muted
+ />
+ ))}
+
+
+ >
+ )}
+
+ );
+}
+
+export function SummaryView({ data, goTo }: { data: EventFinance; goTo: (s: FinanceSection) => void }) {
+ const { t, locale } = useLanguage();
+ const { privacyMode } = usePrivacy();
+ const { num } = useMoney();
+ const s = data.summary;
+ const finalized = data.status !== 'open';
+ const loss = s.profit < 0;
+ const capacity = data.event.capacity;
+ const canAdd = data.status === 'open' && (data.viewer.canEditExpenses || data.viewer.canEditOwnExpenses);
+
+ const nothingYet = s.revenue.gross === 0 && s.expenses.total === 0 && s.revenue.otherIncome === 0;
+ if (nothingYet) {
+ return (
+ } title={t('admin.finance.empty.title')} body={t('admin.finance.empty.body')}>
+ {canAdd && goTo('expenses')}> {t('admin.finance.expenses.add')} }
+
+ );
+ }
+
+ const sold = s.counts.ticketsSold;
+ const be = breakEvenState(s);
+ let breakEvenValue: React.ReactNode;
+ let breakEvenDetail: React.ReactNode;
+ let breakEvenBar: number | null = null;
+ if (be.kind === 'noCosts') {
+ breakEvenValue = {t('admin.finance.breakEven.noCosts')} ;
+ breakEvenDetail = t('admin.finance.breakEven.noCostsHint');
+ } else if (be.kind === 'unreachable') {
+ breakEvenValue = {t('admin.finance.kpi.breakEvenNever')} ;
+ } else if (privacyMode) {
+ breakEvenValue = '••••';
+ } else if (be.kind === 'reached') {
+ breakEvenValue = (
+
+
+ {be.at === 0
+ ? t('admin.finance.breakEven.coveredWithoutTickets')
+ : t('admin.finance.breakEven.reached', { at: num(be.at), sold: num(be.sold) })}
+
+ );
+ } else {
+ breakEvenValue = {t('admin.finance.breakEven.needed', { count: num(be.remaining) })} ;
+ breakEvenBar = be.pct;
+ breakEvenDetail = be.needed > capacity
+ ? {t('admin.finance.kpi.beyondCapacity', { capacity: num(capacity) })}
+ : t('admin.finance.kpi.breakEvenProgress', { sold: num(be.sold), total: num(be.needed) });
+ }
+
+ const show = detailChartsAvailable(s);
+
+ return (
+
+
+
}
+ detail={privacyMode ? undefined : (
+ <>
+
{t('admin.finance.kpi.revenueDetail', { count: num(sold) })}
+ {s.revenue.gross > 0 && (
+
{t('admin.finance.kpi.presale')} · {t('admin.finance.kpi.door')}
+ )}
+ >
+ )}
+ icon={BanknotesIcon}
+ tone="blue"
+ />
+
}
+ detail={privacyMode || s.expenses.total === 0 ? undefined : (
+
{t('admin.finance.charts.paid')} · {t('admin.finance.charts.planned')}
+ )}
+ icon={ShoppingBagIcon}
+ tone="orange"
+ />
+
{t('admin.finance.kpi.estimated')}}
+ value={ }
+ detail={t(finalized ? 'admin.finance.kpi.afterFees' : 'admin.finance.kpi.estimatedHint')}
+ icon={loss ? ArrowTrendingDownIcon : ArrowTrendingUpIcon}
+ tone={finalized ? (loss ? 'red' : 'green') : 'gray'}
+ />
+
+ {breakEvenBar !== null && (
+
+ )}
+
+
+
+ {privacyMode ? (
+
+
+ {t('admin.finance.hiddenStats')}
+
+ ) : (
+ <>
+
+
+ {!show.costs && (
+
+ {canAdd ? (
+ goTo('expenses')} className="hover:underline cursor-pointer">{t('admin.finance.charts.addExpensesHint')}
+ ) : t('admin.finance.charts.addExpensesHint')}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/app/admin/events/[id]/_finance/UserSearch.tsx b/frontend/src/app/admin/events/[id]/_finance/UserSearch.tsx
new file mode 100644
index 0000000..fe31357
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/UserSearch.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { inputClass } from './ui';
+
+export interface UserHit { id: string; name: string; email: string; role: string }
+
+/** Debounced search box with a result list; `search` decides who is eligible. */
+export function UserSearch({ search, onPick, placeholder, noResults }: {
+ search: (q: string) => Promise<{ users: UserHit[] }>;
+ onPick: (u: UserHit) => void;
+ placeholder: string;
+ noResults: string;
+}) {
+ const [q, setQ] = useState('');
+ const [hits, setHits] = useState([]);
+ const [searched, setSearched] = useState(false);
+ const seq = useRef(0);
+
+ useEffect(() => {
+ const term = q.trim();
+ if (term.length < 2) { setHits([]); setSearched(false); return; }
+ const mine = ++seq.current;
+ const timer = setTimeout(() => {
+ search(term)
+ .then((res) => { if (mine === seq.current) { setHits(res.users); setSearched(true); } })
+ .catch(() => { if (mine === seq.current) { setHits([]); setSearched(true); } });
+ }, 250);
+ return () => clearTimeout(timer);
+ }, [q, search]);
+
+ return (
+
+
+
+ setQ(e.target.value)} placeholder={placeholder} />
+
+ {hits.length > 0 && (
+
+ {hits.map((u) => (
+
+ { onPick(u); setQ(''); setHits([]); setSearched(false); }}
+ className="w-full text-left px-3 py-2 hover:bg-gray-50 min-h-[44px]"
+ >
+ {u.name}
+ {u.email} · {u.role}
+
+
+ ))}
+
+ )}
+ {searched && hits.length === 0 &&
{noResults}
}
+
+ );
+}
diff --git a/frontend/src/app/admin/events/[id]/_finance/calc.ts b/frontend/src/app/admin/events/[id]/_finance/calc.ts
new file mode 100644
index 0000000..7bc7b1f
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/calc.ts
@@ -0,0 +1,20 @@
+// Client-side preview of an expense amount, mirroring
+// backend/src/lib/finance/calculate.ts expenseAmount(). Only used to show
+// "this will be X PYG right now" while typing; the server stays authoritative.
+
+import type { CalcType } from '@/lib/api';
+
+export interface PreviewContext { ticketsSold: number; checkedIn: number; sales: number }
+
+export function previewExpense(
+ e: { calcType: CalcType; quantity: number; unitAmount: number; percentBp: number; minimumAmount: number },
+ ctx: PreviewContext,
+): { amount: number; count: number } {
+ switch (e.calcType) {
+ case 'per_ticket_sold': return { amount: e.unitAmount * ctx.ticketsSold, count: ctx.ticketsSold };
+ case 'per_checked_in': return { amount: e.unitAmount * ctx.checkedIn, count: ctx.checkedIn };
+ case 'percent_of_revenue': return { amount: Math.round((Math.max(0, ctx.sales) * e.percentBp) / 10000), count: 0 };
+ case 'minimum_spend': return { amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn), count: ctx.checkedIn };
+ default: return { amount: e.unitAmount * e.quantity, count: e.quantity };
+ }
+}
diff --git a/frontend/src/app/admin/events/[id]/_finance/derive.test.ts b/frontend/src/app/admin/events/[id]/_finance/derive.test.ts
new file mode 100644
index 0000000..dc0cb09
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/derive.test.ts
@@ -0,0 +1,246 @@
+import { describe, it, expect } from 'vitest';
+import {
+ calculateEventFinance, expenseAmount, CALC_TYPES,
+ type FinanceExpense, type FinanceInput, type FinancePartner, type FinancePayment,
+} from '../../../../../../../backend/src/lib/finance/calculate';
+import type { EventExpense, EventFinance, EventPartner, FinanceSummary } from '@/lib/api';
+import { breakEvenState, buildWaterfall, deriveLifecycle, detailChartsAvailable, suggestCategory, tCount } from './derive';
+import { previewExpense } from './calc';
+
+// The frontend derivations run on the backend's real calculation, so these
+// tests also check the UI reads the same numbers the server computes.
+
+let seq = 0;
+const pay = (amount: number, over: Partial = {}): FinancePayment => ({
+ 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 summaryOf = (i: FinanceInput) => calculateEventFinance(i) as unknown as FinanceSummary;
+
+// ---- Scenarios (same as the backend's pinned ones)
+
+const noExpenses = summaryOf(input({
+ ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
+ payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
+ fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
+}));
+const loss = summaryOf(input({
+ ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
+ payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
+ fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
+ expenses: [
+ expense({ unitAmount: 300000, status: 'paid' }),
+ expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
+ ],
+}));
+const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
+const withPartners = summaryOf(input({
+ ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
+ payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
+ otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
+ expenses: [
+ expense({ unitAmount: 200000, status: 'paid' }),
+ expense({ unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
+ ],
+ partners: [ana, partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 })],
+}));
+
+describe('buildWaterfall', () => {
+ const sumTo = (steps: ReturnType) =>
+ steps.slice(1, -1).reduce((s, w) => s + w.amount, steps[0].amount);
+
+ it('no expenses: gross, fees, profit; nothing else shown', () => {
+ const steps = buildWaterfall(noExpenses);
+ expect(steps.map((s) => [s.key, s.amount])).toEqual([['gross', 200000], ['fees', -5800], ['profit', 194200]]);
+ expect(sumTo(steps)).toBe(steps[steps.length - 1].amount);
+ });
+
+ it('a loss: ends at the (negative) profit', () => {
+ const steps = buildWaterfall(loss);
+ expect(steps.map((s) => s.key)).toEqual(['gross', 'fees', 'expenses', 'profit']);
+ expect(steps[steps.length - 1].amount).toBe(-144350);
+ expect(sumTo(steps)).toBe(-144350);
+ });
+
+ it('partners: other income in, partner payouts out, ends at what the organization keeps', () => {
+ const steps = buildWaterfall(withPartners);
+ expect(steps.map((s) => [s.key, s.kind, s.amount])).toEqual([
+ ['gross', 'total', 1000000],
+ ['expenses', 'out', -350000],
+ ['otherIncome', 'in', 50000],
+ ['partners', 'out', -280000],
+ ['organization', 'total', 420000],
+ ]);
+ expect(sumTo(steps)).toBe(420000);
+ });
+
+ it('viewers without the full split end at the profit', () => {
+ const scoped = { ...withPartners, split: { ...withPartners.split, organization: null, distributable: null } };
+ const steps = buildWaterfall(scoped);
+ expect(steps[steps.length - 1]).toEqual({ key: 'profit', kind: 'total', amount: 700000 });
+ expect(steps.some((s) => s.key === 'partners')).toBe(false);
+ });
+});
+
+describe('breakEvenState', () => {
+ it('no costs: neutral, no progress', () => {
+ expect(breakEvenState(noExpenses)).toEqual({ kind: 'noCosts' });
+ });
+ it('not covered: tickets still needed and progress sold / needed', () => {
+ expect(breakEvenState(loss)).toEqual({ kind: 'needed', remaining: 4, sold: 4, needed: 8, pct: 50 });
+ });
+ it('covered: reached at N of M sold', () => {
+ expect(breakEvenState(withPartners)).toEqual({ kind: 'reached', at: 3, sold: 10 });
+ });
+ it('unreachable at this price', () => {
+ const s = summaryOf(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1000 })] }));
+ expect(breakEvenState(s)).toEqual({ kind: 'unreachable' });
+ });
+});
+
+describe('detailChartsAvailable', () => {
+ it('hides cost charts without expenses and the timeline with fewer than 2 days', () => {
+ expect(detailChartsAvailable(noExpenses)).toEqual({ costs: false, methods: true, timeline: false });
+ });
+ it('shows the timeline once there are two sales days', () => {
+ const s = summaryOf(input({ payments: [pay(1, { paidAt: '2026-09-01T10:00:00Z' }), pay(1, { paidAt: '2026-09-02T10:00:00Z' })] }));
+ expect(detailChartsAvailable(s).timeline).toBe(true);
+ });
+});
+
+// ---- Lifecycle
+
+const NOW = Date.parse('2026-09-28T12:00:00Z');
+const exp = (status: 'planned' | 'paid') => ({ status } as EventExpense);
+const prt = (payoutStatus: 'pending' | 'paid') => ({ payoutStatus } as EventPartner);
+const fin = (over: Partial> & { start?: string; end?: string | null } = {}) => ({
+ status: over.status ?? 'open',
+ expenses: over.expenses ?? [],
+ partners: over.partners ?? [],
+ event: { startDatetime: over.start ?? '2026-10-10T20:00:00Z', endDatetime: over.end ?? null },
+} as Parameters[0]);
+
+describe('deriveLifecycle', () => {
+ it('upcoming with no costs: selling', () => {
+ const l = deriveLifecycle(fin(), NOW);
+ expect(l.step).toBe('selling');
+ expect(l.ended).toBe(false);
+ expect(l.todo.map((t) => t.key)).toEqual(['noCosts', 'noPartners', 'notEnded']);
+ });
+
+ it('upcoming with costs: adding costs', () => {
+ expect(deriveLifecycle(fin({ expenses: [exp('paid')] }), NOW).step).toBe('costs');
+ });
+
+ it('ended with planned costs: adding costs, warns about them, counts days since the end', () => {
+ const l = deriveLifecycle(fin({ start: '2026-09-20T20:00:00Z', end: '2026-09-20T23:00:00Z', expenses: [exp('paid'), exp('planned'), exp('planned')] }), NOW);
+ expect(l).toMatchObject({ step: 'costs', ended: true, daysSinceEnd: 7, plannedCount: 2 });
+ expect(l.todo).toContainEqual({ key: 'planned', count: 2, tone: 'warn' });
+ });
+
+ it('ended with no costs: still adding costs, and that is a warning', () => {
+ const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z' }), NOW);
+ expect(l.step).toBe('costs');
+ expect(l.todo).toContainEqual({ key: 'noCosts', tone: 'warn' });
+ });
+
+ it('ended with every cost paid: ready to close', () => {
+ const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z', expenses: [exp('paid')], partners: [prt('pending')] }), NOW);
+ expect(l.step).toBe('ready');
+ expect(l.todo).toEqual([]);
+ });
+
+ it('uses the end time, not the start, to decide the event is over', () => {
+ const l = deriveLifecycle(fin({ start: '2026-09-28T10:00:00Z', end: '2026-09-28T14:00:00Z' }), NOW);
+ expect(l.ended).toBe(false);
+ });
+
+ it('finalized: lists partner payouts still to record', () => {
+ const l = deriveLifecycle(fin({ status: 'finalized', start: '2026-09-01T20:00:00Z', expenses: [exp('planned')], partners: [prt('paid'), prt('pending')] }), NOW);
+ expect(l.step).toBe('finalized');
+ expect(l.todo).toEqual([{ key: 'payoutsPending', count: 1, tone: 'info' }]);
+ });
+
+ it('does not nag collaborators about partners they cannot manage', () => {
+ const l = deriveLifecycle({ ...fin(), viewer: { fullSplit: false } } as any, NOW);
+ expect(l.todo.map((t) => t.key)).not.toContain('noPartners');
+ });
+});
+
+// ---- Category suggestion
+
+const categories = [
+ { id: 'venue', nameEn: 'Venue', nameEs: 'Lugar', archived: false },
+ { id: 'host', nameEn: 'Instructor / host', nameEs: 'Instructor / anfitrión', archived: false },
+ { id: 'food', nameEn: 'Food & drinks', nameEs: 'Comida y bebidas', archived: false },
+ { id: 'staff', nameEn: 'Staff', nameEs: 'Personal', archived: false },
+ { id: 'mkt', nameEn: 'Marketing', nameEs: 'Marketing', archived: false },
+ { id: 'sup', nameEn: 'Supplies', nameEs: 'Materiales', archived: false },
+];
+
+describe('suggestCategory', () => {
+ it.each([
+ ['Studio rental', 'venue'],
+ ['Alquiler del salón', 'venue'],
+ ['Teacher fee', 'host'],
+ ['Pago a la profe', 'host'],
+ ['Snacks and drinks', 'food'],
+ ['Cerveza', 'food'],
+ ['Security', 'staff'],
+ ['Instagram ads', 'mkt'],
+ ['Name badges', 'sup'],
+ ])('%s -> %s', (description, id) => {
+ expect(suggestCategory(description, categories)).toBe(id);
+ });
+
+ it('returns null when nothing matches', () => {
+ expect(suggestCategory('Miscellaneous', categories)).toBeNull();
+ expect(suggestCategory('', categories)).toBeNull();
+ });
+
+ it('skips archived categories', () => {
+ expect(suggestCategory('venue', [{ ...categories[0], archived: true }])).toBeNull();
+ });
+
+ it('matches whole words only', () => {
+ // "ad" is a marketing keyword, but "headphones" should not match it.
+ expect(suggestCategory('headphones', categories)).toBeNull();
+ });
+});
+
+describe('tCount', () => {
+ const t = (key: string, p?: Record) => `${key}:${p?.count}`;
+ it('picks _one for 1 and _other otherwise', () => {
+ expect(tCount(t, 'k', 1)).toBe('k_one:1');
+ expect(tCount(t, 'k', 0)).toBe('k_other:0');
+ expect(tCount(t, 'k', 3)).toBe('k_other:3');
+ });
+});
+
+// ---- Modal preview mirrors the server
+
+describe('previewExpense matches the backend expenseAmount', () => {
+ const ctx = { ticketsSold: 4, checkedIn: 3, sales: 200000 };
+ it.each(CALC_TYPES)('%s', (calcType) => {
+ const e = { calcType, quantity: 1, unitAmount: 50000, percentBp: 1250, minimumAmount: 300000 };
+ const server = expenseAmount({ ...e, computedAmount: 0, isLocked: false }, ctx);
+ expect(previewExpense(e, ctx).amount).toBe(server.amount);
+ });
+
+ it('minimum spend switches to per-guest once it passes the minimum', () => {
+ const e = { calcType: 'minimum_spend' as const, quantity: 1, unitAmount: 50000, percentBp: 0, minimumAmount: 300000 };
+ const big = { ticketsSold: 10, checkedIn: 8, sales: 0 };
+ expect(previewExpense(e, big).amount).toBe(expenseAmount({ ...e, computedAmount: 0, isLocked: false }, big).amount);
+ expect(previewExpense(e, big).amount).toBe(400000);
+ });
+});
diff --git a/frontend/src/app/admin/events/[id]/_finance/derive.ts b/frontend/src/app/admin/events/[id]/_finance/derive.ts
new file mode 100644
index 0000000..9d043eb
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/derive.ts
@@ -0,0 +1,189 @@
+// Pure derivations for the Finance tab: where the event is in its money
+// lifecycle, what is left to do, how break-even reads, and a category guess
+// for a new expense. No React here so it can be unit tested.
+
+import type { EventFinance, ExpenseCategory, FinanceSummary } from '@/lib/api';
+
+export type LifecycleStep = 'selling' | 'costs' | 'ready' | 'finalized';
+export const LIFECYCLE_STEPS: LifecycleStep[] = ['selling', 'costs', 'ready', 'finalized'];
+
+export interface TodoItem {
+ /** i18n key under admin.finance.lifecycle.todo (pluralized with _one / _other when count is set). */
+ key: 'noCosts' | 'planned' | 'noPartners' | 'payoutsPending' | 'notEnded';
+ count?: number;
+ tone: 'warn' | 'info';
+}
+
+export interface Lifecycle {
+ step: LifecycleStep;
+ ended: boolean;
+ /** Whole days since the event ended; null while it has not ended. */
+ daysSinceEnd: number | null;
+ expenseCount: number;
+ plannedCount: number;
+ partnerCount: number;
+ todo: TodoItem[];
+}
+
+type LifecycleInput = Pick & {
+ event: { startDatetime: string; endDatetime?: string | null };
+ viewer?: { fullSplit: boolean };
+};
+
+const DAY = 86_400_000;
+
+/** The moment an event counts as over: its end time, or its start when no end is set. */
+export function eventEndTime(event: { startDatetime: string; endDatetime?: string | null }): number {
+ return new Date(event.endDatetime || event.startDatetime).getTime();
+}
+
+export function deriveLifecycle(data: LifecycleInput, now: number = Date.now()): Lifecycle {
+ const end = eventEndTime(data.event);
+ const ended = Number.isFinite(end) && now >= end;
+ const daysSinceEnd = ended ? Math.floor((now - end) / DAY) : null;
+ const expenseCount = data.expenses.length;
+ const plannedCount = data.expenses.filter((e) => e.status === 'planned').length;
+ const partnerCount = data.partners.length;
+ const fullSplit = data.viewer?.fullSplit ?? true;
+
+ const todo: TodoItem[] = [];
+ let step: LifecycleStep;
+ if (data.status !== 'open') {
+ step = 'finalized';
+ const unpaid = data.partners.filter((p) => p.payoutStatus !== 'paid').length;
+ if (unpaid > 0) todo.push({ key: 'payoutsPending', count: unpaid, tone: 'info' });
+ } else {
+ if (!ended) step = expenseCount === 0 ? 'selling' : 'costs';
+ else step = expenseCount > 0 && plannedCount === 0 ? 'ready' : 'costs';
+
+ if (expenseCount === 0) todo.push({ key: 'noCosts', tone: ended ? 'warn' : 'info' });
+ if (plannedCount > 0) todo.push({ key: 'planned', count: plannedCount, tone: 'warn' });
+ if (partnerCount === 0 && fullSplit) todo.push({ key: 'noPartners', tone: 'info' });
+ if (!ended) todo.push({ key: 'notEnded', tone: 'info' });
+ }
+
+ return { step, ended, daysSinceEnd, expenseCount, plannedCount, partnerCount, todo };
+}
+
+// ==================== Break-even ====================
+
+export type BreakEvenState =
+ | { kind: 'noCosts' }
+ | { kind: 'unreachable' }
+ | { kind: 'reached'; at: number; sold: number }
+ | { kind: 'needed'; remaining: number; sold: number; needed: number; pct: number };
+
+export function breakEvenState(summary: Pick): BreakEvenState {
+ const be = summary.breakEven;
+ const sold = summary.counts.ticketsSold;
+ if (summary.expenses.total === 0) return { kind: 'noCosts' };
+ if (be.tickets === null) return { kind: 'unreachable' };
+ if (sold >= be.tickets) return { kind: 'reached', at: be.tickets, sold };
+ const needed = be.tickets;
+ return {
+ kind: 'needed',
+ remaining: be.remaining ?? needed - sold,
+ sold,
+ needed,
+ pct: needed > 0 ? Math.min(100, Math.round((sold / needed) * 100)) : 100,
+ };
+}
+
+// ==================== Category suggestion ====================
+
+/** Words in an expense description, and the words that identify the matching category by name. */
+const CATEGORY_HINTS: { words: string[]; category: string[] }[] = [
+ {
+ words: ['venue', 'rent', 'rental', 'room', 'hall', 'space', 'studio', 'alquiler', 'local', 'sala', 'lugar', 'espacio', 'salon', 'estudio'],
+ category: ['venue', 'lugar'],
+ },
+ {
+ words: ['instructor', 'teacher', 'host', 'tutor', 'coach', 'facilitator', 'profesor', 'profesora', 'profe', 'anfitrion', 'anfitriona', 'moderador', 'moderadora'],
+ category: ['instructor', 'host', 'anfitrion'],
+ },
+ {
+ words: ['snack', 'snacks', 'drink', 'drinks', 'food', 'beer', 'coffee', 'water', 'pizza', 'catering', 'wine', 'soda',
+ 'comida', 'bebida', 'bebidas', 'cerveza', 'cafe', 'agua', 'merienda', 'bocaditos', 'vino', 'gaseosa'],
+ category: ['food', 'drink', 'comida', 'bebida'],
+ },
+ {
+ words: ['staff', 'helper', 'volunteer', 'security', 'cleaning', 'waiter', 'personal', 'limpieza', 'seguridad', 'ayudante', 'mozo', 'voluntario'],
+ category: ['staff', 'personal'],
+ },
+ {
+ words: ['ads', 'ad', 'marketing', 'instagram', 'facebook', 'flyer', 'flyers', 'promo', 'poster', 'publicidad', 'anuncio', 'anuncios', 'volante', 'volantes'],
+ category: ['marketing'],
+ },
+ {
+ words: ['supplies', 'material', 'materials', 'print', 'printing', 'paper', 'markers', 'badges', 'stickers',
+ 'materiales', 'impresion', 'impresiones', 'papel', 'marcadores', 'etiquetas'],
+ category: ['suppl', 'material'],
+ },
+];
+
+const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
+
+/** Best-guess category id for a description, or null when nothing matches. */
+export function suggestCategory(description: string, categories: Pick[]): string | null {
+ const tokens = new Set(normalize(description).split(/[^a-z0-9]+/).filter(Boolean));
+ if (tokens.size === 0) return null;
+ for (const hint of CATEGORY_HINTS) {
+ if (!hint.words.some((w) => tokens.has(w))) continue;
+ const match = categories.find((c) => !c.archived && hint.category.some((k) => normalize(`${c.nameEn} ${c.nameEs}`).includes(k)));
+ if (match) return match.id;
+ }
+ return null;
+}
+
+// ==================== Copy helpers ====================
+
+type T = (key: string, params?: Record) => string;
+
+/** Picks `${key}_one` or `${key}_other` so counts read naturally in both languages. */
+export function tCount(t: T, key: string, count: number, params: Record = {}): string {
+ return t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...params });
+}
+
+// ==================== Waterfall ====================
+
+export type WaterfallKind = 'total' | 'in' | 'out';
+export interface WaterfallStep {
+ key: 'gross' | 'refunds' | 'fees' | 'expenses' | 'otherIncome' | 'partners' | 'profit' | 'organization';
+ kind: WaterfallKind;
+ /** Signed: positive adds to the running total, negative takes from it. Totals are the level itself. */
+ amount: number;
+}
+
+/**
+ * Gross revenue -> fees -> expenses -> other income -> partner payouts ->
+ * what the organization keeps. Zero steps are left out; the steps always add
+ * up to the final bar. Viewers without the full split end at the profit.
+ */
+export function buildWaterfall(summary: Pick): WaterfallStep[] {
+ const r = summary.revenue;
+ const steps: WaterfallStep[] = [{ key: 'gross', kind: 'total', amount: r.gross }];
+ const add = (key: WaterfallStep['key'], amount: number) => {
+ if (amount !== 0) steps.push({ key, kind: amount > 0 ? 'in' : 'out', amount });
+ };
+ add('refunds', -r.refunds);
+ add('fees', -r.fees);
+ add('expenses', -summary.expenses.total);
+ add('otherIncome', r.otherIncome);
+ const withSplit = summary.split.organization !== null && summary.split.partners.length > 0;
+ if (withSplit) {
+ add('partners', -summary.split.partners.reduce((sum, p) => sum + p.share, 0));
+ steps.push({ key: 'organization', kind: 'total', amount: summary.split.organization! });
+ } else {
+ steps.push({ key: 'profit', kind: 'total', amount: summary.profit });
+ }
+ return steps;
+}
+
+/** Which detail charts have something to show (cumulative sales needs at least two days). */
+export function detailChartsAvailable(summary: Pick) {
+ return {
+ costs: summary.expenses.byCategory.some((c) => c.total > 0),
+ methods: summary.revenue.byMethod.some((m) => m.gross > 0),
+ timeline: summary.salesTimeline.length >= 2,
+ };
+}
diff --git a/frontend/src/app/admin/events/[id]/_finance/format.ts b/frontend/src/app/admin/events/[id]/_finance/format.ts
new file mode 100644
index 0000000..7a53b85
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/format.ts
@@ -0,0 +1,20 @@
+// Input parsing for finance forms. Display formatting lives in @/lib/money.
+
+export { formatNumber, formatPyg, formatPygShort, formatPercent } from '@/lib/money';
+
+/** Basis points to a percent string for inputs (290 -> "2.9"). */
+export function bpToPercent(bp: number): string {
+ return String(Math.round(bp) / 100);
+}
+
+/** Percent input ("2,9" or "2.9") to basis points. */
+export function percentToBp(value: string): number {
+ const n = parseFloat(value.replace(',', '.'));
+ return Number.isFinite(n) ? Math.max(0, Math.min(10000, Math.round(n * 100))) : 0;
+}
+
+/** Whole-PYG input ("1.500.000", "1,500,000" or "1500000") to an integer. */
+export function parsePygInput(value: string): number {
+ const digits = value.replace(/[^\d]/g, '');
+ return digits ? Math.min(2_000_000_000, parseInt(digits, 10)) : 0;
+}
diff --git a/frontend/src/app/admin/events/[id]/_finance/ui.tsx b/frontend/src/app/admin/events/[id]/_finance/ui.tsx
new file mode 100644
index 0000000..17b9f33
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_finance/ui.tsx
@@ -0,0 +1,275 @@
+'use client';
+
+// Small shared pieces for the Finance / Team tabs, following the event page's
+// existing modal (bottom sheet on mobile, centered card on desktop), compact
+// input and badge styles.
+
+import { ReactNode, useEffect, useRef, useState } from 'react';
+import clsx from 'clsx';
+import { XMarkIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
+import Card from '@/components/ui/Card';
+import { usePrivacy } from '@/context/PrivacyContext';
+import { useLanguage } from '@/context/LanguageContext';
+import { PRIVACY_MASK } from '@/components/admin/SensitiveValue';
+import type { FinanceStatus } from '@/lib/api';
+import { formatNumber } from '@/lib/money';
+import { useMoney } from '@/lib/useMoney';
+import { parsePygInput } from './format';
+
+export const inputClass =
+ 'w-full px-3 py-2.5 min-h-[44px] text-base md:text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:bg-gray-50 disabled:text-gray-400';
+
+/** Icon-only buttons: 44px touch target on mobile, visible keyboard focus. */
+export const iconButtonClass =
+ 'inline-flex items-center justify-center min-h-[44px] min-w-[44px] md:min-h-[36px] md:min-w-[36px] rounded-btn cursor-pointer hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow disabled:opacity-40 disabled:cursor-default';
+
+export function Modal({ open, title, subtitle, onClose, children, wide }: {
+ open: boolean; title: string; subtitle?: string; onClose: () => void; children: ReactNode; wide?: boolean;
+}) {
+ const { t } = useLanguage();
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [open, onClose]);
+ if (!open) return null;
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+export function Field({ label, hint, required, error, children }: { label: string; hint?: string; required?: boolean; error?: string; children: ReactNode }) {
+ return (
+
+
+ {label}{required && * }
+
+ {children}
+ {error ?
{error}
: hint &&
{hint}
}
+
+ );
+}
+
+/** Radio group rendered as selectable cards: each option explains itself. */
+export function ChoiceCards({ options, value, onChange, columns = 2, name }: {
+ options: { key: K; title: string; description?: string }[];
+ value: K;
+ onChange: (k: K) => void;
+ columns?: 2 | 3;
+ name: string;
+}) {
+ return (
+
+ {options.map((o) => {
+ const active = o.key === value;
+ return (
+ onChange(o.key)}
+ className={clsx(
+ 'text-left rounded-btn border px-3 py-2.5 min-h-[44px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
+ active ? 'border-primary-yellow bg-yellow-50' : 'border-secondary-light-gray hover:border-gray-300 bg-white',
+ )}
+ >
+
+
+ {o.title}
+
+ {o.description && {o.description} }
+
+ );
+ })}
+
+ );
+}
+
+/** Two or three mutually exclusive options as one control (e.g. Planned / Paid). */
+export function Segmented({ options, value, onChange, label }: {
+ options: { key: K; label: string; icon?: ReactNode }[];
+ value: K;
+ onChange: (k: K) => void;
+ label: string;
+}) {
+ return (
+
+ {options.map((o) => (
+ onChange(o.key)}
+ className={clsx(
+ 'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium min-h-[40px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
+ o.key === value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
+ )}
+ >
+ {o.icon}{o.label}
+
+ ))}
+
+ );
+}
+
+/** Explains why a list is empty and offers the next step. */
+export function EmptyState({ icon, title, body, children }: { icon?: ReactNode; title: string; body?: string; children?: ReactNode }) {
+ return (
+
+ {icon &&
{icon}
}
+
{title}
+ {body &&
{body}
}
+ {children &&
{children}
}
+
+ );
+}
+
+/** Whole-PYG input that shows the UI language's thousands separator while typing. */
+export function PygInput({ value, onChange, disabled, id }: { value: number; onChange: (n: number) => void; disabled?: boolean; id?: string }) {
+ const { locale } = useLanguage();
+ const [text, setText] = useState(value ? formatNumber(value, locale) : '');
+ useEffect(() => { setText(value ? formatNumber(value, locale) : ''); }, [value, locale]);
+ return (
+
+ {
+ const n = parsePygInput(e.target.value);
+ setText(n ? formatNumber(n, locale) : '');
+ onChange(n);
+ }}
+ />
+ PYG
+
+ );
+}
+
+/** Today as YYYY-MM-DD in the viewer's time zone. */
+export function todayIso(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+
+/**
+ * Date field that reads in the active UI language. A native
+ * formats itself with the browser's locale (e.g. "dd-mm-jjjj" on a Dutch
+ * system), so the visible text is ours and the native input only supplies the
+ * picker, laid transparently on top.
+ */
+export function DateInput({ value, onChange, clearable, id, className }: {
+ value: string; onChange: (v: string) => void; clearable?: boolean; id?: string; className?: string;
+}) {
+ const { t, locale } = useLanguage();
+ const ref = useRef(null);
+ const label = value
+ ? new Date(`${value}T12:00:00`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
+ : '';
+ return (
+
+
+
+ {label || t('admin.finance.pickDate')}
+
+
onChange(e.target.value)}
+ onClick={(e) => { try { e.currentTarget.showPicker?.(); } catch { /* picker opens natively */ } }}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ />
+ {clearable && value && (
+
onChange('')}
+ className="absolute right-1 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-btn text-gray-400 hover:text-gray-700 hover:bg-gray-100 cursor-pointer"
+ aria-label={t('admin.finance.clearDate')}
+ >
+
+
+ )}
+
+ );
+}
+
+/** A PYG amount that respects Hide Stats. */
+export function Money({ value, className, signed }: { value: number | null | undefined; className?: string; signed?: boolean }) {
+ const { privacyMode } = usePrivacy();
+ const { pyg } = useMoney();
+ if (value === null || value === undefined) return - ;
+ const text = signed && value > 0 ? `+${pyg(value)}` : pyg(value);
+ return {privacyMode ? PRIVACY_MASK : text} ;
+}
+
+const STATUS_STYLES: Record = {
+ open: 'bg-gray-100 text-gray-700',
+ finalized: 'bg-blue-100 text-blue-800',
+ paid_out: 'bg-green-100 text-green-800',
+};
+
+export function FinanceStatusBadge({ status, label }: { status: FinanceStatus; label: string }) {
+ return {label} ;
+}
+
+export function Pill({ children, tone = 'gray' }: { children: ReactNode; tone?: 'gray' | 'green' | 'yellow' | 'blue' | 'red' }) {
+ const tones = {
+ gray: 'bg-gray-100 text-gray-700',
+ green: 'bg-green-100 text-green-800',
+ yellow: 'bg-yellow-100 text-yellow-800',
+ blue: 'bg-blue-100 text-blue-800',
+ red: 'bg-red-100 text-red-800',
+ };
+ return {children} ;
+}
+
+/** Segmented sub-navigation matching the event page's mobile tab pills. */
+export function SubNav({ items, active, onChange }: { items: { key: K; label: string; count?: number }[]; active: K; onChange: (k: K) => void }) {
+ return (
+
+ {items.map((it) => (
+ onChange(it.key)}
+ className={clsx(
+ // Phones: long labels wrap to two lines instead of running into the next tab.
+ 'px-2 md:px-3 py-1.5 md:py-2 rounded-md text-sm font-medium leading-tight md:whitespace-nowrap min-h-[40px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
+ active === it.key ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
+ )}
+ >
+ {it.label}
+ {it.count !== undefined && it.count > 0 && {it.count} }
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts b/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts
index 58c8be0..a854003 100644
--- a/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts
+++ b/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts
@@ -1,43 +1,81 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
-import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api';
+import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary, type EventPermission } from '@/lib/api';
+
+/** A 403 means "not for this viewer": render without that data instead of failing the page. */
+const forbidden = (error: any) => error?.status === 403;
/**
- * Loads the core data for the admin event detail page (event, tickets, active
- * email templates, door takings) and exposes a reload function used after
- * mutations.
+ * Loads the core data for the event detail page and exposes a reload function
+ * used after mutations. The event itself is fetched straight away so the
+ * header can render while the rest loads; tickets, active email templates and
+ * door takings wait for the viewer's permissions on the event and only request
+ * what they may see, so team members get a working page with fewer tabs.
+ *
+ * `eventLoading` and `detailsLoading` cover the first load only: reloads keep
+ * the current data on screen until the new data replaces it.
*/
-export function useEventDetailData(eventId: string) {
- const [loading, setLoading] = useState(true);
+export function useEventDetailData(eventId: string, permissions: Set | null) {
+ const [eventLoading, setEventLoading] = useState(true);
+ const [detailsLoading, setDetailsLoading] = useState(true);
const [event, setEvent] = useState(null);
const [tickets, setTickets] = useState([]);
const [templates, setTemplates] = useState([]);
const [doorSummary, setDoorSummary] = useState(null);
- const loadEventData = async () => {
+ const loadEvent = async () => {
try {
- const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([
- eventsApi.getById(eventId),
- ticketsApi.getAll({ eventId }),
- emailsApi.getTemplates(),
+ const { event } = await eventsApi.getById(eventId);
+ setEvent(event);
+ } catch (error) {
+ toast.error('Failed to load event data');
+ } finally {
+ setEventLoading(false);
+ }
+ };
+
+ const loadDetails = async () => {
+ if (!permissions) return;
+ const can = (p: EventPermission) => permissions.has(p);
+ try {
+ const [ticketsRes, templatesRes, doorRes] = await Promise.all([
+ can('view_attendees_names')
+ ? ticketsApi.getAll({ eventId }).catch((error) => {
+ if (forbidden(error)) return { tickets: [] as Ticket[] };
+ throw error;
+ })
+ : { tickets: [] as Ticket[] },
+ can('email_attendees')
+ ? emailsApi.getTemplates(eventId).catch((error) => {
+ if (forbidden(error)) return { templates: [] as EmailTemplate[] };
+ throw error;
+ })
+ : { templates: [] as EmailTemplate[] },
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
// night. It is supporting detail, so a failure here must not blank the page.
- doorApi.summary(eventId).catch(() => null),
+ can('view_payments') ? doorApi.summary(eventId).catch(() => null) : null,
]);
- setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) {
toast.error('Failed to load event data');
} finally {
- setLoading(false);
+ setDetailsLoading(false);
}
};
+ const loadEventData = async () => {
+ await Promise.all([loadEvent(), loadDetails()]);
+ };
+
useEffect(() => {
- loadEventData();
+ loadEvent();
}, [eventId]);
- return { loading, event, tickets, templates, doorSummary, loadEventData };
+ useEffect(() => {
+ loadDetails();
+ }, [eventId, permissions]);
+
+ return { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData };
}
diff --git a/frontend/src/app/admin/events/[id]/_tabs/OverviewTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/OverviewTab.tsx
index a5c366c..c51f2c4 100644
--- a/frontend/src/app/admin/events/[id]/_tabs/OverviewTab.tsx
+++ b/frontend/src/app/admin/events/[id]/_tabs/OverviewTab.tsx
@@ -1,6 +1,7 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
+import { Skeleton } from '@/components/ui/Skeleton';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
@@ -10,9 +11,11 @@ interface OverviewTabProps {
formatCurrency: (amount: number, currency: string) => string;
confirmedCount: number;
checkedInCount: number;
+ /** Tickets are still loading, so the seat counts are not known yet. */
+ countsLoading?: boolean;
}
-export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
+export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount, countsLoading = false }: OverviewTabProps) {
return (
@@ -49,8 +52,17 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
Capacity
-
{confirmedCount + checkedInCount} / {event.capacity} spots filled
-
{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining
+ {countsLoading ? (
+
+
+
+
+ ) : (
+ <>
+
{confirmedCount + checkedInCount} / {event.capacity} spots filled
+
{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining
+ >
+ )}
diff --git a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx
index 75056dd..043e701 100644
--- a/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx
+++ b/frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx
@@ -25,6 +25,7 @@ const DOOR_METHOD_LABELS: Record
cash: { en: 'Cash', es: 'Efectivo' },
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
transfer: { en: 'Transfer', es: 'Transferencia' },
+ pos: { en: 'POS', es: 'POS' },
guest: { en: 'Guests', es: 'Invitados' },
};
@@ -50,7 +51,7 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
- {formatCurrency(summary.door.total, summary.currency)}
+ {formatCurrency(summary.door.total, summary.currency, locale)}
@@ -64,7 +65,7 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
{method === 'guest'
? `${entry.count}`
- : formatCurrency(entry.total, summary.currency)}
+ : formatCurrency(entry.total, summary.currency, locale)}
{method !== 'guest' && (
@@ -78,11 +79,11 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
- {es ? 'Preventa' : 'Pre-sale'}: {formatCurrency(summary.presale.total, summary.currency)}
+ {es ? 'Preventa' : 'Pre-sale'}: {formatCurrency(summary.presale.total, summary.currency, locale)}
{' '}({summary.presale.count})
- {es ? 'Total' : 'Total'}: {formatCurrency(summary.total, summary.currency)}
+ {es ? 'Total' : 'Total'}: {formatCurrency(summary.total, summary.currency, locale)}
@@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
handleSavePaymentOptions,
} = payments;
+ // Stored as 0/1 on Postgres; POS is on unless explicitly switched off.
+ const posEnabled = !!(getEffectivePaymentOption('posEnabled') ?? true);
+ const posDisabledGlobally = !!globalPaymentOptions && !(globalPaymentOptions.posEnabled ?? true);
+
return (
{loadingPayments ? (
@@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
+ {/* POS terminal at the door */}
+
+
+
+
+
+
+
+
+
POS
+
+ {locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'}
+
+
+
+
+ {posDisabledGlobally && (
+
+ {locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
+
+ )}
+ updatePaymentOverride('posEnabled', !posEnabled)}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+ posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
+ }`}
+ >
+
+
+
+
+
+
+
{/* Summary */}
@@ -453,6 +494,7 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
+ { label: 'POS', enabled: posEnabled },
].map((method) => (
{method.enabled ? (
diff --git a/frontend/src/app/admin/events/[id]/_tabs/TeamTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/TeamTab.tsx
new file mode 100644
index 0000000..cd0c867
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/_tabs/TeamTab.tsx
@@ -0,0 +1,259 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import toast from 'react-hot-toast';
+import clsx from 'clsx';
+import { TrashIcon, ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
+import { useLanguage } from '@/context/LanguageContext';
+import {
+ financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS,
+ type EventMember, type EventPermission, type RolePreset,
+} from '@/lib/api';
+import Button from '@/components/ui/Button';
+import { TableSkeleton } from '@/components/ui/Skeleton';
+import { Modal, Field, inputClass } from '../_finance/ui';
+import { UserSearch, type UserHit } from '../_finance/UserSearch';
+
+/** Overrides after flipping one permission: drop the key when it matches the preset again. */
+function toggleOverride(member: EventMember, key: EventPermission): Partial
> {
+ const next = { ...member.permissions };
+ const want = !member.effective.includes(key);
+ const presetHas = PRESET_PERMISSIONS[member.rolePreset].includes(key);
+ if (want === presetHas) delete next[key]; else next[key] = want;
+ return next;
+}
+
+function Toggle({ on, overridden, disabled, onClick, label }: { on: boolean; overridden: boolean; disabled?: boolean; onClick: () => void; label: string }) {
+ return (
+
+
+
+ );
+}
+
+export function TeamTab({ eventId }: { eventId: string }) {
+ const { t } = useLanguage();
+ const [members, setMembers] = useState(null);
+ const [adding, setAdding] = useState(false);
+ const [picked, setPicked] = useState(null);
+ const [preset, setPreset] = useState('collaborator');
+ const [busy, setBusy] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ setMembers((await financeApi.getMembers(eventId)).members);
+ } catch (error: any) {
+ toast.error(error.message);
+ setMembers([]);
+ }
+ }, [eventId]);
+ useEffect(() => { load(); }, [load]);
+
+ const search = useCallback((q: string) => financeApi.searchMemberCandidates(eventId, q), [eventId]);
+
+ const update = async (m: EventMember, body: { rolePreset?: RolePreset; permissions?: Partial> }) => {
+ setBusy(m.id);
+ try {
+ const { member } = await financeApi.updateMember(eventId, m.id, body);
+ setMembers((list) => list?.map((x) => (x.id === m.id ? member : x)) ?? null);
+ toast.success(t('admin.team.saved'));
+ } catch (error: any) {
+ toast.error(error.message);
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const remove = async (m: EventMember) => {
+ if (!confirm(t('admin.team.confirmRemove', { name: m.name || m.email || '' }))) return;
+ setBusy(m.id);
+ try {
+ await financeApi.removeMember(eventId, m.id);
+ setMembers((list) => list?.filter((x) => x.id !== m.id) ?? null);
+ toast.success(t('admin.team.removed'));
+ } catch (error: any) {
+ toast.error(error.message);
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const add = async () => {
+ if (!picked) return;
+ setBusy('add');
+ try {
+ await financeApi.addMember(eventId, { userId: picked.id, rolePreset: preset });
+ toast.success(t('admin.team.added'));
+ setAdding(false);
+ setPicked(null);
+ load();
+ } catch (error: any) {
+ toast.error(error.message);
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ if (members === null) return ;
+
+ const PresetSelect = ({ m }: { m: EventMember }) => (
+ update(m, { rolePreset: e.target.value as RolePreset, permissions: m.permissions })}
+ >
+ {ROLE_PRESETS.map((p) => {t(`admin.rolePresets.${p}`)} )}
+
+ );
+ const hasOverrides = (m: EventMember) => Object.keys(m.permissions).length > 0;
+
+ return (
+
+
+
{t('admin.team.hint')}
+
{ setPicked(null); setPreset('collaborator'); setAdding(true); }} className="self-start md:self-auto">
+ {t('admin.team.add')}
+
+
+
+ {members.length === 0 ? (
+
{t('admin.team.empty')}
+ ) : (
+ <>
+ {/* Desktop: permission matrix */}
+
+
+
+
+ {t('admin.finance.split.linkedUser')}
+ {t('admin.team.role')}
+ {EVENT_PERMISSIONS.map((p) => (
+
+ {t(`admin.permissions.${p}`)}
+
+ ))}
+
+
+
+
+ {members.map((m) => (
+
+
+ {m.name}
+ {m.email}
+
+
+ {EVENT_PERMISSIONS.map((p) => (
+
+ update(m, { permissions: toggleOverride(m, p) })}
+ />
+
+ ))}
+
+ {hasOverrides(m) && (
+ update(m, { permissions: {} })} className="p-1.5 rounded-btn hover:bg-gray-100 text-gray-500" title={t('admin.team.reset')} aria-label={t('admin.team.reset')}>
+
+
+ )}
+ remove(m)} disabled={busy === m.id} className="p-1.5 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
+
+
+
+
+ ))}
+
+
+
+ {t('admin.team.override')}
+
+
+
+ {/* Mobile / tablet: one card per member */}
+
+ {members.map((m) => (
+
+
+
+
remove(m)} disabled={busy === m.id} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
+
+
+
+
+
+ {hasOverrides(m) && (
+
update(m, { permissions: {} })} className="text-xs text-gray-500 underline">{t('admin.team.reset')}
+ )}
+
+
+
+ {t('admin.team.permissions')}
+
+ {t('admin.team.permissionsSummary', { on: m.effective.length, total: EVENT_PERMISSIONS.length })}
+
+
+
+
+ {EVENT_PERMISSIONS.map((p) => (
+
+ {t(`admin.permissions.${p}`)}
+ update(m, { permissions: toggleOverride(m, p) })}
+ />
+
+ ))}
+
+
+
+ ))}
+
+ >
+ )}
+
+
setAdding(false)} title={t('admin.team.add')}>
+ {picked ? (
+
+ {picked.name} ({picked.email})
+ setPicked(null)} className="text-xs underline text-gray-500">{t('common.edit')}
+
+ ) : (
+
+ )}
+
+ setPreset(e.target.value as RolePreset)}>
+ {ROLE_PRESETS.map((p) => {t(`admin.rolePresets.${p}`)} )}
+
+
+ {PRESET_PERMISSIONS[preset].map((p) => t(`admin.permissions.${p}`)).join(' · ')}
+
+ setAdding(false)} className="flex-1 min-h-[44px]">{t('common.cancel')}
+ {t('admin.team.add')}
+
+
+
+ );
+}
diff --git a/frontend/src/app/admin/events/[id]/_types.ts b/frontend/src/app/admin/events/[id]/_types.ts
index 3c60110..726a785 100644
--- a/frontend/src/app/admin/events/[id]/_types.ts
+++ b/frontend/src/app/admin/events/[id]/_types.ts
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react';
-export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
+export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments' | 'finance' | 'team';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
diff --git a/frontend/src/app/admin/events/[id]/_utils/format.ts b/frontend/src/app/admin/events/[id]/_utils/format.ts
index 09f355e..c5a58ea 100644
--- a/frontend/src/app/admin/events/[id]/_utils/format.ts
+++ b/frontend/src/app/admin/events/[id]/_utils/format.ts
@@ -1,9 +1,10 @@
// Pure formatting/IO helpers for the admin event detail page.
-export function formatCurrency(amount: number, currency: string): string {
- if (currency === 'PYG') {
- return `${amount.toLocaleString('es-PY')} PYG`;
- }
+import { formatPyg } from '@/lib/money';
+
+/** PYG goes through the shared money helper; `locale` is the UI language (emails keep the default). */
+export function formatCurrency(amount: number, currency: string, locale: string = 'es'): string {
+ if (currency === 'PYG') return formatPyg(amount, locale);
return `$${amount.toFixed(2)} ${currency}`;
}
diff --git a/frontend/src/app/admin/events/[id]/loading.tsx b/frontend/src/app/admin/events/[id]/loading.tsx
new file mode 100644
index 0000000..2cc1afc
--- /dev/null
+++ b/frontend/src/app/admin/events/[id]/loading.tsx
@@ -0,0 +1,7 @@
+import { EventDetailSkeleton } from './_components/EventDetailSkeleton';
+
+// Route-level shell shown the moment an admin opens an event, while the page
+// chunk loads. Mirrors the page's own first-load state.
+export default function AdminEventDetailLoading() {
+ return ;
+}
diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx
index 60554db..33bde0f 100644
--- a/frontend/src/app/admin/events/[id]/page.tsx
+++ b/frontend/src/app/admin/events/[id]/page.tsx
@@ -1,790 +1,9 @@
'use client';
-import { useState, useEffect, useRef } from 'react';
import { useParams } from 'next/navigation';
-import Link from 'next/link';
-import { useLanguage } from '@/context/LanguageContext';
-import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
-import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
-import Card from '@/components/ui/Card';
-import Button from '@/components/ui/Button';
-import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
-import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
-import {
- ArrowLeftIcon,
- CalendarIcon,
- MapPinIcon,
- CurrencyDollarIcon,
- UsersIcon,
- TicketIcon,
- CheckCircleIcon,
- EnvelopeIcon,
- PencilIcon,
- EyeIcon,
- UserGroupIcon,
- CreditCardIcon,
- ChevronDownIcon,
- EllipsisVerticalIcon,
- ArrowUturnLeftIcon,
-} from '@heroicons/react/24/outline';
-import toast from 'react-hot-toast';
-import clsx from 'clsx';
-import { usePrivacy } from '@/context/PrivacyContext';
-import type {
- TabType,
- AttendeeStatusFilter,
- TicketStatusFilter,
- RecipientFilter,
- AddTicketType,
- AddTicketFormState,
- PrimaryAction,
-} from './_types';
-import { formatCurrency, downloadBlob } from './_utils/format';
-import { useEventDetailData } from './_hooks/useEventDetailData';
-import { usePaymentOverrides } from './_hooks/usePaymentOverrides';
-import { OverviewTab } from './_tabs/OverviewTab';
-import { AttendeesTab } from './_tabs/AttendeesTab';
-import { TicketsTab } from './_tabs/TicketsTab';
-import { EmailTab } from './_tabs/EmailTab';
-import { PaymentsTab } from './_tabs/PaymentsTab';
-import { EventModals } from './_modals/EventModals';
-import { AddTicketModal } from './_modals/AddTicketModal';
-import EventFormModal from '../_components/EventFormModal';
-
-const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
- type: 'paid',
- firstName: '',
- lastName: '',
- email: '',
- phone: '',
- adminNote: '',
- checkinNow: false,
-};
+import { EventDetailView } from './_components/EventDetailView';
export default function AdminEventDetailPage() {
const params = useParams();
- const eventId = params.id as string;
- const { locale } = useLanguage();
-
- const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId);
- const [activeTab, setActiveTab] = useState('overview');
-
- // 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,
- });
- setPreviewHtml(res.bodyHtml);
- } catch (error) {
- toast.error('Failed to preview email');
- }
- };
-
- const handleSendEmail = async () => {
- if (!selectedTemplate) {
- toast.error('Please select a template');
- return;
- }
-
- const recipientCount = getFilteredRecipientCount();
- if (recipientCount === 0) {
- toast.error('No recipients match the selected filter');
- return;
- }
-
- if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
- return;
- }
-
- setSending(true);
- try {
- const res = await emailsApi.sendToEvent(eventId, {
- templateSlug: selectedTemplate,
- recipientFilter,
- customVariables: customMessage ? { customMessage } : undefined,
- });
-
- if (res.success) {
- toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
- } else {
- toast.error(res.error || 'Failed to queue emails');
- }
- } catch (error: any) {
- toast.error(error.message || 'Failed to send emails');
- } finally {
- setSending(false);
- }
- };
-
- if (loading) {
- return (
-
- );
- }
-
- if (!event) {
- return (
-
-
Event not found
-
-
- Back to Events
-
-
-
- );
- }
-
- const confirmedCount = getTicketsByStatus('confirmed').length;
- const pendingCount = getTicketsByStatus('pending').length;
- const checkedInCount = getTicketsByStatus('checked_in').length;
- const cancelledCount = getTicketsByStatus('cancelled').length;
- const onHoldCount = getTicketsByStatus('on_hold').length;
- // Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
- // tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
- const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
- const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
- const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
- // Door sales can be taken at a custom amount (someone paying for their whole
- // group), so once the door summary is loaded it is the authority on the total:
- // pre-sale tickets at face value plus whatever was actually taken on the night.
- const presaleRevenue = doorSummary
- ? doorSummary.presale.total
- : (paidConfirmedCount + paidCheckedInCount) * event.price;
- const doorRevenue = doorSummary?.door.total ?? 0;
- const revenue = presaleRevenue + doorRevenue;
-
- const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
- { key: 'overview', label: 'Overview', icon: CalendarIcon },
- { key: 'attendees', label: 'Attendees', icon: UserGroupIcon, count: tickets.length },
- { key: 'tickets', label: 'Tickets', icon: TicketIcon, count: confirmedTickets.length },
- { key: 'email', label: 'Email', icon: EnvelopeIcon },
- { key: 'payments', label: locale === 'es' ? 'Pagos' : 'Payments', icon: CreditCardIcon },
- ];
-
- // ========== Primary action for a ticket ==========
- const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
- if (ticket.status === 'pending' || ticket.status === 'on_hold') {
- return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
- }
- if (ticket.status === 'confirmed') {
- // Unpaid tickets resolve their balance first; check-in stays available via scanner
- if (ticket.paymentStatus === 'unpaid') {
- return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
- }
- return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
- }
- if (ticket.status === 'checked_in') {
- return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
- }
- return null;
- };
-
- return (
-
- {/* ============= HEADER ============= */}
-
-
-
-
-
-
-
-
{event.title}
-
{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}
-
- {/* Desktop header actions */}
-
-
-
-
- View Public
-
-
-
setShowEditForm(true)}>
-
- Edit
-
-
- {/* Mobile header overflow menu */}
-
-
-
-
- }
- >
- { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
- View Public
-
- { 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 ? 'Free' : formatCurrency(event.price, event.currency)}
-
- {showStats && (
-
-
- {confirmedCount + checkedInCount}/{event.capacity}
-
- )}
-
-
- {/* ============= STATS ROW ============= */}
- {/* Desktop: always-visible compact 4-card row */}
-
- {showStats && (
-
- {[
- { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
- { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
- { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
- {
- label: 'Revenue',
- value: formatCurrency(revenue, event.currency),
- icon: CurrencyDollarIcon,
- color: 'bg-gray-50 text-gray-600',
- detail: doorSummary
- ? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
- : undefined,
- },
- ].map((stat) => (
-
-
-
-
-
-
{stat.value}
-
{('detail' in stat && stat.detail) || stat.label}
-
-
- ))}
-
- )}
-
-
- {/* Mobile: collapsible stats */}
-
- {showStats && (
-
-
setMobileStatsExpanded(!mobileStatsExpanded)}
- className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
- >
-
- Stats
- {confirmedCount + checkedInCount}/{event.capacity}
- |
- {checkedInCount} in
- |
- {formatCurrency(revenue, event.currency)}
-
-
-
- {mobileStatsExpanded && (
-
- {[
- { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
- { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
- { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
- {
- label: 'Revenue',
- value: formatCurrency(revenue, event.currency),
- icon: CurrencyDollarIcon,
- color: 'text-gray-600 bg-gray-50',
- detail: doorSummary
- ? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
- : undefined,
- },
- ].map((stat) => (
-
-
-
-
-
-
{stat.value}
-
{('detail' in stat && stat.detail) || stat.label}
-
-
- ))}
-
- )}
-
- )}
-
-
- {/* ============= TAB BAR ============= */}
- {/* Desktop: tab bar inside a card top-section */}
-
-
-
- {tabs.map((tab) => (
- setActiveTab(tab.key)}
- className={clsx(
- 'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
- activeTab === tab.key
- ? 'text-primary-dark'
- : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
- )}
- >
-
- {tab.label}
- {tab.count !== undefined && ({tab.count}) }
- {activeTab === tab.key && (
-
- )}
-
- ))}
-
-
-
-
- {/* Mobile: segmented tab bar */}
-
-
-
- {tabs.map((tab) => (
- setActiveTab(tab.key)}
- className={clsx(
- 'px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap min-h-[36px] flex items-center gap-1',
- activeTab === tab.key
- ? 'bg-white shadow-sm text-primary-dark'
- : 'text-gray-500 hover:text-gray-700'
- )}
- >
-
- {tab.label}
- {tab.count !== undefined && ({tab.count}) }
-
- ))}
-
-
-
-
- {/* ============= TAB CONTENT ============= */}
-
- {activeTab === 'overview' && (
-
- )}
-
- {activeTab === 'attendees' && (
-
- )}
-
- {activeTab === 'tickets' && (
-
- )}
-
- {activeTab === 'email' && (
-
- )}
-
- {activeTab === 'payments' && (
-
- )}
-
-
- {/* ============= MODALS ============= */}
-
-
-
setShowAddTicketModal(false)}
- form={addTicketForm}
- setForm={setAddTicketForm}
- onSubmit={handleAddTicket}
- submitting={submitting}
- eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
- />
-
- setShowEditForm(false)}
- onSaved={() => { setShowEditForm(false); loadEventData(); }}
- />
-
-
-
- );
+ return ;
}
diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx
index c8c49c5..d88bd9f 100644
--- a/frontend/src/app/admin/events/_components/EventFormModal.tsx
+++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
-import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
+import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
interface EventFormData {
title: string;
@@ -24,7 +24,11 @@ interface EventFormData {
endDatetime: string;
location: string;
locationUrl: string;
+ series: string;
price: number;
+ // Kept as the raw input so "empty" (not set -> falls back to price) stays
+ // distinguishable from 0 (a free walk-in).
+ walkInPrice: string;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
@@ -46,13 +50,21 @@ const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefor
const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
- startDatetime: '', endDatetime: '', location: '', locationUrl: '',
- price: 0, currency: 'PYG', capacity: 50, status: 'draft',
+ startDatetime: '', endDatetime: '', location: '', locationUrl: '', series: '',
+ price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
};
+/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
+function isValidWalkInPrice(value: string): boolean {
+ const trimmed = value.trim();
+ if (trimmed === '') return true;
+ const n = Number(trimmed);
+ return Number.isInteger(n) && n >= 0;
+}
+
function isoToLocalDatetime(isoString: string): string {
const date = parseDate(isoString);
const parts = new Intl.DateTimeFormat('en-US', {
@@ -136,8 +148,9 @@ export default function EventFormModal({
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
- location: event.location, locationUrl: event.locationUrl || '',
+ location: event.location, locationUrl: event.locationUrl || '', series: event.series || '',
price: event.price, currency: event.currency, capacity: event.capacity,
+ walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
status: event.status, bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
@@ -208,6 +221,11 @@ export default function EventFormModal({
setSaving(false);
return;
}
+ if (!isValidWalkInPrice(formData.walkInPrice)) {
+ toast.error(t('admin.events.form.walkInPriceInvalid'));
+ setSaving(false);
+ return;
+ }
const eventData: Partial = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
@@ -215,7 +233,10 @@ export default function EventFormModal({
startDatetime: formData.startDatetime,
endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
+ series: formData.series.trim() || null,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
+ // Empty means "not set" and must be saved as null, never 0
+ walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
@@ -341,18 +362,36 @@ export default function EventFormModal({
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
setFormData({ ...formData, locationUrl: e.target.value })} />
+
+
setFormData({ ...formData, series: e.target.value })} />
+
{t('admin.eventForm.seriesHint')}
+
-
-
setFormData({ ...formData, price: Number(e.target.value) })} />
-
-
Currency
-
setFormData({ ...formData, currency: e.target.value })}
- className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
- PYG
- USD
-
+
+
+
{t('admin.events.form.walkInPriceHelp')}
+
+
+
setFormData({ ...formData, capacity: Number(e.target.value) })} />
diff --git a/frontend/src/app/admin/events/_components/EventsListSkeleton.tsx b/frontend/src/app/admin/events/_components/EventsListSkeleton.tsx
new file mode 100644
index 0000000..a866364
--- /dev/null
+++ b/frontend/src/app/admin/events/_components/EventsListSkeleton.tsx
@@ -0,0 +1,83 @@
+import Card from '@/components/ui/Card';
+import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
+
+// Loading placeholders for the admin events list. The page keeps its title,
+// Create button and table header on screen and swaps only the rows for these,
+// so nothing jumps when the data lands. loading.tsx composes the same pieces
+// for the instant route-transition shell.
+
+const TH = 'px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider';
+
+export function EventsTableHead() {
+ return (
+
+
+ Event
+ Date
+ Capacity
+ Status
+ Actions
+
+
+ );
+}
+
+/** Desktop table body rows: thumbnail + title/location, date, capacity, badge, actions. */
+export function EventRowsSkeleton({ rows = 8 }: { rows?: number }) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/** Mobile card list: thumbnail, three text lines, status badge, footer row. */
+export function EventCardsSkeleton({ count = 4 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/app/admin/events/loading.tsx b/frontend/src/app/admin/events/loading.tsx
new file mode 100644
index 0000000..4780907
--- /dev/null
+++ b/frontend/src/app/admin/events/loading.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { useLanguage } from '@/context/LanguageContext';
+import Card from '@/components/ui/Card';
+import { Skeleton } from '@/components/ui/Skeleton';
+import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
+
+// Route-level shell shown the moment an admin navigates here, while the page
+// chunk loads. The title and table header are real; only the rows are placeholders.
+export default function AdminEventsLoading() {
+ const { t } = useLanguage();
+ return (
+
+
+
{t('admin.events.title')}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx
index 8eae65d..4173b24 100644
--- a/frontend/src/app/admin/events/page.tsx
+++ b/frontend/src/app/admin/events/page.tsx
@@ -7,7 +7,6 @@ import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
-import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
@@ -15,6 +14,7 @@ import toast from 'react-hot-toast';
import clsx from 'clsx';
import { parseDate } from '@/lib/utils';
import EventFormModal from './_components/EventFormModal';
+import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
import Pagination from '@/components/admin/Pagination';
export default function AdminEventsPage() {
@@ -26,6 +26,11 @@ export default function AdminEventsPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [loading, setLoading] = useState(true);
+ // True while the rows on screen are not the rows for the current page (first
+ // load, page or page-size change). Reloads after a mutation keep the old rows
+ // up instead, so the table does not flash after every action.
+ const [fetching, setFetching] = useState(true);
+ const requestId = useRef(0);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState
(null);
const [featuredEventId, setFeaturedEventId] = useState(null);
@@ -36,6 +41,7 @@ export default function AdminEventsPage() {
}, []);
useEffect(() => {
+ setFetching(true);
loadEvents();
}, [page, pageSize]);
@@ -59,8 +65,12 @@ export default function AdminEventsPage() {
}, [searchParams, events, loading]);
const loadEvents = async () => {
+ // Drop responses that arrive after a newer request (e.g. a slow page 1
+ // landing after the user has already moved to page 2).
+ const id = ++requestId.current;
try {
const { events, total } = await eventsApi.getAll({ page, pageSize });
+ if (id !== requestId.current) return;
setEvents(events);
setTotal(total ?? events.length);
// If the current page emptied out (e.g. after deleting its last event),
@@ -69,9 +79,13 @@ export default function AdminEventsPage() {
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
}
} catch (error) {
+ if (id !== requestId.current) return;
toast.error('Failed to load events');
} finally {
- setLoading(false);
+ if (id === requestId.current) {
+ setLoading(false);
+ setFetching(false);
+ }
}
};
@@ -172,10 +186,6 @@ export default function AdminEventsPage() {
}
};
- if (loading) {
- return ;
- }
-
return (
@@ -198,16 +208,11 @@ export default function AdminEventsPage() {
{/* Desktop: Table */}
-
-
-
- Event
- Date
- Capacity
- Status
- Actions
-
-
+
+
+ {fetching ? (
+
+ ) : (
{events.length === 0 ? (
@@ -319,13 +324,16 @@ export default function AdminEventsPage() {
))
)}
+ )}
{/* Mobile: Card List */}
- {events.length === 0 ? (
+ {fetching ? (
+
+ ) : events.length === 0 ? (
No events found. Create your first event!
diff --git a/frontend/src/app/admin/finance/_components/ProfitByEventChart.tsx b/frontend/src/app/admin/finance/_components/ProfitByEventChart.tsx
new file mode 100644
index 0000000..e807b57
--- /dev/null
+++ b/frontend/src/app/admin/finance/_components/ProfitByEventChart.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+// Profit per event. Diverging by sign: blue = profit, red = loss, one axis,
+// event names on the category axis so color is never the only cue. A bar far
+// larger than the rest is clipped at the axis limit, marked with a break and
+// labelled with its real value, so the other events stay readable.
+
+import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine, LabelList } from 'recharts';
+import { formatPyg, formatPygShort } from '@/lib/money';
+import { clampRows, clampedAxis } from './clamp';
+
+export default function ProfitByEventChart({ rows, locale }: { rows: { id: string; name: string; profit: number }[]; locale: string }) {
+ const { rows: clamped, limit } = clampRows(rows);
+ const axis = limit !== null ? clampedAxis(clamped.map((c) => c.shown)) : null;
+ // Clipped bars run to the (rounded) end of the axis so the cut is obvious.
+ const data = clamped.map((c) => ({
+ ...c.row,
+ clipped: c.clipped,
+ shown: c.clipped && axis ? (c.row.profit > 0 ? axis.domain[1] : axis.domain[0]) : c.shown,
+ }));
+
+ // Break mark plus the real value inside the clipped end of the bar.
+ const renderClipLabel = (props: any) => {
+ const { x, y, width, height, index } = props;
+ const d = data[index];
+ if (!d?.clipped) return null;
+ const left = Math.min(x, x + width);
+ const right = Math.max(x, x + width);
+ const positive = d.profit > 0;
+ const edge = positive ? right : left;
+ const breakX = positive ? edge - 10 : edge + 10;
+ const text = `${formatPygShort(d.profit, locale)} PYG`;
+ return (
+
+
+
+ {text}
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ formatPygShort(v, locale)}
+ />
+
+
+ active && payload?.length ? (
+
+
{payload[0].payload.name}
+
{formatPyg(payload[0].payload.profit, locale)}
+
+ ) : null}
+ />
+
+ {data.map((r) => = 0 ? '#2a78d6' : '#e34948'} />)}
+ {limit !== null && }
+ |
+
+
+
+ );
+}
diff --git a/frontend/src/app/admin/finance/_components/clamp.test.ts b/frontend/src/app/admin/finance/_components/clamp.test.ts
new file mode 100644
index 0000000..fac1b76
--- /dev/null
+++ b/frontend/src/app/admin/finance/_components/clamp.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { clampLimit, clampRows, clampedAxis, niceCeil } from './clamp';
+
+describe('clampLimit', () => {
+ it('does nothing when values are comparable', () => {
+ expect(clampLimit([100000, 250000, -80000, 190000])).toBeNull();
+ });
+
+ it('clips a single large outlier just above the next bar', () => {
+ expect(clampLimit([12000000, 300000, 250000, -100000])).toBe(375000);
+ });
+
+ it('treats a large loss as an outlier too', () => {
+ expect(clampLimit([-9000000, 200000, 150000])).toBe(250000);
+ });
+
+ it('clips up to three outliers above the first big gap', () => {
+ expect(clampLimit([50e6, 40e6, 30e6, 200000, 150000])).toBe(250000);
+ expect(clampLimit([8e6, 3e6, 1.1e6, 400000, 150000])).toBe(3750000);
+ });
+
+ it('leaves a steadily spread-out series alone', () => {
+ expect(clampLimit([8e6, 4e6, 2e6, 1e6, 500000])).toBeNull();
+ });
+
+ it('does not clip when the gap is below the fourth bar', () => {
+ expect(clampLimit([4e6, 3e6, 2e6, 1.5e6, 100000])).toBeNull();
+ });
+
+ it('needs at least three non-zero bars', () => {
+ expect(clampLimit([12000000, 1000])).toBeNull();
+ expect(clampLimit([12000000, 0, 0, 1000])).toBeNull();
+ });
+});
+
+describe('clampRows', () => {
+ it('keeps the real value and marks clipped rows', () => {
+ const { rows, limit } = clampRows([
+ { id: 'a', profit: 12000000 }, { id: 'b', profit: 300000 }, { id: 'c', profit: -250000 },
+ ]);
+ expect(limit).toBe(375000);
+ expect(rows[0]).toMatchObject({ shown: 375000, clipped: true, row: { profit: 12000000 } });
+ expect(rows[1]).toMatchObject({ shown: 300000, clipped: false });
+ expect(rows[2]).toMatchObject({ shown: -250000, clipped: false });
+ });
+});
+
+describe('clampedAxis', () => {
+ it('rounds the ends up to round numbers', () => {
+ expect(niceCeil(375000)).toBe(500000);
+ expect(niceCeil(250000)).toBe(250000);
+ expect(niceCeil(105)).toBe(200);
+ expect(niceCeil(0)).toBe(0);
+ });
+
+ it('puts ticks at the ends, halves and zero', () => {
+ expect(clampedAxis([-375000, 120000, 375000])).toEqual({ domain: [-500000, 500000], ticks: [-500000, -250000, 0, 250000, 500000] });
+ expect(clampedAxis([10, 375])).toEqual({ domain: [0, 500], ticks: [0, 250, 500] });
+ });
+});
diff --git a/frontend/src/app/admin/finance/_components/clamp.ts b/frontend/src/app/admin/finance/_components/clamp.ts
new file mode 100644
index 0000000..14f2c1c
--- /dev/null
+++ b/frontend/src/app/admin/finance/_components/clamp.ts
@@ -0,0 +1,57 @@
+// Axis clamping for the profit-per-event chart: one huge event would otherwise
+// flatten every other bar. Pure so it can be unit tested.
+
+/** How much bigger than the next bar a bar must be to count as an outlier. */
+const OUTLIER_RATIO = 2.5;
+/** Never clip more than this many bars; past that the data is just spread out. */
+const MAX_CLIPPED = 3;
+
+/**
+ * The axis limit (absolute PYG) to clip at, or null when nothing stands out.
+ * Walks the largest magnitudes from the top and clips above the first big gap.
+ */
+export function clampLimit(values: number[]): number | null {
+ const mags = values.map((v) => Math.abs(v)).filter((v) => v > 0).sort((a, b) => b - a);
+ if (mags.length < 3) return null;
+ for (let k = 1; k <= Math.min(MAX_CLIPPED, mags.length - 2); k++) {
+ if (mags[k - 1] > OUTLIER_RATIO * mags[k]) return Math.round(mags[k] * 1.25);
+ }
+ return null;
+}
+
+export interface ClampedRow
{
+ row: R;
+ /** Value drawn on the chart (clipped to the limit). */
+ shown: number;
+ /** True when the bar is cut off; its real value is labelled on it. */
+ clipped: boolean;
+}
+
+export function clampRows(rows: R[]): { rows: ClampedRow[]; limit: number | null } {
+ const limit = clampLimit(rows.map((r) => r.profit));
+ return {
+ limit,
+ rows: rows.map((row) => {
+ const clipped = limit !== null && Math.abs(row.profit) > limit;
+ return { row, shown: clipped ? Math.sign(row.profit) * limit! : row.profit, clipped };
+ }),
+ };
+}
+
+/** Smallest "round" number (1, 2, 2.5 or 5 times a power of ten) at or above n, for axis ends. */
+export function niceCeil(n: number): number {
+ if (n <= 0) return 0;
+ const pow = 10 ** Math.floor(Math.log10(n));
+ const step = [1, 2, 2.5, 5, 10].find((s) => s * pow >= n)!;
+ return step * pow;
+}
+
+/** Axis domain and ticks for a clipped chart: round ends, ticks at halves. */
+export function clampedAxis(shown: number[]): { domain: [number, number]; ticks: number[] } {
+ const max = Math.max(0, ...shown);
+ const min = Math.min(0, ...shown);
+ const hi = niceCeil(max);
+ const lo = min < 0 ? -niceCeil(-min) : 0;
+ const ticks = [lo, lo / 2, 0, hi / 2, hi].filter((v, i, a) => a.indexOf(v) === i);
+ return { domain: [lo, hi], ticks };
+}
diff --git a/frontend/src/app/admin/finance/page.tsx b/frontend/src/app/admin/finance/page.tsx
new file mode 100644
index 0000000..631aa29
--- /dev/null
+++ b/frontend/src/app/admin/finance/page.tsx
@@ -0,0 +1,294 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+import toast from 'react-hot-toast';
+import clsx from 'clsx';
+import { ChartBarIcon, EyeSlashIcon, FunnelIcon, ChevronDownIcon, ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { useLanguage } from '@/context/LanguageContext';
+import { usePrivacy } from '@/context/PrivacyContext';
+import { financeApi, type FinanceOverview, type FinanceTotals } from '@/lib/api';
+import Card from '@/components/ui/Card';
+import { Skeleton } from '@/components/ui/Skeleton';
+import { Money, FinanceStatusBadge, DateInput, inputClass } from '../events/[id]/_finance/ui';
+import { tCount } from '../events/[id]/_finance/derive';
+import { clampLimit } from './_components/clamp';
+
+const ProfitByEventChart = dynamic(() => import('./_components/ProfitByEventChart'), { ssr: false, loading: () => });
+
+type Filters = { from: string; to: string; series: string; venue: string; partner: string };
+
+function TotalsTable({ rows, label, t }: { rows: (FinanceTotals & { key: string; name: string })[]; label: string; t: (k: string) => string }) {
+ return (
+
+
+
+
+ {label}
+ {t('admin.financeOverview.events')}
+ {/* Phones: gross and expenses fold away so the table fits without scrolling */}
+ {t('admin.finance.kpi.gross')}
+ {t('admin.finance.kpi.expenses')}
+ {t('admin.finance.kpi.profit')}
+
+
+
+ {rows.map((r) => (
+
+ {r.name}
+ {r.events}
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+export default function AdminFinancePage() {
+ const { t, locale } = useLanguage();
+ const { privacyMode } = usePrivacy();
+ const [filters, setFilters] = useState({ from: '', to: '', series: '', venue: '', partner: '' });
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const [readyOnly, setReadyOnly] = useState(false);
+ const activeFilters = Object.values(filters).filter(Boolean).length;
+
+ useEffect(() => {
+ setLoading(true);
+ financeApi.overview(filters)
+ .then(setData)
+ .catch((error) => toast.error(error.message))
+ .finally(() => setLoading(false));
+ }, [filters]);
+
+ const set = (k: keyof Filters, v: string) => setFilters((f) => ({ ...f, [k]: v }));
+ const title = (e: { title: string; titleEs: string | null }) => (locale === 'es' && e.titleEs) || e.title;
+ // Past events whose books are still open: the server lists them, including
+ // ones with no money recorded yet (which the main list leaves out).
+ const readyIds = new Set(data?.readyToClose.map((e) => e.id) ?? []);
+ const isReady = (e: { id: string }) => readyIds.has(e.id);
+ const readyCount = readyIds.size;
+ const showReady = readyOnly && readyCount > 0;
+ const tableEvents = data ? (showReady ? data.readyToClose : data.events) : [];
+ const chartRows = data ? data.events.slice(0, 30).map((e) => ({ id: e.id, name: title(e), profit: e.profit })) : [];
+ const clipped = clampLimit(chartRows.map((r) => r.profit)) !== null;
+ const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
+
+ return (
+
+
+
+
+ {t('admin.financeOverview.title')}
+
+
{t('admin.financeOverview.subtitle')}
+
+
+ {/* Filters: one row above everything they affect */}
+
+ {/* Phones: filters fold away behind one button */}
+ setFiltersOpen(!filtersOpen)}
+ aria-expanded={filtersOpen}
+ className="md:hidden w-full flex items-center gap-2 min-h-[44px] text-sm font-medium cursor-pointer"
+ >
+
+ {t('admin.financeOverview.filters')}
+ {activeFilters > 0 && {activeFilters} }
+
+
+
+
+ {t('admin.financeOverview.from')}
+ set('from', v)} />
+
+
+ {t('admin.financeOverview.to')}
+ set('to', v)} />
+
+
+ {t('admin.financeOverview.series')}
+ set('series', e.target.value)}>
+ {t('admin.financeOverview.all')}
+ {data?.filters.series.map((s) => {s} )}
+ {t('admin.financeOverview.noSeries')}
+
+
+
+ {t('admin.financeOverview.venue')}
+ set('venue', e.target.value)}>
+ {t('admin.financeOverview.all')}
+ {data?.filters.venues.map((v) => {v} )}
+
+
+
+ {t('admin.financeOverview.partner')}
+ set('partner', e.target.value)}>
+ {t('admin.financeOverview.all')}
+ {data?.filters.partners.map((p) => {p.name} )}
+
+
+
+
+
+ {loading && !data ?
: !data || (data.events.length === 0 && data.readyToClose.length === 0) ? (
+
{t('admin.financeOverview.empty')}
+ ) : (
+
+
+ {[
+ { label: t('admin.financeOverview.events'), value:
{data.totals.events} },
+ { label: t('admin.finance.kpi.gross'), value:
},
+ { label: t('admin.finance.kpi.expenses'), value:
},
+ { label: t(data.totals.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit'), value:
},
+ ].map((k) => (
+
+
{k.value}
+
{k.label}
+
+ ))}
+
+
+ {readyCount > 0 && (
+
+
+
+ {tCount(t, 'admin.financeOverview.readyCount', readyCount)} {t('admin.financeOverview.readyHint')}
+
+
setReadyOnly(!readyOnly)}
+ aria-pressed={readyOnly}
+ className={clsx(
+ 'self-start sm:self-auto inline-flex items-center gap-1.5 rounded-full px-3 min-h-[36px] text-sm font-medium cursor-pointer border',
+ readyOnly ? 'bg-amber-900 text-white border-amber-900' : 'bg-white border-amber-300 hover:bg-amber-100',
+ )}
+ >
+ {readyOnly && }
+ {t(readyOnly ? 'admin.financeOverview.showAll' : 'admin.financeOverview.showReady')}
+
+
+ )}
+
+
+ {t(showReady ? 'admin.financeOverview.readyTitle' : 'admin.financeOverview.perEvent')}
+ {showReady || chartRows.length === 0 ? null : privacyMode ? (
+ {t('admin.finance.hiddenStats')}
+ ) : (
+ <>
+
+ {clipped && {t('admin.financeOverview.clippedHint')}
}
+ >
+ )}
+ {/* Phones: one card per event, no sideways scrolling */}
+
+
+
+
+
+ {t('admin.financeOverview.event')}
+ {t('admin.financeOverview.series')}
+ {t('admin.finance.kpi.gross')}
+ {t('admin.finance.kpi.expenses')}
+ {t('admin.finance.kpi.profit')}
+
+
+
+
+ {tableEvents.map((e) => (
+
+
+ {title(e)}
+ {fmtDate(e.startDatetime)} · {e.location}
+
+ {e.series || '-'}
+
+
+
+
+ {isReady(e)
+ ? {t('admin.financeOverview.readyBadge')}
+ : }
+
+
+ ))}
+
+
+
+
+
+
+
+ {t('admin.financeOverview.perSeries')}
+ ({ ...s, key: s.series || '__none__', name: s.series || t('admin.financeOverview.noSeries') }))} />
+
+
+ {t('admin.financeOverview.perVenue')}
+ ({ ...v, key: v.venue, name: v.venue || '-' }))} />
+
+
+
+
+ {t('admin.financeOverview.perPartner')}
+ {data.byPartner.length === 0 ? {t('admin.finance.split.empty')}
: (
+
+
+
+
+ {t('admin.financeOverview.partner')}
+ {t('admin.financeOverview.events')}
+ {t('admin.finance.split.share')}
+ {t('admin.finance.split.reimbursement')}
+ {t('admin.financeOverview.paid')}
+ {t('admin.financeOverview.pending')}
+
+
+
+ {data.byPartner.map((p) => (
+
+ {p.name}
+ {p.events}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx
index 6569c51..88bf821 100644
--- a/frontend/src/app/admin/layout.tsx
+++ b/frontend/src/app/admin/layout.tsx
@@ -29,6 +29,7 @@ import {
QuestionMarkCircleIcon,
EyeIcon,
EyeSlashIcon,
+ ChartBarIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -67,6 +68,7 @@ function AdminLayoutInner({
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
+ { name: t('admin.nav.finance'), href: '/admin/finance', icon: ChartBarIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
diff --git a/frontend/src/app/admin/payment-options/page.tsx b/frontend/src/app/admin/payment-options/page.tsx
index fc7b9de..6cb3543 100644
--- a/frontend/src/app/admin/payment-options/page.tsx
+++ b/frontend/src/app/admin/payment-options/page.tsx
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
+ posEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
allowDuplicateBookings: false,
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
setOptions((prev) => ({ ...prev, [key]: value }));
};
+ // Stored as 0/1 on Postgres; on unless explicitly switched off.
+ const posEnabled = !!(options.posEnabled ?? true);
+
if (loading) {
return (
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
+ {/* POS terminal at the door */}
+
+
+
+
+
+
+
+
+
POS
+
+ {locale === 'es'
+ ? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
+ : 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
+
+
+
+
updateOption('posEnabled', !posEnabled)}
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
+ posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
+ }`}
+ >
+
+
+
+
+
+
{/* Booking Settings */}
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
{locale === 'es' ? 'Efectivo' : 'Cash'}
+
+ {posEnabled ? (
+
+ ) : (
+
+ )}
+
+ POS
+
+
diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx
index 4e5ed84..555d3ce 100644
--- a/frontend/src/app/admin/payments/page.tsx
+++ b/frontend/src/app/admin/payments/page.tsx
@@ -313,6 +313,7 @@ export default function AdminPaymentsPage() {
bank_transfer: BuildingLibraryIcon,
tpago: CreditCardIcon,
bancard: CreditCardIcon,
+ pos: CreditCardIcon,
};
const Icon = icons[provider] || CreditCardIcon;
return ;
@@ -325,6 +326,7 @@ export default function AdminPaymentsPage() {
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
+ pos: 'POS',
};
return labels[provider] || provider;
};
@@ -736,7 +738,7 @@ export default function AdminPaymentsPage() {
{/* By Provider */}
{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}
-
+
{locale === 'es' ? 'Efectivo' : 'Cash'}
{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG
@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {
Bancard
{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG
+
+
POS
+
{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG
+
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() {
{locale === 'es' ? 'Efectivo' : 'Cash'}
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
TPago
+
POS
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() {
{locale === 'es' ? 'Efectivo' : 'Cash'}
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
TPago
+ POS
diff --git a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx
index 0381803..0acd497 100644
--- a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx
+++ b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx
@@ -8,7 +8,7 @@ import {
} from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
-import { PaymentButtons } from './PaymentButtons';
+import { PaymentButtons, type DoorCharge } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return '';
@@ -23,6 +23,7 @@ const METHOD_LABELS: Record
= {
cash: 'cash',
bitcoin: 'bitcoin',
transfer: 'transfer',
+ pos: 'POS',
guest: 'guest',
};
@@ -49,6 +50,8 @@ export function AttendeeRow({
attendee,
currency,
price,
+ methods,
+ canOverrideAmount,
expanded,
flashing,
busy,
@@ -58,11 +61,13 @@ export function AttendeeRow({
attendee: DoorAttendee;
currency: string;
price: number;
+ methods: readonly DoorPaymentMethod[];
+ canOverrideAmount: boolean;
expanded: boolean;
flashing: boolean;
busy: boolean;
onTap: () => void;
- onPay: (method: DoorPaymentMethod, amount: number) => void;
+ onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
}) {
const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
@@ -141,7 +146,14 @@ export function AttendeeRow({
Reactivate as a walk-in — pick how they are paying.
)}
-
+
)}
diff --git a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx
index 7c849a2..3a28f44 100644
--- a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx
+++ b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx
@@ -6,14 +6,29 @@ import {
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
+ CreditCardIcon,
GiftIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
+import { PosChargePanel } from './PosChargePanel';
-// The four tenders staff can take at the door. One tap settles and checks in;
+// The tenders staff can take at the door. One tap settles and checks in;
// long-press (or the chevron) opens multiples for someone paying for their group.
+// POS is the exception: it opens a confirm step, because the card is charged on
+// the terminal first (see PosChargePanel).
+
+/**
+ * What staff chose to charge. The server prices it from quantity; `amount` is
+ * what the screen expects that to come to, and is only sent as a charge when
+ * `override` is set (admin/organizer "Custom").
+ */
+export interface DoorCharge {
+ quantity: number;
+ amount: number;
+ override?: boolean;
+}
const TENDERS: {
method: DoorPaymentMethod;
@@ -24,6 +39,7 @@ const TENDERS: {
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
+ { method: 'pos', label: 'POS', icon: CreditCardIcon, className: 'bg-violet-600 active:bg-violet-700' },
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
];
@@ -32,21 +48,32 @@ const LONG_PRESS_MS = 450;
export function PaymentButtons({
price,
currency,
+ methods,
+ canOverrideAmount,
onPay,
disabled,
}: {
+ /** Unit price for one ticket, as the server will charge it. */
price: number;
currency: string;
- onPay: (method: DoorPaymentMethod, amount: number) => void;
+ /** Tenders enabled for this event. */
+ methods: readonly DoorPaymentMethod[];
+ /** Admin/organizer may type a custom amount; the server enforces the same rule. */
+ canOverrideAmount: boolean;
+ onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
disabled?: boolean;
}) {
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
const [amountsFor, setAmountsFor] = useState(null);
const [customOpen, setCustomOpen] = useState(false);
const [customValue, setCustomValue] = useState('');
+ // POS charge waiting for staff to confirm the terminal approved it.
+ const [posCharge, setPosCharge] = useState(null);
const [pressTimer, setPressTimer] = useState | null>(null);
const [longPressed, setLongPressed] = useState(false);
+ const tenders = TENDERS.filter((t) => methods.includes(t.method));
+
const openAmounts = (method: DoorPaymentMethod) => {
if (method === 'guest') return;
setAmountsFor(method);
@@ -54,6 +81,15 @@ export function PaymentButtons({
setCustomValue('');
};
+ // Every tender settles immediately except POS, which stops at its confirm step.
+ const charge = (method: DoorPaymentMethod, next: DoorCharge) => {
+ if (method === 'pos') {
+ setPosCharge(next);
+ return;
+ }
+ onPay(method, next);
+ };
+
const startPress = (method: DoorPaymentMethod) => {
setLongPressed(false);
const timer = setTimeout(() => {
@@ -72,7 +108,7 @@ export function PaymentButtons({
return;
}
if (disabled) return;
- onPay(method, method === 'guest' ? 0 : price);
+ charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
};
const cancelPress = () => {
@@ -81,6 +117,19 @@ export function PaymentButtons({
setLongPressed(false);
};
+ if (posCharge) {
+ return (
+ onPay('pos', posCharge)}
+ onBack={() => setPosCharge(null)}
+ />
+ );
+ }
+
if (amountsFor) {
const tender = TENDERS.find((t) => t.method === amountsFor)!;
return (
@@ -94,12 +143,12 @@ export function PaymentButtons({
Back
-
+
{[1, 2, 3].map((qty) => (
onPay(tender.method, price * qty)}
+ onClick={() => charge(tender.method, { quantity: qty, amount: price * qty })}
className={clsx(
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
@@ -111,15 +160,17 @@ export function PaymentButtons({
))}
- setCustomOpen((open) => !open)}
- className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
- >
- Custom
-
+ {canOverrideAmount && (
+ setCustomOpen((open) => !open)}
+ className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
+ >
+ Custom
+
+ )}
- {customOpen && (
+ {canOverrideAmount && customOpen && (
onPay(tender.method, Number(customValue))}
+ onClick={() => charge(tender.method, { quantity: 1, amount: Number(customValue), override: true })}
className={clsx(
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
@@ -147,8 +198,11 @@ export function PaymentButtons({
}
return (
-
- {TENDERS.map((tender) => (
+
+ {tenders.map((tender) => (
void;
+ onBack: () => void;
+}) {
+ return (
+
+
+
+
+ POS — charge on the terminal
+
+
+ Back
+
+
+
+
+
Amount to charge
+
{formatCurrency(charge.amount, currency)}
+ {charge.override ? (
+
Custom amount
+ ) : charge.quantity > 1 ? (
+
+ {charge.quantity} × {formatCurrency(unitPrice, currency)}
+
+ ) : null}
+
+
+
+ Mark as paid
+
+
+ Only tap once the terminal has approved the card.
+
+
+ );
+}
diff --git a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx
index 0916074..5a6248d 100644
--- a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx
+++ b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx
@@ -43,6 +43,7 @@ const METHOD_LABELS: Record = {
cash: 'Cash',
bitcoin: 'Bitcoin',
transfer: 'Transfer',
+ pos: 'POS',
guest: 'Guest',
};
@@ -52,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) {
cash: { count: 0, total: 0 },
bitcoin: { count: 0, total: 0 },
transfer: { count: 0, total: 0 },
+ pos: { count: 0, total: 0 },
guest: { count: 0, total: 0 },
};
let grand = 0;
diff --git a/frontend/src/app/admin/scanner/_components/WalkInRow.tsx b/frontend/src/app/admin/scanner/_components/WalkInRow.tsx
index e282463..9d45e57 100644
--- a/frontend/src/app/admin/scanner/_components/WalkInRow.tsx
+++ b/frontend/src/app/admin/scanner/_components/WalkInRow.tsx
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import type { DoorPaymentMethod } from '@/lib/api';
-import { PaymentButtons } from './PaymentButtons';
+import { formatCurrency } from '@/lib/utils';
+import { PaymentButtons, type DoorCharge } from './PaymentButtons';
export interface WalkInDraft {
firstName: string;
@@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
ruc: '',
});
+const PRICE_SOURCE_LABELS: Record<'walk_in' | 'ticket', string> = {
+ walk_in: 'Walk-in price',
+ ticket: 'Ticket price (no walk-in price set)',
+};
+
/**
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
- * name and four tenders. Email, phone and RUC live behind "Add details" so the
+ * name, the walk-in price and the tenders. Email, phone and RUC live behind "Add details" so the
* rare person who wants a receipt never slows down the queue behind them.
*/
export function WalkInRow({
@@ -32,7 +38,10 @@ export function WalkInRow({
expanded,
draft,
price,
+ priceSource,
currency,
+ methods,
+ canOverrideAmount,
busy,
onExpand,
onChange,
@@ -42,12 +51,16 @@ export function WalkInRow({
typedText: string;
expanded: boolean;
draft: WalkInDraft;
+ /** Walk-in unit price as resolved by the server. */
price: number;
+ priceSource: 'walk_in' | 'ticket';
currency: string;
+ methods: readonly DoorPaymentMethod[];
+ canOverrideAmount: boolean;
busy: boolean;
onExpand: () => void;
onChange: (draft: WalkInDraft) => void;
- onPay: (method: DoorPaymentMethod, amount: number) => void;
+ onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
onCancel: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -146,9 +159,16 @@ export function WalkInRow({
)}
+
+
{formatCurrency(price, currency)}
+
{PRICE_SOURCE_LABELS[priceSource]}
+
+
diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx
index 29c057d..3307561 100644
--- a/frontend/src/app/admin/scanner/page.tsx
+++ b/frontend/src/app/admin/scanner/page.tsx
@@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext';
import {
eventsApi,
doorApi,
+ DOOR_PAYMENT_METHODS,
type Event,
type DoorAttendee,
type DoorAttendeesResponse,
@@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/
import { QRScannerOverlay } from './_components/QRScannerOverlay';
import { AttendeeRow } from './_components/AttendeeRow';
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
+import type { DoorCharge } from './_components/PaymentButtons';
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
@@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record
= {
cash: 'paid cash',
bitcoin: 'paid bitcoin',
transfer: 'paid by transfer',
+ pos: 'paid by POS',
guest: 'in as guest',
};
@@ -75,6 +78,9 @@ export default function AdminDoorPage() {
// own shift from the session feed below, which is local to this device; the
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
+ // Typing a custom door amount is an admin decision; the API rejects it for
+ // anyone else (AMOUNT_OVERRIDE_ROLES in routes/door.ts).
+ const canOverrideAmount = user?.role === 'admin' || user?.role === 'organizer';
// ── Events ──
const [events, setEvents] = useState([]);
@@ -122,6 +128,10 @@ export default function AdminDoorPage() {
const eventMeta = data?.event;
const price = eventMeta?.price ?? 0;
+ // Walk-ins pay the walk-in price when the event sets one; the server resolves it.
+ const walkInPrice = eventMeta?.walkInUnitPrice ?? price;
+ const walkInPriceSource = eventMeta?.walkInPriceSource ?? 'ticket';
+ const doorMethods = data?.doorMethods ?? DOOR_PAYMENT_METHODS;
const currency = eventMeta?.currency ?? 'PYG';
const capacity = eventMeta?.capacity ?? 0;
@@ -351,7 +361,7 @@ export default function AdminDoorPage() {
async (opts: {
ticketId?: string;
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
- payment?: { method: DoorPaymentMethod; amount: number };
+ payment?: { method: DoorPaymentMethod; charge: DoorCharge };
entry: DoorEntryMethod;
displayName: string;
/** Ticket row to flash and optimistically mark as in. */
@@ -373,7 +383,7 @@ export default function AdminDoorPage() {
at: clockTime(now),
entry: opts.entry,
method: opts.payment?.method ?? null,
- amount: opts.payment?.amount ?? 0,
+ amount: opts.payment?.charge.amount ?? 0,
startedAt: now.getTime(),
undone: false,
failed: false,
@@ -424,10 +434,19 @@ export default function AdminDoorPage() {
// ── The write itself: background, retried, never blocking the queue ──
try {
+ const { method, charge } = opts.payment ?? {};
const res = await submitDoorAction(eventId, {
ticketId: opts.ticketId,
attendee: opts.attendee,
- payment: opts.payment,
+ // The server prices the charge from quantity; a typed amount only
+ // travels as an explicit admin override.
+ payment: method && charge
+ ? {
+ method,
+ quantity: charge.quantity,
+ ...(charge.override ? { amount: charge.amount, amountOverride: true } : {}),
+ }
+ : undefined,
entryMethod: opts.entry,
idempotencyKey,
});
@@ -435,7 +454,13 @@ export default function AdminDoorPage() {
setSessionEntries((prev) =>
prev.map((e) =>
e.idempotencyKey === idempotencyKey
- ? { ...e, ticketId: res.attendee.ticketId, name: res.attendee.fullName }
+ ? {
+ ...e,
+ ticketId: res.attendee.ticketId,
+ name: res.attendee.fullName,
+ // What was actually recorded is the server's number, not ours.
+ amount: res.payment?.amount ?? e.amount,
+ }
: e,
),
);
@@ -508,10 +533,10 @@ export default function AdminDoorPage() {
);
const handleRowPay = useCallback(
- (attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => {
+ (attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => {
runAction({
ticketId: attendee.ticketId,
- payment: { method, amount },
+ payment: { method, charge },
entry: 'search',
displayName: attendee.fullName,
optimisticTicketId: attendee.ticketId,
@@ -522,7 +547,7 @@ export default function AdminDoorPage() {
);
const handleWalkInPay = useCallback(
- (method: DoorPaymentMethod, amount: number) => {
+ (method: DoorPaymentMethod, charge: DoorCharge) => {
const firstName = walkInDraft.firstName.trim();
if (!firstName) return;
const displayName = walkInDraft.lastName.trim()
@@ -536,7 +561,7 @@ export default function AdminDoorPage() {
email: walkInDraft.email.trim() || undefined,
ruc: walkInDraft.ruc.trim() || undefined,
},
- payment: { method, amount },
+ payment: { method, charge },
entry: 'walkin',
displayName,
busyKey: 'walk-in',
@@ -764,11 +789,13 @@ export default function AdminDoorPage() {
attendee={attendee}
currency={currency}
price={price}
+ methods={doorMethods}
+ canOverrideAmount={canOverrideAmount}
expanded={expandedId === attendee.ticketId}
flashing={flashId === attendee.ticketId}
busy={busyId === attendee.ticketId}
onTap={() => handleRowTap(attendee)}
- onPay={(method, amount) => handleRowPay(attendee, method, amount)}
+ onPay={(method, charge) => handleRowPay(attendee, method, charge)}
/>
))}
@@ -778,8 +805,11 @@ export default function AdminDoorPage() {
typedText={trimmedQuery}
expanded={walkInOpen}
draft={walkInDraft}
- price={price}
+ price={walkInPrice}
+ priceSource={walkInPriceSource}
currency={currency}
+ methods={doorMethods}
+ canOverrideAmount={canOverrideAmount}
busy={busyId === 'walk-in'}
onExpand={() => {
setWalkInDraft(emptyWalkIn(trimmedQuery));
diff --git a/frontend/src/app/admin/settings/_components/ExpenseTemplatesSettings.tsx b/frontend/src/app/admin/settings/_components/ExpenseTemplatesSettings.tsx
new file mode 100644
index 0000000..ddd1bd1
--- /dev/null
+++ b/frontend/src/app/admin/settings/_components/ExpenseTemplatesSettings.tsx
@@ -0,0 +1,396 @@
+'use client';
+
+// Site Settings → Expense Templates: expense categories, reusable expense
+// templates (calc-type aware), template packs applied to events in one click,
+// and the per-method payment fees used for net revenue. Each row saves on its
+// own; everything written here is recorded in the finance audit log.
+
+import { useCallback, useEffect, useState } from 'react';
+import toast from 'react-hot-toast';
+import clsx from 'clsx';
+import { PlusIcon, PencilIcon, TrashIcon, ArchiveBoxIcon, ArrowUturnLeftIcon } from '@heroicons/react/24/outline';
+import { useLanguage } from '@/context/LanguageContext';
+import {
+ financeApi, CALC_TYPES,
+ type CalcType, type ExpenseCategory, type ExpenseTemplate, type ExpenseTemplatePack, type PaymentMethodFee,
+} from '@/lib/api';
+import Card from '@/components/ui/Card';
+import Button from '@/components/ui/Button';
+import { Modal, Field, PygInput, Pill, inputClass } from '../../events/[id]/_finance/ui';
+import { bpToPercent, formatPercent, formatPyg, percentToBp } from '../../events/[id]/_finance/format';
+import { suggestCategory } from '../../events/[id]/_finance/derive';
+
+type T = (key: string, params?: Record) => string;
+
+function Section({ title, hint, action, children }: { title: string; hint?: string; action?: React.ReactNode; children: React.ReactNode }) {
+ return (
+
+
+
+
{title}
+ {hint &&
{hint}
}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+const saveError = (t: T) => (error: any) =>
+ toast.error(error?.code === 'IN_USE' ? t('admin.expenseSettings.inUse') : error.message);
+
+// ==================== Categories ====================
+
+function Categories({ categories, reload, t }: { categories: ExpenseCategory[]; reload: () => void; t: T }) {
+ const [editing, setEditing] = useState | null>(null);
+ const [saving, setSaving] = useState(false);
+ const [showArchived, setShowArchived] = useState(false);
+
+ const save = async () => {
+ if (!editing?.nameEn?.trim() || !editing?.nameEs?.trim()) return;
+ setSaving(true);
+ const body = { nameEn: editing.nameEn.trim(), nameEs: editing.nameEs.trim(), color: editing.color || '#6B7280', sortOrder: editing.sortOrder ?? categories.length };
+ try {
+ if (editing.id) await financeApi.updateCategory(editing.id, body); else await financeApi.createCategory(body);
+ toast.success(t('admin.expenseSettings.saved'));
+ setEditing(null);
+ reload();
+ } catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
+ };
+ const archive = (c: ExpenseCategory) => financeApi.updateCategory(c.id, { archived: !c.archived }).then(reload).catch(saveError(t));
+ const remove = (c: ExpenseCategory) => {
+ if (!confirm(t('admin.expenseSettings.confirmDelete', { name: c.nameEn }))) return;
+ financeApi.deleteCategory(c.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
+ };
+ const visible = categories.filter((c) => showArchived || !c.archived);
+
+ return (
+
+ );
+}
+
+// ==================== Templates ====================
+
+interface TemplateForm { id?: string; name: string; categoryId: string; description: string; calcType: CalcType; amount: number; percent: string; minimumAmount: number }
+
+function templateSummary(tpl: ExpenseTemplate, t: T, locale: string) {
+ const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
+ if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${formatPercent(tpl.percentBp, locale)}`;
+ if (tpl.calcType === 'minimum_spend') return `${kind} · ${formatPyg(tpl.amount, locale)} / min ${formatPyg(tpl.minimumAmount, locale)}`;
+ return `${kind} · ${formatPyg(tpl.amount, locale)}`;
+}
+
+/** Label for the main amount field, matching the event expense modal. */
+const AMOUNT_LABEL: Record = {
+ fixed: 'admin.finance.expenses.amount',
+ per_ticket_sold: 'admin.finance.expenses.amountPerTicket',
+ per_checked_in: 'admin.finance.expenses.amountPerGuest',
+ percent_of_revenue: 'admin.finance.expenses.percentOfRevenue',
+ minimum_spend: 'admin.finance.expenses.minimumAmount',
+};
+
+function Templates({ templates, categories, reload, t, locale }: { templates: ExpenseTemplate[]; categories: ExpenseCategory[]; reload: () => void; t: T; locale: string }) {
+ const [form, setForm] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [showArchived, setShowArchived] = useState(false);
+ // Category follows the name until someone picks one by hand (same as the event expense modal).
+ const [categoryAuto, setCategoryAuto] = useState(true);
+ const set = (k: K, v: TemplateForm[K]) => setForm((f) => (f ? { ...f, [k]: v } : f));
+ const setName = (name: string) => setForm((f) => (f
+ ? { ...f, name, categoryId: categoryAuto ? suggestCategory(name, categories) || '' : f.categoryId }
+ : f));
+
+ const open = (tpl?: ExpenseTemplate) => {
+ setCategoryAuto(!tpl?.categoryId);
+ openForm(tpl);
+ };
+ const openForm = (tpl?: ExpenseTemplate) => setForm(tpl
+ ? { id: tpl.id, name: tpl.name, categoryId: tpl.categoryId || '', description: tpl.description || '', calcType: tpl.calcType, amount: tpl.amount, percent: tpl.percentBp ? bpToPercent(tpl.percentBp) : '', minimumAmount: tpl.minimumAmount }
+ : { name: '', categoryId: '', description: '', calcType: 'fixed', amount: 0, percent: '', minimumAmount: 0 });
+
+ const save = async () => {
+ if (!form?.name.trim()) return;
+ setSaving(true);
+ const body: Partial = {
+ name: form.name.trim(),
+ categoryId: form.categoryId || null,
+ description: form.description.trim() || null,
+ calcType: form.calcType,
+ amount: form.calcType === 'percent_of_revenue' ? 0 : form.amount,
+ percentBp: form.calcType === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
+ minimumAmount: form.calcType === 'minimum_spend' ? form.minimumAmount : 0,
+ };
+ try {
+ if (form.id) await financeApi.updateTemplate(form.id, body); else await financeApi.createTemplate(body);
+ toast.success(t('admin.expenseSettings.saved'));
+ setForm(null);
+ reload();
+ } catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
+ };
+ const remove = (tpl: ExpenseTemplate) => {
+ if (!confirm(t('admin.expenseSettings.confirmDelete', { name: tpl.name }))) return;
+ financeApi.deleteTemplate(tpl.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
+ };
+ const archive = (tpl: ExpenseTemplate) => financeApi.updateTemplate(tpl.id, { archived: !tpl.archived }).then(reload).catch(saveError(t));
+ const catName = (id: string | null) => {
+ const c = categories.find((x) => x.id === id);
+ return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
+ };
+ const visible = templates.filter((x) => showArchived || !x.archived);
+
+ return (
+ open()}> {t('admin.expenseSettings.addTemplate')}}
+ >
+
+ setShowArchived(e.target.checked)} className="accent-primary-yellow" />
+ {t('admin.expenseSettings.showArchived')}
+
+ {visible.length === 0 ? {t('admin.expenseSettings.empty')}
: (
+
+ {visible.map((tpl) => (
+
+
+
{tpl.name}
+
{catName(tpl.categoryId)} · {templateSummary(tpl, t, locale)}
+
+ {tpl.archived && {t('admin.expenseSettings.archived')} }
+ open(tpl)} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}>
+ archive(tpl)} className="p-2 rounded-btn hover:bg-gray-100" title={t(tpl.archived ? 'admin.expenseSettings.unarchive' : 'admin.expenseSettings.archive')}>
+ {tpl.archived ? : }
+
+ remove(tpl)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}>
+
+ ))}
+
+ )}
+ setForm(null)} title={t('admin.expenseSettings.templates')} wide>
+ {form && (
+
+
+
+ setName(e.target.value)} />
+
+
+
+ {form.calcType === 'percent_of_revenue' ? (
+
+ set('percent', e.target.value)} />
+ %
+
+ ) : form.calcType === 'minimum_spend' ? (
+ set('minimumAmount', n)} />
+ ) : (
+ set('amount', n)} />
+ )}
+
+
+ set('calcType', e.target.value as CalcType)}>
+ {CALC_TYPES.map((c) => {t(`admin.finance.calcTypes.${c}`)} )}
+
+
+ {form.calcType === 'minimum_spend' && (
+
+ set('amount', n)} />
+
+ )}
+
+ { setCategoryAuto(false); set('categoryId', e.target.value); }}>
+ {t('admin.finance.charts.uncategorized')}
+ {categories.filter((c) => !c.archived || c.id === form.categoryId).map((c) => {locale === 'es' ? c.nameEs : c.nameEn} )}
+
+
+
+
+
+
+
+ )}
+
+ setForm(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}
+ {t('common.save')}
+
+
+
+ );
+}
+
+// ==================== Packs ====================
+
+function Packs({ packs, templates, reload, t, locale }: { packs: ExpenseTemplatePack[]; templates: ExpenseTemplate[]; reload: () => void; t: T; locale: string }) {
+ const [form, setForm] = useState<{ id?: string; name: string; description: string; templateIds: string[] } | null>(null);
+ const [saving, setSaving] = useState(false);
+ const name = (id: string) => templates.find((x) => x.id === id)?.name || '-';
+
+ const save = async () => {
+ if (!form?.name.trim()) return;
+ setSaving(true);
+ const body = { name: form.name.trim(), description: form.description.trim() || null, templateIds: form.templateIds };
+ try {
+ if (form.id) await financeApi.updatePack(form.id, body); else await financeApi.createPack(body);
+ toast.success(t('admin.expenseSettings.saved'));
+ setForm(null);
+ reload();
+ } catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
+ };
+ const remove = (p: ExpenseTemplatePack) => {
+ if (!confirm(t('admin.expenseSettings.confirmDelete', { name: p.name }))) return;
+ financeApi.deletePack(p.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
+ };
+ const toggle = (id: string) => setForm((f) => f && ({ ...f, templateIds: f.templateIds.includes(id) ? f.templateIds.filter((x) => x !== id) : [...f.templateIds, id] }));
+
+ return (
+ setForm({ name: '', description: '', templateIds: [] })}> {t('admin.expenseSettings.addPack')}}
+ >
+ {packs.length === 0 ? {t('admin.expenseSettings.empty')}
: (
+
+ {packs.map((p) => (
+
+
+
{p.name}
+
{p.templateIds.map(name).join(' · ')}
+
+ setForm({ id: p.id, name: p.name, description: p.description || '', templateIds: p.templateIds })} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}>
+ remove(p)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}>
+
+ ))}
+
+ )}
+ setForm(null)} title={t('admin.expenseSettings.packs')}>
+ {form && (
+ <>
+
+ setForm({ ...form, name: e.target.value })} />
+
+
+ setForm({ ...form, description: e.target.value })} />
+
+
+
+
+ >
+ )}
+
+ setForm(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}
+ {t('common.save')}
+
+
+
+ );
+}
+
+// ==================== Fees ====================
+
+function FeeRow({ fee, t, onSaved }: { fee: PaymentMethodFee; t: T; onSaved: () => void }) {
+ const [percent, setPercent] = useState(fee.percentBp ? bpToPercent(fee.percentBp) : '');
+ const [fixed, setFixed] = useState(fee.fixedAmount);
+ const [saving, setSaving] = useState(false);
+ const dirty = percentToBp(percent) !== fee.percentBp || fixed !== fee.fixedAmount;
+ const save = async () => {
+ setSaving(true);
+ try {
+ await financeApi.updateFee(fee.method, { percentBp: percentToBp(percent), fixedAmount: fixed });
+ toast.success(t('admin.expenseSettings.saved'));
+ onSaved();
+ } catch (error: any) { toast.error(error.message); } finally { setSaving(false); }
+ };
+ return (
+
+ {t(`admin.finance.methods.${fee.method}`)}
+
+ setPercent(e.target.value)} />
+
+
+
+
+ {t('common.save')}
+
+ );
+}
+
+// ==================== Page section ====================
+
+export default function ExpenseTemplatesSettings() {
+ const { t, locale } = useLanguage();
+ const [categories, setCategories] = useState([]);
+ const [templates, setTemplates] = useState([]);
+ const [packs, setPacks] = useState([]);
+ const [fees, setFees] = useState([]);
+
+ const reload = useCallback(() => {
+ Promise.all([financeApi.getCategories(), financeApi.getTemplates(), financeApi.getPacks(), financeApi.getFees()])
+ .then(([c, tpl, p, f]) => { setCategories(c.categories); setTemplates(tpl.templates); setPacks(p.packs); setFees(f.fees); })
+ .catch((error) => toast.error(error.message));
+ }, []);
+ useEffect(() => { reload(); }, [reload]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx
index 964c6bd..01dfe97 100644
--- a/frontend/src/app/admin/settings/page.tsx
+++ b/frontend/src/app/admin/settings/page.tsx
@@ -19,10 +19,12 @@ import {
CheckCircleIcon,
StarIcon,
ScaleIcon,
+ BanknotesIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
+import ExpenseTemplatesSettings from './_components/ExpenseTemplatesSettings';
-type SettingsTab = 'general' | 'legal';
+type SettingsTab = 'general' | 'legal' | 'expenses';
export default function AdminSettingsPage() {
const { t, locale } = useLanguage();
@@ -195,6 +197,12 @@ export default function AdminSettingsPage() {
labelEs: 'Configuración Legal',
icon: ,
},
+ {
+ id: 'expenses',
+ label: t('admin.expenseSettings.tab'),
+ labelEs: t('admin.expenseSettings.tab'),
+ icon: ,
+ },
];
return (
@@ -226,8 +234,8 @@ export default function AdminSettingsPage() {
{/* Tabs */}
-
-
+
+
{tabs.map((tab) => (
)}
+ {/* Expense Templates Tab (rows save individually, no page-level Save) */}
+ {activeTab === 'expenses' && }
+
{/* Legal Settings Tab */}
{activeTab === 'legal' && (
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index b982f97..f94c9df 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -91,12 +91,31 @@
"eventEnded": "Event Ended",
"registrationClosed": "Registration Closed",
"presaleClosesBefore": "Pre-sale registration closes {duration} before the event begins.",
- "presaleClosesAtStart": "Pre-sale registration closes when the event begins."
+ "presaleClosesAtStart": "Pre-sale registration closes when the event begins.",
+ "eventHasEnded": "This event has ended"
},
"booking": {
"join": "Join Event",
"book": "Book Now",
"register": "Register"
+ },
+ "door": {
+ "onlineClosed": "Online sales closed",
+ "headline": "Tickets available at the door",
+ "perPerson": "per person, pay at the door",
+ "onlinePriceWas": "Online price was {price}",
+ "spotsLeft": "{n} spots left",
+ "spotsLeftOne": "1 spot left",
+ "spotsLeftAtDoor": "{n} spots left (at the door)",
+ "accepts": "Accepted at the door: {methods}",
+ "doorsOpen": "Doors open at {time}",
+ "badge": "Tickets at the door",
+ "methods": {
+ "cash": "cash",
+ "pos": "card",
+ "bitcoin": "Lightning",
+ "transfer": "bank transfer"
+ }
}
},
"booking": {
@@ -174,6 +193,13 @@
"receivedConfirmShortly": "Payment received. We will confirm it shortly.",
"receivedConfirmSoon": "Payment received. We will confirm it shortly.",
"confirmForEvent": "We will confirm your {amount} payment for {event} shortly. Nothing else needed."
+ },
+ "myEvents": {
+ "tab": "My Events",
+ "empty": "You are not on any event team yet.",
+ "open": "Open",
+ "back": "Back to my events",
+ "noAccess": "You do not have access to this event."
}
},
"community": {
@@ -286,7 +312,8 @@
"emails": "Emails",
"gallery": "Gallery",
"photos": "Photos",
- "settings": "Settings"
+ "settings": "Settings",
+ "finance": "Finance"
},
"events": {
"title": "Manage Events",
@@ -294,7 +321,13 @@
"edit": "Edit Event",
"delete": "Delete Event",
"publish": "Publish",
- "unpublish": "Unpublish"
+ "unpublish": "Unpublish",
+ "form": {
+ "walkInPrice": "Walk-in price",
+ "walkInPricePlaceholder": "Same as ticket price ({price})",
+ "walkInPriceHelp": "Charged at the door via the Scanner. Not shown on the public event page.",
+ "walkInPriceInvalid": "Walk-in price must be empty or a whole number of 0 or more"
+ }
},
"tickets": {
"title": "Manage Tickets",
@@ -328,6 +361,481 @@
"refunded": "Refunded",
"failed": "Failed"
}
+ },
+ "eventTabs": {
+ "overview": "Overview",
+ "attendees": "Attendees",
+ "tickets": "Tickets",
+ "email": "Email",
+ "payments": "Payments",
+ "finance": "Finance",
+ "team": "Team"
+ },
+ "finance": {
+ "subnav": {
+ "summary": "Summary",
+ "expenses": "Costs & income",
+ "split": "Split & Payouts"
+ },
+ "status": {
+ "open": "Open",
+ "finalized": "Finalized",
+ "paid_out": "Paid out"
+ },
+ "frozenNote": "Numbers frozen at finalize on {date}. Unfinalize to make changes.",
+ "loadError": "Could not load finance data",
+ "hiddenStats": "Finance numbers are hidden. Use Show Stats to reveal them.",
+ "kpi": {
+ "gross": "Gross revenue",
+ "fees": "Payment fees",
+ "expenses": "Expenses",
+ "profit": "Profit",
+ "loss": "Loss",
+ "breakEven": "Break-even",
+ "breakEvenNever": "Not reachable at this price",
+ "presaleDoor": "Pre-sale {presale} · Door {door}",
+ "ticketsSold": "{count} tickets sold",
+ "ticketRevenue": "Ticket revenue",
+ "revenueDetail": "{count} paid tickets, after refunds",
+ "afterFees": "After fees and all expenses",
+ "breakEvenProgress": "{sold} of {total}",
+ "beyondCapacity": "Needs more tickets than the capacity ({capacity}). Raise the price or cut costs.",
+ "estimated": "Estimated",
+ "estimatedHint": "After fees and all costs so far",
+ "presale": "Pre-sale",
+ "door": "Door"
+ },
+ "waterfall": {
+ "title": "From revenue to result",
+ "gross": "Gross revenue",
+ "refunds": "Refunds",
+ "fees": "Payment fees",
+ "otherIncome": "Other income",
+ "net": "Net revenue",
+ "expenses": "Expenses",
+ "profit": "Profit",
+ "organization": "Organization keeps",
+ "partners": "Partner payouts",
+ "legend": {
+ "in": "Money in",
+ "out": "Money out",
+ "total": "Total"
+ },
+ "estimatedHint": "Estimated: numbers follow sales and costs until the books are closed."
+ },
+ "charts": {
+ "byCategory": "Expenses by category",
+ "byMethod": "Revenue by payment method",
+ "cumulative": "Cumulative sales",
+ "breakEvenLine": "Break-even",
+ "revenue": "Revenue",
+ "plannedVsActual": "Planned vs paid expenses",
+ "planned": "Planned",
+ "paid": "Paid",
+ "empty": "No data yet",
+ "uncategorized": "Uncategorized",
+ "breakEvenOffChart": "Break-even is {count} tickets, far above sales so far.",
+ "tickets": "Tickets",
+ "addExpensesHint": "Add expenses to see the cost breakdown."
+ },
+ "methods": {
+ "tpago": "TPago",
+ "bank_transfer": "Bank transfer",
+ "lightning": "Lightning",
+ "cash": "Cash",
+ "pos": "POS",
+ "bancard": "Bancard"
+ },
+ "otherIncome": {
+ "title": "Other income",
+ "add": "Add income",
+ "hint": "Sponsors, venue kickbacks and other money in that is not a ticket.",
+ "empty": "No other income",
+ "confirmDelete": "Delete \"{name}\"?",
+ "placeholder": "e.g. Sponsor: Café Uno",
+ "edit": "Edit income"
+ },
+ "expenses": {
+ "add": "Add expense",
+ "applyTemplate": "Apply template",
+ "applyPack": "Apply pack",
+ "allCategories": "All categories",
+ "empty": "Add costs like the venue, instructor or snacks, or add your usual costs from a template.",
+ "description": "Description",
+ "category": "Category",
+ "calcType": "Calculated as",
+ "amount": "Amount",
+ "lockedAmount": "Locked amount (PYG)",
+ "status": "Status",
+ "paidBy": "Paid by",
+ "organization": "Organization",
+ "otherPartner": "Another partner",
+ "receipt": "Receipt",
+ "uploadReceipt": "Upload receipt",
+ "viewReceipt": "View receipt",
+ "receiptAdminOnly": "Receipt uploads are available to admins and organizers.",
+ "date": "Date",
+ "lock": "Lock amount",
+ "locked": "Locked",
+ "auto": "Auto",
+ "lockHint": "Keep this amount fixed instead of following ticket sales.",
+ "markPaid": "Mark as paid",
+ "markPlanned": "Mark as not paid yet",
+ "confirmDelete": "Delete this expense?",
+ "created": "Expense added",
+ "saved": "Expense saved",
+ "deleted": "Expense deleted",
+ "applied": "{count} expense(s) added",
+ "edit": "Edit expense",
+ "new": "New expense",
+ "pickTemplate": "Choose a template",
+ "pickPack": "Choose a pack",
+ "noTemplates": "No templates yet. Create them in Settings → Expense Templates.",
+ "ownOnly": "You can edit the expenses you added.",
+ "total": "Total",
+ "descriptionRequired": "Add a short description",
+ "descriptionPlaceholder": "e.g. Studio rental",
+ "paidByHint": "Pick a partner if they paid it themselves; they get it back in their payout.",
+ "upload": "Upload",
+ "useTemplate": "Use a template",
+ "useTemplateHint": "Add your usual costs in one tap",
+ "packsHeading": "Packs (several costs at once)",
+ "templatesHeading": "Single costs",
+ "addAll": "Add {count}",
+ "addOne": "Add",
+ "confirmDeleteNamed": "Delete \"{name}\"?",
+ "emptyTitle": "No expenses yet",
+ "unlockHint": "Locked. Unlock to follow ticket sales again.",
+ "frozen": "Finalized: expenses can't be changed.",
+ "filtered": "filtered",
+ "amountPerTicket": "Amount per ticket",
+ "amountPerGuest": "Amount per guest",
+ "percentOfRevenue": "Percentage of ticket revenue",
+ "minimumAmount": "Minimum amount",
+ "categorySuggested": "Suggested from the description"
+ },
+ "calcTypes": {
+ "fixed": "Fixed",
+ "per_ticket_sold": "Per ticket sold",
+ "per_checked_in": "Per checked-in guest",
+ "percent_of_revenue": "Percent of revenue",
+ "minimum_spend": "Minimum spend"
+ },
+ "calcHints": {
+ "fixed": "One set amount, e.g. instructor fee.",
+ "per_ticket_sold": "Grows with every paid ticket, e.g. a drink voucher.",
+ "per_checked_in": "Grows with every guest who shows up, e.g. mat rental.",
+ "percent_of_revenue": "A share of ticket sales, e.g. platform commission.",
+ "minimum_spend": "Venue minimum: the higher of a minimum or per-guest spend."
+ },
+ "expenseStatus": {
+ "planned": "Planned",
+ "paid": "Paid"
+ },
+ "split": {
+ "partners": "Partners",
+ "addPartner": "Add partner",
+ "editPartner": "Edit partner",
+ "empty": "No partners: the organization keeps the whole result.",
+ "linkedUser": "Linked user",
+ "searchUser": "Search users by name or email",
+ "externalName": "Or type a name",
+ "roleLabel": "Role",
+ "shareType": "Deal",
+ "percent": "Percent (%)",
+ "fixedAmount": "Fixed amount (PYG)",
+ "threshold": "Profit threshold (PYG)",
+ "lossRule": "If there is a loss",
+ "lossCap": "Loss cap (PYG)",
+ "share": "Share",
+ "reimbursement": "Reimbursements",
+ "payout": "Payout",
+ "owes": "Owes",
+ "organization": "Organization keeps",
+ "distributable": "Left after fixed & revenue deals",
+ "markPaid": "Mark as paid",
+ "markUnpaid": "Undo payout",
+ "payoutMethod": "Payout method",
+ "payoutNote": "Note",
+ "paidOn": "Paid {date}",
+ "pending": "Pending",
+ "statement": "Statement PDF",
+ "finalize": "Finalize",
+ "unfinalize": "Unfinalize",
+ "unfinalizeConfirm": "Unfinalize? Numbers will follow live sales again. This is logged.",
+ "finalized": "Event finalized",
+ "unfinalized": "Event reopened",
+ "finalizeFirst": "Payouts can be recorded after you close the books.",
+ "ownShareOnly": "You see your own share. The full split is visible to managers.",
+ "partnerSaved": "Partner saved",
+ "partnerRemoved": "Partner removed",
+ "confirmRemove": "Remove this partner?",
+ "needsNameOrUser": "Link a user or type a name",
+ "auditLog": "Activity log",
+ "auditEmpty": "No activity yet",
+ "who": "Who",
+ "whoHint": "Link a user so they can log in and see their statement, or just type a name.",
+ "rolePlaceholder": "e.g. Venue, Co-host, Instructor",
+ "payoutMethodPlaceholder": "e.g. Bank transfer",
+ "confirmRemoveNamed": "Remove {name} from the split?",
+ "distributableHint": "Profit shares are taken from this",
+ "closeBooks": "Close the books",
+ "booksClosed": "Books closed",
+ "closeBooksHint": "Once all costs are in, finalize to freeze the numbers and pay partners. You can reopen later.",
+ "payoutsProgress": "{paid} of {total} partner payouts recorded.",
+ "noPartnersTitle": "No partners on this event",
+ "noPartnersBody": "Add a venue, co-host or instructor who shares in the result. Their payout is calculated for you.",
+ "calculation": "How the result is split",
+ "ofDistributable": "{rule}, taken from {base} left after fixed and revenue deals",
+ "unpaid": "Unpaid",
+ "includesReimbursement": "Incl. {amount} reimbursed",
+ "frozenNoPartners": "Numbers are frozen. There are no partners to pay."
+ },
+ "shareTypes": {
+ "percent_profit": "Percent of profit",
+ "percent_revenue": "Percent of revenue",
+ "fixed": "Fixed amount",
+ "fixed_plus_percent_above_threshold": "Fixed + percent above threshold"
+ },
+ "lossRules": {
+ "proportional": "Shares the loss proportionally",
+ "none": "Does not share losses",
+ "capped": "Shares the loss up to a cap"
+ },
+ "empty": {
+ "title": "No money in or out yet",
+ "body": "Ticket sales appear here automatically. Add the event's costs to see profit and break-even."
+ },
+ "statement": {
+ "title": "Profit & loss",
+ "ticketSales": "Ticket sales ({count})",
+ "carriesLoss": "Carries part of the loss"
+ },
+ "formula": {
+ "perTicket": "{count} tickets × {amount}",
+ "perGuest": "{count} guests × {amount}",
+ "percent": "{percent} of ticket revenue",
+ "minimum": "min {minimum} or {count} × {amount}"
+ },
+ "shareHints": {
+ "percent_profit": "A percentage of what's left after costs.",
+ "percent_revenue": "A percentage of ticket sales, paid even if the event loses money.",
+ "fixed": "A set amount, whatever the result.",
+ "fixed_plus_percent_above_threshold": "A set amount plus a bonus percentage of profit above a target."
+ },
+ "lossRuleTitles": {
+ "none": "Org covers losses",
+ "proportional": "Shares losses",
+ "capped": "Shares up to a cap"
+ },
+ "deal": {
+ "percentProfit": "{percent} of profit",
+ "percentRevenue": "{percent} of ticket sales",
+ "fixed": "Fixed {amount}",
+ "fixedPlus": "{amount} + {percent} of profit above {threshold}"
+ },
+ "audit": {
+ "create": "added",
+ "update": "changed",
+ "delete": "deleted",
+ "apply_template": "added from template",
+ "apply_pack": "added from pack",
+ "mark_paid": "payout recorded",
+ "mark_unpaid": "payout undone",
+ "finalize": "books closed",
+ "unfinalize": "books reopened",
+ "paid_out": "all partners paid",
+ "finalized": "back to finalized",
+ "entity": {
+ "expense": "Expense",
+ "other_income": "Other income",
+ "partner": "Partner",
+ "finance_state": "Finance",
+ "member": "Team"
+ }
+ },
+ "close": "Close",
+ "pickDate": "Pick a date",
+ "clearDate": "Clear date",
+ "lifecycle": {
+ "label": "Books status",
+ "steps": {
+ "selling": "Selling",
+ "costs": "Adding costs",
+ "ready": "Ready to close",
+ "finalized": "Finalized"
+ },
+ "todo": {
+ "noCosts": "No costs added yet",
+ "planned_one": "1 planned expense not marked paid",
+ "planned_other": "{count} planned expenses not marked paid",
+ "noPartners": "No partners added",
+ "payoutsPending_one": "1 partner payout to record",
+ "payoutsPending_other": "{count} partner payouts to record",
+ "notEnded": "The event has not ended yet"
+ },
+ "nudge_one": "This event ended 1 day ago. Add any remaining costs and close the books.",
+ "nudge_other": "This event ended {count} days ago. Add any remaining costs and close the books.",
+ "nudgeToday": "This event ended today. Add any remaining costs and close the books.",
+ "reviewCosts": "Review costs",
+ "frozen": "Numbers frozen on {date}.",
+ "paidOut": "Numbers frozen on {date}. Every partner has been paid.",
+ "nudgeReady_one": "This event ended 1 day ago and every cost is marked paid. You can close the books in Split & Payouts.",
+ "nudgeReady_other": "This event ended {count} days ago and every cost is marked paid. You can close the books in Split & Payouts.",
+ "nudgeReadyToday": "This event ended today and every cost is marked paid. You can close the books in Split & Payouts."
+ },
+ "finalizeDialog": {
+ "title": "Close the books",
+ "subtitle": "Check everything is in before freezing the numbers.",
+ "notEnded": "The event has not ended yet",
+ "noExpenses": "No expenses recorded",
+ "expenses_one": "1 expense recorded",
+ "expenses_other": "{count} expenses recorded",
+ "planned_one": "1 planned expense not marked paid",
+ "planned_other": "{count} planned expenses not marked paid",
+ "allPaid": "All expenses marked paid",
+ "income_one": "1 other income entry",
+ "income_other": "{count} other income entries",
+ "partners_one": "Payout to 1 partner",
+ "partners_other": "Payouts to {count} partners",
+ "noPartners": "No partners: the organization keeps the result",
+ "finalProfit": "Final profit",
+ "finalLoss": "Final loss",
+ "hint": "Numbers stop following sales and costs. You can record partner payouts afterwards, and an admin can reopen the books if needed.",
+ "confirm": "Finalize and freeze numbers"
+ },
+ "breakEven": {
+ "noCosts": "No costs yet",
+ "noCostsHint": "Add costs to see how many tickets cover them.",
+ "reached": "Break-even reached at {at} of {sold} tickets sold",
+ "needed": "{count} more tickets to break even",
+ "coveredWithoutTickets": "Costs already covered by other income"
+ },
+ "preview": {
+ "fixed": "Adds {total} to this event's costs",
+ "perTicket_one": "{amount} × {count} ticket = {total}",
+ "perTicket_other": "{amount} × {count} tickets = {total}",
+ "perGuest_one": "{amount} × {count} checked-in guest = {total}",
+ "perGuest_other": "{amount} × {count} checked-in guests = {total}",
+ "percent": "{percent} of {sales} ticket revenue = {total}",
+ "minimum": "Higher of {minimum} or {count} × {amount} = {total}",
+ "locked": "Locked at {total}"
+ },
+ "ledger": {
+ "income": "Income",
+ "costs": "Costs",
+ "allRows": "All costs and income",
+ "emptyTitle": "No costs or income yet"
+ }
+ },
+ "team": {
+ "title": "Team",
+ "hint": "People added here get access to this event only, with the permissions below. Admins always have full access.",
+ "add": "Add member",
+ "search": "Search users by name or email",
+ "noResults": "No users found",
+ "empty": "No team members yet",
+ "role": "Role",
+ "remove": "Remove",
+ "confirmRemove": "Remove {name} from this event?",
+ "added": "Member added",
+ "saved": "Permissions saved",
+ "removed": "Member removed",
+ "override": "Changed from the role default",
+ "reset": "Reset to role defaults",
+ "permissions": "Permissions",
+ "permissionsSummary": "{on} of {total} on"
+ },
+ "rolePresets": {
+ "staff": "Staff",
+ "collaborator": "Collaborator",
+ "co_manager": "Co-manager"
+ },
+ "permissions": {
+ "view_overview": "View overview",
+ "check_in": "Check in guests",
+ "view_attendees_names": "See attendee names",
+ "view_attendees_pii": "See attendee contact details",
+ "email_attendees": "Email attendees",
+ "view_payments": "View payments",
+ "view_finance": "View finance",
+ "edit_expenses": "Edit expenses",
+ "edit_own_expenses_only": "Add and edit own expenses",
+ "view_full_split": "See the full split",
+ "edit_event": "Edit event",
+ "manage_team": "Manage team"
+ },
+ "expenseSettings": {
+ "tab": "Expense Templates",
+ "categories": "Categories",
+ "templates": "Templates",
+ "packs": "Template packs",
+ "fees": "Payment method fees",
+ "feesHint": "Charged per paid payment when computing an event's net revenue.",
+ "addCategory": "Add category",
+ "addTemplate": "Add template",
+ "addPack": "Add pack",
+ "nameEn": "Name (English)",
+ "nameEs": "Name (Spanish)",
+ "color": "Color",
+ "name": "Name",
+ "description": "Description",
+ "archived": "Archived",
+ "archive": "Archive",
+ "unarchive": "Restore",
+ "showArchived": "Show archived",
+ "packTemplates": "Templates in this pack",
+ "percent": "Percent (%)",
+ "fixed": "Fixed (PYG)",
+ "saved": "Saved",
+ "deleted": "Deleted",
+ "inUse": "In use. Archive it instead.",
+ "confirmDelete": "Delete {name}?",
+ "empty": "Nothing here yet"
+ },
+ "financeOverview": {
+ "title": "Finance",
+ "subtitle": "Profit across events. Finalized events use their frozen numbers.",
+ "from": "From",
+ "to": "To",
+ "series": "Series",
+ "venue": "Venue",
+ "partner": "Partner",
+ "all": "All",
+ "noSeries": "No series",
+ "events": "Events",
+ "event": "Event",
+ "date": "Date",
+ "perEvent": "Profit per event",
+ "perSeries": "By series",
+ "perVenue": "By venue",
+ "perPartner": "Partner totals",
+ "paid": "Paid",
+ "pending": "Pending",
+ "empty": "No events with finance data in this range",
+ "filters": "Filters",
+ "readyCount_one": "1 past event",
+ "readyCount_other": "{count} past events",
+ "readyHint": "still have open books. Add any last costs and close them.",
+ "showReady": "Show ready to close",
+ "showAll": "Show all events",
+ "readyTitle": "Ready to close",
+ "readyBadge": "Ready to close",
+ "clippedHint": "Bars with a break mark are cut off to keep the others readable. The label shows the real value."
+ },
+ "eventForm": {
+ "series": "Series",
+ "seriesHint": "Groups recurring events (e.g. Morning Club) in the Finance overview."
+ },
+ "eventStats": {
+ "capacity": "Capacity",
+ "notCheckedIn": "Not checked in yet",
+ "checkedIn": "Checked in",
+ "revenue": "Revenue",
+ "presaleDoor": "Pre-sale {presale} · Door {door}",
+ "stats": "Stats",
+ "inShort": "{count} in",
+ "free": "Free"
}
},
"footer": {
@@ -386,4 +894,4 @@
"downloading": "Downloading…"
}
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json
index 5771049..8bf4350 100644
--- a/frontend/src/i18n/locales/es.json
+++ b/frontend/src/i18n/locales/es.json
@@ -91,12 +91,31 @@
"eventEnded": "Evento Finalizado",
"registrationClosed": "Inscripciones Cerradas",
"presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.",
- "presaleClosesAtStart": "La preventa cierra cuando comienza el evento."
+ "presaleClosesAtStart": "La preventa cierra cuando comienza el evento.",
+ "eventHasEnded": "Este evento ya terminó"
},
"booking": {
"join": "Unirse al Evento",
"book": "Reservar Ahora",
"register": "Registrarse"
+ },
+ "door": {
+ "onlineClosed": "Venta online cerrada",
+ "headline": "Entradas disponibles en la puerta",
+ "perPerson": "por persona, pago en la puerta",
+ "onlinePriceWas": "El precio online era {price}",
+ "spotsLeft": "{n} lugares disponibles",
+ "spotsLeftOne": "1 lugar disponible",
+ "spotsLeftAtDoor": "{n} lugares disponibles (en la puerta)",
+ "accepts": "Aceptamos en la puerta: {methods}",
+ "doorsOpen": "Las puertas abren a las {time}",
+ "badge": "Entradas en la puerta",
+ "methods": {
+ "cash": "efectivo",
+ "pos": "tarjeta",
+ "bitcoin": "Lightning",
+ "transfer": "transferencia"
+ }
}
},
"booking": {
@@ -174,6 +193,13 @@
"receivedConfirmShortly": "Pago recibido. Lo confirmaremos en breve.",
"receivedConfirmSoon": "Pago recibido. Lo confirmaremos pronto.",
"confirmForEvent": "Confirmaremos tu pago de {amount} para {event} en breve. No necesitas hacer nada más."
+ },
+ "myEvents": {
+ "tab": "Mis eventos",
+ "empty": "Todavía no formás parte del equipo de ningún evento.",
+ "open": "Abrir",
+ "back": "Volver a mis eventos",
+ "noAccess": "No tenés acceso a este evento."
}
},
"community": {
@@ -286,7 +312,8 @@
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
- "settings": "Configuración"
+ "settings": "Configuración",
+ "finance": "Finanzas"
},
"events": {
"title": "Gestionar Eventos",
@@ -294,7 +321,13 @@
"edit": "Editar Evento",
"delete": "Eliminar Evento",
"publish": "Publicar",
- "unpublish": "Despublicar"
+ "unpublish": "Despublicar",
+ "form": {
+ "walkInPrice": "Precio en puerta",
+ "walkInPricePlaceholder": "Igual al precio de la entrada ({price})",
+ "walkInPriceHelp": "Se cobra en la puerta desde el Escáner. No se muestra en la página pública del evento.",
+ "walkInPriceInvalid": "El precio en puerta debe quedar vacío o ser un número entero de 0 o más"
+ }
},
"tickets": {
"title": "Gestionar Tickets",
@@ -328,6 +361,481 @@
"refunded": "Reembolsado",
"failed": "Fallido"
}
+ },
+ "eventTabs": {
+ "overview": "Resumen",
+ "attendees": "Asistentes",
+ "tickets": "Entradas",
+ "email": "Email",
+ "payments": "Pagos",
+ "finance": "Finanzas",
+ "team": "Equipo"
+ },
+ "finance": {
+ "subnav": {
+ "summary": "Resumen",
+ "expenses": "Gastos e ingresos",
+ "split": "Reparto y pagos"
+ },
+ "status": {
+ "open": "Abierto",
+ "finalized": "Cerrado",
+ "paid_out": "Pagado"
+ },
+ "frozenNote": "Números congelados al cerrar el {date}. Reabrí para hacer cambios.",
+ "loadError": "No se pudieron cargar las finanzas",
+ "hiddenStats": "Los números están ocultos. Usá Mostrar estadísticas para verlos.",
+ "kpi": {
+ "gross": "Ingresos brutos",
+ "fees": "Comisiones",
+ "expenses": "Gastos",
+ "profit": "Ganancia",
+ "loss": "Pérdida",
+ "breakEven": "Punto de equilibrio",
+ "breakEvenNever": "No alcanzable a este precio",
+ "presaleDoor": "Preventa {presale} · Puerta {door}",
+ "ticketsSold": "{count} entradas vendidas",
+ "ticketRevenue": "Ingresos por entradas",
+ "revenueDetail": "{count} entradas pagas, sin reembolsos",
+ "afterFees": "Después de comisiones y gastos",
+ "breakEvenProgress": "{sold} de {total}",
+ "beyondCapacity": "Necesita más entradas que la capacidad ({capacity}). Subí el precio o bajá gastos.",
+ "estimated": "Estimado",
+ "estimatedHint": "Después de comisiones y los gastos cargados",
+ "presale": "Preventa",
+ "door": "Puerta"
+ },
+ "waterfall": {
+ "title": "De los ingresos al resultado",
+ "gross": "Ingresos brutos",
+ "refunds": "Reembolsos",
+ "fees": "Comisiones de pago",
+ "otherIncome": "Otros ingresos",
+ "net": "Ingreso neto",
+ "expenses": "Gastos",
+ "profit": "Ganancia",
+ "organization": "Queda para la organización",
+ "partners": "Pagos a socios",
+ "legend": {
+ "in": "Entra",
+ "out": "Sale",
+ "total": "Total"
+ },
+ "estimatedHint": "Estimado: los números siguen las ventas y los gastos hasta cerrar las cuentas."
+ },
+ "charts": {
+ "byCategory": "Gastos por categoría",
+ "byMethod": "Ingresos por método de pago",
+ "cumulative": "Ventas acumuladas",
+ "breakEvenLine": "Equilibrio",
+ "revenue": "Ingresos",
+ "plannedVsActual": "Gastos previstos vs pagados",
+ "planned": "Previsto",
+ "paid": "Pagado",
+ "empty": "Sin datos todavía",
+ "uncategorized": "Sin categoría",
+ "breakEvenOffChart": "El equilibrio es {count} entradas, muy por encima de lo vendido.",
+ "tickets": "Entradas",
+ "addExpensesHint": "Agregá gastos para ver el desglose de costos."
+ },
+ "methods": {
+ "tpago": "TPago",
+ "bank_transfer": "Transferencia",
+ "lightning": "Lightning",
+ "cash": "Efectivo",
+ "pos": "POS",
+ "bancard": "Bancard"
+ },
+ "otherIncome": {
+ "title": "Otros ingresos",
+ "add": "Agregar ingreso",
+ "hint": "Sponsors, comisiones del local y otros ingresos que no son entradas.",
+ "empty": "Sin otros ingresos",
+ "confirmDelete": "¿Eliminar \"{name}\"?",
+ "placeholder": "ej. Sponsor: Café Uno",
+ "edit": "Editar ingreso"
+ },
+ "expenses": {
+ "add": "Agregar gasto",
+ "applyTemplate": "Aplicar plantilla",
+ "applyPack": "Aplicar paquete",
+ "allCategories": "Todas las categorías",
+ "empty": "Agregá gastos como el lugar, el instructor o snacks, o tus gastos habituales desde una plantilla.",
+ "description": "Descripción",
+ "category": "Categoría",
+ "calcType": "Se calcula",
+ "amount": "Monto",
+ "lockedAmount": "Monto fijado (PYG)",
+ "status": "Estado",
+ "paidBy": "Pagado por",
+ "organization": "Organización",
+ "otherPartner": "Otro socio",
+ "receipt": "Comprobante",
+ "uploadReceipt": "Subir comprobante",
+ "viewReceipt": "Ver comprobante",
+ "receiptAdminOnly": "La carga de comprobantes está disponible para admins y organizadores.",
+ "date": "Fecha",
+ "lock": "Fijar monto",
+ "locked": "Fijado",
+ "auto": "Auto",
+ "lockHint": "Mantené este monto fijo en vez de seguir las ventas.",
+ "markPaid": "Marcar como pagado",
+ "markPlanned": "Marcar como no pagado",
+ "confirmDelete": "¿Eliminar este gasto?",
+ "created": "Gasto agregado",
+ "saved": "Gasto guardado",
+ "deleted": "Gasto eliminado",
+ "applied": "{count} gasto(s) agregado(s)",
+ "edit": "Editar gasto",
+ "new": "Nuevo gasto",
+ "pickTemplate": "Elegí una plantilla",
+ "pickPack": "Elegí un paquete",
+ "noTemplates": "Todavía no hay plantillas. Crealas en Configuración → Plantillas de gastos.",
+ "ownOnly": "Podés editar los gastos que agregaste.",
+ "total": "Total",
+ "descriptionRequired": "Agregá una descripción",
+ "descriptionPlaceholder": "ej. Alquiler del estudio",
+ "paidByHint": "Elegí un socio si lo pagó de su bolsillo; se le devuelve en su pago.",
+ "upload": "Subir",
+ "useTemplate": "Usar plantilla",
+ "useTemplateHint": "Agregá tus gastos habituales de una vez",
+ "packsHeading": "Paquetes (varios gastos juntos)",
+ "templatesHeading": "Gastos individuales",
+ "addAll": "Agregar {count}",
+ "addOne": "Agregar",
+ "confirmDeleteNamed": "¿Eliminar \"{name}\"?",
+ "emptyTitle": "Todavía no hay gastos",
+ "unlockHint": "Fijado. Desbloqueá para volver a seguir las ventas.",
+ "frozen": "Cerrado: no se pueden cambiar los gastos.",
+ "filtered": "filtrado",
+ "amountPerTicket": "Monto por entrada",
+ "amountPerGuest": "Monto por asistente",
+ "percentOfRevenue": "Porcentaje de la venta de entradas",
+ "minimumAmount": "Monto mínimo",
+ "categorySuggested": "Sugerida según la descripción"
+ },
+ "calcTypes": {
+ "fixed": "Fijo",
+ "per_ticket_sold": "Por entrada vendida",
+ "per_checked_in": "Por asistente ingresado",
+ "percent_of_revenue": "Porcentaje de ingresos",
+ "minimum_spend": "Consumo mínimo"
+ },
+ "calcHints": {
+ "fixed": "Un monto fijo, ej. honorario del instructor.",
+ "per_ticket_sold": "Crece con cada entrada pagada, ej. un vale de bebida.",
+ "per_checked_in": "Crece con cada asistente que llega, ej. alquiler de mat.",
+ "percent_of_revenue": "Un porcentaje de las ventas, ej. comisión.",
+ "minimum_spend": "Consumo mínimo del local: el mayor entre el mínimo y el consumo por persona."
+ },
+ "expenseStatus": {
+ "planned": "Previsto",
+ "paid": "Pagado"
+ },
+ "split": {
+ "partners": "Socios",
+ "addPartner": "Agregar socio",
+ "editPartner": "Editar socio",
+ "empty": "Sin socios: la organización se queda con todo.",
+ "linkedUser": "Usuario vinculado",
+ "searchUser": "Buscar usuarios por nombre o email",
+ "externalName": "O escribí un nombre",
+ "roleLabel": "Rol",
+ "shareType": "Acuerdo",
+ "percent": "Porcentaje (%)",
+ "fixedAmount": "Monto fijo (PYG)",
+ "threshold": "Umbral de ganancia (PYG)",
+ "lossRule": "Si hay pérdida",
+ "lossCap": "Tope de pérdida (PYG)",
+ "share": "Participación",
+ "reimbursement": "Reembolsos",
+ "payout": "A pagar",
+ "owes": "Debe",
+ "organization": "Queda para la organización",
+ "distributable": "Resto tras acuerdos fijos y de ingresos",
+ "markPaid": "Marcar como pagado",
+ "markUnpaid": "Deshacer pago",
+ "payoutMethod": "Método de pago",
+ "payoutNote": "Nota",
+ "paidOn": "Pagado el {date}",
+ "pending": "Pendiente",
+ "statement": "Liquidación PDF",
+ "finalize": "Cerrar",
+ "unfinalize": "Reabrir",
+ "unfinalizeConfirm": "¿Reabrir? Los números vuelven a seguir las ventas. Queda registrado.",
+ "finalized": "Evento cerrado",
+ "unfinalized": "Evento reabierto",
+ "finalizeFirst": "Los pagos se registran después de cerrar las cuentas.",
+ "ownShareOnly": "Ves tu propia participación. El reparto completo lo ven los responsables.",
+ "partnerSaved": "Socio guardado",
+ "partnerRemoved": "Socio eliminado",
+ "confirmRemove": "¿Quitar este socio?",
+ "needsNameOrUser": "Vinculá un usuario o escribí un nombre",
+ "auditLog": "Registro de actividad",
+ "auditEmpty": "Sin actividad todavía",
+ "who": "Quién",
+ "whoHint": "Vinculá un usuario para que vea su liquidación, o escribí un nombre.",
+ "rolePlaceholder": "ej. Local, Co-anfitrión, Instructor",
+ "payoutMethodPlaceholder": "ej. Transferencia",
+ "confirmRemoveNamed": "¿Quitar a {name} del reparto?",
+ "distributableHint": "Las participaciones en la ganancia salen de acá",
+ "closeBooks": "Cerrar cuentas",
+ "booksClosed": "Cuentas cerradas",
+ "closeBooksHint": "Cuando estén todos los gastos, cerrá para congelar los números y pagar a los socios. Se puede reabrir.",
+ "payoutsProgress": "{paid} de {total} pagos a socios registrados.",
+ "noPartnersTitle": "No hay socios en este evento",
+ "noPartnersBody": "Agregá un local, co-anfitrión o instructor que participe del resultado. Su pago se calcula solo.",
+ "calculation": "Cómo se reparte el resultado",
+ "ofDistributable": "{rule}, sobre {base} que quedan tras los acuerdos fijos y de ingresos",
+ "unpaid": "Sin pagar",
+ "includesReimbursement": "Incluye {amount} de reembolso",
+ "frozenNoPartners": "Los números están congelados. No hay socios a quienes pagar."
+ },
+ "shareTypes": {
+ "percent_profit": "Porcentaje de la ganancia",
+ "percent_revenue": "Porcentaje de ingresos",
+ "fixed": "Monto fijo",
+ "fixed_plus_percent_above_threshold": "Fijo + porcentaje sobre umbral"
+ },
+ "lossRules": {
+ "proportional": "Comparte la pérdida proporcionalmente",
+ "none": "No comparte pérdidas",
+ "capped": "Comparte la pérdida hasta un tope"
+ },
+ "empty": {
+ "title": "Todavía no hay ingresos ni gastos",
+ "body": "Las ventas aparecen solas. Agregá los gastos del evento para ver la ganancia y el punto de equilibrio."
+ },
+ "statement": {
+ "title": "Resultado",
+ "ticketSales": "Venta de entradas ({count})",
+ "carriesLoss": "Asume parte de la pérdida"
+ },
+ "formula": {
+ "perTicket": "{count} entradas × {amount}",
+ "perGuest": "{count} asistentes × {amount}",
+ "percent": "{percent} de los ingresos",
+ "minimum": "mín {minimum} o {count} × {amount}"
+ },
+ "shareHints": {
+ "percent_profit": "Un porcentaje de lo que queda después de los gastos.",
+ "percent_revenue": "Un porcentaje de las ventas, se paga aunque haya pérdida.",
+ "fixed": "Un monto fijo, sea cual sea el resultado.",
+ "fixed_plus_percent_above_threshold": "Un monto fijo más un porcentaje de la ganancia sobre una meta."
+ },
+ "lossRuleTitles": {
+ "none": "La org cubre",
+ "proportional": "Comparte pérdidas",
+ "capped": "Comparte hasta un tope"
+ },
+ "deal": {
+ "percentProfit": "{percent} de la ganancia",
+ "percentRevenue": "{percent} de las ventas",
+ "fixed": "Fijo {amount}",
+ "fixedPlus": "{amount} + {percent} de la ganancia sobre {threshold}"
+ },
+ "audit": {
+ "create": "agregado",
+ "update": "modificado",
+ "delete": "eliminado",
+ "apply_template": "agregado desde plantilla",
+ "apply_pack": "agregado desde paquete",
+ "mark_paid": "pago registrado",
+ "mark_unpaid": "pago deshecho",
+ "finalize": "cuentas cerradas",
+ "unfinalize": "cuentas reabiertas",
+ "paid_out": "todos los socios cobraron",
+ "finalized": "vuelve a cerrado",
+ "entity": {
+ "expense": "Gasto",
+ "other_income": "Otro ingreso",
+ "partner": "Socio",
+ "finance_state": "Finanzas",
+ "member": "Equipo"
+ }
+ },
+ "close": "Cerrar",
+ "pickDate": "Elegí una fecha",
+ "clearDate": "Borrar fecha",
+ "lifecycle": {
+ "label": "Estado de las cuentas",
+ "steps": {
+ "selling": "Vendiendo",
+ "costs": "Cargando gastos",
+ "ready": "Listo para cerrar",
+ "finalized": "Cerrado"
+ },
+ "todo": {
+ "noCosts": "Todavía no hay gastos",
+ "planned_one": "1 gasto planificado sin marcar como pagado",
+ "planned_other": "{count} gastos planificados sin marcar como pagados",
+ "noPartners": "No hay socios",
+ "payoutsPending_one": "Falta registrar 1 pago a socio",
+ "payoutsPending_other": "Faltan registrar {count} pagos a socios",
+ "notEnded": "El evento todavía no terminó"
+ },
+ "nudge_one": "Este evento terminó hace 1 día. Agregá los gastos que falten y cerrá las cuentas.",
+ "nudge_other": "Este evento terminó hace {count} días. Agregá los gastos que falten y cerrá las cuentas.",
+ "nudgeToday": "Este evento terminó hoy. Agregá los gastos que falten y cerrá las cuentas.",
+ "reviewCosts": "Revisar gastos",
+ "frozen": "Números congelados el {date}.",
+ "paidOut": "Números congelados el {date}. Todos los socios cobraron.",
+ "nudgeReady_one": "Este evento terminó hace 1 día y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
+ "nudgeReady_other": "Este evento terminó hace {count} días y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
+ "nudgeReadyToday": "Este evento terminó hoy y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos."
+ },
+ "finalizeDialog": {
+ "title": "Cerrar cuentas",
+ "subtitle": "Revisá que esté todo antes de congelar los números.",
+ "notEnded": "El evento todavía no terminó",
+ "noExpenses": "No hay gastos cargados",
+ "expenses_one": "1 gasto cargado",
+ "expenses_other": "{count} gastos cargados",
+ "planned_one": "1 gasto planificado sin marcar como pagado",
+ "planned_other": "{count} gastos planificados sin marcar como pagados",
+ "allPaid": "Todos los gastos están pagados",
+ "income_one": "1 ingreso adicional",
+ "income_other": "{count} ingresos adicionales",
+ "partners_one": "Pago a 1 socio",
+ "partners_other": "Pagos a {count} socios",
+ "noPartners": "Sin socios: la organización se queda con el resultado",
+ "finalProfit": "Ganancia final",
+ "finalLoss": "Pérdida final",
+ "hint": "Los números dejan de seguir las ventas y los gastos. Después podés registrar los pagos a socios, y un admin puede reabrir las cuentas si hace falta.",
+ "confirm": "Cerrar y congelar números"
+ },
+ "breakEven": {
+ "noCosts": "Todavía sin gastos",
+ "noCostsHint": "Agregá gastos para ver cuántas entradas los cubren.",
+ "reached": "Equilibrio alcanzado con {at} de {sold} entradas vendidas",
+ "needed": "Faltan {count} entradas para cubrir gastos",
+ "coveredWithoutTickets": "Los gastos ya están cubiertos por otros ingresos"
+ },
+ "preview": {
+ "fixed": "Suma {total} a los gastos del evento",
+ "perTicket_one": "{amount} × {count} entrada = {total}",
+ "perTicket_other": "{amount} × {count} entradas = {total}",
+ "perGuest_one": "{amount} × {count} asistente ingresado = {total}",
+ "perGuest_other": "{amount} × {count} asistentes ingresados = {total}",
+ "percent": "{percent} de {sales} en entradas = {total}",
+ "minimum": "El mayor entre {minimum} o {count} × {amount} = {total}",
+ "locked": "Fijado en {total}"
+ },
+ "ledger": {
+ "income": "Ingreso",
+ "costs": "Gastos",
+ "allRows": "Todos los gastos e ingresos",
+ "emptyTitle": "Todavía no hay gastos ni ingresos"
+ }
+ },
+ "team": {
+ "title": "Equipo",
+ "hint": "Las personas que agregues acá acceden solo a este evento, con los permisos de abajo. Los admins siempre tienen acceso completo.",
+ "add": "Agregar miembro",
+ "search": "Buscar usuarios por nombre o email",
+ "noResults": "No se encontraron usuarios",
+ "empty": "Todavía no hay miembros",
+ "role": "Rol",
+ "remove": "Quitar",
+ "confirmRemove": "¿Quitar a {name} de este evento?",
+ "added": "Miembro agregado",
+ "saved": "Permisos guardados",
+ "removed": "Miembro quitado",
+ "override": "Cambiado respecto al rol",
+ "reset": "Volver a los permisos del rol",
+ "permissions": "Permisos",
+ "permissionsSummary": "{on} de {total} activos"
+ },
+ "rolePresets": {
+ "staff": "Staff",
+ "collaborator": "Colaborador",
+ "co_manager": "Co-responsable"
+ },
+ "permissions": {
+ "view_overview": "Ver resumen",
+ "check_in": "Registrar ingresos",
+ "view_attendees_names": "Ver nombres de asistentes",
+ "view_attendees_pii": "Ver datos de contacto",
+ "email_attendees": "Enviar emails a asistentes",
+ "view_payments": "Ver pagos",
+ "view_finance": "Ver finanzas",
+ "edit_expenses": "Editar gastos",
+ "edit_own_expenses_only": "Agregar y editar sus gastos",
+ "view_full_split": "Ver el reparto completo",
+ "edit_event": "Editar evento",
+ "manage_team": "Gestionar equipo"
+ },
+ "expenseSettings": {
+ "tab": "Plantillas de gastos",
+ "categories": "Categorías",
+ "templates": "Plantillas",
+ "packs": "Paquetes de plantillas",
+ "fees": "Comisiones por método de pago",
+ "feesHint": "Se descuentan por cada pago al calcular el ingreso neto de un evento.",
+ "addCategory": "Agregar categoría",
+ "addTemplate": "Agregar plantilla",
+ "addPack": "Agregar paquete",
+ "nameEn": "Nombre (inglés)",
+ "nameEs": "Nombre (español)",
+ "color": "Color",
+ "name": "Nombre",
+ "description": "Descripción",
+ "archived": "Archivado",
+ "archive": "Archivar",
+ "unarchive": "Restaurar",
+ "showArchived": "Mostrar archivados",
+ "packTemplates": "Plantillas del paquete",
+ "percent": "Porcentaje (%)",
+ "fixed": "Fijo (PYG)",
+ "saved": "Guardado",
+ "deleted": "Eliminado",
+ "inUse": "Está en uso. Archivalo.",
+ "confirmDelete": "¿Eliminar {name}?",
+ "empty": "Todavía no hay nada"
+ },
+ "financeOverview": {
+ "title": "Finanzas",
+ "subtitle": "Resultados de todos los eventos. Los eventos cerrados usan sus números congelados.",
+ "from": "Desde",
+ "to": "Hasta",
+ "series": "Serie",
+ "venue": "Lugar",
+ "partner": "Socio",
+ "all": "Todos",
+ "noSeries": "Sin serie",
+ "events": "Eventos",
+ "event": "Evento",
+ "date": "Fecha",
+ "perEvent": "Ganancia por evento",
+ "perSeries": "Por serie",
+ "perVenue": "Por lugar",
+ "perPartner": "Totales por socio",
+ "paid": "Pagado",
+ "pending": "Pendiente",
+ "empty": "No hay eventos con datos financieros en este rango",
+ "filters": "Filtros",
+ "readyCount_one": "1 evento pasado",
+ "readyCount_other": "{count} eventos pasados",
+ "readyHint": "todavía con cuentas abiertas. Agregá los últimos gastos y cerralas.",
+ "showReady": "Ver listos para cerrar",
+ "showAll": "Ver todos los eventos",
+ "readyTitle": "Listos para cerrar",
+ "readyBadge": "Listo para cerrar",
+ "clippedHint": "Las barras con una marca de corte están recortadas para que se lean las demás. La etiqueta muestra el valor real."
+ },
+ "eventForm": {
+ "series": "Serie",
+ "seriesHint": "Agrupa eventos recurrentes (ej. Morning Club) en el resumen de Finanzas."
+ },
+ "eventStats": {
+ "capacity": "Capacidad",
+ "notCheckedIn": "Sin ingresar todavía",
+ "checkedIn": "Ingresados",
+ "revenue": "Ingresos",
+ "presaleDoor": "Preventa {presale} · Puerta {door}",
+ "stats": "Datos",
+ "inShort": "{count} adentro",
+ "free": "Gratis"
}
},
"footer": {
@@ -386,4 +894,4 @@
"downloading": "Descargando…"
}
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/api/door.ts b/frontend/src/lib/api/door.ts
index e97a955..60464b2 100644
--- a/frontend/src/lib/api/door.ts
+++ b/frontend/src/lib/api/door.ts
@@ -5,7 +5,7 @@ import { fetchApi } from './client';
// fire actions optimistically and retry on flaky venue wifi without ever
// creating a duplicate ticket, payment or check-in.
-export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
+export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
@@ -32,7 +32,20 @@ export interface DoorAttendee {
}
export interface DoorAttendeesResponse {
- event: { id: string; title: string; price: number; currency: string; capacity: number };
+ event: {
+ id: string;
+ title: string;
+ price: number;
+ /** The event's walk-in price as configured; null = not set. */
+ walkInPrice: number | null;
+ /** What one walk-in ticket costs, resolved by the server. */
+ walkInUnitPrice: number;
+ walkInPriceSource: 'walk_in' | 'ticket';
+ currency: string;
+ capacity: number;
+ };
+ /** Tenders enabled for this event (POS can be switched off in payment options). */
+ doorMethods: DoorPaymentMethod[];
attendees: DoorAttendee[];
stats: { checkedIn: number; totalActive: number; capacity: number };
}
@@ -46,7 +59,11 @@ export interface DoorCheckinRequest {
email?: string;
ruc?: string;
};
- payment?: { method: DoorPaymentMethod; amount?: number };
+ /**
+ * The server prices the charge (walk-in or ticket price x quantity). `amount`
+ * is only honoured with `amountOverride`, which is admin/organizer only.
+ */
+ payment?: { method: DoorPaymentMethod; quantity?: number; amount?: number; amountOverride?: boolean };
entryMethod?: DoorEntryMethod;
idempotencyKey: string;
}
@@ -55,7 +72,13 @@ export interface DoorCheckinResponse {
ok: true;
action: 'checkin' | 'walkin';
attendee: DoorAttendee;
- payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
+ payment: {
+ id: string;
+ method: DoorPaymentMethod;
+ amount: number;
+ currency: string;
+ amountOverridden: boolean;
+ } | null;
/** 'at_capacity' — the event is full; the attendee was added anyway. */
warnings: string[];
idempotencyKey: string;
diff --git a/frontend/src/lib/api/emails.ts b/frontend/src/lib/api/emails.ts
index bcb3bf2..b5927e6 100644
--- a/frontend/src/lib/api/emails.ts
+++ b/frontend/src/lib/api/emails.ts
@@ -3,7 +3,9 @@ import type { EmailTemplate, EmailVariable, EmailLog, EmailStats, Pagination } f
export const emailsApi = {
// Templates
- getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
+ // Team members (non-admin) pass the event they email for, so the server can check their permission there.
+ getTemplates: (eventId?: string) =>
+ fetchApi<{ templates: EmailTemplate[] }>(`/api/emails/templates${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`),
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
@@ -56,8 +58,8 @@ export const emailsApi = {
templateSlug: string;
variables?: Record
;
locale?: string;
- }) =>
- fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
+ }, eventId?: string) =>
+ fetchApi<{ subject: string; bodyHtml: string }>(`/api/emails/preview${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`, {
method: 'POST',
body: JSON.stringify(data),
}),
diff --git a/frontend/src/lib/api/events.ts b/frontend/src/lib/api/events.ts
index bc60660..5c71247 100644
--- a/frontend/src/lib/api/events.ts
+++ b/frontend/src/lib/api/events.ts
@@ -13,7 +13,8 @@ export const eventsApi = {
return fetchApi<{ events: Event[]; total?: number }>(`/api/events?${query}`);
},
- getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
+ // serverTime (ISO) lets the event page time its refresh at the sales-state flip.
+ getById: (id: string) => fetchApi<{ event: Event; serverTime?: string }>(`/api/events/${id}`),
getNext: () => fetchApi<{ event: Event | null }>('/api/events/next'),
diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts
new file mode 100644
index 0000000..3e1d745
--- /dev/null
+++ b/frontend/src/lib/api/finance.ts
@@ -0,0 +1,310 @@
+import { fetchApi, fetchBlob } from './client';
+
+// ==================== Types ====================
+
+export type EventPermission =
+ | 'view_overview' | 'check_in' | 'view_attendees_names' | 'view_attendees_pii' | 'email_attendees'
+ | 'view_payments' | 'view_finance' | 'edit_expenses' | 'edit_own_expenses_only' | 'view_full_split'
+ | 'edit_event' | 'manage_team';
+
+export const EVENT_PERMISSIONS: EventPermission[] = [
+ 'view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments',
+ 'view_finance', 'edit_expenses', 'edit_own_expenses_only', 'view_full_split', 'edit_event', 'manage_team',
+];
+
+export type RolePreset = 'staff' | 'collaborator' | 'co_manager';
+export const ROLE_PRESETS: RolePreset[] = ['staff', 'collaborator', 'co_manager'];
+
+/** Mirrors backend/src/lib/eventPermissions.ts PRESET_PERMISSIONS. */
+export const PRESET_PERMISSIONS: Record = {
+ staff: ['view_overview', 'check_in', 'view_attendees_names'],
+ collaborator: ['view_overview', 'view_finance'],
+ co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
+};
+
+export type CalcType = 'fixed' | 'per_ticket_sold' | 'per_checked_in' | 'percent_of_revenue' | 'minimum_spend';
+export const CALC_TYPES: CalcType[] = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'];
+export type ShareType = 'percent_profit' | 'percent_revenue' | 'fixed' | 'fixed_plus_percent_above_threshold';
+export const SHARE_TYPES: ShareType[] = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'];
+export type LossRule = 'proportional' | 'none' | 'capped';
+export const LOSS_RULES: LossRule[] = ['proportional', 'none', 'capped'];
+export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
+
+export interface MyEventPermissions {
+ eventId: string;
+ global: boolean;
+ role: string;
+ rolePreset: RolePreset | null;
+ permissions: EventPermission[];
+ canUnfinalize: boolean;
+}
+
+export interface ExpenseCategory {
+ id: string;
+ nameEn: string;
+ nameEs: string;
+ color: string;
+ sortOrder: number;
+ archived: boolean;
+}
+
+export interface EventExpense {
+ id: string;
+ eventId: string;
+ categoryId: string | null;
+ templateId: string | null;
+ description: string;
+ calcType: CalcType;
+ quantity: number;
+ unitAmount: number;
+ percentBp: number;
+ minimumAmount: number;
+ computedAmount: number;
+ isLocked: boolean;
+ status: 'planned' | 'paid';
+ /** 'other' = fronted by a partner the viewer may not see. */
+ paidByPartnerId: string | null;
+ receiptUrl: string | null;
+ expenseDate: string | null;
+ createdBy: string | null;
+ createdAt: string | null;
+ /** Live values from the calculation (present on GET /finance). */
+ amount?: number;
+ liveQuantity?: number;
+ auto?: boolean;
+}
+
+export interface OtherIncome {
+ id: string;
+ description: string;
+ amount: number;
+ createdAt: string | null;
+}
+
+export interface EventPartner {
+ id: string;
+ userId: string | null;
+ externalName: string | null;
+ name: string;
+ roleLabel: string | null;
+ shareType: ShareType;
+ percentBp: number;
+ fixedAmount: number;
+ thresholdAmount: number;
+ lossRule: LossRule;
+ lossCapAmount: number;
+ payoutStatus: 'pending' | 'paid';
+ payoutDate: string | null;
+ payoutMethod: string | null;
+ payoutNote: string | null;
+}
+
+export interface PartnerLine {
+ partnerId: string;
+ name: string;
+ shareType: ShareType;
+ basis: number;
+ share: number;
+ reimbursement: number;
+ payout: number;
+}
+
+export interface FinanceSummary {
+ counts: { ticketsSold: number; checkedIn: number; payments: number };
+ revenue: {
+ gross: number; presale: number; door: number; refunds: number; fees: number; otherIncome: number; sales: number; net: number;
+ byMethod: { method: string; count: number; gross: number; refunds: number; fees: number; net: number }[];
+ };
+ expenses: {
+ lines: { id: string; quantity: number; amount: number; auto: boolean }[];
+ total: number; planned: number; paid: number;
+ byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
+ };
+ profit: number;
+ breakEven: { tickets: number | null; ticketPrice: number; remaining: number | null };
+ /** distributable / organization are null for viewers without view_full_split. */
+ split: { distributable: number | null; partners: PartnerLine[]; organization: number | null };
+ waterfall: { key: string; label?: string; amount: number }[];
+ salesTimeline: { date: string; tickets: number; revenue: number }[];
+}
+
+export interface EventFinance {
+ event: { id: string; title: string; titleEs?: string; startDatetime: string; endDatetime: string | null; location: string; series: string | null; price: number; currency: string; capacity: number };
+ status: FinanceStatus;
+ finalizedAt: string | null;
+ live: boolean;
+ computedAt: string;
+ summary: FinanceSummary;
+ expenses: EventExpense[];
+ otherIncome: OtherIncome[];
+ partners: EventPartner[];
+ categories: ExpenseCategory[];
+ viewer: {
+ fullSplit: boolean;
+ canEditExpenses: boolean;
+ canEditOwnExpenses: boolean;
+ canManageSplit: boolean;
+ canUnfinalize: boolean;
+ userId: string;
+ };
+}
+
+export interface ExpenseTemplate {
+ id: string;
+ name: string;
+ categoryId: string | null;
+ description: string | null;
+ calcType: CalcType;
+ amount: number;
+ percentBp: number;
+ minimumAmount: number;
+ archived: boolean;
+}
+
+export interface ExpenseTemplatePack {
+ id: string;
+ name: string;
+ description: string | null;
+ archived: boolean;
+ templateIds: string[];
+}
+
+export interface PaymentMethodFee {
+ method: string;
+ percentBp: number;
+ fixedAmount: number;
+}
+
+export interface EventMember {
+ id: string;
+ userId: string;
+ name: string | null;
+ email: string | null;
+ globalRole: string | null;
+ rolePreset: RolePreset;
+ permissions: Partial>;
+ effective: EventPermission[];
+ createdAt: string | null;
+}
+
+export interface FinanceAuditEntry {
+ id: string;
+ actorUserId: string | null;
+ actorName: string | null;
+ entityType: string;
+ entityId: string | null;
+ action: string;
+ before: any;
+ after: any;
+ createdAt: string | null;
+}
+
+export interface FinanceTotals { events: number; gross: number; fees: number; net: number; expenses: number; profit: number }
+
+export type FinanceOverviewEvent = Omit & {
+ id: string; title: string; titleEs: string | null; startDatetime: string | null; endDatetime: string | null; series: string | null; location: string;
+ status: string; financeStatus: FinanceStatus; ticketsSold: number; organization: number | null;
+};
+
+export interface FinanceOverview {
+ totals: FinanceTotals;
+ events: FinanceOverviewEvent[];
+ /** Past events with open books, including ones with no money recorded yet; longest-waiting first. */
+ readyToClose: FinanceOverviewEvent[];
+ bySeries: (FinanceTotals & { series: string | null })[];
+ byVenue: (FinanceTotals & { venue: string })[];
+ byPartner: { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }[];
+ filters: { series: string[]; venues: string[]; partners: { key: string; name: string }[] };
+}
+
+export interface MyEvent {
+ event: { id: string; slug: string; title: string; titleEs?: string | null; startDatetime: string; location: string; status: string; bannerUrl?: string | null };
+ rolePreset: RolePreset;
+ permissions: EventPermission[];
+}
+
+export type ExpenseInput = Partial>;
+export type PartnerInput = Partial>;
+
+// ==================== Client ====================
+
+const json = (method: string, body?: unknown): RequestInit => ({ method, body: body === undefined ? undefined : JSON.stringify(body) });
+const e = (id: string) => `/api/events/${encodeURIComponent(id)}`;
+/** Team members pass the event they are working on so the server can check their permission there. */
+const scoped = (eventId?: string) => (eventId ? `?eventId=${encodeURIComponent(eventId)}` : '');
+
+export const financeApi = {
+ myPermissions: (eventId: string) => fetchApi(`${e(eventId)}/my-permissions`),
+ get: (eventId: string) => fetchApi(`${e(eventId)}/finance`),
+ finalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/finalize`, json('POST', {})),
+ unfinalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/unfinalize`, json('POST', {})),
+
+ createExpense: (eventId: string, data: ExpenseInput) =>
+ fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses`, json('POST', data)),
+ updateExpense: (eventId: string, id: string, data: ExpenseInput) =>
+ fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses/${id}`, json('PUT', data)),
+ deleteExpense: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/expenses/${id}`, json('DELETE')),
+ applyTemplate: (eventId: string, data: { templateId?: string; packId?: string }) =>
+ fetchApi<{ expenses: EventExpense[] }>(`${e(eventId)}/expenses/apply-template`, json('POST', data)),
+
+ createIncome: (eventId: string, data: { description: string; amount: number }) =>
+ fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income`, json('POST', data)),
+ updateIncome: (eventId: string, id: string, data: { description?: string; amount?: number }) =>
+ fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income/${id}`, json('PUT', data)),
+ deleteIncome: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/other-income/${id}`, json('DELETE')),
+
+ searchPartnerCandidates: (eventId: string, q: string) =>
+ fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/partners/candidates?q=${encodeURIComponent(q)}`),
+ createPartner: (eventId: string, data: PartnerInput) =>
+ fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners`, json('POST', data)),
+ updatePartner: (eventId: string, id: string, data: PartnerInput) =>
+ fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners/${id}`, json('PUT', data)),
+ deletePartner: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/partners/${id}`, json('DELETE')),
+ markPartnerPaid: (eventId: string, id: string, data: { paid: boolean; payoutDate?: string | null; payoutMethod?: string | null; payoutNote?: string | null }) =>
+ fetchApi<{ partner: EventPartner; status: FinanceStatus }>(`${e(eventId)}/partners/${id}/mark-paid`, json('POST', data)),
+ downloadStatement: (eventId: string, partnerId: string, locale: string) =>
+ fetchBlob(`${e(eventId)}/partners/${partnerId}/statement?locale=${locale}`, 'statement.pdf'),
+
+ auditLog: (eventId: string) => fetchApi<{ entries: FinanceAuditEntry[] }>(`${e(eventId)}/audit-log`),
+
+ getMembers: (eventId: string) => fetchApi<{ members: EventMember[] }>(`${e(eventId)}/members`),
+ searchMemberCandidates: (eventId: string, q: string) =>
+ fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/members/candidates?q=${encodeURIComponent(q)}`),
+ addMember: (eventId: string, data: { userId: string; rolePreset: RolePreset; permissions?: Partial> }) =>
+ fetchApi<{ member: EventMember }>(`${e(eventId)}/members`, json('POST', data)),
+ updateMember: (eventId: string, id: string, data: { rolePreset?: RolePreset; permissions?: Partial> }) =>
+ fetchApi<{ member: EventMember }>(`${e(eventId)}/members/${id}`, json('PUT', data)),
+ removeMember: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/members/${id}`, json('DELETE')),
+
+ // Settings (writes are admin only; reads also work for members via eventId)
+ getCategories: (eventId?: string) => fetchApi<{ categories: ExpenseCategory[] }>(`/api/finance/settings/expense-categories${scoped(eventId)}`),
+ createCategory: (data: Partial) => fetchApi<{ category: ExpenseCategory }>('/api/finance/settings/expense-categories', json('POST', data)),
+ updateCategory: (id: string, data: Partial) => fetchApi<{ category: ExpenseCategory }>(`/api/finance/settings/expense-categories/${id}`, json('PUT', data)),
+ deleteCategory: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-categories/${id}`, json('DELETE')),
+
+ getTemplates: (eventId?: string) => fetchApi<{ templates: ExpenseTemplate[] }>(`/api/finance/settings/expense-templates${scoped(eventId)}`),
+ createTemplate: (data: Partial) => fetchApi<{ template: ExpenseTemplate }>('/api/finance/settings/expense-templates', json('POST', data)),
+ updateTemplate: (id: string, data: Partial) => fetchApi<{ template: ExpenseTemplate }>(`/api/finance/settings/expense-templates/${id}`, json('PUT', data)),
+ deleteTemplate: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-templates/${id}`, json('DELETE')),
+
+ getPacks: (eventId?: string) => fetchApi<{ packs: ExpenseTemplatePack[] }>(`/api/finance/settings/expense-template-packs${scoped(eventId)}`),
+ createPack: (data: Partial) => fetchApi<{ pack: ExpenseTemplatePack }>('/api/finance/settings/expense-template-packs', json('POST', data)),
+ updatePack: (id: string, data: Partial) => fetchApi<{ pack: ExpenseTemplatePack }>(`/api/finance/settings/expense-template-packs/${id}`, json('PUT', data)),
+ deletePack: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-template-packs/${id}`, json('DELETE')),
+
+ getFees: () => fetchApi<{ fees: PaymentMethodFee[] }>('/api/finance/settings/payment-fees'),
+ updateFee: (method: string, data: { percentBp: number; fixedAmount: number }) =>
+ fetchApi<{ fee: PaymentMethodFee }>(`/api/finance/settings/payment-fees/${method}`, json('PUT', data)),
+
+ overview: (params: { from?: string; to?: string; series?: string; venue?: string; partner?: string } = {}) => {
+ const q = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => { if (v) q.set(k, v); });
+ const qs = q.toString();
+ return fetchApi(`/api/finance/overview${qs ? `?${qs}` : ''}`);
+ },
+
+ myEvents: () => fetchApi<{ events: MyEvent[] }>('/api/dashboard/my-events'),
+};
diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts
index c8856c9..5800336 100644
--- a/frontend/src/lib/api/index.ts
+++ b/frontend/src/lib/api/index.ts
@@ -29,6 +29,15 @@ export { legalSettingsApi } from './legalSettings';
export { legalPagesApi } from './legalPages';
export { faqApi } from './faq';
export { photosApi } from './photos';
+export {
+ financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS, CALC_TYPES, SHARE_TYPES, LOSS_RULES,
+} from './finance';
+export type {
+ EventPermission, RolePreset, CalcType, ShareType, LossRule, FinanceStatus, MyEventPermissions, ExpenseCategory,
+ EventExpense, OtherIncome, EventPartner, PartnerLine, FinanceSummary, EventFinance, ExpenseTemplate,
+ ExpenseTemplatePack, PaymentMethodFee, EventMember, FinanceAuditEntry, FinanceOverview, FinanceTotals, MyEvent,
+ ExpenseInput, PartnerInput,
+} from './finance';
export type {
Photo,
PhotoGallery,
diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts
index 2da9d9d..740c14a 100644
--- a/frontend/src/lib/api/payments.ts
+++ b/frontend/src/lib/api/payments.ts
@@ -3,7 +3,7 @@ import type { Payment, PaymentWithDetails } from './types';
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
// verify the money arrived; automatic ones (lightning) confirm themselves.
-export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
+export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash', 'pos'];
export function isManualProvider(provider: string): boolean {
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
index af0f304..17ccf0b 100644
--- a/frontend/src/lib/api/types.ts
+++ b/frontend/src/lib/api/types.ts
@@ -1,3 +1,6 @@
+// online: book online · door: online closed, pay at the door until the event ends
+export type EventSalesState = 'online' | 'door' | 'sold_out' | 'ended' | 'external' | 'cancelled';
+
export interface Event {
id: string;
slug: string;
@@ -12,6 +15,9 @@ export interface Event {
location: string;
locationUrl?: string;
price: number;
+ // Door price for walk-ins. Only returned to admin/organizer/staff; null = not
+ // set (walk-ins pay `price`), 0 = free walk-in.
+ walkInPrice?: number | null;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
@@ -21,9 +27,16 @@ export interface Event {
presaleClosureEnabled?: boolean | null; // null = inherit the site default
presaleCloseMinutesBefore?: number | null; // null = inherit the site default
presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes
+ series?: string | null; // groups recurring events in the finance overview
bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
+ // Public sales state, computed by the server (backend lib/salesState.ts).
+ salesState?: EventSalesState;
+ // Resolved door price (walk-in price, else ticket price). Only sent in the `door` state.
+ doorPrice?: number;
+ // Door tenders accepted, sent with the single event in the `door` state.
+ doorPaymentMethods?: Array<'cash' | 'bitcoin' | 'transfer' | 'pos'>;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
@@ -57,6 +70,8 @@ export interface Ticket {
adminNote?: string;
isGuest?: boolean;
paymentStatus?: 'paid' | 'unpaid' | 'comp';
+ // How the booking was made: public checkout, a door walk-in, or added by an admin
+ bookingSource?: 'online' | 'walk_in' | 'admin';
createdAt: string;
event?: Event;
payment?: Payment;
@@ -124,7 +139,7 @@ export interface LiveSearchResult {
export interface Payment {
id: string;
ticketId: string;
- provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
+ provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos';
amount: number;
currency: string;
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
@@ -178,6 +193,8 @@ export interface PaymentOptionsConfig {
cashEnabled: boolean;
cashInstructions?: string | null;
cashInstructionsEs?: string | null;
+ // POS card terminal on the door screen (never offered at online checkout)
+ posEnabled?: boolean;
// Booking settings
allowDuplicateBookings?: boolean;
}
@@ -362,6 +379,7 @@ export interface FinancialSummary {
cash: number;
bank_transfer: number;
tpago: number;
+ pos?: number;
};
paidCount: number;
pendingCount: number;
diff --git a/frontend/src/lib/money.test.ts b/frontend/src/lib/money.test.ts
new file mode 100644
index 0000000..0e6855d
--- /dev/null
+++ b/frontend/src/lib/money.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import { formatNumber, formatPercent, formatPyg, formatPygShort } from './money';
+
+describe('money formatting', () => {
+ it('uses the UI language thousands separator and never decimals', () => {
+ expect(formatPyg(2470000, 'es')).toBe('2.470.000 PYG');
+ expect(formatPyg(2470000, 'en')).toBe('2,470,000 PYG');
+ expect(formatPyg(1234.6, 'en')).toBe('1,235 PYG');
+ expect(formatPyg(0, 'es')).toBe('0 PYG');
+ });
+
+ it('keeps the sign on losses', () => {
+ expect(formatPyg(-144350, 'es')).toBe('-144.350 PYG');
+ expect(formatNumber(-1000, 'en')).toBe('-1,000');
+ });
+
+ it('abbreviates axis labels with the locale decimal mark', () => {
+ expect(formatPygShort(2470000, 'es')).toBe('2,5M');
+ expect(formatPygShort(2470000, 'en')).toBe('2.5M');
+ expect(formatPygShort(12000000, 'en')).toBe('12M');
+ expect(formatPygShort(850000, 'es')).toBe('850k');
+ expect(formatPygShort(-850000, 'en')).toBe('-850k');
+ expect(formatPygShort(500, 'en')).toBe('500');
+ });
+
+ it('formats basis points as a percent', () => {
+ expect(formatPercent(290, 'es')).toBe('2,9%');
+ expect(formatPercent(290, 'en')).toBe('2.9%');
+ expect(formatPercent(3000, 'en')).toBe('30%');
+ });
+
+ it('defaults to Spanish separators', () => {
+ expect(formatPyg(1500000)).toBe('1.500.000 PYG');
+ });
+});
diff --git a/frontend/src/lib/money.ts b/frontend/src/lib/money.ts
new file mode 100644
index 0000000..53421bc
--- /dev/null
+++ b/frontend/src/lib/money.ts
@@ -0,0 +1,40 @@
+// The one place PYG amounts are formatted for admin screens (cards, tables,
+// charts, modal previews). PYG has no decimals. Separators follow the UI
+// language: English 2,470,000 PYG, Spanish 2.470.000 PYG. Grouping is done by
+// hand so it does not depend on the browser's locale data. Components use the
+// useMoney() hook (lib/useMoney.ts) to bind these to the active language.
+
+export type MoneyLocale = 'en' | 'es';
+
+const SEP: Record = {
+ en: { group: ',', decimal: '.' },
+ es: { group: '.', decimal: ',' },
+};
+
+const sepFor = (locale: string) => SEP[locale === 'en' ? 'en' : 'es'];
+
+export function formatNumber(n: number, locale: string = 'es'): string {
+ const sign = n < 0 ? '-' : '';
+ return sign + Math.abs(Math.round(n)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, sepFor(locale).group);
+}
+
+export function formatPyg(n: number, locale: string = 'es'): string {
+ return `${formatNumber(n, locale)} PYG`;
+}
+
+/** Axis labels: 2.470.000 -> 2,5M (es) / 2.5M (en); 850.000 -> 850k. */
+export function formatPygShort(n: number, locale: string = 'es'): string {
+ const abs = Math.abs(n);
+ const sign = n < 0 ? '-' : '';
+ if (abs >= 1_000_000) {
+ return `${sign}${(abs / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1).replace('.', sepFor(locale).decimal)}M`;
+ }
+ if (abs >= 1_000) return `${sign}${Math.round(abs / 1_000)}k`;
+ return `${sign}${abs}`;
+}
+
+/** Basis points as a percent string: 290 -> "2.9%" (en) / "2,9%" (es). */
+export function formatPercent(bp: number, locale: string = 'es'): string {
+ const v = Math.round(bp) / 100;
+ return `${String(v).replace('.', sepFor(locale).decimal)}%`;
+}
diff --git a/frontend/src/lib/salesState.test.ts b/frontend/src/lib/salesState.test.ts
new file mode 100644
index 0000000..5f07381
--- /dev/null
+++ b/frontend/src/lib/salesState.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { nextSalesStateChangeMs } from './utils';
+
+const START = '2030-01-01T23:00:00.000Z';
+const END = '2030-01-02T02:00:00.000Z';
+const CLOSES = '2030-01-01T21:00:00.000Z';
+
+describe('nextSalesStateChangeMs', () => {
+ it('online flips at the pre-sale cutoff, or the start when closure is off', () => {
+ expect(nextSalesStateChangeMs({ salesState: 'online', presaleClosesAt: CLOSES, startDatetime: START }))
+ .toBe(Date.parse(CLOSES));
+ expect(nextSalesStateChangeMs({ salesState: 'online', presaleClosesAt: null, startDatetime: START }))
+ .toBe(Date.parse(START));
+ });
+
+ it('door and sold_out flip at the end time, or the start when there is no end', () => {
+ expect(nextSalesStateChangeMs({ salesState: 'door', startDatetime: START, endDatetime: END })).toBe(Date.parse(END));
+ expect(nextSalesStateChangeMs({ salesState: 'sold_out', startDatetime: START })).toBe(Date.parse(START));
+ });
+
+ it('other states have no timed change', () => {
+ for (const salesState of ['ended', 'external', 'cancelled', undefined]) {
+ expect(nextSalesStateChangeMs({ salesState, startDatetime: START, endDatetime: END })).toBeNull();
+ }
+ });
+});
diff --git a/frontend/src/lib/useMoney.ts b/frontend/src/lib/useMoney.ts
new file mode 100644
index 0000000..626ea8a
--- /dev/null
+++ b/frontend/src/lib/useMoney.ts
@@ -0,0 +1,15 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+import { useLanguage } from '@/context/LanguageContext';
+import { formatNumber, formatPercent, formatPyg, formatPygShort } from './money';
+
+/** Formatters bound to the active UI language. */
+export function useMoney() {
+ const { locale } = useLanguage();
+ const pyg = useCallback((n: number) => formatPyg(n, locale), [locale]);
+ const num = useCallback((n: number) => formatNumber(n, locale), [locale]);
+ const short = useCallback((n: number) => formatPygShort(n, locale), [locale]);
+ const pct = useCallback((bp: number) => formatPercent(bp, locale), [locale]);
+ return useMemo(() => ({ locale, pyg, num, short, pct }), [locale, pyg, num, short, pct]);
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index fe6147a..b193447 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -249,6 +249,30 @@ export function isPresaleClosed(
return parseDate(event.presaleClosesAt).getTime() <= now.getTime();
}
+/**
+ * When the server-computed `salesState` is next due to change on the clock
+ * (epoch ms), or null when it only changes through an edit or a booking:
+ * online → door/ended when online sales close (pre-sale cutoff, at the latest
+ * the start); door/sold_out → ended at the end time (or start when none).
+ * Mirrors backend lib/salesState.ts.
+ */
+export function nextSalesStateChangeMs(event: {
+ salesState?: string;
+ presaleClosesAt?: string | null;
+ startDatetime: string;
+ endDatetime?: string | null;
+}): number | null {
+ const startMs = parseDate(event.startDatetime).getTime();
+ if (event.salesState === 'online') {
+ const closesMs = event.presaleClosesAt ? parseDate(event.presaleClosesAt).getTime() : startMs;
+ return Math.min(closesMs, startMs);
+ }
+ if (event.salesState === 'door' || event.salesState === 'sold_out') {
+ return event.endDatetime ? parseDate(event.endDatetime).getTime() : startMs;
+ }
+ return null;
+}
+
export type DurationUnit = 'minutes' | 'hours' | 'days';
const MINUTES_PER_UNIT: Record = {
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 2d5e51c..1941295 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -35,6 +35,8 @@
".next-build/types/**/*.ts"
],
"exclude": [
- "node_modules"
+ "node_modules",
+ "**/*.test.ts",
+ "vitest.config.ts"
]
}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..31db98b
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+import path from 'path';
+
+// Unit tests for pure frontend logic (finance derivations, formatting).
+export default defineConfig({
+ resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
+ test: { include: ['src/**/*.test.ts'], environment: 'node' },
+});