From f4bbc2bd5a92122a5a89c148c5118574d06ecdd8 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Thu, 29 Oct 2026 08:07:02 +0530 Subject: [PATCH 01/52] test(e2e): give the two axe-heavy admin tests a 90s timeout They pass alone in ~28s but exceeded the 30s default in a full run. Co-Authored-By: Claude Sonnet 5.5 --- e2e/admin-responsive.spec.js | 1 + e2e/admin.spec.js | 1 + 2 files changed, 2 insertions(+) diff --git a/e2e/admin-responsive.spec.js b/e2e/admin-responsive.spec.js index 729ce19..c19659c 100644 --- a/e2e/admin-responsive.spec.js +++ b/e2e/admin-responsive.spec.js @@ -94,6 +94,7 @@ for (const vp of VIEWPORTS) { }); test('check-in: the list stays open, updates in place, and is audit-logged in full', async ({ page, hasTouch }, testInfo) => { + test.setTimeout(90_000); // sign-in + axe + audit-log checks run close to the 30s default under load await signInAs(page, 'staff'); await openAdminTab(page, 'Events'); await page.getByRole('button', { name: 'Attendees for Test: Board games night' }).click(); diff --git a/e2e/admin.spec.js b/e2e/admin.spec.js index 8bc813f..dec0584 100644 --- a/e2e/admin.spec.js +++ b/e2e/admin.spec.js @@ -4,6 +4,7 @@ import { signInAs, expectAccessible, expectNoHorizontalOverflow, screenshot } fr import { openAdminTab } from './admin-helpers.js'; test('super admin sees the console and the roles and audit screens', async ({ page }, testInfo) => { + test.setTimeout(90_000); // axe on the dense overview takes ~30s on a loaded runner await signInAs(page, 'admin'); await expect(page.locator('.admin-role')).toHaveText(/super admin/i); await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible(); From ac287954a0710c74eef58600e74abdc00dc6e2f1 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Thu, 29 Oct 2026 21:16:05 +0530 Subject: [PATCH 02/52] feat(checkout): SuperProfile hosted checkout for tickets - migration 010: events.superprofile_url/product_id/checkout_mode, checkout_intents, external_orders, bookings.source, users.email_hash/claim_prompt_until - single PaymentProvider superprofile_link (createCheckout = URL with ref, verifyWebhook = constant-time shared secret, refund = NOT_SUPPORTED_IN_APP); https + superprofile.bio allowlist - GET /checkout/:slug records an intent and redirects (works signed out); web-only, hidden in the Mini App - /api/webhooks/superprofile: purchase/refund/cancelled orders, idempotent on order_id, fail-closed amount check, unmatched/flagged orders alert admins, refund-after-check-in keeps the record - Telegram claim flow (/claim, /start claim): one claim per order, 5/hour, generic errors - admin: Orders tab (finance links, support views, keyset pagination), SuperProfile fields on events - remove the old ticket checkout/hold/reconcile paths and the provider webhook route Adapter files, docs and env example are cleaned in the next commit. Co-Authored-By: Claude Sonnet 5.5 --- lib/admin-roles.js | 3 + lib/after.js | 2 +- lib/config.js | 6 +- lib/handlers/admin.js | 58 ++- lib/handlers/bookings.js | 23 +- lib/handlers/checkout.js | 21 + lib/handlers/cron.js | 13 +- lib/handlers/membership.js | 37 +- lib/handlers/pages.js | 2 +- lib/handlers/payments.js | 35 -- lib/handlers/superprofile.js | 51 ++ lib/payments/availability.js | 45 +- lib/payments/index.js | 38 +- lib/payments/superprofile.js | 65 +++ lib/router.js | 8 +- lib/services/bookings.js | 230 +-------- lib/services/checkout.js | 65 +++ lib/services/claims.js | 65 +++ lib/services/events.js | 29 +- lib/services/insights.js | 5 +- lib/services/notifications.js | 30 +- lib/services/orders.js | 269 ++++++++++ lib/services/refunds.js | 9 +- lib/services/superprofile.js | 27 +- lib/services/ticketing.js | 70 +-- lib/telegram/bot.js | 36 +- lib/views/admin.js | 2 +- lib/views/event.js | 15 +- lib/views/layout.js | 2 +- migrations/010_superprofile_checkout.sql | 93 ++++ public/js/admin.js | 35 +- public/js/site.js | 43 +- test/admin.test.js | 26 +- test/closed-events.test.js | 7 +- test/discovery.test.js | 10 +- test/helpers.js | 29 +- test/insights.test.js | 8 +- test/organiser.test.js | 68 +-- test/payments.test.js | 217 +------- test/personas.test.js | 6 +- test/safety.test.js | 32 -- test/site.test.js | 6 +- test/superprofile-checkout.test.js | 598 +++++++++++++++++++++++ 43 files changed, 1556 insertions(+), 883 deletions(-) create mode 100644 lib/handlers/checkout.js delete mode 100644 lib/handlers/payments.js create mode 100644 lib/handlers/superprofile.js create mode 100644 lib/payments/superprofile.js create mode 100644 lib/services/checkout.js create mode 100644 lib/services/claims.js create mode 100644 lib/services/orders.js create mode 100644 migrations/010_superprofile_checkout.sql create mode 100644 test/superprofile-checkout.test.js diff --git a/lib/admin-roles.js b/lib/admin-roles.js index ae47312..eafb8bc 100644 --- a/lib/admin-roles.js +++ b/lib/admin-roles.js @@ -6,6 +6,8 @@ export const R = { CHECKIN: ['super_admin', 'event_manager', 'support'], BOOKINGS: ['super_admin', 'event_manager', 'finance', 'support'], REFUND: ['super_admin', 'finance'], + ORDERS: ['super_admin', 'finance', 'support'], + ORDERS_LINK: ['super_admin', 'finance'], MEMBERSHIPS: ['super_admin', 'finance', 'support', 'moderator'], REVOKE: ['super_admin', 'moderator'], LEADS: ['super_admin', 'event_manager', 'support'], @@ -24,6 +26,7 @@ export const ADMIN_TABS = [ ['discovery', 'Discovery', R.DISCOVERY], ['events', 'Events', R.ANY], ['bookings', 'Bookings & payments', R.BOOKINGS], + ['orders', 'Orders (SuperProfile)', R.ORDERS], ['memberships', 'Memberships', R.MEMBERSHIPS], ['leads', 'Leads & reports', R.LEADS], ['media', 'Media', R.MEDIA], diff --git a/lib/after.js b/lib/after.js index 7bb6996..8e3553d 100644 --- a/lib/after.js +++ b/lib/after.js @@ -1,5 +1,5 @@ // lib/after.js - Run side effects after the HTTP response on Vercel (waitUntil keeps the function -// alive), so providers that require a fast 2xx (Razorpay: 5s) are acknowledged first. +// alive), so webhook senders (Zapier/Pabbly, Telegram) that expect a fast 2xx are acknowledged first. // Locally and in tests the work is awaited before responding, which keeps behaviour deterministic. import { logger } from './logger.js'; diff --git a/lib/config.js b/lib/config.js index ecb3bf8..fb75861 100644 --- a/lib/config.js +++ b/lib/config.js @@ -69,7 +69,6 @@ export function chatIdFromEnv(name) { /** Configuration status for /api/health and admin. Names only — never values. */ export function configStatus() { - const provider = env('PAYMENT_PROVIDER', 'razorpay'); const has = (n) => Boolean(env(n)); const ok = (cond) => (cond ? 'configured' : 'NOT CONFIGURED'); return { @@ -79,10 +78,7 @@ export function configStatus() { telegramBot: ok(Boolean(botToken()) && Boolean(botUsername())), telegramWebhookSecret: ok(/^[A-Za-z0-9_-]{16,256}$/.test(env('TELEGRAM_WEBHOOK_SECRET'))), telegramEventsChat: ok(chatIdFromEnv('TG_CHAT_ID_EVENTS') !== null), - paymentProvider: provider, - paymentKeys: ok(provider === 'cashfree' - ? has('CASHFREE_APP_ID') && has('CASHFREE_SECRET_KEY') - : has('RAZORPAY_KEY_ID') && has('RAZORPAY_KEY_SECRET') && has('RAZORPAY_WEBHOOK_SECRET')), + ticketCheckout: 'superprofile_link', livePayments: livePayments(), adminBootstrap: ok(/^\d+$/.test(env('ADMIN_BOOTSTRAP_TELEGRAM_ID'))), telegramLogin: ok(has('TELEGRAM_LOGIN_CLIENT_ID') && has('TELEGRAM_LOGIN_CLIENT_SECRET')), diff --git a/lib/handlers/admin.js b/lib/handlers/admin.js index 0b84026..b8f89d9 100644 --- a/lib/handlers/admin.js +++ b/lib/handlers/admin.js @@ -7,8 +7,10 @@ import { rateLimit } from '../ratelimit.js'; import { parse, z } from '../validate.js'; import { audit } from '../audit.js'; import { chatIdFromEnv, configStatus, botToken, botUsername, siteUrl, env } from '../config.js'; -import { enqueue } from '../services/notifications.js'; +import { enqueue, deliverMany } from '../services/notifications.js'; import { refundPayment as refundSvc } from '../services/refunds.js'; +import { listOrders as listOrdersSvc, linkOrderToUser } from '../services/orders.js'; +import { isSuperprofileUrl } from '../payments/index.js'; import { removeFromChat, tierChatId } from '../services/invites.js'; import { CATEGORY_LABELS, CITIES, toPublicEvent } from '../services/events.js'; import { getInsights } from '../services/insights.js'; @@ -108,7 +110,8 @@ export async function overview(ctx) { (SELECT count(*)::int FROM payments WHERE status = 'failed') AS failed_payments, (SELECT count(*)::int FROM notifications WHERE status = 'failed' AND kind IN ('booking_confirmed','membership_activated')) AS invite_failures, (SELECT count(*)::int FROM entitlements WHERE status IN ('active','cancelled') AND expires_at BETWEEN now() AND now() + interval '7 days') AS expiring_memberships, - (SELECT count(*)::int FROM bookings WHERE cancel_reason = 'paid_but_unavailable_refund_due' AND status <> 'refunded') AS refunds_due, + (SELECT count(*)::int FROM external_orders WHERE match_state = 'unmatched' AND status = 'paid') AS unmatched_orders, + (SELECT count(*)::int FROM external_orders WHERE match_state = 'flagged') AS flagged_orders, (SELECT count(*)::int FROM leads WHERE status = 'new') AS new_leads, (SELECT count(*)::int FROM reports WHERE status = 'new') AS new_reports, (SELECT count(*)::int FROM users WHERE alerts_opt_in) AS alert_subscribers`); @@ -119,6 +122,8 @@ export async function overview(ctx) { const nullableStr = (max) => z.union([z.string().trim().max(max), z.null()]).optional().transform((v) => (v === '' ? null : v)); const httpsUrl = z.union([z.string().trim().url().regex(/^https:\/\//, 'Must be https'), z.literal(''), z.null()]).optional().transform((v) => v || null); +const spUrl = z.union([z.string().trim().max(500).refine(isSuperprofileUrl, 'Must be an https superprofile.bio link'), z.literal(''), z.null()]).optional().transform((v) => v || null); + const EventBody = z.object({ slug: z.string().trim().toLowerCase().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'lowercase-words-with-dashes').max(120), category: z.enum(Object.keys(CATEGORY_LABELS)), @@ -144,17 +149,28 @@ const EventBody = z.object({ external_payment_url: httpsUrl, telegram_chat_id: z.union([z.coerce.number().int(), z.literal(''), z.null()]).optional().transform((v) => (v === '' || v === undefined ? null : v)), cover_media_id: z.union([z.string().uuid(), z.literal(''), z.null()]).optional().transform((v) => v || null), - is_demo: z.boolean().default(false) -}).strict().refine((e) => !e.end_at || new Date(e.end_at) > new Date(e.start_at), { message: 'End must be after start', path: ['end_at'] }); + is_demo: z.boolean().default(false), + superprofile_url: spUrl, + superprofile_product_id: nullableStr(200), + checkout_mode: z.enum(['none', 'superprofile', 'free']).default('none') +}).strict() + .refine((e) => !e.end_at || new Date(e.end_at) > new Date(e.start_at), { message: 'End must be after start', path: ['end_at'] }) + .refine((e) => e.checkout_mode !== 'superprofile' || Boolean(e.superprofile_url), { message: 'Add the SuperProfile link to use SuperProfile checkout', path: ['superprofile_url'] }); const EVENT_COLS = ['slug', 'category', 'title', 'summary', 'description', 'city', 'area_public', 'venue_private', 'venue_privacy', 'start_at', 'end_at', 'capacity', 'ticket_price_paise', 'inclusions', 'exclusions', 'refund_policy', 'gender_policy', 'food_available', 'stay_available', 'organizer', - 'bookmyshow_url', 'external_payment_url', 'telegram_chat_id', 'cover_media_id', 'is_demo']; + 'bookmyshow_url', 'external_payment_url', 'telegram_chat_id', 'cover_media_id', 'is_demo', 'superprofile_url', 'superprofile_product_id', 'checkout_mode']; function eventValues(e) { return EVENT_COLS.map((c) => (c === 'inclusions' || c === 'exclusions' ? JSON.stringify(e[c]) : e[c] ?? null)); } +async function assertProductFree(c, productId, exceptEventId = null) { + if (!productId) return; + const used = (await c.query('SELECT 1 FROM events WHERE lower(superprofile_product_id) = lower($1) AND ($2::uuid IS NULL OR id <> $2)', [productId, exceptEventId])).rows[0]; + if (used) throw new HttpError(409, 'product_taken', 'That SuperProfile product is already linked to another event', { fields: [{ field: 'superprofile_product_id', message: 'Already linked' }] }); +} + async function assertCoverAuthorized(c, mediaId) { if (!mediaId) return; const m = (await c.query('SELECT authorized_by_owner FROM media WHERE id = $1', [mediaId])).rows[0]; @@ -182,13 +198,14 @@ export async function createEvent(ctx) { const e = parse(EventBody, await readJson(ctx.req)); const row = await db.tx(async (c) => { await assertCoverAuthorized(c, e.cover_media_id); + await assertProductFree(c, e.superprofile_product_id); const dupe = (await c.query('SELECT 1 FROM events WHERE slug = $1', [e.slug])).rows[0]; if (dupe) throw new HttpError(409, 'slug_taken', 'That slug is already used'); const placeholders = EVENT_COLS.map((_, i) => `$${i + 1}`).join(', '); const r = (await c.query( `INSERT INTO events (${EVENT_COLS.join(', ')}, price_status, status) VALUES (${placeholders}, $${EVENT_COLS.length + 1}, 'draft') RETURNING id, slug`, [...eventValues(e), e.ticket_price_paise ? 'set' : 'tbd'])).rows[0]; - await audit(a.actor, 'event.create', r.id, { slug: e.slug, priceSet: Boolean(e.ticket_price_paise) }, c); + await audit(a.actor, 'event.create', r.id, { slug: e.slug, priceSet: Boolean(e.ticket_price_paise), checkoutMode: e.checkout_mode }, c); return r; }); sendJson(ctx.res, 201, { ok: true, event: row }); @@ -200,14 +217,19 @@ export async function updateEvent(ctx) { const e = parse(EventBody, await readJson(ctx.req)); await db.tx(async (c) => { await assertCoverAuthorized(c, e.cover_media_id); - const cur = (await c.query('SELECT id, slug, capacity FROM events WHERE id = $1 FOR UPDATE', [id])).rows[0]; + const cur = (await c.query('SELECT id, slug, capacity, superprofile_product_id, checkout_mode FROM events WHERE id = $1 FOR UPDATE', [id])).rows[0]; if (!cur) throw new HttpError(404, 'not_found', 'Not found'); + await assertProductFree(c, e.superprofile_product_id, id); const taken = (await c.query(`SELECT count(*)::int n FROM bookings WHERE event_id = $1 AND status IN ('confirmed','checked_in')`, [id])).rows[0].n; if (e.capacity < taken) throw new HttpError(409, 'capacity_below_bookings', `Capacity cannot be below ${taken} confirmed bookings`); const sets = EVENT_COLS.map((col, i) => `${col} = $${i + 2}`).join(', '); await c.query(`UPDATE events SET ${sets}, price_status = $${EVENT_COLS.length + 2} WHERE id = $1`, [id, ...eventValues(e), e.ticket_price_paise ? 'set' : 'tbd']); - await audit(a.actor, 'event.update', id, { slug: e.slug }, c); + await audit(a.actor, 'event.update', id, { + slug: e.slug, checkoutMode: e.checkout_mode, + ...(cur.checkout_mode !== e.checkout_mode ? { checkoutModeFrom: cur.checkout_mode } : {}), + ...(cur.superprofile_product_id !== e.superprofile_product_id ? { superprofileProductChanged: true } : {}) + }, c); }); sendJson(ctx.res, 200, { ok: true }); } @@ -281,6 +303,24 @@ export async function listBookings(ctx) { sendJson(ctx.res, 200, { ok: true, bookings: rows, membershipPayments: stars }); } +// ---------- SuperProfile orders ---------- + +export async function listOrders(ctx) { + await guard(ctx, R.ORDERS); + const r = await listOrdersSvc({ state: ctx.query.get('state') || 'unmatched', cursor: ctx.query.get('cursor'), limit: ctx.query.get('limit') || 25 }); + sendJson(ctx.res, 200, { ok: true, ...r }); +} + +const LinkBody = z.object({ telegramUserId: z.coerce.number().int().positive() }).strict(); + +export async function linkOrder(ctx) { + const a = await guard(ctx, R.ORDERS_LINK, { write: true }); + const { telegramUserId } = parse(LinkBody, await readJson(ctx.req)); + const r = await linkOrderToUser(a.actor, uuidParam(ctx.params.id), telegramUserId); + await deliverMany(r.notificationIds); + sendJson(ctx.res, 200, { ok: true, bookingId: r.bookingId }); +} + export async function refundPayment(ctx) { const a = await guard(ctx, R.REFUND, { write: true }); await refundSvc(uuidParam(ctx.params.id), a.actor); @@ -430,7 +470,7 @@ const TierPatch = z.object({ telegram_chat_id: z.union([z.coerce.number().int(), z.null()]).optional(), active: z.boolean().optional(), access_provider: z.enum(['telegram_stars', 'superprofile']).optional(), - superprofile_url: z.union([z.string().trim().url().regex(/^https:\/\//, 'Must be https').max(500), z.null()]).optional(), + superprofile_url: z.union([z.string().trim().max(500).refine(isSuperprofileUrl, 'Must be an https superprofile.bio link'), z.null()]).optional(), superprofile_product_ref: z.union([z.string().trim().min(1).max(200), z.null()]).optional() }).strict(); diff --git a/lib/handlers/bookings.js b/lib/handlers/bookings.js index ae9a024..a55bf98 100644 --- a/lib/handlers/bookings.js +++ b/lib/handlers/bookings.js @@ -1,22 +1,7 @@ -// lib/handlers/bookings.js - Member booking endpoints (session + CSRF). -import { readJson, sendJson, HttpError, hashIp } from '../http.js'; -import { requireUser, requireCsrf } from '../session.js'; -import { parse, z } from '../validate.js'; -import { rateLimit } from '../ratelimit.js'; -import { ticketProvider } from '../payments/index.js'; -import { createBooking as createBookingSvc, listUserBookings, getUserBooking } from '../services/bookings.js'; - -const Body = z.object({ eventId: z.string().uuid() }).strict(); - -export async function createBooking({ req, res, ip }) { - const user = await requireUser(req); - requireCsrf(req, user); - await rateLimit(`book:${user.id}`, 10, 600); - await rateLimit(`bookip:${hashIp(ip)}`, 30, 600); - const { eventId } = parse(Body, await readJson(req)); - const r = await createBookingSvc(user, eventId, ticketProvider()); - sendJson(res, 200, { ok: true, ...r }); -} +// lib/handlers/bookings.js - Member booking reads (session). +import { sendJson, HttpError } from '../http.js'; +import { requireUser } from '../session.js'; +import { listUserBookings, getUserBooking } from '../services/bookings.js'; export async function myBookings({ req, res }) { const user = await requireUser(req); diff --git a/lib/handlers/checkout.js b/lib/handlers/checkout.js new file mode 100644 index 0000000..5dc266e --- /dev/null +++ b/lib/handlers/checkout.js @@ -0,0 +1,21 @@ +// lib/handlers/checkout.js - GET /checkout/:slug → 302 to the event's SuperProfile page. +// Works signed out (paying is never gated on signing in). HEAD requests (link previews, crawlers) +// never create an intent. +import { redirect, hashIp, HttpError } from '../http.js'; +import { getSessionUser } from '../session.js'; +import { rateLimit } from '../ratelimit.js'; +import { createCheckoutIntent } from '../services/checkout.js'; + +export async function checkoutRedirect({ req, res, params, query, ip }) { + const slug = params.slug; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) throw new HttpError(404, 'not_found', 'Not found'); + if (req.method === 'HEAD') return redirect(res, `/events/${slug}`); + await rateLimit(`co:${hashIp(ip)}`, 30, 600); + const user = await getSessionUser(req); + const intent = await createCheckoutIntent({ + slug, user, ticketTypeId: query.get('tier') || null, qty: query.get('qty') || 1 + }); + res.setHeader('Cache-Control', 'no-store'); + if (!intent) return redirect(res, `/events/${slug}`); + redirect(res, intent.url); +} diff --git a/lib/handlers/cron.js b/lib/handlers/cron.js index cc926fa..12722f8 100644 --- a/lib/handlers/cron.js +++ b/lib/handlers/cron.js @@ -2,25 +2,18 @@ // Every job is idempotent: running it twice changes nothing the second time. import { sendJson, safeEqual, HttpError } from '../http.js'; import { env } from '../config.js'; -import { releaseExpiredHolds, reconcilePayments } from '../services/bookings.js'; +import { releaseExpiredHolds } from '../services/bookings.js'; import { queueExpiryReminders, expireAndRevoke } from '../services/membership.js'; import { deliverDue, deliverMany } from '../services/notifications.js'; -import { ticketProvider } from '../payments/index.js'; import { pruneNonces } from '../telegram/auth.js'; import { pruneRateLimits } from '../ratelimit.js'; import { pruneSessions } from '../session.js'; import { logger } from '../logger.js'; import { runFeeds } from '../services/feeds.js'; +import { pruneCheckoutIntents } from '../services/checkout.js'; const JOBS = { 'expire-holds': async () => ({ released: await releaseExpiredHolds() }), - reconcile: async () => { - let provider; - try { provider = ticketProvider(); } catch (err) { return { skipped: err.message }; } - const r = await reconcilePayments(provider); - await deliverMany(r.notificationIds); - return { checked: r.checked, applied: r.applied }; - }, memberships: async () => { const reminders = await queueExpiryReminders(); const revoke = await expireAndRevoke(); @@ -32,7 +25,7 @@ const JOBS = { const r = await runFeeds(); return { fetched: r.length, failed: r.filter((x) => !['ok', 'not_modified'].includes(x.status)).length, newItems: r.reduce((n, x) => n + x.new, 0) }; }, - cleanup: async () => ({ nonces: await pruneNonces(), rateLimits: await pruneRateLimits(), sessions: await pruneSessions() }) + cleanup: async () => ({ nonces: await pruneNonces(), rateLimits: await pruneRateLimits(), sessions: await pruneSessions(), intents: await pruneCheckoutIntents() }) }; export async function runCron({ req, res, params }) { diff --git a/lib/handlers/membership.js b/lib/handlers/membership.js index f89002a..3e7ab82 100644 --- a/lib/handlers/membership.js +++ b/lib/handlers/membership.js @@ -1,16 +1,10 @@ -// lib/handlers/membership.js - POST /api/membership/checkout → Telegram Stars invoice link; -// POST /api/membership/superprofile/webhook ← SuperProfile purchase/expiry (via Zapier/Pabbly). -import { readJson, readRawBody, sendJson, hashIp, safeEqual } from '../http.js'; +// lib/handlers/membership.js - POST /api/membership/checkout → Telegram Stars invoice link. +// (The SuperProfile webhook lives in handlers/superprofile.js.) +import { readJson, sendJson, hashIp } from '../http.js'; import { requireUser, requireCsrf } from '../session.js'; import { parse, z } from '../validate.js'; import { rateLimit } from '../ratelimit.js'; -import { env } from '../config.js'; -import { logger } from '../logger.js'; import { createMembershipInvoice } from '../services/membership.js'; -import { - superprofileConfigured, applySuperprofileEvent, WebhookBody, SUPERPROFILE_SECRET_HEADER -} from '../services/superprofile.js'; -import { recordIntegrationError } from '../services/insights.js'; const Body = z.object({ tierCode: z.string().regex(/^[a-z0-9_]{1,24}$/) }); @@ -22,28 +16,3 @@ export async function membershipCheckout({ req, res, ip }) { const { invoiceLink, tier } = await createMembershipInvoice(user, tierCode); sendJson(res, 200, { ok: true, invoiceLink, tier }); } - -// Auth: shared secret in X-Webhook-Secret (SuperProfile itself does not sign requests). -// Duplicate: 200 no-op. Processing error: 500 so the automation retries. The payload is never logged. -export async function superprofileWebhook({ req, res, ip }) { - if (!superprofileConfigured()) return sendJson(res, 503, { ok: false, error: 'not_configured' }); - const got = req.headers[SUPERPROFILE_SECRET_HEADER]; - if (typeof got !== 'string' || !safeEqual(got, env('SUPERPROFILE_WEBHOOK_SECRET'))) { - logger.warn('superprofile webhook rejected: bad secret'); - return sendJson(res, 401, { ok: false }); - } - await rateLimit(`sp:${hashIp(ip)}`, 300, 60); - let body; - try { body = JSON.parse(await readRawBody(req, 32 * 1024)); } catch { return sendJson(res, 400, { ok: false, error: 'invalid_json' }); } - const p = WebhookBody.safeParse(body); - if (!p.success) return sendJson(res, 400, { ok: false, error: 'validation', fields: p.error.issues.map((i) => i.path.join('.')) }); - try { - const r = await applySuperprofileEvent(p.data); - logger.info('superprofile webhook processed', { event: p.data.event, result: r.result, tierMapped: r.tierMapped }); - sendJson(res, 200, { ok: true, ...r }); - } catch (err) { - await recordIntegrationError('superprofile_webhook', 'processing_failed', String(err.code || err.message)); - logger.error('superprofile webhook failed', err); - sendJson(res, 500, { ok: false }); - } -} diff --git a/lib/handlers/pages.js b/lib/handlers/pages.js index 4e23dbf..1ca0df8 100644 --- a/lib/handlers/pages.js +++ b/lib/handlers/pages.js @@ -144,7 +144,7 @@ export async function sitemap({ res }) { export async function robots({ res }) { // Only the production deployment is indexable; previews are not. const body = isProductionRuntime() - ? `User-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /admin\nDisallow: /me\nDisallow: /bookings/\nDisallow: /signin\nDisallow: /organiser\n\nSitemap: ${siteUrl()}/sitemap.xml\n` + ? `User-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /admin\nDisallow: /me\nDisallow: /bookings/\nDisallow: /checkout/\nDisallow: /signin\nDisallow: /organiser\n\nSitemap: ${siteUrl()}/sitemap.xml\n` : 'User-agent: *\nDisallow: /\n'; sendText(res, 200, body); } diff --git a/lib/handlers/payments.js b/lib/handlers/payments.js deleted file mode 100644 index 9fa7ac0..0000000 --- a/lib/handlers/payments.js +++ /dev/null @@ -1,35 +0,0 @@ -// lib/handlers/payments.js - POST /api/payments/webhook/:provider -// verify signature → webhook_events (unique) → DB transaction → deliver notifications after commit. -// Bad signature: 401. Duplicate: 200 no-op. Processing error: 500 (transaction rolled back, -// including the webhook_events row, so the provider's retry is processed). -import { readRawBody, sendJson } from '../http.js'; -import { providerByName } from '../payments/index.js'; -import { applyTicketEvent } from '../services/bookings.js'; -import { deliverMany } from '../services/notifications.js'; -import { logger } from '../logger.js'; -import { afterResponse } from '../after.js'; -import { recordIntegrationError } from '../services/insights.js'; - -export async function paymentWebhook({ req, res, params }) { - if (!['razorpay', 'cashfree'].includes(params.provider)) return sendJson(res, 404, { ok: false }); - const provider = providerByName(params.provider); - const raw = await readRawBody(req, 256 * 1024); - let evt; - try { - evt = provider.verifyWebhook(req.headers, raw); - } catch (err) { - logger.warn('payment webhook rejected', { provider: params.provider, reason: err.code === 'WEBHOOK_SIGNATURE' ? err.message : 'unparseable' }); - return sendJson(res, err.code === 'WEBHOOK_SIGNATURE' ? 401 : 400, { ok: false }); - } - let r; - try { - r = await applyTicketEvent(provider.name, evt); - } catch (err) { - await recordIntegrationError('payment_webhook', `${provider.name}_processing_failed`, String(err.code || err.message)); - throw err; - } - logger.info('payment webhook processed', { provider: provider.name, type: evt.type, result: r.result }); - // Notifications are already committed to the outbox; delivery failures are retried by cron. - await afterResponse(() => deliverMany(r.notificationIds)); - sendJson(res, 200, { ok: true, result: r.result }); -} diff --git a/lib/handlers/superprofile.js b/lib/handlers/superprofile.js new file mode 100644 index 0000000..c129f39 --- /dev/null +++ b/lib/handlers/superprofile.js @@ -0,0 +1,51 @@ +// lib/handlers/superprofile.js - POST /api/webhooks/superprofile (alias: /api/membership/superprofile/webhook) +// +// SuperProfile has no signed webhooks; Zapier/Pabbly POST here with the shared secret in X-Webhook-Secret. +// Order: secret check (401, before the body is read or any DB write) → rate limit → parse → apply. +// Duplicate: 200 no-op. Processing error: 500 so the automation retries. The payload is never logged. +import { readRawBody, sendJson, hashIp } from '../http.js'; +import { ticketProvider, WebhookSignatureError } from '../payments/index.js'; +import { superprofileConfigured } from '../payments/superprofile.js'; +import { rateLimit } from '../ratelimit.js'; +import { logger } from '../logger.js'; +import { WebhookBody, applySuperprofileEvent } from '../services/superprofile.js'; +import { applyOrderEvent, isTierProduct } from '../services/orders.js'; +import { deliverMany } from '../services/notifications.js'; +import { recordIntegrationError } from '../services/insights.js'; +import { afterResponse } from '../after.js'; + +export async function superprofileWebhook({ req, res, ip }) { + if (!superprofileConfigured()) return sendJson(res, 503, { ok: false, error: 'not_configured' }); + try { + ticketProvider().verifyWebhook(req.headers); + } catch (err) { + if (!(err instanceof WebhookSignatureError)) throw err; + logger.warn('superprofile webhook rejected: bad secret'); + return sendJson(res, 401, { ok: false }); + } + await rateLimit(`sp:${hashIp(ip)}`, 300, 60); + let body; + try { body = JSON.parse(await readRawBody(req, 32 * 1024)); } catch { return sendJson(res, 400, { ok: false, error: 'invalid_json' }); } + const p = WebhookBody.safeParse(body); + if (!p.success) return sendJson(res, 400, { ok: false, error: 'validation', fields: p.error.issues.map((i) => i.path.join('.')) }); + const b = p.data; + try { + const orderShaped = ['purchase', 'refund', 'cancelled'].includes(b.event); + const membership = b.event === 'purchase' && await isTierProduct(b); + if (!orderShaped || membership) { + // Membership lifecycle (and abandoned checkouts) keep the counts-only flow. + if (b.event === 'refund' || b.event === 'cancelled') return sendJson(res, 200, { ok: true, result: 'ignored' }); + const r = await applySuperprofileEvent(b); + logger.info('superprofile webhook processed', { event: b.event, result: r.result, tierMapped: r.tierMapped }); + return sendJson(res, 200, { ok: true, ...r }); + } + const r = await applyOrderEvent(b); + logger.info('superprofile order processed', { event: b.event, result: r.result }); + await afterResponse(() => deliverMany(r.notificationIds)); + sendJson(res, 200, { ok: true, result: r.result, tierMapped: false }); + } catch (err) { + await recordIntegrationError('superprofile_webhook', 'processing_failed', String(err.code || err.message)); + logger.error('superprofile webhook failed', err); + sendJson(res, 500, { ok: false }); + } +} diff --git a/lib/payments/availability.js b/lib/payments/availability.js index 0526884..b27e663 100644 --- a/lib/payments/availability.js +++ b/lib/payments/availability.js @@ -1,32 +1,23 @@ -// lib/payments/availability.js - Can guests pay for a paid ticket tier right now? +// lib/payments/availability.js - Can a guest buy a paid ticket for this event right now? // -// Ticket inventory and booking logic never ask "which provider"; they ask this module whether a -// hosted checkout is available and, if so, receive a PaymentProvider (see ./index.js). Adding a -// future approved provider means adding an adapter there — not touching tiers or bookings. -// -// Paid checkout for organiser tiers is OFF unless the owner explicitly opts in with -// TICKET_CHECKOUT_ENABLED=true AND the selected provider's keys are present. There is no manual -// UPI or pay-at-venue flow: nothing here can mark a payment as paid. Payments only become `paid` -// from a signature-verified provider webhook or server-side reconciliation (services/bookings.js). -import { env, configStatus } from '../config.js'; -import { ticketProvider } from './index.js'; +// A paid ticket is bookable only when the event is linked to a SuperProfile product +// (checkout_mode = 'superprofile' and a superprofile_url). Linking is an admin action, so events +// created in the organiser wizard cannot take payments until an admin links a product. Nothing here +// can mark a payment as paid: bookings are confirmed only by a matched SuperProfile notification. -export function ticketCheckoutStatus() { - if (env('TICKET_CHECKOUT_ENABLED', 'false') !== 'true') { - return { - available: false, - reason: 'not_enabled', - message: 'Online payment for paid tickets is not available yet. You can set up paid tiers and save them; guests can only book free tiers until a payment method is approved and configured.' - }; - } - const status = configStatus(); - if (status.paymentKeys !== 'configured') { - return { available: false, reason: 'not_configured', message: 'Paid checkout is enabled but the payment provider is not configured, so paid tiers cannot be booked.' }; - } - return { available: true, reason: 'configured', provider: status.paymentProvider, testMode: !status.livePayments, message: '' }; +/** Per-event checkout availability. `row` is an events row (or anything with the same columns). */ +export function checkoutForEvent(row) { + const available = row?.checkout_mode === 'superprofile' && Boolean(row?.superprofile_url); + return available + ? { available: true, reason: 'superprofile', provider: 'superprofile_link', message: '' } + : { available: false, reason: 'no_superprofile_product', message: 'Online booking is not available for this event yet.' }; } -/** The provider to charge with, or null when paid checkout is unavailable. */ -export function ticketCheckoutProvider() { - return ticketCheckoutStatus().available ? ticketProvider() : null; +/** Status used by the organiser wizard, which has no SuperProfile product to attach. */ +export function ticketCheckoutStatus() { + return { + available: false, + reason: 'admin_link_required', + message: 'Paid tickets are bought on SuperProfile and need a product linked by the StrangersMeetup team. Until then guests can only book free tickets.' + }; } diff --git a/lib/payments/index.js b/lib/payments/index.js index 4049c96..dba3d92 100644 --- a/lib/payments/index.js +++ b/lib/payments/index.js @@ -1,35 +1,17 @@ -// lib/payments/index.js - PaymentProvider selection. +// lib/payments/index.js - Ticket payments go through one provider, `superprofile_link` (hosted checkout). // // interface PaymentProvider { -// name: 'razorpay' | 'cashfree' -// isTest: boolean -// createCheckout({ bookingId, amountPaise, title, expiresAt, returnUrl }) -> { providerTxnId, checkoutUrl } -// verifyWebhook(headers, rawBody) -> WebhookEvent (throws WebhookSignatureError) -// refund({ providerTxnId, providerPaymentId, amountPaise, refundRef }) -> { refundId, status } -// fetchStatus(providerTxnId) -> { status: 'created'|'paid'|'failed'|'expired', providerPaymentId?, amountPaise? } +// name: 'superprofile_link' +// createCheckout({ url, ref, slug }) -> { providerTxnId, checkoutUrl } +// verifyWebhook(headers) -> true (throws WebhookSignatureError) +// refund() -> throws NOT_SUPPORTED_IN_APP (refunds happen on SuperProfile) // } -// WebhookEvent = { eventId, type: 'paid'|'failed'|'expired'|'refunded'|'ignored', providerTxnId, providerPaymentId?, amountPaise?, reason? } -import { env, livePayments } from '../config.js'; -import { HttpError } from '../http.js'; -import { razorpay } from './razorpay.js'; -import { cashfree } from './cashfree.js'; +// Membership via Telegram Stars is separate (services/membership.js): Stars is the only way to sell +// digital goods inside Telegram. +import { superprofileLink } from './superprofile.js'; -export class WebhookSignatureError extends Error { - constructor(msg = 'invalid webhook signature') { - super(msg); - this.code = 'WEBHOOK_SIGNATURE'; - } -} - -const PROVIDERS = { razorpay, cashfree }; - -export function providerByName(name) { - const factory = PROVIDERS[name]; - if (!factory) throw new HttpError(404, 'unknown_provider', 'Unknown payment provider'); - return factory({ live: livePayments() }); -} +export { WebhookSignatureError, isSuperprofileUrl, withCheckoutRef } from './superprofile.js'; -/** The configured ticket provider. Refuses mismatched live/test keys. */ export function ticketProvider() { - return providerByName(env('PAYMENT_PROVIDER', 'razorpay')); + return superprofileLink; } diff --git a/lib/payments/superprofile.js b/lib/payments/superprofile.js new file mode 100644 index 0000000..6486ddb --- /dev/null +++ b/lib/payments/superprofile.js @@ -0,0 +1,65 @@ +// lib/payments/superprofile.js - The one PaymentProvider: `superprofile_link`. +// +// Buyers pay on a SuperProfile-hosted page; this site never sees card or UPI details. So: +// createCheckout → returns the SuperProfile URL (with our ref attached), nothing is charged here +// verifyWebhook → shared-secret check (constant time) before anything is parsed or written +// refund → NOT_SUPPORTED_IN_APP: refunds are issued on SuperProfile, then arrive as a notification +import { env } from '../config.js'; +import { HttpError, safeEqual } from '../http.js'; + +export const SUPERPROFILE_SECRET_HEADER = 'x-webhook-secret'; +export const PROVIDER_NAME = 'superprofile_link'; + +/** https, no credentials, host is superprofile.bio or a subdomain of it. Rejects look-alike hosts. */ +export function isSuperprofileUrl(value) { + let u; + try { u = new URL(String(value)); } catch { return false; } + if (u.protocol !== 'https:' || u.username || u.password) return false; + if (u.port && u.port !== '443') return false; + const host = u.hostname.toLowerCase(); + return host === 'superprofile.bio' || host.endsWith('.superprofile.bio'); +} + +export class WebhookSignatureError extends Error { + constructor(msg = 'invalid webhook secret') { + super(msg); + this.code = 'WEBHOOK_SIGNATURE'; + } +} + +export function superprofileConfigured() { + return env('SUPERPROFILE_WEBHOOK_SECRET').length >= 24; +} + +/** The SuperProfile URL with our ref carried as query params (SuperProfile may echo them in notifications). */ +export function withCheckoutRef(url, { ref, slug }) { + if (!isSuperprofileUrl(url)) throw new HttpError(409, 'checkout_unavailable', 'Checkout is not available for this event'); + const u = new URL(url); + u.searchParams.set('ref', ref); + u.searchParams.set('utm_source', 'strangersmeetup'); + u.searchParams.set('utm_medium', 'site'); + u.searchParams.set('utm_campaign', slug); + u.searchParams.set('utm_content', ref); + return u.toString(); +} + +export const superprofileLink = { + name: PROVIDER_NAME, + isTest: false, + + async createCheckout({ url, ref, slug }) { + return { providerTxnId: ref, checkoutUrl: withCheckoutRef(url, { ref, slug }) }; + }, + + /** Throws WebhookSignatureError; call before parsing the body or touching the database. */ + verifyWebhook(headers) { + const expected = env('SUPERPROFILE_WEBHOOK_SECRET'); + const got = headers[SUPERPROFILE_SECRET_HEADER]; + if (expected.length < 24 || typeof got !== 'string' || !safeEqual(got, expected)) throw new WebhookSignatureError(); + return true; + }, + + async refund() { + throw new HttpError(501, 'NOT_SUPPORTED_IN_APP', 'Refunds for tickets are issued on SuperProfile. The booking updates when SuperProfile reports the refund.'); + } +}; diff --git a/lib/router.js b/lib/router.js index e12e3bc..6323fc7 100644 --- a/lib/router.js +++ b/lib/router.js @@ -34,6 +34,7 @@ const ROUTES = [ ['GET', '/signin', h('pages', 'memberPage')], ['GET', '/me', h('pages', 'memberPage')], ['GET', '/bookings/:id', h('pages', 'bookingPage')], + ['GET', '/checkout/:slug', h('checkout', 'checkoutRedirect')], ['GET', '/admin', h('admin', 'adminPage')], ['GET', '/admin/preview/:id', h('admin', 'adminPreview')], ['GET', '/organiser', h('organiser', 'dashboardPage')], @@ -63,7 +64,6 @@ const ROUTES = [ ['GET', '/api/me/bookings', h('bookings', 'myBookings')], ['GET', '/api/me/profile', h('profile', 'getProfile')], ['PATCH', '/api/me/profile', h('profile', 'updateProfile')], - ['POST', '/api/bookings', h('bookings', 'createBooking')], ['GET', '/api/bookings/:id', h('bookings', 'getBooking')], ['POST', '/api/membership/checkout', h('membership', 'membershipCheckout')], ['POST', '/api/events/:id/book', h('organiser', 'bookTicket')], @@ -86,8 +86,8 @@ const ROUTES = [ // Webhooks ['POST', '/api/telegram/webhook', h('telegram', 'telegramWebhook')], - ['POST', '/api/payments/webhook/:provider', h('payments', 'paymentWebhook')], - ['POST', '/api/membership/superprofile/webhook', h('membership', 'superprofileWebhook')], + ['POST', '/api/webhooks/superprofile', h('superprofile', 'superprofileWebhook')], + ['POST', '/api/membership/superprofile/webhook', h('superprofile', 'superprofileWebhook')], // Cron (Vercel Cron sends GET with Authorization: Bearer CRON_SECRET) ['GET', '/api/cron/:job', h('cron', 'runCron')], @@ -113,6 +113,8 @@ const ROUTES = [ ['GET', '/api/admin/events/:id/attendees', h('admin', 'attendees')], ['POST', '/api/admin/bookings/:id/check-in', h('admin', 'checkIn')], ['GET', '/api/admin/bookings', h('admin', 'listBookings')], + ['GET', '/api/admin/orders', h('admin', 'listOrders')], + ['POST', '/api/admin/orders/:id/link', h('admin', 'linkOrder')], ['POST', '/api/admin/payments/:id/refund', h('admin', 'refundPayment')], ['GET', '/api/admin/memberships', h('admin', 'listMemberships')], ['POST', '/api/admin/entitlements/:id/revoke', h('admin', 'revokeEntitlement')], diff --git a/lib/services/bookings.js b/lib/services/bookings.js index 5e5ac95..e7083c2 100644 --- a/lib/services/bookings.js +++ b/lib/services/bookings.js @@ -1,14 +1,8 @@ -// lib/services/bookings.js - Ticket booking state machine. The only paths to `confirmed` are -// applyTicketEvent() (verified webhook or server-side reconciliation) — never a client redirect. +// lib/services/bookings.js - Booking reads and seat counting. Paid bookings are created only by a +// matched SuperProfile notification (services/orders.js) — never by a client redirect; free tiers +// confirm in services/ticketing.js. import { db } from '../db.js'; -import { HttpError } from '../http.js'; -import { siteUrl } from '../config.js'; import { SEAT_HOLDING_SQL, formatPaise } from './events.js'; -import { enqueue } from './notifications.js'; -import { audit } from '../audit.js'; -import { logger } from '../logger.js'; - -export const HOLD_MINUTES = 15; export async function seatsTaken(c, eventId, excludeBookingId = null) { const { rows } = await c.query( @@ -25,190 +19,7 @@ export async function tierSeatsTaken(c, ticketTypeId, excludeBookingId = null) { return rows[0].n; } -/** - * Step 1 (one short transaction, event row locked): validate and place a 15-minute seat hold. - * Returns { booking, event, existing } where existing=true means an active booking was reused. - */ -async function placeHold(user, eventId) { - return db.tx(async (c) => { - const event = (await c.query('SELECT * FROM events WHERE id = $1 FOR UPDATE', [eventId])).rows[0]; - if (!event || event.status !== 'published') throw new HttpError(404, 'event_not_found', 'This event is not available'); - if (new Date(event.start_at) <= new Date()) throw new HttpError(409, 'event_started', 'This event has already started'); - if (event.source === 'external') throw new HttpError(409, 'external_event', 'This event is booked on the organiser’s own page'); - if (event.ticketing === 'tiers') throw new HttpError(409, 'choose_ticket', 'Choose a ticket type on the event page to book'); - if (event.price_status !== 'set' || !event.ticket_price_paise) throw new HttpError(409, 'price_tbd', 'Bookings open once the price is announced'); - if (!user.age_confirmed_21) throw new HttpError(403, 'age_required', 'Please confirm you are 21 or older'); - - const active = (await c.query( - `SELECT b.*, p.checkout_url FROM bookings b LEFT JOIN payments p ON p.id = b.payment_id - WHERE b.event_id = $1 AND b.user_id = $2 AND b.status IN ('pending_payment', 'confirmed', 'checked_in') FOR UPDATE OF b`, - [eventId, user.id])).rows[0]; - if (active) { - if (active.status !== 'pending_payment') return { booking: active, event, existing: true }; - if (new Date(active.hold_expires_at) > new Date() && active.checkout_url) return { booking: active, event, existing: true }; - // Stale or never-completed hold: release it and start fresh. - await c.query(`UPDATE bookings SET status = 'cancelled', cancelled_at = now(), cancel_reason = 'hold_replaced' WHERE id = $1`, [active.id]); - } - - if (await seatsTaken(c, eventId) >= event.capacity) throw new HttpError(409, 'sold_out', 'Sorry, this event is sold out'); - const booking = (await c.query( - `INSERT INTO bookings (event_id, user_id, status, amount_paise, hold_expires_at) - VALUES ($1, $2, 'pending_payment', $3, now() + make_interval(mins => $4)) RETURNING *`, - [eventId, user.id, event.ticket_price_paise, HOLD_MINUTES])).rows[0]; - return { booking, event, existing: false }; - }); -} - -/** - * Create (or resume) a booking and a hosted checkout. - * Returns { bookingId, status, checkoutUrl? }. - */ -export async function createBooking(user, eventId, provider) { - const { booking, event, existing } = await placeHold(user, eventId); - if (existing) { - return { bookingId: booking.id, status: booking.status, checkoutUrl: booking.status === 'pending_payment' ? booking.checkout_url : null }; - } - const checkoutUrl = await startCheckout(user.id, booking, event, provider); - return { bookingId: booking.id, status: 'pending_payment', checkoutUrl }; -} - -/** - * Create a hosted checkout for a booking already holding seats (status pending_payment) and record - * the `created` payment. On provider failure, `onFail` releases the hold (default: cancel). - * Provider-agnostic: any PaymentProvider from lib/payments works. Returns the checkout URL. - */ -export async function startCheckout(userId, booking, event, provider, { - onFail = (id) => db.query(`UPDATE bookings SET status = 'cancelled', cancelled_at = now(), cancel_reason = 'checkout_failed' WHERE id = $1`, [id]) -} = {}) { - let checkout; - try { - checkout = await provider.createCheckout({ - bookingId: booking.id, - userId, - amountPaise: booking.amount_paise, - title: `${event.title} — ${event.city}`, - expiresAt: booking.hold_expires_at, - returnUrl: `${siteUrl()}/bookings/${booking.id}` - }); - } catch (err) { - await onFail(booking.id); - if (err instanceof HttpError) throw err; - logger.error('checkout creation failed', err, { provider: provider.name }); - throw new HttpError(502, 'checkout_failed', 'We could not start checkout. Please try again in a moment.'); - } - await db.tx(async (c) => { - const pay = (await c.query( - `INSERT INTO payments (provider, provider_txn_id, purpose, user_id, booking_id, amount, currency, status, is_test, checkout_url, raw_ref) - VALUES ($1, $2, 'ticket', $3, $4, $5, 'INR', 'created', $6, $7, $8) RETURNING id`, - [provider.name, checkout.providerTxnId, userId, booking.id, booking.amount_paise, provider.isTest, checkout.checkoutUrl, - JSON.stringify({ txn: checkout.providerTxnId })])).rows[0]; - await c.query(`UPDATE bookings SET payment_id = $2, payment_mode = COALESCE(payment_mode, 'provider') WHERE id = $1`, [booking.id, pay.id]); - }); - return checkout.checkoutUrl; -} - -/** - * Apply a verified provider event. Idempotent per (provider, eventId) via webhook_events, and per - * payment via its status. Returns { result, notificationIds }. - */ -export async function applyTicketEvent(providerName, evt) { - return db.tx(async (c) => { - const ins = await c.query( - `INSERT INTO webhook_events (provider, event_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id`, [providerName, evt.eventId]); - if (!ins.rowCount) return { result: 'duplicate', notificationIds: [] }; - const whId = ins.rows[0].id; - const finish = async (result, notificationIds = []) => { - await c.query('UPDATE webhook_events SET processed_at = now(), result = $2 WHERE id = $1', [whId, result]); - return { result, notificationIds }; - }; - if (evt.type === 'ignored') return finish('ignored'); - - let payment; - if (evt.type === 'refunded' && !evt.providerTxnId) { - payment = (await c.query(`SELECT * FROM payments WHERE provider = $1 AND provider_payment_id = $2 FOR UPDATE`, [providerName, evt.providerPaymentId])).rows[0]; - } else if (evt.providerTxnId) { - payment = (await c.query(`SELECT * FROM payments WHERE provider = $1 AND provider_txn_id = $2 FOR UPDATE`, [providerName, evt.providerTxnId])).rows[0]; - } else if (evt.bookingId) { - payment = (await c.query(`SELECT * FROM payments WHERE provider = $1 AND booking_id = $2 AND purpose = 'ticket' ORDER BY created_at DESC LIMIT 1 FOR UPDATE`, [providerName, evt.bookingId])).rows[0]; - } - if (!payment || payment.purpose !== 'ticket') return finish('unknown_payment'); - - if (evt.type === 'failed') { - if (payment.status === 'created') { - await c.query(`UPDATE payments SET status = 'failed', failure_reason = $2 WHERE id = $1`, [payment.id, evt.reason || 'failed']); - } - return finish('failed_recorded'); // nothing granted; the hold simply expires - } - - if (evt.type === 'expired') { - if (payment.status === 'created') { - await c.query(`UPDATE payments SET status = 'failed', failure_reason = 'expired' WHERE id = $1`, [payment.id]); - await c.query(`UPDATE bookings SET status = 'cancelled', cancelled_at = now(), cancel_reason = 'checkout_expired' WHERE id = $1 AND status = 'pending_payment'`, [payment.booking_id]); - } - return finish('expired'); - } - - if (evt.type === 'refunded') { - if (payment.status !== 'refunded') { - await c.query(`UPDATE payments SET status = 'refunded', refunded_at = now() WHERE id = $1`, [payment.id]); - await c.query(`UPDATE bookings SET status = 'refunded', refunded_at = now() WHERE id = $1 AND status <> 'refunded'`, [payment.booking_id]); - await c.query(`UPDATE invite_links SET revoked_at = COALESCE(revoked_at, now()) WHERE booking_id = $1`, [payment.booking_id]); - } - return finish('refunded'); - } - - // evt.type === 'paid' - if (payment.status === 'paid' || payment.status === 'refunded') return finish('already_paid'); - // Fail closed: a signed "paid" event that omits or garbles the amount never confirms a booking. - if (evt.amountPaise === undefined || evt.amountPaise === null || !Number.isFinite(Number(evt.amountPaise)) - || Number(evt.amountPaise) !== Number(payment.amount)) { - logger.error('payment amount mismatch', null, { paymentId: payment.id }); - await c.query(`UPDATE payments SET failure_reason = 'amount_mismatch' WHERE id = $1`, [payment.id]); - return finish('amount_mismatch'); - } - if (evt.currency && evt.currency !== 'INR') return finish('currency_mismatch'); - await c.query(`UPDATE payments SET status = 'paid', paid_at = now(), provider_payment_id = COALESCE($2, provider_payment_id), failure_reason = NULL WHERE id = $1`, - [payment.id, evt.providerPaymentId || null]); - - // Lock order matches placeHold (events, then bookings) to avoid deadlocks. - const eventId = (await c.query('SELECT event_id FROM bookings WHERE id = $1', [payment.booking_id])).rows[0].event_id; - const event = (await c.query('SELECT * FROM events WHERE id = $1 FOR UPDATE', [eventId])).rows[0]; - const booking = (await c.query('SELECT * FROM bookings WHERE id = $1 FOR UPDATE', [payment.booking_id])).rows[0]; - if (['confirmed', 'checked_in'].includes(booking.status)) return finish('already_confirmed'); - - const holdActive = booking.status === 'pending_payment' && new Date(booking.hold_expires_at) > new Date(); - const otherActive = (await c.query( - `SELECT 1 FROM bookings WHERE event_id = $1 AND user_id = $2 AND id <> $3 AND status IN ('confirmed', 'checked_in', 'pending_payment') - AND (status <> 'pending_payment' OR hold_expires_at > now())`, [booking.event_id, booking.user_id, booking.id])).rows[0]; - const qty = booking.quantity || 1; - let capacityOk = holdActive || (await seatsTaken(c, booking.event_id, booking.id)) + qty <= event.capacity; - if (capacityOk && !holdActive && booking.ticket_type_id) { - const tier = (await c.query('SELECT quantity FROM ticket_types WHERE id = $1', [booking.ticket_type_id])).rows[0]; - capacityOk = Boolean(tier) && (await tierSeatsTaken(c, booking.ticket_type_id, booking.id)) + qty <= tier.quantity; - } - const eventOk = event.status === 'published'; - - if (capacityOk && eventOk && !otherActive) { - if (booking.status !== 'pending_payment') { - // Hold had been released; release any other stale pending row for the unique index first. - await c.query(`UPDATE bookings SET status = 'cancelled', cancelled_at = now(), cancel_reason = 'hold_replaced' - WHERE event_id = $1 AND user_id = $2 AND id <> $3 AND status = 'pending_payment'`, [booking.event_id, booking.user_id, booking.id]); - } - await c.query(`UPDATE bookings SET status = 'confirmed', confirmed_at = now(), cancelled_at = NULL, cancel_reason = NULL WHERE id = $1`, [booking.id]); - const nid = await enqueue(c, { kind: 'booking_confirmed', userId: booking.user_id, bookingId: booking.id, dedupeKey: `bk:${booking.id}:confirmed` }); - await audit(`webhook:${providerName}`, 'booking.confirmed', booking.id, { paymentId: payment.id }, c); - return finish('confirmed', [nid]); - } - - // Paid but cannot honour (late payment and event full / cancelled / duplicate booking): never keep the money silently. - await c.query(`UPDATE bookings SET status = 'cancelled', cancelled_at = COALESCE(cancelled_at, now()), cancel_reason = 'paid_but_unavailable_refund_due' WHERE id = $1`, [booking.id]); - const nid = await enqueue(c, { kind: 'late_payment_refund_needed', userId: booking.user_id, bookingId: booking.id, dedupeKey: `bk:${booking.id}:refund_needed` }); - await audit(`webhook:${providerName}`, 'booking.refund_needed', booking.id, { paymentId: payment.id, reason: !eventOk ? 'event_not_published' : otherActive ? 'duplicate_booking' : 'capacity' }, c); - return finish('refund_needed', [nid]); - }); -} - -/** Cron: release expired holds (capacity already ignores them; this tidies status). */ +/** Cron: release expired seat holds left by approval requests (capacity already ignores them; this tidies status). */ export async function releaseExpiredHolds() { const r = await db.query( `UPDATE bookings SET status = 'cancelled', cancelled_at = now(), cancel_reason = 'hold_expired' @@ -216,32 +27,6 @@ export async function releaseExpiredHolds() { return r.rowCount; } -/** Cron: ask the provider about payments still 'created' (missed webhooks) and apply the truth. */ -export async function reconcilePayments(provider, { limit = 50 } = {}) { - const { rows } = await db.query( - `SELECT id, provider_txn_id FROM payments - WHERE provider = $1 AND purpose = 'ticket' AND status = 'created' - AND created_at < now() - interval '10 minutes' AND created_at > now() - interval '7 days' - ORDER BY created_at LIMIT $2`, [provider.name, limit]); - const out = { checked: rows.length, applied: 0, notificationIds: [] }; - for (const p of rows) { - try { - const s = await provider.fetchStatus(p.provider_txn_id); - if (s.status === 'created') continue; - const type = s.status === 'paid' ? 'paid' : s.status === 'expired' ? 'expired' : 'failed'; - const r = await applyTicketEvent(provider.name, { - eventId: `reconcile:${p.provider_txn_id}:${type}`, type, providerTxnId: p.provider_txn_id, - providerPaymentId: s.providerPaymentId, amountPaise: s.amountPaise, reason: 'reconciled' - }); - if (!['duplicate'].includes(r.result)) out.applied++; - out.notificationIds.push(...r.notificationIds); - } catch (err) { - logger.warn('reconcile failed', { paymentId: p.id, reason: String(err.message).slice(0, 120) }); - } - } - return out; -} - /** Bookings for the signed-in user. */ export async function listUserBookings(userId) { const { rows } = await db.query( @@ -260,10 +45,10 @@ export async function getUserBooking(userId, bookingId) { const b = await db.one( `SELECT b.*, e.title, e.city, e.start_at, e.timezone, e.venue_private, e.venue_privacy, e.area_public, e.maps_url, e.pickup_points, e.contact_name, e.contact_phone, t.name AS ticket_name, t.kind AS ticket_kind, - p.checkout_url, p.is_test, p.status AS payment_status + eo.status AS payment_status FROM bookings b JOIN events e ON e.id = b.event_id LEFT JOIN ticket_types t ON t.id = b.ticket_type_id - LEFT JOIN payments p ON p.id = b.payment_id + LEFT JOIN external_orders eo ON eo.matched_booking_id = b.id WHERE b.id = $1 AND b.user_id = $2`, [bookingId, userId]); if (!b) return null; const confirmed = ['confirmed', 'checked_in'].includes(b.status); @@ -282,9 +67,6 @@ export async function getUserBooking(userId, bookingId) { paymentStatus: b.payment_status || (b.payment_mode === 'free' ? 'not_required' : null), pickup: pickup ? { name: pickup.name, location: pickup.location || '', time: pickup.time || null } : null, contact: phone ? { name: b.contact_name, phone, whatsappUrl: `https://wa.me/${phone.replace(/^\+/, '')}` } : null, - holdActive: b.status === 'pending_payment' && new Date(b.hold_expires_at) > new Date(), - checkoutUrl: b.status === 'pending_payment' ? b.checkout_url : null, - isTest: b.payment_id ? b.is_test !== false : false, invite: invite ? { link: invite.link, expiresAt: invite.expires_at } : null }; } diff --git a/lib/services/checkout.js b/lib/services/checkout.js new file mode 100644 index 0000000..2fca21f --- /dev/null +++ b/lib/services/checkout.js @@ -0,0 +1,65 @@ +// lib/services/checkout.js - Checkout intents: carry a (possibly anonymous) buyer across to SuperProfile. +// +// "Book Tickets" links to /checkout/:slug. We record an intent with a short random ref, attach the ref +// to the SuperProfile URL, and redirect. When SuperProfile later reports the order with that ref we +// know who bought. Signed-out visitors are redirected too: paying is never gated on signing in, and +// their order is claimed in the bot afterwards. +import crypto from 'node:crypto'; +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { ticketProvider } from '../payments/index.js'; +import { checkoutForEvent } from '../payments/availability.js'; + +export const INTENT_TTL_HOURS = 72; +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/** 8 chars of Crockford base32 = 40 random bits. */ +export function newRef() { + const bytes = crypto.randomBytes(5); + let n = 0n; + for (const b of bytes) n = (n << 8n) | BigInt(b); + let out = ''; + for (let i = 7; i >= 0; i--) out += CROCKFORD[Number((n >> BigInt(i * 5)) & 31n)]; + return out; +} + +/** + * Record an intent for a published, checkout-enabled event and return where to send the buyer. + * Returns null when this event cannot be bought online (the caller sends them back to the event page). + */ +export async function createCheckoutIntent({ slug, user = null, ticketTypeId = null, qty = 1 }) { + const event = await db.one( + `SELECT id, slug, ticketing, checkout_mode, superprofile_url FROM events + WHERE slug = $1 AND status = 'published' AND COALESCE(end_at, start_at) > now()`, [slug]); + if (!event) throw new HttpError(404, 'event_not_found', 'This event is not available'); + if (!checkoutForEvent(event).available) return null; + + const n = Math.min(Math.max(parseInt(qty, 10) || 1, 1), 20); + let ticket = null; + if (ticketTypeId) { + if (!/^[0-9a-f-]{36}$/i.test(ticketTypeId)) throw new HttpError(404, 'ticket_not_found', 'That ticket type is not available'); + ticket = await db.one(`SELECT id, kind, max_per_booking FROM ticket_types WHERE id = $1 AND event_id = $2 AND active`, [ticketTypeId, event.id]); + if (!ticket || ticket.kind !== 'paid') throw new HttpError(404, 'ticket_not_found', 'That ticket type is not available'); + if (n > ticket.max_per_booking) throw new HttpError(400, 'validation', `Up to ${ticket.max_per_booking} per booking`); + } + + let intent = null; + for (let attempt = 0; attempt < 4 && !intent; attempt++) { + const ref = newRef(); + const r = await db.query( + `INSERT INTO checkout_intents (ref, user_id, event_id, ticket_type_id, qty, expires_at) + VALUES ($1, $2, $3, $4, $5, now() + make_interval(hours => $6)) ON CONFLICT (ref) DO NOTHING RETURNING ref`, + [ref, user?.id || null, event.id, ticket?.id || null, n, INTENT_TTL_HOURS]); + intent = r.rows[0] || null; + } + if (!intent) throw new HttpError(503, 'try_again', 'Please try again in a moment'); + + await db.query(`INSERT INTO analytics_events (name, event_id) VALUES ('checkout_click', $1)`, [event.id]); + const { checkoutUrl } = await ticketProvider().createCheckout({ url: event.superprofile_url, ref: intent.ref, slug: event.slug }); + return { url: checkoutUrl, ref: intent.ref }; +} + +export async function pruneCheckoutIntents() { + const r = await db.query(`DELETE FROM checkout_intents WHERE expires_at < now() - interval '30 days'`); + return r.rowCount; +} diff --git a/lib/services/claims.js b/lib/services/claims.js new file mode 100644 index 0000000..4a56f27 --- /dev/null +++ b/lib/services/claims.js @@ -0,0 +1,65 @@ +// lib/services/claims.js - Telegram "claim your order" flow for buyers SuperProfile could not tie to a user. +// +// The buyer opens the bot (t.me/?start=claim), sends their SuperProfile order ID (and optionally +// the email they paid with). One order can be claimed once. Failures all look alike so the bot is no +// oracle for which order IDs exist, and claims are limited to 5 an hour per Telegram user. A short or +// purely numeric order ID (guessable) additionally requires the buyer's email to match. +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { rateLimit } from '../ratelimit.js'; +import { emailHash } from './superprofile.js'; +import { confirmOrderBooking } from './orders.js'; + +export const CLAIM_LIMIT_PER_HOUR = 5; +export const CLAIM_PROMPT_MINUTES = 10; +export const CLAIM_FAILED_MESSAGE = 'We could not claim that order. Check the order ID (and the email you paid with), or contact support.'; + +const failed = () => new HttpError(404, 'claim_failed', CLAIM_FAILED_MESSAGE); + +export function normaliseOrderId(raw) { + const id = String(raw || '').trim(); + return /^[A-Za-z0-9_-]{4,100}$/.test(id) ? id : null; +} + +/** An order ID someone could plausibly guess by counting. */ +export const isGuessableOrderId = (id) => /^\d{1,10}$/.test(id); + +export async function startClaimPrompt(userId) { + await db.query(`UPDATE users SET claim_prompt_until = now() + make_interval(mins => $2) WHERE id = $1`, [userId, CLAIM_PROMPT_MINUTES]); +} + +export async function clearClaimPrompt(userId) { + await db.query('UPDATE users SET claim_prompt_until = NULL WHERE id = $1', [userId]); +} + +export async function isClaimPromptOpen(userId) { + const r = await db.one('SELECT 1 AS x FROM users WHERE id = $1 AND claim_prompt_until > now()', [userId]); + return Boolean(r); +} + +/** Returns { bookingId, notificationIds, eventTitle }; throws HttpError (429 rate limit, 403 age, 404 generic). */ +export async function claimOrder(user, rawOrderId, rawEmail = '') { + await rateLimit(`claim:${user.telegram_user_id}`, CLAIM_LIMIT_PER_HOUR, 3600); + if (!user.age_confirmed_21) throw new HttpError(403, 'age_required', 'Please confirm you are 21 or older on our website first, then claim your order here.'); + const orderId = normaliseOrderId(rawOrderId); + if (!orderId) throw failed(); + const email = String(rawEmail || '').trim(); + const givenHash = email ? emailHash(email) : null; + if (email && !givenHash) throw failed(); + + return db.tx(async (c) => { + const order = (await c.query('SELECT * FROM external_orders WHERE lower(order_id) = lower($1) FOR UPDATE', [orderId])).rows[0]; + if (!order || order.status !== 'paid' || order.matched_booking_id || order.match_state !== 'unmatched' || !order.event_id) throw failed(); + if (isGuessableOrderId(order.order_id) && (!order.buyer_email_hash || givenHash !== order.buyer_email_hash)) throw failed(); + if (givenHash && order.buyer_email_hash && givenHash !== order.buyer_email_hash) throw failed(); + + const event = (await c.query('SELECT * FROM events WHERE id = $1 FOR UPDATE', [order.event_id])).rows[0]; + if (!event || event.status !== 'published') throw failed(); + const r = await confirmOrderBooking(c, { order, event, userId: user.id, actor: `user:${user.id}` }); + if (r.conflict) throw new HttpError(409, 'duplicate_booking', 'You already have a booking for this event. Send /mybookings to see it, or contact support if you bought extra tickets.'); + // The buyer proved the email by quoting it: later orders from it match without a claim. + if (givenHash && order.buyer_email_hash === givenHash) await c.query('UPDATE users SET email_hash = $2 WHERE id = $1', [user.id, givenHash]); + await c.query('UPDATE users SET claim_prompt_until = NULL WHERE id = $1', [user.id]); + return { bookingId: r.bookingId, notificationIds: [r.notificationId], eventTitle: event.title }; + }); +} diff --git a/lib/services/events.js b/lib/services/events.js index 3ea2911..9d94849 100644 --- a/lib/services/events.js +++ b/lib/services/events.js @@ -1,7 +1,7 @@ // lib/services/events.js - Event queries and the public (privacy-safe) representation. import { db } from '../db.js'; import { socialProofMin } from '../config.js'; -import { ticketCheckoutStatus } from '../payments/availability.js'; +import { checkoutForEvent } from '../payments/availability.js'; export const CATEGORY_LABELS = { meetup: 'Meetup', @@ -50,15 +50,19 @@ function list(v) { } /** Public view of one ticket tier. `sold` = confirmed seats, `held` = live checkout holds. */ -export function toPublicTier(t, { checkout, showCounts = false } = {}) { - const used = Number(t.sold || 0) + Number(t.held || 0); - const remaining = Math.max(0, t.quantity - used); +export function toPublicTier(t, { checkout, showCounts = false, requiresApproval = false } = {}) { const free = t.kind === 'free'; + // A paid ticket is sold by SuperProfile, which owns its stock; our own booking counts say nothing about it. + const sellsOnSuperprofile = !free && Boolean(checkout?.available); + const used = sellsOnSuperprofile ? 0 : Number(t.sold || 0) + Number(t.held || 0); + const remaining = Math.max(0, t.quantity - used); let unavailable = null; if (remaining <= 0) { unavailable = Number(t.held || 0) > 0 ? 'Temporarily full — may reopen if a held spot is released' : 'Sold out'; } else if (!free && !checkout?.available) { unavailable = 'Online payment not available yet'; + } else if (!free && requiresApproval) { + unavailable = 'Paid tickets can’t be approval-only yet'; } return { id: t.id, @@ -68,7 +72,7 @@ export function toPublicTier(t, { checkout, showCounts = false } = {}) { priceLabel: free ? 'Free' : formatPaise(t.price_paise), quantity: t.quantity, remaining, - remainingLabel: showCounts ? `${remaining} of ${t.quantity} left` : null, + remainingLabel: showCounts && !sellsOnSuperprofile ? `${remaining} of ${t.quantity} left` : null, maxPerBooking: t.max_per_booking, validOn: t.valid_on ? String(t.valid_on).slice(0, 10) : null, eligibility: t.eligibility || null, @@ -91,13 +95,15 @@ function tiersPriceLabel(tiers) { * Map a DB row to what the public site may show. Never includes venue_private unless the * event's venue is public. `seats_taken` is an internal number used only to decide "Sold out". */ -export function toPublicEvent(row, { checkout = ticketCheckoutStatus() } = {}) { +export function toPublicEvent(row, { checkout = checkoutForEvent(row) } = {}) { const external = row.source === 'external'; const tiered = row.ticketing === 'tiers'; const features = row.features && typeof row.features === 'object' ? row.features : {}; - const tiers = tiered ? list(row.tiers).map((t) => toPublicTier(t, { checkout, showCounts: Boolean(features.balance) })) : []; + const tiers = tiered ? list(row.tiers).map((t) => toPublicTier(t, { checkout, showCounts: Boolean(features.balance), requiresApproval: Boolean(row.requires_approval) })) : []; // External listings: we know nothing about seats or price, so we never claim either. - const soldOut = !external && row.capacity > 0 && (Number(row.seats_taken || 0) >= row.capacity || + // SuperProfile sells its own stock, so a linked event is never "sold out" from our booking counts alone. + const sp = !external && checkout.available; + const soldOut = !external && !(sp && !tiered) && row.capacity > 0 && (Number(row.seats_taken || 0) >= row.capacity || (tiered && tiers.length > 0 && tiers.every((t) => t.remaining <= 0))); const priceSet = tiered ? tiers.length > 0 : row.price_status === 'set' && row.ticket_price_paise > 0; const upcoming = new Date(row.start_at).getTime() > Date.now(); @@ -107,8 +113,9 @@ export function toPublicEvent(row, { checkout = ticketCheckoutStatus() } = {}) { else if (!upcoming) state = 'past'; else if (external) state = 'external'; else if (soldOut) state = 'sold_out'; - else if (!priceSet) state = 'price_tbd'; + else if (!priceSet && !sp) state = 'price_tbd'; else if (tiered && !tiers.some((t) => t.available)) state = 'payment_unavailable'; + else if (!tiered && !sp) state = 'payment_unavailable'; else state = 'open'; const STATE_LABEL = { cancelled: 'Cancelled', @@ -138,7 +145,7 @@ export function toPublicEvent(row, { checkout = ticketCheckoutStatus() } = {}) { endAt: row.end_at ? new Date(row.end_at).toISOString() : null, timezone: row.timezone, capacity: row.capacity, - priceLabel: external ? 'Price set by organiser' : tiered ? tiersPriceLabel(tiers) : priceSet ? formatPaise(row.ticket_price_paise) : 'Price to be announced', + priceLabel: external ? 'Price set by organiser' : tiered ? tiersPriceLabel(tiers) : priceSet ? formatPaise(row.ticket_price_paise) : sp ? 'Price shown at checkout' : 'Price to be announced', pricePaise: !tiered && priceSet ? row.ticket_price_paise : null, ticketing: tiered ? 'tiers' : 'single', tiers, @@ -151,6 +158,8 @@ export function toPublicEvent(row, { checkout = ticketCheckoutStatus() } = {}) { balance: Boolean(features.balance), organiserManaged: Boolean(row.organiser_user_id), checkoutAvailable: Boolean(checkout?.available), + checkoutMode: sp ? 'superprofile' : 'none', + checkoutPath: sp ? `/checkout/${row.slug}` : null, inclusions: arr(row.inclusions), exclusions: arr(row.exclusions), refundPolicy: row.refund_policy || '', diff --git a/lib/services/insights.js b/lib/services/insights.js index 1b3d69d..71e4b0e 100644 --- a/lib/services/insights.js +++ b/lib/services/insights.js @@ -219,7 +219,8 @@ async function membership(since) { `SELECT count(*) FILTER (WHERE event_type = 'purchase')::int AS purchases, count(*) FILTER (WHERE event_type = 'purchase_abandoned')::int AS abandoned, count(*) FILTER (WHERE event_type = 'purchase' AND email_hash IS NULL)::int AS no_email, - (SELECT count(DISTINCT product_ref)::int FROM superprofile_events WHERE tier_id IS NULL AND product_ref IS NOT NULL) AS unmapped + (SELECT count(DISTINCT product_ref)::int FROM superprofile_events WHERE tier_id IS NULL AND product_ref IS NOT NULL) + + (SELECT count(DISTINCT product_id)::int FROM external_orders WHERE event_id IS NULL AND product_id IS NOT NULL) AS unmapped FROM superprofile_events WHERE COALESCE(occurred_at, received_at) >= $1`, [since]); const note = anySp.n ? undefined : 'Connected, but no SuperProfile events have been received yet.'; superprofile = { @@ -228,7 +229,7 @@ async function membership(since) { renewalsInWindow: ok(Math.max(0, e.purchases - m.new_in_window)), expiredInWindow: ok(m.expired_in_window), abandonedInWindow: ok(e.abandoned), - unmappedProducts: ok(e.unmapped, { note: 'Products not linked to a tier (set the product reference on the tier).' }) + unmappedProducts: ok(e.unmapped, { note: 'Products not linked to a tier or an event (set the product reference on the tier, or the product id on the event).' }) }; } return { stars, superprofile, note: 'Stars and SuperProfile members are counted separately; one person could appear in both.' }; diff --git a/lib/services/notifications.js b/lib/services/notifications.js index f4778d5..ba8f174 100644 --- a/lib/services/notifications.js +++ b/lib/services/notifications.js @@ -66,7 +66,7 @@ const HANDLERS = { `You're booked! ${escHtml(b.title)}`, `${escHtml(fmt(b.start_at))} · ${escHtml(b.area_public ? `${b.area_public}, ` : '')}${escHtml(b.city)}`, venue ? `Venue: ${escHtml(venue)}` : hostEvent ? 'The exact venue is on your booking page.' : 'The exact venue is shared inside the private group.', - b.payment_mode === 'free' ? 'Free ticket — no payment needed.' : b.amount_paise ? `Paid: ${formatPaise(b.amount_paise)}${livePayments() ? '' : ' (test mode)'}` : null, + b.payment_mode === 'free' ? 'Free ticket — no payment needed.' : b.amount_paise ? `Paid: ${formatPaise(b.amount_paise)}${b.payment_mode === 'external' ? ' on SuperProfile' : livePayments() ? '' : ' (test mode)'}` : null, `Booking ref: ${b.id.slice(0, 8)}`, hostEvent ? null : invite ? 'Your one-time group invite is below. It works once and expires in 24 hours.' : 'Your private group invite will follow shortly.' ].filter(Boolean); @@ -133,17 +133,23 @@ const HANDLERS = { if (b) await send(b.telegram_user_id, `We're sorry — ${escHtml(b.title)} on ${escHtml(fmt(b.start_at))} has been cancelled. You will receive a full refund to your original payment method.`); }, - async late_payment_refund_needed(n) { - // Payment captured after the hold expired and the event is full: alert finance admins. - const b = await db.one( - `SELECT b.id, e.title FROM bookings b JOIN events e ON e.id = b.event_id WHERE b.id = $1`, [n.booking_id]); + async order_alert(n) { + // dedupe_key = oa::. Order details stay in the admin Orders tab. + const [, orderRow, reason] = String(n.dedupe_key).split(':'); + const o = await db.one('SELECT order_id, qty FROM external_orders WHERE id = $1', [orderRow]); const ids = await adminChatIds(); if (!ids.length) throw new Error('NOT CONFIGURED: no admin Telegram ids'); + const WHY = { + unknown_product: 'its SuperProfile product is not linked to any event', + amount_mismatch: 'the amount does not match the configured price', + currency_mismatch: 'the currency is not INR', + event_not_published: 'the event is not published', + duplicate_booking: 'the buyer already has a booking for this event', + refund_after_check_in: 'it was refunded after the guest checked in' + }; for (const id of ids) { - await send(id, `⚠️ Refund needed: payment received after hold expired and ${escHtml(b?.title || 'event')} is full. Booking ${escHtml(n.booking_id)}. Open /admin → Bookings.`); + await send(id, `⚠️ SuperProfile order ${escHtml(o?.order_id || '')} needs review: ${escHtml(WHY[reason] || reason)}. Nothing was confirmed automatically. Open /admin → Orders.`); } - const u = await db.one('SELECT telegram_user_id FROM users WHERE id = $1', [n.user_id]); - if (u) await send(u.telegram_user_id, `Sorry — ${escHtml(b?.title || 'the event')} filled up before your payment arrived. You will receive a full refund.`); }, async booking_request(n) { @@ -160,14 +166,6 @@ const HANDLERS = { if (b) await send(b.telegram_user_id, `The host couldn't accept your request for ${escHtml(b.title)} this time. You have not been charged.`); }, - async booking_approved_pay(n) { - const b = await db.one( - `SELECT b.id, b.hold_expires_at, e.title, u.telegram_user_id FROM bookings b JOIN events e ON e.id = b.event_id JOIN users u ON u.id = b.user_id - WHERE b.id = $1 AND b.status = 'pending_payment'`, [n.booking_id]); - if (b) await send(b.telegram_user_id, `Good news — the host approved your request for ${escHtml(b.title)}. Complete payment by ${escHtml(fmt(b.hold_expires_at))} to confirm your spot.`, - [[{ text: 'Complete booking', url: `${siteUrl()}/bookings/${b.id}` }]]); - }, - async event_announcement(n) { const e = await db.one(`SELECT slug, title, city, start_at, category FROM events WHERE id = $1 AND status = 'published'`, [n.event_id]); const u = await db.one('SELECT telegram_user_id, alerts_opt_in FROM users WHERE id = $1', [n.user_id]); diff --git a/lib/services/orders.js b/lib/services/orders.js new file mode 100644 index 0000000..e3c29d7 --- /dev/null +++ b/lib/services/orders.js @@ -0,0 +1,269 @@ +// lib/services/orders.js - SuperProfile ticket orders (external_orders) and the bookings they confirm. +// +// A booking is confirmed only from a SuperProfile "purchase" notification (via Zapier/Pabbly → +// /api/webhooks/superprofile) that we can tie to a person: by the checkout ref we attached on the +// way out, or by a previously verified buyer email. Everything else is stored as `unmatched` and +// waits for the buyer to /claim it in the bot or for an admin to link it. Nothing is ever dropped. +// Fails closed: an amount we cannot reconcile with the configured price is stored and flagged, and +// confirms nothing. +import { db } from '../db.js'; +import { HttpError } from '../http.js'; +import { audit } from '../audit.js'; +import { enqueue } from './notifications.js'; +import { emailHash, phoneHash, rupeesToPaise } from './superprofile.js'; +import { removeFromChat } from './invites.js'; +import { recordIntegrationError } from './insights.js'; +import { logger } from '../logger.js'; + +/** Normalise a checkout ref coming back from SuperProfile (case and stray characters vary). */ +export function normaliseRef(v) { + const r = String(v || '').toUpperCase().replace(/[^0-9A-Z]/g, ''); + return r.length === 8 ? r : null; +} + +function parseQty(v) { + const n = Number(v); + return Number.isInteger(n) && n >= 1 && n <= 20 ? n : null; +} + +/** + * Amounts (paise) the order may legitimately have, or null when no price is configured to check + * against. A tiered event accepts any paid tier × quantity (the intent narrows it to one tier). + */ +export function expectedAmounts(event, tiers, intent, qty) { + if (event.ticketing === 'tiers') { + const paid = tiers.filter((t) => t.kind === 'paid' && (!intent?.ticket_type_id || t.id === intent.ticket_type_id)); + if (!paid.length) return null; + return new Set(paid.map((t) => t.price_paise * qty)); + } + if (event.price_status === 'set' && event.ticket_price_paise) return new Set([event.ticket_price_paise * qty]); + return null; +} + +/** Quantity from the payload, else inferred from amount ÷ unit price, else 1. */ +export function inferQty(event, tiers, intent, amountPaise, given) { + const q = parseQty(given); + if (q) return q; + const units = event.ticketing === 'tiers' + ? tiers.filter((t) => t.kind === 'paid' && (!intent?.ticket_type_id || t.id === intent.ticket_type_id)).map((t) => t.price_paise) + : (event.price_status === 'set' && event.ticket_price_paise ? [event.ticket_price_paise] : []); + if (amountPaise && units.length === 1 && amountPaise % units[0] === 0) return parseQty(amountPaise / units[0]) || 1; + return intent?.qty || 1; +} + +/** + * Create the confirmed booking for a paid order and link it. `c` is a transaction client with the + * order row already locked. Returns { bookingId, notificationId } or { conflict: 'duplicate_booking' }. + */ +export async function confirmOrderBooking(c, { order, event, userId, ticketTypeId = null, actor }) { + const live = (await c.query( + `SELECT id FROM bookings WHERE event_id = $1 AND user_id = $2 AND status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in') LIMIT 1`, + [event.id, userId])).rows[0]; + if (live) return { conflict: 'duplicate_booking' }; + const b = (await c.query( + `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, status, amount_paise, payment_mode, source, confirmed_at) + VALUES ($1, $2, $3, $4, 'confirmed', $5, 'external', 'superprofile', now()) RETURNING id`, + [event.id, userId, ticketTypeId, order.qty, order.amount_paise])).rows[0]; + await c.query(`UPDATE external_orders SET matched_booking_id = $2, match_state = 'matched', flag_reason = NULL WHERE id = $1`, [order.id, b.id]); + const nid = await enqueue(c, { kind: 'booking_confirmed', userId, bookingId: b.id, dedupeKey: `bk:${b.id}:confirmed` }); + await audit(actor, 'booking.confirmed_external', b.id, { orderRow: order.id, qty: order.qty }, c); + return { bookingId: b.id, notificationId: nid }; +} + +async function alertNotification(c, orderRowId, reason) { + return enqueue(c, { kind: 'order_alert', dedupeKey: `oa:${orderRowId}:${reason}` }); +} + +/** Apply a "purchase" notification. Idempotent on order_id. */ +async function applyPurchase(b) { + const orderId = b.id; + const productKeys = [b.product_id, b.product].filter(Boolean).map((s) => s.toLowerCase()); + const productId = b.product_id || b.product || null; + const email = b.email ? emailHash(b.email) : null; + const phone = b.phone ? phoneHash(b.phone) : null; + const amount = rupeesToPaise(b.amount); + const currency = (b.currency || 'INR').toUpperCase().slice(0, 8); + const ref = normaliseRef(b.ref); + + const r = await db.tx(async (c) => { + const event = productKeys.length + ? (await c.query('SELECT * FROM events WHERE lower(superprofile_product_id) = ANY($1::text[]) FOR UPDATE', [productKeys])).rows[0] + : null; + const tiers = event?.ticketing === 'tiers' + ? (await c.query('SELECT id, kind, price_paise FROM ticket_types WHERE event_id = $1 AND active', [event.id])).rows : []; + const intent = ref + ? (await c.query('SELECT * FROM checkout_intents WHERE ref = $1 AND expires_at > now()', [ref])).rows[0] : null; + const intentForEvent = intent && event && intent.event_id === event.id ? intent : null; + const qty = event ? inferQty(event, tiers, intentForEvent, amount, b.qty) : (parseQty(b.qty) || 1); + + const ins = await c.query( + `INSERT INTO external_orders (order_id, product_id, event_id, amount_paise, currency, qty, status, buyer_email_hash, buyer_phone_hash, ref, raw_minimal) + VALUES ($1,$2,$3,$4,$5,$6,'paid',$7,$8,$9,$10) ON CONFLICT (order_id) DO NOTHING RETURNING *`, + [orderId, productId, event?.id || null, amount, currency, qty, email, phone, ref, + JSON.stringify({ occurred_at: b.occurred_at || null, intent: Boolean(intentForEvent) })]); + if (!ins.rowCount) return { result: 'duplicate', notificationIds: [] }; + const order = ins.rows[0]; + + const flag = async (reason, result) => { + await c.query(`UPDATE external_orders SET match_state = 'flagged', flag_reason = $2 WHERE id = $1`, [order.id, reason]); + const nid = await alertNotification(c, order.id, reason); + await audit('webhook:superprofile', 'order.flagged', order.id, { reason }, c); + return { result, notificationIds: [nid], alert: reason, orderRow: order.id }; + }; + + if (!event) { + const nid = await alertNotification(c, order.id, 'unknown_product'); + await c.query(`UPDATE external_orders SET flag_reason = 'unknown_product' WHERE id = $1`, [order.id]); + return { result: 'unmatched', notificationIds: [nid], alert: 'unknown_product', orderRow: order.id }; + } + if (currency !== 'INR') return flag('currency_mismatch', 'flagged'); + const expected = expectedAmounts(event, tiers, intentForEvent, qty); + if (expected && (amount === null || !expected.has(amount))) return flag('amount_mismatch', 'flagged'); + if (event.status !== 'published') return flag('event_not_published', 'flagged'); + + // Who bought? The ref we attached (signed-in click), else a buyer email a user verified earlier. + let userId = intentForEvent?.user_id || null; + if (userId) { + const u = (await c.query(`SELECT id FROM users WHERE id = $1 AND status = 'active'`, [userId])).rows[0]; + userId = u?.id || null; + } + if (!userId && email) { + const u = (await c.query(`SELECT id FROM users WHERE email_hash = $1 AND status = 'active' ORDER BY updated_at DESC LIMIT 1`, [email])).rows[0]; + userId = u?.id || null; + } + if (!userId) { + await audit('webhook:superprofile', 'order.unmatched', order.id, { eventId: event.id }, c); + return { result: 'unmatched', notificationIds: [], orderRow: order.id }; + } + const confirmed = await confirmOrderBooking(c, { order, event, userId, ticketTypeId: intentForEvent?.ticket_type_id || null, actor: 'webhook:superprofile' }); + if (confirmed.conflict) return flag(confirmed.conflict, 'flagged'); + return { result: 'confirmed', notificationIds: [confirmed.notificationId], bookingId: confirmed.bookingId, orderRow: order.id }; + }); + return r; +} + +/** Apply a "refund" or "cancelled" notification to an order and its booking. Idempotent. */ +async function applyReversal(b) { + const status = b.event === 'refund' ? 'refunded' : 'cancelled'; + const productKeys = [b.product_id, b.product].filter(Boolean).map((s) => s.toLowerCase()); + const effects = { removeFromChat: null }; + + const r = await db.tx(async (c) => { + const order = (await c.query('SELECT * FROM external_orders WHERE order_id = $1 FOR UPDATE', [b.id])).rows[0]; + if (!order) { + // Refund seen before the purchase (or the purchase never reached us): keep the fact; a late purchase becomes a duplicate. + const event = productKeys.length + ? (await c.query('SELECT id FROM events WHERE lower(superprofile_product_id) = ANY($1::text[])', [productKeys])).rows[0] : null; + await c.query( + `INSERT INTO external_orders (order_id, product_id, event_id, amount_paise, currency, status, raw_minimal) + VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (order_id) DO NOTHING`, + [b.id, b.product_id || b.product || null, event?.id || null, rupeesToPaise(b.amount), (b.currency || 'INR').toUpperCase().slice(0, 8), status, + JSON.stringify({ occurred_at: b.occurred_at || null, before_purchase: true })]); + return { result: `${status}_before_purchase`, notificationIds: [] }; + } + if (order.status !== 'paid') return { result: 'duplicate', notificationIds: [] }; + await c.query('UPDATE external_orders SET status = $2 WHERE id = $1', [order.id, status]); + if (!order.matched_booking_id) { + await audit('webhook:superprofile', `order.${status}`, order.id, { matched: false }, c); + return { result: status, notificationIds: [] }; + } + const booking = (await c.query('SELECT * FROM bookings WHERE id = $1 FOR UPDATE', [order.matched_booking_id])).rows[0]; + if (booking.status === 'checked_in') { + // The guest was already admitted: keep the check-in record, tell finance. + await c.query(`UPDATE external_orders SET match_state = 'flagged', flag_reason = 'refund_after_check_in' WHERE id = $1`, [order.id]); + const nid = await alertNotification(c, order.id, 'refund_after_check_in'); + await audit('webhook:superprofile', `order.${status}_after_check_in`, order.id, { bookingId: booking.id }, c); + return { result: `${status}_after_check_in`, notificationIds: [nid], alert: 'refund_after_check_in', orderRow: order.id }; + } + if (['confirmed', 'pending_payment', 'pending_approval'].includes(booking.status)) { + await c.query( + `UPDATE bookings SET status = $2::booking_status, + refunded_at = CASE WHEN $2 = 'refunded' THEN now() ELSE refunded_at END, + cancelled_at = CASE WHEN $2 = 'cancelled' THEN now() ELSE cancelled_at END, + cancel_reason = CASE WHEN $2 = 'cancelled' THEN 'superprofile_cancelled' ELSE cancel_reason END + WHERE id = $1`, [booking.id, status]); + await c.query('UPDATE invite_links SET revoked_at = COALESCE(revoked_at, now()) WHERE booking_id = $1 AND used_at IS NULL', [booking.id]); + const joined = (await c.query( + `SELECT i.chat_id, u.telegram_user_id FROM invite_links i JOIN users u ON u.id = i.user_id + WHERE i.booking_id = $1 AND i.used_at IS NOT NULL AND i.revoked_at IS NULL LIMIT 1`, [booking.id])).rows[0]; + if (joined) { + effects.removeFromChat = { chatId: joined.chat_id, telegramUserId: joined.telegram_user_id }; + await c.query('UPDATE invite_links SET revoked_at = now() WHERE booking_id = $1 AND revoked_at IS NULL', [booking.id]); + } + const nid = await enqueue(c, { kind: 'booking_refunded', userId: booking.user_id, bookingId: booking.id, dedupeKey: `bk:${booking.id}:refunded` }); + await audit('webhook:superprofile', `order.${status}`, order.id, { bookingId: booking.id }, c); + return { result: status, notificationIds: [nid], bookingId: booking.id }; + } + return { result: status, notificationIds: [] }; + }); + return { ...r, effects }; +} + +/** + * Entry for order-shaped notifications (purchase / refund / cancelled). Runs the transaction, then + * the side effects that must not roll it back: group removal and admin alert logging. + */ +export async function applyOrderEvent(b) { + const r = b.event === 'purchase' ? await applyPurchase(b) : await applyReversal(b); + if (r.effects?.removeFromChat) { + const { chatId, telegramUserId } = r.effects.removeFromChat; + await removeFromChat(chatId, telegramUserId).catch((err) => logger.warn('remove from group failed', { reason: String(err.message).slice(0, 100) })); + } + if (r.alert) await recordIntegrationError('superprofile_webhook', `order_${r.alert}`, `order row ${r.orderRow}`); + return r; +} + +/** Does this product belong to a membership tier (handled by the membership flow)? */ +export async function isTierProduct(b) { + const keys = [b.product_id, b.product].filter(Boolean); + if (!keys.length) return false; + const row = await db.one('SELECT 1 AS x FROM membership_tiers WHERE lower(superprofile_product_ref) = ANY($1::text[])', [keys.map((k) => k.toLowerCase())]); + return Boolean(row); +} + +// ---------- admin ---------- + +/** Keyset-paginated order list for the admin Orders tab. */ +export async function listOrders({ state = 'unmatched', cursor = null, limit = 25 } = {}) { + const n = Math.min(Math.max(parseInt(limit, 10) || 25, 1), 100); + const where = []; + const params = []; + const p = (v) => { params.push(v); return `$${params.length}`; }; + if (state === 'unmatched') where.push(`o.match_state = 'unmatched' AND o.status = 'paid'`); + else if (state === 'flagged') where.push(`o.match_state = 'flagged'`); + else if (state === 'matched') where.push(`o.match_state = 'matched'`); + else if (state !== 'all') throw new HttpError(400, 'validation', 'Unknown state'); + if (cursor) { + const [ts, id] = String(cursor).split('|'); + if (!ts || Number.isNaN(new Date(ts).getTime()) || !/^[0-9a-f-]{36}$/i.test(id || '')) throw new HttpError(400, 'validation', 'Bad cursor'); + where.push(`(o.received_at, o.id) < (${p(ts)}::timestamptz, ${p(id)}::uuid)`); + } + const { rows } = await db.query( + `SELECT o.id, o.order_id, o.product_id, o.amount_paise, o.currency, o.qty, o.status, o.match_state, o.flag_reason, o.received_at, + o.matched_booking_id, (o.buyer_email_hash IS NOT NULL) AS has_email, e.title AS event_title, e.slug AS event_slug + FROM external_orders o LEFT JOIN events e ON e.id = o.event_id + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY o.received_at DESC, o.id DESC LIMIT ${n + 1}`, params); + const page = rows.slice(0, n); + const last = page[page.length - 1]; + return { orders: page, nextCursor: rows.length > n && last ? `${new Date(last.received_at).toISOString()}|${last.id}` : null }; +} + +/** Finance: link a paid, unclaimed order to the user with this Telegram id and confirm their booking. */ +export async function linkOrderToUser(actor, orderRowId, telegramUserId) { + const out = await db.tx(async (c) => { + const order = (await c.query('SELECT * FROM external_orders WHERE id = $1 FOR UPDATE', [orderRowId])).rows[0]; + if (!order) throw new HttpError(404, 'not_found', 'Order not found'); + if (order.status !== 'paid') throw new HttpError(409, 'not_linkable', `Order is ${order.status}`); + if (order.matched_booking_id) throw new HttpError(409, 'already_linked', 'Order is already linked to a booking'); + if (!order.event_id) throw new HttpError(409, 'unknown_product', 'This order’s product is not linked to an event yet. Link the product on the event first.'); + const user = (await c.query(`SELECT id FROM users WHERE telegram_user_id = $1 AND status = 'active'`, [telegramUserId])).rows[0]; + if (!user) throw new HttpError(404, 'user_not_found', 'No active user with that Telegram id. They must sign in once first.'); + const event = (await c.query('SELECT * FROM events WHERE id = $1 FOR UPDATE', [order.event_id])).rows[0]; + const r = await confirmOrderBooking(c, { order, event, userId: user.id, actor }); + if (r.conflict) throw new HttpError(409, r.conflict, 'That user already has a booking for this event'); + await audit(actor, 'order.manual_link', order.id, { bookingId: r.bookingId, orderId: order.order_id }, c); + return { bookingId: r.bookingId, notificationIds: [r.notificationId] }; + }); + return out; +} diff --git a/lib/services/refunds.js b/lib/services/refunds.js index 48a9603..66052d9 100644 --- a/lib/services/refunds.js +++ b/lib/services/refunds.js @@ -1,7 +1,8 @@ -// lib/services/refunds.js - Finance-initiated refunds (tickets via provider, memberships via Stars). +// lib/services/refunds.js - Finance-initiated refunds of Telegram Stars payments (memberships). +// Ticket refunds are issued on SuperProfile and arrive as a notification (services/orders.js). import { db } from '../db.js'; import { HttpError } from '../http.js'; -import { providerByName } from '../payments/index.js'; +import { ticketProvider } from '../payments/index.js'; import { tg } from '../telegram/api.js'; import { enqueue, deliverMany } from './notifications.js'; import { audit } from '../audit.js'; @@ -23,9 +24,7 @@ export async function refundPayment(paymentId, actor) { if (p.provider === 'telegram_stars') { await tg('refundStarPayment', { user_id: p.telegram_user_id, telegram_payment_charge_id: p.provider_txn_id }); } else { - await providerByName(p.provider).refund({ - providerTxnId: p.provider_txn_id, providerPaymentId: p.provider_payment_id, amountPaise: Number(p.amount), refundRef: `rf_${p.id.slice(0, 8)}` - }); + await ticketProvider().refund(); // throws NOT_SUPPORTED_IN_APP: ticket refunds happen on SuperProfile } } catch (err) { await db.query(`UPDATE payments SET failure_reason = NULL WHERE id = $1 AND failure_reason = 'refund_in_progress'`, [p.id]); diff --git a/lib/services/superprofile.js b/lib/services/superprofile.js index c256357..f7212d6 100644 --- a/lib/services/superprofile.js +++ b/lib/services/superprofile.js @@ -6,7 +6,11 @@ // Expected JSON (map these fields in the Zapier/Pabbly "Webhook" step): // { "event": "purchase" | "subscription_expired" | "purchase_abandoned", // "id": "", required, idempotency key -// "product": "", required except for abandons +// "product": "", required except for abandons, refunds and cancellations +// "product_id": "", optional; matched against events.superprofile_product_id first +// "qty": 1, "ref": "", optional ticket quantity / ref carried from the site +// "phone": "", optional, hashed immediately +// event "refund" / "cancelled" reverse an earlier purchase of the same id. // "email": "", optional, hashed immediately // "amount": "499" | 499, optional, rupees // "currency": "INR", "occurred_at": "" } optional @@ -17,11 +21,7 @@ import { HttpError } from '../http.js'; import { audit } from '../audit.js'; import { z } from '../validate.js'; -export const SUPERPROFILE_SECRET_HEADER = 'x-webhook-secret'; - -export function superprofileConfigured() { - return env('SUPERPROFILE_WEBHOOK_SECRET').length >= 24; -} +export { SUPERPROFILE_SECRET_HEADER, superprofileConfigured } from '../payments/superprofile.js'; function hashKey() { const s = env('SESSION_SECRET'); @@ -35,6 +35,13 @@ export function emailHash(email) { return crypto.createHmac('sha256', hashKey()).update(e).digest('hex'); } +/** Keyed hash of the last 10 digits, so +91 98xxx and 098xxx hash alike. */ +export function phoneHash(phone) { + const d = String(phone || '').replace(/\D/g, ''); + if (d.length < 10) return null; + return crypto.createHmac('sha256', hashKey()).update(`phone:${d.slice(-10)}`).digest('hex'); +} + export function rupeesToPaise(v) { if (v === undefined || v === null || v === '') return null; const n = Number(String(v).replace(/[^0-9.]/g, '')); @@ -42,14 +49,18 @@ export function rupeesToPaise(v) { } export const WebhookBody = z.object({ - event: z.enum(['purchase', 'subscription_expired', 'purchase_abandoned']), + event: z.enum(['purchase', 'subscription_expired', 'purchase_abandoned', 'refund', 'cancelled']), id: z.union([z.string(), z.number()]).transform((v) => String(v).trim()).refine((v) => v.length >= 1 && v.length <= 200, 'id required'), product: z.union([z.string(), z.number()]).optional().transform((v) => (v === undefined ? undefined : String(v).trim().slice(0, 200) || undefined)), + product_id: z.union([z.string(), z.number()]).optional().transform((v) => (v === undefined ? undefined : String(v).trim().slice(0, 200) || undefined)), email: z.string().max(320).optional(), + phone: z.union([z.string().max(40), z.number()]).optional(), + qty: z.union([z.string().max(8), z.number()]).optional(), + ref: z.string().max(64).optional(), amount: z.union([z.string().max(40), z.number()]).optional(), currency: z.string().max(8).optional(), occurred_at: z.string().max(64).optional() -}).passthrough().refine((b) => b.event === 'purchase_abandoned' || Boolean(b.product), { message: 'product required', path: ['product'] }); +}).passthrough().refine((b) => ['purchase_abandoned', 'refund', 'cancelled'].includes(b.event) || Boolean(b.product || b.product_id), { message: 'product required', path: ['product'] }); /** Apply one webhook. Idempotent on (event, id). Returns { result, tierMapped }. */ export async function applySuperprofileEvent(b) { diff --git a/lib/services/ticketing.js b/lib/services/ticketing.js index b488830..57b8bf6 100644 --- a/lib/services/ticketing.js +++ b/lib/services/ticketing.js @@ -2,20 +2,17 @@ // // Inventory is decided inside one transaction holding the event row lock (same lock order as // services/bookings.js: events, then bookings), so concurrent bookings cannot oversell an event or -// a tier. Free tiers confirm without any payment. Paid tiers need a configured provider -// (payments/availability.js); otherwise they are refused — never "assumed paid". Approval only -// moves a request forward; it never marks anything as paid. +// a tier. Free tiers confirm without any payment. Paid tiers are bought on SuperProfile (GET +// /checkout/:slug) and confirmed by a matched notification (services/orders.js) — never here, and +// never "assumed paid". Approval only moves a request forward; it never marks anything as paid. import { db } from '../db.js'; import { HttpError } from '../http.js'; import { z } from '../validate.js'; import { audit } from '../audit.js'; import { enqueue } from './notifications.js'; -import { seatsTaken, tierSeatsTaken, startCheckout, HOLD_MINUTES } from './bookings.js'; -import { ticketCheckoutProvider } from '../payments/availability.js'; +import { seatsTaken, tierSeatsTaken } from './bookings.js'; import { isOrganiser } from './organiser.js'; -/** How long an approved guest has to complete a paid checkout. */ -export const APPROVAL_HOLD_HOURS = 24; const LIVE = ['pending_approval', 'pending_payment', 'confirmed', 'checked_in']; export const TierBookingBody = z.object({ @@ -60,8 +57,8 @@ async function assertSeats(c, event, tier, qty, excludeBookingId = null) { } /** - * Book (or request) a tier. Returns { bookingId, status, checkoutUrl? }. - * status: confirmed (free), pending_approval (host approves), pending_payment (paid checkout). + * Book (or request) a free tier. Returns { bookingId, status }. + * status: confirmed (free) or pending_approval (host approves). Paid tiers are not booked here. */ export async function bookTier(user, eventId, body) { if (!user.age_confirmed_21) throw new HttpError(403, 'age_required', 'Please confirm you are 21 or older'); @@ -82,9 +79,7 @@ export async function bookTier(user, eventId, body) { if (pickups.length && !pickups.some((p) => p.id === body.pickupPointId)) { throw new HttpError(400, 'validation', 'Choose a pickup point', { fields: [{ field: 'pickupPointId', message: 'Required' }] }); } - const paid = tier.kind === 'paid'; - const provider = paid ? ticketCheckoutProvider() : null; - if (paid && !provider) throw new HttpError(409, 'payment_unavailable', 'Online payment is not available for this ticket yet. Free tickets can still be booked.'); + if (tier.kind === 'paid') throw new HttpError(409, 'paid_on_superprofile', 'Paid tickets are bought on SuperProfile. Use the Book Tickets button on the event page.'); const prior = (await c.query( `SELECT * FROM bookings WHERE event_id = $1 AND user_id = $2 AND status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in', 'declined') @@ -110,30 +105,16 @@ export async function bookTier(user, eventId, body) { await audit(`user:${user.id}`, 'booking.request', b.id, { eventId, tier: tier.id, qty }, c); return { booking: b, notificationIds: nid ? [nid] : [] }; } - if (!paid) { - const b = (await c.query( - `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, answers, pickup_point_id, amount_paise, status, payment_mode, confirmed_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', 'free', now()) RETURNING *`, common)).rows[0]; - const nid = await enqueue(c, { kind: 'booking_confirmed', userId: user.id, bookingId: b.id, dedupeKey: `bk:${b.id}:confirmed` }); - await audit(`user:${user.id}`, 'booking.confirmed_free', b.id, { eventId, tier: tier.id, qty }, c); - return { booking: b, notificationIds: [nid] }; - } const b = (await c.query( - `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, answers, pickup_point_id, amount_paise, status, payment_mode, hold_expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending_payment', 'provider', now() + make_interval(mins => $8)) RETURNING *`, [...common, HOLD_MINUTES])).rows[0]; - return { booking: b, event, provider }; + `INSERT INTO bookings (event_id, user_id, ticket_type_id, quantity, answers, pickup_point_id, amount_paise, status, payment_mode, confirmed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', 'free', now()) RETURNING *`, common)).rows[0]; + const nid = await enqueue(c, { kind: 'booking_confirmed', userId: user.id, bookingId: b.id, dedupeKey: `bk:${b.id}:confirmed` }); + await audit(`user:${user.id}`, 'booking.confirmed_free', b.id, { eventId, tier: tier.id, qty }, c); + return { booking: b, notificationIds: [nid] }; }); const { booking } = placed; - if (placed.existing) { - const url = booking.status === 'pending_payment' - ? (await db.one('SELECT checkout_url FROM payments WHERE id = $1', [booking.payment_id]))?.checkout_url || null : null; - return { bookingId: booking.id, status: booking.status, existing: true, checkoutUrl: url }; - } - if (placed.provider) { - const checkoutUrl = await startCheckout(user.id, booking, placed.event, placed.provider); - return { bookingId: booking.id, status: 'pending_payment', checkoutUrl, notificationIds: [] }; - } + if (placed.existing) return { bookingId: booking.id, status: booking.status, existing: true }; return { bookingId: booking.id, status: booking.status, notificationIds: placed.notificationIds }; } @@ -155,8 +136,8 @@ export async function manageableEvent(user, eventId, c = db, { lock = false } = } /** - * Approve or decline a request. Approving a free ticket confirms it (if seats remain). Approving a - * paid ticket only opens checkout — the booking is confirmed later by the verified payment webhook. + * Approve or decline a request. Approving a free ticket confirms it (if seats remain). A paid + * ticket cannot be approved: payment happens on SuperProfile, outside this approval step. */ export async function decideRequest(user, bookingId, decision) { const r = await db.tx(async (c) => { @@ -180,22 +161,9 @@ export async function decideRequest(user, bookingId, decision) { await audit(`user:${user.id}`, 'booking.approve', b.id, { eventId: event.id, confirmed: true }, c); return { status: 'confirmed', notificationIds: [nid] }; } - const provider = ticketCheckoutProvider(); - if (!provider) throw new HttpError(409, 'payment_unavailable', 'Online payment is not available yet, so a paid request cannot be approved. The request stays pending.'); - const held = (await c.query( - `UPDATE bookings SET status = 'pending_payment', payment_mode = 'provider', hold_expires_at = now() + make_interval(hours => $3), - decided_at = now(), decided_by = $2 WHERE id = $1 RETURNING *`, [b.id, user.id, APPROVAL_HOLD_HOURS])).rows[0]; - await audit(`user:${user.id}`, 'booking.approve', b.id, { eventId: event.id, awaitingPayment: true }, c); - return { status: 'pending_payment', booking: held, event, provider }; + // Paid tickets are bought on SuperProfile and cannot be gated on approval, so a paid request stays pending. + throw new HttpError(409, 'payment_unavailable', 'Paid tickets are bought on SuperProfile, so a paid request cannot be approved here. The request stays pending.'); }); - if (r.provider) { - // If checkout cannot be created, the request returns to pending rather than being lost. - await startCheckout(r.booking.user_id, r.booking, r.event, r.provider, { - onFail: (id) => db.query(`UPDATE bookings SET status = 'pending_approval', hold_expires_at = NULL, decided_at = NULL, decided_by = NULL WHERE id = $1`, [id]) - }); - const nid = await db.tx((c) => enqueue(c, { kind: 'booking_approved_pay', userId: r.booking.user_id, bookingId: r.booking.id, dedupeKey: `bk:${r.booking.id}:approved` })); - return { status: 'pending_payment', notificationIds: [nid] }; - } return r; } @@ -204,10 +172,10 @@ export async function listEventGuests(user, eventId) { const event = await manageableEvent(user, eventId); const { rows } = await db.query( `SELECT b.id, b.status, b.quantity, b.answers, b.pickup_point_id, b.payment_mode, b.created_at, b.confirmed_at, b.checked_in_at, b.decided_at, - t.name AS ticket, t.kind, u.alias, p.status AS payment_status + t.name AS ticket, t.kind, u.alias, eo.status AS payment_status FROM bookings b JOIN users u ON u.id = b.user_id LEFT JOIN ticket_types t ON t.id = b.ticket_type_id - LEFT JOIN payments p ON p.id = b.payment_id + LEFT JOIN external_orders eo ON eo.matched_booking_id = b.id WHERE b.event_id = $1 AND b.status IN ('pending_approval', 'pending_payment', 'confirmed', 'checked_in', 'declined') ORDER BY b.created_at`, [eventId]); const questions = Array.isArray(event.booking_questions) ? event.booking_questions : []; diff --git a/lib/telegram/bot.js b/lib/telegram/bot.js index b8d5bf9..fad978f 100644 --- a/lib/telegram/bot.js +++ b/lib/telegram/bot.js @@ -7,6 +7,7 @@ import { listPublicEvents, getPublicEventBySlug, listTiers } from '../services/e import { validatePreCheckout, handleSuccessfulPayment, createMembershipInvoice } from '../services/membership.js'; import { inviteForBooking, onChatMemberJoined } from '../services/invites.js'; import { deliverMany } from '../services/notifications.js'; +import { claimOrder, startClaimPrompt, isClaimPromptOpen, CLAIM_LIMIT_PER_HOUR } from '../services/claims.js'; import { eventPath } from '../views/components.js'; import { logger } from '../logger.js'; @@ -14,6 +15,7 @@ export const COMMANDS = [ ['start', 'Open StrangersMeetup'], ['events', 'Upcoming meetups and trips'], ['mybookings', 'Your bookings'], + ['claim', 'Claim a ticket you bought on SuperProfile'], ['membership', 'Membership plans'], ['report', 'Report a safety concern'], ['help', 'Help and support'], @@ -64,6 +66,7 @@ async function cmdStart(msg, user, param) { } } } + if (param === 'claim') return cmdClaim(msg, user, ''); if (param === 'alerts') { await db.query('UPDATE users SET alerts_opt_in = true WHERE id = $1', [user.id]); return reply(chat, 'Done — we will message you here when new events are announced. Send /stopalerts to opt out.'); @@ -74,6 +77,30 @@ async function cmdStart(msg, user, param) { [appButton(), [{ text: 'Upcoming events', callback_data: 'events' }]]); } +const CLAIM_PROMPT = 'Send your SuperProfile order ID to claim your ticket. If you like, add the email you paid with after it, e.g. ABC12345 you@example.com.\n\nYou will get your booking confirmation and a one-time group invite here.'; + +/** /claim [orderId [email]] — also the target of t.me/?start=claim. */ +async function cmdClaim(msg, user, arg) { + const [orderId, email] = arg.trim().split(/\s+/); + if (!orderId) { + await startClaimPrompt(user.id); + return reply(msg.chat.id, CLAIM_PROMPT); + } + return runClaim(msg.chat.id, user, orderId, email); +} + +async function runClaim(chat, user, orderId, email) { + try { + const r = await claimOrder(user, orderId, email); + await reply(chat, `Order claimed ✅ ${esc(r.eventTitle)}. Your confirmation and group invite follow now.`); + await deliverMany(r.notificationIds); + } catch (err) { + if (err.status === 429) return reply(chat, `That was more than ${CLAIM_LIMIT_PER_HOUR} attempts in an hour. Please try again later, or ${supportLine()}`); + if (err.status === 403 || err.status === 404 || err.status === 409) return reply(chat, esc(err.message)); + throw err; + } +} + async function cmdEvents(chat) { const events = await listPublicEvents({ limit: 8 }); if (!events.length) return reply(chat, 'No upcoming events are listed right now. Tap below to be told when new ones are announced.', [[{ text: 'Notify me', url: `https://t.me/${botUsername()}?start=alerts` }]]); @@ -134,12 +161,17 @@ async function onMessage(msg) { const text = msg.text || ''; const m = /^\/([a-z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/i.exec(text.trim()); - if (!m) return reply(msg.chat.id, 'Send /events to see what is coming up, or /help for support.'); + if (!m) { + // After "claim" the bot waits a few minutes for an order ID. + if (text.trim() && await isClaimPromptOpen(user.id)) return cmdClaim(msg, user, text); + return reply(msg.chat.id, 'Send /events to see what is coming up, or /help for support.'); + } const [, cmd, arg = ''] = m; switch (cmd.toLowerCase()) { case 'start': return cmdStart(msg, user, arg.trim() || null); case 'events': return cmdEvents(msg.chat.id); case 'mybookings': return cmdMyBookings(msg.chat.id, user); + case 'claim': return cmdClaim(msg, user, arg); case 'membership': return cmdMembership(msg.chat.id, user); case 'report': return cmdReport(msg, user, arg); case 'stopalerts': @@ -153,7 +185,7 @@ async function onMessage(msg) { case 'support': default: return reply(msg.chat.id, - 'Help\n/events — upcoming events\n/mybookings — your bookings and invites\n/membership — plans\n/report — report a safety concern\n/paysupport — payment problems\n/terms — terms and refunds\n\n' + + 'Help\n/events — upcoming events\n/mybookings — your bookings and invites\n/claim — claim a ticket you bought on SuperProfile\n/membership — plans\n/report — report a safety concern\n/paysupport — payment problems\n/terms — terms and refunds\n\n' + `To cancel or get a refund, reply here with your booking reference. ${supportLine()}`); } } diff --git a/lib/views/admin.js b/lib/views/admin.js index 7454bff..f2c67d4 100644 --- a/lib/views/admin.js +++ b/lib/views/admin.js @@ -49,6 +49,6 @@ export function renderAdminShell({ state, role, csrf }) { ${main} - + `}`; } diff --git a/lib/views/event.js b/lib/views/event.js index 3b8e854..716bee4 100644 --- a/lib/views/event.js +++ b/lib/views/event.js @@ -2,7 +2,7 @@ import { html, raw, jsonLd, fmtFullDate, fmtTime, fmtDate, fmtZone } from './html.js'; import { layout } from './layout.js'; import { badges, eventPath } from './components.js'; -import { siteUrl, livePayments } from '../config.js'; +import { siteUrl } from '../config.js'; import { CITIES } from '../services/events.js'; function eventJsonLd(e) { @@ -104,11 +104,11 @@ function tierBookBox(e, { preview = false } = {}) {

${e.stateLabel}

${preview ? html`

Preview — booking is switched off until you publish.

` : ''} ${hasPaid && !e.checkoutAvailable ? html`

Online payment isn’t available yet, so paid tickets can’t be booked right now.${e.tiers.some((t) => t.kind === 'free') ? ' Free tickets can.' : ''}

` : ''} -
+
Choose a ticket ${e.tiers.map((t) => html`