Skip to content

Commit fcf2755

Browse files
dante01yoonampagent
authored andcommitted
fix(billing): support top-up 3DS verification (#14490)
## Summary Surface the hosted 3DS verification action for pending credit top-ups so customers can complete bank authentication instead of timing out in the top-up dialog. The [reported failure](https://comfy-organization.slack.com/archives/C09LDUKDQ1K/p1785480655794419?thread_ts=1785452793.178309&cid=C09LDUKDQ1K) is real. The backend now preserves authentication-required top-ups and publishes `action_url` ([cloud#5890](Comfy-Org/cloud#5890)), but the frontend only rendered action URLs for subscription operations. Top-up polling also retained the generic two-minute timeout after discovering an authentication challenge. ## Changes - **What**: Expose pending top-up action URLs for the active workspace and render a **Complete verification** CTA using the existing subscription-verification pattern. - **What**: Keep polling action-required top-ups for the 23-hour backend authentication window; ordinary top-ups retain the two-minute timeout. - **What**: Validate the URL in the store and open it with `noopener,noreferrer` without rendering the sensitive hosted URL in the DOM. - **What**: Add cloud E2E coverage for the pending top-up → polled 3DS action → verification CTA flow. ## Review Focus - The verification CTA is permission-gated and disappears when the operation settles or the active workspace changes. - Only operations that have actually surfaced an action URL receive the extended timeout. ## Testing - `pnpm test:unit src/platform/workspace/stores/billingOperationStore.test.ts src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.test.ts` (56 passed) - `pnpm test:browser:local browser_tests/tests/dialogs/creditsTile.spec.ts` (4 passed) - `pnpm test:browser:local browser_tests/tests/dialogs/creditsTile.spec.ts --grep "opens verification" --repeat-each 5` (5 passed) - `pnpm typecheck` and `pnpm typecheck:browser` - `pnpm exec oxfmt --check <touched files>` - `pnpm exec oxlint --type-aware <touched files>` - Pre-push `knip --cache` ## Screenshots Captured from the real `TopUpCreditsDialogContentWorkspace` component with a controlled pending 3DS operation; no hosted payment URL or customer data is present. ### AS IS The top-up remains pending with only a loading indicator. No verification action is available. ![AS IS — pending 3DS top-up without verification CTA](https://ampcode.com/user-content/artifacts/5f60be314f53f524ddf29af87cca410225c23b8bbcc7ca009932fe3e9833d20b-file.png) ### TO BE The pending top-up presents **Complete verification** as the primary action while polling continues. ![TO BE — pending 3DS top-up with Complete verification CTA](https://ampcode.com/user-content/artifacts/794fa7f2ef11048d343b040c5627eb3821f37a014397aeca1fbd8f59569caca2-file.png) ### AS IS → TO BE ![AS IS to TO BE animation](https://ampcode.com/user-content/artifacts/aaa04969432901024282d494ca05bd87c70f728128073ffe196cd68400bead19-file.gif) --------- Co-authored-by: dante01yoon <6510430+dante01yoon@users.noreply.github.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent 509deeb commit fcf2755

5 files changed

Lines changed: 232 additions & 18 deletions

File tree

browser_tests/tests/dialogs/creditsTile.spec.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { expect } from '@playwright/test'
2-
import type { Page } from '@playwright/test'
2+
import type { Page, Request } from '@playwright/test'
33

44
import type { RemoteConfig } from '@/platform/remoteConfig/types'
5-
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
5+
import type {
6+
BillingOpStatusResponse,
7+
BillingStatusResponse,
8+
CreateTopupResponse
9+
} from '@/platform/workspace/api/workspaceApi'
610

711
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
12+
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
813
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
914
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
15+
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
1016
import {
1117
mockWorkspaceTokenMint,
1218
workspace
@@ -315,3 +321,91 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
315321
).toBeVisible()
316322
})
317323
})
324+
325+
test.describe('Top-up 3DS verification', { tag: '@cloud' }, () => {
326+
test.describe.configure({ timeout: 60_000 })
327+
328+
let operationPollRequests: Request[]
329+
let topupDialog: TopUpCreditsDialog
330+
331+
test.beforeEach(async ({ page }) => {
332+
operationPollRequests = []
333+
await page.addInitScript(() => {
334+
window.open = (url, target, features) => {
335+
document.documentElement.dataset.openedUrl = String(url)
336+
document.documentElement.dataset.openedTarget = target ?? ''
337+
document.documentElement.dataset.openedFeatures = features ?? ''
338+
return window
339+
}
340+
})
341+
await new CloudWorkspaceMockHelper(page).setup()
342+
await page.route('**/api/settings/**', (route) => {
343+
if (route.request().method() !== 'GET') return route.fallback()
344+
return route.fulfill(jsonRoute({}))
345+
})
346+
await page.route('**/api/prompt', (route) => {
347+
if (route.request().method() !== 'GET') return route.fallback()
348+
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
349+
})
350+
await page.route('**/api/queue', (route) => {
351+
if (route.request().method() !== 'GET') return route.fallback()
352+
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
353+
})
354+
await page.route('**/api/billing/topup', (route) =>
355+
route.fulfill(
356+
jsonRoute({
357+
billing_op_id: 'topup-3ds-operation',
358+
topup_id: 'topup-3ds-operation',
359+
status: 'pending',
360+
amount_cents: 5000
361+
} satisfies CreateTopupResponse)
362+
)
363+
)
364+
await page.route('**/api/billing/ops/topup-3ds-operation', (route) => {
365+
operationPollRequests.push(route.request())
366+
return route.fulfill(
367+
jsonRoute({
368+
id: 'topup-3ds-operation',
369+
status: 'pending',
370+
started_at: '2026-07-31T00:00:00Z',
371+
action_url: 'https://verify.example/topup-3ds'
372+
} satisfies BillingOpStatusResponse)
373+
)
374+
})
375+
376+
const settingsDialog = await openSettings(page)
377+
await settingsDialog
378+
.getByRole('combobox', { name: 'Search Settings...' })
379+
.focus()
380+
await page.keyboard.press('Escape')
381+
await expect(settingsDialog).toBeHidden()
382+
topupDialog = new TopUpCreditsDialog(page)
383+
await topupDialog.open()
384+
})
385+
386+
test('opens verification when the top-up operation requires authentication', async ({
387+
page
388+
}) => {
389+
await topupDialog.root.getByRole('button', { name: 'Add credits' }).click()
390+
391+
await expect.poll(() => operationPollRequests.length).toBeGreaterThan(0)
392+
const verificationButton = topupDialog.root.getByRole('button', {
393+
name: 'Complete verification'
394+
})
395+
await expect(verificationButton).toBeVisible()
396+
397+
await verificationButton.click()
398+
399+
await expect
400+
.poll(() => page.locator('html').getAttribute('data-opened-url'))
401+
.toBe('https://verify.example/topup-3ds')
402+
await expect(page.locator('html')).toHaveAttribute(
403+
'data-opened-target',
404+
'_blank'
405+
)
406+
await expect(page.locator('html')).toHaveAttribute(
407+
'data-opened-features',
408+
'noopener,noreferrer'
409+
)
410+
})
411+
})

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const mockTrackTopUpPurchase = vi.fn()
1818
const mockTrackBillingEvent = vi.fn()
1919
const mockCanTopUp = vi.hoisted(() => ({ value: true }))
2020
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: true }))
21+
const mockTopupActionOperation = vi.hoisted(() => ({
22+
value: undefined as { actionUrl: string } | undefined
23+
}))
2124

2225
vi.mock('@/composables/billing/useBillingContext', () => ({
2326
useBillingContext: () => ({
@@ -31,13 +34,17 @@ vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
3134
useBillingOperationStore: () => ({
3235
hasPendingOperations: true,
3336
isAddingCredits: false,
37+
get topupActionOperation() {
38+
return mockTopupActionOperation.value
39+
},
3440
startOperation: mockStartOperation
3541
})
3642
}))
3743

3844
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
3945
useWorkspaceUI: () => ({
4046
permissions: {
47+
__v_isRef: true,
4148
get value() {
4249
return { canTopUp: mockCanTopUp.value }
4350
}
@@ -96,7 +103,10 @@ const i18n = createI18n({
96103
messages: {
97104
en: {
98105
g: { close: 'Close' },
99-
subscription: { addCredits: 'Add credits' },
106+
subscription: {
107+
addCredits: 'Add credits',
108+
preview: { completeVerification: 'Complete verification' }
109+
},
100110
credits: {
101111
topUp: {
102112
addMoreCredits: 'Add more credits',
@@ -156,6 +166,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
156166
vi.clearAllMocks()
157167
mockCanTopUp.value = true
158168
mockShouldUseWorkspaceBilling.value = true
169+
mockTopupActionOperation.value = undefined
159170
mockFetchBalance.mockResolvedValue(undefined)
160171
mockFetchStatus.mockResolvedValue(undefined)
161172
})
@@ -166,6 +177,38 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
166177
expect(screen.getByRole('button', { name: 'Add credits' })).toBeEnabled()
167178
})
168179

180+
it('opens topup verification without exposing its URL', async () => {
181+
const actionUrl = 'https://verify.example/sensitive-token'
182+
const open = vi.spyOn(window, 'open').mockReturnValue({} as Window)
183+
mockTopupActionOperation.value = { actionUrl }
184+
185+
const { container } = renderDialog()
186+
187+
expect(container.innerHTML).not.toContain(actionUrl)
188+
await userEvent.click(
189+
screen.getByRole('button', { name: 'Complete verification' })
190+
)
191+
expect(open).toHaveBeenCalledWith(
192+
actionUrl,
193+
'_blank',
194+
'noopener,noreferrer'
195+
)
196+
open.mockRestore()
197+
})
198+
199+
it('hides topup verification after permission is revoked', () => {
200+
mockCanTopUp.value = false
201+
mockTopupActionOperation.value = {
202+
actionUrl: 'https://verify.example/sensitive-token'
203+
}
204+
205+
renderDialog()
206+
207+
expect(
208+
screen.queryByRole('button', { name: 'Complete verification' })
209+
).not.toBeInTheDocument()
210+
})
211+
169212
it('refreshes both balance and status after a completed top-up', async () => {
170213
mockTopup.mockResolvedValue(topupResponse('completed'))
171214

src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,27 @@
125125
</p>
126126

127127
<div class="flex flex-col gap-8 p-8">
128-
<Button
129-
:disabled="!isValidAmount || loading || isPolling"
130-
:loading="loading || isPolling"
131-
variant="primary"
132-
size="lg"
133-
class="h-10 justify-center"
134-
@click="handleBuy"
135-
>
136-
{{ $t('subscription.addCredits') }}
137-
</Button>
128+
<div class="flex flex-col gap-2">
129+
<Button
130+
v-if="topupActionUrl && permissions.canTopUp"
131+
variant="primary"
132+
size="lg"
133+
class="h-10 justify-center"
134+
@click="openTopupVerification"
135+
>
136+
{{ $t('subscription.preview.completeVerification') }}
137+
</Button>
138+
<Button
139+
:disabled="!isValidAmount || loading || isPolling"
140+
:loading="loading || isPolling"
141+
:variant="topupActionUrl ? 'tertiary' : 'primary'"
142+
size="lg"
143+
class="h-10 justify-center"
144+
@click="handleBuy"
145+
>
146+
{{ $t('subscription.addCredits') }}
147+
</Button>
148+
</div>
138149
<div class="flex items-center justify-center gap-1">
139150
<a
140151
:href="pricingUrl"
@@ -184,6 +195,9 @@ const { permissions } = useWorkspaceUI()
184195
185196
const billingOperationStore = useBillingOperationStore()
186197
const isPolling = computed(() => billingOperationStore.isAddingCredits)
198+
const topupActionUrl = computed(
199+
() => billingOperationStore.topupActionOperation?.actionUrl ?? null
200+
)
187201
188202
// Constants
189203
const PRESET_AMOUNTS = [10, 25, 50, 100]
@@ -245,6 +259,11 @@ function handlePresetClick(amount: number) {
245259
selectedPreset.value = amount
246260
}
247261
262+
function openTopupVerification() {
263+
if (!topupActionUrl.value) return
264+
window.open(topupActionUrl.value, '_blank', 'noopener,noreferrer')
265+
}
266+
248267
function handleClose(clearTracking = true) {
249268
if (clearTracking) {
250269
clearTopupTracking()

src/platform/workspace/stores/billingOperationStore.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,26 @@ describe('billingOperationStore', () => {
636636
expect(store.getOperation('op-1')?.actionUrl).toBeNull()
637637
})
638638

639+
it('exposes topup actions only for the active workspace', async () => {
640+
const actionUrl = 'https://verify.example/sensitive-token'
641+
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
642+
id: 'op-1',
643+
status: 'pending',
644+
started_at: new Date().toISOString(),
645+
action_url: actionUrl
646+
})
647+
648+
const store = useBillingOperationStore()
649+
void store.startOperation('op-1', 'topup')
650+
await vi.advanceTimersByTimeAsync(0)
651+
652+
expect(store.topupActionOperation?.actionUrl).toBe(actionUrl)
653+
654+
mockActiveWorkspaceId.value = 'workspace-2'
655+
656+
expect(store.topupActionOperation).toBeUndefined()
657+
})
658+
639659
it('only exposes subscription actions for the active workspace', async () => {
640660
const actionUrl = 'https://verify.example/sensitive-token'
641661
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
@@ -711,6 +731,31 @@ describe('billingOperationStore', () => {
711731
summary: 'billingOperation.topupTimeout'
712732
})
713733
})
734+
735+
it('keeps polling a topup while authentication is required', async () => {
736+
const actionUrl = 'https://verify.example/sensitive-token'
737+
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
738+
id: 'op-1',
739+
status: 'pending',
740+
started_at: new Date().toISOString(),
741+
action_url: actionUrl
742+
})
743+
744+
const store = useBillingOperationStore()
745+
void store.startOperation('op-1', 'topup')
746+
747+
await vi.advanceTimersByTimeAsync(121_000)
748+
749+
expect(store.getOperation('op-1')).toMatchObject({
750+
status: 'pending',
751+
actionUrl,
752+
authenticationRequiredSeen: true
753+
})
754+
expect(mockToastAdd).not.toHaveBeenCalledWith({
755+
severity: 'error',
756+
summary: 'billingOperation.topupTimeout'
757+
})
758+
})
714759
})
715760

716761
describe('cancel operations', () => {

src/platform/workspace/stores/billingOperationStore.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const ACTION_REQUIRED_INTERVAL_MS = 30_000
2424
const BACKOFF_MULTIPLIER = 1.5
2525
const TIMEOUT_MS = 120_000
2626
const SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS = 5 * 60_000
27-
const SUBSCRIPTION_AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000
27+
const AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000
2828

2929
type OperationType = 'subscription' | 'topup' | 'cancel'
3030
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
@@ -97,6 +97,16 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
9797
)
9898
)
9999

100+
const topupActionOperation = computed(() =>
101+
[...operations.value.values()].find(
102+
(op) =>
103+
op.status === 'pending' &&
104+
op.type === 'topup' &&
105+
op.workspaceId === workspaceStore.activeWorkspaceId &&
106+
op.actionUrl !== null
107+
)
108+
)
109+
100110
function getOperation(opId: string) {
101111
return operations.value.get(opId)
102112
}
@@ -227,10 +237,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
227237

228238
function hasTimedOut(operation: BillingOperation): boolean {
229239
const elapsed = Date.now() - operation.startedAt
230-
if (operation.type !== 'subscription') return elapsed > TIMEOUT_MS
231-
return operation.authenticationRequiredSeen
232-
? elapsed > SUBSCRIPTION_AUTHENTICATION_TIMEOUT_MS
233-
: elapsed > SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS
240+
if (operation.type !== 'cancel' && operation.authenticationRequiredSeen) {
241+
return elapsed > AUTHENTICATION_TIMEOUT_MS
242+
}
243+
return operation.type === 'subscription'
244+
? elapsed > SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS
245+
: elapsed > TIMEOUT_MS
234246
}
235247

236248
function stopIfTimedOut(opId: string, operation: BillingOperation): boolean {
@@ -497,6 +509,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
497509
isSettingUp,
498510
isAddingCredits,
499511
subscriptionActionOperation,
512+
topupActionOperation,
500513
getOperation,
501514
startOperation,
502515
clearOperation

0 commit comments

Comments
 (0)