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:
co-authored by
Claude Opus 5.5
parent
e203fb6c74
commit
b3584e6c4d
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user