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
2 changes: 1 addition & 1 deletion modules/billing/STRIPE_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project>.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.
3 changes: 3 additions & 0 deletions modules/billing/services/billing.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions modules/billing/tests/billing.checkout.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down
Loading