Add per-event finance, event team permissions and public door sales state.
Event finance - Finance tab on the event page: P&L summary with a revenue-to-result waterfall, costs and other income in one ledger, and the partner split with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close, Finalized) with what is left to do; closing the books goes through a checklist dialog that freezes the numbers. - Expense modal shows only the fields each calculation type needs, a live preview with the event's real counts, and a category suggested from the description. Date inputs follow the UI language. - Global Finance page: profit per event with outliers clipped and labelled, and a "Ready to close" list of past events whose books are still open. - Calculation service (integer PYG, basis points) with pinned regression scenarios; PYG formatting centralised in lib/money with locale-aware separators. Event team permissions - Per-event members with role presets and requireEventPermission; the header stat "Confirmed" is relabelled "Not checked in yet", which is what it counts. Public sales state - One sales state (online, door, sold out, ended, external, cancelled) for the event page, listings and JSON-LD, with the door price and tenders shown only while people can still pay at the door. Frontend unit tests run with vitest (npm test in frontend/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b51c1360e2
commit
e203fb6c74
+183
-1
@@ -1,7 +1,8 @@
|
||||
import 'dotenv/config';
|
||||
import { db, dbAll, dbGet, events, users } from './index.js';
|
||||
import { db, dbAll, dbGet, events, users, expenseCategories } from './index.js';
|
||||
import { sql, eq, ne } from 'drizzle-orm';
|
||||
import { uniqueSlug } from '../lib/slugify.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
console.log(`Database type: ${dbType}`);
|
||||
@@ -1336,6 +1337,178 @@ async function migrate() {
|
||||
`);
|
||||
}
|
||||
|
||||
// ==================== Event finance, partners & team access ====================
|
||||
// New tables only, so one engine-neutral block with a type map instead of a
|
||||
// copy per branch. Money is whole PYG (INTEGER), percentages are basis points.
|
||||
const T = dbType === 'sqlite'
|
||||
? { id: 'TEXT', ts: 'TEXT', str: 'TEXT', text: 'TEXT' }
|
||||
: { id: 'UUID', ts: 'TIMESTAMP', str: 'VARCHAR(300)', text: 'TEXT' };
|
||||
const run = (stmt: string) =>
|
||||
dbType === 'sqlite' ? (db as any).run(sql.raw(stmt)) : (db as any).execute(sql.raw(stmt));
|
||||
|
||||
try {
|
||||
await run(`ALTER TABLE events ADD COLUMN series ${dbType === 'sqlite' ? 'TEXT' : 'VARCHAR(100)'}`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
const financeTables = [
|
||||
`CREATE TABLE IF NOT EXISTS expense_categories (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
name_en ${T.str} NOT NULL,
|
||||
name_es ${T.str} NOT NULL,
|
||||
color ${T.str} NOT NULL DEFAULT '#6B7280',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS expense_templates (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
name ${T.str} NOT NULL,
|
||||
category_id ${T.id} REFERENCES expense_categories(id),
|
||||
description ${T.text},
|
||||
calc_type ${T.str} NOT NULL,
|
||||
amount INTEGER NOT NULL DEFAULT 0,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
minimum_amount INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS expense_template_packs (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
name ${T.str} NOT NULL,
|
||||
description ${T.text},
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS expense_template_pack_items (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
pack_id ${T.id} NOT NULL REFERENCES expense_template_packs(id),
|
||||
template_id ${T.id} NOT NULL REFERENCES expense_templates(id),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_partners (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
user_id ${T.id} REFERENCES users(id),
|
||||
external_name ${T.str},
|
||||
role_label ${T.str},
|
||||
share_type ${T.str} NOT NULL,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
fixed_amount INTEGER NOT NULL DEFAULT 0,
|
||||
threshold_amount INTEGER NOT NULL DEFAULT 0,
|
||||
loss_rule ${T.str} NOT NULL DEFAULT 'none',
|
||||
loss_cap_amount INTEGER NOT NULL DEFAULT 0,
|
||||
payout_status ${T.str} NOT NULL DEFAULT 'pending',
|
||||
payout_date ${T.ts},
|
||||
payout_method ${T.str},
|
||||
payout_note ${T.text},
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_expenses (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
category_id ${T.id} REFERENCES expense_categories(id),
|
||||
template_id ${T.id} REFERENCES expense_templates(id),
|
||||
description ${T.str} NOT NULL,
|
||||
calc_type ${T.str} NOT NULL DEFAULT 'fixed',
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit_amount INTEGER NOT NULL DEFAULT 0,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
minimum_amount INTEGER NOT NULL DEFAULT 0,
|
||||
computed_amount INTEGER NOT NULL DEFAULT 0,
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
status ${T.str} NOT NULL DEFAULT 'planned',
|
||||
paid_by_partner_id ${T.id} REFERENCES event_partners(id),
|
||||
receipt_url ${T.str},
|
||||
expense_date ${T.ts},
|
||||
created_by ${T.id} REFERENCES users(id),
|
||||
updated_by ${T.id} REFERENCES users(id),
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_other_income (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
description ${T.str} NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
created_by ${T.id} REFERENCES users(id),
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS payment_method_fees (
|
||||
method ${T.str} PRIMARY KEY,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
fixed_amount INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at ${T.ts} NOT NULL,
|
||||
updated_by ${T.id} REFERENCES users(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_finance_state (
|
||||
event_id ${T.id} PRIMARY KEY REFERENCES events(id),
|
||||
status ${T.str} NOT NULL DEFAULT 'open',
|
||||
finalized_at ${T.ts},
|
||||
finalized_by ${T.id} REFERENCES users(id),
|
||||
snapshot_json ${T.text},
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_members (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
user_id ${T.id} NOT NULL REFERENCES users(id),
|
||||
role_preset ${T.str} NOT NULL,
|
||||
permissions ${T.text} NOT NULL DEFAULT '{}',
|
||||
created_by ${T.id} REFERENCES users(id),
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS finance_audit_log (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id},
|
||||
actor_user_id ${T.id} REFERENCES users(id),
|
||||
entity_type ${T.str} NOT NULL,
|
||||
entity_id ${T.str},
|
||||
action ${T.str} NOT NULL,
|
||||
before_json ${T.text},
|
||||
after_json ${T.text},
|
||||
created_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
];
|
||||
for (const stmt of financeTables) {
|
||||
await run(stmt);
|
||||
}
|
||||
|
||||
// Defaults, only when the tables are still empty so archived/deleted rows stay gone.
|
||||
const now = getNow();
|
||||
const categoryCount = await dbGet<any>(
|
||||
(db as any).select({ count: sql<number>`count(*)` }).from(sql`expense_categories`)
|
||||
);
|
||||
if (Number(categoryCount?.count || 0) === 0) {
|
||||
const defaults: [string, string, string][] = [
|
||||
['Venue', 'Lugar', '#2563EB'],
|
||||
['Instructor / host', 'Instructor / anfitrión', '#7C3AED'],
|
||||
['Food & drinks', 'Comida y bebidas', '#EA580C'],
|
||||
['Staff', 'Personal', '#0D9488'],
|
||||
['Marketing', 'Marketing', '#DB2777'],
|
||||
['Supplies', 'Materiales', '#CA8A04'],
|
||||
['Other', 'Otros', '#6B7280'],
|
||||
];
|
||||
for (const [i, [en, es, color]] of defaults.entries()) {
|
||||
await (db as any).insert(expenseCategories).values({
|
||||
id: generateId(), nameEn: en, nameEs: es, color, sortOrder: i, archived: dbType === 'sqlite' ? false : 0,
|
||||
createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
console.log('Seeded default expense categories.');
|
||||
}
|
||||
// One fee row per payments.provider in use; 0% until an admin sets them.
|
||||
for (const method of ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos']) {
|
||||
await run(`INSERT INTO payment_method_fees (method, percent_bp, fixed_amount, updated_at)
|
||||
VALUES ('${method}', 0, 0, ${dbType === 'sqlite' ? `'${new Date().toISOString()}'` : 'NOW()'})
|
||||
ON CONFLICT (method) DO NOTHING`);
|
||||
}
|
||||
|
||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
||||
const indexStatements = [
|
||||
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
|
||||
@@ -1353,6 +1526,15 @@ async function migrate() {
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
|
||||
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_expenses_event_id_idx ON event_expenses(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_other_income_event_id_idx ON event_other_income(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_partners_event_id_idx ON event_partners(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_partners_user_id_idx ON event_partners(user_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS event_members_event_user_idx ON event_members(event_id, user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_members_user_id_idx ON event_members(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS expense_template_pack_items_pack_id_idx ON expense_template_pack_items(pack_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS finance_audit_log_event_id_idx ON finance_audit_log(event_id, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_series_idx ON events(series)`,
|
||||
];
|
||||
for (const stmt of indexStatements) {
|
||||
try {
|
||||
|
||||
+308
-1
@@ -94,6 +94,8 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
// Pre-sale closure: null = inherit the site_settings default
|
||||
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }),
|
||||
presaleCloseMinutesBefore: integer('presale_close_minutes_before'),
|
||||
// Groups recurring events (e.g. "Morning Club") for the cross-event finance overview
|
||||
series: text('series'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -407,6 +409,152 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
});
|
||||
|
||||
// ==================== Event finance (SQLite) ====================
|
||||
// All money columns are whole PYG (integer). Percentages are stored in basis
|
||||
// points (hundredths of a percent): 290 = 2.90%. JSON columns are text.
|
||||
|
||||
export const sqliteExpenseCategories = sqliteTable('expense_categories', {
|
||||
id: text('id').primaryKey(),
|
||||
nameEn: text('name_en').notNull(),
|
||||
nameEs: text('name_es').notNull(),
|
||||
color: text('color').notNull().default('#6B7280'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteExpenseTemplates = sqliteTable('expense_templates', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
|
||||
description: text('description'),
|
||||
// fixed | per_ticket_sold | per_checked_in | percent_of_revenue | minimum_spend
|
||||
calcType: text('calc_type').notNull(),
|
||||
amount: integer('amount').notNull().default(0),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
minimumAmount: integer('minimum_amount').notNull().default(0),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteExpenseTemplatePacks = sqliteTable('expense_template_packs', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteExpenseTemplatePackItems = sqliteTable('expense_template_pack_items', {
|
||||
id: text('id').primaryKey(),
|
||||
packId: text('pack_id').notNull().references(() => sqliteExpenseTemplatePacks.id),
|
||||
templateId: text('template_id').notNull().references(() => sqliteExpenseTemplates.id),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
});
|
||||
|
||||
export const sqliteEventPartners = sqliteTable('event_partners', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
userId: text('user_id').references(() => sqliteUsers.id),
|
||||
externalName: text('external_name'),
|
||||
roleLabel: text('role_label'),
|
||||
// percent_profit | percent_revenue | fixed | fixed_plus_percent_above_threshold
|
||||
shareType: text('share_type').notNull(),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
fixedAmount: integer('fixed_amount').notNull().default(0),
|
||||
thresholdAmount: integer('threshold_amount').notNull().default(0),
|
||||
// proportional | none | capped
|
||||
lossRule: text('loss_rule').notNull().default('none'),
|
||||
lossCapAmount: integer('loss_cap_amount').notNull().default(0),
|
||||
payoutStatus: text('payout_status').notNull().default('pending'),
|
||||
payoutDate: text('payout_date'),
|
||||
payoutMethod: text('payout_method'),
|
||||
payoutNote: text('payout_note'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteEventExpenses = sqliteTable('event_expenses', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
|
||||
templateId: text('template_id').references(() => sqliteExpenseTemplates.id),
|
||||
description: text('description').notNull(),
|
||||
calcType: text('calc_type').notNull().default('fixed'),
|
||||
// fixed: units bought; auto types: the count used at the last calculation
|
||||
quantity: integer('quantity').notNull().default(1),
|
||||
unitAmount: integer('unit_amount').notNull().default(0),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
minimumAmount: integer('minimum_amount').notNull().default(0),
|
||||
computedAmount: integer('computed_amount').notNull().default(0),
|
||||
// Locked rows keep computed_amount instead of following ticket counts
|
||||
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
|
||||
status: text('status').notNull().default('planned'), // planned | paid
|
||||
// NULL = the organization paid; otherwise the partner who fronted it
|
||||
paidByPartnerId: text('paid_by_partner_id').references(() => sqliteEventPartners.id),
|
||||
receiptUrl: text('receipt_url'),
|
||||
expenseDate: text('expense_date'),
|
||||
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteEventOtherIncome = sqliteTable('event_other_income', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
description: text('description').notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Keyed by payments.provider (door payments carry the legacy provider too)
|
||||
export const sqlitePaymentMethodFees = sqliteTable('payment_method_fees', {
|
||||
method: text('method').primaryKey(),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
fixedAmount: integer('fixed_amount').notNull().default(0),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
});
|
||||
|
||||
export const sqliteEventFinanceState = sqliteTable('event_finance_state', {
|
||||
eventId: text('event_id').primaryKey().references(() => sqliteEvents.id),
|
||||
status: text('status').notNull().default('open'), // open | finalized | paid_out
|
||||
finalizedAt: text('finalized_at'),
|
||||
finalizedBy: text('finalized_by').references(() => sqliteUsers.id),
|
||||
snapshotJson: text('snapshot_json'),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteEventMembers = sqliteTable('event_members', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||
rolePreset: text('role_preset').notNull(), // staff | collaborator | co_manager
|
||||
// JSON {permissionKey: boolean} applied on top of the preset
|
||||
permissions: text('permissions').notNull().default('{}'),
|
||||
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteFinanceAuditLog = sqliteTable('finance_audit_log', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id'), // NULL for global finance settings
|
||||
actorUserId: text('actor_user_id').references(() => sqliteUsers.id),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
action: text('action').notNull(),
|
||||
beforeJson: text('before_json'),
|
||||
afterJson: text('after_json'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
// ==================== PostgreSQL Schema ====================
|
||||
export const pgUsers = pgTable('users', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -497,6 +645,7 @@ export const pgEvents = pgTable('events', {
|
||||
// Pre-sale closure: null = inherit the site_settings default
|
||||
presaleClosureEnabled: pgInteger('presale_closure_enabled'),
|
||||
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'),
|
||||
series: varchar('series', { length: 100 }),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -794,6 +943,144 @@ export const pgSiteSettings = pgTable('site_settings', {
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
// ==================== Event finance (PostgreSQL) ====================
|
||||
// See the SQLite block: integer PYG, basis-point percentages, JSON as text.
|
||||
|
||||
export const pgExpenseCategories = pgTable('expense_categories', {
|
||||
id: uuid('id').primaryKey(),
|
||||
nameEn: varchar('name_en', { length: 100 }).notNull(),
|
||||
nameEs: varchar('name_es', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 20 }).notNull().default('#6B7280'),
|
||||
sortOrder: pgInteger('sort_order').notNull().default(0),
|
||||
archived: pgInteger('archived').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgExpenseTemplates = pgTable('expense_templates', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
|
||||
description: pgText('description'),
|
||||
calcType: varchar('calc_type', { length: 40 }).notNull(),
|
||||
amount: pgInteger('amount').notNull().default(0),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
|
||||
archived: pgInteger('archived').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgExpenseTemplatePacks = pgTable('expense_template_packs', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
description: pgText('description'),
|
||||
archived: pgInteger('archived').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgExpenseTemplatePackItems = pgTable('expense_template_pack_items', {
|
||||
id: uuid('id').primaryKey(),
|
||||
packId: uuid('pack_id').notNull().references(() => pgExpenseTemplatePacks.id),
|
||||
templateId: uuid('template_id').notNull().references(() => pgExpenseTemplates.id),
|
||||
sortOrder: pgInteger('sort_order').notNull().default(0),
|
||||
});
|
||||
|
||||
export const pgEventPartners = pgTable('event_partners', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
userId: uuid('user_id').references(() => pgUsers.id),
|
||||
externalName: varchar('external_name', { length: 200 }),
|
||||
roleLabel: varchar('role_label', { length: 100 }),
|
||||
shareType: varchar('share_type', { length: 40 }).notNull(),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
|
||||
thresholdAmount: pgInteger('threshold_amount').notNull().default(0),
|
||||
lossRule: varchar('loss_rule', { length: 20 }).notNull().default('none'),
|
||||
lossCapAmount: pgInteger('loss_cap_amount').notNull().default(0),
|
||||
payoutStatus: varchar('payout_status', { length: 20 }).notNull().default('pending'),
|
||||
payoutDate: timestamp('payout_date'),
|
||||
payoutMethod: varchar('payout_method', { length: 50 }),
|
||||
payoutNote: pgText('payout_note'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgEventExpenses = pgTable('event_expenses', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
|
||||
templateId: uuid('template_id').references(() => pgExpenseTemplates.id),
|
||||
description: varchar('description', { length: 300 }).notNull(),
|
||||
calcType: varchar('calc_type', { length: 40 }).notNull().default('fixed'),
|
||||
quantity: pgInteger('quantity').notNull().default(1),
|
||||
unitAmount: pgInteger('unit_amount').notNull().default(0),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
|
||||
computedAmount: pgInteger('computed_amount').notNull().default(0),
|
||||
isLocked: pgInteger('is_locked').notNull().default(0),
|
||||
status: varchar('status', { length: 20 }).notNull().default('planned'),
|
||||
paidByPartnerId: uuid('paid_by_partner_id').references(() => pgEventPartners.id),
|
||||
receiptUrl: varchar('receipt_url', { length: 500 }),
|
||||
expenseDate: timestamp('expense_date'),
|
||||
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgEventOtherIncome = pgTable('event_other_income', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
description: varchar('description', { length: 300 }).notNull(),
|
||||
amount: pgInteger('amount').notNull(),
|
||||
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgPaymentMethodFees = pgTable('payment_method_fees', {
|
||||
method: varchar('method', { length: 50 }).primaryKey(),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
export const pgEventFinanceState = pgTable('event_finance_state', {
|
||||
eventId: uuid('event_id').primaryKey().references(() => pgEvents.id),
|
||||
status: varchar('status', { length: 20 }).notNull().default('open'),
|
||||
finalizedAt: timestamp('finalized_at'),
|
||||
finalizedBy: uuid('finalized_by').references(() => pgUsers.id),
|
||||
snapshotJson: pgText('snapshot_json'),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgEventMembers = pgTable('event_members', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||
rolePreset: varchar('role_preset', { length: 20 }).notNull(),
|
||||
permissions: pgText('permissions').notNull().default('{}'),
|
||||
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgFinanceAuditLog = pgTable('finance_audit_log', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id'),
|
||||
actorUserId: uuid('actor_user_id').references(() => pgUsers.id),
|
||||
entityType: varchar('entity_type', { length: 50 }).notNull(),
|
||||
// Text, not uuid: settings rows are keyed by method name
|
||||
entityId: varchar('entity_id', { length: 100 }),
|
||||
action: varchar('action', { length: 50 }).notNull(),
|
||||
beforeJson: pgText('before_json'),
|
||||
afterJson: pgText('after_json'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
// Export the appropriate schema based on DB_TYPE
|
||||
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||
@@ -818,6 +1105,17 @@ export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLeg
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
||||
export const expenseCategories = dbType === 'postgres' ? pgExpenseCategories : sqliteExpenseCategories;
|
||||
export const expenseTemplates = dbType === 'postgres' ? pgExpenseTemplates : sqliteExpenseTemplates;
|
||||
export const expenseTemplatePacks = dbType === 'postgres' ? pgExpenseTemplatePacks : sqliteExpenseTemplatePacks;
|
||||
export const expenseTemplatePackItems = dbType === 'postgres' ? pgExpenseTemplatePackItems : sqliteExpenseTemplatePackItems;
|
||||
export const eventPartners = dbType === 'postgres' ? pgEventPartners : sqliteEventPartners;
|
||||
export const eventExpenses = dbType === 'postgres' ? pgEventExpenses : sqliteEventExpenses;
|
||||
export const eventOtherIncome = dbType === 'postgres' ? pgEventOtherIncome : sqliteEventOtherIncome;
|
||||
export const paymentMethodFees = dbType === 'postgres' ? pgPaymentMethodFees : sqlitePaymentMethodFees;
|
||||
export const eventFinanceState = dbType === 'postgres' ? pgEventFinanceState : sqliteEventFinanceState;
|
||||
export const eventMembers = dbType === 'postgres' ? pgEventMembers : sqliteEventMembers;
|
||||
export const financeAuditLog = dbType === 'postgres' ? pgFinanceAuditLog : sqliteFinanceAuditLog;
|
||||
|
||||
// Type exports
|
||||
export type User = typeof sqliteUsers.$inferSelect;
|
||||
@@ -851,4 +1149,13 @@ export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
|
||||
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
||||
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||
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;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { getClientIp } from './lib/rateLimit.js';
|
||||
import eventsRoutes from './routes/events.js';
|
||||
import ticketsRoutes from './routes/tickets.js';
|
||||
import doorRoutes from './routes/door.js';
|
||||
import eventFinanceRoutes from './routes/eventFinance.js';
|
||||
import financeRoutes from './routes/finance.js';
|
||||
import usersRoutes from './routes/users.js';
|
||||
import contactsRoutes from './routes/contacts.js';
|
||||
import paymentsRoutes from './routes/payments.js';
|
||||
@@ -2025,6 +2027,8 @@ app.route('/api/auth-ext', authExtRoutes);
|
||||
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
|
||||
// Mounted first so the generic /:id routes below can never shadow them.
|
||||
app.route('/api/events', doorRoutes);
|
||||
// Per-event finance, partners and team access (/api/events/:id/finance, /expenses, /members, ...)
|
||||
app.route('/api/events', eventFinanceRoutes);
|
||||
app.route('/api/events', eventsRoutes);
|
||||
app.route('/api/tickets', ticketsRoutes);
|
||||
app.route('/api/users', usersRoutes);
|
||||
@@ -2040,6 +2044,7 @@ app.route('/api/site-settings', siteSettingsRoutes);
|
||||
app.route('/api/legal-pages', legalPagesRoutes);
|
||||
app.route('/api/legal-settings', legalSettingsRoutes);
|
||||
app.route('/api/faq', faqRoutes);
|
||||
app.route('/api/finance', financeRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to
|
||||
// the terminal today.
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, dbGet, paymentOptions, eventPaymentOverrides } from '../db/index.js';
|
||||
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
|
||||
|
||||
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||
@@ -52,6 +55,19 @@ export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMe
|
||||
return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled);
|
||||
}
|
||||
|
||||
/** Door tenders available for this event (POS can be switched off per event). */
|
||||
export async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
|
||||
const [globalOptions, overrides] = await Promise.all([
|
||||
dbGet<any>((db as any).select().from(paymentOptions)),
|
||||
dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
),
|
||||
]);
|
||||
// Override wins when set; POS defaults to on when nothing is configured.
|
||||
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
|
||||
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
|
||||
}
|
||||
|
||||
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
|
||||
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Per-event access control.
|
||||
//
|
||||
// Global roles still work exactly as before: every route passes the roles it
|
||||
// always allowed (`globalRoles`). On top of that, a user linked to one event
|
||||
// through event_members gets the permissions of their role preset, adjusted by
|
||||
// per-member overrides — for that event only. A collaborator with role 'user'
|
||||
// therefore reaches the routes of their own events and gets 403 everywhere
|
||||
// else.
|
||||
//
|
||||
// Finance and team management are deliberately NOT part of any global role
|
||||
// except admin: organizers only see them on events where an admin granted it.
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, dbGet, eventMembers, tickets, payments } from '../db/index.js';
|
||||
import { getAuthUser, type AuthUser } from './auth.js';
|
||||
|
||||
export const EVENT_PERMISSIONS = [
|
||||
'view_overview',
|
||||
'check_in',
|
||||
'view_attendees_names',
|
||||
'view_attendees_pii',
|
||||
'email_attendees',
|
||||
'view_payments',
|
||||
'view_finance',
|
||||
'edit_expenses',
|
||||
'edit_own_expenses_only',
|
||||
'view_full_split',
|
||||
'edit_event',
|
||||
'manage_team',
|
||||
] as const;
|
||||
export type EventPermission = (typeof EVENT_PERMISSIONS)[number];
|
||||
|
||||
export const ROLE_PRESETS = ['staff', 'collaborator', 'co_manager'] as const;
|
||||
export type RolePreset = (typeof ROLE_PRESETS)[number];
|
||||
|
||||
export const PRESET_PERMISSIONS: Record<RolePreset, readonly EventPermission[]> = {
|
||||
staff: ['view_overview', 'check_in', 'view_attendees_names'],
|
||||
// Sees the event's P&L and only their own share, not the full split.
|
||||
collaborator: ['view_overview', 'view_finance'],
|
||||
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
|
||||
};
|
||||
|
||||
/**
|
||||
* What a global role can already do on every event, mirroring the existing
|
||||
* route allowlists. Used for the UI (which tabs to show) and for new routes.
|
||||
*/
|
||||
export const GLOBAL_ROLE_PERMISSIONS: Record<string, readonly EventPermission[]> = {
|
||||
admin: EVENT_PERMISSIONS,
|
||||
organizer: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments', 'edit_event'],
|
||||
staff: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii'],
|
||||
};
|
||||
|
||||
export function isEventPermission(key: string): key is EventPermission {
|
||||
return (EVENT_PERMISSIONS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function parseOverrides(raw: unknown): Partial<Record<EventPermission, boolean>> {
|
||||
let obj: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { obj = JSON.parse(raw); } catch { obj = {}; }
|
||||
}
|
||||
const out: Partial<Record<EventPermission, boolean>> = {};
|
||||
if (obj && typeof obj === 'object') {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (isEventPermission(k) && typeof v === 'boolean') out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Preset permissions with the member's overrides applied in either direction. */
|
||||
export function resolveMemberPermissions(preset: string, overrides: unknown): Set<EventPermission> {
|
||||
const base = PRESET_PERMISSIONS[preset as RolePreset] || [];
|
||||
const set = new Set<EventPermission>(base);
|
||||
for (const [k, v] of Object.entries(parseOverrides(overrides))) {
|
||||
if (v) set.add(k as EventPermission); else set.delete(k as EventPermission);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export interface EventAccess {
|
||||
eventId: string | null;
|
||||
/** True when the user's global role passed the route's allowlist. */
|
||||
global: boolean;
|
||||
role: string;
|
||||
permissions: Set<EventPermission>;
|
||||
membership: { id: string; rolePreset: RolePreset } | null;
|
||||
}
|
||||
|
||||
export async function getMembership(userId: string, eventId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventMembers)
|
||||
.where(and(eq((eventMembers as any).eventId, eventId), eq((eventMembers as any).userId, userId)))
|
||||
);
|
||||
}
|
||||
|
||||
/** Union of what the user's global role and their membership (if any) grant on this event. */
|
||||
export async function getEffectivePermissions(user: Pick<AuthUser, 'id' | 'role'>, eventId: string): Promise<EventAccess> {
|
||||
const permissions = new Set<EventPermission>(GLOBAL_ROLE_PERMISSIONS[user.role] || []);
|
||||
const member = await getMembership(user.id, eventId);
|
||||
if (member) {
|
||||
for (const p of resolveMemberPermissions(member.rolePreset, member.permissions)) permissions.add(p);
|
||||
}
|
||||
return {
|
||||
eventId,
|
||||
global: user.role === 'admin',
|
||||
role: user.role,
|
||||
permissions,
|
||||
membership: member ? { id: member.id, rolePreset: member.rolePreset } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function canUnfinalize(access: EventAccess): boolean {
|
||||
return access.role === 'admin' || access.membership?.rolePreset === 'co_manager';
|
||||
}
|
||||
|
||||
// ==================== Event id resolvers ====================
|
||||
|
||||
type EventIdResolver = (c: Context) => Promise<string | null> | string | null;
|
||||
|
||||
export const eventFromParam = (name = 'id'): EventIdResolver => (c) => c.req.param(name) || null;
|
||||
|
||||
export const eventFromQuery = (name = 'eventId'): EventIdResolver => (c) => c.req.query(name) || null;
|
||||
|
||||
/** Reads eventId from a JSON body. Hono caches the parsed body, so validators can read it again. */
|
||||
export const eventFromBody = (name = 'eventId'): EventIdResolver => async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
return typeof body?.[name] === 'string' ? body[name] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Query string first, then the JSON body (routes that accept both). */
|
||||
export const eventFromQueryOrBody = (name = 'eventId'): EventIdResolver => async (c) =>
|
||||
eventFromQuery(name)(c) || (c.req.method === 'GET' ? null : await eventFromBody(name)(c));
|
||||
|
||||
export const eventFromTicketParam = (name = 'id'): EventIdResolver => async (c) => {
|
||||
const id = c.req.param(name);
|
||||
if (!id) return null;
|
||||
const row = await dbGet<any>(
|
||||
(db as any).select({ eventId: (tickets as any).eventId }).from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
return row?.eventId ?? null;
|
||||
};
|
||||
|
||||
export const eventFromPaymentParam = (name = 'id'): EventIdResolver => async (c) => {
|
||||
const id = c.req.param(name);
|
||||
if (!id) return null;
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ eventId: (tickets as any).eventId })
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
return row?.eventId ?? null;
|
||||
};
|
||||
|
||||
// ==================== Middleware ====================
|
||||
|
||||
export interface RequireEventPermissionOptions {
|
||||
/** Global roles that pass on every event (the route's existing allowlist). Default: admin only. */
|
||||
globalRoles?: readonly string[];
|
||||
/** Where the event id comes from. Default: the :id path param. */
|
||||
eventId?: EventIdResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow the request when the user's global role is in `globalRoles`, or when
|
||||
* their membership on the resolved event grants any of `keys`. Sets
|
||||
* c.get('user') like requireAuth, and c.get('eventAccess') for handlers that
|
||||
* shape their response (e.g. hide attendee contact details).
|
||||
*/
|
||||
export function requireEventPermission(
|
||||
keys: EventPermission | readonly EventPermission[],
|
||||
opts: RequireEventPermissionOptions = {},
|
||||
) {
|
||||
const wanted = (Array.isArray(keys) ? keys : [keys]) as readonly EventPermission[];
|
||||
const globalRoles = opts.globalRoles || ['admin'];
|
||||
const resolve = opts.eventId || eventFromParam('id');
|
||||
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
c.set('user', user);
|
||||
|
||||
if (globalRoles.includes(user.role)) {
|
||||
const eventId = await resolve(c);
|
||||
c.set('eventAccess', {
|
||||
eventId,
|
||||
global: true,
|
||||
role: user.role,
|
||||
permissions: new Set(GLOBAL_ROLE_PERMISSIONS[user.role] || EVENT_PERMISSIONS),
|
||||
membership: null,
|
||||
} satisfies EventAccess);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = await resolve(c);
|
||||
if (!eventId) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
const access = await getEffectivePermissions(user, eventId);
|
||||
if (!wanted.some((k) => access.permissions.has(k))) {
|
||||
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: wanted[0] }, 403);
|
||||
}
|
||||
c.set('eventAccess', access);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export function getEventAccess(c: Context): EventAccess | null {
|
||||
return ((c as any).get('eventAccess') as EventAccess | undefined) || null;
|
||||
}
|
||||
|
||||
/** True when the request may see attendee contact details (email, phone, RUC). */
|
||||
export function canSeeAttendeePii(c: Context): boolean {
|
||||
const access = getEventAccess(c);
|
||||
return !access || access.global || access.permissions.has('view_attendees_pii');
|
||||
}
|
||||
|
||||
/** Drop attendee contact details for members without view_attendees_pii. */
|
||||
export function redactAttendee<T extends Record<string, any>>(t: T): T {
|
||||
const { attendeeEmail, attendeePhone, attendeeRuc, ...rest } = t as any;
|
||||
return { ...rest, attendeeEmail: null, attendeePhone: null, attendeeRuc: null } as T;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateEventFinance,
|
||||
expenseAmount,
|
||||
paymentFee,
|
||||
roundPyg,
|
||||
type FinanceExpense,
|
||||
type FinanceInput,
|
||||
type FinancePartner,
|
||||
type FinancePayment,
|
||||
} from './calculate.js';
|
||||
|
||||
let seq = 0;
|
||||
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
|
||||
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
|
||||
});
|
||||
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
|
||||
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
|
||||
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
|
||||
});
|
||||
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
|
||||
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
|
||||
lossRule: 'none', lossCapAmount: 0, ...over,
|
||||
});
|
||||
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
|
||||
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
|
||||
});
|
||||
const sumShares = (r: ReturnType<typeof calculateEventFinance>) =>
|
||||
r.split.partners.reduce((s, p) => s + p.share, 0) + r.split.organization;
|
||||
|
||||
describe('calculateEventFinance: profit case', () => {
|
||||
const studio = partner({ name: 'Studio', percentBp: 3000 });
|
||||
const r = calculateEventFinance(input({
|
||||
ticketsSold: 20,
|
||||
checkedIn: 18,
|
||||
payments: [
|
||||
...Array.from({ length: 15 }, () => pay(100000)),
|
||||
...Array.from({ length: 3 }, () => pay(100000, { provider: 'lightning' })),
|
||||
pay(120000, { provider: 'cash', source: 'door', paidAt: '2026-09-05T20:00:00Z' }),
|
||||
pay(120000, { provider: 'pos', source: 'door', paidAt: '2026-09-05T20:10:00Z' }),
|
||||
],
|
||||
fees: [
|
||||
{ method: 'tpago', percentBp: 350, fixedAmount: 0 }, // 3.5%
|
||||
{ method: 'pos', percentBp: 290, fixedAmount: 500 },
|
||||
],
|
||||
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 150000 }],
|
||||
expenses: [expense({ unitAmount: 800000, status: 'paid' }), expense({ calcType: 'per_checked_in', unitAmount: 15000 })],
|
||||
partners: [studio],
|
||||
}));
|
||||
|
||||
it('splits gross into pre-sale and door and by method', () => {
|
||||
expect(r.revenue.gross).toBe(2040000);
|
||||
expect(r.revenue.presale).toBe(1800000);
|
||||
expect(r.revenue.door).toBe(240000);
|
||||
expect(r.revenue.byMethod.map((m) => [m.method, m.gross])).toEqual([
|
||||
['tpago', 1500000], ['lightning', 300000], ['cash', 120000], ['pos', 120000],
|
||||
]);
|
||||
});
|
||||
|
||||
it('charges fees per payment from the method rules', () => {
|
||||
// tpago: 15 x 3500; pos: 3480 + 500; lightning and cash have no rule
|
||||
expect(r.revenue.fees).toBe(15 * 3500 + 3980);
|
||||
expect(r.revenue.byMethod.find((m) => m.method === 'pos')!.fees).toBe(3980);
|
||||
});
|
||||
|
||||
it('adds other income into net revenue and subtracts expenses', () => {
|
||||
expect(r.revenue.net).toBe(2040000 - 56480 + 150000);
|
||||
expect(r.expenses.total).toBe(800000 + 18 * 15000);
|
||||
expect(r.expenses.paid).toBe(800000);
|
||||
expect(r.expenses.planned).toBe(270000);
|
||||
expect(r.profit).toBe(2133520 - 1070000);
|
||||
});
|
||||
|
||||
it('gives the partner its percentage of profit and the organization the rest', () => {
|
||||
expect(r.split.partners[0].share).toBe(roundPyg(1063520 * 0.3));
|
||||
expect(r.split.organization).toBe(1063520 - roundPyg(1063520 * 0.3));
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('builds a cumulative sales timeline and a waterfall ending in the split', () => {
|
||||
expect(r.salesTimeline).toEqual([
|
||||
{ date: '2026-09-01', tickets: 18, revenue: 1800000 },
|
||||
{ date: '2026-09-05', tickets: 20, revenue: 2040000 },
|
||||
]);
|
||||
expect(r.waterfall.map((w) => w.key)).toEqual([
|
||||
'gross', 'refunds', 'fees', 'otherIncome', 'net', 'expenses', 'profit', `partner:${studio.id}`, 'organization',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: refunds', () => {
|
||||
it('subtracts refunded payments and charges no fee on them', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(100000), pay(100000), pay(100000, { status: 'refunded' })],
|
||||
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
|
||||
}));
|
||||
expect(r.revenue.gross).toBe(300000);
|
||||
expect(r.revenue.refunds).toBe(100000);
|
||||
expect(r.revenue.sales).toBe(200000);
|
||||
expect(r.revenue.fees).toBe(20000);
|
||||
expect(r.revenue.net).toBe(180000);
|
||||
expect(r.salesTimeline.at(-1)).toEqual({ date: '2026-09-01', tickets: 2, revenue: 200000 });
|
||||
});
|
||||
|
||||
it('uses sales after refunds as the base for revenue percentages', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(100000), pay(100000, { status: 'refunded' })],
|
||||
expenses: [expense({ calcType: 'percent_of_revenue', percentBp: 1000 })],
|
||||
}));
|
||||
expect(r.expenses.total).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expense calc types', () => {
|
||||
const ctx = { ticketsSold: 30, checkedIn: 25, sales: 3000000 };
|
||||
|
||||
it('fixed multiplies units by the unit amount', () => {
|
||||
expect(expenseAmount(expense({ quantity: 3, unitAmount: 50000 }), ctx)).toEqual({ quantity: 3, amount: 150000 });
|
||||
});
|
||||
|
||||
it('per_ticket_sold follows tickets sold', () => {
|
||||
expect(expenseAmount(expense({ calcType: 'per_ticket_sold', unitAmount: 10000 }), ctx)).toEqual({ quantity: 30, amount: 300000 });
|
||||
});
|
||||
|
||||
it('per_checked_in follows check-ins', () => {
|
||||
expect(expenseAmount(expense({ calcType: 'per_checked_in', unitAmount: 10000 }), ctx)).toEqual({ quantity: 25, amount: 250000 });
|
||||
});
|
||||
|
||||
it('percent_of_revenue takes basis points of sales, rounded to the guaraní', () => {
|
||||
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 1250 }), ctx).amount).toBe(375000);
|
||||
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 333 }), { ...ctx, sales: 1001 }).amount).toBe(33);
|
||||
});
|
||||
|
||||
it('minimum_spend charges the minimum until per-head spend passes it', () => {
|
||||
const e = expense({ calcType: 'minimum_spend', unitAmount: 40000, minimumAmount: 1500000 });
|
||||
expect(expenseAmount(e, { ...ctx, checkedIn: 20 }).amount).toBe(1500000); // 800k < minimum
|
||||
expect(expenseAmount(e, { ...ctx, checkedIn: 50 }).amount).toBe(2000000); // 2.0M > minimum
|
||||
expect(expenseAmount(e, { ...ctx, checkedIn: 0 }).amount).toBe(1500000);
|
||||
});
|
||||
|
||||
it('locked rows keep their stored amount regardless of counts', () => {
|
||||
const e = expense({ calcType: 'per_checked_in', unitAmount: 10000, isLocked: true, computedAmount: 123000, quantity: 12 });
|
||||
expect(expenseAmount(e, ctx)).toEqual({ quantity: 12, amount: 123000 });
|
||||
const r = calculateEventFinance(input({ checkedIn: 99, expenses: [e] }));
|
||||
expect(r.expenses.lines[0]).toMatchObject({ amount: 123000, auto: false });
|
||||
});
|
||||
|
||||
it('marks unlocked non-fixed rows as auto-calculated', () => {
|
||||
const r = calculateEventFinance(input({ expenses: [expense(), expense({ calcType: 'per_ticket_sold' })] }));
|
||||
expect(r.expenses.lines.map((l) => l.auto)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: loss rules', () => {
|
||||
// Net revenue 500k, expenses 1.5M => profit -1,000,000
|
||||
const lossInput = (p: FinancePartner) => input({
|
||||
payments: [pay(500000)],
|
||||
expenses: [expense({ unitAmount: 1500000 })],
|
||||
partners: [p],
|
||||
});
|
||||
|
||||
it('proportional: the partner carries its percentage of the loss', () => {
|
||||
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'proportional' })));
|
||||
expect(r.profit).toBe(-1000000);
|
||||
expect(r.split.partners[0].share).toBe(-400000);
|
||||
expect(r.split.organization).toBe(-600000);
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('none: the organization carries the whole loss', () => {
|
||||
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'none' })));
|
||||
expect(r.split.partners[0].share).toBe(0);
|
||||
expect(r.split.organization).toBe(-1000000);
|
||||
});
|
||||
|
||||
it('capped: the partner carries its percentage up to the cap', () => {
|
||||
const capped = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 250000 })));
|
||||
expect(capped.split.partners[0].share).toBe(-250000);
|
||||
expect(capped.split.organization).toBe(-750000);
|
||||
// A cap larger than the proportional share does not increase it
|
||||
const loose = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 900000 })));
|
||||
expect(loose.split.partners[0].share).toBe(-400000);
|
||||
});
|
||||
|
||||
it('loss rules do not change a profitable split', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(1000000)],
|
||||
partners: [partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 1 })],
|
||||
}));
|
||||
expect(r.split.partners[0].share).toBe(400000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: reimbursements', () => {
|
||||
it('adds paid costs a partner fronted to their payout without counting them twice', () => {
|
||||
const host = partner({ name: 'Host', percentBp: 5000 });
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(1000000)],
|
||||
expenses: [
|
||||
expense({ unitAmount: 200000, status: 'paid', paidByPartnerId: host.id }),
|
||||
expense({ unitAmount: 100000, status: 'planned', paidByPartnerId: host.id }), // not fronted yet
|
||||
expense({ unitAmount: 100000, status: 'paid' }),
|
||||
],
|
||||
partners: [host],
|
||||
}));
|
||||
expect(r.profit).toBe(600000);
|
||||
const h = r.split.partners[0];
|
||||
expect(h.reimbursement).toBe(200000);
|
||||
expect(h.share).toBe(300000);
|
||||
expect(h.payout).toBe(500000);
|
||||
expect(r.split.organization).toBe(300000);
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('nets a loss share against a reimbursement', () => {
|
||||
const host = partner({ percentBp: 5000, lossRule: 'proportional' });
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(100000)],
|
||||
expenses: [expense({ unitAmount: 300000, status: 'paid', paidByPartnerId: host.id })],
|
||||
partners: [host],
|
||||
}));
|
||||
expect(r.profit).toBe(-200000);
|
||||
expect(r.split.partners[0]).toMatchObject({ share: -100000, reimbursement: 300000, payout: 200000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: partner share types', () => {
|
||||
it('percent_revenue is paid on sales after refunds, before profit shares', () => {
|
||||
const venue = partner({ name: 'Venue', shareType: 'percent_revenue', percentBp: 2000 });
|
||||
const cohost = partner({ name: 'Co-host', shareType: 'percent_profit', percentBp: 5000 });
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(600000), pay(400000), pay(100000, { status: 'refunded' })],
|
||||
expenses: [expense({ unitAmount: 300000 })],
|
||||
partners: [venue, cohost],
|
||||
}));
|
||||
expect(r.profit).toBe(700000);
|
||||
expect(r.split.partners[0].share).toBe(200000); // 20% of 1,000,000
|
||||
expect(r.split.distributable).toBe(500000);
|
||||
expect(r.split.partners[1].share).toBe(250000);
|
||||
expect(r.split.organization).toBe(250000);
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('percent_revenue is still owed when the event loses money', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(500000)],
|
||||
expenses: [expense({ unitAmount: 800000 })],
|
||||
partners: [partner({ shareType: 'percent_revenue', percentBp: 1000 })],
|
||||
}));
|
||||
expect(r.split.partners[0].share).toBe(50000);
|
||||
expect(r.split.organization).toBe(-350000);
|
||||
});
|
||||
|
||||
it('fixed is owed regardless of profit', () => {
|
||||
const r = calculateEventFinance(input({ partners: [partner({ shareType: 'fixed', fixedAmount: 300000 })] }));
|
||||
expect(r.split.partners[0].share).toBe(300000);
|
||||
expect(r.split.organization).toBe(-300000);
|
||||
});
|
||||
|
||||
it('fixed_plus_percent_above_threshold pays the fixed part plus a percentage above the threshold', () => {
|
||||
const p = partner({ shareType: 'fixed_plus_percent_above_threshold', fixedAmount: 100000, percentBp: 1000, thresholdAmount: 500000 });
|
||||
const high = calculateEventFinance(input({ payments: [pay(1600000)], partners: [p] }));
|
||||
// distributable = 1.6M - 100k fixed = 1.5M; 10% of (1.5M - 500k) = 100k
|
||||
expect(high.split.partners[0].share).toBe(200000);
|
||||
expect(sumShares(high)).toBe(high.profit);
|
||||
const low = calculateEventFinance(input({ payments: [pay(400000)], partners: [p] }));
|
||||
expect(low.split.partners[0].share).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: break-even', () => {
|
||||
it('finds the smallest ticket count that covers all expenses', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000,
|
||||
ticketsSold: 4,
|
||||
expenses: [expense({ unitAmount: 1000000 }), expense({ calcType: 'per_ticket_sold', unitAmount: 20000 })],
|
||||
}));
|
||||
// 80k contribution per ticket => ceil(1,000,000 / 80,000) = 13
|
||||
expect(r.breakEven).toEqual({ tickets: 13, ticketPrice: 100000, remaining: 9 });
|
||||
});
|
||||
|
||||
it('accounts for minimum spend, other income and fees', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000,
|
||||
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
|
||||
otherIncome: [{ id: 'i', description: 's', amount: 90000 }],
|
||||
expenses: [expense({ calcType: 'minimum_spend', unitAmount: 30000, minimumAmount: 900000 })],
|
||||
}));
|
||||
// 90k net per ticket, 90k income: 9 tickets => 810k + 90k = 900k = minimum
|
||||
expect(r.breakEven.tickets).toBe(9);
|
||||
});
|
||||
|
||||
it('is null when a ticket cannot cover its own variable cost, or the price is 0', () => {
|
||||
expect(calculateEventFinance(input({ expenses: [expense({ calcType: 'per_ticket_sold', unitAmount: 150000 }), expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
|
||||
expect(calculateEventFinance(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
|
||||
});
|
||||
|
||||
it('is 0 when there is nothing to cover', () => {
|
||||
expect(calculateEventFinance(input()).breakEven.tickets).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paymentFee', () => {
|
||||
it('adds the fixed part and skips free payments', () => {
|
||||
expect(paymentFee(100000, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(3400);
|
||||
expect(paymentFee(0, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(0);
|
||||
expect(paymentFee(100000, undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression scenarios pinned while reworking the Finance tab UI: the UI now
|
||||
// derives its waterfall, break-even and lifecycle from these results, so the
|
||||
// numbers themselves must not move.
|
||||
describe('calculateEventFinance: pinned scenarios', () => {
|
||||
const tpagoFee = { method: 'tpago', percentBp: 290, fixedAmount: 0 };
|
||||
|
||||
it('no expenses: profit is revenue after fees and break-even is 0', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
|
||||
fees: [tpagoFee],
|
||||
}));
|
||||
expect(r.revenue).toMatchObject({ gross: 200000, presale: 200000, door: 0, fees: 5800, sales: 200000, net: 194200 });
|
||||
expect(r.expenses.total).toBe(0);
|
||||
expect(r.profit).toBe(194200);
|
||||
expect(r.breakEven).toEqual({ tickets: 0, ticketPrice: 50000, remaining: 0 });
|
||||
expect(r.split).toEqual({ distributable: 194200, partners: [], organization: 194200 });
|
||||
});
|
||||
|
||||
it('a loss: planned and paid costs above revenue, break-even beyond sales', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
|
||||
fees: [tpagoFee],
|
||||
expenses: [
|
||||
expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000, status: 'paid' }),
|
||||
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
|
||||
],
|
||||
}));
|
||||
expect(r.revenue).toMatchObject({ gross: 200000, presale: 150000, door: 50000, fees: 4350, net: 195650 });
|
||||
expect(r.expenses).toMatchObject({ total: 340000, paid: 300000, planned: 40000 });
|
||||
expect(r.profit).toBe(-144350);
|
||||
// 218 bp fee mix: each extra ticket adds 50000 - 1090 - 10000 = 38910 against 300000 fixed.
|
||||
expect(r.breakEven).toEqual({ tickets: 8, ticketPrice: 50000, remaining: 4 });
|
||||
expect(r.split.organization).toBe(-144350);
|
||||
});
|
||||
|
||||
it('partners: fixed deal first, then percent of what is left, reimbursement on top', () => {
|
||||
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
|
||||
const venue = partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 });
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
|
||||
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
|
||||
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
|
||||
expenses: [
|
||||
expense({ calcType: 'fixed', quantity: 1, unitAmount: 200000, status: 'paid' }),
|
||||
expense({ calcType: 'fixed', quantity: 1, unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
|
||||
],
|
||||
partners: [ana, venue],
|
||||
}));
|
||||
expect(r.revenue).toMatchObject({ gross: 1000000, fees: 0, otherIncome: 50000, net: 1050000 });
|
||||
expect(r.profit).toBe(700000);
|
||||
expect(r.split.distributable).toBe(600000);
|
||||
const byName = Object.fromEntries(r.split.partners.map((p) => [p.name, p]));
|
||||
expect(byName.Ana).toMatchObject({ share: 180000, reimbursement: 150000, payout: 330000 });
|
||||
expect(byName['Venue Co']).toMatchObject({ share: 100000, reimbursement: 0, payout: 100000 });
|
||||
expect(r.split.organization).toBe(420000);
|
||||
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: break-even edges', () => {
|
||||
it('lands exactly on the ticket where profit reaches 0', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000, ticketsSold: 1,
|
||||
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
|
||||
}));
|
||||
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 2 });
|
||||
});
|
||||
|
||||
it('reports 0 remaining once sales pass break-even', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000, ticketsSold: 5,
|
||||
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 250000 })],
|
||||
}));
|
||||
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
|
||||
});
|
||||
|
||||
it('other income can cover costs before any ticket', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000,
|
||||
otherIncome: [{ id: 'i', description: 'Sponsor', amount: 500000 }],
|
||||
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
|
||||
}));
|
||||
expect(r.breakEven.tickets).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Event P&L: a pure function from an event's money facts to the numbers the
|
||||
* Finance tab, the partner statements and the finalize snapshot show.
|
||||
*
|
||||
* Everything is whole PYG. Percentages arrive in basis points (290 = 2.90%)
|
||||
* and every percentage product is rounded to the nearest guaraní once, at the
|
||||
* line it applies to, so totals always equal the sum of the lines shown.
|
||||
*
|
||||
* The waterfall:
|
||||
* gross (paid + later-refunded payments)
|
||||
* - refunds
|
||||
* - payment fees (per payment, from payment_method_fees by provider)
|
||||
* + other income
|
||||
* = net revenue
|
||||
* - expenses (planned and paid; auto rows follow the current counts unless locked)
|
||||
* = profit / loss
|
||||
* then the split:
|
||||
* 1. fixed amounts and revenue shares are owed regardless of profit, so they
|
||||
* come off first and leave the distributable profit;
|
||||
* 2. profit shares are taken from the distributable profit, applying each
|
||||
* partner's loss rule when it is negative;
|
||||
* 3. the organization keeps the remainder.
|
||||
* Reimbursements for costs a partner fronted are already inside expenses (so
|
||||
* they are not deducted twice); they are added to that partner's payout.
|
||||
*/
|
||||
|
||||
export const CALC_TYPES = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'] as const;
|
||||
export type CalcType = (typeof CALC_TYPES)[number];
|
||||
|
||||
export const SHARE_TYPES = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'] as const;
|
||||
export type ShareType = (typeof SHARE_TYPES)[number];
|
||||
|
||||
export const LOSS_RULES = ['proportional', 'none', 'capped'] as const;
|
||||
export type LossRule = (typeof LOSS_RULES)[number];
|
||||
|
||||
export interface FinancePayment {
|
||||
id: string;
|
||||
/** PYG recorded at payment time (Lightning included). */
|
||||
amount: number;
|
||||
/** payments.provider: tpago | bank_transfer | lightning | cash | pos | bancard */
|
||||
provider: string;
|
||||
source: 'presale' | 'door';
|
||||
status: 'paid' | 'refunded';
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
export interface FinanceExpense {
|
||||
id: string;
|
||||
description: string;
|
||||
categoryId: string | null;
|
||||
calcType: CalcType;
|
||||
quantity: number;
|
||||
unitAmount: number;
|
||||
percentBp: number;
|
||||
minimumAmount: number;
|
||||
/** Stored amount; authoritative for locked rows. */
|
||||
computedAmount: number;
|
||||
isLocked: boolean;
|
||||
status: 'planned' | 'paid';
|
||||
/** null = paid by the organization. */
|
||||
paidByPartnerId: string | null;
|
||||
}
|
||||
|
||||
export interface FinanceOtherIncome {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface FeeRule {
|
||||
method: string;
|
||||
percentBp: number;
|
||||
fixedAmount: number;
|
||||
}
|
||||
|
||||
export interface FinancePartner {
|
||||
id: string;
|
||||
name: string;
|
||||
shareType: ShareType;
|
||||
percentBp: number;
|
||||
fixedAmount: number;
|
||||
thresholdAmount: number;
|
||||
lossRule: LossRule;
|
||||
lossCapAmount: number;
|
||||
}
|
||||
|
||||
export interface FinanceInput {
|
||||
/** Current online ticket price, used for break-even. */
|
||||
ticketPrice: number;
|
||||
/** Paid, non-cancelled tickets (comps excluded). */
|
||||
ticketsSold: number;
|
||||
/** Checked-in tickets, comps included (they still eat and drink). */
|
||||
checkedIn: number;
|
||||
payments: FinancePayment[];
|
||||
expenses: FinanceExpense[];
|
||||
otherIncome: FinanceOtherIncome[];
|
||||
fees: FeeRule[];
|
||||
partners: FinancePartner[];
|
||||
}
|
||||
|
||||
export interface ExpenseLine {
|
||||
id: string;
|
||||
/** Count the amount was derived from (units, tickets or check-ins). */
|
||||
quantity: number;
|
||||
amount: number;
|
||||
/** True when the amount follows ticket counts / revenue. */
|
||||
auto: boolean;
|
||||
}
|
||||
|
||||
export interface MethodTotals {
|
||||
method: string;
|
||||
count: number;
|
||||
gross: number;
|
||||
refunds: number;
|
||||
fees: number;
|
||||
net: number;
|
||||
}
|
||||
|
||||
export interface PartnerResult {
|
||||
partnerId: string;
|
||||
name: string;
|
||||
shareType: ShareType;
|
||||
/** What the base amount was for the percentage part (profit or revenue). */
|
||||
basis: number;
|
||||
/** Positive = paid to the partner; negative = the partner carries part of a loss. */
|
||||
share: number;
|
||||
reimbursement: number;
|
||||
/** share + reimbursement. Negative means the partner owes the organization. */
|
||||
payout: number;
|
||||
}
|
||||
|
||||
export interface FinanceResult {
|
||||
counts: { ticketsSold: number; checkedIn: number; payments: number };
|
||||
revenue: {
|
||||
gross: number;
|
||||
presale: number;
|
||||
door: number;
|
||||
refunds: number;
|
||||
fees: number;
|
||||
otherIncome: number;
|
||||
/** gross - refunds: the base for revenue percentages. */
|
||||
sales: number;
|
||||
net: number;
|
||||
byMethod: MethodTotals[];
|
||||
};
|
||||
expenses: {
|
||||
lines: ExpenseLine[];
|
||||
total: number;
|
||||
planned: number;
|
||||
paid: number;
|
||||
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
|
||||
};
|
||||
profit: number;
|
||||
breakEven: {
|
||||
/** Tickets needed at ticketPrice for profit >= 0; null when unreachable. */
|
||||
tickets: number | null;
|
||||
ticketPrice: number;
|
||||
/** Tickets still needed beyond those already sold. */
|
||||
remaining: number | null;
|
||||
};
|
||||
split: {
|
||||
/** Profit left after fixed amounts and revenue shares. */
|
||||
distributable: number;
|
||||
partners: PartnerResult[];
|
||||
organization: number;
|
||||
};
|
||||
waterfall: { key: string; label?: string; amount: number }[];
|
||||
/** Cumulative paid sales per day (YYYY-MM-DD, UTC). */
|
||||
salesTimeline: { date: string; tickets: number; revenue: number }[];
|
||||
}
|
||||
|
||||
/** Round half away from zero so a loss and a profit of the same size split symmetrically. */
|
||||
export function roundPyg(value: number): number {
|
||||
return Math.sign(value) * Math.round(Math.abs(value));
|
||||
}
|
||||
|
||||
export function applyBp(base: number, bp: number): number {
|
||||
return roundPyg((base * bp) / 10000);
|
||||
}
|
||||
|
||||
export function paymentFee(amount: number, rule: FeeRule | undefined): number {
|
||||
if (!rule || amount <= 0) return 0;
|
||||
return applyBp(amount, rule.percentBp) + rule.fixedAmount;
|
||||
}
|
||||
|
||||
/** Amount for one expense row at the given counts. Locked rows keep their stored amount. */
|
||||
export function expenseAmount(
|
||||
e: Pick<FinanceExpense, 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' | 'isLocked'>,
|
||||
ctx: { ticketsSold: number; checkedIn: number; sales: number },
|
||||
): { quantity: number; amount: number } {
|
||||
if (e.isLocked) return { quantity: e.quantity, amount: e.computedAmount };
|
||||
switch (e.calcType) {
|
||||
case 'per_ticket_sold':
|
||||
return { quantity: ctx.ticketsSold, amount: e.unitAmount * ctx.ticketsSold };
|
||||
case 'per_checked_in':
|
||||
return { quantity: ctx.checkedIn, amount: e.unitAmount * ctx.checkedIn };
|
||||
case 'percent_of_revenue':
|
||||
return { quantity: 1, amount: applyBp(Math.max(0, ctx.sales), e.percentBp) };
|
||||
case 'minimum_spend':
|
||||
return { quantity: ctx.checkedIn, amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn) };
|
||||
case 'fixed':
|
||||
default:
|
||||
return { quantity: e.quantity, amount: e.unitAmount * e.quantity };
|
||||
}
|
||||
}
|
||||
|
||||
export function isAutoCalc(calcType: CalcType): boolean {
|
||||
return calcType !== 'fixed';
|
||||
}
|
||||
|
||||
function computePartners(profit: number, sales: number, partners: FinancePartner[], reimbursements: Map<string, number>) {
|
||||
// Pass 1: amounts owed regardless of profit.
|
||||
const upfront = new Map<string, number>();
|
||||
for (const p of partners) {
|
||||
let owed = 0;
|
||||
if (p.shareType === 'fixed' || p.shareType === 'fixed_plus_percent_above_threshold') owed = p.fixedAmount;
|
||||
else if (p.shareType === 'percent_revenue') owed = applyBp(Math.max(0, sales), p.percentBp);
|
||||
upfront.set(p.id, owed);
|
||||
}
|
||||
const distributable = profit - [...upfront.values()].reduce((a, b) => a + b, 0);
|
||||
|
||||
// Pass 2: profit-based parts, taken from the distributable profit.
|
||||
let profitParts = 0;
|
||||
const results: PartnerResult[] = partners.map((p) => {
|
||||
let basis = p.shareType === 'percent_revenue' ? sales : distributable;
|
||||
let profitPart = 0;
|
||||
if (p.shareType === 'percent_profit') {
|
||||
const raw = applyBp(distributable, p.percentBp);
|
||||
if (distributable >= 0 || p.lossRule === 'proportional') profitPart = raw;
|
||||
else if (p.lossRule === 'capped') profitPart = Math.max(raw, -Math.abs(p.lossCapAmount));
|
||||
} else if (p.shareType === 'fixed_plus_percent_above_threshold') {
|
||||
basis = Math.max(0, distributable - p.thresholdAmount);
|
||||
profitPart = applyBp(basis, p.percentBp);
|
||||
}
|
||||
profitParts += profitPart;
|
||||
const share = (upfront.get(p.id) || 0) + profitPart;
|
||||
const reimbursement = reimbursements.get(p.id) || 0;
|
||||
return { partnerId: p.id, name: p.name, shareType: p.shareType, basis, share, reimbursement, payout: share + reimbursement };
|
||||
});
|
||||
|
||||
return { distributable, partners: results, organization: distributable - profitParts };
|
||||
}
|
||||
|
||||
/** Profit at a hypothetical ticket count, for break-even. Assumes every sold ticket checks in. */
|
||||
function projectedProfit(n: number, input: FinanceInput, feeRate: { bp: number; fixed: number }, otherIncome: number): number {
|
||||
const sales = n * input.ticketPrice;
|
||||
const fees = n > 0 && input.ticketPrice > 0 ? n * (applyBp(input.ticketPrice, feeRate.bp) + feeRate.fixed) : 0;
|
||||
const ctx = { ticketsSold: n, checkedIn: n, sales };
|
||||
const expenses = input.expenses.reduce((sum, e) => sum + expenseAmount(e, ctx).amount, 0);
|
||||
return sales - fees + otherIncome - expenses;
|
||||
}
|
||||
|
||||
const BREAK_EVEN_SEARCH_LIMIT = 100000;
|
||||
|
||||
export function calculateEventFinance(input: FinanceInput): FinanceResult {
|
||||
const feeByMethod = new Map(input.fees.map((f) => [f.method, f]));
|
||||
|
||||
// ---- Revenue
|
||||
const methods = new Map<string, MethodTotals>();
|
||||
let gross = 0, presale = 0, door = 0, refunds = 0, fees = 0;
|
||||
for (const p of input.payments) {
|
||||
const m = methods.get(p.provider) || { method: p.provider, count: 0, gross: 0, refunds: 0, fees: 0, net: 0 };
|
||||
const fee = p.status === 'paid' ? paymentFee(p.amount, feeByMethod.get(p.provider)) : 0;
|
||||
m.count += 1;
|
||||
m.gross += p.amount;
|
||||
gross += p.amount;
|
||||
if (p.source === 'door') door += p.amount; else presale += p.amount;
|
||||
if (p.status === 'refunded') {
|
||||
// Refunds are whole-payment; the processor fee on a refunded payment is
|
||||
// not tracked, so it is treated as returned too.
|
||||
m.refunds += p.amount;
|
||||
refunds += p.amount;
|
||||
}
|
||||
m.fees += fee;
|
||||
fees += fee;
|
||||
m.net = m.gross - m.refunds - m.fees;
|
||||
methods.set(p.provider, m);
|
||||
}
|
||||
const otherIncome = input.otherIncome.reduce((sum, i) => sum + i.amount, 0);
|
||||
const sales = gross - refunds;
|
||||
const net = sales - fees + otherIncome;
|
||||
|
||||
// ---- Expenses
|
||||
const ctx = { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, sales };
|
||||
const lines: ExpenseLine[] = [];
|
||||
const byCategory = new Map<string | null, { categoryId: string | null; planned: number; paid: number; total: number }>();
|
||||
const reimbursements = new Map<string, number>();
|
||||
let planned = 0, paid = 0;
|
||||
for (const e of input.expenses) {
|
||||
const { quantity, amount } = expenseAmount(e, ctx);
|
||||
lines.push({ id: e.id, quantity, amount, auto: isAutoCalc(e.calcType) && !e.isLocked });
|
||||
const cat = byCategory.get(e.categoryId) || { categoryId: e.categoryId, planned: 0, paid: 0, total: 0 };
|
||||
if (e.status === 'paid') {
|
||||
paid += amount;
|
||||
cat.paid += amount;
|
||||
if (e.paidByPartnerId) reimbursements.set(e.paidByPartnerId, (reimbursements.get(e.paidByPartnerId) || 0) + amount);
|
||||
} else {
|
||||
planned += amount;
|
||||
cat.planned += amount;
|
||||
}
|
||||
cat.total += amount;
|
||||
byCategory.set(e.categoryId, cat);
|
||||
}
|
||||
const expenseTotal = planned + paid;
|
||||
const profit = net - expenseTotal;
|
||||
|
||||
// ---- Break-even at the current price, using the fee mix seen so far
|
||||
// (or the most expensive configured method before any sales).
|
||||
const paidPayments = input.payments.filter((p) => p.status === 'paid' && p.amount > 0);
|
||||
const feeRate = paidPayments.length > 0 && gross > 0
|
||||
? { bp: Math.round((fees / Math.max(1, sales)) * 10000), fixed: 0 }
|
||||
: input.fees.reduce((worst, f) => (f.percentBp > worst.bp ? { bp: f.percentBp, fixed: f.fixedAmount } : worst), { bp: 0, fixed: 0 });
|
||||
const profitAt = (n: number) => projectedProfit(n, input, feeRate, otherIncome);
|
||||
let breakEvenTickets: number | null = null;
|
||||
if (input.ticketPrice > 0) {
|
||||
// Profit is non-decreasing in n for every calc type, so a doubling search
|
||||
// followed by bisection finds the smallest n with profit >= 0.
|
||||
if (profitAt(0) >= 0) {
|
||||
breakEvenTickets = 0;
|
||||
} else {
|
||||
let lo = 0; // profitAt(lo) < 0
|
||||
let hi = 1;
|
||||
while (hi < BREAK_EVEN_SEARCH_LIMIT && profitAt(hi) < 0) {
|
||||
lo = hi;
|
||||
hi = Math.min(hi * 2, BREAK_EVEN_SEARCH_LIMIT);
|
||||
}
|
||||
if (profitAt(hi) >= 0) {
|
||||
while (lo + 1 < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (profitAt(mid) >= 0) hi = mid; else lo = mid;
|
||||
}
|
||||
breakEvenTickets = hi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Split
|
||||
const split = computePartners(profit, sales, input.partners, reimbursements);
|
||||
|
||||
// ---- Waterfall (signed amounts, in display order)
|
||||
const waterfall: FinanceResult['waterfall'] = [
|
||||
{ key: 'gross', amount: gross },
|
||||
{ key: 'refunds', amount: -refunds },
|
||||
{ key: 'fees', amount: -fees },
|
||||
{ key: 'otherIncome', amount: otherIncome },
|
||||
{ key: 'net', amount: net },
|
||||
{ key: 'expenses', amount: -expenseTotal },
|
||||
{ key: 'profit', amount: profit },
|
||||
...split.partners.map((p) => ({ key: `partner:${p.partnerId}`, label: p.name, amount: -p.share })),
|
||||
{ key: 'organization', amount: split.organization },
|
||||
];
|
||||
|
||||
// ---- Cumulative sales per day
|
||||
const byDay = new Map<string, { tickets: number; revenue: number }>();
|
||||
for (const p of input.payments) {
|
||||
if (p.status !== 'paid' || !p.paidAt) continue;
|
||||
const day = new Date(p.paidAt).toISOString().slice(0, 10);
|
||||
const d = byDay.get(day) || { tickets: 0, revenue: 0 };
|
||||
d.tickets += 1;
|
||||
d.revenue += p.amount;
|
||||
byDay.set(day, d);
|
||||
}
|
||||
let runTickets = 0, runRevenue = 0;
|
||||
const salesTimeline = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, d]) => {
|
||||
runTickets += d.tickets;
|
||||
runRevenue += d.revenue;
|
||||
return { date, tickets: runTickets, revenue: runRevenue };
|
||||
});
|
||||
|
||||
return {
|
||||
counts: { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, payments: input.payments.length },
|
||||
revenue: {
|
||||
gross, presale, door, refunds, fees, otherIncome, sales, net,
|
||||
byMethod: [...methods.values()].sort((a, b) => b.gross - a.gross),
|
||||
},
|
||||
expenses: {
|
||||
lines,
|
||||
total: expenseTotal,
|
||||
planned,
|
||||
paid,
|
||||
byCategory: [...byCategory.values()].sort((a, b) => b.total - a.total),
|
||||
},
|
||||
profit,
|
||||
breakEven: {
|
||||
tickets: breakEvenTickets,
|
||||
ticketPrice: input.ticketPrice,
|
||||
remaining: breakEvenTickets === null ? null : Math.max(0, breakEvenTickets - input.ticketsSold),
|
||||
},
|
||||
split,
|
||||
waterfall,
|
||||
salesTimeline,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Reads an event's money facts from the database and runs calculateEventFinance.
|
||||
// Once an event is finalized, the frozen snapshot is returned instead so the
|
||||
// numbers partners were paid on never drift with later ticket changes.
|
||||
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbAll, dbGet, events, tickets, payments, eventExpenses, eventOtherIncome, eventPartners,
|
||||
paymentMethodFees, eventFinanceState, users,
|
||||
} from '../../db/index.js';
|
||||
import { calculateEventFinance, type FinanceInput, type FinanceResult, type CalcType, type ShareType, type LossRule } from './calculate.js';
|
||||
|
||||
export const num = (v: any): number => {
|
||||
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
export const pyg = (v: any): number => Math.round(num(v));
|
||||
export const iso = (v: any): string | null => {
|
||||
if (!v) return null;
|
||||
const d = v instanceof Date ? v : new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
};
|
||||
export const bool = (v: any): boolean => v === true || v === 1 || v === '1';
|
||||
|
||||
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
|
||||
|
||||
export interface FinanceSnapshot {
|
||||
version: 1;
|
||||
computedAt: string;
|
||||
result: FinanceResult;
|
||||
}
|
||||
|
||||
export function serializeExpense(e: any) {
|
||||
return {
|
||||
id: e.id,
|
||||
eventId: e.eventId,
|
||||
categoryId: e.categoryId ?? null,
|
||||
templateId: e.templateId ?? null,
|
||||
description: e.description,
|
||||
calcType: e.calcType as CalcType,
|
||||
quantity: pyg(e.quantity),
|
||||
unitAmount: pyg(e.unitAmount),
|
||||
percentBp: pyg(e.percentBp),
|
||||
minimumAmount: pyg(e.minimumAmount),
|
||||
computedAmount: pyg(e.computedAmount),
|
||||
isLocked: bool(e.isLocked),
|
||||
status: e.status as 'planned' | 'paid',
|
||||
paidByPartnerId: e.paidByPartnerId ?? null,
|
||||
receiptUrl: e.receiptUrl ?? null,
|
||||
expenseDate: iso(e.expenseDate),
|
||||
createdBy: e.createdBy ?? null,
|
||||
updatedBy: e.updatedBy ?? null,
|
||||
createdAt: iso(e.createdAt),
|
||||
updatedAt: iso(e.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePartner(p: any, userName?: string | null) {
|
||||
return {
|
||||
id: p.id,
|
||||
eventId: p.eventId,
|
||||
userId: p.userId ?? null,
|
||||
externalName: p.externalName ?? null,
|
||||
name: p.externalName || userName || 'Partner',
|
||||
roleLabel: p.roleLabel ?? null,
|
||||
shareType: p.shareType as ShareType,
|
||||
percentBp: pyg(p.percentBp),
|
||||
fixedAmount: pyg(p.fixedAmount),
|
||||
thresholdAmount: pyg(p.thresholdAmount),
|
||||
lossRule: p.lossRule as LossRule,
|
||||
lossCapAmount: pyg(p.lossCapAmount),
|
||||
payoutStatus: p.payoutStatus as 'pending' | 'paid',
|
||||
payoutDate: iso(p.payoutDate),
|
||||
payoutMethod: p.payoutMethod ?? null,
|
||||
payoutNote: p.payoutNote ?? null,
|
||||
createdAt: iso(p.createdAt),
|
||||
updatedAt: iso(p.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeIncome(i: any) {
|
||||
return {
|
||||
id: i.id,
|
||||
eventId: i.eventId,
|
||||
description: i.description,
|
||||
amount: pyg(i.amount),
|
||||
createdBy: i.createdBy ?? null,
|
||||
createdAt: iso(i.createdAt),
|
||||
updatedAt: iso(i.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export type SerializedExpense = ReturnType<typeof serializeExpense>;
|
||||
export type SerializedPartner = ReturnType<typeof serializePartner>;
|
||||
export type SerializedIncome = ReturnType<typeof serializeIncome>;
|
||||
|
||||
export async function getFinanceState(eventId: string) {
|
||||
const row = await dbGet<any>((db as any).select().from(eventFinanceState).where(eq((eventFinanceState as any).eventId, eventId)));
|
||||
let snapshot: FinanceSnapshot | null = null;
|
||||
if (row?.snapshotJson) {
|
||||
try { snapshot = JSON.parse(row.snapshotJson); } catch { snapshot = null; }
|
||||
}
|
||||
return {
|
||||
exists: !!row,
|
||||
status: (row?.status || 'open') as FinanceStatus,
|
||||
finalizedAt: iso(row?.finalizedAt),
|
||||
finalizedBy: row?.finalizedBy ?? null,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPartners(eventId: string): Promise<SerializedPartner[]> {
|
||||
const rows = await dbAll<any>((db as any).select().from(eventPartners).where(eq((eventPartners as any).eventId, eventId)));
|
||||
const userIds = [...new Set(rows.map((r: any) => r.userId).filter(Boolean))] as string[];
|
||||
const names = new Map<string, string>();
|
||||
if (userIds.length > 0) {
|
||||
const us = await dbAll<any>(
|
||||
(db as any).select({ id: (users as any).id, name: (users as any).name }).from(users).where(inArray((users as any).id, userIds))
|
||||
);
|
||||
for (const u of us) names.set(u.id, u.name);
|
||||
}
|
||||
return rows
|
||||
.map((r: any) => serializePartner(r, r.userId ? names.get(r.userId) : null))
|
||||
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||
}
|
||||
|
||||
export async function loadExpenses(eventId: string): Promise<SerializedExpense[]> {
|
||||
const rows = await dbAll<any>((db as any).select().from(eventExpenses).where(eq((eventExpenses as any).eventId, eventId)));
|
||||
return rows
|
||||
.map(serializeExpense)
|
||||
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||
}
|
||||
|
||||
export async function loadOtherIncome(eventId: string): Promise<SerializedIncome[]> {
|
||||
const rows = await dbAll<any>((db as any).select().from(eventOtherIncome).where(eq((eventOtherIncome as any).eventId, eventId)));
|
||||
return rows
|
||||
.map(serializeIncome)
|
||||
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||
}
|
||||
|
||||
export async function loadFeeRules() {
|
||||
const rows = await dbAll<any>((db as any).select().from(paymentMethodFees));
|
||||
return rows.map((r: any) => ({
|
||||
method: r.method as string,
|
||||
percentBp: pyg(r.percentBp),
|
||||
fixedAmount: pyg(r.fixedAmount),
|
||||
updatedAt: iso(r.updatedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Ticket counts the auto-calculated expenses follow. */
|
||||
export async function loadTicketCounts(eventId: string) {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
sold: sql<number>`sum(case when ${(tickets as any).status} in ('confirmed', 'checked_in') and ${(tickets as any).paymentStatus} = 'paid' then 1 else 0 end)`,
|
||||
checkedIn: sql<number>`sum(case when ${(tickets as any).status} = 'checked_in' then 1 else 0 end)`,
|
||||
})
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId))
|
||||
);
|
||||
return { ticketsSold: Number(row?.sold || 0), checkedIn: Number(row?.checkedIn || 0) };
|
||||
}
|
||||
|
||||
export async function buildFinanceInput(eventId: string, event: any) {
|
||||
const [counts, payRows, expenses, otherIncome, partners, fees] = await Promise.all([
|
||||
loadTicketCounts(eventId),
|
||||
dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (payments as any).id,
|
||||
amount: (payments as any).amount,
|
||||
provider: (payments as any).provider,
|
||||
source: (payments as any).source,
|
||||
status: (payments as any).status,
|
||||
paidAt: (payments as any).paidAt,
|
||||
createdAt: (payments as any).createdAt,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
inArray((payments as any).status, ['paid', 'refunded'])
|
||||
))
|
||||
),
|
||||
loadExpenses(eventId),
|
||||
loadOtherIncome(eventId),
|
||||
loadPartners(eventId),
|
||||
loadFeeRules(),
|
||||
]);
|
||||
|
||||
const input: FinanceInput = {
|
||||
ticketPrice: pyg(event.price),
|
||||
ticketsSold: counts.ticketsSold,
|
||||
checkedIn: counts.checkedIn,
|
||||
payments: payRows
|
||||
.map((p: any) => ({
|
||||
id: p.id,
|
||||
amount: pyg(p.amount),
|
||||
provider: p.provider,
|
||||
source: p.source === 'door' ? 'door' as const : 'presale' as const,
|
||||
status: p.status as 'paid' | 'refunded',
|
||||
paidAt: iso(p.paidAt) || iso(p.createdAt),
|
||||
}))
|
||||
// Comps are recorded as 0 PYG payments; they are not sales.
|
||||
.filter((p) => p.amount > 0),
|
||||
expenses: expenses.map((e) => ({
|
||||
id: e.id, description: e.description, categoryId: e.categoryId, calcType: e.calcType, quantity: e.quantity,
|
||||
unitAmount: e.unitAmount, percentBp: e.percentBp, minimumAmount: e.minimumAmount, computedAmount: e.computedAmount,
|
||||
isLocked: e.isLocked, status: e.status, paidByPartnerId: e.paidByPartnerId,
|
||||
})),
|
||||
otherIncome: otherIncome.map((i) => ({ id: i.id, description: i.description, amount: i.amount })),
|
||||
fees,
|
||||
partners: partners.map((p) => ({
|
||||
id: p.id, name: p.name, shareType: p.shareType, percentBp: p.percentBp, fixedAmount: p.fixedAmount,
|
||||
thresholdAmount: p.thresholdAmount, lossRule: p.lossRule, lossCapAmount: p.lossCapAmount,
|
||||
})),
|
||||
};
|
||||
return { input, expenses, otherIncome, partners, counts };
|
||||
}
|
||||
|
||||
export async function getEvent(eventId: string) {
|
||||
return dbGet<any>((db as any).select().from(events).where(eq((events as any).id, eventId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's finance numbers plus the rows behind them. Uses the finalize
|
||||
* snapshot when there is one; otherwise calculates live.
|
||||
*/
|
||||
export async function getEventFinance(eventId: string, event?: any) {
|
||||
const ev = event || await getEvent(eventId);
|
||||
if (!ev) return null;
|
||||
const [state, built] = await Promise.all([getFinanceState(eventId), buildFinanceInput(eventId, ev)]);
|
||||
const frozen = state.status !== 'open' && state.snapshot;
|
||||
const result = frozen ? state.snapshot!.result : calculateEventFinance(built.input);
|
||||
return {
|
||||
event: ev,
|
||||
state,
|
||||
live: !frozen,
|
||||
computedAt: frozen ? state.snapshot!.computedAt : new Date().toISOString(),
|
||||
result,
|
||||
expenses: built.expenses,
|
||||
otherIncome: built.otherIncome,
|
||||
partners: built.partners,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Partner statement PDF: the event P&L summary, the partner's deal, what they
|
||||
// fronted, and the resulting payout. Uses the ticket PDF's brand helpers.
|
||||
|
||||
import {
|
||||
COLORS, PAGE_W, PAGE_H, MARGIN, CONTENT_W, ACCENT_H, FOOTER_H, LOGO_RATIO,
|
||||
getLogo, drawLabel, drawDivider, createDoc, collect, siteUrl,
|
||||
} from '../pdf.js';
|
||||
import type { FinanceResult, PartnerResult } from './calculate.js';
|
||||
import type { SerializedExpense, SerializedPartner } from './load.js';
|
||||
|
||||
export function formatPygAmount(amount: number): string {
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
return `${sign}${Math.abs(Math.round(amount)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')} PYG`;
|
||||
}
|
||||
|
||||
const pct = (bp: number) => `${(bp / 100).toLocaleString('es-PY', { maximumFractionDigits: 2 })}%`;
|
||||
|
||||
const STRINGS = {
|
||||
en: {
|
||||
title: 'Partner statement',
|
||||
draft: 'DRAFT: the event is not finalized, numbers may still change.',
|
||||
event: 'Event',
|
||||
partner: 'Partner',
|
||||
summary: 'Event summary',
|
||||
gross: 'Gross ticket revenue',
|
||||
refunds: 'Refunds',
|
||||
fees: 'Payment fees',
|
||||
otherIncome: 'Other income',
|
||||
net: 'Net revenue',
|
||||
expenses: 'Expenses',
|
||||
profit: 'Profit / loss',
|
||||
deal: 'Agreement',
|
||||
share: 'Share',
|
||||
reimbursements: 'Reimbursements (costs you paid)',
|
||||
none: 'None',
|
||||
payout: 'Total payout',
|
||||
owes: 'Amount owed to the organization',
|
||||
status: 'Payout status',
|
||||
paid: 'Paid',
|
||||
pending: 'Pending',
|
||||
generated: 'Generated',
|
||||
lossNote: { proportional: 'shares losses proportionally', none: 'does not share losses', capped: 'shares losses up to' },
|
||||
shareTypes: {
|
||||
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} of profit`,
|
||||
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} of ticket revenue`,
|
||||
fixed: (p: SerializedPartner) => `Fixed ${formatPygAmount(p.fixedAmount)}`,
|
||||
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
|
||||
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} of profit above ${formatPygAmount(p.thresholdAmount)}`,
|
||||
},
|
||||
},
|
||||
es: {
|
||||
title: 'Liquidación de socio',
|
||||
draft: 'BORRADOR: el evento no está cerrado, los números pueden cambiar.',
|
||||
event: 'Evento',
|
||||
partner: 'Socio',
|
||||
summary: 'Resumen del evento',
|
||||
gross: 'Ingresos brutos por entradas',
|
||||
refunds: 'Reembolsos',
|
||||
fees: 'Comisiones de pago',
|
||||
otherIncome: 'Otros ingresos',
|
||||
net: 'Ingresos netos',
|
||||
expenses: 'Gastos',
|
||||
profit: 'Ganancia / pérdida',
|
||||
deal: 'Acuerdo',
|
||||
share: 'Participación',
|
||||
reimbursements: 'Reembolsos (gastos que pagaste)',
|
||||
none: 'Ninguno',
|
||||
payout: 'Total a pagar',
|
||||
owes: 'Monto adeudado a la organización',
|
||||
status: 'Estado del pago',
|
||||
paid: 'Pagado',
|
||||
pending: 'Pendiente',
|
||||
generated: 'Generado',
|
||||
lossNote: { proportional: 'comparte pérdidas proporcionalmente', none: 'no comparte pérdidas', capped: 'comparte pérdidas hasta' },
|
||||
shareTypes: {
|
||||
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} de la ganancia`,
|
||||
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} de los ingresos por entradas`,
|
||||
fixed: (p: SerializedPartner) => `Fijo ${formatPygAmount(p.fixedAmount)}`,
|
||||
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
|
||||
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} de la ganancia sobre ${formatPygAmount(p.thresholdAmount)}`,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface StatementData {
|
||||
locale: 'en' | 'es';
|
||||
event: { title: string; startDatetime: string | Date; location: string };
|
||||
finalized: boolean;
|
||||
timezone?: string;
|
||||
result: FinanceResult;
|
||||
partner: SerializedPartner;
|
||||
line: PartnerResult | undefined;
|
||||
frontedExpenses: SerializedExpense[];
|
||||
/** Amount per expense id, from the same result. */
|
||||
expenseAmounts: Map<string, number>;
|
||||
}
|
||||
|
||||
export async function generatePartnerStatementPDF(data: StatementData): Promise<Buffer> {
|
||||
const t = STRINGS[data.locale];
|
||||
const doc = createDoc();
|
||||
const done = collect(doc);
|
||||
const tz = data.timezone || 'America/Asuncion';
|
||||
const dateFmt = new Intl.DateTimeFormat(data.locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'long', timeZone: tz });
|
||||
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
|
||||
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
|
||||
|
||||
let y = MARGIN + 6;
|
||||
const logo = getLogo();
|
||||
if (logo) {
|
||||
const w = 120;
|
||||
doc.image(logo, MARGIN, y, { width: w });
|
||||
y += w / LOGO_RATIO + 16;
|
||||
} else {
|
||||
doc.font('Helvetica-Bold').fontSize(18).fillColor(COLORS.navy).text('spanglish social', MARGIN, y);
|
||||
y += 32;
|
||||
}
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(22).fillColor(COLORS.navy).text(t.title, MARGIN, y, { width: CONTENT_W });
|
||||
y += 32;
|
||||
|
||||
if (!data.finalized) {
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(COLORS.orange).text(t.draft, MARGIN, y, { width: CONTENT_W });
|
||||
y += 20;
|
||||
}
|
||||
|
||||
const col = CONTENT_W / 2;
|
||||
drawLabel(doc, t.event, y, col - 12);
|
||||
drawLabel(doc, t.partner, y, col, MARGIN + col);
|
||||
y += 14;
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor(COLORS.navy);
|
||||
doc.text(data.event.title, MARGIN, y, { width: col - 12 });
|
||||
doc.text(data.partner.name, MARGIN + col, y, { width: col });
|
||||
y += 16;
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||
doc.text(`${dateFmt.format(new Date(data.event.startDatetime))} · ${data.event.location}`, MARGIN, y, { width: col - 12 });
|
||||
if (data.partner.roleLabel) doc.text(data.partner.roleLabel, MARGIN + col, y, { width: col });
|
||||
y += 34;
|
||||
|
||||
const row = (label: string, amount: number, opts: { bold?: boolean } = {}) => {
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.bold ? 12 : 10.5).fillColor(COLORS.navy);
|
||||
doc.text(label, MARGIN, y, { width: CONTENT_W - 160 });
|
||||
doc.text(formatPygAmount(amount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
|
||||
y += opts.bold ? 20 : 17;
|
||||
};
|
||||
|
||||
drawLabel(doc, t.summary, y);
|
||||
y += 16;
|
||||
const r = data.result;
|
||||
row(t.gross, r.revenue.gross);
|
||||
if (r.revenue.refunds) row(t.refunds, -r.revenue.refunds);
|
||||
row(t.fees, -r.revenue.fees);
|
||||
if (r.revenue.otherIncome) row(t.otherIncome, r.revenue.otherIncome);
|
||||
drawDivider(doc, y); y += 8;
|
||||
row(t.net, r.revenue.net, { bold: true });
|
||||
row(t.expenses, -r.expenses.total);
|
||||
drawDivider(doc, y); y += 8;
|
||||
row(t.profit, r.profit, { bold: true });
|
||||
y += 18;
|
||||
|
||||
drawLabel(doc, t.deal, y);
|
||||
y += 16;
|
||||
let deal = t.shareTypes[data.partner.shareType](data.partner);
|
||||
if (data.partner.shareType === 'percent_profit') {
|
||||
deal += data.partner.lossRule === 'capped'
|
||||
? ` · ${t.lossNote.capped} ${formatPygAmount(data.partner.lossCapAmount)}`
|
||||
: ` · ${t.lossNote[data.partner.lossRule]}`;
|
||||
}
|
||||
doc.font('Helvetica').fontSize(10.5).fillColor(COLORS.navy).text(deal, MARGIN, y, { width: CONTENT_W });
|
||||
y += 26;
|
||||
|
||||
const share = data.line?.share ?? 0;
|
||||
const reimbursement = data.line?.reimbursement ?? 0;
|
||||
const payout = data.line?.payout ?? 0;
|
||||
row(t.share, share);
|
||||
|
||||
drawLabel(doc, t.reimbursements, y + 4);
|
||||
y += 20;
|
||||
if (data.frontedExpenses.length === 0) {
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted).text(t.none, MARGIN, y);
|
||||
y += 16;
|
||||
} else {
|
||||
for (const e of data.frontedExpenses) {
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||
doc.text(e.description, MARGIN + 10, y, { width: CONTENT_W - 170 });
|
||||
doc.text(formatPygAmount(data.expenseAmounts.get(e.id) ?? e.computedAmount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
|
||||
y += 15;
|
||||
}
|
||||
row(t.reimbursements, reimbursement);
|
||||
}
|
||||
y += 6;
|
||||
drawDivider(doc, y); y += 10;
|
||||
row(payout < 0 ? t.owes : t.payout, Math.abs(payout), { bold: true });
|
||||
y += 6;
|
||||
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||
const status = data.partner.payoutStatus === 'paid'
|
||||
? `${t.paid}${data.partner.payoutDate ? ` · ${dateFmt.format(new Date(data.partner.payoutDate))}` : ''}${data.partner.payoutMethod ? ` · ${data.partner.payoutMethod}` : ''}`
|
||||
: t.pending;
|
||||
doc.text(`${t.status}: ${status}`, MARGIN, y, { width: CONTENT_W });
|
||||
if (data.partner.payoutNote) {
|
||||
y += 15;
|
||||
doc.text(data.partner.payoutNote, MARGIN, y, { width: CONTENT_W });
|
||||
}
|
||||
|
||||
const footerY = PAGE_H - FOOTER_H;
|
||||
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
|
||||
doc.font('Helvetica').fontSize(9).fillColor(COLORS.footerMuted)
|
||||
.text(`${t.generated} ${dateFmt.format(new Date())}`, MARGIN, footerY + FOOTER_H / 2 - 5, { width: CONTENT_W / 2 });
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#FFFFFF')
|
||||
.text(siteUrl().domain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
return done;
|
||||
}
|
||||
+14
-14
@@ -25,7 +25,7 @@ interface TicketData {
|
||||
|
||||
// ==================== Brand ====================
|
||||
|
||||
const COLORS = {
|
||||
export const COLORS = {
|
||||
navy: '#002F44',
|
||||
orange: '#F5821F',
|
||||
cream: '#FDF8F0',
|
||||
@@ -37,14 +37,14 @@ const COLORS = {
|
||||
footerMuted: '#7FA3B5',
|
||||
};
|
||||
|
||||
const PAGE_W = 595.28;
|
||||
const PAGE_H = 841.89;
|
||||
const MARGIN = 48;
|
||||
const CONTENT_W = PAGE_W - MARGIN * 2;
|
||||
const ACCENT_H = 10;
|
||||
const FOOTER_H = 48;
|
||||
export const PAGE_W = 595.28;
|
||||
export const PAGE_H = 841.89;
|
||||
export const MARGIN = 48;
|
||||
export const CONTENT_W = PAGE_W - MARGIN * 2;
|
||||
export const ACCENT_H = 10;
|
||||
export const FOOTER_H = 48;
|
||||
|
||||
const LOGO_RATIO = 1158 / 324;
|
||||
export const LOGO_RATIO = 1158 / 324;
|
||||
|
||||
const STRINGS = {
|
||||
en: {
|
||||
@@ -82,7 +82,7 @@ function loadLogo(): Buffer | null {
|
||||
}
|
||||
|
||||
let logoCache: Buffer | null | undefined;
|
||||
function getLogo(): Buffer | null {
|
||||
export function getLogo(): Buffer | null {
|
||||
if (logoCache === undefined) logoCache = loadLogo();
|
||||
return logoCache;
|
||||
}
|
||||
@@ -152,7 +152,7 @@ function splitLocation(location: string): { name: string; address?: string } {
|
||||
|
||||
// ==================== Drawing helpers ====================
|
||||
|
||||
function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
|
||||
export function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(8)
|
||||
@@ -160,7 +160,7 @@ function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CON
|
||||
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
|
||||
}
|
||||
|
||||
function drawDivider(doc: PDFKit.PDFDocument, y: number) {
|
||||
export function drawDivider(doc: PDFKit.PDFDocument, y: number) {
|
||||
doc
|
||||
.moveTo(MARGIN, y)
|
||||
.lineTo(PAGE_W - MARGIN, y)
|
||||
@@ -347,11 +347,11 @@ function renderTicketPage(
|
||||
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
|
||||
}
|
||||
|
||||
function createDoc(): PDFKit.PDFDocument {
|
||||
export function createDoc(): PDFKit.PDFDocument {
|
||||
return new PDFDocument({ size: 'A4', margin: 0 });
|
||||
}
|
||||
|
||||
function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
|
||||
export function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
@@ -360,7 +360,7 @@ function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
|
||||
});
|
||||
}
|
||||
|
||||
function siteUrl(): { base: string; domain: string } {
|
||||
export function siteUrl(): { base: string; domain: string } {
|
||||
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||
let domain = base;
|
||||
try {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
|
||||
@@ -264,7 +265,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
});
|
||||
|
||||
// Export attendees for a specific event (admin) — CSV download
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||
const q = c.req.query('q') || '';
|
||||
@@ -368,14 +369,14 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
});
|
||||
|
||||
// Legacy alias — keep old path working
|
||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
||||
adminRouter.get('/events/:eventId/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const newUrl = new URL(c.req.url);
|
||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||
return c.redirect(newUrl.toString(), 301);
|
||||
});
|
||||
|
||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, eventMembers } from '../db/index.js';
|
||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import { resolveMemberPermissions, EVENT_PERMISSIONS } from '../lib/eventPermissions.js';
|
||||
|
||||
const dashboard = new Hono();
|
||||
|
||||
// Apply authentication to all routes
|
||||
dashboard.use('*', requireAuth());
|
||||
|
||||
// ==================== My Events (team memberships) ====================
|
||||
|
||||
// Events the user was added to as staff / collaborator / co-manager, with what
|
||||
// they may do on each. Opens the scoped event page at /dashboard/events/:id.
|
||||
dashboard.get('/my-events', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ m: eventMembers, e: events })
|
||||
.from(eventMembers)
|
||||
.innerJoin(events, eq((eventMembers as any).eventId, (events as any).id))
|
||||
.where(eq((eventMembers as any).userId, user.id))
|
||||
);
|
||||
const toIso = (v: any) => (v instanceof Date ? v.toISOString() : v);
|
||||
return c.json({
|
||||
events: rows
|
||||
.map((r: any) => ({
|
||||
event: {
|
||||
id: r.e.id,
|
||||
slug: r.e.slug,
|
||||
title: r.e.title,
|
||||
titleEs: r.e.titleEs,
|
||||
startDatetime: toIso(r.e.startDatetime),
|
||||
location: r.e.location,
|
||||
status: r.e.status,
|
||||
bannerUrl: r.e.bannerUrl,
|
||||
},
|
||||
rolePreset: r.m.rolePreset,
|
||||
permissions: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(r.m.rolePreset, r.m.permissions).has(p)),
|
||||
}))
|
||||
.sort((a: any, b: any) => String(b.event.startDatetime).localeCompare(String(a.event.startDatetime))),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Profile Routes ====================
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
|
||||
@@ -22,15 +22,14 @@ import { z } from 'zod';
|
||||
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
|
||||
paymentOptions, eventPaymentOverrides,
|
||||
} from '../db/index.js';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam, canSeeAttendeePii } from '../lib/eventPermissions.js';
|
||||
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
|
||||
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
import {
|
||||
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
||||
paymentStatusForMethod, enabledDoorMethods, type DoorPaymentMethod,
|
||||
paymentStatusForMethod, loadDoorMethods, type DoorPaymentMethod,
|
||||
} from '../lib/doorPayments.js';
|
||||
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import emailService from '../lib/email.js';
|
||||
@@ -111,18 +110,6 @@ async function loadEvent(eventId: string | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Door tenders available for this event (POS can be switched off per event). */
|
||||
async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
|
||||
const [globalOptions, overrides] = await Promise.all([
|
||||
dbGet<any>((db as any).select().from(paymentOptions)),
|
||||
dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
),
|
||||
]);
|
||||
// Override wins when set; POS defaults to on when nothing is configured.
|
||||
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
|
||||
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
|
||||
}
|
||||
|
||||
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
|
||||
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
||||
@@ -147,7 +134,7 @@ async function seatsHeld(eventId: string): Promise<number> {
|
||||
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
|
||||
// tickets are included on purpose: staff must be able to see and reactivate them.
|
||||
|
||||
doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => {
|
||||
doorRouter.get('/:eventId/door-attendees', requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const event = await loadEvent(eventId);
|
||||
@@ -195,6 +182,7 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
|
||||
adminNames,
|
||||
doorMethod: doorMethods.get(t.id) || null,
|
||||
}))
|
||||
.map((a) => (canSeeAttendeePii(c) ? a : { ...a, email: null, phone: null }))
|
||||
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
|
||||
|
||||
const checkedIn = attendees.filter((a) => a.checkedIn).length;
|
||||
@@ -280,7 +268,7 @@ async function findProcessedKey(key: string) {
|
||||
|
||||
doorRouter.post(
|
||||
'/:eventId/door-checkin',
|
||||
requireAuth([...STAFF_ROLES]),
|
||||
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
|
||||
zValidator('json', doorCheckinSchema),
|
||||
async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
@@ -595,7 +583,7 @@ doorRouter.post(
|
||||
|
||||
doorRouter.post(
|
||||
'/:eventId/door-checkin/undo',
|
||||
requireAuth([...STAFF_ROLES]),
|
||||
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
|
||||
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
|
||||
async (c) => {
|
||||
const { idempotencyKey } = c.req.valid('json');
|
||||
@@ -662,7 +650,7 @@ doorRouter.post(
|
||||
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
||||
// pre-sale/door split the event dashboard shows.
|
||||
|
||||
doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async (c) => {
|
||||
doorRouter.get('/:eventId/door-summary', requireEventPermission('view_payments', { globalRoles: REVENUE_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const event = await loadEvent(eventId);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam, eventFromQuery } from '../lib/eventPermissions.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||
@@ -58,7 +59,7 @@ function safeParseVariables(raw: any): any[] {
|
||||
// ==================== Template Routes ====================
|
||||
|
||||
// Get all email templates
|
||||
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/templates', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const templates = await dbAll<any>(
|
||||
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
|
||||
);
|
||||
@@ -239,7 +240,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
||||
// ==================== Email Sending Routes ====================
|
||||
|
||||
// Send email using template to event attendees (non-blocking, queued)
|
||||
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.post('/send/event/:eventId', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const { eventId } = c.req.param();
|
||||
const user = (c as any).get('user');
|
||||
const body = await c.req.json();
|
||||
@@ -286,7 +287,7 @@ emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidato
|
||||
});
|
||||
|
||||
// Preview email (render template without sending)
|
||||
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.post('/preview', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { templateSlug, variables, locale } = body;
|
||||
|
||||
@@ -327,7 +328,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
// ==================== Email Logs Routes ====================
|
||||
|
||||
// Get email logs
|
||||
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/logs', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
const status = c.req.query('status');
|
||||
const search = c.req.query('search');
|
||||
@@ -420,7 +421,7 @@ emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async
|
||||
});
|
||||
|
||||
// Get email stats
|
||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/stats', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
|
||||
// URL to run the same suite in a throwaway schema (dropped afterwards).
|
||||
const PG_URL = process.env.FINANCE_TEST_PG_URL;
|
||||
const PG_SCHEMA = `fintest_${Date.now()}`;
|
||||
if (PG_URL) {
|
||||
process.env.DB_TYPE = 'postgres';
|
||||
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
|
||||
} else {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'finance-test-'));
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = join(dir, 'test.db');
|
||||
}
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'finance-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
type TestUser = { id: string; name: string; role: string; languagePreference?: string | null };
|
||||
const ADMIN: TestUser = { id: randomUUID(), name: 'The Admin', role: 'admin' };
|
||||
const ORGANIZER: TestUser = { id: randomUUID(), name: 'The Organizer', role: 'organizer' };
|
||||
const COLLAB: TestUser = { id: randomUUID(), name: 'Pilates Studio', role: 'user' };
|
||||
const COMANAGER: TestUser = { id: randomUUID(), name: 'Co Manager', role: 'user' };
|
||||
const DOOR: TestUser = { id: randomUUID(), name: 'Door Helper', role: 'user' };
|
||||
const STRANGER: TestUser = { id: randomUUID(), name: 'Stranger', role: 'user' };
|
||||
|
||||
// Session auth is Better Auth's concern and has its own suite; this keeps the
|
||||
// role and membership checks real.
|
||||
let currentUser: TestUser = ADMIN;
|
||||
vi.mock('../lib/auth.js', () => ({
|
||||
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||
c.set('user', currentUser);
|
||||
await next();
|
||||
},
|
||||
getAuthUser: async () => currentUser,
|
||||
}));
|
||||
vi.mock('../lib/email.js', () => ({ default: {} }));
|
||||
|
||||
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = currentUser;
|
||||
currentUser = user;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
currentUser = previous;
|
||||
}
|
||||
}
|
||||
|
||||
let app: any;
|
||||
let dbm: any;
|
||||
|
||||
const EVENT_ID = randomUUID();
|
||||
const OTHER_EVENT_ID = randomUUID();
|
||||
const SEED_USER_ID = randomUUID();
|
||||
const PRICE = 100000;
|
||||
|
||||
async function call(method: string, path: string, body?: unknown) {
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const type = res.headers.get('content-type') || '';
|
||||
return { status: res.status, type, body: type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer()) };
|
||||
}
|
||||
const get = (p: string) => call('GET', p);
|
||||
const post = (p: string, b: unknown = {}) => call('POST', p, b);
|
||||
const put = (p: string, b: unknown) => call('PUT', p, b);
|
||||
|
||||
// Seeded through drizzle so the same rows work on both engines.
|
||||
async function seedTicket(label: string, eventId: string, opts: { status: string; paymentStatus: string; pay?: { amount: number; provider: string; source?: string; status?: string } }) {
|
||||
const { db, tickets, payments } = dbm;
|
||||
const now = dbm.getNow();
|
||||
const id = randomUUID();
|
||||
await db.insert(tickets).values({
|
||||
id, userId: SEED_USER_ID, eventId, attendeeFirstName: `Guest ${label}`, attendeeLastName: 'Test',
|
||||
attendeeEmail: `${label}@test.py`, attendeePhone: '+595 981 000 000', status: opts.status,
|
||||
paymentStatus: opts.paymentStatus, isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
|
||||
});
|
||||
if (opts.pay) {
|
||||
await db.insert(payments).values({
|
||||
id: randomUUID(), ticketId: id, provider: opts.pay.provider, amount: opts.pay.amount, currency: 'PYG',
|
||||
status: opts.pay.status || 'paid', source: opts.pay.source || 'presale', paidAt: now, createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
return (async () => {
|
||||
const { Hono } = await import('hono');
|
||||
app = new Hono();
|
||||
app.route('/api/events', (await import('./door.js')).default);
|
||||
app.route('/api/events', (await import('./eventFinance.js')).default);
|
||||
app.route('/api/events', (await import('./events.js')).default);
|
||||
app.route('/api/finance', (await import('./finance.js')).default);
|
||||
app.route('/api/dashboard', (await import('./dashboard.js')).default);
|
||||
|
||||
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')) };
|
||||
const { db, users, events } = dbm;
|
||||
const now = dbm.getNow();
|
||||
for (const u of [ADMIN, ORGANIZER, COLLAB, COMANAGER, DOOR, STRANGER, { id: SEED_USER_ID, name: 'Seed', role: 'user' }]) {
|
||||
await db.insert(users).values({
|
||||
id: u.id, email: `${u.name.toLowerCase().replace(/ /g, '.')}@test.py`, name: u.name, role: u.role,
|
||||
isClaimed: dbm.toDbBool(true), accountStatus: 'active', createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
for (const [id, title] of [[EVENT_ID, 'Morning Club: Pilates Edition'], [OTHER_EVENT_ID, 'Some Other Event']]) {
|
||||
await db.insert(events).values({
|
||||
id, title, description: 'desc', startDatetime: now, location: 'Studio Uno', price: PRICE, currency: 'PYG',
|
||||
capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// 10 pre-sale TPago tickets (8 checked in), 2 cash walk-ins at the door, 1 refund.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await seedTicket(`t${i}`, EVENT_ID, { status: i < 8 ? 'checked_in' : 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
|
||||
}
|
||||
await seedTicket('door1', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||
await seedTicket('door2', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||
await seedTicket('refunded', EVENT_ID, { status: 'cancelled', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago', status: 'refunded' } });
|
||||
await seedTicket('other1', OTHER_EVENT_ID, { status: 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
|
||||
});
|
||||
|
||||
describe('event finance access', () => {
|
||||
it('lets admins in and keeps organizers out until they are granted access', async () => {
|
||||
expect((await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
|
||||
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(403);
|
||||
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
|
||||
const perms = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/my-permissions`));
|
||||
expect(perms.body.permissions).toContain('view_payments');
|
||||
expect(perms.body.permissions).not.toContain('view_finance');
|
||||
});
|
||||
|
||||
it('lets an admin add team members, and writes the audit log', async () => {
|
||||
const add = (userId: string, rolePreset: string, permissions?: Record<string, boolean>) =>
|
||||
as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId, rolePreset, permissions }));
|
||||
expect((await add(COLLAB.id, 'collaborator')).status).toBe(201);
|
||||
expect((await add(COMANAGER.id, 'co_manager')).status).toBe(201);
|
||||
expect((await add(DOOR.id, 'staff')).status).toBe(201);
|
||||
expect((await add(COLLAB.id, 'staff')).status).toBe(409);
|
||||
|
||||
const candidates = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members/candidates?q=stran`));
|
||||
expect(candidates.body.users.map((u: any) => u.id)).toEqual([STRANGER.id]);
|
||||
|
||||
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
|
||||
expect(log.body.entries.filter((e: any) => e.entityType === 'member' && e.action === 'create')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a collaborator only their events and permitted routes', async () => {
|
||||
const mine = await as(COLLAB, () => get('/api/dashboard/my-events'));
|
||||
expect(mine.status).toBe(200);
|
||||
expect(mine.body.events.map((e: any) => e.event.id)).toEqual([EVENT_ID]);
|
||||
expect(mine.body.events[0].permissions).toEqual(['view_overview', 'view_finance']);
|
||||
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
|
||||
// Other events' data
|
||||
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/attendees`))).status).toBe(403);
|
||||
// Routes on their own event that the preset does not grant
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/attendees`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-summary`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/audit-log`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'x', unitAmount: 1 }))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}`, { title: 'Hacked' }))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get('/api/finance/overview'))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get('/api/finance/settings/expense-templates'))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('shows staff members attendee names without contact details', async () => {
|
||||
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.attendees.length).toBeGreaterThan(0);
|
||||
expect(res.body.attendees.every((a: any) => a.attendeeEmail === null && a.attendeePhone === null)).toBe(true);
|
||||
const door = await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`));
|
||||
expect(door.status).toBe(200);
|
||||
expect(door.body.attendees.every((a: any) => a.email === null)).toBe(true);
|
||||
// Admins still get everything
|
||||
const full = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||
expect(full.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies per-member overrides in both directions', async () => {
|
||||
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
|
||||
const door = list.body.members.find((m: any) => m.userId === DOOR.id);
|
||||
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${door.id}`, { permissions: { view_attendees_pii: true, check_in: false } }));
|
||||
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
|
||||
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||
expect(res.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Morning Club: Pilates Edition finance flow', () => {
|
||||
let packId = '';
|
||||
let partnerId = '';
|
||||
|
||||
it('builds a template pack in settings', async () => {
|
||||
const cats = await as(ADMIN, () => get('/api/finance/settings/expense-categories'));
|
||||
const venue = cats.body.categories.find((c: any) => c.nameEn === 'Venue');
|
||||
const make = (body: any) => as(ADMIN, () => post('/api/finance/settings/expense-templates', body));
|
||||
const studio = await make({ name: 'Studio minimum spend', categoryId: venue.id, calcType: 'minimum_spend', amount: 30000, minimumAmount: 500000 });
|
||||
const mats = await make({ name: 'Mat rental', calcType: 'per_checked_in', amount: 5000 });
|
||||
const instructor = await make({ name: 'Instructor', calcType: 'fixed', amount: 300000 });
|
||||
const promo = await make({ name: 'Promo share', calcType: 'percent_of_revenue', percentBp: 500 });
|
||||
expect([studio, mats, instructor, promo].map((r) => r.status)).toEqual([201, 201, 201, 201]);
|
||||
|
||||
const pack = await as(ADMIN, () => post('/api/finance/settings/expense-template-packs', {
|
||||
name: 'Morning Club pack', templateIds: [studio.body.template.id, mats.body.template.id, instructor.body.template.id, promo.body.template.id],
|
||||
}));
|
||||
expect(pack.status).toBe(201);
|
||||
packId = pack.body.pack.id;
|
||||
expect(pack.body.pack.templateIds).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('applies the pack to the event with amounts from current counts', async () => {
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses/apply-template`, { packId }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.expenses.map((e: any) => [e.description, e.computedAmount])).toEqual([
|
||||
['Studio minimum spend', 500000], // 10 checked in x 30k = 300k < 500k minimum
|
||||
['Mat rental', 50000],
|
||||
['Instructor', 300000],
|
||||
['Promo share', 62000], // 5% of 1,240,000 sales after the refund
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a partner at a percent of profit and computes the split', async () => {
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, {
|
||||
userId: COLLAB.id, roleLabel: 'Studio', shareType: 'percent_profit', percentBp: 3000, lossRule: 'proportional',
|
||||
}));
|
||||
expect(res.status).toBe(201);
|
||||
partnerId = res.body.partner.id;
|
||||
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
const s = fin.body.summary;
|
||||
expect(s.revenue.gross).toBe(1340000);
|
||||
expect(s.revenue.refunds).toBe(100000);
|
||||
expect(s.revenue.presale).toBe(1100000);
|
||||
expect(s.revenue.door).toBe(240000);
|
||||
expect(s.expenses.total).toBe(912000);
|
||||
expect(s.profit).toBe(1240000 - 912000);
|
||||
expect(s.split.partners[0].share).toBe(Math.round(328000 * 0.3));
|
||||
expect(s.split.organization).toBe(328000 - 98400);
|
||||
expect(s.breakEven.tickets).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the collaborator their own share but not the full split', async () => {
|
||||
const fin = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.body.viewer.fullSplit).toBe(false);
|
||||
expect(fin.body.summary.split.organization).toBeNull();
|
||||
expect(fin.body.summary.split.partners.map((p: any) => p.partnerId)).toEqual([partnerId]);
|
||||
expect(fin.body.summary.waterfall.some((w: any) => w.key === 'organization')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets an organizer in once they are granted finance on this event', async () => {
|
||||
await as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId: ORGANIZER.id, rolePreset: 'collaborator', permissions: { view_full_split: true } }));
|
||||
const fin = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.status).toBe(200);
|
||||
expect(fin.body.viewer.fullSplit).toBe(true);
|
||||
expect((await as(ORGANIZER, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('finalizes, freezes the numbers and blocks edits', async () => {
|
||||
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/finance/finalize`))).status).toBe(403);
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/finance/finalize`));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// A late walk-in no longer moves the finalized numbers
|
||||
await seedTicket('late', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.body.status).toBe('finalized');
|
||||
expect(fin.body.live).toBe(false);
|
||||
expect(fin.body.summary.revenue.gross).toBe(1340000);
|
||||
|
||||
const blocked = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Late cost', unitAmount: 1000 }));
|
||||
expect(blocked.status).toBe(409);
|
||||
expect(blocked.body.code).toBe('FINANCE_FINALIZED');
|
||||
});
|
||||
|
||||
it('exports the partner statement to the partner and not to others', async () => {
|
||||
const pdf = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement?locale=es`));
|
||||
expect(pdf.status).toBe(200);
|
||||
expect(pdf.type).toBe('application/pdf');
|
||||
expect((pdf.body as Buffer).subarray(0, 4).toString()).toBe('%PDF');
|
||||
|
||||
const other = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, { externalName: 'Someone', shareType: 'fixed', fixedAmount: 1 }));
|
||||
expect(other.status).toBe(409); // finalized
|
||||
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement`))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('marks the payout and moves the event to paid out', async () => {
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners/${partnerId}/mark-paid`, { paid: true, payoutMethod: 'transfer' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('paid_out');
|
||||
expect(res.body.partner.payoutStatus).toBe('paid');
|
||||
});
|
||||
|
||||
it('only lets admins and co-managers unfinalize, and logs it', async () => {
|
||||
expect((await as(ORGANIZER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(403);
|
||||
expect((await as(COMANAGER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(200);
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.body.status).toBe('open');
|
||||
expect(fin.body.summary.revenue.gross).toBe(1460000); // the late walk-in now counts
|
||||
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
|
||||
const actions = log.body.entries.filter((e: any) => e.entityType === 'finance_state').map((e: any) => e.action);
|
||||
expect(actions).toEqual(expect.arrayContaining(['finalize', 'paid_out', 'unfinalize']));
|
||||
});
|
||||
|
||||
it('limits edit_own_expenses_only members to their own rows', async () => {
|
||||
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
|
||||
const collab = list.body.members.find((m: any) => m.userId === COLLAB.id);
|
||||
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${collab.id}`, { permissions: { edit_own_expenses_only: true } }));
|
||||
const own = await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Flowers', unitAmount: 40000, status: 'paid', paidByPartnerId: partnerId }));
|
||||
expect(own.status).toBe(201);
|
||||
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${own.body.expense.id}`, { unitAmount: 45000 }))).status).toBe(200);
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
const adminRow = fin.body.expenses.find((e: any) => e.createdBy === ADMIN.id);
|
||||
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${adminRow.id}`, { unitAmount: 1 }))).status).toBe(403);
|
||||
// The reimbursement shows up on the partner line
|
||||
expect(fin.body.summary.split.partners[0].reimbursement).toBe(45000);
|
||||
});
|
||||
|
||||
it('includes the event in the cross-event overview', async () => {
|
||||
await as(ADMIN, () => put(`/api/events/${EVENT_ID}`, { series: 'Morning Club' }));
|
||||
const res = await as(ADMIN, () => get('/api/finance/overview?series=Morning%20Club'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.events.map((e: any) => e.id)).toEqual([EVENT_ID]);
|
||||
expect(res.body.bySeries[0].series).toBe('Morning Club');
|
||||
expect(res.body.byPartner[0].name).toBe('Pilates Studio');
|
||||
expect(res.body.filters.series).toEqual(['Morning Club']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-event overview: ready to close', () => {
|
||||
const PAST_QUIET = 'evt-past-quiet';
|
||||
const FUTURE = 'evt-future';
|
||||
const PAST_DRAFT = 'evt-past-draft';
|
||||
|
||||
beforeAll(async () => {
|
||||
const { db, events } = dbm;
|
||||
const now = dbm.getNow();
|
||||
const at = (iso: string) => dbm.toDbDate(iso);
|
||||
for (const [id, title, start, status] of [
|
||||
[PAST_QUIET, 'Quiet past event', '2026-01-10T20:00:00Z', 'published'],
|
||||
[FUTURE, 'Future event', '2099-01-10T20:00:00Z', 'published'],
|
||||
[PAST_DRAFT, 'Past draft', '2026-01-11T20:00:00Z', 'draft'],
|
||||
] as const) {
|
||||
await db.insert(events).values({
|
||||
id, title, description: 'desc', startDatetime: at(start), location: 'Studio Uno', price: PRICE, currency: 'PYG',
|
||||
capacity: 40, status, externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lists past events with open books even with no money, and keeps them out of the totals', async () => {
|
||||
const res = await as(ADMIN, () => get('/api/finance/overview'));
|
||||
expect(res.status).toBe(200);
|
||||
const ready = res.body.readyToClose.map((e: any) => e.id);
|
||||
expect(ready).toContain(PAST_QUIET);
|
||||
expect(ready).not.toContain(FUTURE);
|
||||
expect(ready).not.toContain(PAST_DRAFT);
|
||||
expect(res.body.events.map((e: any) => e.id)).not.toContain(PAST_QUIET);
|
||||
// Longest-waiting first
|
||||
const starts = res.body.readyToClose.map((e: any) => e.startDatetime);
|
||||
expect([...starts].sort()).toEqual(starts);
|
||||
});
|
||||
|
||||
it('respects the venue filter', async () => {
|
||||
const res = await as(ADMIN, () => get('/api/finance/overview?venue=Nowhere'));
|
||||
expect(res.body.readyToClose).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,936 @@
|
||||
// Event finance, partners and team access, all scoped to one event and
|
||||
// mounted under /api/events:
|
||||
//
|
||||
// GET /:id/my-permissions what the current user may do here
|
||||
// GET /:id/finance P&L summary, chart data and the rows behind it
|
||||
// POST /:id/finance/finalize | unfinalize freeze / reopen the numbers
|
||||
// CRUD /:id/expenses (+ /apply-template) costs, manual or from templates / packs
|
||||
// CRUD /:id/other-income sponsors, venue kickbacks, ...
|
||||
// CRUD /:id/partners (+ /:pid/mark-paid, /:pid/statement PDF)
|
||||
// CRUD /:id/members (+ /candidates) per-event team access
|
||||
// GET /:id/audit-log
|
||||
//
|
||||
// Every route is guarded by requireEventPermission; only global admins pass on
|
||||
// every event. Every write commits together with its finance_audit_log row.
|
||||
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, desc, eq, inArray, notInArray, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbAll, dbGet, users, eventExpenses, eventOtherIncome, eventPartners, eventFinanceState, eventMembers,
|
||||
expenseCategories, expenseTemplates, expenseTemplatePackItems, financeAuditLog,
|
||||
} from '../db/index.js';
|
||||
import { requireAuth, type AuthUser } from '../lib/auth.js';
|
||||
import {
|
||||
requireEventPermission, getEventAccess, getEffectivePermissions, canUnfinalize, resolveMemberPermissions,
|
||||
parseOverrides, EVENT_PERMISSIONS, ROLE_PRESETS, type EventPermission,
|
||||
} from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
||||
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||
import { financeAuditOp } from '../lib/finance/audit.js';
|
||||
import {
|
||||
getEventFinance, getEvent, getFinanceState, serializeExpense, serializeIncome, serializePartner, iso, pyg, bool,
|
||||
loadPartners, type SerializedExpense,
|
||||
} from '../lib/finance/load.js';
|
||||
import { expenseAmount, isAutoCalc, CALC_TYPES, SHARE_TYPES, LOSS_RULES } from '../lib/finance/calculate.js';
|
||||
import { generatePartnerStatementPDF } from '../lib/finance/statementPdf.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
const financeRouter = new Hono();
|
||||
|
||||
const validationHook = (result: any, c: any) => {
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||
return c.json({ error: errors }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_PYG = 2_000_000_000;
|
||||
const money = z.number().int().min(0).max(MAX_PYG);
|
||||
const bp = z.number().int().min(0).max(10000);
|
||||
// Receipts come from the media upload (/uploads/...) or an external link; never javascript: etc.
|
||||
const receiptUrl = z.string().max(500).regex(/^(https?:\/\/|\/uploads\/)/, 'must be an http(s) or /uploads/ URL');
|
||||
|
||||
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
|
||||
const can = (c: Context, key: EventPermission) => !!getEventAccess(c)?.permissions.has(key);
|
||||
|
||||
async function requireEvent(c: Context) {
|
||||
const event = await getEvent(c.req.param('id')!);
|
||||
return event || null;
|
||||
}
|
||||
|
||||
/** 409 while the numbers are frozen. */
|
||||
async function assertOpen(c: Context, eventId: string) {
|
||||
const state = await getFinanceState(eventId);
|
||||
if (state.status !== 'open') {
|
||||
return c.json({ error: 'Finance is finalized for this event. Unfinalize it to make changes.', code: 'FINANCE_FINALIZED' }, 409);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts and sales the auto-calculated expenses follow right now. */
|
||||
async function liveContext(eventId: string, event: any) {
|
||||
const fin = await getEventFinance(eventId, event);
|
||||
return {
|
||||
ticketsSold: fin!.result.counts.ticketsSold,
|
||||
checkedIn: fin!.result.counts.checkedIn,
|
||||
sales: fin!.result.revenue.sales,
|
||||
};
|
||||
}
|
||||
|
||||
async function partnerBelongsToEvent(partnerId: string, eventId: string) {
|
||||
const row = await dbGet<any>(
|
||||
(db as any).select({ id: (eventPartners as any).id }).from(eventPartners)
|
||||
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function categoryExists(categoryId: string) {
|
||||
const row = await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId)));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// ==================== Permissions for the UI ====================
|
||||
|
||||
financeRouter.get('/:id/my-permissions', requireAuth(), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const access = await getEffectivePermissions(currentUser(c), event.id);
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
global: access.global,
|
||||
role: access.role,
|
||||
rolePreset: access.membership?.rolePreset ?? null,
|
||||
permissions: EVENT_PERMISSIONS.filter((p) => access.permissions.has(p)),
|
||||
canUnfinalize: canUnfinalize(access),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Summary ====================
|
||||
|
||||
/**
|
||||
* Collaborators without view_full_split see the event's P&L and their own
|
||||
* partner line only: other partners and the organization's remainder are removed.
|
||||
*/
|
||||
function scopeToOwnShare<T extends NonNullable<Awaited<ReturnType<typeof getEventFinance>>>>(fin: T, userId: string) {
|
||||
const own = new Set(fin.partners.filter((p) => p.userId === userId).map((p) => p.id));
|
||||
return {
|
||||
...fin,
|
||||
partners: fin.partners.filter((p) => own.has(p.id)),
|
||||
expenses: fin.expenses.map((e) => (e.paidByPartnerId && !own.has(e.paidByPartnerId) ? { ...e, paidByPartnerId: 'other' } : e)),
|
||||
result: {
|
||||
...fin.result,
|
||||
split: {
|
||||
distributable: null,
|
||||
organization: null,
|
||||
partners: fin.result.split.partners.filter((p) => own.has(p.partnerId)),
|
||||
},
|
||||
waterfall: fin.result.waterfall.filter((w) =>
|
||||
w.key === 'organization' ? false : w.key.startsWith('partner:') ? own.has(w.key.slice(8)) : true),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
financeRouter.get('/:id/finance', requireEventPermission('view_finance'), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const fin = await getEventFinance(event.id, event);
|
||||
if (!fin) return c.json({ error: 'Event not found' }, 404);
|
||||
|
||||
const lines = new Map(fin.result.expenses.lines.map((l) => [l.id, l]));
|
||||
const withAmounts = {
|
||||
...fin,
|
||||
expenses: fin.expenses.map((e) => ({
|
||||
...e,
|
||||
amount: lines.get(e.id)?.amount ?? e.computedAmount,
|
||||
liveQuantity: lines.get(e.id)?.quantity ?? e.quantity,
|
||||
auto: lines.get(e.id)?.auto ?? false,
|
||||
})),
|
||||
};
|
||||
const fullSplit = can(c, 'view_full_split');
|
||||
const scoped = fullSplit ? withAmounts : scopeToOwnShare(withAmounts, currentUser(c).id);
|
||||
|
||||
const categories = await dbAll<any>((db as any).select().from(expenseCategories));
|
||||
const access = getEventAccess(c)!;
|
||||
|
||||
return c.json({
|
||||
event: omitWalkInPrice({
|
||||
id: event.id, title: event.title, titleEs: event.titleEs, startDatetime: iso(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? iso(event.endDatetime) : null,
|
||||
location: event.location, series: event.series ?? null, price: pyg(event.price), currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
}),
|
||||
status: fin.state.status,
|
||||
finalizedAt: fin.state.finalizedAt,
|
||||
live: fin.live,
|
||||
computedAt: fin.computedAt,
|
||||
summary: scoped.result,
|
||||
expenses: scoped.expenses,
|
||||
otherIncome: scoped.otherIncome,
|
||||
partners: scoped.partners,
|
||||
categories: categories
|
||||
.map((cat: any) => ({ id: cat.id, nameEn: cat.nameEn, nameEs: cat.nameEs, color: cat.color, sortOrder: pyg(cat.sortOrder), archived: bool(cat.archived) }))
|
||||
.sort((a: any, b: any) => a.sortOrder - b.sortOrder),
|
||||
viewer: {
|
||||
fullSplit,
|
||||
canEditExpenses: access.permissions.has('edit_expenses'),
|
||||
canEditOwnExpenses: access.permissions.has('edit_own_expenses_only'),
|
||||
canManageSplit: fullSplit && access.permissions.has('edit_expenses'),
|
||||
canUnfinalize: canUnfinalize(access),
|
||||
userId: currentUser(c).id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Expenses ====================
|
||||
|
||||
const expenseFields = {
|
||||
description: z.string().trim().min(1).max(300),
|
||||
categoryId: z.string().nullable().optional(),
|
||||
calcType: z.enum(CALC_TYPES),
|
||||
quantity: z.number().int().min(0).max(1_000_000),
|
||||
unitAmount: money,
|
||||
percentBp: bp,
|
||||
minimumAmount: money,
|
||||
computedAmount: money.optional(),
|
||||
isLocked: z.boolean(),
|
||||
status: z.enum(['planned', 'paid']),
|
||||
paidByPartnerId: z.string().nullable().optional(),
|
||||
receiptUrl: receiptUrl.nullable().optional(),
|
||||
expenseDate: z.string().nullable().optional(),
|
||||
};
|
||||
const createExpenseSchema = z.object({
|
||||
...expenseFields,
|
||||
calcType: expenseFields.calcType.default('fixed'),
|
||||
quantity: expenseFields.quantity.default(1),
|
||||
unitAmount: money.default(0),
|
||||
percentBp: bp.default(0),
|
||||
minimumAmount: money.default(0),
|
||||
isLocked: z.boolean().default(false),
|
||||
status: expenseFields.status.default('planned'),
|
||||
});
|
||||
const updateExpenseSchema = z.object(expenseFields).partial();
|
||||
|
||||
const EXPENSE_EDITORS = ['edit_expenses', 'edit_own_expenses_only'] as const;
|
||||
|
||||
/** Members limited to their own expenses may only touch rows they created. */
|
||||
function canEditRow(c: Context, row: any) {
|
||||
return can(c, 'edit_expenses') || row.createdBy === currentUser(c).id;
|
||||
}
|
||||
|
||||
type LiveContext = { ticketsSold: number; checkedIn: number; sales: number };
|
||||
|
||||
/**
|
||||
* Quantity and amount to store for a row. Unlocked rows follow the live counts.
|
||||
* A locked row keeps its frozen amount; locking freezes the amount it shows at
|
||||
* that moment unless one is typed in (`typed`).
|
||||
*/
|
||||
function storedAmount(row: any, ctx: LiveContext, opts: { typed?: number; wasLocked?: boolean; frozen?: { quantity: number; amount: number } } = {}) {
|
||||
const live = expenseAmount({ ...row, isLocked: false }, ctx);
|
||||
const quantity = isAutoCalc(row.calcType) ? live.quantity : row.quantity;
|
||||
if (!row.isLocked) return { quantity, computedAmount: live.amount };
|
||||
if (opts.typed !== undefined) return { quantity, computedAmount: opts.typed };
|
||||
if (opts.wasLocked && opts.frozen) return { quantity: opts.frozen.quantity, computedAmount: opts.frozen.amount };
|
||||
return { quantity, computedAmount: live.amount };
|
||||
}
|
||||
|
||||
async function validateExpenseRefs(c: Context, eventId: string, data: { categoryId?: string | null; paidByPartnerId?: string | null }) {
|
||||
if (data.categoryId && !(await categoryExists(data.categoryId))) {
|
||||
return c.json({ error: 'Unknown expense category' }, 400);
|
||||
}
|
||||
if (data.paidByPartnerId && !(await partnerBelongsToEvent(data.paidByPartnerId, eventId))) {
|
||||
return c.json({ error: 'paidByPartnerId must be a partner of this event' }, 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
financeRouter.post('/:id/expenses', requireEventPermission(EXPENSE_EDITORS), zValidator('json', createExpenseSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const data = c.req.valid('json');
|
||||
const bad = await validateExpenseRefs(c, event.id, data);
|
||||
if (bad) return bad;
|
||||
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const ctx = await liveContext(event.id, event);
|
||||
const amounts = storedAmount(data, ctx, { typed: data.computedAmount });
|
||||
const values = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
categoryId: data.categoryId || null,
|
||||
templateId: null,
|
||||
description: data.description,
|
||||
calcType: data.calcType,
|
||||
quantity: amounts.quantity,
|
||||
unitAmount: data.unitAmount,
|
||||
percentBp: data.percentBp,
|
||||
minimumAmount: data.minimumAmount,
|
||||
computedAmount: amounts.computedAmount,
|
||||
isLocked: toDbBool(data.isLocked),
|
||||
status: data.status,
|
||||
paidByPartnerId: data.paidByPartnerId || null,
|
||||
receiptUrl: data.receiptUrl || null,
|
||||
expenseDate: data.expenseDate ? toDbDate(data.expenseDate) : null,
|
||||
createdBy: user.id,
|
||||
updatedBy: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await runOps([
|
||||
insertOp(eventExpenses, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: values.id, action: 'create', after: serializeExpense(values) }),
|
||||
]);
|
||||
return c.json({ expense: serializeExpense(values) }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), zValidator('json', updateExpenseSchema, validationHook), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const event = await getEvent(eventId);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventExpenses)
|
||||
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Expense not found' }, 404);
|
||||
if (!canEditRow(c, existing)) return c.json({ error: 'You can only edit expenses you added', code: 'EVENT_PERMISSION' }, 403);
|
||||
|
||||
const data = c.req.valid('json');
|
||||
const bad = await validateExpenseRefs(c, eventId, data);
|
||||
if (bad) return bad;
|
||||
|
||||
const before = serializeExpense(existing);
|
||||
const merged = { ...before, ...Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)) } as any;
|
||||
const ctx = await liveContext(eventId, event);
|
||||
const amounts = storedAmount(merged, ctx, {
|
||||
typed: data.computedAmount,
|
||||
wasLocked: before.isLocked,
|
||||
frozen: { quantity: before.quantity, amount: before.computedAmount },
|
||||
});
|
||||
const user = currentUser(c);
|
||||
const updates: Record<string, any> = {
|
||||
description: merged.description,
|
||||
categoryId: merged.categoryId || null,
|
||||
calcType: merged.calcType,
|
||||
quantity: amounts.quantity,
|
||||
unitAmount: merged.unitAmount,
|
||||
percentBp: merged.percentBp,
|
||||
minimumAmount: merged.minimumAmount,
|
||||
computedAmount: amounts.computedAmount,
|
||||
isLocked: toDbBool(!!merged.isLocked),
|
||||
status: merged.status,
|
||||
paidByPartnerId: merged.paidByPartnerId || null,
|
||||
receiptUrl: merged.receiptUrl || null,
|
||||
expenseDate: merged.expenseDate ? toDbDate(merged.expenseDate) : null,
|
||||
updatedBy: user.id,
|
||||
updatedAt: getNow(),
|
||||
};
|
||||
const after = serializeExpense({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(eventExpenses, updates, eq((eventExpenses as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'update', before, after }),
|
||||
]);
|
||||
return c.json({ expense: after });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventExpenses)
|
||||
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Expense not found' }, 404);
|
||||
if (!canEditRow(c, existing)) return c.json({ error: 'You can only delete expenses you added', code: 'EVENT_PERMISSION' }, 403);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
deleteOp(eventExpenses, eq((eventExpenses as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'delete', before: serializeExpense(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Expense deleted' });
|
||||
});
|
||||
|
||||
const applyTemplateSchema = z.object({
|
||||
templateId: z.string().optional(),
|
||||
packId: z.string().optional(),
|
||||
}).refine((d) => !!d.templateId !== !!d.packId, { message: 'Provide exactly one of templateId or packId' });
|
||||
|
||||
financeRouter.post('/:id/expenses/apply-template', requireEventPermission(EXPENSE_EDITORS), zValidator('json', applyTemplateSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const { templateId, packId } = c.req.valid('json');
|
||||
|
||||
let templateIds: string[];
|
||||
if (packId) {
|
||||
const items = await dbAll<any>((db as any).select().from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).packId, packId)));
|
||||
if (items.length === 0) return c.json({ error: 'Template pack not found or empty' }, 404);
|
||||
templateIds = items.sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId);
|
||||
} else {
|
||||
templateIds = [templateId!];
|
||||
}
|
||||
const templates = await dbAll<any>((db as any).select().from(expenseTemplates).where(inArray((expenseTemplates as any).id, templateIds)));
|
||||
const byId = new Map(templates.filter((t: any) => !bool(t.archived)).map((t: any) => [t.id, t]));
|
||||
const ordered = templateIds.map((id) => byId.get(id)).filter(Boolean) as any[];
|
||||
if (ordered.length === 0) return c.json({ error: 'Template not found' }, 404);
|
||||
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const ctx = await liveContext(event.id, event);
|
||||
const ops: TxOp[] = [];
|
||||
const created: SerializedExpense[] = [];
|
||||
for (const t of ordered) {
|
||||
const row: any = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
categoryId: t.categoryId || null,
|
||||
templateId: t.id,
|
||||
description: t.name,
|
||||
calcType: t.calcType,
|
||||
quantity: 1,
|
||||
unitAmount: pyg(t.amount),
|
||||
percentBp: pyg(t.percentBp),
|
||||
minimumAmount: pyg(t.minimumAmount),
|
||||
computedAmount: 0,
|
||||
isLocked: toDbBool(false),
|
||||
status: 'planned',
|
||||
paidByPartnerId: null,
|
||||
receiptUrl: null,
|
||||
expenseDate: null,
|
||||
createdBy: user.id,
|
||||
updatedBy: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const amounts = storedAmount(row, ctx);
|
||||
row.quantity = amounts.quantity;
|
||||
row.computedAmount = amounts.computedAmount;
|
||||
ops.push(insertOp(eventExpenses, row));
|
||||
const serialized = serializeExpense(row);
|
||||
created.push(serialized);
|
||||
ops.push(financeAuditOp({
|
||||
eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: row.id,
|
||||
action: packId ? 'apply_pack' : 'apply_template', after: { ...serialized, packId: packId ?? null },
|
||||
}));
|
||||
}
|
||||
await runOps(ops);
|
||||
return c.json({ expenses: created }, 201);
|
||||
});
|
||||
|
||||
// ==================== Other income ====================
|
||||
|
||||
const incomeSchema = z.object({ description: z.string().trim().min(1).max(300), amount: money });
|
||||
|
||||
financeRouter.post('/:id/other-income', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const data = c.req.valid('json');
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const values = { id: generateId(), eventId: event.id, description: data.description, amount: data.amount, createdBy: user.id, createdAt: now, updatedAt: now };
|
||||
await runOps([
|
||||
insertOp(eventOtherIncome, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'other_income', entityId: values.id, action: 'create', after: serializeIncome(values) }),
|
||||
]);
|
||||
return c.json({ income: serializeIncome(values) }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema.partial(), validationHook), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventOtherIncome)
|
||||
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Income not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
if (data.description !== undefined) updates.description = data.description;
|
||||
if (data.amount !== undefined) updates.amount = data.amount;
|
||||
const user = currentUser(c);
|
||||
const after = serializeIncome({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(eventOtherIncome, updates, eq((eventOtherIncome as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'update', before: serializeIncome(existing), after }),
|
||||
]);
|
||||
return c.json({ income: after });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventOtherIncome)
|
||||
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Income not found' }, 404);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
deleteOp(eventOtherIncome, eq((eventOtherIncome as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'delete', before: serializeIncome(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Income deleted' });
|
||||
});
|
||||
|
||||
// ==================== Partners ====================
|
||||
|
||||
const partnerFields = {
|
||||
userId: z.string().nullable().optional(),
|
||||
externalName: z.string().trim().max(200).nullable().optional(),
|
||||
roleLabel: z.string().trim().max(100).nullable().optional(),
|
||||
shareType: z.enum(SHARE_TYPES),
|
||||
percentBp: bp,
|
||||
fixedAmount: money,
|
||||
thresholdAmount: money,
|
||||
lossRule: z.enum(LOSS_RULES),
|
||||
lossCapAmount: money,
|
||||
};
|
||||
const createPartnerSchema = z.object({
|
||||
...partnerFields,
|
||||
percentBp: bp.default(0),
|
||||
fixedAmount: money.default(0),
|
||||
thresholdAmount: money.default(0),
|
||||
lossRule: partnerFields.lossRule.default('none'),
|
||||
lossCapAmount: money.default(0),
|
||||
}).refine((d) => !!d.userId || !!d.externalName, { message: 'A partner needs a linked user or a name' });
|
||||
const updatePartnerSchema = z.object(partnerFields).partial();
|
||||
|
||||
/** Editing the split needs both the expense editor and the full-split view. */
|
||||
function canManageSplit(c: Context) {
|
||||
return can(c, 'edit_expenses') && can(c, 'view_full_split');
|
||||
}
|
||||
const splitForbidden = (c: Context) =>
|
||||
c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
|
||||
|
||||
async function userExists(userId: string) {
|
||||
return !!(await dbGet<any>((db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).id, userId))));
|
||||
}
|
||||
|
||||
async function loadPartner(eventId: string, partnerId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select().from(eventPartners)
|
||||
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
|
||||
);
|
||||
}
|
||||
|
||||
// Users that can be linked to a partner (so the partner can log in and see their statement).
|
||||
financeRouter.get('/:id/partners/candidates', requireEventPermission('edit_expenses'), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const q = (c.req.query('q') || '').trim().toLowerCase();
|
||||
if (q.length < 2) return c.json({ users: [] });
|
||||
return c.json({ users: await searchUsers(q, []) });
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/partners', requireEventPermission('edit_expenses'), zValidator('json', createPartnerSchema, validationHook), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const data = c.req.valid('json');
|
||||
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const values = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
userId: data.userId || null,
|
||||
externalName: data.externalName || null,
|
||||
roleLabel: data.roleLabel || null,
|
||||
shareType: data.shareType,
|
||||
percentBp: data.percentBp,
|
||||
fixedAmount: data.fixedAmount,
|
||||
thresholdAmount: data.thresholdAmount,
|
||||
lossRule: data.lossRule,
|
||||
lossCapAmount: data.lossCapAmount,
|
||||
payoutStatus: 'pending',
|
||||
payoutDate: null,
|
||||
payoutMethod: null,
|
||||
payoutNote: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await runOps([
|
||||
insertOp(eventPartners, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'partner', entityId: values.id, action: 'create', after: serializePartner(values) }),
|
||||
]);
|
||||
const partner = (await loadPartners(event.id)).find((p) => p.id === values.id);
|
||||
return c.json({ partner }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), zValidator('json', updatePartnerSchema, validationHook), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
for (const [k, v] of Object.entries(data)) if (v !== undefined) updates[k] = v === '' ? null : v;
|
||||
const merged = { ...existing, ...updates };
|
||||
if (!merged.userId && !merged.externalName) return c.json({ error: 'A partner needs a linked user or a name' }, 400);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'update', before: serializePartner(existing), after: serializePartner(merged) }),
|
||||
]);
|
||||
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
|
||||
return c.json({ partner });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||
const fronted = await dbGet<any>(
|
||||
(db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).paidByPartnerId, existing.id))
|
||||
);
|
||||
if (fronted) {
|
||||
return c.json({ error: 'This partner paid for expenses. Change who paid those expenses first.', code: 'PARTNER_HAS_EXPENSES' }, 409);
|
||||
}
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
deleteOp(eventPartners, eq((eventPartners as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'delete', before: serializePartner(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Partner removed' });
|
||||
});
|
||||
|
||||
const markPaidSchema = z.object({
|
||||
paid: z.boolean().default(true),
|
||||
payoutDate: z.string().nullable().optional(),
|
||||
payoutMethod: z.string().trim().max(50).nullable().optional(),
|
||||
payoutNote: z.string().trim().max(1000).nullable().optional(),
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/partners/:partnerId/mark-paid', requireEventPermission('edit_expenses'), zValidator('json', markPaidSchema, validationHook), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const eventId = c.req.param('id');
|
||||
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||
const state = await getFinanceState(eventId);
|
||||
if (state.status === 'open') {
|
||||
return c.json({ error: 'Finalize the event before recording payouts, so they match the frozen numbers.', code: 'FINANCE_NOT_FINALIZED' }, 409);
|
||||
}
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
const updates = data.paid
|
||||
? {
|
||||
payoutStatus: 'paid',
|
||||
payoutDate: data.payoutDate ? toDbDate(data.payoutDate) : now,
|
||||
payoutMethod: data.payoutMethod || null,
|
||||
payoutNote: data.payoutNote || null,
|
||||
updatedAt: now,
|
||||
}
|
||||
: { payoutStatus: 'pending', payoutDate: null, payoutMethod: null, payoutNote: data.payoutNote ?? existing.payoutNote ?? null, updatedAt: now };
|
||||
|
||||
const user = currentUser(c);
|
||||
const ops: TxOp[] = [
|
||||
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
|
||||
financeAuditOp({
|
||||
eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: data.paid ? 'mark_paid' : 'mark_unpaid',
|
||||
before: serializePartner(existing), after: serializePartner({ ...existing, ...updates }),
|
||||
}),
|
||||
];
|
||||
// The event is paid out once every partner is.
|
||||
const all = await loadPartners(eventId);
|
||||
const allPaid = all.every((p) => (p.id === existing.id ? data.paid : p.payoutStatus === 'paid'));
|
||||
const nextStatus = allPaid ? 'paid_out' : 'finalized';
|
||||
if (nextStatus !== state.status) {
|
||||
ops.push(updateOp(eventFinanceState, { status: nextStatus, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)));
|
||||
ops.push(financeAuditOp({ eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: nextStatus, before: { status: state.status }, after: { status: nextStatus } }));
|
||||
}
|
||||
await runOps(ops);
|
||||
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
|
||||
return c.json({ partner, status: nextStatus });
|
||||
});
|
||||
|
||||
financeRouter.get('/:id/partners/:partnerId/statement', requireEventPermission('view_finance'), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const fin = await getEventFinance(event.id, event);
|
||||
const partner = fin!.partners.find((p) => p.id === c.req.param('partnerId'));
|
||||
if (!partner) return c.json({ error: 'Partner not found' }, 404);
|
||||
if (!can(c, 'view_full_split') && partner.userId !== currentUser(c).id) {
|
||||
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
|
||||
}
|
||||
const localeParam = c.req.query('locale');
|
||||
const locale = localeParam === 'es' || (!localeParam && currentUser(c).languagePreference === 'es') ? 'es' : 'en';
|
||||
const amounts = new Map(fin!.result.expenses.lines.map((l) => [l.id, l.amount]));
|
||||
const pdf = await generatePartnerStatementPDF({
|
||||
locale,
|
||||
event: { title: (locale === 'es' && event.titleEs) || event.title, startDatetime: event.startDatetime, location: event.location },
|
||||
finalized: fin!.state.status !== 'open',
|
||||
result: fin!.result,
|
||||
partner,
|
||||
line: fin!.result.split.partners.find((p) => p.partnerId === partner.id),
|
||||
frontedExpenses: fin!.expenses.filter((e) => e.paidByPartnerId === partner.id && e.status === 'paid'),
|
||||
expenseAmounts: amounts,
|
||||
});
|
||||
const safeName = partner.name.replace(/[^a-zA-Z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'partner';
|
||||
const slug = (event.slug || event.id).replace(/[^a-zA-Z0-9-_]+/g, '-');
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="statement-${slug}-${safeName}.pdf"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Finalize ====================
|
||||
|
||||
financeRouter.post('/:id/finance/finalize', requireEventPermission('edit_expenses'), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const fin = await getEventFinance(event.id, event);
|
||||
if (fin!.state.status !== 'open') return c.json({ error: 'Already finalized', code: 'FINANCE_FINALIZED' }, 409);
|
||||
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const snapshot = { version: 1 as const, computedAt: new Date().toISOString(), result: fin!.result };
|
||||
const ops: TxOp[] = [];
|
||||
// Persist the amounts auto rows had at finalize, so the rows read the same as the snapshot.
|
||||
const lines = new Map(fin!.result.expenses.lines.map((l) => [l.id, l]));
|
||||
for (const e of fin!.expenses) {
|
||||
const line = lines.get(e.id);
|
||||
if (line && (line.amount !== e.computedAmount || line.quantity !== e.quantity)) {
|
||||
ops.push(updateOp(eventExpenses, { computedAmount: line.amount, quantity: line.quantity }, eq((eventExpenses as any).id, e.id)));
|
||||
}
|
||||
}
|
||||
const stateValues = { status: 'finalized', finalizedAt: now, finalizedBy: user.id, snapshotJson: JSON.stringify(snapshot), updatedAt: now };
|
||||
ops.push(fin!.state.exists
|
||||
? updateOp(eventFinanceState, stateValues, eq((eventFinanceState as any).eventId, event.id))
|
||||
: insertOp(eventFinanceState, { eventId: event.id, ...stateValues }));
|
||||
ops.push(financeAuditOp({
|
||||
eventId: event.id, actorUserId: user.id, entityType: 'finance_state', entityId: event.id, action: 'finalize',
|
||||
before: { status: 'open' }, after: { status: 'finalized', profit: snapshot.result.profit, split: snapshot.result.split },
|
||||
}));
|
||||
await runOps(ops);
|
||||
return c.json({ status: 'finalized', finalizedAt: iso(now) });
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/finance/unfinalize', requireEventPermission('view_finance'), async (c) => {
|
||||
const access = getEventAccess(c)!;
|
||||
if (!canUnfinalize(access)) {
|
||||
return c.json({ error: 'Only admins and co-managers can unfinalize', code: 'EVENT_PERMISSION' }, 403);
|
||||
}
|
||||
const eventId = c.req.param('id');
|
||||
const state = await getFinanceState(eventId);
|
||||
if (state.status === 'open') return c.json({ error: 'Not finalized' }, 409);
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
await runOps([
|
||||
updateOp(eventFinanceState, { status: 'open', finalizedAt: null, finalizedBy: null, snapshotJson: null, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)),
|
||||
financeAuditOp({
|
||||
eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: 'unfinalize',
|
||||
before: { status: state.status, finalizedAt: state.finalizedAt, finalizedBy: state.finalizedBy, snapshot: state.snapshot },
|
||||
after: { status: 'open' },
|
||||
}),
|
||||
]);
|
||||
return c.json({ status: 'open' });
|
||||
});
|
||||
|
||||
// ==================== Audit log ====================
|
||||
|
||||
financeRouter.get('/:id/audit-log', requireEventPermission('view_full_split'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const limit = Math.min(200, Math.max(1, parseInt(c.req.query('limit') || '100', 10) || 100));
|
||||
const offset = Math.max(0, parseInt(c.req.query('offset') || '0', 10) || 0);
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (financeAuditLog as any).id,
|
||||
actorUserId: (financeAuditLog as any).actorUserId,
|
||||
actorName: (users as any).name,
|
||||
entityType: (financeAuditLog as any).entityType,
|
||||
entityId: (financeAuditLog as any).entityId,
|
||||
action: (financeAuditLog as any).action,
|
||||
beforeJson: (financeAuditLog as any).beforeJson,
|
||||
afterJson: (financeAuditLog as any).afterJson,
|
||||
createdAt: (financeAuditLog as any).createdAt,
|
||||
})
|
||||
.from(financeAuditLog)
|
||||
.leftJoin(users, eq((financeAuditLog as any).actorUserId, (users as any).id))
|
||||
.where(eq((financeAuditLog as any).eventId, eventId))
|
||||
.orderBy(desc((financeAuditLog as any).createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
);
|
||||
const parse = (s: string | null) => { if (!s) return null; try { return JSON.parse(s); } catch { return null; } };
|
||||
return c.json({
|
||||
entries: rows.map((r: any) => ({
|
||||
id: r.id, actorUserId: r.actorUserId, actorName: r.actorName ?? null, entityType: r.entityType, entityId: r.entityId,
|
||||
action: r.action, before: parse(r.beforeJson), after: parse(r.afterJson), createdAt: iso(r.createdAt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Team members ====================
|
||||
|
||||
const permissionOverrides = z.record(z.enum(EVENT_PERMISSIONS), z.boolean());
|
||||
const createMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
rolePreset: z.enum(ROLE_PRESETS),
|
||||
permissions: permissionOverrides.optional(),
|
||||
});
|
||||
const updateMemberSchema = z.object({
|
||||
rolePreset: z.enum(ROLE_PRESETS).optional(),
|
||||
permissions: permissionOverrides.optional(),
|
||||
});
|
||||
|
||||
function serializeMember(m: any, u: any) {
|
||||
const overrides = parseOverrides(m.permissions);
|
||||
return {
|
||||
id: m.id,
|
||||
eventId: m.eventId,
|
||||
userId: m.userId,
|
||||
name: u?.name ?? null,
|
||||
email: u?.email ?? null,
|
||||
globalRole: u?.role ?? null,
|
||||
rolePreset: m.rolePreset,
|
||||
permissions: overrides,
|
||||
effective: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(m.rolePreset, overrides).has(p)),
|
||||
createdAt: iso(m.createdAt),
|
||||
updatedAt: iso(m.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMember(eventId: string, memberId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select().from(eventMembers)
|
||||
.where(and(eq((eventMembers as any).id, memberId), eq((eventMembers as any).eventId, eventId)))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadUser(userId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||
.from(users).where(eq((users as any).id, userId))
|
||||
);
|
||||
}
|
||||
|
||||
financeRouter.get('/:id/members', requireEventPermission('manage_team'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ m: eventMembers, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||
.from(eventMembers)
|
||||
.leftJoin(users, eq((eventMembers as any).userId, (users as any).id))
|
||||
.where(eq((eventMembers as any).eventId, eventId))
|
||||
);
|
||||
return c.json({
|
||||
members: rows
|
||||
.map((r: any) => serializeMember(r.m, { name: r.name, email: r.email, role: r.role }))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
|
||||
});
|
||||
});
|
||||
|
||||
/** Active users matching q by name or email, excluding some ids. */
|
||||
async function searchUsers(q: string, excludeIds: string[]) {
|
||||
const like = `%${q.replace(/[%_]/g, '')}%`;
|
||||
const conditions: any[] = [
|
||||
or(sql`lower(${(users as any).name}) like ${like}`, sql`lower(${(users as any).email}) like ${like}`),
|
||||
eq((users as any).accountStatus, 'active'),
|
||||
];
|
||||
if (excludeIds.length > 0) conditions.push(notInArray((users as any).id, excludeIds));
|
||||
return dbAll<any>(
|
||||
(db as any)
|
||||
.select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||
.from(users)
|
||||
.where(and(...conditions))
|
||||
.limit(10)
|
||||
);
|
||||
}
|
||||
|
||||
financeRouter.get('/:id/members/candidates', requireEventPermission('manage_team'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const q = (c.req.query('q') || '').trim().toLowerCase();
|
||||
if (q.length < 2) return c.json({ users: [] });
|
||||
const existing = await dbAll<any>(
|
||||
(db as any).select({ userId: (eventMembers as any).userId }).from(eventMembers).where(eq((eventMembers as any).eventId, eventId))
|
||||
);
|
||||
return c.json({ users: await searchUsers(q, existing.map((e: any) => e.userId)) });
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/members', requireEventPermission('manage_team'), zValidator('json', createMemberSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const target = await loadUser(data.userId);
|
||||
if (!target) return c.json({ error: 'User not found' }, 400);
|
||||
const dupe = await dbGet<any>(
|
||||
(db as any).select({ id: (eventMembers as any).id }).from(eventMembers)
|
||||
.where(and(eq((eventMembers as any).eventId, event.id), eq((eventMembers as any).userId, data.userId)))
|
||||
);
|
||||
if (dupe) return c.json({ error: 'This user is already on the team', code: 'ALREADY_MEMBER' }, 409);
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const values = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
userId: data.userId,
|
||||
rolePreset: data.rolePreset,
|
||||
permissions: JSON.stringify(data.permissions || {}),
|
||||
createdBy: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const member = serializeMember(values, target);
|
||||
await runOps([
|
||||
insertOp(eventMembers, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'member', entityId: values.id, action: 'create', after: member }),
|
||||
]);
|
||||
return c.json({ member }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/members/:memberId', requireEventPermission('manage_team'), zValidator('json', updateMemberSchema, validationHook), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const existing = await loadMember(eventId, c.req.param('memberId'));
|
||||
if (!existing) return c.json({ error: 'Member not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
if (data.rolePreset) updates.rolePreset = data.rolePreset;
|
||||
if (data.permissions) updates.permissions = JSON.stringify(data.permissions);
|
||||
const target = await loadUser(existing.userId);
|
||||
const before = serializeMember(existing, target);
|
||||
const after = serializeMember({ ...existing, ...updates }, target);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
updateOp(eventMembers, updates, eq((eventMembers as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'update', before, after }),
|
||||
]);
|
||||
return c.json({ member: after });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/members/:memberId', requireEventPermission('manage_team'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const existing = await loadMember(eventId, c.req.param('memberId'));
|
||||
if (!existing) return c.json({ error: 'Member not found' }, 404);
|
||||
const user = currentUser(c);
|
||||
const target = await loadUser(existing.userId);
|
||||
await runOps([
|
||||
deleteOp(eventMembers, eq((eventMembers as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'delete', before: serializeMember(existing, target) }),
|
||||
]);
|
||||
return c.json({ message: 'Member removed' });
|
||||
});
|
||||
|
||||
export default financeRouter;
|
||||
@@ -4,12 +4,15 @@ import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
|
||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import { publicSalesFields } from '../lib/salesState.js';
|
||||
import { loadDoorMethods } from '../lib/doorPayments.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -50,6 +53,26 @@ function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?:
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Seat counts plus the public sales state for a normalized event. `raw` is the
|
||||
// DB row: doorPrice is resolved from its walk-in price, and only in the `door`
|
||||
// state (see lib/salesState.ts).
|
||||
function withSeatsAndSales(
|
||||
raw: any,
|
||||
normalized: any,
|
||||
settings: any,
|
||||
counts: { paid: number; claimed: number },
|
||||
nowMs: number = Date.now()
|
||||
) {
|
||||
const availableSeats = calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed);
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats,
|
||||
...publicSalesFields(raw, settings, availableSeats, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
// Load every slug currently in use (canonical event slugs + historical aliases),
|
||||
// optionally excluding a given event's own canonical slug + aliases.
|
||||
async function getAllSlugsInUse(excludeEventId?: string): Promise<string[]> {
|
||||
@@ -166,6 +189,8 @@ const baseEventSchema = z.object({
|
||||
// Accept price as number or string (handles "45000" and "41,44" formats)
|
||||
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||
walkInPrice: walkInPriceSchema,
|
||||
// Groups recurring events for the finance overview ("" clears it)
|
||||
series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(),
|
||||
currency: z.string().default('PYG'),
|
||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
@@ -290,7 +315,11 @@ eventsRouter.get('/', async (c) => {
|
||||
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||
// capacity check enforces (lib/capacity.ts).
|
||||
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||
// Scoped to the returned events so a page of 25 does not scan every ticket.
|
||||
const eventIds = result.map((event: any) => event.id);
|
||||
const countRows = eventIds.length > 0
|
||||
? await dbAll<any>(eventSeatBreakdownQuery(db, eventIds))
|
||||
: [];
|
||||
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of countRows) {
|
||||
countByEvent.set(row.eventId, {
|
||||
@@ -300,16 +329,16 @@ eventsRouter.get('/', async (c) => {
|
||||
}
|
||||
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice });
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
};
|
||||
});
|
||||
const nowMs = Date.now();
|
||||
const eventsWithCounts = result.map((event: any) =>
|
||||
withSeatsAndSales(
|
||||
event,
|
||||
normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }),
|
||||
siteSettingsRow,
|
||||
countByEvent.get(event.id) || { paid: 0, claimed: 0 },
|
||||
nowMs,
|
||||
)
|
||||
);
|
||||
|
||||
return paginated
|
||||
? c.json({ events: eventsWithCounts, total, page, pageSize })
|
||||
@@ -335,17 +364,21 @@ eventsRouter.get('/:id', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow(), {
|
||||
const settings = await getSiteSettingsRow();
|
||||
const normalized = normalizeEvent(event, settings, {
|
||||
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
|
||||
});
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const publicEvent = withSeatsAndSales(event, normalized, settings, counts);
|
||||
// Door tenders (never the comp "guest" one) for the page's "pay at the door" line.
|
||||
const doorPaymentMethods = publicEvent.salesState === 'door'
|
||||
? (await loadDoorMethods(event.id)).filter((m) => m !== 'guest')
|
||||
: undefined;
|
||||
// serverTime lets the page schedule its refresh at presaleClosesAt even when
|
||||
// the visitor's clock is off.
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
},
|
||||
event: { ...publicEvent, ...(doorPaymentMethods ? { doorPaymentMethods } : {}) },
|
||||
serverTime: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,13 +426,8 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
||||
}
|
||||
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
};
|
||||
const settings = await getSiteSettingsRow();
|
||||
return withSeatsAndSales(event, normalizeEvent(event, settings), settings, counts);
|
||||
}
|
||||
|
||||
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
|
||||
@@ -462,13 +490,9 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent, settings);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
...withSeatsAndSales(featuredEvent, normalizeEvent(featuredEvent, settings), settings, counts),
|
||||
isFeatured: true,
|
||||
},
|
||||
});
|
||||
@@ -523,7 +547,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
||||
eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
@@ -669,17 +693,17 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
});
|
||||
|
||||
// Get event attendees (admin/organizer only)
|
||||
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
eventsRouter.get('/:id/attendees', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer', 'staff'] }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const attendees = await dbAll(
|
||||
const attendees = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
);
|
||||
|
||||
return c.json({ attendees });
|
||||
return c.json({ attendees: canSeeAttendeePii(c) ? attendees : attendees.map(redactAttendee) });
|
||||
});
|
||||
|
||||
// Duplicate event (admin/organizer only)
|
||||
@@ -723,6 +747,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
externalBookingUrl: existing.externalBookingUrl,
|
||||
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
|
||||
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
|
||||
series: existing.series ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -733,7 +758,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
});
|
||||
|
||||
// List slug aliases for an event (admin/organizer only)
|
||||
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
@@ -754,7 +779,7 @@ eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async
|
||||
});
|
||||
|
||||
// Remove a slug alias from an event (admin/organizer only)
|
||||
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const slug = c.req.param('slug');
|
||||
|
||||
|
||||
@@ -178,3 +178,68 @@ describe('public event responses', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('public sales state and door price', () => {
|
||||
const hours = (n: number) => new Date(Date.now() + n * 3_600_000).toISOString();
|
||||
|
||||
async function createEvent(fields: Record<string, unknown>) {
|
||||
const { status, body } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, ...fields })
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
return body.event as { id: string; slug: string };
|
||||
}
|
||||
|
||||
it('adds doorPrice only in the door state and never exposes the raw walk-in price', async () => {
|
||||
// Starts in 1h, pre-sale closed 2h before start (site default): door state.
|
||||
const door = await createEvent({
|
||||
title: 'Door State', walkInPrice: 31000, startDatetime: hours(1), endDatetime: hours(4),
|
||||
});
|
||||
// Starts in 3 days: still online.
|
||||
const online = await createEvent({
|
||||
title: 'Online State', walkInPrice: 31000, startDatetime: hours(72), endDatetime: hours(75),
|
||||
});
|
||||
// Already over.
|
||||
const ended = await createEvent({
|
||||
title: 'Ended State', walkInPrice: 31000, startDatetime: hours(-4), endDatetime: hours(-1),
|
||||
});
|
||||
|
||||
for (const user of [null, MEMBER]) {
|
||||
await as(user, async () => {
|
||||
const single = await request('GET', `/api/events/${door.slug}`);
|
||||
expect(single.body.event.salesState).toBe('door');
|
||||
expect(single.body.event.doorPrice).toBe(31000);
|
||||
expect(single.body.event.presaleClosesAt).toEqual(expect.any(String));
|
||||
expect(single.body.event.availableSeats).toBe(40);
|
||||
expect(single.body.event.doorPaymentMethods).toEqual(['cash', 'bitcoin', 'transfer', 'pos']);
|
||||
expect(single.body.event).not.toHaveProperty('walkInPrice');
|
||||
expect(typeof single.body.serverTime).toBe('string');
|
||||
|
||||
for (const other of [online, ended]) {
|
||||
const res = await request('GET', `/api/events/${other.slug}`);
|
||||
expect(res.body.event.salesState).toBe(other === online ? 'online' : 'ended');
|
||||
expect(res.body.event).not.toHaveProperty('doorPrice');
|
||||
expect(res.body.event).not.toHaveProperty('doorPaymentMethods');
|
||||
expect(res.body.event).not.toHaveProperty('walkInPrice');
|
||||
expect(JSON.stringify(res.body)).not.toContain('31000');
|
||||
}
|
||||
|
||||
const list = await request('GET', '/api/events');
|
||||
const byId = new Map(list.body.events.map((e: any) => [e.id, e]));
|
||||
expect((byId.get(door.id) as any).salesState).toBe('door');
|
||||
expect((byId.get(door.id) as any).doorPrice).toBe(31000);
|
||||
expect((byId.get(online.id) as any)).not.toHaveProperty('doorPrice');
|
||||
for (const e of list.body.events) expect(e).not.toHaveProperty('walkInPrice');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the ticket price when no walk-in price is set', async () => {
|
||||
const door = await createEvent({
|
||||
title: 'Door Fallback', walkInPrice: null, startDatetime: hours(1), endDatetime: hours(4),
|
||||
});
|
||||
const res = await request('GET', `/api/events/${door.slug}`);
|
||||
expect(res.body.event.salesState).toBe('door');
|
||||
expect(res.body.event.doorPrice).toBe(21000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
// Global finance: expense categories, templates, template packs, payment
|
||||
// method fees (Site Settings → Expense Templates) and the cross-event overview.
|
||||
// Mounted at /api/finance.
|
||||
//
|
||||
// Writes are admin only. The read endpoints for categories/templates/packs are
|
||||
// also open to members who can add expenses on the event given as ?eventId=,
|
||||
// so the "Apply template" pickers work for them.
|
||||
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbAll, dbGet, events, expenseCategories, expenseTemplates, expenseTemplatePacks, expenseTemplatePackItems,
|
||||
eventExpenses, paymentMethodFees,
|
||||
} from '../db/index.js';
|
||||
import { requireAuth, type AuthUser } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromQuery } from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
||||
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||
import { financeAuditOp } from '../lib/finance/audit.js';
|
||||
import { getEventFinance, iso, pyg, bool, loadFeeRules } from '../lib/finance/load.js';
|
||||
import { CALC_TYPES } from '../lib/finance/calculate.js';
|
||||
|
||||
const financeGlobalRouter = new Hono();
|
||||
|
||||
const validationHook = (result: any, c: any) => {
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||
return c.json({ error: errors }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const money = z.number().int().min(0).max(2_000_000_000);
|
||||
const bp = z.number().int().min(0).max(10000);
|
||||
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
|
||||
const ADMIN = requireAuth(['admin']);
|
||||
const TEMPLATE_READERS = requireEventPermission(['edit_expenses', 'edit_own_expenses_only'], { eventId: eventFromQuery() });
|
||||
|
||||
// ==================== Categories ====================
|
||||
|
||||
const serializeCategory = (r: any) => ({
|
||||
id: r.id, nameEn: r.nameEn, nameEs: r.nameEs, color: r.color, sortOrder: pyg(r.sortOrder), archived: bool(r.archived),
|
||||
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
|
||||
});
|
||||
|
||||
const categorySchema = z.object({
|
||||
nameEn: z.string().trim().min(1).max(100),
|
||||
nameEs: z.string().trim().min(1).max(100),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#6B7280'),
|
||||
sortOrder: z.number().int().min(0).max(10000).default(0),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
|
||||
financeGlobalRouter.get('/settings/expense-categories', TEMPLATE_READERS, async (c) => {
|
||||
const rows = await dbAll<any>((db as any).select().from(expenseCategories));
|
||||
return c.json({ categories: rows.map(serializeCategory).sort((a, b) => a.sortOrder - b.sortOrder) });
|
||||
});
|
||||
|
||||
financeGlobalRouter.post('/settings/expense-categories', ADMIN, zValidator('json', categorySchema, validationHook), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
const values = { id: generateId(), ...data, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
|
||||
await runOps([
|
||||
insertOp(expenseCategories, values),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: values.id, action: 'create', after: serializeCategory(values) }),
|
||||
]);
|
||||
return c.json({ category: serializeCategory(values) }, 201);
|
||||
});
|
||||
|
||||
financeGlobalRouter.put('/settings/expense-categories/:id', ADMIN, zValidator('json', categorySchema.partial(), validationHook), async (c) => {
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, c.req.param('id'))));
|
||||
if (!existing) return c.json({ error: 'Category not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
|
||||
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||
const after = serializeCategory({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(expenseCategories, updates, eq((expenseCategories as any).id, existing.id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: existing.id, action: 'update', before: serializeCategory(existing), after }),
|
||||
]);
|
||||
return c.json({ category: after });
|
||||
});
|
||||
|
||||
financeGlobalRouter.delete('/settings/expense-categories/:id', ADMIN, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, id)));
|
||||
if (!existing) return c.json({ error: 'Category not found' }, 404);
|
||||
const [usedByExpense, usedByTemplate] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))),
|
||||
dbGet<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(eq((expenseTemplates as any).categoryId, id))),
|
||||
]);
|
||||
if (usedByExpense || usedByTemplate) {
|
||||
return c.json({ error: 'This category is in use. Archive it instead.', code: 'IN_USE' }, 409);
|
||||
}
|
||||
await runOps([
|
||||
deleteOp(expenseCategories, eq((expenseCategories as any).id, id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: id, action: 'delete', before: serializeCategory(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Category deleted' });
|
||||
});
|
||||
|
||||
// ==================== Templates ====================
|
||||
|
||||
const serializeTemplate = (r: any) => ({
|
||||
id: r.id, name: r.name, categoryId: r.categoryId ?? null, description: r.description ?? null, calcType: r.calcType,
|
||||
amount: pyg(r.amount), percentBp: pyg(r.percentBp), minimumAmount: pyg(r.minimumAmount), archived: bool(r.archived),
|
||||
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
|
||||
});
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
categoryId: z.string().nullable().optional(),
|
||||
description: z.string().trim().max(1000).nullable().optional(),
|
||||
calcType: z.enum(CALC_TYPES),
|
||||
amount: money.default(0),
|
||||
percentBp: bp.default(0),
|
||||
minimumAmount: money.default(0),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
|
||||
async function validCategory(categoryId: string | null | undefined) {
|
||||
if (!categoryId) return true;
|
||||
return !!(await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId))));
|
||||
}
|
||||
|
||||
financeGlobalRouter.get('/settings/expense-templates', TEMPLATE_READERS, async (c) => {
|
||||
const rows = await dbAll<any>((db as any).select().from(expenseTemplates));
|
||||
return c.json({ templates: rows.map(serializeTemplate).sort((a, b) => a.name.localeCompare(b.name)) });
|
||||
});
|
||||
|
||||
financeGlobalRouter.post('/settings/expense-templates', ADMIN, zValidator('json', templateSchema, validationHook), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
|
||||
const now = getNow();
|
||||
const values = {
|
||||
id: generateId(), ...data, categoryId: data.categoryId || null, description: data.description || null,
|
||||
archived: toDbBool(data.archived), createdAt: now, updatedAt: now,
|
||||
};
|
||||
await runOps([
|
||||
insertOp(expenseTemplates, values),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: values.id, action: 'create', after: serializeTemplate(values) }),
|
||||
]);
|
||||
return c.json({ template: serializeTemplate(values) }, 201);
|
||||
});
|
||||
|
||||
financeGlobalRouter.put('/settings/expense-templates/:id', ADMIN, zValidator('json', templateSchema.partial(), validationHook), async (c) => {
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, c.req.param('id'))));
|
||||
if (!existing) return c.json({ error: 'Template not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
|
||||
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
|
||||
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||
if (data.categoryId === '') updates.categoryId = null;
|
||||
const after = serializeTemplate({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(expenseTemplates, updates, eq((expenseTemplates as any).id, existing.id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: existing.id, action: 'update', before: serializeTemplate(existing), after }),
|
||||
]);
|
||||
return c.json({ template: after });
|
||||
});
|
||||
|
||||
financeGlobalRouter.delete('/settings/expense-templates/:id', ADMIN, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, id)));
|
||||
if (!existing) return c.json({ error: 'Template not found' }, 404);
|
||||
const [usedByExpense, usedByPack] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))),
|
||||
dbGet<any>((db as any).select({ id: (expenseTemplatePackItems as any).id }).from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).templateId, id))),
|
||||
]);
|
||||
if (usedByExpense || usedByPack) {
|
||||
return c.json({ error: 'This template has been used or is in a pack. Archive it instead.', code: 'IN_USE' }, 409);
|
||||
}
|
||||
await runOps([
|
||||
deleteOp(expenseTemplates, eq((expenseTemplates as any).id, id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: id, action: 'delete', before: serializeTemplate(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Template deleted' });
|
||||
});
|
||||
|
||||
// ==================== Template packs ====================
|
||||
|
||||
const packSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(1000).nullable().optional(),
|
||||
archived: z.boolean().default(false),
|
||||
templateIds: z.array(z.string()).max(50).default([]),
|
||||
});
|
||||
|
||||
async function loadPacks() {
|
||||
const [packs, items] = await Promise.all([
|
||||
dbAll<any>((db as any).select().from(expenseTemplatePacks)),
|
||||
dbAll<any>((db as any).select().from(expenseTemplatePackItems)),
|
||||
]);
|
||||
return packs
|
||||
.map((p: any) => ({
|
||||
id: p.id, name: p.name, description: p.description ?? null, archived: bool(p.archived),
|
||||
templateIds: items.filter((i: any) => i.packId === p.id).sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId),
|
||||
createdAt: iso(p.createdAt), updatedAt: iso(p.updatedAt),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function validTemplates(ids: string[]) {
|
||||
if (ids.length === 0) return true;
|
||||
const rows = await dbAll<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(inArray((expenseTemplates as any).id, ids)));
|
||||
return rows.length === new Set(ids).size;
|
||||
}
|
||||
|
||||
const itemOps = (packId: string, templateIds: string[]): TxOp[] =>
|
||||
[...new Set(templateIds)].map((templateId, i) => insertOp(expenseTemplatePackItems, { id: generateId(), packId, templateId, sortOrder: i }));
|
||||
|
||||
financeGlobalRouter.get('/settings/expense-template-packs', TEMPLATE_READERS, async (c) => {
|
||||
return c.json({ packs: await loadPacks() });
|
||||
});
|
||||
|
||||
financeGlobalRouter.post('/settings/expense-template-packs', ADMIN, zValidator('json', packSchema, validationHook), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
if (!(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
|
||||
const now = getNow();
|
||||
const values = { id: generateId(), name: data.name, description: data.description || null, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
|
||||
await runOps([
|
||||
insertOp(expenseTemplatePacks, values),
|
||||
...itemOps(values.id, data.templateIds),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: values.id, action: 'create', after: { ...data } }),
|
||||
]);
|
||||
const pack = (await loadPacks()).find((p) => p.id === values.id);
|
||||
return c.json({ pack }, 201);
|
||||
});
|
||||
|
||||
financeGlobalRouter.put('/settings/expense-template-packs/:id', ADMIN, zValidator('json', packSchema.partial(), validationHook), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const before = (await loadPacks()).find((p) => p.id === id);
|
||||
if (!before) return c.json({ error: 'Pack not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
if (data.templateIds && !(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.description !== undefined) updates.description = data.description || null;
|
||||
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||
const ops: TxOp[] = [updateOp(expenseTemplatePacks, updates, eq((expenseTemplatePacks as any).id, id))];
|
||||
if (data.templateIds) {
|
||||
ops.push(deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)));
|
||||
ops.push(...itemOps(id, data.templateIds));
|
||||
}
|
||||
ops.push(financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'update', before, after: { ...before, ...data } }));
|
||||
await runOps(ops);
|
||||
const pack = (await loadPacks()).find((p) => p.id === id);
|
||||
return c.json({ pack });
|
||||
});
|
||||
|
||||
financeGlobalRouter.delete('/settings/expense-template-packs/:id', ADMIN, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const before = (await loadPacks()).find((p) => p.id === id);
|
||||
if (!before) return c.json({ error: 'Pack not found' }, 404);
|
||||
await runOps([
|
||||
deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)),
|
||||
deleteOp(expenseTemplatePacks, eq((expenseTemplatePacks as any).id, id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'delete', before }),
|
||||
]);
|
||||
return c.json({ message: 'Pack deleted' });
|
||||
});
|
||||
|
||||
// ==================== Payment method fees ====================
|
||||
|
||||
financeGlobalRouter.get('/settings/payment-fees', ADMIN, async (c) => {
|
||||
return c.json({ fees: await loadFeeRules() });
|
||||
});
|
||||
|
||||
const feeSchema = z.object({ percentBp: bp, fixedAmount: money });
|
||||
const FEE_METHODS = ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos', 'bancard'] as const;
|
||||
|
||||
financeGlobalRouter.put('/settings/payment-fees/:method', ADMIN, zValidator('json', feeSchema, validationHook), async (c) => {
|
||||
const method = c.req.param('method');
|
||||
if (!(FEE_METHODS as readonly string[]).includes(method)) return c.json({ error: 'Unknown payment method' }, 400);
|
||||
const data = c.req.valid('json');
|
||||
const existing = await dbGet<any>((db as any).select().from(paymentMethodFees).where(eq((paymentMethodFees as any).method, method)));
|
||||
const user = currentUser(c);
|
||||
const values = { percentBp: data.percentBp, fixedAmount: data.fixedAmount, updatedAt: getNow(), updatedBy: user.id };
|
||||
await runOps([
|
||||
existing
|
||||
? updateOp(paymentMethodFees, values, eq((paymentMethodFees as any).method, method))
|
||||
: insertOp(paymentMethodFees, { method, ...values }),
|
||||
financeAuditOp({
|
||||
eventId: null, actorUserId: user.id, entityType: 'payment_fee', entityId: method, action: existing ? 'update' : 'create',
|
||||
before: existing ? { percentBp: pyg(existing.percentBp), fixedAmount: pyg(existing.fixedAmount) } : null, after: data,
|
||||
}),
|
||||
]);
|
||||
return c.json({ fee: { method, ...data } });
|
||||
});
|
||||
|
||||
// ==================== Cross-event overview ====================
|
||||
|
||||
type Totals = { events: number; gross: number; fees: number; net: number; expenses: number; profit: number };
|
||||
const emptyTotals = (): Totals => ({ events: 0, gross: 0, fees: 0, net: 0, expenses: 0, profit: 0 });
|
||||
const addTo = (t: Totals, row: Omit<Totals, 'events'>) => {
|
||||
t.events += 1; t.gross += row.gross; t.fees += row.fees; t.net += row.net; t.expenses += row.expenses; t.profit += row.profit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profit per event, per series, per venue and payouts per partner. Finalized
|
||||
* events use their snapshot. `readyToClose` lists past events whose books are
|
||||
* still open (with or without any money recorded), longest-waiting first. Filters: from / to (ISO dates on the event start),
|
||||
* series, venue (exact location text), partner (user id or external name).
|
||||
*/
|
||||
financeGlobalRouter.get('/overview', ADMIN, async (c) => {
|
||||
const isDate = (v?: string) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined);
|
||||
const from = isDate(c.req.query('from'));
|
||||
const to = isDate(c.req.query('to'));
|
||||
const seriesFilter = c.req.query('series');
|
||||
const venueFilter = c.req.query('venue');
|
||||
const partnerFilter = c.req.query('partner');
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (from) conditions.push(gte((events as any).startDatetime, toDbDate(from)));
|
||||
if (to) conditions.push(lte((events as any).startDatetime, toDbDate(`${to}T23:59:59.999Z`)));
|
||||
const allEvents = await dbAll<any>(
|
||||
(db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined)
|
||||
);
|
||||
|
||||
const rows: any[] = [];
|
||||
const seriesOptions = new Set<string>();
|
||||
const venueOptions = new Set<string>();
|
||||
const partnerOptions = new Map<string, string>();
|
||||
const bySeries = new Map<string, Totals>();
|
||||
const byVenue = new Map<string, Totals>();
|
||||
const byPartner = new Map<string, { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }>();
|
||||
const totals = emptyTotals();
|
||||
// Past events whose books are still open, including ones with no money in
|
||||
// or out yet (those are left out of `events` and the totals).
|
||||
const readyToClose: any[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const ev of allEvents) {
|
||||
if (ev.status === 'draft') continue;
|
||||
const fin = await getEventFinance(ev.id, ev);
|
||||
if (!fin) continue;
|
||||
const r = fin.result;
|
||||
const hasMoney = !(r.revenue.gross === 0 && r.expenses.total === 0 && r.revenue.otherIncome === 0 && fin.partners.length === 0);
|
||||
const ended = new Date(ev.endDatetime || ev.startDatetime).getTime() <= now;
|
||||
const ready = fin.state.status === 'open' && ended;
|
||||
// Nothing to report for events with no money in or out, unless they still need closing.
|
||||
if (!hasMoney && !ready) continue;
|
||||
|
||||
const series = ev.series || null;
|
||||
const venue = (ev.location || '').trim();
|
||||
const partnerKeys = fin.partners.map((p) => p.userId || `name:${p.name}`);
|
||||
if (hasMoney) {
|
||||
if (series) seriesOptions.add(series);
|
||||
if (venue) venueOptions.add(venue);
|
||||
fin.partners.forEach((p, i) => partnerOptions.set(partnerKeys[i], p.name));
|
||||
}
|
||||
|
||||
if (seriesFilter && (seriesFilter === '__none__' ? series !== null : series !== seriesFilter)) continue;
|
||||
if (venueFilter && venue !== venueFilter) continue;
|
||||
if (partnerFilter && !partnerKeys.includes(partnerFilter)) continue;
|
||||
|
||||
const row = { gross: r.revenue.gross, fees: r.revenue.fees, net: r.revenue.net, expenses: r.expenses.total, profit: r.profit };
|
||||
const eventRow = {
|
||||
id: ev.id, title: ev.title, titleEs: ev.titleEs ?? null, startDatetime: iso(ev.startDatetime),
|
||||
endDatetime: ev.endDatetime ? iso(ev.endDatetime) : null, series, location: venue,
|
||||
status: ev.status, financeStatus: fin.state.status, ticketsSold: r.counts.ticketsSold, ...row,
|
||||
organization: r.split.organization,
|
||||
};
|
||||
if (ready) readyToClose.push(eventRow);
|
||||
if (!hasMoney) continue;
|
||||
rows.push(eventRow);
|
||||
addTo(totals, row);
|
||||
const sKey = series || '';
|
||||
if (!bySeries.has(sKey)) bySeries.set(sKey, emptyTotals());
|
||||
addTo(bySeries.get(sKey)!, row);
|
||||
if (!byVenue.has(venue)) byVenue.set(venue, emptyTotals());
|
||||
addTo(byVenue.get(venue)!, row);
|
||||
|
||||
fin.partners.forEach((p, i) => {
|
||||
const key = partnerKeys[i];
|
||||
const line = r.split.partners.find((x) => x.partnerId === p.id);
|
||||
const agg = byPartner.get(key) || { key, name: p.name, userId: p.userId, events: 0, share: 0, reimbursement: 0, payout: 0, paid: 0, pending: 0 };
|
||||
agg.events += 1;
|
||||
agg.share += line?.share ?? 0;
|
||||
agg.reimbursement += line?.reimbursement ?? 0;
|
||||
agg.payout += line?.payout ?? 0;
|
||||
if (p.payoutStatus === 'paid') agg.paid += line?.payout ?? 0; else agg.pending += line?.payout ?? 0;
|
||||
byPartner.set(key, agg);
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => (b.startDatetime || '').localeCompare(a.startDatetime || ''));
|
||||
// Longest-waiting first.
|
||||
readyToClose.sort((a, b) => (a.startDatetime || '').localeCompare(b.startDatetime || ''));
|
||||
const sortByProfit = <T extends { profit: number }>(xs: T[]) => xs.sort((a, b) => b.profit - a.profit);
|
||||
return c.json({
|
||||
totals,
|
||||
events: rows,
|
||||
readyToClose,
|
||||
bySeries: sortByProfit([...bySeries.entries()].map(([series, t]) => ({ series: series || null, ...t }))),
|
||||
byVenue: sortByProfit([...byVenue.entries()].map(([venue, t]) => ({ venue, ...t }))),
|
||||
byPartner: [...byPartner.values()].sort((a, b) => b.payout - a.payout),
|
||||
filters: {
|
||||
series: [...seriesOptions].sort(),
|
||||
venues: [...venueOptions].sort(),
|
||||
partners: [...partnerOptions.entries()].map(([key, name]) => ({ key, name })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default financeGlobalRouter;
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||
|
||||
const paymentOptionsRouter = new Hono();
|
||||
@@ -276,7 +277,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
});
|
||||
|
||||
// Get event payment overrides (admin only)
|
||||
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
paymentOptionsRouter.get('/event/:eventId/overrides', requireEventPermission('view_payments', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const overrides = await dbGet<any>(
|
||||
@@ -287,7 +288,7 @@ paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'org
|
||||
});
|
||||
|
||||
// Update event payment overrides
|
||||
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
|
||||
paymentOptionsRouter.put('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), zValidator('json', updateEventOverridesSchema), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
@@ -339,7 +340,7 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
|
||||
});
|
||||
|
||||
// Delete event payment overrides (revert to global)
|
||||
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
paymentOptionsRouter.delete('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
await (db as any)
|
||||
|
||||
@@ -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<any>(
|
||||
(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<any>(
|
||||
@@ -1394,7 +1398,7 @@ ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'st
|
||||
});
|
||||
|
||||
// Update admin note
|
||||
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
|
||||
ticketsRouter.post('/:id/note', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), zValidator('json', updateNoteSchema), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { note } = c.req.valid('json');
|
||||
|
||||
@@ -1419,7 +1423,7 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
|
||||
});
|
||||
|
||||
// Admin create ticket (at the door)
|
||||
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
|
||||
ticketsRouter.post('/admin/create', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), zValidator('json', adminCreateTicketSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Get event
|
||||
@@ -1558,7 +1562,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
// pay-link (Bancard/TPago) email sent when an email is provided
|
||||
// guest — free comp ticket, not counted in revenue; confirmation email only
|
||||
// when an email is provided
|
||||
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||
ticketsRouter.post('/admin/add', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), zValidator('json', z.object({
|
||||
eventId: z.string(),
|
||||
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
|
||||
// Door walk-ins can be logged with nothing filled in, so firstName is only
|
||||
@@ -1756,7 +1760,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
||||
});
|
||||
|
||||
// Get all tickets (admin) - includes payment for each ticket
|
||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
ticketsRouter.get('/', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
const status = c.req.query('status');
|
||||
|
||||
@@ -1787,8 +1791,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const showPii = canSeeAttendeePii(c);
|
||||
const ticketsWithPayment = ticketsList.map((t: any) => ({
|
||||
...t,
|
||||
...(showPii ? t : redactAttendee(t)),
|
||||
payment: paymentByTicketId[t.id] || null,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user