Compare commits

..

17 Commits

Author SHA1 Message Date
DrJKL
b19d8d968f fix: clipboard copy and industry normalization regressions
- useCopyToClipboard: use navigator.clipboard.writeText directly.
  vueuse 14.3's useClipboard copy() no longer rejects on failure (it
  swallows errors into an internal legacy path gated on an async
  permission query), which bypassed our execCommand fallback and left
  the clipboard empty in the error-dialog e2e tests.
- Pin fuse.js to 7.0.0: 7.5 enforces minMatchCharLength strictly, so
  4-char queries like 'film' stopped matching survey industry
  categories. Retuning the fuzzy config for 7.5 is follow-up work.
2026-07-16 23:33:13 -07:00
DrJKL
04c14d98c5 ci: don't fail website screenshot regen on label cleanup
The restricted pull_request token can't always remove the trigger
label; that 403 was failing otherwise-successful runs.
2026-07-16 23:33:12 -07:00
DrJKL
ba2d7c5774 ci: bump website Playwright image to v1.61.1
Match @playwright/test 1.61.1 from the catalog; the stale v1.58.1 image
broke website-e2e and made screenshot regeneration a no-op.
2026-07-16 23:33:11 -07:00
DrJKL
99faedfa81 fix: pin primevue family to 4.2.5
The lockfile regeneration floated primevue 4.2.5 -> 4.5.5, whose
@primeuix/styled 0.7.4 production bundle emits the dark-theme rule as
unmatchable nested CSS ('.dark-theme ... { & :root ... }'). Built apps
lost :root color-scheme: dark, flipping native form controls (e.g. the
legacy menu's Extra options checkbox) to light rendering. Dev builds use
the package's development bundle, which emits the flat selector - which
is why pnpm dev looked fine while pnpm preview did not.

Pin the family to main's resolved 4.2.5 until the upstream production
CSS generation is fixed.
2026-07-16 20:45:18 -07:00
DrJKL
2182166da0 Merge remote-tracking branch 'origin/main' into drjkl/update-deps
# Conflicts:
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
2026-07-16 18:02:26 -07:00
DrJKL
ecfd1f3816 fix: restore fallow tooling dropped in merge resolution
The branch merge of main accidentally reverted #13271 (fallow dep,
scripts, config, gitignore entry). Restore it from main.
2026-07-16 17:57:22 -07:00
Alexander Brown
f79f0e6066 Merge branch 'main' into drjkl/update-deps 2026-07-16 17:33:50 -07:00
Alexander Brown
c924d62f0c Update container target to v0.0.21 2026-07-17 00:33:20 +00:00
Alexander Brown
99674df73b chore: add fallow dev dependency, scripts, and minimal config (#13271)
Adds the [fallow](https://github.com/fallow-rs/fallow)
codebase-intelligence analyzer as a devDependency to surface unused
code, duplication, and circular dependencies.

## Changes
- `package.json` — `fallow` devDep (`catalog:`) + scripts:
  - `pnpm fallow` — full-repo scan
  - `pnpm fallow:audit` — changed-files gate (suitable for CI)
- `pnpm-workspace.yaml` — `fallow` catalog entry
- `.fallowrc.json` — minimal config: entry points (`src/main.ts`,
`index.html`) + duplicate-clone threshold, ignoring generated files
- `.gitignore` — `.fallow/` cache dir

This PR only wires up the tool. The first scan surfaced cleanup
candidates (unused deps/exports, duplicate exports, circular deps);
those are tracked as separate follow-up PRs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:32:29 +00:00
DrJKL
797fd0c78c chore: hold dompurify at 3.4.7 for happy-dom compat
DOMPurify >=3.4.8 changed its node iterator ('better template
scrubbing') in a way that silently strips legitimate tags (h1, etc.)
under happy-dom - real browsers and jsdom are unaffected. Pin to 3.4.7
(still ahead of main's 3.4.5, keeps the 3.4.6/3.4.7 DOM-clobbering
fixes) until the upstream incompatibility is resolved.
2026-07-16 17:09:44 -07:00
Dante
cfaf89edea feat: role-aware billing status banner for team workspaces (FE-1246) (#13641)
Adds the single billing-status banner slot for team plans, rendered in
priority order — **paused > payment declined > out of credits > ending**
— with role-aware CTAs. At most one state shows at a time.

Placement matches the prototype (`comfydesigner/team-workspaces-v1`) and
Figma: the banner lives in the **workspace settings panel**, in one slot
between the tab list and the tab body, so it shows on every tab. Our
container has two tabs, so one mount covers both rather than duplicating
the banner into each panel as the prototype does.

Derivation lives in a pure `deriveBillingBanner()`; the component only
renders. State comes from `billing-status` fields, never from error
strings (per Luke's user story).


## Gated on the plan, not the workspace type

`useBillingContext` gains `isTeamPlan`: a credit stop marks the
per-credit Team plan, a `team-` slug the retired seat-based ones.
Workspace type is the wrong question in both directions:

- A **team workspace can sit on a retired seat-based plan** (tier
STANDARD/CREATOR/PRO) — the type gate admits it.
- Once consolidated billing lands (cloud#5010), a **personal workspace
can hold a team plan**. The backend blocks that today pending BE-1526,
so the type gate is only accidentally right, and only for now. Per
[Hunter](https://comfy-organization.slack.com/archives/C0BEE5503RQ/p1784151520761519?thread_ts=1784051425.176829):
*"We should deprecate the workspace type and derive it from the
subscription."*

Two deliberate non-choices:

- **Not gated on `isActiveSubscription`**, unlike the existing
`isLegacyTeamPlan`. The spend gate folds `billing_status` into
`is_active`, so a paused or payment-failed team plan reports
`is_active=false` — it must still read as a team plan exactly when the
banner is needed.
- **Not gated on `subscription_tier === 'TEAM'`**, which would silently
drop every legacy team subscriber. The FE cannot express `'TEAM'`
anyway: `tierPricing.ts` resolves `SubscriptionTier` from the
**registry** spec for what is an **ingest** field. That's a real bug,
but orthogonal — filing separately.

## Fixes payment_failed, which was dead code

`payment_failed` denies spend, so it always arrives with
`is_active=false` — and the check sat **below** the `is_active` gate,
two lines under a comment documenting that exact trap for `paused`.
**Every team in Stripe dunning saw no banner.** Now hoisted alongside
`paused`.

Its tests passed only because they spread the `funded` fixture
(`isActiveSubscription: true`) onto `payment_failed` — a pairing the
backend never emits. They now pin `is_active=false`. Both this and the
`isTeamPlan` decoupling are mutation-tested: reverting either kills
tests.

Members no longer fall through from `payment_failed` to out-of-credits —
with the real pairing that path was unreachable, and DES-380 says
members never see the payment banner.

## Renders

The six role/state variants render from the Storybook stories at
`Platform/Workspace/BillingStatusBanner`. Each story drives the real
`deriveBillingBanner` through a stubbed billing context, so a story can
only show a state the backend can actually produce — not a hand-set
banner kind.

Run them with `DISTRIBUTION=cloud pnpm storybook` — the banner is
cloud-only and `isCloud` is compile-time, so a plain `pnpm storybook`
renders every story empty.

Icon language follows the prototype's severity rule: amber
triangle-alert for every action-needed state (paused, payment declined,
out of credits), muted circle-alert reserved for the informational "plan
ends" notice.

## Feature flag

Personal-workspace routing now keys off **`billing_control_enabled`**,
replacing `consolidated_billing_enabled` (which had exactly one consumer
— this routing check — so it is replaced rather than left alongside).
Cloud registers the new flag in
[Comfy-Org/cloud#5091](https://github.com/Comfy-Org/cloud/pull/5091).

This coupling is load-bearing for the banner. `isTeamPlan` requires the
workspace billing rail, so a personal workspace holding a team plan only
surfaces billing state once its routing flag is on. Had routing stayed
on `consolidated_billing_enabled` while the subscribe path unblocks
under `billing_control_enabled`, the two could diverge: a personal
workspace could buy a team plan and then be routed to legacy, which
carries no `billing_status` at all, blanking the banner.

**Merge order:** cloud#5091 must merge and `billing_control_enabled`
must exist in PostHog before this ships, otherwise the flag resolves
false for everyone and every personal workspace routes to legacy. Both
flags default false, so the floor is today's behaviour — but any users
already rolled out on `consolidated_billing_enabled` would revert to
legacy billing until `billing_control_enabled` is rolled out to them.
Worth confirming the current rollout state before merge.

## Backend status

`billing_status: 'paused'` merged in cloud
[#5075](https://github.com/Comfy-Org/cloud/pull/5075) but is **not
emitted yet**: the lifecycle handler that writes it is unreachable in
prod (ingest's forward allowlist omits `customer.subscription.*`;
billing-api is ClusterIP with no ingress). That's BE-1530's unfinished
half, tracked backend-side. The paused banner stays inert until then.

The other three states render from fields the API already returns:
`billing_status=payment_failed` + `renewal_date`, `has_funds`, and
`cancel_at`.

## How has this been tested?

- `useBillingContext.test.ts` — `isTeamPlan` across per-credit, legacy,
paused, payment-failed, and team-workspace-on-personal-plan
- `deriveBillingBanner.test.ts` — priority order, role gating, and the
realistic `is_active=false` pairing for both paused and payment_failed
- `BillingStatusBanner.test.ts` — per-variant copy and actions, date
interpolation + no-date fallback, cross-mount dismiss, ending read-only
for a non-original owner
- `useBillingBanner.test.ts` — dismiss resets after a top-up so a later
exhaustion re-shows
- `WorkspacePanelContent.test.ts` — the banner takes one slot above the
tab body
- 696 tests pass across the touched areas; typecheck / lint / knip clean

## Known gaps

- **Blocked-run surface:** settings-scoped per the prototype, so a user
who exhausts credits mid-canvas won't see it until they open settings.
Queue-time blocked-submit is carried by the existing
insufficient-credits dialog. An app-shell surface would be a design
call, not this PR.
- **Personal workspaces on consolidated billing** get no banner —
`isTeamPlan` excludes them by design, matching FE-1246's team scope and
the `!isInPersonalWorkspace` precedent in `useSubscriptionDialog`. Their
copy would need personal variants (`outOfCredits.body` says "Your
team…") and a free-tier decision. Belongs with the consolidated-billing
rollout.
- **Prototype slot:** the prototype's banner exposes an `actions` slot
used only by its Invoices view. We have no such view, so it's omitted
until needed.


## screenshots

<img width="1150" height="574" alt="01-paused-owner"
src="https://github.com/user-attachments/assets/c1ff0abd-0286-4453-85f2-6f89cff1e655"
/>
<img width="1150" height="574" alt="02-paused-member"
src="https://github.com/user-attachments/assets/ad5ce843-cbc6-49d1-95fc-28e908bc010a"
/>
<img width="1150" height="574" alt="03-payment-declined"
src="https://github.com/user-attachments/assets/717c1191-8f12-4835-90c6-5fe345b20780"
/>
<img width="1150" height="574" alt="04-payment-declined-no-date"
src="https://github.com/user-attachments/assets/c7e483e4-e0cb-4810-b090-a9c432546dbd"
/>
<img width="1150" height="574" alt="05-out-of-credits-owner"
src="https://github.com/user-attachments/assets/9261fd39-ecdb-4501-a401-47d9cbee04dc"
/>
<img width="1150" height="574" alt="06-out-of-credits-member"
src="https://github.com/user-attachments/assets/4a91af88-4405-43e1-8975-b64fdf180a13"
/>
<img width="1150" height="574" alt="07-ending-owner"
src="https://github.com/user-attachments/assets/80e5011e-ef11-482f-bc49-1ddfbe991221"
/>
<img width="1150" height="574" alt="08-ending-non-original-owner"
src="https://github.com/user-attachments/assets/129b79aa-1427-4286-829c-eed501a6d44d"
/>
2026-07-17 00:08:30 +00:00
Alexander Brown
5e1f0ddd03 Merge branch 'main' into drjkl/update-deps 2026-07-16 15:25:23 -07:00
DrJKL
4adf9bdda5 ci: update e2e container to comfyui-ci-container 0.0.19
Matches the Playwright 1.61 bump in this PR.
2026-07-16 15:22:15 -07:00
DrJKL
0e360677cf chore: respect minimumReleaseAge instead of exempting fresh releases
Drop the auto-added minimumReleaseAgeExclude block and lower range floors
so resolution picks the newest age-compliant versions: vue 3.5.39,
vite 8.1.4, tailwindcss 4.3.2, storybook 10.5.0. The <24h releases flow
in automatically once they age past the gate.
2026-07-16 15:10:06 -07:00
DrJKL
3f656030f7 pnpm dedupe 2026-07-16 14:49:04 -07:00
DrJKL
08c34f2680 chore: reformat with oxfmt 0.59 2026-07-16 14:39:58 -07:00
DrJKL
935142428d chore: update core dev dependencies
- vue 3.5.40, vite 8.1.5, oxlint 1.74, oxfmt 0.59, eslint 10.7,
  vitest 4.1.10, vue-tsc 3.3.7, tailwindcss 4.3.3, playwright 1.61.1,
  storybook 10.5.2, knip 6.27, typescript-eslint 8.64 + ecosystem minors
- pnpm 11.3.0 -> 11.13.1 (packageManager)
- happy-dom held at 20.9.0: 20.10.x throws NotSupportedError on external
  script append, breaking GTM/Typeform loader tests
- fix defineModel default typing for vue 3.5.40 (default factory param
  is now Record<string, unknown>)
- fix useClipboard mock for vueuse 14.3 (ComputedRef fields + copyPending)
- drop knip ignores obsoleted by knip 6.27
- rename invalid CSS ident --color-dark-elevation-1.5 (dot) to -1-5;
  oxfmt 0.59 hard-errors on it
2026-07-16 14:37:52 -07:00
59 changed files with 6682 additions and 4733 deletions

9
.fallowrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"entry": ["src/main.ts", "index.html"],
"duplicates": {
"minOccurrences": 3,
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
},
"rules": {}
}

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -63,7 +63,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,7 +28,7 @@ jobs:
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
timeout-minutes: 15
permissions:
contents: read

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,7 +14,7 @@ jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
timeout-minutes: 15
permissions:
contents: write
@@ -128,6 +128,9 @@ jobs:
- name: Remove label
if: always() && github.event_name == 'pull_request'
# Label cleanup is best-effort; the restricted pull_request token may
# lack permission to remove labels and must not fail the run.
continue-on-error: true
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn.lock
.eslintcache
.prettiercache
.stylelintcache
.fallow/
node_modules
.pnpm-store

View File

@@ -88,6 +88,11 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:

View File

@@ -60,7 +60,5 @@
.claude/
.cursor/
.cursorrules
**/AGENTS.md
**/CLAUDE.md
# Agent instructions
**/AGENTS.md @christian-byrne

View File

@@ -17,9 +17,7 @@ import { mockSystemStats } from '../fixtures/data/systemStats'
// Extend the base set with test-specific nodes
const nodeDefs = createMockNodeDefinitions({
MyCustomNode: {
/* ... */
}
MyCustomNode: {/* ... */}
})
await page.route('**/api/object_info', (route) =>

View File

@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
// billing_control_enabled routes personal workspaces to the unified pricing
// table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
billing_control_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the

View File

@@ -31,9 +31,7 @@ export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
/* props */
}
args: {/* props */}
}
```

View File

@@ -37,10 +37,8 @@ const config: KnipConfig = {
entry: ['src/scripts/**/*.ts']
}
},
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -29,6 +29,8 @@
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"fallow": "fallow",
"fallow:audit": "fallow audit",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"json-schema": "tsx scripts/generate-json-schema.ts",
@@ -95,7 +97,7 @@
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
"@vueuse/router": "^14.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
@@ -109,7 +111,7 @@
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"fuse.js": "7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
@@ -172,6 +174,7 @@
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fallow": "catalog:",
"fast-check": "catalog:",
"fs-extra": "^11.2.0",
"globals": "catalog:",
@@ -216,5 +219,5 @@
"node": ">=25 <26",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.3.0"
"packageManager": "pnpm@11.13.1"
}

View File

@@ -78,9 +78,9 @@
--color-interface-panel-job-progress-border: var(--base-foreground);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b / 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b / 0.1);
--color-modal-tag: rgb(from var(--color-smoke-400) r g b / 0.4);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
@@ -107,7 +107,7 @@
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-1-5: rgba(from white r g b / 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@@ -1873,9 +1873,9 @@ audio.comfy-audio.empty-audio-widget {
/* Shadow Cover TOP */
linear-gradient(--value(--color-*) 30%, transparent) center top,
/* Shadow Cover BOTTOM */
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
/* Shadow TOP */
radial-gradient(
radial-gradient(
farthest-side at 50% 0,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
@@ -1883,7 +1883,7 @@ audio.comfy-audio.empty-audio-widget {
)
center top,
/* Shadow BOTTOM */
radial-gradient(
radial-gradient(
farthest-side at 50% 100%,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,

9461
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,26 +24,26 @@ catalog:
'@iconify/tailwind4': ^1.2.3
'@iconify/tools': ^5.0.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.1
'@lobehub/i18n-cli': ^1.26.1
'@lucide/vue': ^1.17.0
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@playwright/test': ^1.61.1
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
'@primevue/core': ^4.2.5
'@primevue/forms': ^4.2.5
'@primevue/core': 4.2.5
'@primevue/forms': 4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@primevue/themes': 4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^2.1.0
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-docs': ^10.5.0
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.3.0
'@storybook/vue3': ^10.5.0
'@storybook/vue3-vite': ^10.5.0
'@tailwindcss/vite': ^4.3.2
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
@@ -64,11 +64,11 @@ catalog:
'@types/three': ^0.184.1
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@vitejs/plugin-vue': ^6.0.8
'@vitest/coverage-v8': ^4.1.10
'@vitest/ui': ^4.1.10
'@vueuse/core': ^14.3.0
'@vueuse/integrations': ^14.3.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^6.4.2
@@ -76,74 +76,75 @@ catalog:
class-variance-authority: ^0.7.1
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.4.5
dompurify: 3.4.7
dotenv: ^16.4.5
eslint: ^10.4.0
eslint: ^10.7.0
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-import-resolver-typescript: ^4.4.5
eslint-plugin-better-tailwindcss: ^4.6.1
eslint-plugin-import-x: ^4.17.1
eslint-plugin-oxlint: 1.73.0
eslint-plugin-playwright: ^2.10.5
eslint-plugin-storybook: ^10.5.0
eslint-plugin-testing-library: ^7.16.2
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
eslint-plugin-vue: ^10.9.2
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.8.9
happy-dom: 20.9.0
husky: ^9.1.7
jiti: 2.6.1
jiti: 2.7.0
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.15.0
knip: ^6.27.0
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
minisearch: ^7.2.0
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
oxfmt: ^0.59.0
oxlint: ^1.74.0
oxlint-tsgolint: ^0.24.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
primevue: 4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
storybook: ^10.5.0
stylelint: ^16.26.1
tailwindcss: ^4.3.0
tailwindcss: ^4.3.2
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.3
typescript-eslint: ^8.60.0
typescript-eslint: ^8.64.0
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.13
vite: ^8.1.4
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.1.0
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vite-plugin-vue-devtools: ^8.1.5
vitest: ^4.1.10
vue: ^3.5.39
vue-component-type-helpers: ^3.3.7
vue-eslint-parser: ^10.4.1
vue-i18n: ^9.14.5
vue-router: ^4.4.3
vue-tsc: ^3.2.5
vue-tsc: ^3.3.7
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27

View File

@@ -32,11 +32,11 @@ let _runWhenIdle: (
* Falls back to setTimeout-based scheduling in browsers without native support.
*/
export let runWhenGlobalIdle: (
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
// Self-invoking function to set up the idle callback implementation
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: GlobalWindow = globalThis as GlobalWindow

View File

@@ -112,5 +112,12 @@ export interface BillingContext extends BillingState, BillingActions {
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
/**
* True when the subscription is a team plan of either generation. Unlike
* `isLegacyTeamPlan` this does not require an active subscription: the spend
* gate folds billing_status into is_active, so a paused or payment-failed team
* plan reports is_active=false and must still read as a team plan.
*/
isTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockBillingControlEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -27,7 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockBillingControlEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
Object.defineProperty(mockBillingControlEnabled, 'value', {
get: () => billingControlEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
billingControlEnabledRef.value = value
}
})
return {
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
get billingControlEnabled() {
return mockBillingControlEnabled.value
}
}
})
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
it('selects workspace type for personal when billing control is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team regardless of consolidated billing', () => {
it('selects workspace type for team regardless of billing control', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
expect(isLegacyTeamPlan.value).toBe(true)
})
})
describe('isTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isTeamPlan } = useBillingContext()
expect(isTeamPlan.value).toBe(false)
})
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
// isTeamPlan reads the credit stop and the slug, never the tier — which is
// what keeps it working despite that divergence.
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
// The banner states that need isTeamPlan most — paused and payment_failed —
// are exactly the ones the backend reports with is_active=false, because the
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
// subscription would blank the banner precisely when it is needed.
it('stays true for a paused team plan, which the backend reports inactive', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'paused',
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('stays true for a legacy team plan whose payment failed', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'payment_failed',
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
plan_slug: 'pro-monthly'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(false)
})
})
})

View File

@@ -35,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
* workspaces, and for personal workspaces once billing control is enabled;
* personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -141,6 +141,21 @@ function useBillingContextInternal(): BillingContext {
false)
)
// Plan identity, independent of subscription health: the per-credit Team plan
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
// isActiveSubscription on purpose — paused and payment_failed both force
// is_active=false, which is exactly when callers still need to know this is a
// team plan.
const isTeamPlan = computed(
() =>
type.value === 'workspace' &&
(currentTeamCreditStop.value !== null ||
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false))
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -191,9 +206,9 @@ function useBillingContextInternal(): BillingContext {
error.value = null
}
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
// type flips when the team-workspaces or billing-control flag resolves from
// authenticated config, swapping the active backend. Reset then reinit on
// every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
@@ -299,6 +314,7 @@ function useBillingContextInternal(): BillingContext {
isActiveSubscription,
isFreeTier,
isLegacyTeamPlan,
isTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
billingControlEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
})
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
it('moves personal to workspace billing when billing control is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
it('uses workspace billing for team workspaces regardless of billing control', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
it('uses workspace billing for team workspaces with billing control enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()

View File

@@ -8,7 +8,7 @@ import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* stay legacy until `billingControlEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
@@ -23,7 +23,7 @@ export function useBillingRouting() {
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
return 'legacy'
}

View File

@@ -1,17 +1,8 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCopy = vi.fn()
const mockWriteText = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
@@ -22,34 +13,31 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: mockWriteText }
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockCopy.mockResolvedValue(undefined)
mockWriteText.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockWriteText).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
mockWriteText.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
@@ -62,7 +50,7 @@ describe('useCopyToClipboard', () => {
})
it('shows error toast when both modern and legacy fail', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
mockWriteText.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
@@ -73,19 +61,17 @@ describe('useCopyToClipboard', () => {
)
})
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
it('falls through to legacy when clipboard API is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).not.toHaveBeenCalled()
expect(mockWriteText).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })

View File

@@ -1,4 +1,3 @@
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
@@ -20,15 +19,14 @@ function legacyCopy(text: string): boolean {
}
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
if (isSupported.value) {
await copy(text)
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
success = true
}
} catch {

View File

@@ -7,7 +7,7 @@ import {
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
localStorage.setItem('ff:billing_control_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
it('billingControlEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
})
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
cachedBillingControlEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
billing_control_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
@@ -295,15 +295,14 @@ describe('useFeatureFlags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
})

View File

@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -32,7 +32,7 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -191,15 +191,15 @@ export function useFeatureFlags() {
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
* Whether personal workspaces use the workspace-scoped billing flow. While
* false (default), personal workspaces stay on the legacy per-user billing
* flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
get billingControlEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
remoteConfig.value.billing_control_enabled,
cachedBillingControlEnabled
)
},
get signupTurnstileMode() {

View File

@@ -2473,6 +2473,11 @@
},
"credits": {
"activity": "Activity",
"insufficient": {
"memberTitle": "This workspace is out of credits",
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
"memberCta": "Ok, got it"
},
"credits": "Credits",
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
@@ -2882,6 +2887,32 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
"addCredits": "Add credits",
"dismiss": "Dismiss"
},
"ending": {
"title": "Your team plan ends on {date}",
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan"
},
"updatePayment": "Update payment"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",

View File

@@ -19,7 +19,9 @@ export const hasNonEmptyValue = (
return true
}
export const isOtherValue = (current: string | string[] | undefined): boolean =>
export const isOtherValue = (
current: string | string[] | undefined
): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (

View File

@@ -8,10 +8,19 @@ const mockDialogService = {
showTopUpCreditsDialog: vi.fn()
}
const mockBilling = {
fetchStatus: vi.fn(),
fetchBalance: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => mockBilling)
}))
describe('useAccountPreconditionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -55,4 +64,18 @@ describe('useAccountPreconditionDialog', () => {
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
})
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
useAccountPreconditionDialog().open('credits')
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
})
it('does not touch billing state for non-credit preconditions', () => {
useAccountPreconditionDialog().open('subscription')
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
})
})

View File

@@ -1,3 +1,4 @@
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
import { useDialogService } from '@/services/dialogService'
@@ -28,11 +29,19 @@ export function useAccountPreconditionDialog() {
reason: 'subscription_required'
})
return
case 'credits':
case 'credits': {
// The server just declared the balance exhausted; there is no push or
// polling for billing state, so refresh it here to converge
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
// refresh is best-effort: allSettled keeps a flaky billing API from
// surfacing as unhandled rejections.
const { fetchStatus, fetchBalance } = useBillingContext()
void Promise.allSettled([fetchStatus(), fetchBalance()])
void dialogService.showTopUpCreditsDialog({
isInsufficientCredits: true
})
return
}
}
}

View File

@@ -266,6 +266,21 @@ describe('useSubscriptionDialog', () => {
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'out_of_credits' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props).toHaveProperty('onClose')
expect(props).not.toHaveProperty('reason')
expect(props).not.toHaveProperty('initialPlanMode')
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()

View File

@@ -55,12 +55,12 @@ export const useSubscriptionDialog = () => {
// Members can't manage the workspace subscription, so a blocked run shows a
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
// pricing table — including out-of-credits, whose member recovery path is
// also owner-only (FE-1246).
if (
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
!permissions.value.canManageSubscription
) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
@@ -88,9 +88,9 @@ export const useSubscriptionDialog = () => {
} as const
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
// one workspace) for workspaces on the workspace-scoped billing flow.
// Replaces the old personal-vs-team workspace fork. Personal workspaces
// still on the legacy flow (billing control disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.

View File

@@ -73,6 +73,33 @@ describe('resolveAccountPrecondition', () => {
).toBe('credits')
})
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'insufficient_credits',
exceptionMessage: 'Workspace balance exhausted'
})
).toBe('credits')
})
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Insufficient credits to queue workflows'
})
).toBe('credits')
})
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Subscription required to queue workflows'
})
).toBe('subscription')
})
it('returns undefined for an ordinary workflow error', () => {
expect(
resolveAccountPrecondition({

View File

@@ -33,7 +33,13 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your account to use this node.'
])
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your workspace to continue.'
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
'Payment Required: Please add credits to your workspace to continue.',
// Submit-time 429 rejection for a team workspace out of credits
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
// with the subscription-required rejection, so it can only be told apart by
// message.
'Insufficient credits to queue workflows'
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
@@ -243,8 +249,12 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
// 'insufficient_credits' is the error.type BE-2866 will emit on the
// personal submit-time 402; the team submit path is a 429 matched by
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
matches: (info, message) =>
info.exceptionType === 'InsufficientFundsError' ||
info.exceptionType === 'insufficient_credits' ||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
},

View File

@@ -1,5 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
cachedBillingControlEnabled.value = Boolean(
config.billing_control_enabled
)
}
return

View File

@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
undefined
)

View File

@@ -115,7 +115,7 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
billing_control_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -250,6 +250,9 @@ export type BillingStatus =
| 'pending_payment'
| 'paid'
| 'payment_failed'
// A Stripe-paused subscription stays `active` on the activity axis; the pause
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
| 'paused'
| 'inactive'
export interface CurrentTeamCreditStop {

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
data-testid="insufficient-credits-member-message"
>
<div
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
>
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
{{ $t('credits.insufficient.memberTitle') }}
</p>
<button
type="button"
:aria-label="$t('g.close')"
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
@click="onClose"
>
<i class="icon-[lucide--x] size-3" />
</button>
</div>
<div class="p-4">
<p class="m-0 font-inter text-sm text-muted-foreground">
{{ $t('credits.insufficient.memberDescription') }}
</p>
</div>
<div class="flex items-center justify-end p-4">
<Button variant="secondary" size="lg" @click="onClose">
{{ $t('credits.insufficient.memberCta') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onClose } = defineProps<{
onClose: () => void
}>()
</script>

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { SubscriptionInfo } from '@/composables/billing/types'
import { i18n } from '@/i18n'
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
import BillingStatusBanner from './BillingStatusBanner.vue'
/**
* The single billing banner slot for team workspaces (FE-1246), rendered in
* priority order: paused > payment declined > out of credits > ending. At most
* one state shows at a time. Each story drives the real `deriveBillingBanner`
* through the stubbed billing context, so these are the states the backend can
* actually emit — not hand-set banner kinds.
*
* The amber triangle-alert marks action-needed states; the muted circle-alert is
* reserved for the informational "plan ends" notice (DES-380 severity rule).
*
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
* renders empty.
*/
const meta: Meta<typeof BillingStatusBanner> = {
title: 'Platform/Workspace/BillingStatusBanner',
component: BillingStatusBanner,
parameters: { layout: 'fullscreen' }
}
export default meta
type Story = StoryObj<typeof BillingStatusBanner>
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
const teamSubscription: SubscriptionInfo = {
isActive: true,
tier: null,
duration: 'MONTHLY',
planSlug: 'team-monthly',
renewalDate: RENEWAL_DATE,
endDate: null,
isCancelled: false,
hasFunds: true
}
const funded = teamSubscription
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
const cancelled: SubscriptionInfo = {
...teamSubscription,
isCancelled: true,
endDate: PLAN_END_DATE
}
const owner: Partial<WorkspaceUIMockState> = {}
const member: Partial<WorkspaceUIMockState> = {
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
function story(
billing: Partial<BillingContextMockState>,
workspace: Partial<WorkspaceUIMockState>
): Story {
return {
beforeEach() {
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
// than inherit the developer's.
i18n.global.locale.value = 'en'
setBillingContextMock({ isTeamPlan: true, ...billing })
setWorkspaceUIMock(workspace)
}
}
}
/**
* Stripe suspended the subscription. The backend folds billing_status into
* is_active, so a paused workspace always reports `is_active: false` — the
* pairing these stories pin.
*/
export const PausedOwner: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'paused',
subscriptionStatus: 'active'
},
owner
)
/** Members get an admin-directed notice and no action. */
export const PausedMember: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'paused',
subscriptionStatus: 'active'
},
member
)
/**
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
* for the same reason paused is — payment_failed also denies spend.
*/
export const PaymentDeclined: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'payment_failed',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
owner
)
/** Payment declined before a renewal date is known. */
export const PaymentDeclinedNoDate: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'payment_failed',
subscriptionStatus: 'active'
},
owner
)
/** Shared team credits exhausted. Session-dismissible. */
export const OutOfCreditsOwner: Story = story(
{
subscription: exhausted,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
owner
)
/** Members can't top up, so they get contact-admin copy and no Add credits. */
export const OutOfCreditsMember: Story = story(
{
subscription: exhausted,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
member
)
/** Cancelled but still active until the period end. Informational. */
export const EndingOwner: Story = story(
{
subscription: cancelled,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'canceled'
},
owner
)
/**
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
* sees the notice read-only.
*/
export const EndingNonOriginalOwner: Story = story(
{
subscription: cancelled,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'canceled'
},
{ canManageSubscriptionLifecycle: false }
)

View File

@@ -0,0 +1,317 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import type {
BillingStatus,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
interface Subscription {
hasFunds: boolean
isCancelled: boolean
endDate: string | null
}
const state = vi.hoisted(() => ({
billingControlEnabled: true,
isActiveSubscription: true,
isTeamPlan: true,
billingStatus: 'paid' as string | null,
subscription: {
hasFunds: true,
isCancelled: false,
endDate: null
} as Subscription | null,
renewalDate: null as string | null,
workspaceType: 'team' as string,
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true,
showTopUpCreditsDialog: vi.fn(),
manageSubscription: vi.fn(),
handleResubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get billingControlEnabled() {
return state.billingControlEnabled
}
}
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => state.isActiveSubscription),
isTeamPlan: computed(() => state.isTeamPlan),
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
subscription: computed(() => state.subscription),
renewalDate: computed(() => state.renewalDate),
manageSubscription: state.manageSubscription
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: state.canManageSubscription,
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
canTopUp: state.canTopUp
})),
workspaceType: computed(() => state.workspaceType as WorkspaceType)
})
}))
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
useResubscribe: () => ({
isResubscribing: computed(() => false),
handleResubscribe: state.handleResubscribe
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: state.showTopUpCreditsDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspacePanel: {
billingStatus: {
warning: {
title: 'Payment declined',
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
bodyNoDate:
"Your last payment didn't go through. Update payment to avoid a pause."
},
paused: {
title: 'Subscription paused',
body: "This workspace's subscription is paused. Update payment to resume.",
memberBody:
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
outOfCredits: {
title: 'Out of credits',
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
bodyNoDate:
'Your team has used all its credits. Add more credits to continue generating.',
memberBody:
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
addCredits: 'Add credits',
dismiss: 'Dismiss'
},
ending: {
title: 'Your team plan ends on {date}',
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
reactivate: 'Reactivate plan'
},
updatePayment: 'Update payment'
}
}
}
}
})
const globalOptions = {
plugins: [i18n],
stubs: {
Button: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
}
}
}
function renderBanner() {
return render(BillingStatusBanner, { global: globalOptions })
}
function exhausted() {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
}
// The spend gate folds billing_status into is_active, so the backend never emits
// paused alongside an active subscription.
function pausedState() {
state.billingStatus = 'paused'
state.isActiveSubscription = false
}
function paymentFailedState() {
state.billingStatus = 'payment_failed'
state.isActiveSubscription = false
}
describe('BillingStatusBanner', () => {
beforeEach(() => {
state.billingControlEnabled = true
state.isActiveSubscription = true
state.isTeamPlan = true
state.billingStatus = 'paid'
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
state.renewalDate = null
state.workspaceType = 'team'
state.canManageSubscription = true
state.canManageSubscriptionLifecycle = true
state.canTopUp = true
vi.clearAllMocks()
})
it('renders nothing for a healthy funded team', () => {
renderBanner()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('renders nothing when billing control is rolled back, even out of credits', () => {
state.billingControlEnabled = false
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
renderBanner()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows out-of-credits with an Add credits action for owners', async () => {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
})
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
state.canManageSubscription = false
state.canTopUp = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your workspace admins need to add more credits'
)
expect(
screen.queryByRole('button', { name: 'Add credits' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
})
it('dismisses the out-of-credits banner for the session', async () => {
exhausted()
renderBanner()
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shares one dismiss across instances rather than tracking it per mount', async () => {
exhausted()
render(
{
components: { BillingStatusBanner },
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
},
{ global: globalOptions }
)
expect(screen.getAllByRole('status')).toHaveLength(2)
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows the paused banner with Update payment for owners', async () => {
pausedState()
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
expect(screen.getByRole('status')).toHaveTextContent(
'Update payment to resume'
)
await userEvent.click(
screen.getByRole('button', { name: 'Update payment' })
)
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
})
it('shows the paused member notice without an action', () => {
pausedState()
state.canManageSubscription = false
state.canTopUp = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your workspace admins need to update the payment method'
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows the payment-declined banner with Update payment for owners', () => {
paymentFailedState()
state.renewalDate = '2026-08-01T00:00:00Z'
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
expect(
screen.getByRole('button', { name: 'Update payment' })
).toBeInTheDocument()
})
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
paymentFailedState()
state.renewalDate = null
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Update payment to avoid a pause'
)
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
})
it('shows the ending banner with a Reactivate action', async () => {
state.subscription = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
}
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your team plan ends on'
)
await userEvent.click(
screen.getByRole('button', { name: 'Reactivate plan' })
)
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
})
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
state.subscription = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
}
state.canManageSubscriptionLifecycle = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your team plan ends on'
)
expect(
screen.queryByRole('button', { name: 'Reactivate plan' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,165 @@
<template>
<div v-if="banner" class="@container">
<div
role="status"
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'size-4 shrink-0',
banner.muted
? 'icon-[lucide--circle-alert] text-muted-foreground'
: 'icon-[lucide--triangle-alert] text-warning-background'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<div
v-if="banner.dismissible || banner.action"
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
>
<Button
v-if="banner.dismissible"
variant="textonly"
size="lg"
@click="dismiss"
>
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
</Button>
<Button
v-if="banner.action === 'addCredits'"
variant="secondary"
size="lg"
@click="handleAddCredits"
>
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
</Button>
<Button
v-else-if="banner.action === 'reactivate'"
variant="secondary"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
</Button>
<Button
v-else-if="banner.action === 'updatePayment'"
variant="inverted"
size="lg"
@click="handleUpdatePayment"
>
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
const { t, d } = useI18n()
const { renewalDate, subscription, manageSubscription } = useBillingContext()
const { permissions } = useWorkspaceUI()
const { kind, dismiss } = useBillingBanner()
const { isResubscribing, handleResubscribe } = useResubscribe()
const dialogService = useDialogService()
const canManage = computed(() => permissions.value.canManageSubscription)
const canManageLifecycle = computed(
() => permissions.value.canManageSubscriptionLifecycle
)
const canTopUp = computed(() => permissions.value.canTopUp)
const cycleResetDate = computed(() => {
const raw = renewalDate.value
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
})
const planEndDate = computed(() => {
const raw = subscription.value?.endDate
return raw
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
: ''
})
interface BannerView {
muted: boolean
title: string
body: string
action: BannerAction | null
dismissible: boolean
}
const banner = computed<BannerView | null>(() => {
const bs = 'workspacePanel.billingStatus'
switch (kind.value) {
case 'paused':
return {
muted: false,
title: t(`${bs}.paused.title`),
body: canManage.value
? t(`${bs}.paused.body`)
: t(`${bs}.paused.memberBody`),
action: canManage.value ? 'updatePayment' : null,
dismissible: false
}
case 'paymentFailed':
return {
muted: false,
title: t(`${bs}.warning.title`),
body: cycleResetDate.value
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
: t(`${bs}.warning.bodyNoDate`),
action: 'updatePayment',
dismissible: false
}
case 'outOfCredits':
return {
muted: false,
title: t(`${bs}.outOfCredits.title`),
body: canTopUp.value
? cycleResetDate.value
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
: t(`${bs}.outOfCredits.bodyNoDate`)
: t(`${bs}.outOfCredits.memberBody`),
action: canTopUp.value ? 'addCredits' : null,
dismissible: true
}
case 'ending':
return {
muted: true,
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
body: t(`${bs}.ending.body`),
action: canManageLifecycle.value ? 'reactivate' : null,
dismissible: false
}
default:
return null
}
})
function handleAddCredits() {
void dialogService.showTopUpCreditsDialog()
}
function handleUpdatePayment() {
void manageSubscription()
}
</script>

View File

@@ -65,6 +65,16 @@ vi.mock(
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
() => ({
default: {
name: 'BillingStatusBanner',
template: '<div data-testid="billing-banner" />'
}
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -93,6 +103,25 @@ function renderComponent() {
})
}
describe('WorkspacePanelContent billing banner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
renderComponent()
const banner = screen.getByTestId('billing-banner')
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
expect(
planPanel.compareDocumentPosition(banner) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
})
})
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -41,6 +41,8 @@
</TabsTrigger>
</TabsList>
<BillingStatusBanner class="mt-4" />
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
@@ -58,6 +60,7 @@ import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { BillingBannerInputs } from './useBillingBanner'
import { deriveBillingBanner } from './useBillingBanner'
const funded: BillingBannerInputs = {
billingControlEnabled: true,
isTeamPlan: true,
isLoaded: true,
isActiveSubscription: true,
billingStatus: 'paid',
hasFunds: true,
isCancelled: false,
endDate: null,
canManage: true,
outOfCreditsDismissed: false
}
// The backend folds billing_status into is_active, so every spend-denying status
// arrives paired with is_active=false. Pinning that pairing is what keeps these
// cases honest — spreading `funded` instead would assert an input the backend
// cannot emit, and pass no matter where the check sits.
const paused: Partial<BillingBannerInputs> = {
billingStatus: 'paused',
isActiveSubscription: false
}
const paymentFailed: Partial<BillingBannerInputs> = {
billingStatus: 'payment_failed',
isActiveSubscription: false
}
function derive(overrides: Partial<BillingBannerInputs>) {
return deriveBillingBanner({ ...funded, ...overrides })
}
describe('deriveBillingBanner', () => {
it('shows no banner for a healthy funded team', () => {
expect(derive({})).toBeNull()
})
it('shows no banner outside a team plan', () => {
expect(derive({ isTeamPlan: false, hasFunds: false })).toBeNull()
})
it('shows no banner when billing control is rolled back, even out of credits', () => {
expect(derive({ billingControlEnabled: false, hasFunds: false })).toBeNull()
})
it('shows no banner until the subscription snapshot has loaded', () => {
expect(derive({ isLoaded: false, hasFunds: false })).toBeNull()
})
it('surfaces out of credits when the balance is exhausted', () => {
expect(derive({ hasFunds: false })).toBe('outOfCredits')
})
it('shows out of credits to members too', () => {
expect(derive({ hasFunds: false, canManage: false })).toBe('outOfCredits')
})
it('hides out of credits once dismissed', () => {
expect(derive({ hasFunds: false, outOfCreditsDismissed: true })).toBeNull()
})
it('shows payment failed to owners even though the backend reports the plan inactive', () => {
expect(derive(paymentFailed)).toBe('paymentFailed')
})
it('prioritizes payment failure over out of credits for owners', () => {
expect(derive({ ...paymentFailed, hasFunds: false })).toBe('paymentFailed')
})
it('hides payment failed from members, who get the run-lock modal instead', () => {
expect(derive({ ...paymentFailed, canManage: false })).toBeNull()
})
it('prioritizes paused above everything, for owners and members', () => {
expect(derive({ ...paused, hasFunds: false })).toBe('paused')
expect(derive({ ...paused, canManage: false })).toBe('paused')
})
it('shows paused even though the backend reports the workspace inactive', () => {
expect(derive(paused)).toBe('paused')
})
it('surfaces the ending banner for a cancelled-but-active owner', () => {
expect(
derive({
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
})
).toBe('ending')
})
it('does not show the ending banner until the end date is populated', () => {
expect(
derive({
isCancelled: true,
endDate: null
})
).toBeNull()
})
it('hides the ending banner from members', () => {
expect(
derive({
isCancelled: true,
endDate: '2026-08-01T00:00:00Z',
canManage: false
})
).toBeNull()
})
it('shows no banner for an inactive subscription (that is a run-lock modal)', () => {
expect(
derive({ isActiveSubscription: false, billingStatus: 'inactive' })
).toBeNull()
})
})

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const mocks = vi.hoisted(() => ({
billing: null as {
isActiveSubscription: { value: boolean }
isTeamPlan: { value: boolean }
billingStatus: { value: string | null }
subscription: { value: { hasFunds: boolean } | null }
} | null,
billingControlEnabled: null as { value: boolean } | null
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const billingControlEnabled = ref(true)
mocks.billingControlEnabled = billingControlEnabled
return {
useFeatureFlags: () => ({
flags: {
get billingControlEnabled() {
return billingControlEnabled.value
}
}
})
}
})
vi.mock('@/composables/billing/useBillingContext', async () => {
const { ref } = await import('vue')
const billing = {
isActiveSubscription: ref(true),
isTeamPlan: ref(true),
billingStatus: ref<string | null>('paid'),
subscription: ref<{ hasFunds: boolean } | null>({ hasFunds: true })
}
mocks.billing = billing
return { useBillingContext: () => billing }
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', async () => {
const { computed } = await import('vue')
return {
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true
}))
})
}
})
import { useBillingBanner } from './useBillingBanner'
describe('useBillingBanner', () => {
beforeEach(() => {
const b = mocks.billing!
b.isActiveSubscription.value = true
b.isTeamPlan.value = true
b.billingStatus.value = 'paid'
b.subscription.value = { hasFunds: true }
mocks.billingControlEnabled!.value = true
})
it('suppresses the banner entirely when billing control is rolled back', async () => {
const b = mocks.billing!
const { kind } = useBillingBanner()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
mocks.billingControlEnabled!.value = false
await nextTick()
expect(kind.value).toBeNull()
})
it('re-shows the out-of-credits banner after a top-up and a later exhaustion', async () => {
const b = mocks.billing!
const { kind, dismiss } = useBillingBanner()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
dismiss()
await nextTick()
expect(kind.value).toBeNull()
b.subscription.value = { hasFunds: true }
await nextTick()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
})
})

View File

@@ -0,0 +1,106 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { BillingStatus } from '@/platform/workspace/api/workspaceApi'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
export type BillingBannerKind =
| 'paused'
| 'paymentFailed'
| 'outOfCredits'
| 'ending'
export interface BillingBannerInputs {
billingControlEnabled: boolean
isTeamPlan: boolean
isLoaded: boolean
isActiveSubscription: boolean
billingStatus: BillingStatus | null
hasFunds: boolean | null
isCancelled: boolean
endDate: string | null
canManage: boolean
outOfCreditsDismissed: boolean
}
// The single billing banner slot, in priority order: paused > paymentFailed >
// outOfCredits > ending. billingControlEnabled is the FE-1246 kill switch: the
// whole banner is behind it so a PostHog rollback hides it for everyone. Then
// gated on the team PLAN rather than the workspace type, because personal
// workspaces are due to gain team plans (BE-1526) — a workspace-type gate would
// then hide the banner from real team subscribers.
export function deriveBillingBanner(
inputs: BillingBannerInputs
): BillingBannerKind | null {
if (!inputs.billingControlEnabled || !inputs.isTeamPlan || !inputs.isLoaded) {
return null
}
// Both sit above the isActiveSubscription gate because the backend folds
// billing_status into is_active: paused and payment_failed each report
// is_active=false, so either check would be dead code below it.
if (inputs.billingStatus === 'paused') return 'paused'
if (inputs.billingStatus === 'payment_failed' && inputs.canManage) {
return 'paymentFailed'
}
// Inactive workspaces surface a run-lock modal, not this banner. Members hit
// this on payment_failed, which is per design — only billing managers see it.
if (!inputs.isActiveSubscription) return null
if (inputs.hasFunds === false && !inputs.outOfCreditsDismissed) {
return 'outOfCredits'
}
if (inputs.isCancelled && inputs.endDate && inputs.canManage) {
return 'ending'
}
return null
}
function useBillingBannerInternal() {
const { isActiveSubscription, billingStatus, subscription, isTeamPlan } =
useBillingContext()
const { permissions } = useWorkspaceUI()
const { flags } = useFeatureFlags()
const dismissed = ref(false)
const kind = computed<BillingBannerKind | null>(() => {
if (!isCloud) return null
return deriveBillingBanner({
billingControlEnabled: flags.billingControlEnabled,
isTeamPlan: isTeamPlan.value,
isLoaded: subscription.value !== null,
isActiveSubscription: isActiveSubscription.value,
billingStatus: billingStatus.value,
hasFunds: subscription.value?.hasFunds ?? null,
isCancelled: subscription.value?.isCancelled ?? false,
endDate: subscription.value?.endDate ?? null,
canManage: permissions.value.canManageSubscription,
outOfCreditsDismissed: dismissed.value
})
})
// Dismiss silences only the out-of-credits banner, and only for the current
// exhaustion episode: reset once the workspace is funded again so a later
// exhaustion re-shows. Shared state, so it survives the settings panel
// unmounting when the dialog closes.
const hasExhaustedFunds = computed(
() => subscription.value?.hasFunds === false
)
watch(hasExhaustedFunds, (exhausted) => {
if (!exhausted) dismissed.value = false
})
function dismiss() {
dismissed.value = true
}
return { kind, dismiss }
}
export const useBillingBanner = createSharedComposable(useBillingBannerInternal)

View File

@@ -224,9 +224,10 @@ function resolveValues(values: unknown): string[] {
}
const modelValue = defineModel<string | undefined>({
default(modelProps: Props) {
default(modelProps: Record<string, unknown>) {
try {
const values = modelProps.widget.options?.values
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
const resolved = typeof values === 'function' ? values() : values
const firstValue = Array.isArray(resolved)
? resolved.find((value) => value !== null && value !== undefined)

View File

@@ -41,8 +41,9 @@ provide(
)
const modelValue = defineModel<string | undefined>({
default(props: Props) {
const values = props.widget.options?.values
default(modelProps: Record<string, unknown>) {
const modelWidget = modelProps.widget as Props['widget'] | undefined
const values = modelWidget?.options?.values
return (Array.isArray(values) ? values[0] : undefined) ?? ''
}
})

View File

@@ -124,7 +124,7 @@ const findElementsInTag = (
end: number,
id: Uint8Array
): EbmlElementRange | null => {
for (let pos = start; pos < end - 1; ) {
for (let pos = start; pos < end - 1;) {
if (matchesId(data, pos, id)) {
const size = readVint(data, pos + 2)
if (size && size.value > 0) {
@@ -307,7 +307,7 @@ const ebmlToString = (
const parseMetadata = (data: Uint8Array): ComfyMetadata => {
const meta: ComfyMetadata = {}
for (let pos = 0; pos < data.length - 2; ) {
for (let pos = 0; pos < data.length - 2;) {
const tagInfo = findNextTag(data, pos)
if (!tagInfo) {
pos++

View File

@@ -306,13 +306,9 @@ export function useNodeFactory() {
// Create node based on type and configuration
switch (type) {
case 'basic':
return {
/* basic node implementation */
}
return {/* basic node implementation */}
case 'complex':
return {
/* complex node implementation */
}
return {/* complex node implementation */}
default:
throw new Error(`Unknown node type: ${type}`)
}
@@ -347,9 +343,7 @@ Services in ComfyUI can be tested effectively using different approaches dependi
```typescript
// Manual instantiation required
const mockData = [
/* test data */
]
const mockData = [/* test data */]
const service = new NodeSearchService(mockData)
```

View File

@@ -0,0 +1,123 @@
/**
* showTopUpCreditsDialog must route team members (who cannot top up) to the
* read-only contact-admin notice instead of the purchase dialog, while
* owners/personal/legacy users keep the purchase flow.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const closeDialog = vi.hoisted(() => vi.fn())
const state = vi.hoisted(() => ({
isActiveSubscription: true,
isFreeTier: false,
type: 'workspace' as 'workspace' | 'legacy',
canTopUp: true
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog })
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: state.isActiveSubscription },
isFreeTier: { value: state.isFreeTier },
type: { value: state.type }
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: { value: { canTopUp: state.canTopUp } }
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: vi.fn() })
}))
const showSubscriptionDialog = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ show: showSubscriptionDialog })
})
)
import { useDialogService } from '@/services/dialogService'
describe('showTopUpCreditsDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
state.isActiveSubscription = true
state.isFreeTier = false
state.type = 'workspace'
state.canTopUp = true
})
it('shows the purchase dialog to users who can top up', async () => {
await useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('top-up-credits')
})
it('shows the contact-admin notice to team members instead of the purchase dialog', async () => {
state.canTopUp = false
await useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('insufficient-credits-member')
// The member notice draws its own header + close button, so it must open
// headless or Reka wraps it in duplicate chrome.
expect(args.dialogComponentProps.headless).toBe(true)
expect(args.dialogComponentProps.renderer).toBe('reka')
args.props.onClose()
expect(closeDialog).toHaveBeenCalledWith({
key: 'insufficient-credits-member'
})
})
it('ignores workspace permissions on legacy billing', async () => {
state.type = 'legacy'
state.canTopUp = false
await useDialogService().showTopUpCreditsDialog()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('top-up-credits')
})
it('routes a member of an inactive team to the subscription-required flow, not the credits notice', async () => {
state.isActiveSubscription = false
state.canTopUp = false
await useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
expect(showSubscriptionDialog).toHaveBeenCalledWith({
reason: 'out_of_credits'
})
expect(showDialog).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,9 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
import InsufficientCreditsMemberDialog from '@/platform/workspace/components/InsufficientCreditsMemberDialog.vue'
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
@@ -339,6 +341,28 @@ export const useDialogService = () => {
return
}
// Members can't top up a team workspace, so they get a read-only
// "ask your workspace admins" notice instead of the purchase dialog.
if (
type.value === 'workspace' &&
!useWorkspaceUI().permissions.value.canTopUp
) {
return dialogStore.showDialog({
key: 'insufficient-credits-member',
component: InsufficientCreditsMemberDialog,
props: {
onClose: () =>
dialogStore.closeDialog({ key: 'insufficient-credits-member' })
},
dialogComponentProps: {
renderer: 'reka',
headless: true,
contentClass:
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
}
})
}
const component =
type.value === 'workspace'
? TopUpCreditsDialogContentWorkspace

View File

@@ -147,9 +147,7 @@ describe('useAuthStore', () => {
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
const mockAuth: MockAuth = {
/* mock Auth object */
}
const mockAuth: MockAuth = {/* mock Auth object */}
const mockUser: MockUser = {
uid: 'test-user-id',

View File

@@ -2,14 +2,42 @@ import { computed, ref } from 'vue'
import type { BillingContext } from '@/composables/billing/types'
type Subscription = BillingContext['subscription']['value']
/** The billing state a story wants the stub to report. */
export interface BillingContextMockState {
subscription: Subscription
isActiveSubscription: boolean
isTeamPlan: boolean
billingStatus: BillingContext['billingStatus']['value']
subscriptionStatus: BillingContext['subscriptionStatus']['value']
renewalDate: string | null
}
const defaultState: BillingContextMockState = {
subscription: null,
isActiveSubscription: false,
isTeamPlan: false,
billingStatus: null,
subscriptionStatus: null,
renewalDate: null
}
const state = ref<BillingContextMockState>({ ...defaultState })
/** Drives the stub from a story's `beforeEach`. */
export function setBillingContextMock(next: Partial<BillingContextMockState>) {
state.value = { ...defaultState, ...next }
}
/**
* Storybook mock for `useBillingContext`.
*
* The real facade lazily instantiates the legacy billing adapter, which pulls
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
* (no Firebase). This static stub lets presentational billing components — e.g.
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
* without any network or auth.
* (no Firebase). This stub lets billing components — e.g. UnifiedPricingTable,
* BillingStatusBanner — render without any network or auth. It defaults to the
* unsubscribed state; call `setBillingContextMock` to drive a specific one.
*
* Typed against `BillingContext` so the stub stays in lockstep with the real
* composable's return shape: drifted or removed keys fail to compile.
@@ -18,7 +46,7 @@ export function useBillingContext(): BillingContext {
return {
type: computed(() => 'legacy' as const),
isInitialized: ref(true),
subscription: computed(() => null),
subscription: computed(() => state.value.subscription),
balance: computed(() => null),
plans: computed(() => []),
currentPlanSlug: computed(() => null),
@@ -26,13 +54,14 @@ export function useBillingContext(): BillingContext {
currentTeamCreditStop: computed(() => null),
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => false),
isActiveSubscription: computed(() => state.value.isActiveSubscription),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
billingStatus: computed(() => null),
subscriptionStatus: computed(() => null),
isTeamPlan: computed(() => state.value.isTeamPlan),
billingStatus: computed(() => state.value.billingStatus),
subscriptionStatus: computed(() => state.value.subscriptionStatus),
tier: computed(() => null),
renewalDate: computed(() => null),
renewalDate: computed(() => state.value.renewalDate),
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
initialize: async () => {},
fetchStatus: async () => {},

View File

@@ -26,14 +26,14 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
BILLING_CONTROL_ENABLED = 'billing_control_enabled'
}
export function useFeatureFlags() {
return {
flags: {
teamWorkspacesEnabled: true,
consolidatedBillingEnabled: true
billingControlEnabled: true
}
}
}

View File

@@ -0,0 +1,45 @@
import { computed, ref } from 'vue'
import type { WorkspaceType } from '@/platform/workspace/api/workspaceApi'
/** The workspace role/permission state a story wants the stub to report. */
export interface WorkspaceUIMockState {
workspaceType: WorkspaceType
canManageSubscription: boolean
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
}
const defaultState: WorkspaceUIMockState = {
workspaceType: 'team',
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true
}
const state = ref<WorkspaceUIMockState>({ ...defaultState })
/** Drives the stub from a story's `beforeEach`. */
export function setWorkspaceUIMock(next: Partial<WorkspaceUIMockState>) {
state.value = { ...defaultState, ...next }
}
/**
* Storybook mock for `useWorkspaceUI`.
*
* The real composable derives permissions from `useCurrentUser` (Firebase auth)
* and the team workspace store, neither of which is available in Storybook. This
* stub exposes only the role surface the billing banner reads; add keys here as
* other stories need them.
*/
export function useWorkspaceUI() {
return {
workspaceType: computed(() => state.value.workspaceType),
permissions: computed(() => ({
canManageSubscription: state.value.canManageSubscription,
canManageSubscriptionLifecycle:
state.value.canManageSubscriptionLifecycle,
canTopUp: state.value.canTopUp
}))
}
}

View File

@@ -132,9 +132,9 @@ export function fixBadLinks(
// We can set to null (delete), so undefined means we haven't set it at all.
if (patchedNode['inputs']![slot] !== undefined) {
logger.log(
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
'inputs'
]![slot]!} Skipping.`
` > Already set ${node.id}.inputs[${slot}] to ${
patchedNode['inputs']![slot]!
} Skipping.`
)
return false
}