Skip to content

Commit 307c66a

Browse files
fix: show Billing & invoices button for past-due subscriptions (#15266)
## Summary Restore **Billing & invoices** for subscription managers regardless of subscription state. The reported regression is an existing past-due subscription represented by `is_active=false`, `billing_status=payment_failed`, and a plan slug; `payment_failed` alone can also describe a non-subscription payment failure. ## Changes - Gate **Billing & invoices** on subscription-management permission rather than active-subscription access. - Treat `payment_failed` or `paused` as a delinquent subscription only when the server also provides a plan slug. - Keep **Manage plan** and **Cancel plan** available for delinquent subscriptions while **Change plan** remains hidden. - Fall back to **Subscribe** and withhold **Cancel plan** when a failed payment has no subscription plan. - Updated subscription panel, workspace menu, and user popover unit coverage (78 tests passing); added the `keeps billing management available for a past-due subscription` Playwright regression test. ## Review Focus The API returns `is_active=false` for the reported past-due subscription, so the former `canAccessSubscriptionFeatures` gate removed every recovery action. The fix keeps the server response authoritative: invoice access is permission-based across billing states, while subscription-specific recovery requires the server-provided delinquent status and plan identity. Related: [Slack report](https://comfy-organization.slack.com/archives/C097YV5BT41/p1786744822687599?thread_ts=1786730571.344519&cid=C097YV5BT41), FE-1530, FE-1452, FE-1435, BE-6784. ## Screenshots ### AS IS → TO BE (past-due subscription) ![AS IS and TO BE comparison](https://ampcode.com/user-content/artifacts/57aec8c7c4f8982e206a0d2efa9687b8e05b49b862d433e658e4664c7f47fcd2-file.png) ### All billing variants `Billing & invoices` was verified visible in all ten owner variants: active paid, payment failed, paused, ended, canceled-active, free, never-subscribed, legacy paid, legacy free, and legacy never-subscribed. ![Billing and invoices across all variants](https://ampcode.com/user-content/artifacts/68df1407fb94bbb621024e83b925d299425110e983ccaf51518ad3a2a2f59f04-file.png) ### Regression check | Variant | `origin/main` | PR | Result | | --- | --- | --- | --- | | Active paid | Visible | Visible | Unchanged | | Past due (`payment_failed` + plan slug) | Hidden | Visible | Fixed; Change plan remains hidden; Cancel plan available | | Paused subscription (`paused` + plan slug) | Hidden | Visible | Fixed; Change plan remains hidden; Cancel plan available | | Failed payment without a plan | Hidden | Visible | Invoice access restored; Subscribe shown; Cancel plan hidden | | Ended | Visible | Visible | Unchanged; Subscribe remains available | | Canceled, still active | Visible | Visible | Unchanged | | Free | Visible | Visible | Unchanged; Subscribe remains available | | Never subscribed | Visible | Visible | Unchanged; Subscribe remains available | | Legacy paid | Visible | Visible | Unchanged | | Legacy free | Visible | Visible | Unchanged; Subscribe remains available | | Legacy never subscribed | Visible | Visible | Unchanged; Subscribe remains available | No new clipping, overlap, or CTA regressions were found. Every invoice button remained within the Settings dialog bounds. The sparse delinquent card and the ended-state Free/$100 presentation are also present on `origin/main`; they are pre-existing and unchanged by this PR. --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 3860f64 commit 307c66a

7 files changed

Lines changed: 161 additions & 23 deletions

File tree

browser_tests/tests/dialogs/creditsTile.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ const endedPersonalBillingStatus: BillingStatusResponse = {
8585
has_funds: true
8686
}
8787

88+
const pastDueBillingStatus: BillingStatusResponse = {
89+
...mockBillingStatus,
90+
is_active: false,
91+
plan_slug: 'pro-monthly',
92+
billing_status: 'payment_failed'
93+
}
94+
8895
async function mockCloudBoot(
8996
page: Page,
9097
billingControlEnabled = true,
@@ -276,6 +283,25 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
276283
.toBe('https://billing.example/portal')
277284
})
278285

286+
test('keeps billing management available for a past-due subscription', async ({
287+
page
288+
}) => {
289+
test.setTimeout(60_000)
290+
291+
await mockCloudBoot(page, true, pastDueBillingStatus)
292+
293+
const content = await openPlanAndCredits(page)
294+
await expect(
295+
content.getByRole('button', { name: 'Billing & invoices' })
296+
).toBeVisible()
297+
await expect(
298+
content.getByRole('button', { name: 'Change plan' })
299+
).toHaveCount(0)
300+
301+
await content.getByRole('button', { name: 'More Options' }).click()
302+
await expect(page.getByText('Cancel plan', { exact: true })).toBeVisible()
303+
})
304+
279305
test('keeps the legacy Workspace UX when billing controls are disabled', async ({
280306
page
281307
}) => {

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import enMessages from '@/locales/en/main.json'
1212
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'
1313

1414
const state = vi.hoisted(() => ({
15+
billingStatus: 'paid',
1516
canAccessSubscriptionFeatures: true,
1617
isFreeTier: false,
1718
isCancelled: false,
19+
planSlug: 'pro-monthly' as string | null,
1820
canTopUp: false,
1921
canManageSubscription: false,
2022
canManageSubscriptionLifecycle: false,
@@ -53,12 +55,14 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
5355

5456
vi.mock('@/composables/billing/useBillingContext', () => ({
5557
useBillingContext: () => ({
58+
billingStatus: computed(() => state.billingStatus),
5659
canAccessSubscriptionFeatures: computed(
5760
() => state.canAccessSubscriptionFeatures
5861
),
5962
isFreeTier: computed(() => state.isFreeTier),
6063
subscription: computed(() => ({
61-
isCancelled: state.isCancelled
64+
isCancelled: state.isCancelled,
65+
planSlug: state.planSlug
6266
})),
6367
balance: ref({ amountMicros: 100 }),
6468
isLoading: ref(false),
@@ -157,9 +161,11 @@ function renderComponent(
157161

158162
describe('CurrentUserPopoverWorkspace', () => {
159163
beforeEach(() => {
164+
state.billingStatus = 'paid'
160165
state.canAccessSubscriptionFeatures = true
161166
state.isFreeTier = false
162167
state.isCancelled = false
168+
state.planSlug = 'pro-monthly'
163169
state.canTopUp = false
164170
state.canManageSubscription = false
165171
state.canManageSubscriptionLifecycle = false
@@ -249,6 +255,38 @@ describe('CurrentUserPopoverWorkspace', () => {
249255
).not.toBeInTheDocument()
250256
})
251257

258+
it.for(['payment_failed', 'paused'])(
259+
'keeps Manage plan available for an existing %s subscription',
260+
(billingStatus) => {
261+
state.billingStatus = billingStatus
262+
state.canAccessSubscriptionFeatures = false
263+
state.canManageSubscription = true
264+
265+
renderComponent('team')
266+
267+
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
268+
expect(
269+
screen.queryByRole('button', { name: 'Subscribe' })
270+
).not.toBeInTheDocument()
271+
}
272+
)
273+
274+
it('shows Subscribe instead of Manage plan when payment_failed has no plan', () => {
275+
state.billingStatus = 'payment_failed'
276+
state.canAccessSubscriptionFeatures = false
277+
state.canManageSubscription = true
278+
state.planSlug = null
279+
280+
renderComponent('team')
281+
282+
expect(
283+
screen.queryByTestId('manage-plan-menu-item')
284+
).not.toBeInTheDocument()
285+
expect(
286+
screen.getByRole('button', { name: 'Subscribe' })
287+
).toBeInTheDocument()
288+
})
289+
252290
it.for([
253291
{
254292
name: 'allows a lifecycle manager to resubscribe a cancelled plan',
@@ -335,10 +373,13 @@ describe('CurrentUserPopoverWorkspace', () => {
335373
state.canManageSubscription = true
336374
const { emitted } = renderComponent(workspaceType)
337375

338-
const menuItem = screen.getByTestId('manage-plan-menu-item')
376+
const menuItem = screen.getByRole('button', {
377+
name: enMessages.subscription.managePlan
378+
})
339379
expect(menuItem).toHaveTextContent(enMessages.subscription.managePlan)
340380

341-
await user.click(menuItem)
381+
menuItem.focus()
382+
await user.keyboard('{Enter}')
342383

343384
expect(state.showSettingsDialog).toHaveBeenCalledWith('workspace')
344385
expect(emitted('close')).toHaveLength(1)

src/platform/workspace/components/CurrentUserPopoverWorkspace.vue

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@
143143
}}</span>
144144
</div>
145145

146-
<div
146+
<button
147147
v-if="!accountActionsOnly && showManagePlan"
148+
type="button"
148149
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
149150
data-testid="manage-plan-menu-item"
150151
@click="handleOpenPlanAndCreditsSettings"
@@ -153,7 +154,7 @@
153154
<span class="flex-1 text-sm text-base-foreground">{{
154155
$t('subscription.managePlan')
155156
}}</span>
156-
</div>
157+
</button>
157158

158159
<!-- Partner Nodes Pricing (always shown) -->
159160
<div
@@ -271,6 +272,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
271272
const settingsDialog = useSettingsDialog()
272273
const dialogService = useDialogService()
273274
const {
275+
billingStatus,
274276
canAccessSubscriptionFeatures,
275277
isFreeTier,
276278
subscription,
@@ -304,15 +306,22 @@ const displayedCredits = computed(() => {
304306
const showPlansAndPricing = computed(
305307
() => permissions.value.canManageSubscription
306308
)
309+
const hasDelinquentSubscription = computed(
310+
() =>
311+
(billingStatus.value === 'payment_failed' ||
312+
billingStatus.value === 'paused') &&
313+
Boolean(subscription.value?.planSlug)
314+
)
307315
const showManagePlan = computed(
308316
() =>
309317
permissions.value.canManageSubscription &&
310-
canAccessSubscriptionFeatures.value
318+
(canAccessSubscriptionFeatures.value || hasDelinquentSubscription.value)
311319
)
312320
const showSubscribeAction = computed(
313321
() =>
314322
(isCancelled.value && permissions.value.canManageSubscriptionLifecycle) ||
315323
(!canAccessSubscriptionFeatures.value &&
324+
!hasDelinquentSubscription.value &&
316325
permissions.value.canManageSubscription)
317326
)
318327

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
584584
})
585585

586586
it.for(['paid', 'payment_failed', 'paused'] as BillingStatus[])(
587-
'keeps a %s personal plan visible until it is terminal',
587+
'keeps billing access for a non-terminal %s personal plan',
588588
(billingStatus) => {
589589
mockIsInPersonalWorkspace.value = true
590590
mockIsActiveSubscription.value = false
@@ -598,6 +598,12 @@ describe('SubscriptionPanelContentWorkspace', () => {
598598
expect(
599599
screen.queryByRole('button', { name: 'Subscribe' })
600600
).not.toBeInTheDocument()
601+
expect(
602+
screen.getByRole('button', { name: 'Billing & invoices' })
603+
).toBeInTheDocument()
604+
expect(
605+
screen.queryByRole('button', { name: 'Change plan' })
606+
).not.toBeInTheDocument()
601607
}
602608
)
603609

@@ -866,7 +872,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
866872
{ state: 'never-subscribed', hasSubscription: false, tier: 'PRO' },
867873
{ state: 'Free', hasSubscription: true, tier: 'FREE' }
868874
] as const)(
869-
'hides legacy billing access from $state personal workspaces',
875+
'keeps billing access for $state personal workspaces',
870876
({ hasSubscription, tier }) => {
871877
mockBillingType.value = 'legacy'
872878
mockBillingStatus.value = 'inactive'
@@ -881,8 +887,8 @@ describe('SubscriptionPanelContentWorkspace', () => {
881887

882888
expect(screen.getByRole('heading', { name: 'Free' })).toBeInTheDocument()
883889
expect(
884-
screen.queryByRole('button', { name: 'Billing & invoices' })
885-
).not.toBeInTheDocument()
890+
screen.getByRole('button', { name: 'Billing & invoices' })
891+
).toBeInTheDocument()
886892
expect(
887893
screen.getByRole('button', { name: 'Subscribe' })
888894
).toBeInTheDocument()

src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,7 @@
177177
</div>
178178
<div class="flex flex-wrap gap-2 md:ml-auto">
179179
<Button
180-
v-if="
181-
isCloud &&
182-
permissions.canManageSubscription &&
183-
(billingType === 'workspace' ||
184-
(isSubscriptionEnded &&
185-
!isFreeTierPlan &&
186-
subscription !== null))
187-
"
180+
v-if="isCloud && permissions.canManageSubscription"
188181
size="lg"
189182
variant="secondary"
190183
class="rounded-lg bg-interface-menu-component-surface-selected px-4 text-sm font-normal text-text-primary"
@@ -242,7 +235,10 @@
242235
</div>
243236

244237
<div
245-
v-if="canAccessSubscriptionFeatures"
238+
v-if="
239+
canAccessSubscriptionFeatures ||
240+
(isCloud && permissions.canManageSubscription)
241+
"
246242
class="flex flex-wrap gap-2 md:ml-auto"
247243
>
248244
<Button
@@ -279,6 +275,7 @@
279275
<Button
280276
v-else-if="
281277
!isSubscriptionCancelled &&
278+
canAccessSubscriptionFeatures &&
282279
permissions.canManageSubscription
283280
"
284281
size="lg"
@@ -452,7 +449,6 @@ function openSubscriptionVerification() {
452449
}
453450
454451
const {
455-
type: billingType,
456452
canAccessSubscriptionFeatures,
457453
isFreeTier: isFreeTierPlan,
458454
isTeamPlan,

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
44
import { useWorkspaceMenuItems } from './useWorkspaceMenuItems'
55

66
const state = vi.hoisted(() => ({
7+
billingStatus: 'paid',
78
canLeaveWorkspace: false,
89
canManageSubscription: false,
910
canManageSubscriptionLifecycle: false,
1011
isActiveSubscription: true,
1112
isDeleteDisabled: false,
1213
isFreeTier: false,
1314
isInPersonalWorkspace: false,
15+
planSlug: 'pro-monthly' as string | null,
1416
isSubscriptionCancelled: false
1517
}))
1618

@@ -27,8 +29,12 @@ vi.mock('vue-i18n', () => ({
2729

2830
vi.mock('@/composables/billing/useBillingContext', () => ({
2931
useBillingContext: () => ({
32+
billingStatus: computed(() => state.billingStatus),
3033
isFreeTier: computed(() => state.isFreeTier),
31-
subscription: computed(() => ({ endDate: '2026-08-01T00:00:00Z' }))
34+
subscription: computed(() => ({
35+
endDate: '2026-08-01T00:00:00Z',
36+
planSlug: state.planSlug
37+
}))
3238
})
3339
}))
3440

@@ -66,13 +72,15 @@ vi.mock('@/services/dialogService', () => ({
6672

6773
describe('useWorkspaceMenuItems', () => {
6874
beforeEach(() => {
75+
state.billingStatus = 'paid'
6976
state.canLeaveWorkspace = false
7077
state.canManageSubscription = false
7178
state.canManageSubscriptionLifecycle = false
7279
state.isActiveSubscription = true
7380
state.isDeleteDisabled = false
7481
state.isFreeTier = false
7582
state.isInPersonalWorkspace = false
83+
state.planSlug = 'pro-monthly'
7684
state.isSubscriptionCancelled = false
7785
})
7886

@@ -113,6 +121,55 @@ describe('useWorkspaceMenuItems', () => {
113121
)
114122
})
115123

124+
it('allows cancellation while a payment_failed plan needs payment recovery', () => {
125+
state.billingStatus = 'payment_failed'
126+
state.canManageSubscriptionLifecycle = true
127+
state.isActiveSubscription = false
128+
129+
const { menuItems } = useWorkspaceMenuItems()
130+
131+
expect(menuItems.value.map((item) => item.label)).toContain(
132+
'subscription.cancelPlan'
133+
)
134+
})
135+
136+
it('allows cancellation while an existing plan is paused', () => {
137+
state.billingStatus = 'paused'
138+
state.canManageSubscriptionLifecycle = true
139+
state.isActiveSubscription = false
140+
141+
const { menuItems } = useWorkspaceMenuItems()
142+
143+
expect(menuItems.value.map((item) => item.label)).toContain(
144+
'subscription.cancelPlan'
145+
)
146+
})
147+
148+
it('withholds cancellation when payment_failed has no subscription plan', () => {
149+
state.billingStatus = 'payment_failed'
150+
state.canManageSubscriptionLifecycle = true
151+
state.isActiveSubscription = false
152+
state.planSlug = null
153+
154+
const { menuItems } = useWorkspaceMenuItems()
155+
156+
expect(menuItems.value.map((item) => item.label)).not.toContain(
157+
'subscription.cancelPlan'
158+
)
159+
})
160+
161+
it('withholds cancellation for payment_failed without lifecycle permission', () => {
162+
state.billingStatus = 'payment_failed'
163+
state.canManageSubscriptionLifecycle = false
164+
state.isActiveSubscription = false
165+
166+
const { menuItems } = useWorkspaceMenuItems()
167+
168+
expect(menuItems.value.map((item) => item.label)).not.toContain(
169+
'subscription.cancelPlan'
170+
)
171+
})
172+
116173
it('rechecks eligibility before opening the cancellation dialog', () => {
117174
state.canManageSubscriptionLifecycle = true
118175
const { menuItems } = useWorkspaceMenuItems()

src/platform/workspace/composables/useWorkspaceMenuItems.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { useDialogService } from '@/services/dialogService'
1515
*/
1616
export function useWorkspaceMenuItems() {
1717
const { t } = useI18n()
18-
const { isFreeTier, subscription } = useBillingContext()
18+
const { billingStatus, isFreeTier, subscription } = useBillingContext()
1919
const {
2020
permissions,
2121
uiConfig,
@@ -65,7 +65,10 @@ export function useWorkspaceMenuItems() {
6565
const canCancelPlan = computed(
6666
() =>
6767
permissions.value.canManageSubscriptionLifecycle &&
68-
isActiveSubscription.value &&
68+
(isActiveSubscription.value ||
69+
((billingStatus.value === 'payment_failed' ||
70+
billingStatus.value === 'paused') &&
71+
Boolean(subscription.value?.planSlug))) &&
6972
!isSubscriptionCancelled.value &&
7073
!isFreeTier.value
7174
)

0 commit comments

Comments
 (0)