Skip to content

Commit b75de70

Browse files
comfy-pr-botdante01yoonampagent
authored
[backport core/1.48] fix(billing): support subscription 3DS verification (FE-1436) (#14304)
Backport of #14242 to `core/1.48` Automatically created by backport workflow. Co-authored-by: Dante <bunggl@naver.com> Co-authored-by: Dante Yoon <6510430+dante01yoon@users.noreply.github.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent 6dd9fc9 commit b75de70

24 files changed

Lines changed: 919 additions & 49 deletions

browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,23 @@ const UNEXPECTED_OPERATION_RESPONSE = {
273273
completed_at: '2026-07-20T00:00:01Z'
274274
} satisfies BillingOpStatusResponse
275275

276+
const RECOVERED_3DS_OPERATION_ID = 'recovered-3ds-subscription'
277+
const RECOVERED_3DS_ACTION_URL = 'https://verify.example/3ds-session'
278+
279+
const RECOVERED_3DS_STATUS = {
280+
...ACTIVE_TEAM_STATUS,
281+
billing_status: 'pending_payment',
282+
pending_billing_op_id: RECOVERED_3DS_OPERATION_ID,
283+
action_url: RECOVERED_3DS_ACTION_URL
284+
} satisfies BillingStatusResponse
285+
286+
const RECOVERED_3DS_OPERATION = {
287+
id: RECOVERED_3DS_OPERATION_ID,
288+
status: 'pending',
289+
started_at: '2026-07-20T00:00:00Z',
290+
action_url: RECOVERED_3DS_ACTION_URL
291+
} satisfies BillingOpStatusResponse
292+
276293
const TRANSIENT_STATUS_ERROR = {
277294
code: 'billing_status_unavailable',
278295
message: 'Billing status is temporarily unavailable'
@@ -444,6 +461,36 @@ async function mockPopupBlockedCreatorDowngrade(page: Page) {
444461
}
445462
}
446463

464+
async function mockRecovered3dsSubscription(page: Page) {
465+
const statusRequests: Request[] = []
466+
const operationPollRequests: Request[] = []
467+
const subscribeRequests: Request[] = []
468+
469+
await page.route('**/api/billing/status', (route) => {
470+
statusRequests.push(route.request())
471+
return route.fulfill(jsonRoute(RECOVERED_3DS_STATUS))
472+
})
473+
await page.route(
474+
`**/api/billing/ops/${RECOVERED_3DS_OPERATION_ID}`,
475+
(route) => {
476+
operationPollRequests.push(route.request())
477+
return route.fulfill(jsonRoute(RECOVERED_3DS_OPERATION))
478+
}
479+
)
480+
await page.route('**/api/billing/subscribe', (route) => {
481+
subscribeRequests.push(route.request())
482+
return route.fulfill({
483+
...jsonRoute({
484+
code: 'unexpected_subscribe',
485+
message: 'Recovered operations must not resubscribe'
486+
} satisfies ErrorResponse),
487+
status: 500
488+
})
489+
})
490+
491+
return { statusRequests, operationPollRequests, subscribeRequests }
492+
}
493+
447494
const pricingHeading = (page: Page) =>
448495
page.getByRole('heading', { name: 'Choose a Plan' })
449496

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

671-
test('shows the existing success view when subscribe replays 200', async ({
718+
test('completes the non-3DS flow when subscribe replays 200', async ({
672719
page
673720
}) => {
674721
await page.goto(`${APP_URL}/?pricing=personal`)
@@ -696,6 +743,9 @@ test.describe('Scheduled Team downgrade', { tag: '@cloud' }, () => {
696743
name: "You're all set"
697744
})
698745
await expect(successHeading).toBeVisible()
746+
await expect(
747+
page.getByRole('button', { name: 'Complete verification' })
748+
).toBeHidden()
699749
await expect.poll(() => statusRefreshRequests.length).toBe(1)
700750
await expect.poll(() => balanceRefreshRequests.length).toBe(1)
701751
const successView = successHeading.locator('..').locator('..')
@@ -721,6 +771,64 @@ test.describe('Scheduled Team downgrade', { tag: '@cloud' }, () => {
721771
})
722772
})
723773

774+
test.describe('Recovered 3DS subscription', { tag: '@cloud' }, () => {
775+
let statusRequests: Request[]
776+
let operationPollRequests: Request[]
777+
let subscribeRequests: Request[]
778+
779+
test.beforeEach(async ({ page }) => {
780+
await page.addInitScript(() => {
781+
window.open = (url, target, features) => {
782+
document.documentElement.dataset.openedUrl = String(url)
783+
document.documentElement.dataset.openedTarget = target ?? ''
784+
document.documentElement.dataset.openedFeatures = features ?? ''
785+
return window
786+
}
787+
})
788+
await setupCloudApp(page, workspace('team', 'owner'), [
789+
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
790+
])
791+
const recoveryMock = await mockRecovered3dsSubscription(page)
792+
statusRequests = recoveryMock.statusRequests
793+
operationPollRequests = recoveryMock.operationPollRequests
794+
subscribeRequests = recoveryMock.subscribeRequests
795+
})
796+
797+
test('recovers on a fresh page without resubscribing and opens verification on click', async ({
798+
page
799+
}) => {
800+
await page.goto(APP_URL)
801+
await waitForCloudApp(page)
802+
await page.getByRole('button', { name: 'Current user' }).click()
803+
await page.getByTestId('manage-plan-menu-item').click()
804+
await expect.poll(() => statusRequests.length).toBeGreaterThan(0)
805+
await expect.poll(() => operationPollRequests.length).toBeGreaterThan(0)
806+
807+
const verificationButton = page.getByRole('button', {
808+
name: 'Complete verification'
809+
})
810+
await expect(verificationButton).toBeVisible()
811+
await expect(page.locator('html')).not.toContainText(
812+
RECOVERED_3DS_ACTION_URL
813+
)
814+
expect(subscribeRequests).toHaveLength(0)
815+
816+
await verificationButton.click()
817+
818+
await expect
819+
.poll(() => page.locator('html').getAttribute('data-opened-url'))
820+
.toBe(RECOVERED_3DS_ACTION_URL)
821+
await expect(page.locator('html')).toHaveAttribute(
822+
'data-opened-target',
823+
'_blank'
824+
)
825+
await expect(page.locator('html')).toHaveAttribute(
826+
'data-opened-features',
827+
'noopener,noreferrer'
828+
)
829+
})
830+
})
831+
724832
test.describe(
725833
'Billing reconciliation after plan changes',
726834
{ tag: '@cloud' },

packages/ingest-types/src/types.gen.ts

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ingest-types/src/zod.gen.ts

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/locales/en/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2841,6 +2841,7 @@
28412841
"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."
28422842
},
28432843
"preview": {
2844+
"completeVerification": "Complete verification",
28442845
"confirmPayment": "Confirm your payment",
28452846
"confirmPlanChange": "Confirm your plan change",
28462847
"startingToday": "Starts today",

src/platform/workspace/api/workspaceApi.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,8 @@ describe('workspaceApi', () => {
566566
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
567567
'/api/billing/ops/op-1',
568568
{
569-
headers: AUTH_HEADER
569+
headers: AUTH_HEADER,
570+
timeout: 30_000
570571
}
571572
)
572573
expect(result).toEqual(data)

src/platform/workspace/api/workspaceApi.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@ export interface BillingStatusResponse {
276276
subscription_duration?: SubscriptionDuration
277277
plan_slug?: string
278278
billing_status?: BillingStatus
279+
pending_billing_op_id?: string
280+
action_url?: string
279281
has_funds: boolean
280282
cancel_at?: string
281283
renewal_date?: string
@@ -313,6 +315,7 @@ export interface BillingOpStatusResponse {
313315
error_message?: string
314316
started_at: string
315317
completed_at?: string
318+
action_url?: string
316319
}
317320

318321
interface BillingEvent {
@@ -814,7 +817,7 @@ export const workspaceApi = {
814817
try {
815818
const response = await workspaceApiClient.get<BillingOpStatusResponse>(
816819
api.apiURL(`/billing/ops/${opId}`),
817-
{ headers }
820+
{ headers, timeout: 30_000 }
818821
)
819822
return response.data
820823
} catch (err) {

src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,39 @@ describe('SubscriptionAddPaymentPreviewWorkspace', () => {
148148
)
149149
expect(emitted().addCreditCard).toBeTruthy()
150150
})
151+
152+
it('opens verification only from its button without exposing the URL', async () => {
153+
const actionUrl = 'https://verify.example/sensitive-token'
154+
const open = vi.spyOn(window, 'open').mockReturnValue({} as Window)
155+
const { container } = render(SubscriptionAddPaymentPreviewWorkspace, {
156+
props: { tierKey: 'creator', actionUrl },
157+
global: globalOptions
158+
})
159+
160+
expect(open).not.toHaveBeenCalled()
161+
expect(container.innerHTML).not.toContain(actionUrl)
162+
await userEvent.click(
163+
screen.getByRole('button', {
164+
name: 'subscription.preview.completeVerification'
165+
})
166+
)
167+
expect(open).toHaveBeenCalledWith(
168+
actionUrl,
169+
'_blank',
170+
'noopener,noreferrer'
171+
)
172+
})
173+
174+
it('prevents leaving the preview while payment is pending', () => {
175+
render(SubscriptionAddPaymentPreviewWorkspace, {
176+
props: { tierKey: 'creator', isLoading: true },
177+
global: globalOptions
178+
})
179+
180+
expect(
181+
screen.getByRole('button', {
182+
name: 'subscription.preview.backToAllPlans'
183+
})
184+
).toBeDisabled()
185+
})
151186
})

src/platform/workspace/components/SubscriptionAddPaymentPreviewWorkspace.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,16 @@
136136
<!-- Terms Agreement -->
137137
<SubscriptionTermsNote />
138138

139-
<!-- Add Credit Card Button -->
139+
<Button
140+
v-if="actionUrl"
141+
variant="primary"
142+
size="lg"
143+
class="w-full rounded-lg"
144+
@click="openVerification"
145+
>
146+
{{ $t('subscription.preview.completeVerification') }}
147+
</Button>
148+
140149
<Button
141150
variant="tertiary"
142151
size="lg"
@@ -151,6 +160,7 @@
151160
<Button
152161
variant="textonly"
153162
class="cursor-pointer text-center text-xs text-muted-foreground transition-colors hover:bg-none hover:text-base-foreground"
163+
:disabled="isLoading"
154164
@click="$emit('back')"
155165
>
156166
{{ $t('subscription.preview.backToAllPlans') }}
@@ -186,14 +196,16 @@ interface Props {
186196
previewData?: PreviewSubscribeResponse | null
187197
/** Team-plan checkout (selected slider stop); overrides tier-derived display. */
188198
teamPlan?: TeamPlanSelection | null
199+
actionUrl?: string | null
189200
}
190201
191202
const {
192203
tierKey,
193204
billingCycle = 'monthly',
194205
isLoading = false,
195206
previewData = null,
196-
teamPlan = null
207+
teamPlan = null,
208+
actionUrl = null
197209
} = defineProps<Props>()
198210
199211
defineEmits<{
@@ -205,6 +217,11 @@ const { t, n } = useI18n()
205217
206218
const isFeaturesCollapsed = ref(true)
207219
220+
function openVerification() {
221+
if (!actionUrl) return
222+
window.open(actionUrl, '_blank', 'noopener,noreferrer')
223+
}
224+
208225
const tierName = computed(() =>
209226
teamPlan
210227
? t('subscription.teamPlan.name')

0 commit comments

Comments
 (0)