From 3aa7a99c07f26ab850c70bbac04ea7408af33832 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Thu, 4 Jun 2026 17:37:50 +0200 Subject: [PATCH] feat(billing): config-gate allow_promotion_codes on subscription Checkout (#3793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the automaticTax pattern: set checkoutParams.allow_promotion_codes = true only when config.stripe.allowPromotionCodes === true (strict, default off). Existing downstreams (comes/montaine/pierreb/ism) are unaffected. Extras-pack Checkout left unchanged (one-time payment, no coupon use-case). Fix false claim in STRIPE_SETUP.md (doc/code drift). Add 4 unit tests: absent/false/truthy-non-strict → absent; true → present. --- modules/billing/STRIPE_SETUP.md | 2 +- modules/billing/services/billing.service.js | 3 + .../tests/billing.checkout.unit.tests.js | 72 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/modules/billing/STRIPE_SETUP.md b/modules/billing/STRIPE_SETUP.md index ded41ddbe..e4f56df3c 100644 --- a/modules/billing/STRIPE_SETUP.md +++ b/modules/billing/STRIPE_SETUP.md @@ -75,4 +75,4 @@ This allows EU B2B customers to enter their VAT number (`FR12345678901`) during **Effect**: invoices generated by Stripe include the customer's VAT ID, making them eligible for VAT reversal (autoliquidation) under EU B2B rules — required for French B2B invoice compliance. -No code change required — `billing.service.js` passes `allow_promotion_codes: true` to Checkout; `tax_id_collection` is a Dashboard-level toggle that Stripe applies automatically to all Checkout sessions. +Promo codes on Checkout are **config-gated** (default off). Set `config.stripe.allowPromotionCodes: true` in the downstream `defaults/.config.js` to enable the promo-code field on hosted Checkout. `tax_id_collection` is a Dashboard-level toggle that Stripe applies automatically to all Checkout sessions. diff --git a/modules/billing/services/billing.service.js b/modules/billing/services/billing.service.js index 5318541fd..caf984e7c 100644 --- a/modules/billing/services/billing.service.js +++ b/modules/billing/services/billing.service.js @@ -195,6 +195,9 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => { checkoutParams.automatic_tax = { enabled: true }; checkoutParams.customer_update = { address: 'auto', name: 'auto' }; } + if (config?.stripe?.allowPromotionCodes === true) { + checkoutParams.allow_promotion_codes = true; + } // No Stripe idempotency key here. Checkout sessions are ephemeral (24h TTL). // A static `sub_checkout_${orgId}_${priceId}` key locks the user into a // single session for 24h: if they abandon the first attempt, retrying within diff --git a/modules/billing/tests/billing.checkout.unit.tests.js b/modules/billing/tests/billing.checkout.unit.tests.js index fea01928f..351752041 100644 --- a/modules/billing/tests/billing.checkout.unit.tests.js +++ b/modules/billing/tests/billing.checkout.unit.tests.js @@ -311,6 +311,78 @@ describe('Billing service unit tests:', () => { expect(callArgs.customer_update).toBeUndefined(); }); + test('should NOT include allow_promotion_codes when config.stripe.allowPromotionCodes is absent', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_no_promo' } }, + })); + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + stripeCustomerId: 'cus_no_promo', + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'); + + const callArgs = mockStripeInstance.checkout.sessions.create.mock.calls[0][0]; + expect(callArgs.allow_promotion_codes).toBeUndefined(); + }); + + test('should NOT include allow_promotion_codes when config.stripe.allowPromotionCodes is false', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_promo_false', allowPromotionCodes: false } }, + })); + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + stripeCustomerId: 'cus_promo_false', + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'); + + const callArgs = mockStripeInstance.checkout.sessions.create.mock.calls[0][0]; + expect(callArgs.allow_promotion_codes).toBeUndefined(); + }); + + test('should include allow_promotion_codes: true when config.stripe.allowPromotionCodes is true', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_promo_on', allowPromotionCodes: true } }, + })); + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + stripeCustomerId: 'cus_promo_on', + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'); + + const callArgs = mockStripeInstance.checkout.sessions.create.mock.calls[0][0]; + expect(callArgs.allow_promotion_codes).toBe(true); + }); + + test('should NOT include allow_promotion_codes when allowPromotionCodes is truthy but not strict true (e.g. 1)', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_promo_truthy', allowPromotionCodes: 1 } }, + })); + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + stripeCustomerId: 'cus_promo_truthy', + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'); + + const callArgs = mockStripeInstance.checkout.sessions.create.mock.calls[0][0]; + expect(callArgs.allow_promotion_codes).toBeUndefined(); + }); + test('should throw 409 with portalUrl when active subscription exists', async () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: { stripe: { secretKey: 'sk_test_block_active' } },