Skip to content

Commit 596c71a

Browse files
committed
feat: role-aware billing status banner for team workspaces
Surface a single priority-ordered billing banner on the graph and linear shells (FE-1246 + FE-968 banner states): - BillingStatusBanner renders one of paused > payment_failed > out-of-credits > ending, gated to team workspaces. Owner variants get an action (Update payment / Add credits / Reactivate); members get read-only copy for paused and out-of-credits, and fall through owner-only states. - Pure deriveBillingBanner() encodes the priority + role gating; a thin useBillingbanner() shared composable feeds it from useBillingContext, and owns the session dismiss for the out-of-credits banner. - out-of-credits also routes the submit-time rejection to the credits account-precondition modal (team 429 message + personal insufficient_credits type), with team members getting a read-only notice instead of the top-up dialog. - paused is an inert stub until the backend projects a 'paused' subscription status; every other state renders from today's billing fields.
1 parent ffb4ae3 commit 596c71a

20 files changed

Lines changed: 1103 additions & 8 deletions

src/locales/en/main.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2472,6 +2472,11 @@
24722472
},
24732473
"credits": {
24742474
"activity": "Activity",
2475+
"insufficient": {
2476+
"memberTitle": "This workspace is out of credits",
2477+
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
2478+
"memberCta": "Ok, got it"
2479+
},
24752480
"credits": "Credits",
24762481
"yourCreditBalance": "Your credit balance",
24772482
"purchaseCredits": "Purchase Credits",
@@ -2881,6 +2886,32 @@
28812886
"updatePassword": "Update Password"
28822887
},
28832888
"workspacePanel": {
2889+
"billingStatus": {
2890+
"warning": {
2891+
"title": "Payment declined",
2892+
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
2893+
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
2894+
},
2895+
"paused": {
2896+
"title": "Subscription paused",
2897+
"body": "This workspace's subscription is paused. Update payment to resume.",
2898+
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
2899+
},
2900+
"outOfCredits": {
2901+
"title": "Out of credits",
2902+
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
2903+
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
2904+
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
2905+
"addCredits": "Add credits",
2906+
"dismiss": "Dismiss"
2907+
},
2908+
"ending": {
2909+
"title": "Your team plan ends on {date}",
2910+
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
2911+
"reactivate": "Reactivate plan"
2912+
},
2913+
"updatePayment": "Update payment"
2914+
},
28842915
"invite": "Invite",
28852916
"inviteMember": "Invite member",
28862917
"inviteLimitReached": "You've reached the maximum of {count} members",

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,19 @@ const mockDialogService = {
88
showTopUpCreditsDialog: vi.fn()
99
}
1010

11+
const mockBilling = {
12+
fetchStatus: vi.fn(),
13+
fetchBalance: vi.fn()
14+
}
15+
1116
vi.mock('@/services/dialogService', () => ({
1217
useDialogService: vi.fn(() => mockDialogService)
1318
}))
1419

20+
vi.mock('@/composables/billing/useBillingContext', () => ({
21+
useBillingContext: vi.fn(() => mockBilling)
22+
}))
23+
1524
describe('useAccountPreconditionDialog', () => {
1625
beforeEach(() => {
1726
vi.clearAllMocks()
@@ -55,4 +64,18 @@ describe('useAccountPreconditionDialog', () => {
5564
mockDialogService.showSubscriptionRequiredDialog
5665
).not.toHaveBeenCalled()
5766
})
67+
68+
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
69+
useAccountPreconditionDialog().open('credits')
70+
71+
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
72+
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
73+
})
74+
75+
it('does not touch billing state for non-credit preconditions', () => {
76+
useAccountPreconditionDialog().open('subscription')
77+
78+
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
79+
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
80+
})
5881
})

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useBillingContext } from '@/composables/billing/useBillingContext'
12
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
23
import { useDialogService } from '@/services/dialogService'
34

@@ -28,11 +29,19 @@ export function useAccountPreconditionDialog() {
2829
reason: 'subscription_required'
2930
})
3031
return
31-
case 'credits':
32+
case 'credits': {
33+
// The server just declared the balance exhausted; there is no push or
34+
// polling for billing state, so refresh it here to converge
35+
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
36+
// refresh is best-effort: allSettled keeps a flaky billing API from
37+
// surfacing as unhandled rejections.
38+
const { fetchStatus, fetchBalance } = useBillingContext()
39+
void Promise.allSettled([fetchStatus(), fetchBalance()])
3240
void dialogService.showTopUpCreditsDialog({
3341
isInsufficientCredits: true
3442
})
3543
return
44+
}
3645
}
3746
}
3847

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,21 @@ describe('useSubscriptionDialog', () => {
242242
expect(mockTrackSubscription).not.toHaveBeenCalled()
243243
})
244244

245+
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
246+
mockShouldUseWorkspaceBilling.value = true
247+
mockIsInPersonalWorkspace.value = false
248+
mockCanManageSubscription.value = false
249+
const { showPricingTable } = useSubscriptionDialog()
250+
251+
showPricingTable({ reason: 'out_of_credits' })
252+
253+
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
254+
const props = mockShowLayoutDialog.mock.calls[0][0].props
255+
expect(props).toHaveProperty('onClose')
256+
expect(props).not.toHaveProperty('reason')
257+
expect(props).not.toHaveProperty('initialPlanMode')
258+
})
259+
245260
it('does not track on non-cloud', () => {
246261
mockIsCloud.value = false
247262
const { showPricingTable } = useSubscriptionDialog()

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ export const useSubscriptionDialog = () => {
5555

5656
// Members can't manage the workspace subscription, so a blocked run shows a
5757
// small read-only "ask your owner to reactivate" modal instead of the
58-
// pricing table. Out-of-credits still routes everyone to the credits flow.
58+
// pricing table — including out-of-credits, whose member recovery path is
59+
// also owner-only (FE-1246).
5960
if (
6061
shouldUseWorkspaceBilling.value &&
6162
!workspaceStore.isInPersonalWorkspace &&
62-
!permissions.value.canManageSubscription &&
63-
options?.reason !== 'out_of_credits'
63+
!permissions.value.canManageSubscription
6464
) {
6565
dialogService.showLayoutDialog({
6666
key: DIALOG_KEY,

src/platform/errorCatalog/accountPreconditionRouting.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ describe('resolveAccountPrecondition', () => {
7373
).toBe('credits')
7474
})
7575

76+
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
77+
expect(
78+
resolveAccountPrecondition({
79+
exceptionType: 'insufficient_credits',
80+
exceptionMessage: 'Workspace balance exhausted'
81+
})
82+
).toBe('credits')
83+
})
84+
85+
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
86+
expect(
87+
resolveAccountPrecondition({
88+
exceptionType: 'PAYMENT_REQUIRED',
89+
exceptionMessage: 'Insufficient credits to queue workflows'
90+
})
91+
).toBe('credits')
92+
})
93+
94+
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
95+
expect(
96+
resolveAccountPrecondition({
97+
exceptionType: 'PAYMENT_REQUIRED',
98+
exceptionMessage: 'Subscription required to queue workflows'
99+
})
100+
).toBe('subscription')
101+
})
102+
76103
it('returns undefined for an ordinary workflow error', () => {
77104
expect(
78105
resolveAccountPrecondition({

src/platform/errorCatalog/runtimeErrorMatcher.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
3333
'Payment Required: Please add credits to your account to use this node.'
3434
])
3535
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
36-
'Payment Required: Please add credits to your workspace to continue.'
36+
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
37+
'Payment Required: Please add credits to your workspace to continue.',
38+
// Submit-time 429 rejection for a team workspace out of credits
39+
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
40+
// with the subscription-required rejection, so it can only be told apart by
41+
// message.
42+
'Insufficient credits to queue workflows'
3743
])
3844
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
3945
'Workspace has no active subscription. Please subscribe to a plan to continue.',
@@ -243,8 +249,12 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
243249
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
244250
},
245251
{
252+
// 'insufficient_credits' is the error.type BE-2866 will emit on the
253+
// personal submit-time 402; the team submit path is a 429 matched by
254+
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
246255
matches: (info, message) =>
247256
info.exceptionType === 'InsufficientFundsError' ||
257+
info.exceptionType === 'insufficient_credits' ||
248258
INSUFFICIENT_CREDITS_MESSAGES.has(message),
249259
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
250260
},

src/platform/workspace/api/workspaceApi.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ export type BillingSubscriptionStatus =
244244
| 'scheduled'
245245
| 'ended'
246246
| 'canceled'
247+
// Not yet emitted by the backend; the paused banner stays inert until the
248+
// status API projects the workspace's Stripe-paused state (see FE-968).
249+
| 'paused'
247250

248251
export type BillingStatus =
249252
| 'awaiting_payment_method'

0 commit comments

Comments
 (0)