From c9ae63154974405c91d7cfe425327d5370a8adf2 Mon Sep 17 00:00:00 2001 From: Satelink Network Date: Tue, 10 Nov 2026 15:26:28 +0530 Subject: [PATCH] fix(payments): separate UPI_CHECKOUT_ENABLED from ENABLE_LIVE_PAYMENTS ENABLE_LIVE_PAYMENTS used to open both the SuperProfile link-out and the PhonePe/UPI checkout. It now gates only the SuperProfile hosted link-out. A new UPI_CHECKOUT_ENABLED flag (default false, exact 'true' only) gates every UPI surface: the /checkout/:slug/upi page, intent creation, status polling, guest claim (HTTP and Telegram), and the PhonePe webhook. Each returns 404 while off, and the generic /checkout/:slug link falls back to the event page instead of handing off to the closed UPI page. Refunds of already-captured payments are deliberately not gated. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 +- README.md | 2 +- lib/config.js | 7 ++ lib/handlers/checkout.js | 2 + lib/handlers/upi-checkout.js | 8 +- lib/payments/availability.js | 26 +++-- lib/services/upi-payments.js | 18 +++- mcp/tools/testing.js | 1 + test/helpers.js | 1 + test/m10-operational-hardening.test.js | 6 +- test/payment-flags.test.js | 126 +++++++++++++++++++++++++ test/telegram-workflows.test.js | 8 +- test/upi-checkout.test.js | 14 ++- 13 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 test/payment-flags.test.js diff --git a/.env.example b/.env.example index 70b5ff5..ccc6992 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 4b9ea91..bf07e3c 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/lib/config.js b/lib/config.js index 70603a0..0e0411e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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; @@ -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) diff --git a/lib/handlers/checkout.js b/lib/handlers/checkout.js index 2f57df5..4e2defa 100644 --- a/lib/handlers/checkout.js +++ b/lib/handlers/checkout.js @@ -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; @@ -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}`); } diff --git a/lib/handlers/upi-checkout.js b/lib/handlers/upi-checkout.js index 502c149..430f9c7 100644 --- a/lib/handlers/upi-checkout.js +++ b/lib/handlers/upi-checkout.js @@ -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'; @@ -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'); @@ -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); @@ -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'); @@ -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) { @@ -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); diff --git a/lib/payments/availability.js b/lib/payments/availability.js index d32175a..840ce62 100644 --- a/lib/payments/availability.js +++ b/lib/payments/availability.js @@ -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: '' } diff --git a/lib/services/upi-payments.js b/lib/services/upi-payments.js index 4d76dcc..8ae7a67 100644 --- a/lib/services/upi-payments.js +++ b/lib/services/upi-payments.js @@ -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'; @@ -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. */ @@ -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.'); } @@ -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 @@ -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'); @@ -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); diff --git a/mcp/tools/testing.js b/mcp/tools/testing.js index 0d24bca..20d373d 100644 --- a/mcp/tools/testing.js +++ b/mcp/tools/testing.js @@ -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' }; diff --git a/test/helpers.js b/test/helpers.js index 01b8eeb..5eb0b38 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -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' diff --git a/test/m10-operational-hardening.test.js b/test/m10-operational-hardening.test.js index 98681f9..6f29c27 100644 --- a/test/m10-operational-hardening.test.js +++ b/test/m10-operational-hardening.test.js @@ -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); }); diff --git a/test/payment-flags.test.js b/test/payment-flags.test.js new file mode 100644 index 0000000..989d5f8 --- /dev/null +++ b/test/payment-flags.test.js @@ -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); +})); diff --git a/test/telegram-workflows.test.js b/test/telegram-workflows.test.js index 31404df..e3d098e 100644 --- a/test/telegram-workflows.test.js +++ b/test/telegram-workflows.test.js @@ -367,7 +367,13 @@ test('operational recovery: /start claim_upi__ 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); diff --git a/test/upi-checkout.test.js b/test/upi-checkout.test.js index 2762876..00a5a69 100644 --- a/test/upi-checkout.test.js +++ b/test/upi-checkout.test.js @@ -43,7 +43,9 @@ let admin; let seq = 0; before(async () => { - process.env.ENABLE_LIVE_PAYMENTS = 'true'; + // UPI runs on its own flag; SuperProfile's ENABLE_LIVE_PAYMENTS stays off to prove they are independent. + process.env.UPI_CHECKOUT_ENABLED = 'true'; + process.env.ENABLE_LIVE_PAYMENTS = 'false'; process.env.ALLOW_PREVIEW_PAID_CHECKOUT = 'true'; stubUrl('https://api-preprod.phonepe.com', (url, init) => { if (url.includes('/pg/v1/refund')) { @@ -74,6 +76,7 @@ before(async () => { after(async () => { stubUrl('https://api-preprod.phonepe.com', null); + process.env.UPI_CHECKOUT_ENABLED = 'false'; process.env.ENABLE_LIVE_PAYMENTS = 'false'; delete process.env.ALLOW_PREVIEW_PAID_CHECKOUT; await stopServer(); @@ -430,13 +433,15 @@ test('concurrency: simultaneous checkout intents respect capacity limits', async // ---------------- 10. Safety Boundary Enforcement ---------------- -test('safety boundary: ENABLE_LIVE_PAYMENTS=false blocks paid intent creation in non-test mode', async () => { +test('safety boundary: UPI_CHECKOUT_ENABLED=false blocks paid intent creation even with ENABLE_LIVE_PAYMENTS=true', async () => { const origAllow = process.env.ALLOW_PREVIEW_PAID_CHECKOUT; const origLive = process.env.ENABLE_LIVE_PAYMENTS; + const origUpi = process.env.UPI_CHECKOUT_ENABLED; try { delete process.env.ALLOW_PREVIEW_PAID_CHECKOUT; - process.env.ENABLE_LIVE_PAYMENTS = 'false'; + process.env.ENABLE_LIVE_PAYMENTS = 'true'; + process.env.UPI_CHECKOUT_ENABLED = 'false'; const { event, tier } = await createPaidEvent({ price: 20000 }); @@ -447,11 +452,12 @@ test('safety boundary: ENABLE_LIVE_PAYMENTS=false blocks paid intent creation in ticketTypeId: tier.id, quantity: 1 }), - (err) => err.status === 403 && err.code === 'live_payments_disabled' + (err) => err.status === 404 && err.code === 'not_found' ); } finally { process.env.ALLOW_PREVIEW_PAID_CHECKOUT = origAllow; process.env.ENABLE_LIVE_PAYMENTS = origLive; + process.env.UPI_CHECKOUT_ENABLED = origUpi; } });