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 -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 ====================