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
1 change: 1 addition & 0 deletions .github/workflows/frontend-deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/frontend-test-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ jobs:
e2e_test_token: ${{ secrets.E2E_TEST_TOKEN }}
slack_token: ${{ secrets.SLACK_TOKEN }}
environment: staging
tests: --grep "@oss|@enterprise|@saas"
3 changes: 3 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
12 changes: 12 additions & 0 deletions api/e2etests/e2e_seed_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = [
{
Expand Down
1 change: 1 addition & 0 deletions frontend/e2e/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
115 changes: 115 additions & 0 deletions frontend/e2e/tests/billing-test.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { test, expect } from '../test-setup'
import { byId, log, createHelpers } from '../helpers'
import { E2E_BILLING_USER, PASSWORD } from '../config'

declare global {
interface Window {
__cbCheckoutClicks: Array<string | null>
}
}

test.describe('Billing', () => {
test('Free trial buttons stay wired after toggling between yearly and monthly @saas', async ({
page,
}) => {
const { click, login, waitForElementVisible } = createHelpers(page)

// 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<HTMLElement>('[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_BILLING_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 getTrialButton = () =>
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 = getTrialButton()
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 = getTrialButton()
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 = getTrialButton()
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])
})
})
6 changes: 3 additions & 3 deletions frontend/web/components/modals/payment/Payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,9 +42,9 @@ export const Payment: FC<PaymentProps> = ({

useEffect(() => {
if (ready && !error) {
initChargebee({ isPaymentsEnabled })
bindChargebeeButtons({ isPaymentsEnabled })
}
}, [ready, error, isPaymentsEnabled])
}, [ready, error, isPaymentsEnabled, yearly])

if (isAWS) {
return (
Expand Down
25 changes: 18 additions & 7 deletions frontend/web/components/modals/payment/chargebee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,21 @@ import firstpromoter from 'project/firstPromoter'

let initialised = false

type InitChargebeeParams = {
type BindChargebeeButtonsParams = {
isPaymentsEnabled: boolean
}

export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => {
if (initialised || !Project.chargebee?.site) return

const initChargebee = ({
isPaymentsEnabled,
}: BindChargebeeButtonsParams) => {
Chargebee.init({ site: Project.chargebee.site })
Chargebee.registerAgain()
firstpromoter()
Chargebee.getInstance().setCheckoutCallbacks?.(() => ({
success: (hostedPageId: string) => {
AppActions.updateSubscription(hostedPageId)
},
}))

// Handle plan cookie from signup flow
const planId = API.getCookie('plan')
if (planId && isPaymentsEnabled) {
const link = document.createElement('a')
Expand All @@ -32,6 +30,19 @@ export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => {
document.body.removeChild(link)
API.setCookie('plan', null)
}
}

export const bindChargebeeButtons = ({
isPaymentsEnabled,
}: BindChargebeeButtonsParams) => {
if (!Project.chargebee?.site) return

initialised = true
if (!initialised) {
initChargebee({ isPaymentsEnabled })
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()
}
Loading