Skip to content

Commit fec0fe2

Browse files
christian-byrneConnor Byrne
andauthored
fix(billing): don't show the subscribe paywall before billing status resolves (#14965)
## Summary Part of FE-1540 / PAY-004. Six Pylon tickets (#16322, #16286, #16285, #16284, #16280, #16274), one customer threatening a formal payment dispute. `canRunWorkflows` is `false` from the very first render — `isInitialized` starts `false`, `subscription` is `null`, and `canAccessSubscriptionFeatures` is `statusData.value?.is_active ?? false` (`useWorkspaceBilling.ts:127-129`). Neither `CloudRunButtonWrapper.vue` nor `LinearControls.vue` read `isInitialized`, `isLoading`, or `error`, so **the locked "Subscribe to run" button was the default render**, asserted on no data at all. A paying subscriber whose status fetch is slow, failing, or scoped to the wrong workspace saw a paywall over Run, with no recovery path — `CloudRunButtonWrapper`'s focus refetch is gated behind `refreshBillingOnFocus`, which is only set inside the payment-recovery dialog, so it never fires for a plain status desync. Worth stating because it contradicts an assumption made during triage: `canAccessSubscriptionFeatures` does not mean "this user has a paid subscription", it means "the last status response said `is_active: true`". Those differ during a desync, which is the incident. ## Change Adds `showsSubscribeToRunPrompt` to the billing context — it only claims the user needs to subscribe once billing status has resolved: ```ts const showsSubscribeToRunPrompt = computed( () => isInitialized.value && !canRunWorkflows.value ) ``` Consumed at the three render sites (`CloudRunButtonWrapper.vue:3`, `LinearControls.vue:182,246`) instead of each re-deriving the rule. During the unresolved window the normal Run button renders. Entitlement is still enforced by the backend on execution, and `useAccountPreconditionDialog` already handles that rejection, so failing open for that window is the safer direction — a subscriber who cannot run is a dispute, a non-subscriber who clicks Run gets a clear server-driven prompt. ## Scope Deliberately the low-risk half of FE-1540. **Not** included, because they change Run-button behaviour for all cloud users and want a human call during an active incident: - an unsolicited focus/visibility refetch on the Run path (what Robin asked for in-thread) - hardening `useWorkspaceBilling.ts:244` so a successful-but-stale response cannot silently downgrade a previously-active subscription Also not addressed here: `getBillingStatus()` sends no workspace parameter (`workspaceApi.ts:430-441`), so a subscription on a team workspace while Personal is active reads the wrong billing entirely. Tracked on FE-1540. ## Tests New case `keeps the run button while billing status is still resolving`, verified red against the old gate: ``` × keeps the run button while billing status is still resolving Tests 1 failed | 11 passed (12) ``` 275 tests pass across the 19 affected suites. `pnpm typecheck` exit 0, `pnpm knip` exit 0. - Part of FE-1540 Co-authored-by: Connor Byrne <c.byrne@comfy.org>
1 parent c3ffeba commit fec0fe2

6 files changed

Lines changed: 49 additions & 14 deletions

File tree

src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { nextTick, ref } from 'vue'
66
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
77

88
const mockCanRunWorkflows = ref(true)
9+
const mockIsInitialized = ref(true)
910
const mockBillingStatus = ref<string | null>('paid')
1011
const state = vi.hoisted(() => ({
1112
v1PaymentRecovery: true,
@@ -19,15 +20,21 @@ const state = vi.hoisted(() => ({
1920
updateDialog: vi.fn()
2021
}))
2122

22-
vi.mock('@/composables/billing/useBillingContext', () => ({
23-
useBillingContext: () => ({
24-
canRunWorkflows: mockCanRunWorkflows,
25-
billingStatus: mockBillingStatus,
26-
manageSubscription: state.manageSubscription,
27-
fetchStatus: state.fetchStatus,
28-
fetchBalance: state.fetchBalance
29-
})
30-
}))
23+
vi.mock('@/composables/billing/useBillingContext', async () => {
24+
const { computed } = await import('vue')
25+
return {
26+
useBillingContext: () => ({
27+
canRunWorkflows: mockCanRunWorkflows,
28+
showsSubscribeToRunPrompt: computed(
29+
() => mockIsInitialized.value && !mockCanRunWorkflows.value
30+
),
31+
billingStatus: mockBillingStatus,
32+
manageSubscription: state.manageSubscription,
33+
fetchStatus: state.fetchStatus,
34+
fetchBalance: state.fetchBalance
35+
})
36+
}
37+
})
3138

3239
vi.mock('@/composables/useFeatureFlags', () => ({
3340
useFeatureFlags: () => ({
@@ -89,6 +96,7 @@ function renderWrapper() {
8996
describe('CloudRunButtonWrapper', () => {
9097
beforeEach(() => {
9198
mockCanRunWorkflows.value = true
99+
mockIsInitialized.value = true
92100
mockBillingStatus.value = 'paid'
93101
state.v1PaymentRecovery = true
94102
state.canManageSubscription = true
@@ -104,6 +112,18 @@ describe('CloudRunButtonWrapper', () => {
104112
).not.toBeInTheDocument()
105113
})
106114

115+
it('keeps the run button while billing status is still resolving', () => {
116+
mockCanRunWorkflows.value = false
117+
mockIsInitialized.value = false
118+
119+
render(CloudRunButtonWrapper)
120+
121+
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
122+
expect(
123+
screen.queryByTestId('subscribe-to-run-button')
124+
).not.toBeInTheDocument()
125+
})
126+
107127
it('locks the run button when the subscription is inactive', () => {
108128
mockCanRunWorkflows.value = false
109129
renderWrapper()

src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<ComfyQueueButton
3-
v-if="canRunWorkflows || paymentRecoveryLock"
3+
v-if="!showsSubscribeToRunPrompt || paymentRecoveryLock"
44
:payment-recovery-lock="paymentRecoveryLock"
55
@payment-recovery-click="showPaymentRecoveryDialog"
66
/>
@@ -22,7 +22,7 @@ import { useDialogStore } from '@/stores/dialogStore'
2222
2323
const DIALOG_KEY = 'subscription-paused'
2424
const {
25-
canRunWorkflows,
25+
showsSubscribeToRunPrompt,
2626
billingStatus,
2727
manageSubscription,
2828
fetchStatus,

src/composables/billing/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export interface BillingContext extends BillingState, BillingActions {
137137
isTeamPlan: ComputedRef<boolean>
138138
getMaxSeats: (tierKey: TierKey) => number
139139
canRunWorkflows: ComputedRef<boolean>
140+
showsSubscribeToRunPrompt: ComputedRef<boolean>
140141
/** @deprecated Use canAccessSubscriptionFeatures instead */
141142
isActiveSubscription: ComputedRef<boolean>
142143
}

src/composables/billing/useBillingContext.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ function useBillingContextInternal(): BillingContext {
162162
freeTierQuota.freeTierExecutionPermitted.value)
163163
)
164164

165+
const showsSubscribeToRunPrompt = computed(
166+
() => isInitialized.value && !canRunWorkflows.value
167+
)
168+
165169
const isLegacyTeamPlan = computed(
166170
() =>
167171
type.value === 'workspace' &&
@@ -354,6 +358,7 @@ function useBillingContextInternal(): BillingContext {
354358
error,
355359
isActiveSubscription,
356360
canRunWorkflows,
361+
showsSubscribeToRunPrompt,
357362
canAccessSubscriptionFeatures,
358363
isFreeTier,
359364
isLegacyTeamPlan,

src/renderer/extensions/linearMode/LinearControls.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const { t } = useI18n()
3232
const commandStore = useCommandStore()
3333
const { batchCount } = storeToRefs(useQueueSettingsStore())
3434
const settingStore = useSettingStore()
35-
const { canRunWorkflows } = useBillingContext()
35+
const { canRunWorkflows, showsSubscribeToRunPrompt } = useBillingContext()
3636
const workflowStore = useWorkflowStore()
3737
const { isBuilderMode } = useAppMode()
3838
const appModeStore = useAppModeStore()
@@ -179,7 +179,10 @@ function replayAppModeTour() {
179179
>
180180
<LinearRunErrorWarning v-if="showRunErrorWarning" />
181181
<div v-coachmark="COACH_IDS.appRunButton">
182-
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
182+
<SubscribeToRunButton
183+
v-if="showsSubscribeToRunPrompt"
184+
class="mt-4 w-full"
185+
/>
183186
<div v-else class="mt-4 flex">
184187
<PartnerNodesList mobile />
185188
<Popover side="top" @open-auto-focus.prevent>
@@ -243,7 +246,10 @@ function replayAppModeTour() {
243246
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
244247
class="h-7 min-w-40"
245248
/>
246-
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
249+
<SubscribeToRunButton
250+
v-if="showsSubscribeToRunPrompt"
251+
class="mt-4 w-full"
252+
/>
247253
<Button
248254
v-else
249255
variant="primary"

src/storybook/mocks/useBillingContext.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export function useBillingContext(): BillingContext {
6262
error: ref<string | null>(null),
6363
isActiveSubscription: computed(() => state.value.isActiveSubscription),
6464
canRunWorkflows: computed(() => state.value.isActiveSubscription),
65+
showsSubscribeToRunPrompt: computed(
66+
() => !state.value.isActiveSubscription
67+
),
6568
canAccessSubscriptionFeatures: computed(
6669
() => state.value.isActiveSubscription
6770
),

0 commit comments

Comments
 (0)