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
@@ -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 && (
<>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{money(revenue)}</span>
<span className="text-sm whitespace-nowrap">{revenueLabel}</span>
</>
)}
</div>
@@ -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