From e91d87fbfd01a5db91915cd5727fb1ac6ff672f1 Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 23 Jul 2026 23:44:18 -0500 Subject: [PATCH] feat(stripe): enable promotion codes on checkout sessions (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add allow_promotion_codes: true to the checkout.sessions.create call in POST /checkout. This is what renders the "Add promotion code" field on Stripe's hosted checkout page; for API-created sessions there is no Dashboard equivalent (that toggle only exists for Payment Links, which we don't use), so a comment warns against removing it as "redundant". The billingPortal branch is untouched — portal sessions handle discounts separately. Testing: no Stripe-SDK test/mock existed (the billing-*.test.js files cover the #146 usage-metering path, not Stripe). Added stripe-checkout.test.js using the repo's in-process router-mount convention with a minimal `stripe` stub injected via require.cache, asserting the checkout payload carries allow_promotion_codes:true (and still builds a subscription session for the requested price). Full suite 557/557. Co-authored-by: Claude Opus 4.8 (1M context) --- server/routes/stripe.js | 5 ++ server/test/stripe-checkout.test.js | 83 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 server/test/stripe-checkout.test.js diff --git a/server/routes/stripe.js b/server/routes/stripe.js index 8079297..60efddb 100644 --- a/server/routes/stripe.js +++ b/server/routes/stripe.js @@ -50,6 +50,11 @@ router.post('/checkout', requireAuth, async (req, res) => { customer: customerId, mode: 'subscription', payment_method_types: ['card'], + // Renders the "Add promotion code" field on Stripe's hosted checkout page. For + // API-created sessions this is the ONLY way to enable it — there is no Stripe Dashboard + // toggle for it outside Payment Links (which we don't use). Do not remove thinking it's + // redundant with a dashboard setting. + allow_promotion_codes: true, line_items: [{ price: priceId, quantity: 1 }], success_url: `${req.headers.origin || appUrl}/#/settings?payment=success`, cancel_url: `${req.headers.origin || appUrl}/#/settings?payment=cancelled`, diff --git a/server/test/stripe-checkout.test.js b/server/test/stripe-checkout.test.js new file mode 100644 index 0000000..d4a34e7 --- /dev/null +++ b/server/test/stripe-checkout.test.js @@ -0,0 +1,83 @@ +'use strict'; + +// Stripe checkout session shape. There was no pre-existing Stripe-SDK test/mock in the repo +// (the billing-*.test.js files cover the #146 usage-metering path, not Stripe), so this uses +// the repo's in-process router-mount convention with a minimal `stripe` stub injected via +// require.cache — capturing the exact params passed to checkout.sessions.create. +// +// Guards that the hosted-checkout promo-code field stays enabled: allow_promotion_codes:true +// is the ONLY way to render it for API-created sessions (no dashboard equivalent), so a +// silent removal would quietly break promotion codes with no other signal. + +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +process.env.DATA_DIR = path.join(os.tmpdir(), 'st-stripe-' + crypto.randomBytes(4).toString('hex')); +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; +process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; // makes routes/stripe build the (stubbed) client + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const express = require('express'); + +// --- stub the Stripe SDK: capture checkout.sessions.create params --- +let capturedCheckout = null; +const fakeStripeFactory = () => ({ + customers: { create: async () => ({ id: 'cus_test' }) }, + checkout: { sessions: { create: async (params) => { capturedCheckout = params; return { url: 'https://stripe.test/checkout' }; } } }, + billingPortal: { sessions: { create: async () => ({ url: 'https://stripe.test/portal' }) } }, +}); +const stripePath = require.resolve('stripe'); +require.cache[stripePath] = { id: stripePath, filename: stripePath, loaded: true, exports: fakeStripeFactory }; + +// --- stub requireAuth so the route runs with a fixed user (no JWT plumbing needed) --- +const authPath = require.resolve('../middleware/auth'); +require.cache[authPath] = { + id: authPath, filename: authPath, loaded: true, + exports: { + requireAuth: (req, _res, next) => { + req.user = { id: 'u-test', email: 'u@test.local', name: 'Test', + stripe_customer_id: 'cus_test', stripe_subscription_id: null }; + next(); + }, + }, +}; + +const { db } = require('../db/database'); +// A plan with a Stripe price so the handler reaches checkout.sessions.create. +db.prepare(`INSERT OR REPLACE INTO plans (id, name, display_name, stripe_price_monthly, stripe_price_yearly) + VALUES ('promo_test', 'promo_test', 'Promo', 'price_test_m', 'price_test_y')`).run(); + +const stripeRouter = require('../routes/stripe'); + +let server, base; +before(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/stripe', stripeRouter); + server = http.createServer(app); + await new Promise((r) => server.listen(0, r)); + base = `http://127.0.0.1:${server.address().port}`; +}); +after(() => new Promise((r) => server.close(r))); + +test('POST /checkout passes allow_promotion_codes:true to Stripe', async () => { + capturedCheckout = null; + const res = await fetch(`${base}/api/stripe/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan_id: 'promo_test', interval: 'monthly' }), + }); + const body = await res.json(); + assert.equal(res.status, 200); + assert.equal(body.type, 'checkout', 'took the checkout branch, not the portal branch'); + + assert.ok(capturedCheckout, 'checkout.sessions.create was called'); + assert.equal(capturedCheckout.allow_promotion_codes, true, + 'allow_promotion_codes:true must be present so the promo-code field renders on hosted checkout'); + // sanity: it is still a subscription checkout for the requested price + assert.equal(capturedCheckout.mode, 'subscription'); + assert.equal(capturedCheckout.line_items[0].price, 'price_test_m'); +});