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
+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))
);