Dev #38
@@ -117,6 +117,11 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
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: {
|
||||||
@@ -459,6 +492,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
const id = generateId();
|
const id = generateId();
|
||||||
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