diff --git a/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts b/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts index bb5cfb4afab..ae183a90de8 100644 --- a/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts +++ b/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts @@ -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' @@ -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' }) @@ -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`) @@ -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('..') @@ -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' }, diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts index f31edac5ef9..31a53f9af48 100644 --- a/packages/ingest-types/src/types.gen.ts +++ b/packages/ingest-types/src/types.gen.ts @@ -879,6 +879,10 @@ export type BillingOpStatusResponse = { * When the operation completed (success or failure) */ completed_at?: string + /** + * HTTPS URL for completing required customer authentication + */ + action_url?: string } /** @@ -1506,6 +1510,14 @@ export type BillingStatusResponse = { */ plan_slug?: string billing_status?: BillingStatus + /** + * The workspace's in-flight billing operation, when one exists. + */ + pending_billing_op_id?: string + /** + * The customer action URL for the pending operation, when action is required. + */ + action_url?: string /** * Whether the workspace has available credits */ diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts index c0d6f53b249..7ff7ba458cb 100644 --- a/packages/ingest-types/src/zod.gen.ts +++ b/packages/ingest-types/src/zod.gen.ts @@ -506,7 +506,8 @@ export const zBillingOpStatusResponse = z.object({ status: z.enum(['pending', 'succeeded', 'failed']), error_message: z.string().optional(), started_at: z.string().datetime(), - completed_at: z.string().datetime().optional() + completed_at: z.string().datetime().optional(), + action_url: z.string().optional() }) /** @@ -980,6 +981,8 @@ export const zBillingStatusResponse = z.object({ subscription_duration: zSubscriptionDuration.optional(), plan_slug: z.string().optional(), billing_status: zBillingStatus.optional(), + pending_billing_op_id: z.string().optional(), + action_url: z.string().optional(), has_funds: z.boolean(), cancel_at: z.string().datetime().optional(), renewal_date: z.string().datetime().optional(), diff --git a/src/locales/en/main.json b/src/locales/en/main.json index bc71e7fd800..8152f9c63b1 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/platform/workspace/api/workspaceApi.test.ts b/src/platform/workspace/api/workspaceApi.test.ts index 58c1ed40aa2..c927615f0c6 100644 --- a/src/platform/workspace/api/workspaceApi.test.ts +++ b/src/platform/workspace/api/workspaceApi.test.ts @@ -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) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 3ba0576b77f..0aa075bed29 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -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 @@ -313,6 +315,7 @@ export interface BillingOpStatusResponse { error_message?: string started_at: string completed_at?: string + action_url?: string } interface BillingEvent { @@ -814,7 +817,7 @@ export const workspaceApi = { try { const response = await workspaceApiClient.get( api.apiURL(`/billing/ops/${opId}`), - { headers } + { headers, timeout: 30_000 } ) return response.data } catch (err) { diff --git a/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.test.ts b/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.test.ts index af1e0eb7dbf..ac88a3e8222 100644 --- a/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.test.ts +++ b/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.test.ts @@ -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' + ) + }) + + 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() + }) }) diff --git a/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.vue b/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.vue index 8e15af61c89..b2ea72667ff 100644 --- a/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.vue @@ -136,7 +136,16 @@ - + + @@ -353,6 +362,14 @@ const { t, n, locale } = useI18n() const billingOperationStore = useBillingOperationStore() const isSettingUp = computed(() => billingOperationStore.isSettingUp) +const subscriptionActionUrl = computed( + () => billingOperationStore.subscriptionActionOperation?.actionUrl ?? null +) + +function openSubscriptionVerification() { + if (!subscriptionActionUrl.value) return + window.open(subscriptionActionUrl.value, '_blank', 'noopener,noreferrer') +} const { isActiveSubscription, diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.test.ts b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.test.ts index 05a0ea06e14..b746b4b8f91 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.test.ts +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.test.ts @@ -21,6 +21,7 @@ vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({ selectedTierKey: ref(null), selectedTeamStop: ref(null), selectedBillingCycle: ref('yearly'), + activeCheckoutActionUrl: ref(null), isPolling: ref(false), isTeamCheckout: computed(() => false), previewVariant: computed(() => null), diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue index 7371114b09d..c726ea26974 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue @@ -14,6 +14,7 @@ variant="muted-textonly" class="absolute top-2.5 left-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10" :aria-label="$t('g.back')" + :disabled="isPolling" @click="handleBackToPricing" > @@ -64,6 +65,7 @@ :preview-data="previewData!" :team-plan="selectedTeamStop!" :is-loading="isSubscribing || isPolling" + :action-url="activeCheckoutActionUrl" @confirm="handleTeamSubscribe" @back="handleBackToPricing" /> @@ -73,6 +75,7 @@ :team-plan="selectedTeamStop!" :billing-cycle="selectedBillingCycle" :is-loading="isSubscribing || isPolling" + :action-url="activeCheckoutActionUrl" @add-credit-card="handleTeamSubscribe" @back="handleBackToPricing" /> @@ -83,6 +86,7 @@ :tier-key="selectedTierKey!" :billing-cycle="selectedBillingCycle" :is-loading="isSubscribing || isPolling" + :action-url="activeCheckoutActionUrl" @add-credit-card="handleAddCreditCard" @back="handleBackToPricing" /> @@ -91,6 +95,7 @@ v-else-if="previewVariant === 'personal-change'" :preview-data="previewData!" :is-loading="isSubscribing || isPolling" + :action-url="activeCheckoutActionUrl" @confirm="handleConfirmTransition" @back="handleBackToPricing" /> @@ -144,6 +149,7 @@ const { selectedTierKey, selectedTeamStop, selectedBillingCycle, + activeCheckoutActionUrl, isPolling, isTeamCheckout, previewVariant, @@ -169,7 +175,12 @@ onMounted(() => { // Backspace mirrors the back arrow on the confirm step, but never while an // editable element is focused (let it delete text there). useEventListener(window, 'keydown', (event: KeyboardEvent) => { - if (event.key !== 'Backspace' || checkoutStep.value !== 'preview') return + if ( + event.key !== 'Backspace' || + checkoutStep.value !== 'preview' || + isPolling.value + ) + return const target = event.target if ( target instanceof HTMLInputElement || diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts index 550eb5a5271..c3e1ace8fd9 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts @@ -120,6 +120,7 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => { previewData: mockPreviewData, selectedTierKey: ref('standard'), selectedBillingCycle: ref('yearly'), + activeCheckoutActionUrl: ref(null), isPolling: ref(false), handleSubscribeClick: mockHandleSubscribeClick, handleBackToPricing: mockHandleBackToPricing, diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue index ca84db35d35..7c16c920966 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue @@ -8,6 +8,7 @@ variant="muted-textonly" class="absolute top-2.5 left-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10" :aria-label="$t('g.back')" + :disabled="isPolling" @click="handleBackToPricing" > @@ -85,6 +86,7 @@ :tier-key="selectedTierKey!" :billing-cycle="selectedBillingCycle" :is-loading="isSubscribing || isPolling" + :action-url="activeCheckoutActionUrl" @add-credit-card="handleAddCreditCard" @back="handleBackToPricing" /> @@ -98,6 +100,7 @@ " :preview-data="previewData" :is-loading="isSubscribing || isPolling" + :action-url="activeCheckoutActionUrl" @confirm="handleConfirmTransition" @back="handleBackToPricing" /> @@ -151,6 +154,7 @@ const { previewData, selectedTierKey, selectedBillingCycle, + activeCheckoutActionUrl, isPolling, handleSubscribeClick, handleBackToPricing, diff --git a/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.test.ts b/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.test.ts index 9abe51b9a42..6bf54c8fe10 100644 --- a/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.test.ts +++ b/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.test.ts @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import type { @@ -130,6 +131,31 @@ describe('SubscriptionTransitionPreviewWorkspace', () => { expect(screen.getByText('$82.50')).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(SubscriptionTransitionPreviewWorkspace, { + props: { + previewData: preview({}), + 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' + ) + }) + it('renders a scheduled downgrade with the after-that block and no charge', () => { render(SubscriptionTransitionPreviewWorkspace, { props: { diff --git a/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue b/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue index d34bb3d7d45..efb0119641e 100644 --- a/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionTransitionPreviewWorkspace.vue @@ -172,6 +172,16 @@
+ +