Keep event revenue at what was paid when the ticket price changes.

The event header revenue and the door summary's pre-sale total were
computed as settled tickets × the current event price, so editing the
price rewrote revenue for tickets already sold. They now sum the paid
payment amounts; the header no longer falls back to count × price.
Admin analytics per-event revenue gets the same fix.

Bookings that haven't been paid yet owe the current price, so a price or
currency change now reprices open `pending` payments in the same
transaction as the event update. Paid/refunded history, pending_approval,
on_hold and Lightning invoices keep their amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-10-03 04:38:35 +00:00
co-authored by Claude Opus 5.5
parent e203fb6c74
commit b3584e6c4d
6 changed files with 247 additions and 30 deletions
+13 -1
View File
@@ -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<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`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<string, number>();
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,
};
});
+27 -3
View File
@@ -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 ====================
+18 -6
View File
@@ -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<any>(
(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<string>();
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,
@@ -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<string, string> = {};
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<number> {
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);
});
});
+27 -6
View File
@@ -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))
);