diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 2555595..64d747f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -183,6 +183,17 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { .groupBy((tickets as any).eventId) ); + // Revenue is what was actually paid, not tickets × the current event price + // (which an admin can change after tickets have been sold). + const revenueRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`coalesce(sum(${(payments as any).amount}), 0)` }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .where(eq((payments as any).status, 'paid')) + .groupBy((tickets as any).eventId) + ); + const toMap = (rows: any[]) => { const m = new Map(); for (const r of rows) m.set(r.eventId, Number(r.count) || 0); @@ -191,6 +202,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { const totalMap = toMap(totalRows); const confirmedMap = toMap(confirmedRows); const checkedInMap = toMap(checkedInRows); + const revenueMap = toMap(revenueRows); const eventStats = allEvents.map((event: any) => { const confirmedBookings = confirmedMap.get(event.id) || 0; @@ -202,7 +214,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { totalBookings: totalMap.get(event.id) || 0, confirmedBookings, checkedIn: checkedInMap.get(event.id) || 0, - revenue: confirmedBookings * event.price, + revenue: revenueMap.get(event.id) || 0, }; }); diff --git a/backend/src/routes/door.integration.test.ts b/backend/src/routes/door.integration.test.ts index 6abff81..5812f2e 100644 --- a/backend/src/routes/door.integration.test.ts +++ b/backend/src/routes/door.integration.test.ts @@ -79,6 +79,8 @@ function seedTicket(row: { phone?: string | null; bookingId?: string | null; qr?: string; + /** Seed the pre-sale payment a paid ticket was bought with, at this amount. */ + paidAmount?: number; }) { sqlite .prepare( @@ -100,6 +102,17 @@ function seedTicket(row: { row.qr ?? `QR-${row.id}`, new Date().toISOString() ); + if (row.paidAmount !== undefined) seedPayment(row.id, row.paidAmount, 'paid'); +} + +function seedPayment(ticketId: string, amount: number, status: string, provider = 'bancard') { + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO payments (id, ticket_id, provider, amount, currency, status, paid_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 'PYG', ?, ?, ?, ?)` + ) + .run(`pay-${ticketId}`, ticketId, provider, amount, status, status === 'paid' ? now : null, now, now); } beforeAll(() => { @@ -134,7 +147,7 @@ beforeAll(() => { ) .run(EVENT_ID, now, PRICE, now, now); - seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567' }); + seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567', paidAmount: PRICE }); seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' }); seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' }); seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' }); @@ -197,7 +210,7 @@ describe('door-checkin: existing ticket', () => { const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at; expect(after).toBe(before); - expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-paid').n).toBe(0); + expect(sqlite.prepare("SELECT COUNT(*) n FROM payments WHERE ticket_id = ? AND source = 'door'").get('tkt-paid').n).toBe(0); }); it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => { @@ -323,7 +336,7 @@ describe('door-checkin: walk-ins', () => { describe('undo', () => { it('reverts a plain check-in to its previous state', async () => { - seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid' }); + seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid', paidAmount: PRICE }); await post(`/api/events/${EVENT_ID}/door-checkin`, { ticketId: 'tkt-undo', idempotencyKey: 'key-undo-checkin', @@ -440,6 +453,17 @@ describe('door-summary', () => { expect(body.door.lines.length).toBe(body.door.count); expect(body.door.lines[0]).toHaveProperty('name'); }); + + it('keeps pre-sale revenue at what was paid when the ticket price changes', async () => { + sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(99000, EVENT_ID); + try { + const { body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`)); + expect(body.presale).toEqual({ count: 2, total: PRICE * 2 }); + expect(body.door.total).toBe(PRICE * 6); + } finally { + sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(PRICE, EVENT_ID); + } + }); }); // ==================== Walk-in price & POS ==================== diff --git a/backend/src/routes/door.ts b/backend/src/routes/door.ts index b6c3e71..52a61aa 100644 --- a/backend/src/routes/door.ts +++ b/backend/src/routes/door.ts @@ -297,7 +297,8 @@ doorRouter.post( const nowIso = new Date().toISOString(); const quantity = data.payment?.quantity ?? 1; // Walk-ins pay the walk-in price (falling back to the ticket price); an - // existing ticket settles the balance at the price it was booked at. + // existing unpaid ticket settles at the current ticket price (its pending + // payment is repriced whenever the event price changes). const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice; const computedAmount = unitPrice * quantity; const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount; @@ -691,21 +692,32 @@ doorRouter.get('/:eventId/door-summary', requireEventPermission('view_payments', doorTotal += amount; } - // Pre-sale revenue keeps the dashboard's existing definition — settled tickets - // at event price — minus anything that was actually taken at the door. + // Pre-sale revenue: settled tickets that weren't taken at the door, at the + // amount actually paid — never the current event price, which an admin can + // change after tickets have been sold. const doorTicketIds = new Set(rows.map((r: any) => r.ticketId)); const settled = await dbAll( (db as any) - .select({ id: (tickets as any).id }) + .select({ id: (tickets as any).id, amount: (payments as any).amount }) .from(tickets) + .leftJoin(payments, and( + eq((payments as any).ticketId, (tickets as any).id), + eq((payments as any).status, 'paid') + )) .where(and( eq((tickets as any).eventId, eventId), eq((tickets as any).paymentStatus, 'paid'), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` )) ); - const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length; - const presaleTotal = presaleCount * event.price; + const presaleIds = new Set(); + let presaleTotal = 0; + for (const t of settled) { + if (doorTicketIds.has(t.id)) continue; + presaleIds.add(t.id); + presaleTotal += num(t.amount); + } + const presaleCount = presaleIds.size; return c.json({ eventId, diff --git a/backend/src/routes/events.priceChange.integration.test.ts b/backend/src/routes/events.priceChange.integration.test.ts new file mode 100644 index 0000000..d9289a4 --- /dev/null +++ b/backend/src/routes/events.priceChange.integration.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { execFileSync } from 'child_process'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +// Changing an event's ticket price must never rewrite what was already paid, +// but anyone who booked and hasn't paid yet owes the new price. These tests pin +// which payments follow a price change and which keep their amount. + +// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres +// URL to run the same suite in a throwaway schema (dropped afterwards). +const PG_URL = process.env.FINANCE_TEST_PG_URL; +const PG_SCHEMA = `pricetest_${Date.now()}`; +if (PG_URL) { + process.env.DB_TYPE = 'postgres'; + process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`; +} else { + const dir = mkdtempSync(join(tmpdir(), 'events-price-test-')); + process.env.DB_TYPE = 'sqlite'; + process.env.DATABASE_URL = join(dir, 'test.db'); +} +process.env.FRONTEND_URL = 'http://localhost:3002'; +process.env.BETTER_AUTH_SECRET = 'events-price-secret-0123456789abcdef'; +delete process.env.REDIS_URL; +delete process.env.REVALIDATE_SECRET; + +const ADMIN = { id: randomUUID(), name: 'The Admin', role: 'admin' }; + +vi.mock('../lib/auth.js', () => ({ + requireAuth: (roles?: string[]) => async (c: any, next: any) => { + if (roles && !roles.includes(ADMIN.role)) return c.json({ error: 'Forbidden' }, 403); + c.set('user', ADMIN); + await next(); + }, + getAuthUser: async () => ADMIN, +})); + +let app: any; +let dbm: any; + +const SEED_USER_ID = randomUUID(); +const EVENT_ID = randomUUID(); +const OTHER_EVENT_ID = randomUUID(); +const OLD_PRICE = 50000; +const NEW_PRICE = 70000; +// Ticket id by label, so assertions read by name. +const ticketIds: Record = {}; + +async function put(path: string, body: unknown) { + const res = await app.request(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +/** One ticket with its payment row, the way a booking leaves them. Seeded through drizzle so it works on both engines. */ +async function seedBooking(label: string, eventId: string, paymentStatus: string, provider = 'bancard') { + const { db, tickets, payments } = dbm; + const now = dbm.getNow(); + const id = randomUUID(); + ticketIds[label] = id; + const paid = paymentStatus === 'paid'; + await db.insert(tickets).values({ + id, userId: SEED_USER_ID, eventId, attendeeFirstName: label, status: paid ? 'confirmed' : 'pending', + paymentStatus: paid ? 'paid' : 'unpaid', isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now, + }); + await db.insert(payments).values({ + id: randomUUID(), ticketId: id, provider, amount: OLD_PRICE, currency: 'PYG', + status: paymentStatus, paidAt: paid ? now : null, createdAt: now, updatedAt: now, + }); +} + +async function amountOf(label: string): Promise { + const { db, dbGet, payments, eq } = dbm; + const row = await dbGet(db.select({ amount: payments.amount }).from(payments).where(eq(payments.ticketId, ticketIds[label]))); + return Number(row.amount); +} + +beforeAll(() => { + if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' }); + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' }); + + return (async () => { + const { Hono } = await import('hono'); + app = new Hono(); + app.route('/api/events', (await import('./events.js')).default); + + dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')), ...(await import('drizzle-orm')) }; + const { db, users, events } = dbm; + const now = dbm.getNow(); + await db.insert(users).values({ + id: SEED_USER_ID, email: 'seed@test.py', name: 'Seed', role: 'user', + isClaimed: dbm.toDbBool(false), accountStatus: 'unclaimed', createdAt: now, updatedAt: now, + }); + for (const id of [EVENT_ID, OTHER_EVENT_ID]) { + await db.insert(events).values({ + id, title: `Event ${id}`, description: 'desc', startDatetime: now, location: 'Asuncion', price: OLD_PRICE, + currency: 'PYG', capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now, + }); + } + + await seedBooking('paid', EVENT_ID, 'paid'); + await seedBooking('refunded', EVENT_ID, 'refunded'); + await seedBooking('pending', EVENT_ID, 'pending'); + await seedBooking('pending-tpago', EVENT_ID, 'pending', 'tpago'); + await seedBooking('claimed', EVENT_ID, 'pending_approval', 'bank_transfer'); + await seedBooking('on-hold', EVENT_ID, 'on_hold'); + await seedBooking('lightning', EVENT_ID, 'pending', 'lightning'); + await seedBooking('other-event', OTHER_EVENT_ID, 'pending'); + })(); +}, 120_000); + +afterAll(() => { + if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' }); +}); + +describe('changing the ticket price', () => { + it('leaves every payment alone when the price is not part of the edit', async () => { + const { status } = await put(`/api/events/${EVENT_ID}`, { location: 'Encarnación' }); + expect(status).toBe(200); + expect(await amountOf('pending')).toBe(OLD_PRICE); + }); + + it('leaves every payment alone when the price is resent unchanged', async () => { + const { status } = await put(`/api/events/${EVENT_ID}`, { price: OLD_PRICE, currency: 'PYG' }); + expect(status).toBe(200); + expect(await amountOf('pending')).toBe(OLD_PRICE); + }); + + it('reprices open payments and keeps what was already paid', async () => { + const { status, body } = await put(`/api/events/${EVENT_ID}`, { price: NEW_PRICE }); + expect(status).toBe(200); + expect(body.event.price).toBe(NEW_PRICE); + + // Not yet paid: owe the current price. + expect(await amountOf('pending')).toBe(NEW_PRICE); + expect(await amountOf('pending-tpago')).toBe(NEW_PRICE); + + // History stays as it happened. + expect(await amountOf('paid')).toBe(OLD_PRICE); + expect(await amountOf('refunded')).toBe(OLD_PRICE); + // The customer already sent the old amount / an admin is reviewing it. + expect(await amountOf('claimed')).toBe(OLD_PRICE); + expect(await amountOf('on-hold')).toBe(OLD_PRICE); + // The Lightning invoice was issued for a fixed amount. + expect(await amountOf('lightning')).toBe(OLD_PRICE); + // Other events are untouched. + expect(await amountOf('other-event')).toBe(OLD_PRICE); + }); +}); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 2d8b606..0d48d83 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js'; -import { eq, desc, and, gte, sql } from 'drizzle-orm'; +import { eq, ne, desc, and, gte, inArray, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; @@ -13,6 +13,7 @@ import { resolvePresaleClosure } from '../lib/presale.js'; import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js'; import { publicSalesFields } from '../lib/salesState.js'; import { loadDoorMethods } from '../lib/doorPayments.js'; +import { runOps, updateOp, type TxOp } from '../lib/txOps.js'; interface UserContext { id: string; @@ -615,11 +616,31 @@ eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['a updateData.slug = newSlug; } - await (db as any) - .update(events) - .set(updateData) - .where(eq((events as any).id, id)); - + const ops: TxOp[] = [updateOp(events, updateData, eq((events as any).id, id))]; + + // Anyone who booked but hasn't paid yet owes the current price, so open + // payments follow a price/currency change. Settled payments keep what was + // actually paid. Excluded: pending_approval (the customer already sent the + // old amount), on_hold (under review) and Lightning (the invoice is fixed). + const newPrice = data.price !== undefined ? data.price : Number(existing.price); + const newCurrency = data.currency ?? existing.currency; + if (newPrice !== Number(existing.price) || newCurrency !== existing.currency) { + ops.push(updateOp( + payments, + { amount: newPrice, currency: newCurrency, updatedAt: now }, + and( + eq((payments as any).status, 'pending'), + ne((payments as any).provider, 'lightning'), + inArray( + (payments as any).ticketId, + (db as any).select({ id: (tickets as any).id }).from(tickets).where(eq((tickets as any).eventId, id)) + ) + ) + )); + } + + await runOps(ops); + const updated = await dbGet( (db as any).select().from(events).where(eq((events as any).id, id)) ); diff --git a/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx b/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx index 0222233..0368480 100644 --- a/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx +++ b/frontend/src/app/admin/events/[id]/_components/EventDetailView.tsx @@ -455,21 +455,15 @@ export function EventDetailView({ eventId, backHref }: { eventId: string; backHr const checkedInCount = getTicketsByStatus('checked_in').length; const cancelledCount = getTicketsByStatus('cancelled').length; const onHoldCount = getTicketsByStatus('on_hold').length; - // Revenue counts only settled tickets: unpaid (balance due) and comp (guest) - // tickets are excluded; legacy rows without paymentStatus fall back to !isGuest - const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest); - const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length; - const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length; - // Door sales can be taken at a custom amount (someone paying for their whole - // group), so once the door summary is loaded it is the authority on the total: - // pre-sale tickets at face value plus whatever was actually taken on the night. - const presaleRevenue = doorSummary - ? doorSummary.presale.total - : (paidConfirmedCount + paidCheckedInCount) * event.price; + // Revenue comes only from the door summary, which adds up what was actually + // paid. Never derive it from ticket count × event.price: the price can change + // after tickets have been sold. + const presaleRevenue = doorSummary?.presale.total ?? 0; const doorRevenue = doorSummary?.door.total ?? 0; const revenue = presaleRevenue + doorRevenue; // Header money follows the UI language's thousands separator. const money = (amount: number) => formatCurrency(amount, event.currency, locale); + const revenueLabel = doorSummary ? money(revenue) : '—'; // "confirmed" tickets become "checked_in" at the door, so this counts the // guests who have a ticket and have not arrived yet (not all confirmed ones). const notCheckedInLabel = t('admin.eventStats.notCheckedIn'); @@ -587,7 +581,7 @@ export function EventDetailView({ eventId, backHref }: { eventId: string; backHr { label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' }, ...(!showRevenue ? [] : [{ label: t('admin.eventStats.revenue'), - value: money(revenue), + value: revenueLabel, icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600', detail: doorSummary @@ -625,7 +619,7 @@ export function EventDetailView({ eventId, backHref }: { eventId: string; backHr {showRevenue && ( <> | - {money(revenue)} + {revenueLabel} )} @@ -639,7 +633,7 @@ export function EventDetailView({ eventId, backHref }: { eventId: string; backHr { label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' }, ...(!showRevenue ? [] : [{ label: t('admin.eventStats.revenue'), - value: money(revenue), + value: revenueLabel, icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50', detail: doorSummary