From fc4af38e8a01384d305d825a0b0c0c194456e5d0 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 18 Jun 2026 23:59:17 +0000 Subject: [PATCH] Add per-quantity TPago payment links for multi-ticket checkout. Each ticket quantity (1-5) can have its own TPago link so checkout, emails, and admin config use the correct fixed-amount URL. --- backend/src/db/migrate.ts | 44 +++++++++++++++++++ backend/src/db/schema.ts | 16 +++++++ backend/src/lib/email.ts | 12 ++++- backend/src/routes/payment-options.ts | 20 +++++++++ backend/src/routes/tickets.ts | 10 +++++ .../src/app/(public)/book/[eventId]/page.tsx | 7 +-- .../app/(public)/booking/[ticketId]/page.tsx | 7 +-- frontend/src/app/admin/events/[id]/page.tsx | 33 ++++++++++---- .../src/app/admin/payment-options/page.tsx | 34 +++++++++++--- frontend/src/lib/api.ts | 5 +++ frontend/src/lib/utils.ts | 28 ++++++++++++ 11 files changed, 195 insertions(+), 21 deletions(-) diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index e9a2750..5c4c187 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -259,6 +259,10 @@ async function migrate() { id TEXT PRIMARY KEY, tpago_enabled INTEGER NOT NULL DEFAULT 0, tpago_link TEXT, + tpago_link_2 TEXT, + tpago_link_3 TEXT, + tpago_link_4 TEXT, + tpago_link_5 TEXT, tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER NOT NULL DEFAULT 0, @@ -283,6 +287,13 @@ async function migrate() { await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } + // Add per-quantity TPago link columns to payment_options if they don't exist + for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { + try { + await (db as any).run(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} TEXT`)); + } catch (e) { /* column may already exist */ } + } + // Event payment overrides table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS event_payment_overrides ( @@ -290,6 +301,10 @@ async function migrate() { event_id TEXT NOT NULL REFERENCES events(id), tpago_enabled INTEGER, tpago_link TEXT, + tpago_link_2 TEXT, + tpago_link_3 TEXT, + tpago_link_4 TEXT, + tpago_link_5 TEXT, tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER, @@ -309,6 +324,13 @@ async function migrate() { ) `); + // Add per-quantity TPago link columns to event_payment_overrides if they don't exist + for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { + try { + await (db as any).run(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} TEXT`)); + } catch (e) { /* column may already exist */ } + } + await (db as any).run(sql` CREATE TABLE IF NOT EXISTS contacts ( id TEXT PRIMARY KEY, @@ -702,6 +724,10 @@ async function migrate() { id UUID PRIMARY KEY, tpago_enabled INTEGER NOT NULL DEFAULT 0, tpago_link VARCHAR(500), + tpago_link_2 VARCHAR(500), + tpago_link_3 VARCHAR(500), + tpago_link_4 VARCHAR(500), + tpago_link_5 VARCHAR(500), tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER NOT NULL DEFAULT 0, @@ -726,12 +752,23 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } + // Add per-quantity TPago link columns to payment_options if they don't exist + for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { + try { + await (db as any).execute(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} VARCHAR(500)`)); + } catch (e) { /* column may already exist */ } + } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS event_payment_overrides ( id UUID PRIMARY KEY, event_id UUID NOT NULL REFERENCES events(id), tpago_enabled INTEGER, tpago_link VARCHAR(500), + tpago_link_2 VARCHAR(500), + tpago_link_3 VARCHAR(500), + tpago_link_4 VARCHAR(500), + tpago_link_5 VARCHAR(500), tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER, @@ -751,6 +788,13 @@ async function migrate() { ) `); + // Add per-quantity TPago link columns to event_payment_overrides if they don't exist + for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { + try { + await (db as any).execute(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} VARCHAR(500)`)); + } catch (e) { /* column may already exist */ } + } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS contacts ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ea05a9c..c8e834a 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -135,6 +135,10 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', { // TPago configuration tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false), tpagoLink: text('tpago_link'), + tpagoLink2: text('tpago_link_2'), + tpagoLink3: text('tpago_link_3'), + tpagoLink4: text('tpago_link_4'), + tpagoLink5: text('tpago_link_5'), tpagoInstructions: text('tpago_instructions'), tpagoInstructionsEs: text('tpago_instructions_es'), // Bank Transfer configuration @@ -166,6 +170,10 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides' // Override flags (null means use global) tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }), tpagoLink: text('tpago_link'), + tpagoLink2: text('tpago_link_2'), + tpagoLink3: text('tpago_link_3'), + tpagoLink4: text('tpago_link_4'), + tpagoLink5: text('tpago_link_5'), tpagoInstructions: text('tpago_instructions'), tpagoInstructionsEs: text('tpago_instructions_es'), bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }), @@ -467,6 +475,10 @@ export const pgPaymentOptions = pgTable('payment_options', { id: uuid('id').primaryKey(), tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0), tpagoLink: varchar('tpago_link', { length: 500 }), + tpagoLink2: varchar('tpago_link_2', { length: 500 }), + tpagoLink3: varchar('tpago_link_3', { length: 500 }), + tpagoLink4: varchar('tpago_link_4', { length: 500 }), + tpagoLink5: varchar('tpago_link_5', { length: 500 }), tpagoInstructions: pgText('tpago_instructions'), tpagoInstructionsEs: pgText('tpago_instructions_es'), bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0), @@ -492,6 +504,10 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', { eventId: uuid('event_id').notNull().references(() => pgEvents.id), tpagoEnabled: pgInteger('tpago_enabled'), tpagoLink: varchar('tpago_link', { length: 500 }), + tpagoLink2: varchar('tpago_link_2', { length: 500 }), + tpagoLink3: varchar('tpago_link_3', { length: 500 }), + tpagoLink4: varchar('tpago_link_4', { length: 500 }), + tpagoLink5: varchar('tpago_link_5', { length: 500 }), tpagoInstructions: pgText('tpago_instructions'), tpagoInstructionsEs: pgText('tpago_instructions_es'), bankTransferEnabled: pgInteger('bank_transfer_enabled'), diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index f591580..5ff1449 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -748,6 +748,10 @@ export const emailService = { const defaults = { tpagoEnabled: false, tpagoLink: null, + tpagoLink2: null, + tpagoLink3: null, + tpagoLink4: null, + tpagoLink5: null, tpagoInstructions: null, tpagoInstructionsEs: null, bankTransferEnabled: false, @@ -766,6 +770,10 @@ export const emailService = { return { tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled, tpagoLink: overrides?.tpagoLink ?? global.tpagoLink, + tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2, + tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3, + tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4, + tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5, tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions, tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs, bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled, @@ -885,7 +893,9 @@ export const emailService = { // Add payment-method specific variables if (payment.provider === 'tpago') { - variables.tpagoLink = paymentConfig.tpagoLink || ''; + // Select the TPago link matching the number of tickets (1-5), falling back to the base link + const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`; + variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || ''; } else { // Bank transfer variables.bankName = paymentConfig.bankName || ''; diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 2ec11c9..33fd275 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -18,6 +18,10 @@ const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => { const updatePaymentOptionsSchema = z.object({ tpagoEnabled: booleanOrNumber.optional(), tpagoLink: z.string().optional().nullable(), + tpagoLink2: z.string().optional().nullable(), + tpagoLink3: z.string().optional().nullable(), + tpagoLink4: z.string().optional().nullable(), + tpagoLink5: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(), bankTransferEnabled: booleanOrNumber.optional(), @@ -40,6 +44,10 @@ const updatePaymentOptionsSchema = z.object({ const updateEventOverridesSchema = z.object({ tpagoEnabled: booleanOrNumber.optional().nullable(), tpagoLink: z.string().optional().nullable(), + tpagoLink2: z.string().optional().nullable(), + tpagoLink3: z.string().optional().nullable(), + tpagoLink4: z.string().optional().nullable(), + tpagoLink5: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(), bankTransferEnabled: booleanOrNumber.optional().nullable(), @@ -68,6 +76,10 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { paymentOptions: { tpagoEnabled: false, tpagoLink: null, + tpagoLink2: null, + tpagoLink3: null, + tpagoLink4: null, + tpagoLink5: null, tpagoInstructions: null, tpagoInstructionsEs: null, bankTransferEnabled: false, @@ -171,6 +183,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { const defaults = { tpagoEnabled: false, tpagoLink: null, + tpagoLink2: null, + tpagoLink3: null, + tpagoLink4: null, + tpagoLink5: null, tpagoInstructions: null, tpagoInstructionsEs: null, bankTransferEnabled: false, @@ -193,6 +209,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { const merged = { tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled, tpagoLink: overrides?.tpagoLink ?? global.tpagoLink, + tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2, + tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3, + tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4, + tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5, tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions, tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs, bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled, diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index da5df76..611dcc3 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -631,11 +631,21 @@ ticketsRouter.get('/:id', async (c) => { (db as any).select().from(payments).where(eq((payments as any).ticketId, id)) ); + // Count how many tickets belong to this booking (for per-quantity payment links) + let bookingTicketCount = 1; + if (ticket.bookingId) { + const bookingTickets = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + bookingTicketCount = bookingTickets.length || 1; + } + return c.json({ ticket: { ...ticket, event, payment, + bookingTicketCount, }, }); }); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 790a329..8eeaf84 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; @@ -665,6 +665,7 @@ export default function BookingPage() { const isTpago = bookingResult.paymentMethod === 'tpago'; const ticketCount = bookingResult.ticketCount || 1; const totalAmount = (event?.price || 0) * ticketCount; + const tpagoLink = getTpagoLink(paymentConfig, ticketCount); return (
@@ -755,9 +756,9 @@ export default function BookingPage() {

{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}

- {paymentConfig.tpagoLink && ( + {tpagoLink && ( @@ -418,9 +419,9 @@ export default function BookingPaymentPage() {

{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}

- {paymentConfig.tpagoLink && ( + {tpagoLink && (
- updatePaymentOverride('tpagoLink', e.target.value || null)} - placeholder={globalPaymentOptions?.tpagoLink || 'https://www.tpago.com.py/links?alias=...'} - className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" - /> +

+ {locale === 'es' + ? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.' + : 'Each link has a fixed amount. One link per ticket quantity.'} +

+
+ {([1, 2, 3, 4, 5] as const).map((qty) => { + const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig; + return ( +
+ + {qty} {qty === 1 ? 'ticket' : 'tickets'} + + updatePaymentOverride(key, (e.target.value || null) as any)} + placeholder={(globalPaymentOptions?.[key] as string | null) || 'https://www.tpago.com.py/links?alias=...'} + className="flex-1 px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + /> +
+ ); + })} +
diff --git a/frontend/src/app/admin/payment-options/page.tsx b/frontend/src/app/admin/payment-options/page.tsx index d2b07b1..fc7b9de 100644 --- a/frontend/src/app/admin/payment-options/page.tsx +++ b/frontend/src/app/admin/payment-options/page.tsx @@ -26,6 +26,10 @@ export default function PaymentOptionsPage() { const [options, setOptions] = useState({ tpagoEnabled: false, tpagoLink: null, + tpagoLink2: null, + tpagoLink3: null, + tpagoLink4: null, + tpagoLink5: null, tpagoInstructions: null, tpagoInstructionsEs: null, bankTransferEnabled: false, @@ -140,13 +144,31 @@ export default function PaymentOptionsPage() {
- updateOption('tpagoLink', e.target.value || null)} - placeholder="https://www.tpago.com.py/links?alias=..." - /> +

+ {locale === 'es' + ? 'Cada enlace tiene un monto fijo. Usá un enlace distinto para cada cantidad de tickets.' + : 'Each link has a fixed amount. Use a different link for each ticket quantity.'} +

+
+ {([1, 2, 3, 4, 5] as const).map((qty) => { + const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig; + return ( +
+ + {qty} {locale === 'es' ? (qty === 1 ? 'ticket' : 'tickets') : (qty === 1 ? 'ticket' : 'tickets')} + + updateOption(key, (e.target.value || null) as any)} + placeholder="https://www.tpago.com.py/links?alias=..." + className="flex-1" + /> +
+ ); + })} +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 07b2b66..3e9149c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -559,6 +559,7 @@ export interface Event { export interface Ticket { id: string; bookingId?: string; // Groups multiple tickets from same booking + bookingTicketCount?: number; // Total tickets in the booking (for per-quantity payment links) userId: string; eventId: string; attendeeFirstName: string; @@ -673,6 +674,10 @@ export interface PaymentWithDetails extends Payment { export interface PaymentOptionsConfig { tpagoEnabled: boolean; tpagoLink?: string | null; + tpagoLink2?: string | null; + tpagoLink3?: string | null; + tpagoLink4?: string | null; + tpagoLink5?: string | null; tpagoInstructions?: string | null; tpagoInstructionsEs?: string | null; bankTransferEnabled: boolean; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index c6082d0..6b9bf1d 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -165,3 +165,31 @@ export function formatPrice(price: number, currency: string = 'PYG'): string { export function formatCurrency(amount: number, currency: string = 'PYG'): string { return formatPrice(amount, currency); } + +// --------------------------------------------------------------------------- +// Payment helpers +// --------------------------------------------------------------------------- + +type TpagoLinkConfig = { + tpagoLink?: string | null; + tpagoLink2?: string | null; + tpagoLink3?: string | null; + tpagoLink4?: string | null; + tpagoLink5?: string | null; +}; + +/** + * Select the TPago payment link that matches the number of tickets being + * purchased (1-5). Each link has a fixed amount baked in, so the quantity + * determines which one to use. Falls back to the base (1-ticket) link when a + * specific quantity link isn't configured. + */ +export function getTpagoLink( + config: TpagoLinkConfig | null | undefined, + ticketCount: number +): string | null { + if (!config) return null; + const count = Math.min(Math.max(1, Math.floor(ticketCount || 1)), 5); + const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig; + return config[key] || config.tpagoLink || null; +}