Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price): null falls back to the ticket price, 0 is a free walk-in. It is set in the Create/Edit Event modal (EN/ES) and carried through create, update and duplicate, but it is internal: public event responses and the attendee-facing ticket/dashboard endpoints drop it, and only admin, organizer and staff callers receive it. The door screen no longer trusts the amount the client sends. It sends a quantity and the server prices the charge from the event record: walk-ins pay the walk-in price, existing tickets the ticket price. A typed amount is only honoured with amountOverride from admin/organizer and is written to audit_logs in the same transaction. The charged amount stays snapshotted on the payment row. POS is a new door tender for the physical card terminal. Staff pick POS, see the amount to key in, charge the card, then confirm with "Mark as paid"; it is then recorded like any other door payment (provider and method 'pos'). It can be switched off globally or per event via payment options, and shows up in the payments filters, bookings, revenue summaries and door takings. PosChargePanel is where an automatic terminal push would go later; nothing calls a terminal today. tickets.booking_source (online | walk_in | admin) records how a booking was made. The migration backfills walk-ins from the door screen's idempotency records; everything else stays online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
515707c9e4
commit
b51c1360e2
@@ -117,6 +117,11 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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
|
// Add short description columns to events
|
||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
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
|
// Invoices table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS invoices (
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
@@ -392,6 +412,14 @@ async function migrate() {
|
|||||||
} catch (e) { /* column may already exist */ }
|
} 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`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id TEXT PRIMARY KEY,
|
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`);
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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
|
// Add short description columns to events
|
||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
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
|
// Invoices table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS invoices (
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
@@ -1006,6 +1053,14 @@ async function migrate() {
|
|||||||
} catch (e) { /* column may already exist */ }
|
} 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`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ export const sqliteEvents = sqliteTable('events', {
|
|||||||
location: text('location').notNull(),
|
location: text('location').notNull(),
|
||||||
locationUrl: text('location_url'),
|
locationUrl: text('location_url'),
|
||||||
price: real('price').notNull().default(0),
|
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'),
|
currency: text('currency').notNull().default('PYG'),
|
||||||
capacity: integer('capacity').notNull().default(50),
|
capacity: integer('capacity').notNull().default(50),
|
||||||
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
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),
|
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
// 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'),
|
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(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sqlitePayments = sqliteTable('payments', {
|
export const sqlitePayments = sqliteTable('payments', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
|
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(),
|
amount: real('amount').notNull(),
|
||||||
currency: text('currency').notNull().default('PYG'),
|
currency: text('currency').notNull().default('PYG'),
|
||||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
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'),
|
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
|
||||||
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
|
// 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.
|
// '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(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_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),
|
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
cashInstructions: text('cash_instructions'),
|
cashInstructions: text('cash_instructions'),
|
||||||
cashInstructionsEs: text('cash_instructions_es'),
|
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
|
// Booking settings
|
||||||
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
||||||
// Metadata
|
// Metadata
|
||||||
@@ -226,6 +234,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
|
|||||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
||||||
cashInstructions: text('cash_instructions'),
|
cashInstructions: text('cash_instructions'),
|
||||||
cashInstructionsEs: text('cash_instructions_es'),
|
cashInstructionsEs: text('cash_instructions_es'),
|
||||||
|
posEnabled: integer('pos_enabled', { mode: 'boolean' }),
|
||||||
// Metadata
|
// Metadata
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
@@ -476,6 +485,9 @@ export const pgEvents = pgTable('events', {
|
|||||||
location: varchar('location', { length: 500 }).notNull(),
|
location: varchar('location', { length: 500 }).notNull(),
|
||||||
locationUrl: varchar('location_url', { length: 500 }),
|
locationUrl: varchar('location_url', { length: 500 }),
|
||||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
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'),
|
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||||
capacity: pgInteger('capacity').notNull().default(50),
|
capacity: pgInteger('capacity').notNull().default(50),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||||
@@ -515,6 +527,8 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||||
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
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(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -578,6 +592,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
|||||||
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
||||||
cashInstructions: pgText('cash_instructions'),
|
cashInstructions: pgText('cash_instructions'),
|
||||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||||
|
posEnabled: pgInteger('pos_enabled').notNull().default(1),
|
||||||
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
@@ -607,6 +622,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
|
|||||||
cashEnabled: pgInteger('cash_enabled'),
|
cashEnabled: pgInteger('cash_enabled'),
|
||||||
cashInstructions: pgText('cash_instructions'),
|
cashInstructions: pgText('cash_instructions'),
|
||||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||||
|
posEnabled: pgInteger('pos_enabled'),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -788,7 +788,7 @@ const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
tags: ['Tickets'],
|
tags: ['Tickets'],
|
||||||
summary: 'Check in, settle payment, or create a walk-in (atomic)',
|
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: [] }],
|
security: [{ bearerAuth: [] }],
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -817,8 +817,10 @@ const openApiSpec = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['method'],
|
required: ['method'],
|
||||||
properties: {
|
properties: {
|
||||||
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] },
|
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] },
|
||||||
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
|
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'] },
|
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
|
||||||
@@ -831,7 +833,8 @@ const openApiSpec = {
|
|||||||
responses: {
|
responses: {
|
||||||
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
|
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
|
||||||
200: { description: 'Replay of an already-processed idempotencyKey' },
|
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' },
|
404: { description: 'Event or ticket not found' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Door payment tenders.
|
// 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
|
// existing payments.provider so the rest of the app (capacity, sweeps, admin
|
||||||
// payment lists, receipts) keeps working unchanged, while payments.method
|
// payment lists, receipts) keeps working unchanged, while payments.method
|
||||||
// records which tender was actually used for the end-of-night cash-up.
|
// 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
|
// 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,
|
// Lightning flow lands it slots in here: the tender keeps its name and provider,
|
||||||
// only the settlement path in routes/door.ts changes.
|
// 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];
|
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||||
|
|
||||||
interface DoorTender {
|
interface DoorTender {
|
||||||
/** Existing payments.provider this tender is stored as. */
|
/** 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. */
|
/** Human label used in payment references and toasts. */
|
||||||
label: string;
|
label: string;
|
||||||
/** Comp tenders carry no revenue and always record a zero amount. */
|
/** Comp tenders carry no revenue and always record a zero amount. */
|
||||||
isComp: boolean;
|
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> = {
|
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
|
||||||
cash: { provider: 'cash', label: 'cash', isComp: false },
|
cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
|
||||||
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
|
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
|
||||||
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
|
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
|
||||||
guest: { provider: 'cash', label: 'guest', isComp: true },
|
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 {
|
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
|
||||||
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
|
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 eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
|
||||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', 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' },
|
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
// settlement) and the booking is auto-approved on success. No admin involved.
|
||||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
||||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
||||||
// card handled offline, cash at the door). These are never auto-confirmed
|
// card handled offline, cash or the POS terminal at the door). These are
|
||||||
// and never auto-failed; an admin settles them by hand. Bank transfer and
|
// 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
|
// TPago additionally expose an online "I've paid" step that moves the
|
||||||
// payment to 'pending_approval'.
|
// payment to 'pending_approval'.
|
||||||
//
|
//
|
||||||
@@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> =
|
|||||||
bank_transfer: { kind: 'manual' },
|
bank_transfer: { kind: 'manual' },
|
||||||
card: { kind: 'manual' },
|
card: { kind: 'manual' },
|
||||||
cash: { 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(
|
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),
|
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),
|
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),
|
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,
|
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
|
||||||
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').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 { auth } from '../lib/betterAuth.js';
|
||||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
|
||||||
const dashboard = new Hono();
|
const dashboard = new Hono();
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ dashboard.get('/tickets/:id', async (c) => {
|
|||||||
return c.json({
|
return c.json({
|
||||||
ticket: {
|
ticket: {
|
||||||
...ticket,
|
...ticket,
|
||||||
event,
|
event: omitWalkInPrice(event as any),
|
||||||
payment,
|
payment,
|
||||||
invoice,
|
invoice,
|
||||||
},
|
},
|
||||||
@@ -272,7 +273,7 @@ dashboard.get('/next-event', async (c) => {
|
|||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
nextEvent: {
|
nextEvent: {
|
||||||
event: nextEvent,
|
event: omitWalkInPrice(nextEvent),
|
||||||
ticket: nextTicket,
|
ticket: nextTicket,
|
||||||
payment: nextPayment,
|
payment: nextPayment,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ describe('door-checkin: existing ticket', () => {
|
|||||||
it('takes a group payment at a multiple of the ticket price', async () => {
|
it('takes a group payment at a multiple of the ticket price', async () => {
|
||||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
ticketId: 'tkt-unpaid-2',
|
ticketId: 'tkt-unpaid-2',
|
||||||
payment: { method: 'transfer', amount: PRICE * 2 },
|
payment: { method: 'transfer', quantity: 2 },
|
||||||
idempotencyKey: 'key-unpaid-2-transfer',
|
idempotencyKey: 'key-unpaid-2-transfer',
|
||||||
});
|
});
|
||||||
expect(body.payment.amount).toBe(PRICE * 2);
|
expect(body.payment.amount).toBe(PRICE * 2);
|
||||||
@@ -441,3 +441,227 @@ describe('door-summary', () => {
|
|||||||
expect(body.door.lines[0]).toHaveProperty('name');
|
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 { z } from 'zod';
|
||||||
import { eq, and, inArray, sql } from 'drizzle-orm';
|
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
|
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
|
||||||
|
paymentOptions, eventPaymentOverrides,
|
||||||
} from '../db/index.js';
|
} from '../db/index.js';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.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 { seatHolderCountQuery } from '../lib/capacity.js';
|
||||||
import {
|
import {
|
||||||
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
||||||
paymentStatusForMethod, type DoorPaymentMethod,
|
paymentStatusForMethod, enabledDoorMethods, type DoorPaymentMethod,
|
||||||
} from '../lib/doorPayments.js';
|
} from '../lib/doorPayments.js';
|
||||||
|
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
|
|
||||||
const doorRouter = new Hono();
|
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
|
// never see what the event took overall. Matches the existing convention for
|
||||||
// revenue aggregates (admin/export/financial, admin/analytics).
|
// revenue aggregates (admin/export/financial, admin/analytics).
|
||||||
const REVENUE_ROLES = ['admin', 'organizer'] as const;
|
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';
|
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
||||||
|
|
||||||
// ==================== Shared helpers ====================
|
// ==================== Shared helpers ====================
|
||||||
@@ -99,10 +106,24 @@ async function loadEvent(eventId: string | undefined) {
|
|||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
price: num(event.price),
|
price: num(event.price),
|
||||||
|
walkInPrice: parseWalkInPrice(event.walkInPrice),
|
||||||
capacity: Number(event.capacity),
|
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. */
|
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
|
||||||
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
||||||
const unique = [...new Set(adminIds.filter(Boolean))];
|
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);
|
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
|
const attendees = rows
|
||||||
.map((t: any) => toDoorAttendee(t, {
|
.map((t: any) => toDoorAttendee(t, {
|
||||||
price: event.price,
|
price: event.price,
|
||||||
@@ -181,9 +205,15 @@ doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async
|
|||||||
id: event.id,
|
id: event.id,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
price: event.price,
|
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,
|
currency: event.currency,
|
||||||
capacity: event.capacity,
|
capacity: event.capacity,
|
||||||
},
|
},
|
||||||
|
doorMethods: enabledMethods,
|
||||||
attendees,
|
attendees,
|
||||||
stats: { checkedIn, totalActive, capacity: event.capacity },
|
stats: { checkedIn, totalActive, capacity: event.capacity },
|
||||||
});
|
});
|
||||||
@@ -204,9 +234,16 @@ const doorCheckinSchema = z.object({
|
|||||||
}).optional(),
|
}).optional(),
|
||||||
payment: z.object({
|
payment: z.object({
|
||||||
method: z.enum(DOOR_PAYMENT_METHODS),
|
method: z.enum(DOOR_PAYMENT_METHODS),
|
||||||
// Omitted means "one ticket at event price"; a multiple covers someone
|
// How many tickets' worth is being paid (someone paying for their group).
|
||||||
// paying for their whole group in one go.
|
// 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(),
|
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(),
|
}).optional(),
|
||||||
// How the attendee reached this action, for the session feed.
|
// How the attendee reached this action, for the session feed.
|
||||||
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
|
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 });
|
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);
|
const event = await loadEvent(eventId);
|
||||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
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 now = getNow();
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
const quantity = data.payment?.quantity ?? 1;
|
||||||
const requestedAmount = data.payment?.amount ?? event.price;
|
// 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[] = [];
|
const ops: TxOp[] = [];
|
||||||
let undoState: UndoState;
|
let undoState: UndoState;
|
||||||
let action: 'checkin' | 'walkin';
|
let action: 'checkin' | 'walkin';
|
||||||
let ticketRow: any;
|
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;
|
let emailTicketId: string | null = null;
|
||||||
|
|
||||||
if (data.ticketId) {
|
if (data.ticketId) {
|
||||||
@@ -326,7 +379,7 @@ doorRouter.post(
|
|||||||
method,
|
method,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}, eq((payments as any).id, existingPayment.id)));
|
}, 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 {
|
} else {
|
||||||
const paymentId = generateId();
|
const paymentId = generateId();
|
||||||
undo.createdPaymentId = paymentId;
|
undo.createdPaymentId = paymentId;
|
||||||
@@ -345,7 +398,7 @@ doorRouter.post(
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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,
|
checkinAt: now,
|
||||||
checkedInByAdminId: adminUser?.id || null,
|
checkedInByAdminId: adminUser?.id || null,
|
||||||
adminNote: null,
|
adminNote: null,
|
||||||
|
bookingSource: 'walk_in',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
ops.push(insertOp(tickets, newTicket));
|
ops.push(insertOp(tickets, newTicket));
|
||||||
@@ -448,13 +502,35 @@ doorRouter.post(
|
|||||||
updatedAt: now,
|
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 };
|
undoState = { kind: 'created', ticketId, paymentId };
|
||||||
ticketRow = newTicket;
|
ticketRow = newTicket;
|
||||||
// Only mail people who actually gave an address; no QR for the rest.
|
// Only mail people who actually gave an address; no QR for the rest.
|
||||||
if (hasEmail) emailTicketId = ticketId;
|
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.
|
// Staff at the door is the authority: a full event is a warning, never a block.
|
||||||
const held = await seatsHeld(eventId);
|
const held = await seatsHeld(eventId);
|
||||||
const atCapacity = event.capacity > 0 && held >= event.capacity;
|
const atCapacity = event.capacity > 0 && held >= event.capacity;
|
||||||
@@ -496,6 +572,13 @@ doorRouter.post(
|
|||||||
throw err;
|
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) {
|
if (emailTicketId) {
|
||||||
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
|
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
|
||||||
console.error('[Email] Failed to send door walk-in confirmation:', 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 { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
import { resolvePresaleClosure } from '../lib/presale.js';
|
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||||
|
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
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
|
// `settings` is the site_settings row; when given, the effective pre-sale
|
||||||
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
||||||
// booking API agree on when registration closes.
|
// 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;
|
if (!event) return event;
|
||||||
|
const { walkInPrice, ...publicFields } = event;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...event,
|
...publicFields,
|
||||||
|
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
|
||||||
// Convert price from string/decimal to clean number
|
// Convert price from string/decimal to clean number
|
||||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||||
// Convert capacity from string to number if needed
|
// Convert capacity from string to number if needed
|
||||||
@@ -114,6 +119,29 @@ const parsePrice = (val: unknown): number => {
|
|||||||
return 0;
|
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)
|
// Helper to normalize boolean (handles true/false and 0/1)
|
||||||
const normalizeBoolean = (val: unknown): boolean => {
|
const normalizeBoolean = (val: unknown): boolean => {
|
||||||
if (typeof val === 'boolean') return val;
|
if (typeof val === 'boolean') return val;
|
||||||
@@ -137,6 +165,7 @@ const baseEventSchema = z.object({
|
|||||||
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
// Accept price as number or string (handles "45000" and "41,44" formats)
|
// 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),
|
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||||
|
walkInPrice: walkInPriceSchema,
|
||||||
currency: z.string().default('PYG'),
|
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),
|
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'),
|
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.
|
// any client-supplied status filter, so drafts cannot leak.
|
||||||
const authUser: any = await getAuthUser(c);
|
const authUser: any = await getAuthUser(c);
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||||
|
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
|
||||||
|
|
||||||
const conditions: any[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
@@ -271,7 +301,7 @@ eventsRouter.get('/', async (c) => {
|
|||||||
|
|
||||||
const siteSettingsRow = await getSiteSettingsRow();
|
const siteSettingsRow = await getSiteSettingsRow();
|
||||||
const eventsWithCounts = result.map((event: any) => {
|
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 };
|
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
@@ -295,16 +325,19 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
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.
|
// Draft events are only visible to privileged users (admin preview); hide from public.
|
||||||
if ((event as any).status === 'draft') {
|
if ((event as any).status === 'draft') {
|
||||||
const authUser: any = await getAuthUser(c);
|
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||||
if (!isPrivileged) {
|
if (!isPrivileged) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
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);
|
const counts = await getEventSeatCounts(event.id);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
@@ -460,6 +493,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
const siteSettingsRow = await getSiteSettingsRow();
|
const siteSettingsRow = await getSiteSettingsRow();
|
||||||
const tz = siteTimezoneOf(siteSettingsRow);
|
const tz = siteTimezoneOf(siteSettingsRow);
|
||||||
|
|
||||||
|
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
|
||||||
|
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||||
|
|
||||||
// Convert data for database compatibility
|
// Convert data for database compatibility
|
||||||
const dbData = convertBooleansForDb(data);
|
const dbData = convertBooleansForDb(data);
|
||||||
|
|
||||||
@@ -483,7 +519,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
revalidateFrontendCache();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
// Return normalized event data
|
// 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)
|
// 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);
|
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 now = getNow();
|
||||||
const siteSettingsRow = await getSiteSettingsRow();
|
const siteSettingsRow = await getSiteSettingsRow();
|
||||||
const tz = siteTimezoneOf(siteSettingsRow);
|
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)
|
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||||
revalidateFrontendCache();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow) });
|
return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete event (admin only)
|
// Delete event (admin only)
|
||||||
@@ -670,6 +714,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
|||||||
location: existing.location,
|
location: existing.location,
|
||||||
locationUrl: existing.locationUrl,
|
locationUrl: existing.locationUrl,
|
||||||
price: existing.price,
|
price: existing.price,
|
||||||
|
walkInPrice: existing.walkInPrice ?? null,
|
||||||
currency: existing.currency,
|
currency: existing.currency,
|
||||||
capacity: existing.capacity,
|
capacity: existing.capacity,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
@@ -684,7 +729,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
|||||||
|
|
||||||
await (db as any).insert(events).values(duplicatedEvent);
|
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)
|
// 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(),
|
cashEnabled: booleanOrNumber.optional(),
|
||||||
cashInstructions: z.string().optional().nullable(),
|
cashInstructions: z.string().optional().nullable(),
|
||||||
cashInstructionsEs: z.string().optional().nullable(),
|
cashInstructionsEs: z.string().optional().nullable(),
|
||||||
|
// POS terminal on the door screen
|
||||||
|
posEnabled: booleanOrNumber.optional(),
|
||||||
// Booking settings
|
// Booking settings
|
||||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||||
});
|
});
|
||||||
@@ -79,6 +81,7 @@ const updateEventOverridesSchema = z.object({
|
|||||||
cashEnabled: booleanOrNumber.optional().nullable(),
|
cashEnabled: booleanOrNumber.optional().nullable(),
|
||||||
cashInstructions: z.string().optional().nullable(),
|
cashInstructions: z.string().optional().nullable(),
|
||||||
cashInstructionsEs: z.string().optional().nullable(),
|
cashInstructionsEs: z.string().optional().nullable(),
|
||||||
|
posEnabled: booleanOrNumber.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get global payment options
|
// Get global payment options
|
||||||
@@ -111,6 +114,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
cashEnabled: true,
|
cashEnabled: true,
|
||||||
cashInstructions: null,
|
cashInstructions: null,
|
||||||
cashInstructionsEs: null,
|
cashInstructionsEs: null,
|
||||||
|
posEnabled: true,
|
||||||
allowDuplicateBookings: false,
|
allowDuplicateBookings: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -219,6 +223,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
cashEnabled: true,
|
cashEnabled: true,
|
||||||
cashInstructions: null,
|
cashInstructions: null,
|
||||||
cashInstructionsEs: null,
|
cashInstructionsEs: null,
|
||||||
|
posEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const global = globalOptions || defaults;
|
const global = globalOptions || defaults;
|
||||||
@@ -245,6 +250,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
||||||
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
||||||
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
||||||
|
posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full bank/TPago credentials are only returned when the caller proves they hold
|
// 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,
|
qrCode,
|
||||||
checkinAt: data.autoCheckin ? now : null,
|
checkinAt: data.autoCheckin ? now : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
|
bookingSource: 'admin',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1663,6 +1664,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
checkinAt: data.checkinNow ? now : null,
|
checkinAt: data.checkinNow ? now : null,
|
||||||
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
|
bookingSource: 'admin',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
|||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { authSessions } from '../db/auth-schema.js';
|
import { authSessions } from '../db/auth-schema.js';
|
||||||
import { getNow, toDbDate } from '../lib/utils.js';
|
import { getNow, toDbDate } from '../lib/utils.js';
|
||||||
|
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -249,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...ticket,
|
...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)' },
|
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
|
||||||
cash: { en: 'Cash', es: 'Efectivo' },
|
cash: { en: 'Cash', es: 'Efectivo' },
|
||||||
bancard: { en: 'Card', es: 'Tarjeta' },
|
bancard: { en: 'Card', es: 'Tarjeta' },
|
||||||
|
pos: { en: 'POS', es: 'POS' },
|
||||||
};
|
};
|
||||||
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export default function AdminBookingsPage() {
|
|||||||
lightning: 'Lightning',
|
lightning: 'Lightning',
|
||||||
tpago: 'TPago',
|
tpago: 'TPago',
|
||||||
bancard: 'Bancard',
|
bancard: 'Bancard',
|
||||||
|
pos: 'POS',
|
||||||
};
|
};
|
||||||
return labels[provider] || provider;
|
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')}`}>
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||||
{ticket.payment?.status || 'pending'}
|
{ticket.payment?.status || 'pending'}
|
||||||
</span>
|
</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 && (
|
{ticket.payment && (
|
||||||
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
<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' },
|
cash: { en: 'Cash', es: 'Efectivo' },
|
||||||
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
|
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
|
||||||
transfer: { en: 'Transfer', es: 'Transferencia' },
|
transfer: { en: 'Transfer', es: 'Transferencia' },
|
||||||
|
pos: { en: 'POS', es: 'POS' },
|
||||||
guest: { en: 'Guests', es: 'Invitados' },
|
guest: { en: 'Guests', es: 'Invitados' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
|||||||
handleSavePaymentOptions,
|
handleSavePaymentOptions,
|
||||||
} = payments;
|
} = 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{loadingPayments ? (
|
{loadingPayments ? (
|
||||||
@@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-4 md:p-5">
|
<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: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
||||||
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
||||||
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
||||||
|
{ label: 'POS', enabled: posEnabled },
|
||||||
].map((method) => (
|
].map((method) => (
|
||||||
<div key={method.label} className="flex items-center gap-1.5">
|
<div key={method.label} className="flex items-center gap-1.5">
|
||||||
{method.enabled ? (
|
{method.enabled ? (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
|
|||||||
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
|
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
|
||||||
|
|
||||||
interface EventFormData {
|
interface EventFormData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -25,6 +25,9 @@ interface EventFormData {
|
|||||||
location: string;
|
location: string;
|
||||||
locationUrl: string;
|
locationUrl: string;
|
||||||
price: number;
|
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;
|
currency: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||||
@@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = {
|
|||||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||||
shortDescription: '', shortDescriptionEs: '',
|
shortDescription: '', shortDescriptionEs: '',
|
||||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
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: '',
|
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||||
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
|
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||||
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
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 {
|
function isoToLocalDatetime(isoString: string): string {
|
||||||
const date = parseDate(isoString);
|
const date = parseDate(isoString);
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
@@ -138,6 +149,7 @@ export default function EventFormModal({
|
|||||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||||
location: event.location, locationUrl: event.locationUrl || '',
|
location: event.location, locationUrl: event.locationUrl || '',
|
||||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||||
|
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
|
||||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||||
externalBookingUrl: event.externalBookingUrl || '',
|
externalBookingUrl: event.externalBookingUrl || '',
|
||||||
@@ -208,6 +220,11 @@ export default function EventFormModal({
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isValidWalkInPrice(formData.walkInPrice)) {
|
||||||
|
toast.error(t('admin.events.form.walkInPriceInvalid'));
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const eventData: Partial<Event> = {
|
const eventData: Partial<Event> = {
|
||||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||||
@@ -216,6 +233,8 @@ export default function EventFormModal({
|
|||||||
endDatetime: formData.endDatetime || undefined,
|
endDatetime: formData.endDatetime || undefined,
|
||||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
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,
|
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||||
externalBookingEnabled: formData.externalBookingEnabled,
|
externalBookingEnabled: formData.externalBookingEnabled,
|
||||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
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}
|
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div>
|
||||||
<Input label="Price" type="number" min="0" value={formData.price}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
<Input label="Price" type="number" min="0" value={formData.price}
|
||||||
<div>
|
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
|
||||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
value={formData.walkInPrice}
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
|
||||||
<option value="PYG">PYG</option>
|
placeholder={t('admin.events.form.walkInPricePlaceholder', {
|
||||||
<option value="USD">USD</option>
|
price: formatPrice(formData.price || 0, formData.currency),
|
||||||
</select>
|
})}
|
||||||
|
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>
|
</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}
|
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
|
|||||||
bankNotesEs: null,
|
bankNotesEs: null,
|
||||||
lightningEnabled: true,
|
lightningEnabled: true,
|
||||||
cashEnabled: true,
|
cashEnabled: true,
|
||||||
|
posEnabled: true,
|
||||||
cashInstructions: null,
|
cashInstructions: null,
|
||||||
cashInstructionsEs: null,
|
cashInstructionsEs: null,
|
||||||
allowDuplicateBookings: false,
|
allowDuplicateBookings: false,
|
||||||
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
|
|||||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stored as 0/1 on Postgres; on unless explicitly switched off.
|
||||||
|
const posEnabled = !!(options.posEnabled ?? true);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Booking Settings */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
|
|||||||
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export default function AdminPaymentsPage() {
|
|||||||
bank_transfer: BuildingLibraryIcon,
|
bank_transfer: BuildingLibraryIcon,
|
||||||
tpago: CreditCardIcon,
|
tpago: CreditCardIcon,
|
||||||
bancard: CreditCardIcon,
|
bancard: CreditCardIcon,
|
||||||
|
pos: CreditCardIcon,
|
||||||
};
|
};
|
||||||
const Icon = icons[provider] || CreditCardIcon;
|
const Icon = icons[provider] || CreditCardIcon;
|
||||||
return <Icon className="w-4 h-4" />;
|
return <Icon className="w-4 h-4" />;
|
||||||
@@ -325,6 +326,7 @@ export default function AdminPaymentsPage() {
|
|||||||
lightning: 'Lightning',
|
lightning: 'Lightning',
|
||||||
tpago: 'TPago',
|
tpago: 'TPago',
|
||||||
bancard: 'Bancard',
|
bancard: 'Bancard',
|
||||||
|
pos: 'POS',
|
||||||
};
|
};
|
||||||
return labels[provider] || provider;
|
return labels[provider] || provider;
|
||||||
};
|
};
|
||||||
@@ -736,7 +738,7 @@ export default function AdminPaymentsPage() {
|
|||||||
{/* By Provider */}
|
{/* By Provider */}
|
||||||
<div className="bg-secondary-gray rounded-btn p-4">
|
<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>
|
<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>
|
<div>
|
||||||
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
||||||
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</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-gray-500">Bancard</p>
|
||||||
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||||
<option value="tpago">TPago</option>
|
<option value="tpago">TPago</option>
|
||||||
|
<option value="pos">POS</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-[200px]">
|
<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="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||||
<option value="tpago">TPago</option>
|
<option value="tpago">TPago</option>
|
||||||
|
<option value="pos">POS</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
|
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
|
||||||
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import { PaymentButtons } from './PaymentButtons';
|
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
|
||||||
|
|
||||||
function checkinTime(checkinAt: string | null): string {
|
function checkinTime(checkinAt: string | null): string {
|
||||||
if (!checkinAt) return '';
|
if (!checkinAt) return '';
|
||||||
@@ -23,6 +23,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
|||||||
cash: 'cash',
|
cash: 'cash',
|
||||||
bitcoin: 'bitcoin',
|
bitcoin: 'bitcoin',
|
||||||
transfer: 'transfer',
|
transfer: 'transfer',
|
||||||
|
pos: 'POS',
|
||||||
guest: 'guest',
|
guest: 'guest',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ export function AttendeeRow({
|
|||||||
attendee,
|
attendee,
|
||||||
currency,
|
currency,
|
||||||
price,
|
price,
|
||||||
|
methods,
|
||||||
|
canOverrideAmount,
|
||||||
expanded,
|
expanded,
|
||||||
flashing,
|
flashing,
|
||||||
busy,
|
busy,
|
||||||
@@ -58,11 +61,13 @@ export function AttendeeRow({
|
|||||||
attendee: DoorAttendee;
|
attendee: DoorAttendee;
|
||||||
currency: string;
|
currency: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
methods: readonly DoorPaymentMethod[];
|
||||||
|
canOverrideAmount: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
flashing: boolean;
|
flashing: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onTap: () => void;
|
onTap: () => void;
|
||||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||||
}) {
|
}) {
|
||||||
const isCancelled = attendee.status === 'cancelled';
|
const isCancelled = attendee.status === 'cancelled';
|
||||||
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
|
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.
|
Reactivate as a walk-in — pick how they are paying.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
|
<PaymentButtons
|
||||||
|
price={price}
|
||||||
|
currency={currency}
|
||||||
|
methods={methods}
|
||||||
|
canOverrideAmount={canOverrideAmount}
|
||||||
|
onPay={onPay}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,29 @@ import {
|
|||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
BuildingLibraryIcon,
|
BuildingLibraryIcon,
|
||||||
|
CreditCardIcon,
|
||||||
GiftIcon,
|
GiftIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { DoorPaymentMethod } from '@/lib/api';
|
import type { DoorPaymentMethod } from '@/lib/api';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
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.
|
// 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: {
|
const TENDERS: {
|
||||||
method: DoorPaymentMethod;
|
method: DoorPaymentMethod;
|
||||||
@@ -24,6 +39,7 @@ const TENDERS: {
|
|||||||
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
|
{ 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: '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: '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' },
|
{ 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({
|
export function PaymentButtons({
|
||||||
price,
|
price,
|
||||||
currency,
|
currency,
|
||||||
|
methods,
|
||||||
|
canOverrideAmount,
|
||||||
onPay,
|
onPay,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
|
/** Unit price for one ticket, as the server will charge it. */
|
||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
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;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
|
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
|
||||||
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
|
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
|
||||||
const [customOpen, setCustomOpen] = useState(false);
|
const [customOpen, setCustomOpen] = useState(false);
|
||||||
const [customValue, setCustomValue] = useState('');
|
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 [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [longPressed, setLongPressed] = useState(false);
|
const [longPressed, setLongPressed] = useState(false);
|
||||||
|
|
||||||
|
const tenders = TENDERS.filter((t) => methods.includes(t.method));
|
||||||
|
|
||||||
const openAmounts = (method: DoorPaymentMethod) => {
|
const openAmounts = (method: DoorPaymentMethod) => {
|
||||||
if (method === 'guest') return;
|
if (method === 'guest') return;
|
||||||
setAmountsFor(method);
|
setAmountsFor(method);
|
||||||
@@ -54,6 +81,15 @@ export function PaymentButtons({
|
|||||||
setCustomValue('');
|
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) => {
|
const startPress = (method: DoorPaymentMethod) => {
|
||||||
setLongPressed(false);
|
setLongPressed(false);
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -72,7 +108,7 @@ export function PaymentButtons({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
onPay(method, method === 'guest' ? 0 : price);
|
charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelPress = () => {
|
const cancelPress = () => {
|
||||||
@@ -81,6 +117,19 @@ export function PaymentButtons({
|
|||||||
setLongPressed(false);
|
setLongPressed(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (posCharge) {
|
||||||
|
return (
|
||||||
|
<PosChargePanel
|
||||||
|
charge={posCharge}
|
||||||
|
unitPrice={price}
|
||||||
|
currency={currency}
|
||||||
|
disabled={disabled}
|
||||||
|
onConfirm={() => onPay('pos', posCharge)}
|
||||||
|
onBack={() => setPosCharge(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (amountsFor) {
|
if (amountsFor) {
|
||||||
const tender = TENDERS.find((t) => t.method === amountsFor)!;
|
const tender = TENDERS.find((t) => t.method === amountsFor)!;
|
||||||
return (
|
return (
|
||||||
@@ -94,12 +143,12 @@ export function PaymentButtons({
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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) => (
|
{[1, 2, 3].map((qty) => (
|
||||||
<button
|
<button
|
||||||
key={qty}
|
key={qty}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onPay(tender.method, price * qty)}
|
onClick={() => charge(tender.method, { quantity: qty, amount: price * qty })}
|
||||||
className={clsx(
|
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',
|
'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,
|
tender.className,
|
||||||
@@ -111,15 +160,17 @@ export function PaymentButtons({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
{canOverrideAmount && (
|
||||||
disabled={disabled}
|
<button
|
||||||
onClick={() => setCustomOpen((open) => !open)}
|
disabled={disabled}
|
||||||
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"
|
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>
|
Custom
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{customOpen && (
|
{canOverrideAmount && customOpen && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -132,7 +183,7 @@ export function PaymentButtons({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={disabled || !customValue || Number(customValue) < 0}
|
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(
|
className={clsx(
|
||||||
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
|
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||||
tender.className,
|
tender.className,
|
||||||
@@ -147,8 +198,11 @@ export function PaymentButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div
|
||||||
{TENDERS.map((tender) => (
|
className="grid gap-2"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${tenders.length}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{tenders.map((tender) => (
|
||||||
<button
|
<button
|
||||||
key={tender.method}
|
key={tender.method}
|
||||||
disabled={disabled}
|
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',
|
cash: 'Cash',
|
||||||
bitcoin: 'Bitcoin',
|
bitcoin: 'Bitcoin',
|
||||||
transfer: 'Transfer',
|
transfer: 'Transfer',
|
||||||
|
pos: 'POS',
|
||||||
guest: 'Guest',
|
guest: 'Guest',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) {
|
|||||||
cash: { count: 0, total: 0 },
|
cash: { count: 0, total: 0 },
|
||||||
bitcoin: { count: 0, total: 0 },
|
bitcoin: { count: 0, total: 0 },
|
||||||
transfer: { count: 0, total: 0 },
|
transfer: { count: 0, total: 0 },
|
||||||
|
pos: { count: 0, total: 0 },
|
||||||
guest: { count: 0, total: 0 },
|
guest: { count: 0, total: 0 },
|
||||||
};
|
};
|
||||||
let grand = 0;
|
let grand = 0;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { DoorPaymentMethod } from '@/lib/api';
|
import type { DoorPaymentMethod } from '@/lib/api';
|
||||||
import { PaymentButtons } from './PaymentButtons';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
|
||||||
|
|
||||||
export interface WalkInDraft {
|
export interface WalkInDraft {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
|
|||||||
ruc: '',
|
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
|
* 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.
|
* rare person who wants a receipt never slows down the queue behind them.
|
||||||
*/
|
*/
|
||||||
export function WalkInRow({
|
export function WalkInRow({
|
||||||
@@ -32,7 +38,10 @@ export function WalkInRow({
|
|||||||
expanded,
|
expanded,
|
||||||
draft,
|
draft,
|
||||||
price,
|
price,
|
||||||
|
priceSource,
|
||||||
currency,
|
currency,
|
||||||
|
methods,
|
||||||
|
canOverrideAmount,
|
||||||
busy,
|
busy,
|
||||||
onExpand,
|
onExpand,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -42,12 +51,16 @@ export function WalkInRow({
|
|||||||
typedText: string;
|
typedText: string;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
draft: WalkInDraft;
|
draft: WalkInDraft;
|
||||||
|
/** Walk-in unit price as resolved by the server. */
|
||||||
price: number;
|
price: number;
|
||||||
|
priceSource: 'walk_in' | 'ticket';
|
||||||
currency: string;
|
currency: string;
|
||||||
|
methods: readonly DoorPaymentMethod[];
|
||||||
|
canOverrideAmount: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
onChange: (draft: WalkInDraft) => void;
|
onChange: (draft: WalkInDraft) => void;
|
||||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
@@ -146,9 +159,16 @@ export function WalkInRow({
|
|||||||
</div>
|
</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
|
<PaymentButtons
|
||||||
price={price}
|
price={price}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
|
methods={methods}
|
||||||
|
canOverrideAmount={canOverrideAmount}
|
||||||
onPay={onPay}
|
onPay={onPay}
|
||||||
disabled={busy || !draft.firstName.trim()}
|
disabled={busy || !draft.firstName.trim()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext';
|
|||||||
import {
|
import {
|
||||||
eventsApi,
|
eventsApi,
|
||||||
doorApi,
|
doorApi,
|
||||||
|
DOOR_PAYMENT_METHODS,
|
||||||
type Event,
|
type Event,
|
||||||
type DoorAttendee,
|
type DoorAttendee,
|
||||||
type DoorAttendeesResponse,
|
type DoorAttendeesResponse,
|
||||||
@@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/
|
|||||||
import { QRScannerOverlay } from './_components/QRScannerOverlay';
|
import { QRScannerOverlay } from './_components/QRScannerOverlay';
|
||||||
import { AttendeeRow } from './_components/AttendeeRow';
|
import { AttendeeRow } from './_components/AttendeeRow';
|
||||||
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
||||||
|
import type { DoorCharge } from './_components/PaymentButtons';
|
||||||
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
||||||
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
|
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record<DoorPaymentMethod, string> = {
|
|||||||
cash: 'paid cash',
|
cash: 'paid cash',
|
||||||
bitcoin: 'paid bitcoin',
|
bitcoin: 'paid bitcoin',
|
||||||
transfer: 'paid by transfer',
|
transfer: 'paid by transfer',
|
||||||
|
pos: 'paid by POS',
|
||||||
guest: 'in as guest',
|
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
|
// 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).
|
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
|
||||||
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
|
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 ──
|
// ── Events ──
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
@@ -122,6 +128,10 @@ export default function AdminDoorPage() {
|
|||||||
|
|
||||||
const eventMeta = data?.event;
|
const eventMeta = data?.event;
|
||||||
const price = eventMeta?.price ?? 0;
|
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 currency = eventMeta?.currency ?? 'PYG';
|
||||||
const capacity = eventMeta?.capacity ?? 0;
|
const capacity = eventMeta?.capacity ?? 0;
|
||||||
|
|
||||||
@@ -351,7 +361,7 @@ export default function AdminDoorPage() {
|
|||||||
async (opts: {
|
async (opts: {
|
||||||
ticketId?: string;
|
ticketId?: string;
|
||||||
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
|
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
|
||||||
payment?: { method: DoorPaymentMethod; amount: number };
|
payment?: { method: DoorPaymentMethod; charge: DoorCharge };
|
||||||
entry: DoorEntryMethod;
|
entry: DoorEntryMethod;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** Ticket row to flash and optimistically mark as in. */
|
/** Ticket row to flash and optimistically mark as in. */
|
||||||
@@ -373,7 +383,7 @@ export default function AdminDoorPage() {
|
|||||||
at: clockTime(now),
|
at: clockTime(now),
|
||||||
entry: opts.entry,
|
entry: opts.entry,
|
||||||
method: opts.payment?.method ?? null,
|
method: opts.payment?.method ?? null,
|
||||||
amount: opts.payment?.amount ?? 0,
|
amount: opts.payment?.charge.amount ?? 0,
|
||||||
startedAt: now.getTime(),
|
startedAt: now.getTime(),
|
||||||
undone: false,
|
undone: false,
|
||||||
failed: false,
|
failed: false,
|
||||||
@@ -424,10 +434,19 @@ export default function AdminDoorPage() {
|
|||||||
|
|
||||||
// ── The write itself: background, retried, never blocking the queue ──
|
// ── The write itself: background, retried, never blocking the queue ──
|
||||||
try {
|
try {
|
||||||
|
const { method, charge } = opts.payment ?? {};
|
||||||
const res = await submitDoorAction(eventId, {
|
const res = await submitDoorAction(eventId, {
|
||||||
ticketId: opts.ticketId,
|
ticketId: opts.ticketId,
|
||||||
attendee: opts.attendee,
|
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,
|
entryMethod: opts.entry,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
});
|
});
|
||||||
@@ -435,7 +454,13 @@ export default function AdminDoorPage() {
|
|||||||
setSessionEntries((prev) =>
|
setSessionEntries((prev) =>
|
||||||
prev.map((e) =>
|
prev.map((e) =>
|
||||||
e.idempotencyKey === idempotencyKey
|
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,
|
: e,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -508,10 +533,10 @@ export default function AdminDoorPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRowPay = useCallback(
|
const handleRowPay = useCallback(
|
||||||
(attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => {
|
(attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => {
|
||||||
runAction({
|
runAction({
|
||||||
ticketId: attendee.ticketId,
|
ticketId: attendee.ticketId,
|
||||||
payment: { method, amount },
|
payment: { method, charge },
|
||||||
entry: 'search',
|
entry: 'search',
|
||||||
displayName: attendee.fullName,
|
displayName: attendee.fullName,
|
||||||
optimisticTicketId: attendee.ticketId,
|
optimisticTicketId: attendee.ticketId,
|
||||||
@@ -522,7 +547,7 @@ export default function AdminDoorPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleWalkInPay = useCallback(
|
const handleWalkInPay = useCallback(
|
||||||
(method: DoorPaymentMethod, amount: number) => {
|
(method: DoorPaymentMethod, charge: DoorCharge) => {
|
||||||
const firstName = walkInDraft.firstName.trim();
|
const firstName = walkInDraft.firstName.trim();
|
||||||
if (!firstName) return;
|
if (!firstName) return;
|
||||||
const displayName = walkInDraft.lastName.trim()
|
const displayName = walkInDraft.lastName.trim()
|
||||||
@@ -536,7 +561,7 @@ export default function AdminDoorPage() {
|
|||||||
email: walkInDraft.email.trim() || undefined,
|
email: walkInDraft.email.trim() || undefined,
|
||||||
ruc: walkInDraft.ruc.trim() || undefined,
|
ruc: walkInDraft.ruc.trim() || undefined,
|
||||||
},
|
},
|
||||||
payment: { method, amount },
|
payment: { method, charge },
|
||||||
entry: 'walkin',
|
entry: 'walkin',
|
||||||
displayName,
|
displayName,
|
||||||
busyKey: 'walk-in',
|
busyKey: 'walk-in',
|
||||||
@@ -764,11 +789,13 @@ export default function AdminDoorPage() {
|
|||||||
attendee={attendee}
|
attendee={attendee}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
price={price}
|
price={price}
|
||||||
|
methods={doorMethods}
|
||||||
|
canOverrideAmount={canOverrideAmount}
|
||||||
expanded={expandedId === attendee.ticketId}
|
expanded={expandedId === attendee.ticketId}
|
||||||
flashing={flashId === attendee.ticketId}
|
flashing={flashId === attendee.ticketId}
|
||||||
busy={busyId === attendee.ticketId}
|
busy={busyId === attendee.ticketId}
|
||||||
onTap={() => handleRowTap(attendee)}
|
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}
|
typedText={trimmedQuery}
|
||||||
expanded={walkInOpen}
|
expanded={walkInOpen}
|
||||||
draft={walkInDraft}
|
draft={walkInDraft}
|
||||||
price={price}
|
price={walkInPrice}
|
||||||
|
priceSource={walkInPriceSource}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
|
methods={doorMethods}
|
||||||
|
canOverrideAmount={canOverrideAmount}
|
||||||
busy={busyId === 'walk-in'}
|
busy={busyId === 'walk-in'}
|
||||||
onExpand={() => {
|
onExpand={() => {
|
||||||
setWalkInDraft(emptyWalkIn(trimmedQuery));
|
setWalkInDraft(emptyWalkIn(trimmedQuery));
|
||||||
|
|||||||
@@ -294,7 +294,13 @@
|
|||||||
"edit": "Edit Event",
|
"edit": "Edit Event",
|
||||||
"delete": "Delete Event",
|
"delete": "Delete Event",
|
||||||
"publish": "Publish",
|
"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": {
|
"tickets": {
|
||||||
"title": "Manage Tickets",
|
"title": "Manage Tickets",
|
||||||
|
|||||||
@@ -294,7 +294,13 @@
|
|||||||
"edit": "Editar Evento",
|
"edit": "Editar Evento",
|
||||||
"delete": "Eliminar Evento",
|
"delete": "Eliminar Evento",
|
||||||
"publish": "Publicar",
|
"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": {
|
"tickets": {
|
||||||
"title": "Gestionar Tickets",
|
"title": "Gestionar Tickets",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchApi } from './client';
|
|||||||
// fire actions optimistically and retry on flaky venue wifi without ever
|
// fire actions optimistically and retry on flaky venue wifi without ever
|
||||||
// creating a duplicate ticket, payment or check-in.
|
// 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 DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||||
|
|
||||||
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
|
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
|
||||||
@@ -32,7 +32,20 @@ export interface DoorAttendee {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DoorAttendeesResponse {
|
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[];
|
attendees: DoorAttendee[];
|
||||||
stats: { checkedIn: number; totalActive: number; capacity: number };
|
stats: { checkedIn: number; totalActive: number; capacity: number };
|
||||||
}
|
}
|
||||||
@@ -46,7 +59,11 @@ export interface DoorCheckinRequest {
|
|||||||
email?: string;
|
email?: string;
|
||||||
ruc?: 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;
|
entryMethod?: DoorEntryMethod;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
}
|
}
|
||||||
@@ -55,7 +72,13 @@ export interface DoorCheckinResponse {
|
|||||||
ok: true;
|
ok: true;
|
||||||
action: 'checkin' | 'walkin';
|
action: 'checkin' | 'walkin';
|
||||||
attendee: DoorAttendee;
|
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. */
|
/** 'at_capacity' — the event is full; the attendee was added anyway. */
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
idempotencyKey: 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
|
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
|
||||||
// verify the money arrived; automatic ones (lightning) confirm themselves.
|
// 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 {
|
export function isManualProvider(provider: string): boolean {
|
||||||
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
|
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export interface Event {
|
|||||||
location: string;
|
location: string;
|
||||||
locationUrl?: string;
|
locationUrl?: string;
|
||||||
price: number;
|
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;
|
currency: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||||
@@ -57,6 +60,8 @@ export interface Ticket {
|
|||||||
adminNote?: string;
|
adminNote?: string;
|
||||||
isGuest?: boolean;
|
isGuest?: boolean;
|
||||||
paymentStatus?: 'paid' | 'unpaid' | 'comp';
|
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;
|
createdAt: string;
|
||||||
event?: Event;
|
event?: Event;
|
||||||
payment?: Payment;
|
payment?: Payment;
|
||||||
@@ -124,7 +129,7 @@ export interface LiveSearchResult {
|
|||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos';
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
|
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
|
||||||
@@ -178,6 +183,8 @@ export interface PaymentOptionsConfig {
|
|||||||
cashEnabled: boolean;
|
cashEnabled: boolean;
|
||||||
cashInstructions?: string | null;
|
cashInstructions?: string | null;
|
||||||
cashInstructionsEs?: string | null;
|
cashInstructionsEs?: string | null;
|
||||||
|
// POS card terminal on the door screen (never offered at online checkout)
|
||||||
|
posEnabled?: boolean;
|
||||||
// Booking settings
|
// Booking settings
|
||||||
allowDuplicateBookings?: boolean;
|
allowDuplicateBookings?: boolean;
|
||||||
}
|
}
|
||||||
@@ -362,6 +369,7 @@ export interface FinancialSummary {
|
|||||||
cash: number;
|
cash: number;
|
||||||
bank_transfer: number;
|
bank_transfer: number;
|
||||||
tpago: number;
|
tpago: number;
|
||||||
|
pos?: number;
|
||||||
};
|
};
|
||||||
paidCount: number;
|
paidCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user