Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ TG_CHAT_ID_TIER_3=
TG_CHAT_ID_EVENTS=

# Payments
# Tickets are bought on SuperProfile (no card gateway keys here). ENABLE_LIVE_PAYMENTS only labels
# Telegram Stars membership payments as test/live.
# ENABLE_LIVE_PAYMENTS gates only the SuperProfile hosted link-out for paid tickets (and labels
# Telegram Stars membership payments as test/live).
ENABLE_LIVE_PAYMENTS=false
# UPI_CHECKOUT_ENABLED gates every UPI/PhonePe route, page, intent, claim and webhook. Keep false:
# PhonePe v1 cannot meet the payment verification contract (docs/UPI_PROVIDER_READINESS.md).
UPI_CHECKOUT_ENABLED=false

# SuperProfile notifications (tickets + memberships) via Zapier/Pabbly: shared secret sent as X-Webhook-Secret, 24+ chars
# Endpoint: POST /api/webhooks/superprofile
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@ only when `MIGRATE_ON_PREVIEW=true`, which must be set only if Preview has its o
## Rules this codebase keeps
- No fabricated data: every number is a query; demo rows are `is_demo` and labelled "Sample"; social proof hidden below `SOCIAL_PROOF_MIN`.
- Server is the source of truth: bookings/entitlements change only in verified webhooks or server-side reconciliation.
- Secrets only in env vars; `.env.example` lists names only. Live payments off until `ENABLE_LIVE_PAYMENTS=true`.
- Secrets only in env vars; `.env.example` lists names only. SuperProfile link-out off until `ENABLE_LIVE_PAYMENTS=true`; UPI/PhonePe off until `UPI_CHECKOUT_ENABLED=true` (independent flags).
7 changes: 7 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,16 @@ export function siteUrl() {
return `http://localhost:${env('PORT', '3000')}`;
}

/** Gates only the SuperProfile hosted link-out. UPI/PhonePe has its own flag below. */
export function livePayments() {
return env('ENABLE_LIVE_PAYMENTS', 'false') === 'true';
}

/** Gates every UPI/PhonePe route, page, intent, claim and webhook. Off unless explicitly 'true'. */
export function upiCheckoutEnabled() {
return env('UPI_CHECKOUT_ENABLED', 'false') === 'true';
}

export function socialProofMin() {
const n = parseInt(env('SOCIAL_PROOF_MIN', '5'), 10);
return Number.isFinite(n) && n >= 5 ? n : 5;
Expand Down Expand Up @@ -132,6 +138,7 @@ export function configStatus() {
telegramEventsChat: ok(chatIdFromEnv('TG_CHAT_ID_EVENTS') !== null),
ticketCheckout: 'superprofile_link',
livePayments: livePayments(),
upiCheckout: upiCheckoutEnabled(),
adminBootstrap: ok(/^\d+$/.test(env('ADMIN_BOOTSTRAP_TELEGRAM_ID'))),
telegramLogin: ok(has('TELEGRAM_LOGIN_CLIENT_ID') && has('TELEGRAM_LOGIN_CLIENT_SECRET')),
superprofileWebhook: ok(env('SUPERPROFILE_WEBHOOK_SECRET').length >= 24)
Expand Down
2 changes: 2 additions & 0 deletions lib/handlers/checkout.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getSessionUser } from '../session.js';
import { rateLimit } from '../ratelimit.js';
import { createCheckoutIntent } from '../services/checkout.js';
import { db } from '../db.js';
import { upiCheckoutEnabled } from '../config.js';

export async function checkoutRedirect({ req, res, params, query, ip }) {
const slug = params.slug;
Expand All @@ -15,6 +16,7 @@ export async function checkoutRedirect({ req, res, params, query, ip }) {

const ev = await db.one('SELECT checkout_mode FROM events WHERE slug = $1', [slug]);
if (ev?.checkout_mode === 'upi_direct') {
if (!upiCheckoutEnabled()) return redirect(res, `/events/${slug}`);
const qStr = query.toString() ? `?${query.toString()}` : '';
return redirect(res, `/checkout/${slug}/upi${qStr}`);
}
Expand Down
8 changes: 7 additions & 1 deletion lib/handlers/upi-checkout.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
createUpiPaymentIntent,
getUpiPaymentStatus,
claimUpiBooking,
processUpiWebhook
processUpiWebhook,
assertUpiCheckoutEnabled
} from '../services/upi-payments.js';
import { renderUpiCheckout } from '../views/checkout-upi.js';
import { WebhookSignatureError } from '../upi/adapter.js';
Expand All @@ -21,6 +22,7 @@ function isMobileUserAgent(ua = '') {
* GET /checkout/:slug/upi - Renders the UPI-First interactive checkout view.
*/
export async function upiCheckoutPage({ req, res, params, query, ip }) {
assertUpiCheckoutEnabled(); // before rate limits and body reads: the route does not exist when off
const slug = params.slug;
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) throw new HttpError(404, 'not_found', 'Event not found');

Expand Down Expand Up @@ -90,6 +92,7 @@ export async function upiCheckoutPage({ req, res, params, query, ip }) {
* POST /api/checkout/upi/intent - Server API to create UPI payment intent.
*/
export async function createUpiIntentApi({ req, res, ip }) {
assertUpiCheckoutEnabled(); // before rate limits and body reads: the route does not exist when off
await rateLimit(`upi_intent:${hashIp(ip)}`, 30, 600);
const user = await getSessionUser(req);
const body = await readJson(req);
Expand Down Expand Up @@ -120,6 +123,7 @@ export async function createUpiIntentApi({ req, res, ip }) {
* GET /api/checkout/upi/status/:orderId - Client polling endpoint.
*/
export async function getUpiStatusApi({ req, res, params, query, ip }) {
assertUpiCheckoutEnabled(); // before rate limits and body reads: the route does not exist when off
const orderId = params.id || params.orderid || params.orderId;
if (!orderId || !/^[A-Za-z0-9_-]{8,80}$/.test(orderId)) {
throw new HttpError(400, 'validation', 'Invalid order ID');
Expand All @@ -138,6 +142,7 @@ export async function getUpiStatusApi({ req, res, params, query, ip }) {
* POST /api/checkout/upi/claim - Allows an authenticated attendee to claim a guest booking.
*/
export async function claimUpiBookingApi({ req, res, ip }) {
assertUpiCheckoutEnabled(); // before rate limits and body reads: the route does not exist when off
await rateLimit(`upi_claim:${hashIp(ip)}`, 30, 600);
const user = await getSessionUser(req);
if (!user) {
Expand All @@ -160,6 +165,7 @@ export async function claimUpiBookingApi({ req, res, ip }) {
* POST /api/payments/webhook/phonepe - Webhook receiver for PhonePe PG callbacks.
*/
export async function phonepeWebhook({ req, res, ip }) {
assertUpiCheckoutEnabled(); // before rate limits and body reads: the route does not exist when off
await rateLimit(`phonepe_hook:${hashIp(ip)}`, 300, 60);
const rawBody = await readRawBody(req, 64 * 1024);

Expand Down
26 changes: 17 additions & 9 deletions lib/payments/availability.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,29 @@
// 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.

import { livePayments } from '../config.js';
import { livePayments, upiCheckoutEnabled } from '../config.js';

// Paid checkout stays off in Preview unless an approved test mode is explicitly enabled.
const PREVIEW_BLOCKED = Object.freeze({ available: false, reason: 'preview_paid_checkout_disabled', message: 'Paid checkout is disabled in Preview deployments.' });
function previewPaidCheckoutBlocked() {
return process.env.VERCEL_ENV === 'preview' && process.env.ALLOW_PREVIEW_PAID_CHECKOUT !== 'true';
}

/** Per-event checkout availability. `row` is an events row (or anything with the same columns). */
export function checkoutForEvent(row) {
// Gate all paid checkouts (including SuperProfile redirect) on live payments being enabled
if (!livePayments()) {
return { available: false, reason: 'live_payments_disabled', message: 'Paid ticket checkout is currently disabled.' };
}
// Disable paid checkout in Preview unless an approved test mode is explicitly enabled
if (process.env.VERCEL_ENV === 'preview' && process.env.ALLOW_PREVIEW_PAID_CHECKOUT !== 'true') {
return { available: false, reason: 'preview_paid_checkout_disabled', message: 'Paid checkout is disabled in Preview deployments.' };
}
// UPI/PhonePe events are gated by UPI_CHECKOUT_ENABLED alone; ENABLE_LIVE_PAYMENTS never opens them.
if (row?.checkout_mode === 'upi_direct') {
if (!upiCheckoutEnabled()) {
return { available: false, reason: 'upi_checkout_disabled', message: 'Paid ticket checkout is currently disabled.' };
}
if (previewPaidCheckoutBlocked()) return PREVIEW_BLOCKED;
return { available: true, reason: 'upi_direct', provider: 'phonepe', message: '' };
}
// ENABLE_LIVE_PAYMENTS gates only the SuperProfile hosted link-out.
if (!livePayments()) {
return { available: false, reason: 'live_payments_disabled', message: 'Paid ticket checkout is currently disabled.' };
}
if (previewPaidCheckoutBlocked()) return PREVIEW_BLOCKED;
const available = row?.checkout_mode === 'superprofile' && Boolean(row?.superprofile_url);
return available
? { available: true, reason: 'superprofile', provider: 'superprofile_link', message: '' }
Expand Down
18 changes: 13 additions & 5 deletions lib/services/upi-payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import crypto from 'node:crypto';
import { db } from '../db.js';
import { HttpError } from '../http.js';
import { audit } from '../audit.js';
import { livePayments } from '../config.js';
import { upiCheckoutEnabled } from '../config.js';
import { phonePeInstance, mockInstance, PhonePeAdapter, PaymentAdapterError } from '../upi/adapter.js';
import { enqueue } from './notifications.js';
import { removeFromChat } from './invites.js';
Expand Down Expand Up @@ -170,6 +170,14 @@ export async function activeTierPaymentHolds(c, ticketTypeId, excludeOrderId = n
return rows[0].n;
}

/**
* UPI/PhonePe checkout does not exist unless UPI_CHECKOUT_ENABLED=true (404, as for an unknown route).
* ENABLE_LIVE_PAYMENTS does not open it. Refunds of already-captured payments are deliberately not gated.
*/
export function assertUpiCheckoutEnabled() {
if (!upiCheckoutEnabled()) throw new HttpError(404, 'not_found', 'Not found');
}

/**
* Creates an internal payment intent, reserves inventory check, and returns deep links + dynamic QR.
*/
Expand All @@ -181,10 +189,7 @@ export async function createUpiPaymentIntent({
deviceType = 'desktop',
clientIp = '127.0.0.1'
}) {
// Check live payment boundary
if (!livePayments()) {
throw new HttpError(403, 'live_payments_disabled', 'Paid checkout is currently disabled.');
}
assertUpiCheckoutEnabled();
if (process.env.VERCEL_ENV === 'preview' && process.env.ALLOW_PREVIEW_PAID_CHECKOUT !== 'true') {
throw new HttpError(403, 'preview_paid_checkout_disabled', 'Paid checkout is disabled in Preview deployments.');
}
Expand Down Expand Up @@ -336,6 +341,7 @@ export async function createUpiPaymentIntent({
* Checks or polls the status of an existing UPI payment order.
*/
export async function getUpiPaymentStatus(orderId, { user = null, guestToken = null, checkGateway = false } = {}) {
assertUpiCheckoutEnabled();
const payment = await db.one(
`SELECT p.*, e.title AS event_title, e.slug AS event_slug, b.id AS booking_id
FROM payments p
Expand Down Expand Up @@ -834,6 +840,7 @@ export async function applySuccessfulUpiPayment({
* Claims a guest UPI booking once the user logs in.
*/
export async function claimUpiBooking({ orderId, guestToken, user }) {
assertUpiCheckoutEnabled();
const uid = user?.id || user?.userId;
if (!uid) {
throw new HttpError(401, 'unauthorized', 'Must be signed in to claim a booking');
Expand Down Expand Up @@ -950,6 +957,7 @@ export async function claimUpiBooking({ orderId, guestToken, user }) {
* Ingests, verifies, and processes incoming UPI provider webhooks.
*/
export async function processUpiWebhook({ headers, rawBody, clientIp }) {
assertUpiCheckoutEnabled();
const adapter = getAdapter();
const verified = adapter.verifyWebhook(headers, rawBody);

Expand Down
1 change: 1 addition & 0 deletions mcp/tools/testing.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export async function runSafeTests({ suite }) {
IS_LOCAL_TEST: 'true',
DEV_LOGIN: 'true',
ENABLE_LIVE_PAYMENTS: 'false',
UPI_CHECKOUT_ENABLED: 'false',
TEST_DATABASE_URL: 'postgres://localhost/sm_rec_test'
};

Expand Down
1 change: 1 addition & 0 deletions test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Object.assign(process.env, {
TG_CHAT_ID_TIER_3: '-1002222222223',
SUPERPROFILE_WEBHOOK_SECRET: 'test_sp_webhook_secret_0123456789',
ENABLE_LIVE_PAYMENTS: 'false',
UPI_CHECKOUT_ENABLED: 'false',
PUBLIC_SITE_URL: 'https://sm.test',
ADMIN_BOOTSTRAP_TELEGRAM_ID: '900000001',
LOG_SILENT: 'true'
Expand Down
6 changes: 3 additions & 3 deletions test/m10-operational-hardening.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,14 @@ test('7. Concurrency Invariant: Check-in vs Cancellation ordering and state prot
assert.equal(cancelRes.json.ok, false);
});

test('8. Live Payments Hard Boundary: ENABLE_LIVE_PAYMENTS=false blocks paid checkout intents', async () => {
test('8. Payments Hard Boundary: UPI_CHECKOUT_ENABLED=false blocks UPI checkout intents', async () => {
const client = new Client();
const checkoutRes = await client.post('/api/checkout/upi/intent', {
eventId,
ticketTypeId,
quantity: 1
});
// With ENABLE_LIVE_PAYMENTS=false and no test override, intent creation fails closed
assert.ok([400, 403, 404, 503].includes(checkoutRes.status));
// With UPI_CHECKOUT_ENABLED unset the UPI routes do not exist
assert.equal(checkoutRes.status, 404);
assert.equal(checkoutRes.json.ok, false);
});
126 changes: 126 additions & 0 deletions test/payment-flags.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// test/payment-flags.test.js - ENABLE_LIVE_PAYMENTS gates only the SuperProfile link-out;
// UPI_CHECKOUT_ENABLED gates every UPI/PhonePe route. Free booking is unaffected by either. Real Postgres.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { resetDb, startServer, stopServer, Client, db, createEvent, signedInClient } from './helpers.js';
import { checkoutForEvent } from '../lib/payments/availability.js';
import { configStatus } from '../lib/config.js';

const SP_URL = 'https://superprofile.bio/meetup_host/chennai-social';
let seq = 0;

before(async () => {
await resetDb();
await startServer();
});
after(async () => {
process.env.ENABLE_LIVE_PAYMENTS = 'false';
process.env.UPI_CHECKOUT_ENABLED = 'false';
await stopServer();
});

function withFlags(live, upi, fn) {
return async () => {
const prev = [process.env.ENABLE_LIVE_PAYMENTS, process.env.UPI_CHECKOUT_ENABLED];
process.env.ENABLE_LIVE_PAYMENTS = live;
process.env.UPI_CHECKOUT_ENABLED = upi;
try { await fn(); } finally {
[process.env.ENABLE_LIVE_PAYMENTS, process.env.UPI_CHECKOUT_ENABLED] = prev;
}
};
}

async function spEvent() {
const ev = await createEvent({ price: 49900 });
await db.query(`UPDATE events SET superprofile_url = $2, superprofile_product_id = $3, checkout_mode = 'superprofile' WHERE id = $1`,
[ev.id, SP_URL, `prod-flags-${++seq}-${crypto.randomBytes(3).toString('hex')}`]);
return ev;
}

async function upiEvent() {
const ev = await createEvent({ price: 49900 });
await db.query(`UPDATE events SET ticketing = 'tiers', checkout_mode = 'upi_direct' WHERE id = $1`, [ev.id]);
const tier = await db.one(
`INSERT INTO ticket_types (event_id, name, kind, price_paise, quantity, max_per_booking, active)
VALUES ($1, 'Entry', 'paid', 49900, 10, 4, true) RETURNING *`, [ev.id]);
return { ev, tier };
}

async function freeEvent() {
const ev = await createEvent({ price: null }); // tiered events carry prices on ticket_types
await db.query(`UPDATE events SET ticketing = 'tiers' WHERE id = $1`, [ev.id]);
const tier = await db.one(
`INSERT INTO ticket_types (event_id, name, kind, price_paise, quantity, max_per_booking, active)
VALUES ($1, 'Free entry', 'free', 0, 10, 1, true) RETURNING *`, [ev.id]);
return { ev, tier };
}

/** Every UPI/PhonePe surface must 404 while UPI_CHECKOUT_ENABLED is off. */
async function assertUpiClosed() {
const { ev, tier } = await upiEvent();
const user = await signedInClient();
const anon = new Client();

assert.equal((await user.get(`/checkout/${ev.slug}/upi`)).status, 404, 'UPI page');
const intent = await user.post('/api/checkout/upi/intent', { slug: ev.slug, ticketTypeId: tier.id, quantity: 1 });
assert.equal(intent.status, 404, 'UPI intent');
assert.equal((await user.get('/api/checkout/upi/status/ORDER12345678')).status, 404, 'UPI status');
assert.equal((await user.post('/api/checkout/upi/claim', { orderId: 'ORDER12345678', guestToken: 'x' })).status, 404, 'UPI claim');
assert.equal((await anon.post('/api/payments/webhook/phonepe', { response: 'e30=' })).status, 404, 'PhonePe webhook');

// The generic Book Tickets link must not hand off to the closed UPI page.
const co = await anon.get(`/checkout/${ev.slug}`);
assert.equal(co.status, 302);
assert.equal(co.headers.get('location'), `/events/${ev.slug}`);
assert.equal(checkoutForEvent({ checkout_mode: 'upi_direct' }).reason, 'upi_checkout_disabled');
assert.equal((await db.one('SELECT count(*)::int n FROM payments WHERE booking_id IS NULL AND raw_ref->>\'eventId\' = $1', [ev.id])).n, 0, 'no intent row');
}

async function assertFreeBookingWorks() {
const { ev, tier } = await freeEvent();
const guest = await signedInClient();
const r = await guest.post(`/api/events/${ev.id}/book`, { ticketTypeId: tier.id, quantity: 1 });
assert.equal(r.status, 200, r.text);
assert.equal((await db.one('SELECT status FROM bookings WHERE id = $1', [r.json.bookingId])).status, 'confirmed');
}

test('ENABLE_LIVE_PAYMENTS=true, UPI_CHECKOUT_ENABLED=false: SuperProfile link-out works, every UPI route is closed, free booking works',
withFlags('true', 'false', async () => {
const ev = await spEvent();
const r = await new Client().get(`/checkout/${ev.slug}`);
assert.equal(r.status, 302, r.text);
assert.ok(r.headers.get('location').startsWith(SP_URL), 'redirects to SuperProfile');
await assertUpiClosed();
await assertFreeBookingWorks();
const cfg = configStatus();
assert.equal(cfg.livePayments, true);
assert.equal(cfg.upiCheckout, false);
}));

test('both flags false: everything paid is closed, free booking works', withFlags('false', 'false', async () => {
const ev = await spEvent();
const r = await new Client().get(`/checkout/${ev.slug}`);
assert.equal(r.status, 302);
assert.equal(r.headers.get('location'), `/events/${ev.slug}`, 'no SuperProfile hand-off');
assert.equal(checkoutForEvent({ checkout_mode: 'superprofile', superprofile_url: SP_URL }).reason, 'live_payments_disabled');
await assertUpiClosed();
await assertFreeBookingWorks();
}));

test('UPI_CHECKOUT_ENABLED=true, ENABLE_LIVE_PAYMENTS=false: UPI opens on its own flag, SuperProfile stays closed',
withFlags('false', 'true', async () => {
assert.equal(checkoutForEvent({ checkout_mode: 'upi_direct' }).available, true);
assert.equal(checkoutForEvent({ checkout_mode: 'superprofile', superprofile_url: SP_URL }).available, false);
const { ev } = await upiEvent();
const co = await new Client().get(`/checkout/${ev.slug}`);
assert.equal(co.status, 302);
assert.equal(co.headers.get('location'), `/checkout/${ev.slug}/upi`);
await assertFreeBookingWorks();
}));

test('UPI_CHECKOUT_ENABLED only opens on the exact string "true"', withFlags('false', 'TRUE', async () => {
assert.equal(checkoutForEvent({ checkout_mode: 'upi_direct' }).available, false);
process.env.UPI_CHECKOUT_ENABLED = '1';
assert.equal(checkoutForEvent({ checkout_mode: 'upi_direct' }).available, false);
}));
8 changes: 7 additions & 1 deletion test/telegram-workflows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,13 @@ test('operational recovery: /start claim_upi_<orderId>_<guestToken> claims guest

const c = new Client();
const tgUser = { id: 7402, first_name: 'Pooja' };
const r = await c.post('/api/telegram/webhook', msg(tgUser, `/start claim_upi_${orderId}_${guestToken}`), SECRET);
process.env.UPI_CHECKOUT_ENABLED = 'true'; // the claim path is part of UPI checkout
let r;
try {
r = await c.post('/api/telegram/webhook', msg(tgUser, `/start claim_upi_${orderId}_${guestToken}`), SECRET);
} finally {
process.env.UPI_CHECKOUT_ENABLED = 'false';
}
assert.equal(r.status, 200);

const reply = callsTo('sendMessage').find((m) => m.params.chat_id === 7402);
Expand Down
Loading
Loading