Dev #38
@@ -117,6 +117,11 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN walk_in_price REAL`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
||||
@@ -297,6 +302,21 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: tickets.booking_source (online | walk_in | admin). On first run,
|
||||
// backfill walk-ins created on the door screen: each one left an idempotency
|
||||
// record whose undo_state is {kind: 'created', ticketId}. Nothing else can be
|
||||
// identified reliably, so all other existing tickets stay 'online'.
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_source TEXT NOT NULL DEFAULT 'online'`);
|
||||
await (db as any).run(sql`
|
||||
UPDATE tickets SET booking_source = 'walk_in'
|
||||
WHERE id IN (
|
||||
SELECT json_extract(undo_state, '$.ticketId') FROM idempotency_keys
|
||||
WHERE scope = 'door-checkin' AND json_extract(undo_state, '$.kind') = 'created'
|
||||
)
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
@@ -392,6 +412,14 @@ async function migrate() {
|
||||
} catch (e) { /* column may already exist */ }
|
||||
}
|
||||
|
||||
// POS card terminal at the door: on globally by default, NULL override = inherit
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -773,6 +801,11 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN walk_in_price DECIMAL(10, 2)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
||||
@@ -913,6 +946,20 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: tickets.booking_source (online | walk_in | admin), with the
|
||||
// same one-time walk-in backfill as the sqlite branch.
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_source VARCHAR(20) NOT NULL DEFAULT 'online'`);
|
||||
await (db as any).execute(sql`
|
||||
UPDATE tickets SET booking_source = 'walk_in'
|
||||
WHERE id::text IN (
|
||||
SELECT undo_state::json->>'ticketId' FROM idempotency_keys
|
||||
WHERE scope = 'door-checkin' AND undo_state IS NOT NULL
|
||||
AND undo_state::json->>'kind' = 'created'
|
||||
)
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
@@ -1006,6 +1053,14 @@ async function migrate() {
|
||||
} catch (e) { /* column may already exist */ }
|
||||
}
|
||||
|
||||
// POS card terminal at the door: on globally by default, NULL override = inherit
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id UUID PRIMARY KEY,
|
||||
|
||||
@@ -82,6 +82,9 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
location: text('location').notNull(),
|
||||
locationUrl: text('location_url'),
|
||||
price: real('price').notNull().default(0),
|
||||
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
|
||||
// Admin/staff only: never serialized to public event responses.
|
||||
walkInPrice: real('walk_in_price'),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
capacity: integer('capacity').notNull().default(50),
|
||||
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||
@@ -121,13 +124,16 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
|
||||
// How the booking was made: public checkout, a walk-in on the door screen, or
|
||||
// added by an admin. Distinct from payments.source, which is where money was taken.
|
||||
bookingSource: text('booking_source', { enum: ['online', 'walk_in', 'admin'] }).notNull().default('online'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqlitePayments = sqliteTable('payments', {
|
||||
id: text('id').primaryKey(),
|
||||
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago', 'pos'] }).notNull(),
|
||||
amount: real('amount').notNull(),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||
@@ -146,7 +152,7 @@ export const sqlitePayments = sqliteTable('payments', {
|
||||
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
|
||||
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
|
||||
// 'guest' is a zero-amount comp entry and carries no revenue.
|
||||
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }),
|
||||
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -194,6 +200,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
cashInstructions: text('cash_instructions'),
|
||||
cashInstructionsEs: text('cash_instructions_es'),
|
||||
// POS card terminal at the door (Scanner only, never offered at online checkout)
|
||||
posEnabled: integer('pos_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
||||
// Metadata
|
||||
@@ -226,6 +234,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
|
||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
||||
cashInstructions: text('cash_instructions'),
|
||||
cashInstructionsEs: text('cash_instructions_es'),
|
||||
posEnabled: integer('pos_enabled', { mode: 'boolean' }),
|
||||
// Metadata
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
@@ -476,6 +485,9 @@ export const pgEvents = pgTable('events', {
|
||||
location: varchar('location', { length: 500 }).notNull(),
|
||||
locationUrl: varchar('location_url', { length: 500 }),
|
||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
|
||||
// Admin/staff only: never serialized to public event responses.
|
||||
walkInPrice: decimal('walk_in_price', { precision: 10, scale: 2 }),
|
||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||
capacity: pgInteger('capacity').notNull().default(50),
|
||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||
@@ -515,6 +527,8 @@ export const pgTickets = pgTable('tickets', {
|
||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
||||
// online | walk_in | admin — see sqliteTickets.bookingSource
|
||||
bookingSource: varchar('booking_source', { length: 20 }).notNull().default('online'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -578,6 +592,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
||||
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
||||
cashInstructions: pgText('cash_instructions'),
|
||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||
posEnabled: pgInteger('pos_enabled').notNull().default(1),
|
||||
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
@@ -607,6 +622,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
|
||||
cashEnabled: pgInteger('cash_enabled'),
|
||||
cashInstructions: pgText('cash_instructions'),
|
||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||
posEnabled: pgInteger('pos_enabled'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -788,7 +788,7 @@ const openApiSpec = {
|
||||
post: {
|
||||
tags: ['Tickets'],
|
||||
summary: 'Check in, settle payment, or create a walk-in (atomic)',
|
||||
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
|
||||
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
@@ -817,8 +817,10 @@ const openApiSpec = {
|
||||
type: 'object',
|
||||
required: ['method'],
|
||||
properties: {
|
||||
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] },
|
||||
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
|
||||
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] },
|
||||
quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' },
|
||||
amount: { type: 'number', description: 'Ignored unless amountOverride is true.' },
|
||||
amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' },
|
||||
},
|
||||
},
|
||||
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
|
||||
@@ -831,7 +833,8 @@ const openApiSpec = {
|
||||
responses: {
|
||||
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
|
||||
200: { description: 'Replay of an already-processed idempotencyKey' },
|
||||
400: { description: 'Ticket belongs to a different event' },
|
||||
400: { description: 'Ticket belongs to a different event, or the payment method is not enabled for this event' },
|
||||
403: { description: 'amountOverride sent by a role that may not override the door amount' },
|
||||
404: { description: 'Event or ticket not found' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Door payment tenders.
|
||||
//
|
||||
// The door check-in screen offers four one-tap tenders. Each maps onto an
|
||||
// The door check-in screen offers five tenders. Each maps onto an
|
||||
// existing payments.provider so the rest of the app (capacity, sweeps, admin
|
||||
// payment lists, receipts) keeps working unchanged, while payments.method
|
||||
// records which tender was actually used for the end-of-night cash-up.
|
||||
@@ -9,27 +9,49 @@
|
||||
// already made — the same trust model as cash, no invoice generated. When a real
|
||||
// Lightning flow lands it slots in here: the tender keeps its name and provider,
|
||||
// only the settlement path in routes/door.ts changes.
|
||||
//
|
||||
// POS is the physical card terminal. Staff open the POS step, which shows the
|
||||
// amount to key into the terminal, charge the card, then confirm "Mark as paid";
|
||||
// only that confirmation reaches this API, recorded like any other tender. A
|
||||
// future automatic amount push to the terminal belongs in the POS step itself
|
||||
// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to
|
||||
// the terminal today.
|
||||
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
|
||||
|
||||
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||
|
||||
interface DoorTender {
|
||||
/** Existing payments.provider this tender is stored as. */
|
||||
provider: 'cash' | 'lightning' | 'bank_transfer';
|
||||
provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos';
|
||||
/** Human label used in payment references and toasts. */
|
||||
label: string;
|
||||
/** Comp tenders carry no revenue and always record a zero amount. */
|
||||
isComp: boolean;
|
||||
/**
|
||||
* How the money is confirmed before the door screen records it:
|
||||
* 'on_tap' staff already hold the money when they tap (cash, …)
|
||||
* 'staff_confirm' staff charge an external device first, then confirm
|
||||
*/
|
||||
confirmation: 'on_tap' | 'staff_confirm';
|
||||
}
|
||||
|
||||
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
|
||||
cash: { provider: 'cash', label: 'cash', isComp: false },
|
||||
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
|
||||
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
|
||||
guest: { provider: 'cash', label: 'guest', isComp: true },
|
||||
cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
|
||||
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
|
||||
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
|
||||
pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' },
|
||||
guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tenders that can be switched off per event through payment options. Only POS
|
||||
* is configurable: the other door tenders are always available to staff.
|
||||
*/
|
||||
export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] {
|
||||
return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled);
|
||||
}
|
||||
|
||||
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
|
||||
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ export async function sendPaymentReceipt(paymentId: string): Promise<{ success:
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago', pos: 'POS' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
|
||||
};
|
||||
|
||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
||||
// card handled offline, cash at the door). These are never auto-confirmed
|
||||
// and never auto-failed; an admin settles them by hand. Bank transfer and
|
||||
// card handled offline, cash or the POS terminal at the door). These are
|
||||
// never auto-confirmed and never auto-failed; an admin settles them by hand. Bank transfer and
|
||||
// TPago additionally expose an online "I've paid" step that moves the
|
||||
// payment to 'pending_approval'.
|
||||
//
|
||||
@@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> =
|
||||
bank_transfer: { kind: 'manual' },
|
||||
card: { kind: 'manual' },
|
||||
cash: { kind: 'manual' },
|
||||
// Card on the physical POS terminal at the door; staff confirm it by hand
|
||||
pos: { kind: 'manual' },
|
||||
};
|
||||
|
||||
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveWalkInPrice, omitWalkInPrice, parseWalkInPrice, canSeeWalkInPrice } from './walkInPrice.js';
|
||||
|
||||
describe('resolveWalkInPrice', () => {
|
||||
it('falls back to the ticket price when no walk-in price is set', () => {
|
||||
expect(resolveWalkInPrice({ price: 21000, walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||
expect(resolveWalkInPrice({ price: 21000 })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||
});
|
||||
|
||||
it('treats 0 as a free walk-in, not as unset', () => {
|
||||
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 0 })).toEqual({ unitPrice: 0, source: 'walk_in' });
|
||||
});
|
||||
|
||||
it('uses a set walk-in price', () => {
|
||||
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 25000 })).toEqual({ unitPrice: 25000, source: 'walk_in' });
|
||||
});
|
||||
|
||||
it('reads Postgres decimal strings', () => {
|
||||
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: '25000.00' })).toEqual({ unitPrice: 25000, source: 'walk_in' });
|
||||
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||
expect(parseWalkInPrice('0.00')).toBe(0);
|
||||
expect(parseWalkInPrice('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('omitWalkInPrice', () => {
|
||||
it('drops the walk-in price and keeps everything else', () => {
|
||||
const event = { id: 'e1', price: 21000, walkInPrice: 25000, currency: 'PYG' };
|
||||
const result = omitWalkInPrice(event);
|
||||
expect(result).not.toHaveProperty('walkInPrice');
|
||||
expect(result).toEqual({ id: 'e1', price: 21000, currency: 'PYG' });
|
||||
expect(event.walkInPrice).toBe(25000);
|
||||
});
|
||||
|
||||
it('passes null through', () => {
|
||||
expect(omitWalkInPrice(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSeeWalkInPrice', () => {
|
||||
it('is limited to event managers and door staff', () => {
|
||||
expect(canSeeWalkInPrice('admin')).toBe(true);
|
||||
expect(canSeeWalkInPrice('organizer')).toBe(true);
|
||||
expect(canSeeWalkInPrice('staff')).toBe(true);
|
||||
expect(canSeeWalkInPrice('marketing')).toBe(false);
|
||||
expect(canSeeWalkInPrice('user')).toBe(false);
|
||||
expect(canSeeWalkInPrice(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Walk-in (door) pricing.
|
||||
//
|
||||
// An event may set a separate price for people who buy at the door. It is an
|
||||
// internal number: staff charge it on the door screen, but it never appears on
|
||||
// the public event page, listings or JSON-LD, so every public serializer must
|
||||
// drop it (see omitWalkInPrice).
|
||||
//
|
||||
// walkInPrice null -> not set, walk-ins pay the regular ticket price
|
||||
// walkInPrice 0 -> free walk-in (a real value, distinct from null)
|
||||
// walkInPrice n -> walk-ins pay n, in the event's currency
|
||||
|
||||
export type WalkInPriceSource = 'walk_in' | 'ticket';
|
||||
|
||||
/** Roles that may see an event's walk-in price: event managers and door staff. */
|
||||
export const WALK_IN_PRICE_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||
|
||||
export function canSeeWalkInPrice(role: string | null | undefined): boolean {
|
||||
return !!role && (WALK_IN_PRICE_ROLES as readonly string[]).includes(role);
|
||||
}
|
||||
|
||||
/** Postgres decimals arrive as strings; null/undefined/garbage stay null. */
|
||||
export function parseWalkInPrice(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = typeof value === 'string' ? parseFloat(value) : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** The per-ticket price a walk-in is charged, and where it came from. */
|
||||
export function resolveWalkInPrice(event: { price: unknown; walkInPrice?: unknown }): {
|
||||
unitPrice: number;
|
||||
source: WalkInPriceSource;
|
||||
} {
|
||||
const walkIn = parseWalkInPrice(event.walkInPrice);
|
||||
if (walkIn !== null) return { unitPrice: walkIn, source: 'walk_in' };
|
||||
const price = typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price);
|
||||
return { unitPrice: Number.isFinite(price) ? price : 0, source: 'ticket' };
|
||||
}
|
||||
|
||||
/** Copy of an event row without its walk-in price, for anything a non-staff caller can read. */
|
||||
export function omitWalkInPrice<T extends Record<string, any> | null | undefined>(event: T): T {
|
||||
if (!event) return event;
|
||||
const { walkInPrice: _omitted, ...rest } = event as Record<string, any>;
|
||||
return rest as T;
|
||||
}
|
||||
@@ -519,6 +519,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
pos: filteredPayments.filter((p: any) => p.provider === 'pos' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
},
|
||||
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
|
||||
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, t
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
const dashboard = new Hono();
|
||||
|
||||
@@ -208,7 +209,7 @@ dashboard.get('/tickets/:id', async (c) => {
|
||||
return c.json({
|
||||
ticket: {
|
||||
...ticket,
|
||||
event,
|
||||
event: omitWalkInPrice(event as any),
|
||||
payment,
|
||||
invoice,
|
||||
},
|
||||
@@ -272,7 +273,7 @@ dashboard.get('/next-event', async (c) => {
|
||||
|
||||
return c.json({
|
||||
nextEvent: {
|
||||
event: nextEvent,
|
||||
event: omitWalkInPrice(nextEvent),
|
||||
ticket: nextTicket,
|
||||
payment: nextPayment,
|
||||
},
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('door-checkin: existing ticket', () => {
|
||||
it('takes a group payment at a multiple of the ticket price', async () => {
|
||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-unpaid-2',
|
||||
payment: { method: 'transfer', amount: PRICE * 2 },
|
||||
payment: { method: 'transfer', quantity: 2 },
|
||||
idempotencyKey: 'key-unpaid-2-transfer',
|
||||
});
|
||||
expect(body.payment.amount).toBe(PRICE * 2);
|
||||
@@ -441,3 +441,227 @@ describe('door-summary', () => {
|
||||
expect(body.door.lines[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Walk-in price & POS ====================
|
||||
// Separate events so these cannot drift into the door-summary totals above.
|
||||
|
||||
const WALKIN_EVENT_ID = 'evt-door-walkin';
|
||||
const WALKIN_PRICE = 25000;
|
||||
const WALKIN_TICKET_PRICE = 21000;
|
||||
const FREE_WALKIN_EVENT_ID = 'evt-door-free-walkin';
|
||||
const NO_POS_EVENT_ID = 'evt-door-no-pos';
|
||||
|
||||
describe('walk-in pricing', () => {
|
||||
beforeAll(() => {
|
||||
const now = new Date().toISOString();
|
||||
const insertEvent = sqlite.prepare(
|
||||
`INSERT INTO events (id, title, description, start_datetime, location, price, walk_in_price, currency, capacity, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'desc', ?, 'Asuncion', ?, ?, 'PYG', 100, 'published', ?, ?)`
|
||||
);
|
||||
insertEvent.run(WALKIN_EVENT_ID, 'Walk-in Night', now, WALKIN_TICKET_PRICE, WALKIN_PRICE, now, now);
|
||||
insertEvent.run(FREE_WALKIN_EVENT_ID, 'Free Door Night', now, WALKIN_TICKET_PRICE, 0, now, now);
|
||||
insertEvent.run(NO_POS_EVENT_ID, 'No POS Night', now, WALKIN_TICKET_PRICE, null, now, now);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'admin', 1, 'active', ?, ?)`
|
||||
)
|
||||
.run(ADMIN.id, 'admin@test.py', ADMIN.name, now, now);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO event_payment_overrides (id, event_id, pos_enabled, created_at, updated_at)
|
||||
VALUES ('ovr-no-pos', ?, 0, ?, ?)`
|
||||
)
|
||||
.run(NO_POS_EVENT_ID, now, now);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO tickets (id, user_id, event_id, attendee_first_name, status, payment_status, is_guest, qr_code, created_at)
|
||||
VALUES ('tkt-walkin-event-unpaid', 'seed-user', ?, 'Pre', 'confirmed', 'unpaid', 0, 'QR-walkin-unpaid', ?)`
|
||||
)
|
||||
.run(WALKIN_EVENT_ID, now);
|
||||
});
|
||||
|
||||
it('resolves the walk-in unit price on the server for the door screen', async () => {
|
||||
const withPrice = await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`);
|
||||
expect(withPrice.body.event).toMatchObject({
|
||||
price: WALKIN_TICKET_PRICE,
|
||||
walkInPrice: WALKIN_PRICE,
|
||||
walkInUnitPrice: WALKIN_PRICE,
|
||||
walkInPriceSource: 'walk_in',
|
||||
});
|
||||
|
||||
const fallback = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||
expect(fallback.body.event).toMatchObject({
|
||||
walkInPrice: null,
|
||||
walkInUnitPrice: PRICE,
|
||||
walkInPriceSource: 'ticket',
|
||||
});
|
||||
|
||||
const free = await get(`/api/events/${FREE_WALKIN_EVENT_ID}/door-attendees`);
|
||||
expect(free.body.event).toMatchObject({ walkInPrice: 0, walkInUnitPrice: 0, walkInPriceSource: 'walk_in' });
|
||||
});
|
||||
|
||||
it('charges walk-ins the walk-in price and marks them as walk-in bookings', async () => {
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Door', lastName: 'Buyer' },
|
||||
payment: { method: 'cash' },
|
||||
entryMethod: 'walkin',
|
||||
idempotencyKey: 'key-walkin-price-cash',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.payment).toMatchObject({ method: 'cash', amount: WALKIN_PRICE, currency: 'PYG', amountOverridden: false });
|
||||
|
||||
const ticket = sqlite.prepare('SELECT booking_source FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||
expect(ticket.booking_source).toBe('walk_in');
|
||||
const payment = sqlite.prepare('SELECT amount, currency, source FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment).toMatchObject({ amount: WALKIN_PRICE, currency: 'PYG', source: 'door' });
|
||||
});
|
||||
|
||||
it('treats a walk-in price of 0 as a free walk-in, not as "unset"', async () => {
|
||||
const { body } = await post(`/api/events/${FREE_WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Free' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-free',
|
||||
});
|
||||
expect(body.payment.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('multiplies the resolved price by quantity', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Group' },
|
||||
payment: { method: 'cash', quantity: 3 },
|
||||
idempotencyKey: 'key-walkin-price-group',
|
||||
});
|
||||
expect(body.payment.amount).toBe(WALKIN_PRICE * 3);
|
||||
});
|
||||
|
||||
it('ignores a client-sent amount without the override flag', async () => {
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Cheap' },
|
||||
payment: { method: 'cash', amount: 1 },
|
||||
idempotencyKey: 'key-walkin-client-amount',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.payment.amount).toBe(WALKIN_PRICE);
|
||||
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.amount).toBe(WALKIN_PRICE);
|
||||
});
|
||||
|
||||
it('refuses an amount override from door staff and writes nothing', async () => {
|
||||
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Discount' },
|
||||
payment: { method: 'cash', amount: 1000, amountOverride: true },
|
||||
idempotencyKey: 'key-walkin-staff-override',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
expect(body.code).toBe('OVERRIDE_FORBIDDEN');
|
||||
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
|
||||
});
|
||||
|
||||
it('lets an admin override the amount and records it in the audit log', async () => {
|
||||
const { status, body } = await as(ADMIN, () => post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Friend' },
|
||||
payment: { method: 'cash', amount: 10000, amountOverride: true },
|
||||
idempotencyKey: 'key-walkin-admin-override',
|
||||
}));
|
||||
expect(status).toBe(201);
|
||||
expect(body.payment).toMatchObject({ amount: 10000, amountOverridden: true });
|
||||
|
||||
const log = sqlite
|
||||
.prepare(`SELECT * FROM audit_logs WHERE action = 'door_amount_override' AND target_id = ?`)
|
||||
.get(body.payment.id);
|
||||
expect(log.user_id).toBe(ADMIN.id);
|
||||
expect(JSON.parse(log.details)).toMatchObject({
|
||||
eventId: WALKIN_EVENT_ID,
|
||||
computedAmount: WALKIN_PRICE,
|
||||
chargedAmount: 10000,
|
||||
currency: 'PYG',
|
||||
});
|
||||
});
|
||||
|
||||
it('settles an existing unpaid ticket at the ticket price, not the walk-in price', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-walkin-event-unpaid',
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-event-existing',
|
||||
});
|
||||
expect(body.payment.amount).toBe(WALKIN_TICKET_PRICE);
|
||||
});
|
||||
|
||||
it('keeps the charged amount when the walk-in price is edited afterwards', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Snapshot' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-snapshot',
|
||||
});
|
||||
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(99000, WALKIN_EVENT_ID);
|
||||
try {
|
||||
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.amount).toBe(WALKIN_PRICE);
|
||||
} finally {
|
||||
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(WALKIN_PRICE, WALKIN_EVENT_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POS tender', () => {
|
||||
it('is offered on the door screen unless switched off for the event', async () => {
|
||||
expect((await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`)).body.doorMethods)
|
||||
.toEqual(['cash', 'bitcoin', 'transfer', 'pos', 'guest']);
|
||||
expect((await get(`/api/events/${NO_POS_EVENT_ID}/door-attendees`)).body.doorMethods)
|
||||
.toEqual(['cash', 'bitcoin', 'transfer', 'guest']);
|
||||
});
|
||||
|
||||
it('records a confirmed POS walk-in as a paid door payment at the walk-in price', async () => {
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Card', lastName: 'Payer' },
|
||||
payment: { method: 'pos' },
|
||||
entryMethod: 'walkin',
|
||||
idempotencyKey: 'key-walkin-pos',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.attendee).toMatchObject({ checkedIn: true, paymentStatus: 'paid', doorMethod: 'pos' });
|
||||
expect(body.payment).toMatchObject({ method: 'pos', amount: WALKIN_PRICE });
|
||||
|
||||
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment).toMatchObject({
|
||||
provider: 'pos',
|
||||
method: 'pos',
|
||||
source: 'door',
|
||||
status: 'paid',
|
||||
amount: WALKIN_PRICE,
|
||||
paid_by_admin_id: STAFF.id,
|
||||
reference: 'Door — paid by POS',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows POS takings in the door summary', async () => {
|
||||
const { body } = await as(ADMIN, () => get(`/api/events/${WALKIN_EVENT_ID}/door-summary`));
|
||||
expect(body.door.byMethod.pos).toEqual({ count: 1, total: WALKIN_PRICE });
|
||||
});
|
||||
|
||||
it('can be undone like any other walk-in', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Declined' },
|
||||
payment: { method: 'pos' },
|
||||
idempotencyKey: 'key-walkin-pos-undo',
|
||||
});
|
||||
await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-walkin-pos-undo' });
|
||||
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('is rejected when POS is disabled for the event', async () => {
|
||||
const { status, body } = await post(`/api/events/${NO_POS_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Nope' },
|
||||
payment: { method: 'pos' },
|
||||
idempotencyKey: 'key-walkin-pos-disabled',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(body.code).toBe('METHOD_DISABLED');
|
||||
});
|
||||
});
|
||||
|
||||
+93
-10
@@ -21,7 +21,8 @@ import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
|
||||
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
|
||||
paymentOptions, eventPaymentOverrides,
|
||||
} from '../db/index.js';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
|
||||
@@ -29,8 +30,9 @@ import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
import {
|
||||
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
||||
paymentStatusForMethod, type DoorPaymentMethod,
|
||||
paymentStatusForMethod, enabledDoorMethods, type DoorPaymentMethod,
|
||||
} from '../lib/doorPayments.js';
|
||||
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import emailService from '../lib/email.js';
|
||||
|
||||
const doorRouter = new Hono();
|
||||
@@ -41,6 +43,11 @@ const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||
// never see what the event took overall. Matches the existing convention for
|
||||
// revenue aggregates (admin/export/financial, admin/analytics).
|
||||
const REVENUE_ROLES = ['admin', 'organizer'] as const;
|
||||
// The server prices every door charge from the event record. Only the roles the
|
||||
// app treats as administrators may override that with a typed amount, and every
|
||||
// override is written to audit_logs.
|
||||
const AMOUNT_OVERRIDE_ROLES = ['admin', 'organizer'] as const;
|
||||
const MAX_DOOR_QUANTITY = 50;
|
||||
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
||||
|
||||
// ==================== Shared helpers ====================
|
||||
@@ -99,10 +106,24 @@ async function loadEvent(eventId: string | undefined) {
|
||||
return {
|
||||
...event,
|
||||
price: num(event.price),
|
||||
walkInPrice: parseWalkInPrice(event.walkInPrice),
|
||||
capacity: Number(event.capacity),
|
||||
};
|
||||
}
|
||||
|
||||
/** Door tenders available for this event (POS can be switched off per event). */
|
||||
async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
|
||||
const [globalOptions, overrides] = await Promise.all([
|
||||
dbGet<any>((db as any).select().from(paymentOptions)),
|
||||
dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
),
|
||||
]);
|
||||
// Override wins when set; POS defaults to on when nothing is configured.
|
||||
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
|
||||
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
|
||||
}
|
||||
|
||||
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
|
||||
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(adminIds.filter(Boolean))];
|
||||
@@ -164,6 +185,9 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
|
||||
);
|
||||
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
|
||||
|
||||
const enabledMethods = await loadDoorMethods(event.id);
|
||||
const walkIn = resolveWalkInPrice(event);
|
||||
|
||||
const attendees = rows
|
||||
.map((t: any) => toDoorAttendee(t, {
|
||||
price: event.price,
|
||||
@@ -181,9 +205,15 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
price: event.price,
|
||||
// What one walk-in ticket costs, resolved server-side (walk-in price, or
|
||||
// the ticket price when none is set) so the screen shows what will be charged.
|
||||
walkInPrice: event.walkInPrice,
|
||||
walkInUnitPrice: walkIn.unitPrice,
|
||||
walkInPriceSource: walkIn.source,
|
||||
currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
},
|
||||
doorMethods: enabledMethods,
|
||||
attendees,
|
||||
stats: { checkedIn, totalActive, capacity: event.capacity },
|
||||
});
|
||||
@@ -204,9 +234,16 @@ const doorCheckinSchema = z.object({
|
||||
}).optional(),
|
||||
payment: z.object({
|
||||
method: z.enum(DOOR_PAYMENT_METHODS),
|
||||
// Omitted means "one ticket at event price"; a multiple covers someone
|
||||
// paying for their whole group in one go.
|
||||
// How many tickets' worth is being paid (someone paying for their group).
|
||||
// The server prices it: walk-in price for walk-ins, ticket price otherwise.
|
||||
quantity: z.number().int().min(1).max(MAX_DOOR_QUANTITY).optional(),
|
||||
// A typed amount is ignored unless amountOverride is set, which only
|
||||
// admin/organizer may do.
|
||||
amount: z.number().min(0).optional(),
|
||||
amountOverride: z.boolean().optional(),
|
||||
}).refine((p) => !p.amountOverride || typeof p.amount === 'number', {
|
||||
message: 'amount is required when amountOverride is set',
|
||||
path: ['amount'],
|
||||
}).optional(),
|
||||
// How the attendee reached this action, for the session feed.
|
||||
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
|
||||
@@ -255,19 +292,35 @@ doorRouter.post(
|
||||
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
|
||||
}
|
||||
|
||||
const amountOverride = !!data.payment?.amountOverride;
|
||||
if (amountOverride && !(AMOUNT_OVERRIDE_ROLES as readonly string[]).includes(adminUser?.role)) {
|
||||
return c.json({ error: 'Only an admin can override the door amount', code: 'OVERRIDE_FORBIDDEN' }, 403);
|
||||
}
|
||||
|
||||
const event = await loadEvent(eventId);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
|
||||
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
||||
if (method && !(await loadDoorMethods(event.id)).includes(method)) {
|
||||
return c.json({ error: `${DOOR_TENDERS[method].label} is not enabled for this event`, code: 'METHOD_DISABLED' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const nowIso = new Date().toISOString();
|
||||
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
||||
const requestedAmount = data.payment?.amount ?? event.price;
|
||||
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.
|
||||
const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice;
|
||||
const computedAmount = unitPrice * quantity;
|
||||
const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount;
|
||||
|
||||
const ops: TxOp[] = [];
|
||||
let undoState: UndoState;
|
||||
let action: 'checkin' | 'walkin';
|
||||
let ticketRow: any;
|
||||
let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null;
|
||||
let paymentSummary: {
|
||||
id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean;
|
||||
} | null = null;
|
||||
let emailTicketId: string | null = null;
|
||||
|
||||
if (data.ticketId) {
|
||||
@@ -326,7 +379,7 @@ doorRouter.post(
|
||||
method,
|
||||
updatedAt: now,
|
||||
}, eq((payments as any).id, existingPayment.id)));
|
||||
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency };
|
||||
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||
} else {
|
||||
const paymentId = generateId();
|
||||
undo.createdPaymentId = paymentId;
|
||||
@@ -345,7 +398,7 @@ doorRouter.post(
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
paymentSummary = { id: paymentId, method, amount, currency: event.currency };
|
||||
paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +482,7 @@ doorRouter.post(
|
||||
checkinAt: now,
|
||||
checkedInByAdminId: adminUser?.id || null,
|
||||
adminNote: null,
|
||||
bookingSource: 'walk_in',
|
||||
createdAt: now,
|
||||
};
|
||||
ops.push(insertOp(tickets, newTicket));
|
||||
@@ -448,13 +502,35 @@ doorRouter.post(
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency };
|
||||
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||
undoState = { kind: 'created', ticketId, paymentId };
|
||||
ticketRow = newTicket;
|
||||
// Only mail people who actually gave an address; no QR for the rest.
|
||||
if (hasEmail) emailTicketId = ticketId;
|
||||
}
|
||||
|
||||
// Written in the same transaction as the payment, so the log can never
|
||||
// disagree with what was recorded.
|
||||
if (paymentSummary?.amountOverridden) {
|
||||
ops.push(insertOp(auditLogs, {
|
||||
id: generateId(),
|
||||
userId: adminUser?.id || null,
|
||||
action: 'door_amount_override',
|
||||
target: 'payment',
|
||||
targetId: paymentSummary.id,
|
||||
details: JSON.stringify({
|
||||
eventId,
|
||||
ticketId: ticketRow.id,
|
||||
method: paymentSummary.method,
|
||||
quantity,
|
||||
computedAmount,
|
||||
chargedAmount: paymentSummary.amount,
|
||||
currency: event.currency,
|
||||
}),
|
||||
timestamp: now,
|
||||
}));
|
||||
}
|
||||
|
||||
// Staff at the door is the authority: a full event is a warning, never a block.
|
||||
const held = await seatsHeld(eventId);
|
||||
const atCapacity = event.capacity > 0 && held >= event.capacity;
|
||||
@@ -496,6 +572,13 @@ doorRouter.post(
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (paymentSummary?.amountOverridden) {
|
||||
console.info(
|
||||
`[Door] Amount override by ${adminUser?.id} (${adminUser?.role}) on event ${eventId}: ` +
|
||||
`${paymentSummary.amount} ${event.currency} instead of ${computedAmount} (${paymentSummary.method})`
|
||||
);
|
||||
}
|
||||
|
||||
if (emailTicketId) {
|
||||
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
|
||||
console.error('[Email] Failed to send door walk-in confirmation:', err);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -24,10 +25,14 @@ const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
// `settings` is the site_settings row; when given, the effective pre-sale
|
||||
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
||||
// booking API agree on when registration closes.
|
||||
function normalizeEvent(event: any, settings?: any) {
|
||||
// The walk-in (door) price is internal: it is dropped unless the caller is
|
||||
// admin/organizer/staff and `includeWalkInPrice` is set.
|
||||
function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) {
|
||||
if (!event) return event;
|
||||
const { walkInPrice, ...publicFields } = event;
|
||||
const normalized = {
|
||||
...event,
|
||||
...publicFields,
|
||||
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
|
||||
// Convert price from string/decimal to clean number
|
||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||
// Convert capacity from string to number if needed
|
||||
@@ -114,6 +119,29 @@ const parsePrice = (val: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Walk-in price: empty/null means "not set" (fall back to price) and must stay
|
||||
// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input
|
||||
// fails validation instead of silently becoming 0 the way parsePrice does.
|
||||
const walkInPriceSchema = z.union([z.number(), z.string(), z.null()])
|
||||
.transform((val) => {
|
||||
if (val === null) return null;
|
||||
if (typeof val === 'number') return val;
|
||||
const trimmed = val.trim();
|
||||
if (trimmed === '') return null;
|
||||
return Number(trimmed.replace(',', '.'));
|
||||
})
|
||||
.pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable())
|
||||
.optional();
|
||||
|
||||
// PYG has no minor unit, so a PYG walk-in price must be a whole number.
|
||||
function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null {
|
||||
if (walkInPrice == null) return null;
|
||||
if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) {
|
||||
return 'walkInPrice: Walk-in price must be a whole number for PYG';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to normalize boolean (handles true/false and 0/1)
|
||||
const normalizeBoolean = (val: unknown): boolean => {
|
||||
if (typeof val === 'boolean') return val;
|
||||
@@ -137,6 +165,7 @@ const baseEventSchema = z.object({
|
||||
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
// Accept price as number or string (handles "45000" and "41,44" formats)
|
||||
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||
walkInPrice: walkInPriceSchema,
|
||||
currency: z.string().default('PYG'),
|
||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
@@ -219,6 +248,7 @@ eventsRouter.get('/', async (c) => {
|
||||
// any client-supplied status filter, so drafts cannot leak.
|
||||
const authUser: any = await getAuthUser(c);
|
||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
|
||||
|
||||
const conditions: any[] = [];
|
||||
|
||||
@@ -271,7 +301,7 @@ eventsRouter.get('/', async (c) => {
|
||||
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event, siteSettingsRow);
|
||||
const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice });
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
@@ -295,16 +325,19 @@ eventsRouter.get('/:id', async (c) => {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const authUser: any = await getAuthUser(c);
|
||||
|
||||
// Draft events are only visible to privileged users (admin preview); hide from public.
|
||||
if ((event as any).status === 'draft') {
|
||||
const authUser: any = await getAuthUser(c);
|
||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||
if (!isPrivileged) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow());
|
||||
const normalized = normalizeEvent(event, await getSiteSettingsRow(), {
|
||||
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
|
||||
});
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
return c.json({
|
||||
event: {
|
||||
@@ -460,6 +493,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
|
||||
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
|
||||
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
|
||||
@@ -483,7 +519,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
revalidateFrontendCache();
|
||||
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow) }, 201);
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201);
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
@@ -498,6 +534,14 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
if (data.walkInPrice !== undefined || data.currency !== undefined) {
|
||||
const walkInError = walkInPriceError(
|
||||
data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice),
|
||||
data.currency ?? existing.currency,
|
||||
);
|
||||
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
@@ -559,7 +603,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow) });
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
|
||||
});
|
||||
|
||||
// Delete event (admin only)
|
||||
@@ -670,6 +714,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
location: existing.location,
|
||||
locationUrl: existing.locationUrl,
|
||||
price: existing.price,
|
||||
walkInPrice: existing.walkInPrice ?? null,
|
||||
currency: existing.currency,
|
||||
capacity: existing.capacity,
|
||||
status: 'draft',
|
||||
@@ -684,7 +729,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
|
||||
await (db as any).insert(events).values(duplicatedEvent);
|
||||
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201);
|
||||
});
|
||||
|
||||
// List slug aliases for an event (admin/organizer only)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// The walk-in price is internal: admins set it, door staff charge it, and no
|
||||
// public event response may carry it. These tests pin both halves.
|
||||
|
||||
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||
const dir = mkdtempSync(join(tmpdir(), 'events-walkin-test-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = dbPath;
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'events-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL;
|
||||
delete process.env.REVALIDATE_SECRET;
|
||||
|
||||
type TestUser = { id: string; name: string; role: string } | null;
|
||||
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
|
||||
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
||||
const MEMBER = { id: 'member-user-id', name: 'Member', role: 'user' };
|
||||
|
||||
// Anonymous by default, like the public site and its server-side fetches.
|
||||
let currentUser: TestUser = null;
|
||||
|
||||
vi.mock('../lib/auth.js', () => ({
|
||||
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||
if (!currentUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||
c.set('user', currentUser);
|
||||
await next();
|
||||
},
|
||||
getAuthUser: async () => currentUser,
|
||||
}));
|
||||
|
||||
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = currentUser;
|
||||
currentUser = user;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
currentUser = previous;
|
||||
}
|
||||
}
|
||||
|
||||
let app: any;
|
||||
let sqlite: any;
|
||||
|
||||
async function request(method: string, path: string, body?: unknown) {
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
const baseEvent = {
|
||||
title: 'Walk-in Test',
|
||||
description: 'desc',
|
||||
startDatetime: '2099-01-10T20:00',
|
||||
location: 'Asuncion',
|
||||
price: 21000,
|
||||
currency: 'PYG',
|
||||
capacity: 40,
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
return (async () => {
|
||||
const { Hono } = await import('hono');
|
||||
const eventsRoutes = (await import('./events.js')).default;
|
||||
app = new Hono();
|
||||
app.route('/api/events', eventsRoutes);
|
||||
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
sqlite = new Database(dbPath);
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
describe('admin event form: walk-in price', () => {
|
||||
it('saves an empty walk-in price as null, not 0', async () => {
|
||||
const { status, body } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Empty walk-in', walkInPrice: '' })
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
expect(body.event.walkInPrice).toBeNull();
|
||||
expect(sqlite.prepare('SELECT walk_in_price FROM events WHERE id = ?').get(body.event.id).walk_in_price).toBeNull();
|
||||
});
|
||||
|
||||
it('saves 0 as a free walk-in and a number as-is', async () => {
|
||||
const free = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Free walk-in', walkInPrice: 0 })
|
||||
);
|
||||
expect(free.body.event.walkInPrice).toBe(0);
|
||||
|
||||
const priced = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Priced walk-in', walkInPrice: '25000' })
|
||||
);
|
||||
expect(priced.body.event.walkInPrice).toBe(25000);
|
||||
});
|
||||
|
||||
it('rejects negative, non-numeric and fractional PYG walk-in prices', async () => {
|
||||
for (const walkInPrice of [-1, 'abc', 25000.5]) {
|
||||
const { status } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Bad walk-in', walkInPrice })
|
||||
);
|
||||
expect(status, `walkInPrice ${walkInPrice}`).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates and clears the walk-in price, and duplicates carry it over', async () => {
|
||||
const created = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Editable walk-in', walkInPrice: 25000 })
|
||||
);
|
||||
const id = created.body.event.id;
|
||||
|
||||
const updated = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 30000 }));
|
||||
expect(updated.body.event.walkInPrice).toBe(30000);
|
||||
|
||||
const copy = await as(ADMIN, () => request('POST', `/api/events/${id}/duplicate`));
|
||||
expect(copy.status).toBe(201);
|
||||
expect(copy.body.event.walkInPrice).toBe(30000);
|
||||
|
||||
const cleared = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: null }));
|
||||
expect(cleared.body.event.walkInPrice).toBeNull();
|
||||
|
||||
// Omitting the field leaves it untouched.
|
||||
await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 0 }));
|
||||
const untouched = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { capacity: 45 }));
|
||||
expect(untouched.body.event.walkInPrice).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public event responses', () => {
|
||||
let slug: string;
|
||||
let id: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { body } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Secret Door Price', walkInPrice: 25000 })
|
||||
);
|
||||
slug = body.event.slug;
|
||||
id = body.event.id;
|
||||
});
|
||||
|
||||
it('never include the walk-in price for anonymous or regular users', async () => {
|
||||
for (const user of [null, MEMBER]) {
|
||||
await as(user, async () => {
|
||||
const single = await request('GET', `/api/events/${slug}`);
|
||||
expect(single.status).toBe(200);
|
||||
expect(single.body.event).not.toHaveProperty('walkInPrice');
|
||||
expect(single.body.event.price).toBe(21000);
|
||||
|
||||
const list = await request('GET', '/api/events');
|
||||
const listed = list.body.events.find((e: any) => e.id === id);
|
||||
expect(listed).toBeTruthy();
|
||||
expect(listed).not.toHaveProperty('walkInPrice');
|
||||
|
||||
const next = await request('GET', '/api/events/next/upcoming');
|
||||
expect(next.body.event).not.toHaveProperty('walkInPrice');
|
||||
|
||||
// Belt and braces: the value must not appear anywhere in the payload.
|
||||
expect(JSON.stringify(single.body)).not.toContain('25000');
|
||||
expect(JSON.stringify(list.body)).not.toContain('25000');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('includes it for admin and door staff', async () => {
|
||||
for (const user of [ADMIN, STAFF]) {
|
||||
const single = await as(user, () => request('GET', `/api/events/${id}`));
|
||||
expect(single.body.event.walkInPrice).toBe(25000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,8 @@ const updatePaymentOptionsSchema = z.object({
|
||||
cashEnabled: booleanOrNumber.optional(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
// POS terminal on the door screen
|
||||
posEnabled: booleanOrNumber.optional(),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||
});
|
||||
@@ -79,6 +81,7 @@ const updateEventOverridesSchema = z.object({
|
||||
cashEnabled: booleanOrNumber.optional().nullable(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
posEnabled: booleanOrNumber.optional().nullable(),
|
||||
});
|
||||
|
||||
// Get global payment options
|
||||
@@ -111,6 +114,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
posEnabled: true,
|
||||
allowDuplicateBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -219,6 +223,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
posEnabled: true,
|
||||
};
|
||||
|
||||
const global = globalOptions || defaults;
|
||||
@@ -245,6 +250,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
||||
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
||||
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
||||
posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true,
|
||||
};
|
||||
|
||||
// Full bank/TPago credentials are only returned when the caller proves they hold
|
||||
|
||||
@@ -1508,6 +1508,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
qrCode,
|
||||
checkinAt: data.autoCheckin ? now : null,
|
||||
adminNote: data.adminNote || null,
|
||||
bookingSource: 'admin',
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
@@ -1663,6 +1664,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
||||
checkinAt: data.checkinNow ? now : null,
|
||||
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
||||
adminNote: data.adminNote || null,
|
||||
bookingSource: 'admin',
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { authSessions } from '../db/auth-schema.js';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -249,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
event,
|
||||
event: omitWalkInPrice(event as any),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
|
||||
cash: { en: 'Cash', es: 'Efectivo' },
|
||||
bancard: { en: 'Card', es: 'Tarjeta' },
|
||||
pos: { en: 'POS', es: 'POS' },
|
||||
};
|
||||
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
||||
};
|
||||
|
||||
@@ -177,6 +177,7 @@ export default function AdminBookingsPage() {
|
||||
lightning: 'Lightning',
|
||||
tpago: 'TPago',
|
||||
bancard: 'Bancard',
|
||||
pos: 'POS',
|
||||
};
|
||||
return labels[provider] || provider;
|
||||
};
|
||||
@@ -443,7 +444,10 @@ export default function AdminBookingsPage() {
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||
{ticket.payment?.status || 'pending'}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{getPaymentMethodLabel(getDisplayProvider(ticket))}
|
||||
{ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')}
|
||||
</p>
|
||||
{ticket.payment && (
|
||||
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }>
|
||||
cash: { en: 'Cash', es: 'Efectivo' },
|
||||
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
|
||||
transfer: { en: 'Transfer', es: 'Transferencia' },
|
||||
pos: { en: 'POS', es: 'POS' },
|
||||
guest: { en: 'Guests', es: 'Invitados' },
|
||||
};
|
||||
|
||||
@@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
||||
handleSavePaymentOptions,
|
||||
} = payments;
|
||||
|
||||
// Stored as 0/1 on Postgres; POS is on unless explicitly switched off.
|
||||
const posEnabled = !!(getEffectivePaymentOption('posEnabled') ?? true);
|
||||
const posDisabledGlobally = !!globalPaymentOptions && !(globalPaymentOptions.posEnabled ?? true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadingPayments ? (
|
||||
@@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* POS terminal at the door */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-violet-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<CreditCardIcon className="w-4 h-4 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">POS</h4>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{posDisabledGlobally && (
|
||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updatePaymentOverride('posEnabled', !posEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
posEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
@@ -453,6 +494,7 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
||||
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
||||
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
||||
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
||||
{ label: 'POS', enabled: posEnabled },
|
||||
].map((method) => (
|
||||
<div key={method.label} className="flex items-center gap-1.5">
|
||||
{method.enabled ? (
|
||||
|
||||
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
|
||||
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
|
||||
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
|
||||
|
||||
interface EventFormData {
|
||||
title: string;
|
||||
@@ -25,6 +25,9 @@ interface EventFormData {
|
||||
location: string;
|
||||
locationUrl: string;
|
||||
price: number;
|
||||
// Kept as the raw input so "empty" (not set -> falls back to price) stays
|
||||
// distinguishable from 0 (a free walk-in).
|
||||
walkInPrice: string;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
@@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = {
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
|
||||
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
||||
};
|
||||
|
||||
/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
|
||||
function isValidWalkInPrice(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return true;
|
||||
const n = Number(trimmed);
|
||||
return Number.isInteger(n) && n >= 0;
|
||||
}
|
||||
|
||||
function isoToLocalDatetime(isoString: string): string {
|
||||
const date = parseDate(isoString);
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -138,6 +149,7 @@ export default function EventFormModal({
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location, locationUrl: event.locationUrl || '',
|
||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
@@ -208,6 +220,11 @@ export default function EventFormModal({
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (!isValidWalkInPrice(formData.walkInPrice)) {
|
||||
toast.error(t('admin.events.form.walkInPriceInvalid'));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
@@ -216,6 +233,8 @@ export default function EventFormModal({
|
||||
endDatetime: formData.endDatetime || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
// Empty means "not set" and must be saved as null, never 0
|
||||
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
@@ -342,17 +361,30 @@ export default function EventFormModal({
|
||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
|
||||
value={formData.walkInPrice}
|
||||
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
|
||||
placeholder={t('admin.events.form.walkInPricePlaceholder', {
|
||||
price: formatPrice(formData.price || 0, formData.currency),
|
||||
})}
|
||||
error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('admin.events.form.walkInPriceHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
|
||||
bankNotesEs: null,
|
||||
lightningEnabled: true,
|
||||
cashEnabled: true,
|
||||
posEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
|
||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Stored as 0/1 on Postgres; on unless explicitly switched off.
|
||||
const posEnabled = !!(options.posEnabled ?? true);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* POS terminal at the door */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-full flex items-center justify-center">
|
||||
<CreditCardIcon className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">POS</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
|
||||
: 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('posEnabled', !posEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
posEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Booking Settings */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
|
||||
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{posEnabled ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||
)}
|
||||
<span className={posEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||
POS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -313,6 +313,7 @@ export default function AdminPaymentsPage() {
|
||||
bank_transfer: BuildingLibraryIcon,
|
||||
tpago: CreditCardIcon,
|
||||
bancard: CreditCardIcon,
|
||||
pos: CreditCardIcon,
|
||||
};
|
||||
const Icon = icons[provider] || CreditCardIcon;
|
||||
return <Icon className="w-4 h-4" />;
|
||||
@@ -325,6 +326,7 @@ export default function AdminPaymentsPage() {
|
||||
lightning: 'Lightning',
|
||||
tpago: 'TPago',
|
||||
bancard: 'Bancard',
|
||||
pos: 'POS',
|
||||
};
|
||||
return labels[provider] || provider;
|
||||
};
|
||||
@@ -736,7 +738,7 @@ export default function AdminPaymentsPage() {
|
||||
{/* By Provider */}
|
||||
<div className="bg-secondary-gray rounded-btn p-4">
|
||||
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
||||
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
|
||||
@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {
|
||||
<p className="text-gray-500">Bancard</p>
|
||||
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">POS</p>
|
||||
<p className="text-lg font-bold">{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() {
|
||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||
<option value="tpago">TPago</option>
|
||||
<option value="pos">POS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() {
|
||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||
<option value="tpago">TPago</option>
|
||||
<option value="pos">POS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
|
||||
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import { PaymentButtons } from './PaymentButtons';
|
||||
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
|
||||
|
||||
function checkinTime(checkinAt: string | null): string {
|
||||
if (!checkinAt) return '';
|
||||
@@ -23,6 +23,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'cash',
|
||||
bitcoin: 'bitcoin',
|
||||
transfer: 'transfer',
|
||||
pos: 'POS',
|
||||
guest: 'guest',
|
||||
};
|
||||
|
||||
@@ -49,6 +50,8 @@ export function AttendeeRow({
|
||||
attendee,
|
||||
currency,
|
||||
price,
|
||||
methods,
|
||||
canOverrideAmount,
|
||||
expanded,
|
||||
flashing,
|
||||
busy,
|
||||
@@ -58,11 +61,13 @@ export function AttendeeRow({
|
||||
attendee: DoorAttendee;
|
||||
currency: string;
|
||||
price: number;
|
||||
methods: readonly DoorPaymentMethod[];
|
||||
canOverrideAmount: boolean;
|
||||
expanded: boolean;
|
||||
flashing: boolean;
|
||||
busy: boolean;
|
||||
onTap: () => void;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||
}) {
|
||||
const isCancelled = attendee.status === 'cancelled';
|
||||
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
|
||||
@@ -141,7 +146,14 @@ export function AttendeeRow({
|
||||
Reactivate as a walk-in — pick how they are paying.
|
||||
</p>
|
||||
)}
|
||||
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
|
||||
<PaymentButtons
|
||||
price={price}
|
||||
currency={currency}
|
||||
methods={methods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
onPay={onPay}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,14 +6,29 @@ import {
|
||||
BanknotesIcon,
|
||||
BoltIcon,
|
||||
BuildingLibraryIcon,
|
||||
CreditCardIcon,
|
||||
GiftIcon,
|
||||
ChevronDownIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { DoorPaymentMethod } from '@/lib/api';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { PosChargePanel } from './PosChargePanel';
|
||||
|
||||
// The four tenders staff can take at the door. One tap settles and checks in;
|
||||
// The tenders staff can take at the door. One tap settles and checks in;
|
||||
// long-press (or the chevron) opens multiples for someone paying for their group.
|
||||
// POS is the exception: it opens a confirm step, because the card is charged on
|
||||
// the terminal first (see PosChargePanel).
|
||||
|
||||
/**
|
||||
* What staff chose to charge. The server prices it from quantity; `amount` is
|
||||
* what the screen expects that to come to, and is only sent as a charge when
|
||||
* `override` is set (admin/organizer "Custom").
|
||||
*/
|
||||
export interface DoorCharge {
|
||||
quantity: number;
|
||||
amount: number;
|
||||
override?: boolean;
|
||||
}
|
||||
|
||||
const TENDERS: {
|
||||
method: DoorPaymentMethod;
|
||||
@@ -24,6 +39,7 @@ const TENDERS: {
|
||||
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
|
||||
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
|
||||
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
|
||||
{ method: 'pos', label: 'POS', icon: CreditCardIcon, className: 'bg-violet-600 active:bg-violet-700' },
|
||||
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
|
||||
];
|
||||
|
||||
@@ -32,21 +48,32 @@ const LONG_PRESS_MS = 450;
|
||||
export function PaymentButtons({
|
||||
price,
|
||||
currency,
|
||||
methods,
|
||||
canOverrideAmount,
|
||||
onPay,
|
||||
disabled,
|
||||
}: {
|
||||
/** Unit price for one ticket, as the server will charge it. */
|
||||
price: number;
|
||||
currency: string;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
/** Tenders enabled for this event. */
|
||||
methods: readonly DoorPaymentMethod[];
|
||||
/** Admin/organizer may type a custom amount; the server enforces the same rule. */
|
||||
canOverrideAmount: boolean;
|
||||
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
|
||||
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
// POS charge waiting for staff to confirm the terminal approved it.
|
||||
const [posCharge, setPosCharge] = useState<DoorCharge | null>(null);
|
||||
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [longPressed, setLongPressed] = useState(false);
|
||||
|
||||
const tenders = TENDERS.filter((t) => methods.includes(t.method));
|
||||
|
||||
const openAmounts = (method: DoorPaymentMethod) => {
|
||||
if (method === 'guest') return;
|
||||
setAmountsFor(method);
|
||||
@@ -54,6 +81,15 @@ export function PaymentButtons({
|
||||
setCustomValue('');
|
||||
};
|
||||
|
||||
// Every tender settles immediately except POS, which stops at its confirm step.
|
||||
const charge = (method: DoorPaymentMethod, next: DoorCharge) => {
|
||||
if (method === 'pos') {
|
||||
setPosCharge(next);
|
||||
return;
|
||||
}
|
||||
onPay(method, next);
|
||||
};
|
||||
|
||||
const startPress = (method: DoorPaymentMethod) => {
|
||||
setLongPressed(false);
|
||||
const timer = setTimeout(() => {
|
||||
@@ -72,7 +108,7 @@ export function PaymentButtons({
|
||||
return;
|
||||
}
|
||||
if (disabled) return;
|
||||
onPay(method, method === 'guest' ? 0 : price);
|
||||
charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
|
||||
};
|
||||
|
||||
const cancelPress = () => {
|
||||
@@ -81,6 +117,19 @@ export function PaymentButtons({
|
||||
setLongPressed(false);
|
||||
};
|
||||
|
||||
if (posCharge) {
|
||||
return (
|
||||
<PosChargePanel
|
||||
charge={posCharge}
|
||||
unitPrice={price}
|
||||
currency={currency}
|
||||
disabled={disabled}
|
||||
onConfirm={() => onPay('pos', posCharge)}
|
||||
onBack={() => setPosCharge(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (amountsFor) {
|
||||
const tender = TENDERS.find((t) => t.method === amountsFor)!;
|
||||
return (
|
||||
@@ -94,12 +143,12 @@ export function PaymentButtons({
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className={clsx('grid gap-2', canOverrideAmount ? 'grid-cols-4' : 'grid-cols-3')}>
|
||||
{[1, 2, 3].map((qty) => (
|
||||
<button
|
||||
key={qty}
|
||||
disabled={disabled}
|
||||
onClick={() => onPay(tender.method, price * qty)}
|
||||
onClick={() => charge(tender.method, { quantity: qty, amount: price * qty })}
|
||||
className={clsx(
|
||||
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||
tender.className,
|
||||
@@ -111,15 +160,17 @@ export function PaymentButtons({
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={() => setCustomOpen((open) => !open)}
|
||||
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
{canOverrideAmount && (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={() => setCustomOpen((open) => !open)}
|
||||
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{customOpen && (
|
||||
{canOverrideAmount && customOpen && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -132,7 +183,7 @@ export function PaymentButtons({
|
||||
/>
|
||||
<button
|
||||
disabled={disabled || !customValue || Number(customValue) < 0}
|
||||
onClick={() => onPay(tender.method, Number(customValue))}
|
||||
onClick={() => charge(tender.method, { quantity: 1, amount: Number(customValue), override: true })}
|
||||
className={clsx(
|
||||
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||
tender.className,
|
||||
@@ -147,8 +198,11 @@ export function PaymentButtons({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{TENDERS.map((tender) => (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${tenders.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tenders.map((tender) => (
|
||||
<button
|
||||
key={tender.method}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { CreditCardIcon } from '@heroicons/react/24/outline';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { DoorCharge } from './PaymentButtons';
|
||||
|
||||
// The POS step of the door screen.
|
||||
//
|
||||
// Unlike cash, the money is not in staff's hand when they pick POS: they first
|
||||
// key the amount into the physical card terminal, wait for approval, and only
|
||||
// then confirm here. So POS is two taps: pick POS (this panel opens with the
|
||||
// amount to type), then "Mark as paid", which records it through the same
|
||||
// door-checkin call as every other tender.
|
||||
//
|
||||
// Nothing talks to the terminal today. If an automatic amount push is added
|
||||
// later, it starts when this panel opens and its approval replaces (or pre-fills)
|
||||
// the manual confirmation below; the rest of the door flow stays unchanged.
|
||||
|
||||
export function PosChargePanel({
|
||||
charge,
|
||||
unitPrice,
|
||||
currency,
|
||||
disabled,
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: {
|
||||
charge: DoorCharge;
|
||||
unitPrice: number;
|
||||
currency: string;
|
||||
disabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-sm font-semibold text-white flex items-center gap-1.5">
|
||||
<CreditCardIcon className="w-5 h-5" />
|
||||
POS — charge on the terminal
|
||||
</p>
|
||||
<button onClick={onBack} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-gray-900 border border-violet-500/40 px-4 py-3 text-center">
|
||||
<p className="text-[11px] uppercase tracking-wide text-gray-400">Amount to charge</p>
|
||||
<p className="text-3xl font-bold text-white tabular-nums">{formatCurrency(charge.amount, currency)}</p>
|
||||
{charge.override ? (
|
||||
<p className="text-xs text-amber-400 mt-0.5">Custom amount</p>
|
||||
) : charge.quantity > 1 ? (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{charge.quantity} × {formatCurrency(unitPrice, currency)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
className="w-full min-h-[56px] rounded-2xl bg-violet-600 active:bg-violet-700 text-white text-lg font-bold disabled:opacity-50 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
Mark as paid
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 text-center px-2">
|
||||
Only tap once the terminal has approved the card.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'Cash',
|
||||
bitcoin: 'Bitcoin',
|
||||
transfer: 'Transfer',
|
||||
pos: 'POS',
|
||||
guest: 'Guest',
|
||||
};
|
||||
|
||||
@@ -52,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) {
|
||||
cash: { count: 0, total: 0 },
|
||||
bitcoin: { count: 0, total: 0 },
|
||||
transfer: { count: 0, total: 0 },
|
||||
pos: { count: 0, total: 0 },
|
||||
guest: { count: 0, total: 0 },
|
||||
};
|
||||
let grand = 0;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import type { DoorPaymentMethod } from '@/lib/api';
|
||||
import { PaymentButtons } from './PaymentButtons';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
|
||||
|
||||
export interface WalkInDraft {
|
||||
firstName: string;
|
||||
@@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
const PRICE_SOURCE_LABELS: Record<'walk_in' | 'ticket', string> = {
|
||||
walk_in: 'Walk-in price',
|
||||
ticket: 'Ticket price (no walk-in price set)',
|
||||
};
|
||||
|
||||
/**
|
||||
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
|
||||
* name and four tenders. Email, phone and RUC live behind "Add details" so the
|
||||
* name, the walk-in price and the tenders. Email, phone and RUC live behind "Add details" so the
|
||||
* rare person who wants a receipt never slows down the queue behind them.
|
||||
*/
|
||||
export function WalkInRow({
|
||||
@@ -32,7 +38,10 @@ export function WalkInRow({
|
||||
expanded,
|
||||
draft,
|
||||
price,
|
||||
priceSource,
|
||||
currency,
|
||||
methods,
|
||||
canOverrideAmount,
|
||||
busy,
|
||||
onExpand,
|
||||
onChange,
|
||||
@@ -42,12 +51,16 @@ export function WalkInRow({
|
||||
typedText: string;
|
||||
expanded: boolean;
|
||||
draft: WalkInDraft;
|
||||
/** Walk-in unit price as resolved by the server. */
|
||||
price: number;
|
||||
priceSource: 'walk_in' | 'ticket';
|
||||
currency: string;
|
||||
methods: readonly DoorPaymentMethod[];
|
||||
canOverrideAmount: boolean;
|
||||
busy: boolean;
|
||||
onExpand: () => void;
|
||||
onChange: (draft: WalkInDraft) => void;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
@@ -146,9 +159,16 @@ export function WalkInRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-1 pt-1">
|
||||
<p className="text-2xl font-bold text-white tabular-nums">{formatCurrency(price, currency)}</p>
|
||||
<p className="text-xs text-gray-400">{PRICE_SOURCE_LABELS[priceSource]}</p>
|
||||
</div>
|
||||
|
||||
<PaymentButtons
|
||||
price={price}
|
||||
currency={currency}
|
||||
methods={methods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
onPay={onPay}
|
||||
disabled={busy || !draft.firstName.trim()}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
eventsApi,
|
||||
doorApi,
|
||||
DOOR_PAYMENT_METHODS,
|
||||
type Event,
|
||||
type DoorAttendee,
|
||||
type DoorAttendeesResponse,
|
||||
@@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/
|
||||
import { QRScannerOverlay } from './_components/QRScannerOverlay';
|
||||
import { AttendeeRow } from './_components/AttendeeRow';
|
||||
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
||||
import type { DoorCharge } from './_components/PaymentButtons';
|
||||
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
||||
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
|
||||
|
||||
@@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'paid cash',
|
||||
bitcoin: 'paid bitcoin',
|
||||
transfer: 'paid by transfer',
|
||||
pos: 'paid by POS',
|
||||
guest: 'in as guest',
|
||||
};
|
||||
|
||||
@@ -75,6 +78,9 @@ export default function AdminDoorPage() {
|
||||
// own shift from the session feed below, which is local to this device; the
|
||||
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
|
||||
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
|
||||
// Typing a custom door amount is an admin decision; the API rejects it for
|
||||
// anyone else (AMOUNT_OVERRIDE_ROLES in routes/door.ts).
|
||||
const canOverrideAmount = user?.role === 'admin' || user?.role === 'organizer';
|
||||
|
||||
// ── Events ──
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
@@ -122,6 +128,10 @@ export default function AdminDoorPage() {
|
||||
|
||||
const eventMeta = data?.event;
|
||||
const price = eventMeta?.price ?? 0;
|
||||
// Walk-ins pay the walk-in price when the event sets one; the server resolves it.
|
||||
const walkInPrice = eventMeta?.walkInUnitPrice ?? price;
|
||||
const walkInPriceSource = eventMeta?.walkInPriceSource ?? 'ticket';
|
||||
const doorMethods = data?.doorMethods ?? DOOR_PAYMENT_METHODS;
|
||||
const currency = eventMeta?.currency ?? 'PYG';
|
||||
const capacity = eventMeta?.capacity ?? 0;
|
||||
|
||||
@@ -351,7 +361,7 @@ export default function AdminDoorPage() {
|
||||
async (opts: {
|
||||
ticketId?: string;
|
||||
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
|
||||
payment?: { method: DoorPaymentMethod; amount: number };
|
||||
payment?: { method: DoorPaymentMethod; charge: DoorCharge };
|
||||
entry: DoorEntryMethod;
|
||||
displayName: string;
|
||||
/** Ticket row to flash and optimistically mark as in. */
|
||||
@@ -373,7 +383,7 @@ export default function AdminDoorPage() {
|
||||
at: clockTime(now),
|
||||
entry: opts.entry,
|
||||
method: opts.payment?.method ?? null,
|
||||
amount: opts.payment?.amount ?? 0,
|
||||
amount: opts.payment?.charge.amount ?? 0,
|
||||
startedAt: now.getTime(),
|
||||
undone: false,
|
||||
failed: false,
|
||||
@@ -424,10 +434,19 @@ export default function AdminDoorPage() {
|
||||
|
||||
// ── The write itself: background, retried, never blocking the queue ──
|
||||
try {
|
||||
const { method, charge } = opts.payment ?? {};
|
||||
const res = await submitDoorAction(eventId, {
|
||||
ticketId: opts.ticketId,
|
||||
attendee: opts.attendee,
|
||||
payment: opts.payment,
|
||||
// The server prices the charge from quantity; a typed amount only
|
||||
// travels as an explicit admin override.
|
||||
payment: method && charge
|
||||
? {
|
||||
method,
|
||||
quantity: charge.quantity,
|
||||
...(charge.override ? { amount: charge.amount, amountOverride: true } : {}),
|
||||
}
|
||||
: undefined,
|
||||
entryMethod: opts.entry,
|
||||
idempotencyKey,
|
||||
});
|
||||
@@ -435,7 +454,13 @@ export default function AdminDoorPage() {
|
||||
setSessionEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.idempotencyKey === idempotencyKey
|
||||
? { ...e, ticketId: res.attendee.ticketId, name: res.attendee.fullName }
|
||||
? {
|
||||
...e,
|
||||
ticketId: res.attendee.ticketId,
|
||||
name: res.attendee.fullName,
|
||||
// What was actually recorded is the server's number, not ours.
|
||||
amount: res.payment?.amount ?? e.amount,
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
@@ -508,10 +533,10 @@ export default function AdminDoorPage() {
|
||||
);
|
||||
|
||||
const handleRowPay = useCallback(
|
||||
(attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => {
|
||||
(attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => {
|
||||
runAction({
|
||||
ticketId: attendee.ticketId,
|
||||
payment: { method, amount },
|
||||
payment: { method, charge },
|
||||
entry: 'search',
|
||||
displayName: attendee.fullName,
|
||||
optimisticTicketId: attendee.ticketId,
|
||||
@@ -522,7 +547,7 @@ export default function AdminDoorPage() {
|
||||
);
|
||||
|
||||
const handleWalkInPay = useCallback(
|
||||
(method: DoorPaymentMethod, amount: number) => {
|
||||
(method: DoorPaymentMethod, charge: DoorCharge) => {
|
||||
const firstName = walkInDraft.firstName.trim();
|
||||
if (!firstName) return;
|
||||
const displayName = walkInDraft.lastName.trim()
|
||||
@@ -536,7 +561,7 @@ export default function AdminDoorPage() {
|
||||
email: walkInDraft.email.trim() || undefined,
|
||||
ruc: walkInDraft.ruc.trim() || undefined,
|
||||
},
|
||||
payment: { method, amount },
|
||||
payment: { method, charge },
|
||||
entry: 'walkin',
|
||||
displayName,
|
||||
busyKey: 'walk-in',
|
||||
@@ -764,11 +789,13 @@ export default function AdminDoorPage() {
|
||||
attendee={attendee}
|
||||
currency={currency}
|
||||
price={price}
|
||||
methods={doorMethods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
expanded={expandedId === attendee.ticketId}
|
||||
flashing={flashId === attendee.ticketId}
|
||||
busy={busyId === attendee.ticketId}
|
||||
onTap={() => handleRowTap(attendee)}
|
||||
onPay={(method, amount) => handleRowPay(attendee, method, amount)}
|
||||
onPay={(method, charge) => handleRowPay(attendee, method, charge)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -778,8 +805,11 @@ export default function AdminDoorPage() {
|
||||
typedText={trimmedQuery}
|
||||
expanded={walkInOpen}
|
||||
draft={walkInDraft}
|
||||
price={price}
|
||||
price={walkInPrice}
|
||||
priceSource={walkInPriceSource}
|
||||
currency={currency}
|
||||
methods={doorMethods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
busy={busyId === 'walk-in'}
|
||||
onExpand={() => {
|
||||
setWalkInDraft(emptyWalkIn(trimmedQuery));
|
||||
|
||||
@@ -294,7 +294,13 @@
|
||||
"edit": "Edit Event",
|
||||
"delete": "Delete Event",
|
||||
"publish": "Publish",
|
||||
"unpublish": "Unpublish"
|
||||
"unpublish": "Unpublish",
|
||||
"form": {
|
||||
"walkInPrice": "Walk-in price",
|
||||
"walkInPricePlaceholder": "Same as ticket price ({price})",
|
||||
"walkInPriceHelp": "Charged at the door via the Scanner. Not shown on the public event page.",
|
||||
"walkInPriceInvalid": "Walk-in price must be empty or a whole number of 0 or more"
|
||||
}
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Manage Tickets",
|
||||
|
||||
@@ -294,7 +294,13 @@
|
||||
"edit": "Editar Evento",
|
||||
"delete": "Eliminar Evento",
|
||||
"publish": "Publicar",
|
||||
"unpublish": "Despublicar"
|
||||
"unpublish": "Despublicar",
|
||||
"form": {
|
||||
"walkInPrice": "Precio en puerta",
|
||||
"walkInPricePlaceholder": "Igual al precio de la entrada ({price})",
|
||||
"walkInPriceHelp": "Se cobra en la puerta desde el Escáner. No se muestra en la página pública del evento.",
|
||||
"walkInPriceInvalid": "El precio en puerta debe quedar vacío o ser un número entero de 0 o más"
|
||||
}
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Gestionar Tickets",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fetchApi } from './client';
|
||||
// fire actions optimistically and retry on flaky venue wifi without ever
|
||||
// creating a duplicate ticket, payment or check-in.
|
||||
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
|
||||
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||
|
||||
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
|
||||
@@ -32,7 +32,20 @@ export interface DoorAttendee {
|
||||
}
|
||||
|
||||
export interface DoorAttendeesResponse {
|
||||
event: { id: string; title: string; price: number; currency: string; capacity: number };
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
/** The event's walk-in price as configured; null = not set. */
|
||||
walkInPrice: number | null;
|
||||
/** What one walk-in ticket costs, resolved by the server. */
|
||||
walkInUnitPrice: number;
|
||||
walkInPriceSource: 'walk_in' | 'ticket';
|
||||
currency: string;
|
||||
capacity: number;
|
||||
};
|
||||
/** Tenders enabled for this event (POS can be switched off in payment options). */
|
||||
doorMethods: DoorPaymentMethod[];
|
||||
attendees: DoorAttendee[];
|
||||
stats: { checkedIn: number; totalActive: number; capacity: number };
|
||||
}
|
||||
@@ -46,7 +59,11 @@ export interface DoorCheckinRequest {
|
||||
email?: string;
|
||||
ruc?: string;
|
||||
};
|
||||
payment?: { method: DoorPaymentMethod; amount?: number };
|
||||
/**
|
||||
* The server prices the charge (walk-in or ticket price x quantity). `amount`
|
||||
* is only honoured with `amountOverride`, which is admin/organizer only.
|
||||
*/
|
||||
payment?: { method: DoorPaymentMethod; quantity?: number; amount?: number; amountOverride?: boolean };
|
||||
entryMethod?: DoorEntryMethod;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
@@ -55,7 +72,13 @@ export interface DoorCheckinResponse {
|
||||
ok: true;
|
||||
action: 'checkin' | 'walkin';
|
||||
attendee: DoorAttendee;
|
||||
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
|
||||
payment: {
|
||||
id: string;
|
||||
method: DoorPaymentMethod;
|
||||
amount: number;
|
||||
currency: string;
|
||||
amountOverridden: boolean;
|
||||
} | null;
|
||||
/** 'at_capacity' — the event is full; the attendee was added anyway. */
|
||||
warnings: string[];
|
||||
idempotencyKey: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Payment, PaymentWithDetails } from './types';
|
||||
|
||||
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
|
||||
// verify the money arrived; automatic ones (lightning) confirm themselves.
|
||||
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
|
||||
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash', 'pos'];
|
||||
|
||||
export function isManualProvider(provider: string): boolean {
|
||||
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface Event {
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
price: number;
|
||||
// Door price for walk-ins. Only returned to admin/organizer/staff; null = not
|
||||
// set (walk-ins pay `price`), 0 = free walk-in.
|
||||
walkInPrice?: number | null;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
@@ -57,6 +60,8 @@ export interface Ticket {
|
||||
adminNote?: string;
|
||||
isGuest?: boolean;
|
||||
paymentStatus?: 'paid' | 'unpaid' | 'comp';
|
||||
// How the booking was made: public checkout, a door walk-in, or added by an admin
|
||||
bookingSource?: 'online' | 'walk_in' | 'admin';
|
||||
createdAt: string;
|
||||
event?: Event;
|
||||
payment?: Payment;
|
||||
@@ -124,7 +129,7 @@ export interface LiveSearchResult {
|
||||
export interface Payment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos';
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
|
||||
@@ -178,6 +183,8 @@ export interface PaymentOptionsConfig {
|
||||
cashEnabled: boolean;
|
||||
cashInstructions?: string | null;
|
||||
cashInstructionsEs?: string | null;
|
||||
// POS card terminal on the door screen (never offered at online checkout)
|
||||
posEnabled?: boolean;
|
||||
// Booking settings
|
||||
allowDuplicateBookings?: boolean;
|
||||
}
|
||||
@@ -362,6 +369,7 @@ export interface FinancialSummary {
|
||||
cash: number;
|
||||
bank_transfer: number;
|
||||
tpago: number;
|
||||
pos?: number;
|
||||
};
|
||||
paidCount: number;
|
||||
pendingCount: number;
|
||||
|
||||
Reference in New Issue
Block a user