Skip to content

Commit 5fd3e35

Browse files
huntcsgampagent
andcommitted
fix: restore failed redirect checkout
Amp-Thread-ID: https://ampcode.com/threads/T-01a02c80-27c8-70bc-b916-186bbe86557a Co-authored-by: Amp <amp@ampcode.com>
1 parent eafce95 commit 5fd3e35

7 files changed

Lines changed: 455 additions & 42 deletions

File tree

src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

3+
import { savePendingSubscriptionCheckout } from '@/platform/workspace/utils/pendingSubscriptionCheckout'
4+
35
import { useSubscriptionDialog } from './useSubscriptionDialog'
46

57
const mockCloseDialog = vi.fn()
@@ -19,6 +21,10 @@ const mockIsTeamPlan = vi.hoisted(() => ({ value: false }))
1921
const mockCurrentPlanSlug = vi.hoisted(() => ({ value: null as string | null }))
2022
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
2123
const mockEmbeddedCheckoutEnabled = vi.hoisted(() => ({ value: false }))
24+
const mockActiveWorkspaceId = vi.hoisted(() => ({ value: 'workspace-1' }))
25+
const mockStartOperation = vi.hoisted(() => vi.fn())
26+
const mockFetchPlans = vi.hoisted(() => vi.fn())
27+
const mockTeamCreditStops = vi.hoisted(() => ({ value: null }))
2228

2329
vi.mock('vue', async (importOriginal) => {
2430
const actual = await importOriginal()
@@ -74,19 +80,28 @@ vi.mock('@/platform/distribution/types', () => ({
7480

7581
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
7682
useTeamWorkspaceStore: () => ({
83+
get activeWorkspaceId() {
84+
return mockActiveWorkspaceId.value
85+
},
7786
get isInPersonalWorkspace() {
7887
return mockIsInPersonalWorkspace.value
7988
}
8089
})
8190
}))
8291

92+
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
93+
useBillingOperationStore: () => ({ startOperation: mockStartOperation })
94+
}))
95+
8396
vi.mock('@/composables/billing/useBillingContext', () => ({
8497
useBillingContext: () => ({
8598
isFreeTier: mockIsFreeTier,
8699
isLegacyTeamPlan: mockIsLegacyTeamPlan,
87100
isTeamPlan: mockIsTeamPlan,
88101
currentPlanSlug: mockCurrentPlanSlug,
89-
tier: mockTier
102+
tier: mockTier,
103+
fetchPlans: mockFetchPlans,
104+
teamCreditStops: mockTeamCreditStops
90105
})
91106
}))
92107

@@ -129,6 +144,10 @@ describe('useSubscriptionDialog', () => {
129144
mockCurrentPlanSlug.value = null
130145
mockCanManageSubscription.value = true
131146
mockEmbeddedCheckoutEnabled.value = false
147+
mockActiveWorkspaceId.value = 'workspace-1'
148+
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
149+
mockFetchPlans.mockResolvedValue(undefined)
150+
sessionStorage.clear()
132151
})
133152

134153
describe('showPricingTable', () => {
@@ -568,7 +587,7 @@ describe('useSubscriptionDialog', () => {
568587
it('does nothing when no resume intent is stored', () => {
569588
const { resumePendingPricingFlow } = useSubscriptionDialog()
570589

571-
resumePendingPricingFlow()
590+
void resumePendingPricingFlow()
572591

573592
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
574593
})
@@ -580,7 +599,7 @@ describe('useSubscriptionDialog', () => {
580599
mockCurrentPlanSlug.value = 'creator-monthly'
581600

582601
const { resumePendingPricingFlow } = useSubscriptionDialog()
583-
resumePendingPricingFlow()
602+
void resumePendingPricingFlow()
584603

585604
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBeNull()
586605
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
@@ -596,7 +615,7 @@ describe('useSubscriptionDialog', () => {
596615
mockIsInPersonalWorkspace.value = true
597616

598617
const { resumePendingPricingFlow } = useSubscriptionDialog()
599-
resumePendingPricingFlow()
618+
void resumePendingPricingFlow()
600619

601620
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBeNull()
602621
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
@@ -607,11 +626,76 @@ describe('useSubscriptionDialog', () => {
607626
mockIsInPersonalWorkspace.value = false
608627

609628
const { resumePendingPricingFlow } = useSubscriptionDialog()
610-
resumePendingPricingFlow()
629+
void resumePendingPricingFlow()
611630
mockShowLayoutDialog.mockClear()
612631

613-
resumePendingPricingFlow()
632+
void resumePendingPricingFlow()
633+
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
634+
})
635+
636+
it('reconciles a failed redirect and reopens the attempted checkout', async () => {
637+
mockShouldUseWorkspaceBilling.value = true
638+
mockStartOperation.mockResolvedValueOnce({ status: 'failed' })
639+
savePendingSubscriptionCheckout({
640+
operationId: 'op-alipay',
641+
workspaceId: 'workspace-1',
642+
selection: {
643+
planMode: 'personal',
644+
tierKey: 'creator',
645+
billingCycle: 'monthly'
646+
},
647+
attemptedAt: Date.now()
648+
})
649+
650+
const { resumePendingPricingFlow } = useSubscriptionDialog()
651+
await resumePendingPricingFlow()
652+
653+
expect(mockStartOperation).toHaveBeenCalledWith(
654+
'op-alipay',
655+
'subscription',
656+
{
657+
tier: 'creator',
658+
cycle: 'monthly',
659+
attemptStartedAt: expect.any(Number)
660+
}
661+
)
662+
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
663+
expect.objectContaining({
664+
props: expect.objectContaining({
665+
initialPlanMode: 'personal',
666+
initialCheckout: {
667+
planMode: 'personal',
668+
tierKey: 'creator',
669+
billingCycle: 'monthly'
670+
}
671+
})
672+
})
673+
)
674+
expect(
675+
sessionStorage.getItem('comfy:pending-subscription-checkout')
676+
).toBeNull()
677+
})
678+
679+
it('clears a checkout for another workspace without reconciling it', async () => {
680+
savePendingSubscriptionCheckout({
681+
operationId: 'op-other-workspace',
682+
workspaceId: 'workspace-2',
683+
selection: {
684+
planMode: 'personal',
685+
tierKey: 'standard',
686+
billingCycle: 'yearly'
687+
},
688+
attemptedAt: Date.now()
689+
})
690+
691+
const { resumePendingPricingFlow } = useSubscriptionDialog()
692+
await resumePendingPricingFlow()
693+
694+
expect(mockStartOperation).not.toHaveBeenCalled()
614695
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
696+
expect(
697+
sessionStorage.getItem('comfy:pending-subscription-checkout')
698+
).toBeNull()
615699
})
616700
})
617701
})

src/platform/cloud/subscription/composables/useSubscriptionDialog.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,22 @@ import { useDialogStore } from '@/stores/dialogStore'
44
import { useBillingContext } from '@/composables/billing/useBillingContext'
55
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
66
import { useFeatureFlags } from '@/composables/useFeatureFlags'
7+
import {
8+
getStopDiscountedMonthlyUsd,
9+
mapApiTeamCreditStops
10+
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
711
import { isCloud } from '@/platform/distribution/types'
812
import { useTelemetry } from '@/platform/telemetry'
913
import type { PaymentIntentSource } from '@/platform/telemetry/types'
1014
import type { SubscriptionCheckoutSelection } from '@/platform/workspace/composables/useSubscriptionCheckout'
1115
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
16+
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
1217
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
18+
import {
19+
clearPendingSubscriptionCheckout,
20+
getPendingSubscriptionCheckout
21+
} from '@/platform/workspace/utils/pendingSubscriptionCheckout'
22+
import type { PendingSubscriptionCheckout } from '@/platform/workspace/utils/pendingSubscriptionCheckout'
1323

1424
const DIALOG_KEY = 'subscription-required'
1525
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
@@ -227,11 +237,71 @@ export const useSubscriptionDialog = () => {
227237
})
228238
}
229239

230-
/**
231-
* Check for and consume a pending team pricing resume intent.
232-
* Call once after workspace initialization on app boot.
233-
*/
234-
function resumePendingPricingFlow() {
240+
async function restoreCheckoutSelection(
241+
pending: PendingSubscriptionCheckout
242+
): Promise<SubscriptionCheckoutSelection | null> {
243+
const selection = pending.selection
244+
if (selection.planMode === 'personal') return selection
245+
246+
const { fetchPlans, teamCreditStops } = useBillingContext()
247+
await fetchPlans()
248+
const stop = mapApiTeamCreditStops(teamCreditStops.value?.stops ?? []).find(
249+
({ id }) => id === selection.teamCreditStopId
250+
)
251+
if (!stop?.id) return null
252+
253+
return {
254+
planMode: 'team',
255+
stop: {
256+
id: stop.id,
257+
usd: stop.usd,
258+
credits: stop.credits,
259+
discountedUsd: getStopDiscountedMonthlyUsd(stop, selection.billingCycle)
260+
},
261+
billingCycle: selection.billingCycle
262+
}
263+
}
264+
265+
async function resumePendingCheckout(
266+
pending: PendingSubscriptionCheckout
267+
): Promise<void> {
268+
if (pending.workspaceId !== workspaceStore.activeWorkspaceId) {
269+
clearPendingSubscriptionCheckout(pending.operationId)
270+
return
271+
}
272+
273+
const billingOperationStore = useBillingOperationStore()
274+
try {
275+
const operation = await billingOperationStore.startOperation(
276+
pending.operationId,
277+
'subscription',
278+
{
279+
tier:
280+
pending.selection.planMode === 'personal'
281+
? pending.selection.tierKey
282+
: 'team',
283+
cycle: pending.selection.billingCycle,
284+
attemptStartedAt: pending.attemptedAt
285+
}
286+
)
287+
if (operation.status === 'succeeded') return
288+
289+
const initialCheckout = await restoreCheckoutSelection(pending)
290+
if (initialCheckout) {
291+
showPricingTable({
292+
planMode: initialCheckout.planMode,
293+
initialCheckout
294+
})
295+
}
296+
} finally {
297+
clearPendingSubscriptionCheckout(pending.operationId)
298+
}
299+
}
300+
301+
function resumePendingPricingFlow(): Promise<void> | void {
302+
const pendingCheckout = getPendingSubscriptionCheckout()
303+
if (pendingCheckout) return resumePendingCheckout(pendingCheckout)
304+
235305
try {
236306
const pending = sessionStorage.getItem(RESUME_PRICING_KEY)
237307
if (!pending) return

src/platform/workspace/auth/WorkspaceAuthGate.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,10 @@ async function initialize(): Promise<void> {
173173
throw new Error('Unified cloud auth was cleared during workspace setup')
174174
}
175175
176-
// Resume any pending pricing flow from team workspace creation
177-
// Only safe after workspace store initialized successfully — the pricing
178-
// dialog reads workspace state to decide which variant to show.
176+
// Pricing recovery reads the initialized workspace to scope its intent.
179177
const workspaceStore = useTeamWorkspaceStore()
180178
if (workspaceStore.initState === 'ready') {
181-
subscriptionDialog.resumePendingPricingFlow()
179+
void subscriptionDialog.resumePendingPricingFlow()
182180
}
183181
184182
if (generation === initializationGeneration) {

src/platform/workspace/composables/useSubscriptionCheckout.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ describe('useSubscriptionCheckout', () => {
463463
canDowngradeToPersonal: true
464464
}
465465
mockSubscription.value = null
466+
sessionStorage.clear()
466467
emit = vi.fn()
467468
})
468469

@@ -2962,6 +2963,51 @@ describe('useSubscriptionCheckout', () => {
29622963
expect(checkout.checkoutStep.value).toBe('preview')
29632964
})
29642965

2966+
it('persists the pending attempt until its operation becomes terminal', async () => {
2967+
const checkout = await setup()
2968+
checkout.selectedTierKey.value = 'creator'
2969+
checkout.selectedBillingCycle.value = 'monthly'
2970+
mockSubscribe.mockResolvedValueOnce({
2971+
status: 'pending_payment',
2972+
billing_op_id: 'op-alipay'
2973+
})
2974+
let resolveOperation!: (operation: {
2975+
status: 'failed'
2976+
workspaceId: string
2977+
}) => void
2978+
mockStartOperation.mockImplementationOnce(
2979+
() =>
2980+
new Promise((resolve) => {
2981+
resolveOperation = resolve
2982+
})
2983+
)
2984+
2985+
const payment = checkout.handleAddCreditCard()
2986+
await vi.waitFor(() => {
2987+
expect(
2988+
JSON.parse(
2989+
sessionStorage.getItem('comfy:pending-subscription-checkout') ??
2990+
'null'
2991+
)
2992+
).toMatchObject({
2993+
operationId: 'op-alipay',
2994+
workspaceId: 'workspace-1',
2995+
selection: {
2996+
planMode: 'personal',
2997+
tierKey: 'creator',
2998+
billingCycle: 'monthly'
2999+
}
3000+
})
3001+
})
3002+
3003+
resolveOperation({ status: 'failed', workspaceId: 'workspace-1' })
3004+
await payment
3005+
3006+
expect(
3007+
sessionStorage.getItem('comfy:pending-subscription-checkout')
3008+
).toBeNull()
3009+
})
3010+
29653011
it('shows error toast on subscribe failure', async () => {
29663012
const checkout = await setupWithApprovedPreview()
29673013
checkout.selectedTierKey.value = 'standard'

0 commit comments

Comments
 (0)