Skip to content
110 changes: 109 additions & 1 deletion browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,23 @@ const UNEXPECTED_OPERATION_RESPONSE = {
completed_at: '2026-07-20T00:00:01Z'
} satisfies BillingOpStatusResponse

const RECOVERED_3DS_OPERATION_ID = 'recovered-3ds-subscription'
const RECOVERED_3DS_ACTION_URL = 'https://verify.example/3ds-session'

const RECOVERED_3DS_STATUS = {
...ACTIVE_TEAM_STATUS,
billing_status: 'pending_payment',
pending_billing_op_id: RECOVERED_3DS_OPERATION_ID,
action_url: RECOVERED_3DS_ACTION_URL
} satisfies BillingStatusResponse

const RECOVERED_3DS_OPERATION = {
id: RECOVERED_3DS_OPERATION_ID,
status: 'pending',
started_at: '2026-07-20T00:00:00Z',
action_url: RECOVERED_3DS_ACTION_URL
} satisfies BillingOpStatusResponse

const TRANSIENT_STATUS_ERROR = {
code: 'billing_status_unavailable',
message: 'Billing status is temporarily unavailable'
Expand Down Expand Up @@ -444,6 +461,36 @@ async function mockPopupBlockedCreatorDowngrade(page: Page) {
}
}

async function mockRecovered3dsSubscription(page: Page) {
const statusRequests: Request[] = []
const operationPollRequests: Request[] = []
const subscribeRequests: Request[] = []

await page.route('**/api/billing/status', (route) => {
statusRequests.push(route.request())
return route.fulfill(jsonRoute(RECOVERED_3DS_STATUS))
})
await page.route(
`**/api/billing/ops/${RECOVERED_3DS_OPERATION_ID}`,
(route) => {
operationPollRequests.push(route.request())
return route.fulfill(jsonRoute(RECOVERED_3DS_OPERATION))
}
)
await page.route('**/api/billing/subscribe', (route) => {
subscribeRequests.push(route.request())
return route.fulfill({
...jsonRoute({
code: 'unexpected_subscribe',
message: 'Recovered operations must not resubscribe'
} satisfies ErrorResponse),
status: 500
})
})

return { statusRequests, operationPollRequests, subscribeRequests }
}

const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })

Expand Down Expand Up @@ -668,7 +715,7 @@ test.describe('Scheduled Team downgrade', { tag: '@cloud' }, () => {
releasePostSubscribeRefresh = downgradeMock.releasePostSubscribeRefresh
})

test('shows the existing success view when subscribe replays 200', async ({
test('completes the non-3DS flow when subscribe replays 200', async ({
page
}) => {
await page.goto(`${APP_URL}/?pricing=personal`)
Expand Down Expand Up @@ -696,6 +743,9 @@ test.describe('Scheduled Team downgrade', { tag: '@cloud' }, () => {
name: "You're all set"
})
await expect(successHeading).toBeVisible()
await expect(
page.getByRole('button', { name: 'Complete verification' })
).toBeHidden()
await expect.poll(() => statusRefreshRequests.length).toBe(1)
await expect.poll(() => balanceRefreshRequests.length).toBe(1)
const successView = successHeading.locator('..').locator('..')
Expand All @@ -721,6 +771,64 @@ test.describe('Scheduled Team downgrade', { tag: '@cloud' }, () => {
})
})

test.describe('Recovered 3DS subscription', { tag: '@cloud' }, () => {
let statusRequests: Request[]
let operationPollRequests: Request[]
let subscribeRequests: Request[]

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.open = (url, target, features) => {
document.documentElement.dataset.openedUrl = String(url)
document.documentElement.dataset.openedTarget = target ?? ''
document.documentElement.dataset.openedFeatures = features ?? ''
return window
}
})
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
const recoveryMock = await mockRecovered3dsSubscription(page)
statusRequests = recoveryMock.statusRequests
operationPollRequests = recoveryMock.operationPollRequests
subscribeRequests = recoveryMock.subscribeRequests
})

test('recovers on a fresh page without resubscribing and opens verification on click', async ({
page
}) => {
await page.goto(APP_URL)
await waitForCloudApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByTestId('manage-plan-menu-item').click()
await expect.poll(() => statusRequests.length).toBeGreaterThan(0)
await expect.poll(() => operationPollRequests.length).toBeGreaterThan(0)

const verificationButton = page.getByRole('button', {
name: 'Complete verification'
})
await expect(verificationButton).toBeVisible()
await expect(page.locator('html')).not.toContainText(
RECOVERED_3DS_ACTION_URL
)
expect(subscribeRequests).toHaveLength(0)

await verificationButton.click()

await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe(RECOVERED_3DS_ACTION_URL)
await expect(page.locator('html')).toHaveAttribute(
'data-opened-target',
'_blank'
)
await expect(page.locator('html')).toHaveAttribute(
'data-opened-features',
'noopener,noreferrer'
)
})
})

test.describe(
'Billing reconciliation after plan changes',
{ tag: '@cloud' },
Expand Down
12 changes: 12 additions & 0 deletions packages/ingest-types/src/types.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/ingest-types/src/zod.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2850,6 +2850,7 @@
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
},
"preview": {
"completeVerification": "Complete verification",
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starts today",
Expand Down
3 changes: 2 additions & 1 deletion src/platform/workspace/api/workspaceApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,8 @@ describe('workspaceApi', () => {
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/api/billing/ops/op-1',
{
headers: AUTH_HEADER
headers: AUTH_HEADER,
timeout: 30_000
}
)
expect(result).toEqual(data)
Expand Down
5 changes: 4 additions & 1 deletion src/platform/workspace/api/workspaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ export interface BillingStatusResponse {
subscription_duration?: SubscriptionDuration
plan_slug?: string
billing_status?: BillingStatus
pending_billing_op_id?: string
action_url?: string
has_funds: boolean
cancel_at?: string
renewal_date?: string
Expand Down Expand Up @@ -313,6 +315,7 @@ export interface BillingOpStatusResponse {
error_message?: string
started_at: string
completed_at?: string
action_url?: string
}

interface BillingEvent {
Expand Down Expand Up @@ -814,7 +817,7 @@ export const workspaceApi = {
try {
const response = await workspaceApiClient.get<BillingOpStatusResponse>(
api.apiURL(`/billing/ops/${opId}`),
{ headers }
{ headers, timeout: 30_000 }
)
return response.data
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,39 @@ describe('SubscriptionAddPaymentPreviewWorkspace', () => {
)
expect(emitted().addCreditCard).toBeTruthy()
})

it('opens verification only from its button without exposing the URL', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
const open = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const { container } = render(SubscriptionAddPaymentPreviewWorkspace, {
props: { tierKey: 'creator', actionUrl },
global: globalOptions
})

expect(open).not.toHaveBeenCalled()
expect(container.innerHTML).not.toContain(actionUrl)
await userEvent.click(
screen.getByRole('button', {
name: 'subscription.preview.completeVerification'
})
)
expect(open).toHaveBeenCalledWith(
actionUrl,
'_blank',
'noopener,noreferrer'
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('prevents leaving the preview while payment is pending', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: { tierKey: 'creator', isLoading: true },
global: globalOptions
})

expect(
screen.getByRole('button', {
name: 'subscription.preview.backToAllPlans'
})
).toBeDisabled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,16 @@
<!-- Terms Agreement -->
<SubscriptionTermsNote />

<!-- Add Credit Card Button -->
<Button
v-if="actionUrl"
variant="primary"
size="lg"
class="w-full rounded-lg"
@click="openVerification"
>
{{ $t('subscription.preview.completeVerification') }}
</Button>

<Button
variant="tertiary"
size="lg"
Expand All @@ -151,6 +160,7 @@
<Button
variant="textonly"
class="cursor-pointer text-center text-xs text-muted-foreground transition-colors hover:bg-none hover:text-base-foreground"
:disabled="isLoading"
@click="$emit('back')"
>
{{ $t('subscription.preview.backToAllPlans') }}
Expand Down Expand Up @@ -186,14 +196,16 @@ interface Props {
previewData?: PreviewSubscribeResponse | null
/** Team-plan checkout (selected slider stop); overrides tier-derived display. */
teamPlan?: TeamPlanSelection | null
actionUrl?: string | null
}

const {
tierKey,
billingCycle = 'monthly',
isLoading = false,
previewData = null,
teamPlan = null
teamPlan = null,
actionUrl = null
} = defineProps<Props>()

defineEmits<{
Expand All @@ -205,6 +217,11 @@ const { t, n } = useI18n()

const isFeaturesCollapsed = ref(true)

function openVerification() {
if (!actionUrl) return
window.open(actionUrl, '_blank', 'noopener,noreferrer')
}

const tierName = computed(() =>
teamPlan
? t('subscription.teamPlan.name')
Expand Down
Loading
Loading