From 37c8fb5511441fe2775bc02739bdbb9d6c554b10 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 11:26:36 +0100 Subject: [PATCH 1/5] fix(Billing): Free trial buttons stop working after toggling yearly/monthly Chargebee's DOM-scan registration (`registerAgain`) only ran on the first `initChargebee` call, so the `PaymentButton` instances remounted by the yearly/monthly toggle picked up no click handler and silently did nothing. Call `registerAgain` on every `initChargebee` entry (one-time init stays guarded) and re-run the effect when the toggle changes. beep boop --- frontend/e2e/tests/billing-test.pw.ts | 126 ++++++++++++++++++ .../web/components/modals/payment/Payment.tsx | 2 +- .../components/modals/payment/chargebee.ts | 48 ++++--- 3 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 frontend/e2e/tests/billing-test.pw.ts diff --git a/frontend/e2e/tests/billing-test.pw.ts b/frontend/e2e/tests/billing-test.pw.ts new file mode 100644 index 000000000000..1fe99574fad6 --- /dev/null +++ b/frontend/e2e/tests/billing-test.pw.ts @@ -0,0 +1,126 @@ +import { test, expect } from '../test-setup' +import { byId, log, createHelpers, getFlagsmith } from '../helpers' +import { E2E_USER, PASSWORD } from '../config' + +declare global { + interface Window { + __cbCheckoutClicks: Array + } +} + +test.describe('Billing', () => { + test('Free trial buttons stay wired after toggling between yearly and monthly @enterprise', async ({ + page, + }) => { + const { click, login, waitForElementVisible } = createHelpers(page) + + const flagsmith = await getFlagsmith() + if ( + !flagsmith.hasFeature('payments_enabled') || + !flagsmith.hasFeature('rtk_payment_modal_migration') + ) { + log( + 'Skipping: requires payments_enabled and rtk_payment_modal_migration flags', + ) + return + } + + // Serve an empty Chargebee bundle so useScript resolves without pulling + // the real library from Chargebee's CDN. + await page.route('**/js.chargebee.com/**/chargebee.js*', (route) => + route.fulfill({ + body: '', + contentType: 'application/javascript', + status: 200, + }), + ) + + // Stub window.Chargebee with a minimal implementation of the DOM-scan + // contract: registerAgain() binds a click handler to every + // [data-cb-type="checkout"] element that records the plan id. + // This is what the real library does — we record instead of opening a + // hosted checkout so the test can observe whether the button is wired. + await page.addInitScript(() => { + window.__cbCheckoutClicks = [] + const registerAgain = () => { + document + .querySelectorAll('[data-cb-type="checkout"]') + .forEach((element) => { + const marker = '__cbBound' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((element as any)[marker]) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(element as any)[marker] = true + element.addEventListener('click', (event) => { + event.preventDefault() + window.__cbCheckoutClicks.push( + element.getAttribute('data-cb-plan-id'), + ) + }) + }) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Chargebee = { + getInstance: () => ({ + openCheckout: () => {}, + setCheckoutCallbacks: () => {}, + }), + init: () => {}, + registerAgain, + } + }) + + log('Login') + await login(E2E_USER, PASSWORD) + + log('Navigate to Billing tab') + await waitForElementVisible(byId('organisation-link')) + await click(byId('organisation-link')) + await waitForElementVisible(byId('org-settings-link')) + await click(byId('org-settings-link')) + await click(byId('billing')) + + const trialButton = () => + page + .locator('button[data-cb-type="checkout"]') + .filter({ hasText: '14 Day Free Trial' }) + .first() + const toggle = page.getByRole('switch').first() + + log('Confirm free-trial button is wired in yearly mode') + const yearlyButton = trialButton() + await yearlyButton.waitFor({ state: 'visible' }) + const yearlyPlanId = await yearlyButton.getAttribute('data-cb-plan-id') + expect(yearlyPlanId).toBeTruthy() + await yearlyButton.click() + await expect + .poll(() => page.evaluate(() => window.__cbCheckoutClicks)) + .toEqual([yearlyPlanId]) + + log('Toggle to Monthly and click the free-trial button') + await toggle.click() + const monthlyButton = trialButton() + await monthlyButton.waitFor({ state: 'visible' }) + const monthlyPlanId = await monthlyButton.getAttribute('data-cb-plan-id') + expect(monthlyPlanId).toBeTruthy() + expect(monthlyPlanId).not.toBe(yearlyPlanId) + await monthlyButton.click() + // Before the fix, registerAgain is not called after the toggle, so the + // remounted button has no click handler and nothing is recorded. + await expect + .poll(() => page.evaluate(() => window.__cbCheckoutClicks)) + .toEqual([yearlyPlanId, monthlyPlanId]) + + log('Toggle back to Yearly and click the free-trial button') + await toggle.click() + const yearlyButtonAgain = trialButton() + await yearlyButtonAgain.waitFor({ state: 'visible' }) + expect(await yearlyButtonAgain.getAttribute('data-cb-plan-id')).toBe( + yearlyPlanId, + ) + await yearlyButtonAgain.click() + await expect + .poll(() => page.evaluate(() => window.__cbCheckoutClicks)) + .toEqual([yearlyPlanId, monthlyPlanId, yearlyPlanId]) + }) +}) diff --git a/frontend/web/components/modals/payment/Payment.tsx b/frontend/web/components/modals/payment/Payment.tsx index a099ec443cc3..80ca73dfe573 100644 --- a/frontend/web/components/modals/payment/Payment.tsx +++ b/frontend/web/components/modals/payment/Payment.tsx @@ -44,7 +44,7 @@ export const Payment: FC = ({ if (ready && !error) { initChargebee({ isPaymentsEnabled }) } - }, [ready, error, isPaymentsEnabled]) + }, [ready, error, isPaymentsEnabled, yearly]) if (isAWS) { return ( diff --git a/frontend/web/components/modals/payment/chargebee.ts b/frontend/web/components/modals/payment/chargebee.ts index c6be329bf2aa..a496a795bf90 100644 --- a/frontend/web/components/modals/payment/chargebee.ts +++ b/frontend/web/components/modals/payment/chargebee.ts @@ -8,30 +8,34 @@ type InitChargebeeParams = { } export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => { - if (initialised || !Project.chargebee?.site) return + if (!Project.chargebee?.site) return - Chargebee.init({ site: Project.chargebee.site }) - Chargebee.registerAgain() - firstpromoter() - Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ - success: (hostedPageId: string) => { - AppActions.updateSubscription(hostedPageId) - }, - })) + if (!initialised) { + Chargebee.init({ site: Project.chargebee.site }) + firstpromoter() + Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ + success: (hostedPageId: string) => { + AppActions.updateSubscription(hostedPageId) + }, + })) + + const planId = API.getCookie('plan') + if (planId && isPaymentsEnabled) { + const link = document.createElement('a') + link.setAttribute('data-cb-type', 'checkout') + link.setAttribute('data-cb-plan-id', planId) + link.setAttribute('href', '#') + document.body.appendChild(link) + Chargebee.registerAgain() + link.click() + document.body.removeChild(link) + API.setCookie('plan', null) + } - // Handle plan cookie from signup flow - const planId = API.getCookie('plan') - if (planId && isPaymentsEnabled) { - const link = document.createElement('a') - link.setAttribute('data-cb-type', 'checkout') - link.setAttribute('data-cb-plan-id', planId) - link.setAttribute('href', '#') - document.body.appendChild(link) - Chargebee.registerAgain() - link.click() - document.body.removeChild(link) - API.setCookie('plan', null) + initialised = true } - initialised = true + // Re-scan the DOM so buttons rendered after the initial mount + // (e.g. after toggling the yearly/monthly switch) get wired up. + Chargebee.registerAgain() } From 239f6adcf97fc112821d48a9046a100ef028bcd3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 11:36:54 +0100 Subject: [PATCH 2/5] test(Billing): Promote the free-trial test to the new @saas marker Billing only exists on SaaS (Chargebee, trial buttons, paid plan toggles), so the @enterprise tag was a misnomer: private-cloud builds don't ship payments and the runtime flag-guard skipped the test there anyway. Introduce a @saas marker and wire it into the staging/production E2E runs alongside @oss and @enterprise, so the same grep makes intent explicit and leaves private-cloud runs free of payments-only tests. beep boop --- .github/workflows/frontend-deploy-production.yml | 1 + .github/workflows/frontend-test-staging.yml | 1 + frontend/e2e/tests/billing-test.pw.ts | 15 ++------------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/frontend-deploy-production.yml b/.github/workflows/frontend-deploy-production.yml index 2fdb43ae3d70..5f2e1b3f66e7 100644 --- a/.github/workflows/frontend-deploy-production.yml +++ b/.github/workflows/frontend-deploy-production.yml @@ -68,6 +68,7 @@ jobs: e2e_test_token: ${{ secrets.E2E_TEST_TOKEN }} slack_token: ${{ secrets.SLACK_TOKEN }} environment: prod + tests: --grep "@oss|@enterprise|@saas" deploy-production: name: Deploy to Vercel Production diff --git a/.github/workflows/frontend-test-staging.yml b/.github/workflows/frontend-test-staging.yml index da8f552a281a..00e516b235d5 100644 --- a/.github/workflows/frontend-test-staging.yml +++ b/.github/workflows/frontend-test-staging.yml @@ -21,3 +21,4 @@ jobs: e2e_test_token: ${{ secrets.E2E_TEST_TOKEN }} slack_token: ${{ secrets.SLACK_TOKEN }} environment: staging + tests: --grep "@oss|@enterprise|@saas" diff --git a/frontend/e2e/tests/billing-test.pw.ts b/frontend/e2e/tests/billing-test.pw.ts index 1fe99574fad6..11c161ecee4f 100644 --- a/frontend/e2e/tests/billing-test.pw.ts +++ b/frontend/e2e/tests/billing-test.pw.ts @@ -1,5 +1,5 @@ import { test, expect } from '../test-setup' -import { byId, log, createHelpers, getFlagsmith } from '../helpers' +import { byId, log, createHelpers } from '../helpers' import { E2E_USER, PASSWORD } from '../config' declare global { @@ -9,22 +9,11 @@ declare global { } test.describe('Billing', () => { - test('Free trial buttons stay wired after toggling between yearly and monthly @enterprise', async ({ + test('Free trial buttons stay wired after toggling between yearly and monthly @saas', async ({ page, }) => { const { click, login, waitForElementVisible } = createHelpers(page) - const flagsmith = await getFlagsmith() - if ( - !flagsmith.hasFeature('payments_enabled') || - !flagsmith.hasFeature('rtk_payment_modal_migration') - ) { - log( - 'Skipping: requires payments_enabled and rtk_payment_modal_migration flags', - ) - return - } - // Serve an empty Chargebee bundle so useScript resolves without pulling // the real library from Chargebee's CDN. await page.route('**/js.chargebee.com/**/chargebee.js*', (route) => From 832c145babe084c6820862f1b2c18dce84a216b7 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 12:06:03 +0100 Subject: [PATCH 3/5] test(Billing): Seed a dedicated Free-plan user for trial-button tests PaymentButton only hits the Chargebee DOM-scan branch (where the bug lives) when the organisation has no active subscription. e2e_user's org is seeded on Enterprise with a live subscription_id for the rest of the suite, so billing tests can't reach the buggy code path on it. Add e2e_billing_user on its own organisation, leaving the default Free-plan Subscription the Organisation hook creates untouched, and switch billing-test.pw.ts to that user. beep boop --- api/app/settings/common.py | 3 +++ api/e2etests/e2e_seed_data.py | 12 ++++++++++++ frontend/e2e/config.ts | 1 + frontend/e2e/tests/billing-test.pw.ts | 4 ++-- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index a112501fd89d..634c27cf9bd5 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -601,6 +601,9 @@ f"e2e_non_admin_user_with_a_role@{E2E_TEST_EMAIL_DOMAIN}" ) E2E_SEPARATE_TEST_USER = f"e2e_separate_test_user@{E2E_TEST_EMAIL_DOMAIN}" +# User on a Free-plan organisation, used by the Billing tab E2E tests +# that need to exercise the "no active subscription" payment flow. +E2E_BILLING_USER = f"e2e_billing_user@{E2E_TEST_EMAIL_DOMAIN}" # Identity for E2E segment tests E2E_IDENTITY = "test-identity" diff --git a/api/e2etests/e2e_seed_data.py b/api/e2etests/e2e_seed_data.py index 3a08ea5fc1ef..f00717c897de 100644 --- a/api/e2etests/e2e_seed_data.py +++ b/api/e2etests/e2e_seed_data.py @@ -59,6 +59,7 @@ def teardown() -> None: user_email=settings.E2E_NON_ADMIN_USER_WITH_A_ROLE ) delete_user_and_its_organisations(user_email=settings.E2E_SEPARATE_TEST_USER) + delete_user_and_its_organisations(user_email=settings.E2E_BILLING_USER) def seed_data() -> None: @@ -108,6 +109,17 @@ def seed_data() -> None: ) separate_test_user.add_organisation(separate_org, OrganisationRole.ADMIN) + # Billing-tab tests need an organisation on a Free plan (no + # `subscription_id`) so PaymentButton renders the Chargebee DOM-scan + # branch. The default Subscription created by Organisation's AFTER_CREATE + # hook already matches — leave it untouched. + billing_org: Organisation = Organisation.objects.create(name="E2E Billing Org") + billing_user: FFAdminUser = FFAdminUser.objects.create_user( # type: ignore[no-untyped-call] + email=settings.E2E_BILLING_USER, + password=PASSWORD, + ) + billing_user.add_organisation(billing_org, OrganisationRole.ADMIN) + # We add different projects and environments to give each e2e test its own isolated context. project_test_data = [ { diff --git a/frontend/e2e/config.ts b/frontend/e2e/config.ts index d09b12a1b8e8..e7d3146d6fea 100644 --- a/frontend/e2e/config.ts +++ b/frontend/e2e/config.ts @@ -10,6 +10,7 @@ export const E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS = `e2e_non_admin_user_with_ export const E2E_NON_ADMIN_USER_WITH_A_ROLE = `e2e_non_admin_user_with_a_role@${E2E_EMAIL_DOMAIN}` export const E2E_CHANGE_MAIL = `e2e_change_email@${E2E_EMAIL_DOMAIN}` export const E2E_SEPARATE_TEST_USER = `e2e_separate_test_user@${E2E_EMAIL_DOMAIN}` +export const E2E_BILLING_USER = `e2e_billing_user@${E2E_EMAIL_DOMAIN}` export const PASSWORD = 'Str0ngp4ssw0rd!' export const E2E_TEST_IDENTITY = 'test-identity' diff --git a/frontend/e2e/tests/billing-test.pw.ts b/frontend/e2e/tests/billing-test.pw.ts index 11c161ecee4f..aedeee2e76ed 100644 --- a/frontend/e2e/tests/billing-test.pw.ts +++ b/frontend/e2e/tests/billing-test.pw.ts @@ -1,6 +1,6 @@ import { test, expect } from '../test-setup' import { byId, log, createHelpers } from '../helpers' -import { E2E_USER, PASSWORD } from '../config' +import { E2E_BILLING_USER, PASSWORD } from '../config' declare global { interface Window { @@ -60,7 +60,7 @@ test.describe('Billing', () => { }) log('Login') - await login(E2E_USER, PASSWORD) + await login(E2E_BILLING_USER, PASSWORD) log('Navigate to Billing tab') await waitForElementVisible(byId('organisation-link')) From abdc85b899aa197964554c360fda740f927ecc9e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 12:29:09 +0100 Subject: [PATCH 4/5] test(Billing): Rename trialButton factory to getTrialButton beep boop --- frontend/e2e/tests/billing-test.pw.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/e2e/tests/billing-test.pw.ts b/frontend/e2e/tests/billing-test.pw.ts index aedeee2e76ed..6195e8de213e 100644 --- a/frontend/e2e/tests/billing-test.pw.ts +++ b/frontend/e2e/tests/billing-test.pw.ts @@ -69,7 +69,7 @@ test.describe('Billing', () => { await click(byId('org-settings-link')) await click(byId('billing')) - const trialButton = () => + const getTrialButton = () => page .locator('button[data-cb-type="checkout"]') .filter({ hasText: '14 Day Free Trial' }) @@ -77,7 +77,7 @@ test.describe('Billing', () => { const toggle = page.getByRole('switch').first() log('Confirm free-trial button is wired in yearly mode') - const yearlyButton = trialButton() + const yearlyButton = getTrialButton() await yearlyButton.waitFor({ state: 'visible' }) const yearlyPlanId = await yearlyButton.getAttribute('data-cb-plan-id') expect(yearlyPlanId).toBeTruthy() @@ -88,7 +88,7 @@ test.describe('Billing', () => { log('Toggle to Monthly and click the free-trial button') await toggle.click() - const monthlyButton = trialButton() + const monthlyButton = getTrialButton() await monthlyButton.waitFor({ state: 'visible' }) const monthlyPlanId = await monthlyButton.getAttribute('data-cb-plan-id') expect(monthlyPlanId).toBeTruthy() @@ -102,7 +102,7 @@ test.describe('Billing', () => { log('Toggle back to Yearly and click the free-trial button') await toggle.click() - const yearlyButtonAgain = trialButton() + const yearlyButtonAgain = getTrialButton() await yearlyButtonAgain.waitFor({ state: 'visible' }) expect(await yearlyButtonAgain.getAttribute('data-cb-plan-id')).toBe( yearlyPlanId, From 11045aacf55fa242aea881ba27572ecf9fb7b424 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 12:32:42 +0100 Subject: [PATCH 5/5] refactor(Billing): Split bindChargebeeButtons from one-time initChargebee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep initChargebee focused on the one-time init (init, checkout callbacks, plan-cookie flow) and let the exported entry point read as what callers are actually asking for — wire the currently rendered Chargebee buttons. beep boop --- .../web/components/modals/payment/Payment.tsx | 4 +- .../components/modals/payment/chargebee.ts | 53 +++++++++++-------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/frontend/web/components/modals/payment/Payment.tsx b/frontend/web/components/modals/payment/Payment.tsx index 80ca73dfe573..67cc56d61b92 100644 --- a/frontend/web/components/modals/payment/Payment.tsx +++ b/frontend/web/components/modals/payment/Payment.tsx @@ -19,7 +19,7 @@ import { } from './constants' import { useScript } from 'common/hooks/useScript' import { usePaymentState } from './hooks' -import { initChargebee } from './chargebee' +import { bindChargebeeButtons } from './chargebee' export type PaymentProps = { isDisableAccountText?: string @@ -42,7 +42,7 @@ export const Payment: FC = ({ useEffect(() => { if (ready && !error) { - initChargebee({ isPaymentsEnabled }) + bindChargebeeButtons({ isPaymentsEnabled }) } }, [ready, error, isPaymentsEnabled, yearly]) diff --git a/frontend/web/components/modals/payment/chargebee.ts b/frontend/web/components/modals/payment/chargebee.ts index a496a795bf90..1ee8d20f23f1 100644 --- a/frontend/web/components/modals/payment/chargebee.ts +++ b/frontend/web/components/modals/payment/chargebee.ts @@ -3,35 +3,42 @@ import firstpromoter from 'project/firstPromoter' let initialised = false -type InitChargebeeParams = { +type BindChargebeeButtonsParams = { isPaymentsEnabled: boolean } -export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => { - if (!Project.chargebee?.site) return +const initChargebee = ({ + isPaymentsEnabled, +}: BindChargebeeButtonsParams) => { + Chargebee.init({ site: Project.chargebee.site }) + firstpromoter() + Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ + success: (hostedPageId: string) => { + AppActions.updateSubscription(hostedPageId) + }, + })) - if (!initialised) { - Chargebee.init({ site: Project.chargebee.site }) - firstpromoter() - Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ - success: (hostedPageId: string) => { - AppActions.updateSubscription(hostedPageId) - }, - })) + const planId = API.getCookie('plan') + if (planId && isPaymentsEnabled) { + const link = document.createElement('a') + link.setAttribute('data-cb-type', 'checkout') + link.setAttribute('data-cb-plan-id', planId) + link.setAttribute('href', '#') + document.body.appendChild(link) + Chargebee.registerAgain() + link.click() + document.body.removeChild(link) + API.setCookie('plan', null) + } +} - const planId = API.getCookie('plan') - if (planId && isPaymentsEnabled) { - const link = document.createElement('a') - link.setAttribute('data-cb-type', 'checkout') - link.setAttribute('data-cb-plan-id', planId) - link.setAttribute('href', '#') - document.body.appendChild(link) - Chargebee.registerAgain() - link.click() - document.body.removeChild(link) - API.setCookie('plan', null) - } +export const bindChargebeeButtons = ({ + isPaymentsEnabled, +}: BindChargebeeButtonsParams) => { + if (!Project.chargebee?.site) return + if (!initialised) { + initChargebee({ isPaymentsEnabled }) initialised = true }