Compare commits

...

6 Commits

Author SHA1 Message Date
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
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
claude[bot]
a154e6a311 Redirect comfy.org/login to cloud.comfy.org/login (#13727)
<!-- ccr-slack-attribution -->
_Requested by **nav** · [Slack
thread](https://comfy-organization.slack.com/archives/C0B61J7QCJW/p1784217313626029?thread_ts=1784217313.626029&cid=C0B61J7QCJW)_

## Summary

Redirect comfy.org/login to cloud.comfy.org/login.

## Changes

- **What**: Adds a `/login` entry to the `redirects` array in
`apps/website/vercel.json`, matching the existing `/blog` ->
`https://blog.comfy.org` cross-origin redirect pattern.

## Review Focus

- Before: `comfy.org/login` had no redirect (404/no route).
- After: `comfy.org/login` returns a 302 (`permanent: false`) to
`https://cloud.comfy.org/login`.
- How: Astro's native redirects don't support cross-origin destinations,
so this uses Vercel's `redirects` config in `apps/website/vercel.json`,
consistent with the existing `/blog` entry.

## Screenshots (if applicable)

N/A

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UZGZ27NenZf5dzdvNcTFev)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 22:20:02 +00:00
steven-comfy
46fec1d47d feat: add hidden /booking-confirmation page for meeting-booking confirmations (#13730)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/booking-confirmation`, a hidden `noindex` page intended as the
post-submit redirect target after a visitor books a meeting with the GTM
team. The page confirms the booking, tells the visitor to check their
email for the calendar invite, and offers a small list of "while you
wait" resources.

## Design

Mirrors the `/individual-submission` template landed in #13697 —
`BaseLayout` + `HeroSection` — so typography and spacing feel native to
the marketing site. The "Resources while you wait" heading uses the
yellow-italic accent used elsewhere on the site; each resource is a
centered `label — url` row.

Resource links source their URLs from `config/routes.ts` where possible
(`routes.learning`, `routes.customers`, `externalLinks.workflows`,
`externalLinks.docs`) so the page stays in sync if those paths ever
move.

## Hidden-page mechanics

Follows the pattern already used for `/individual-submission`,
`/terms-of-service`, and `/payment/*`:

- `noindex` meta tag on the page (both `en` and `zh-CN`)
- Both `/booking-confirmation` and `/zh-CN/booking-confirmation` added
to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts` via
`LOCALE_PREFIXES`, so neither URL is emitted in the sitemap
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`
- English + Simplified Chinese variants both exist as full pages
(matching the existing `/individual-submission` treatment)

## Next step (not in this PR)

Someone with HubSpot / Chili Piper access needs to configure the
meeting-booking flow to redirect visitors to
`https://comfy.org/booking-confirmation` after they schedule.

## Verification

- `pnpm typecheck` → 0 errors, 0 warnings (10 pre-existing hints
unrelated to this change)
- `pnpm build` (in `apps/website`) → 504 pages built;
`/booking-confirmation/` and `/zh-CN/booking-confirmation/` emitted;
neither appears in `dist/sitemap-0.xml`
- `oxlint` and `oxfmt` clean on changed files (also enforced by
lint-staged pre-commit)
- Playwright preview at 1280×900 (English + zh-CN) and 390×844 (English
mobile) — hero, body copy, section header, and all 4 resource rows
render as expected; no horizontal overflow on mobile; only console noise
was transient Vite dep-optimizer 504s unrelated to this change
- `/review` (oracle) flagged one issue on the initial revision: the
zh-CN page was `noindex` but its URL still appeared in the sitemap.
Fixed by extending the exclusion set across both `LOCALE_PREFIXES`; the
follow-up `/review` returned 0 findings.

## Screenshots

## Screenshots

![Desktop 1280x900 view of /booking-confirmation: centered hero 'You're
booked.', body 'Check your email for the calendar invite and meeting
link!', yellow-italic 'Resources while you wait' heading, and four
centered rows showing Learning Center/Workflow templates/Customer
stories/Docs with their comfy.org
URLs.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033519-7d23611a-06c4-4a0b-9a2d-1bd641cc4141.png)

![Mobile 390x844 view of /booking-confirmation: same layout stacks
cleanly with no horizontal overflow; resource rows wrap onto two
centered lines
each.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033915-819c8536-6bb6-4172-af7a-efd28e6df53e.png)

![Desktop 1280x900 view of /zh-CN/booking-confirmation: Chinese
translation with hero '预约成功。', '等待期间的资源' heading, and four resource rows
in Simplified
Chinese.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235034319-32b66ebb-50ae-47fb-ac64-be27b7e5a278.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-07-16 22:11:14 +00:00
Alexander Brown
1052658a02 refactor: isolate node locale serialization (#13643)
## Summary

Move node-definition locale serialization into a focused, testable
module while preserving generated locale behavior.

## Changes

- **What**: Extract locale key normalization and Vue I18n escaping from
the Playwright collector into `nodeDefLocaleSerializer.ts`.
- **What**: Keep input and output tooltips raw while escaping fields
compiled by Vue I18n.
- **What**: Replace broad utility tests with behavioral serializer
coverage for escaping, output shapes, ordering, and missing values.

## Review Focus

Check parity with the generator introduced in #13631, especially raw
tooltips, runtime widget labels, known data-type output elision, and
normalized keys.

Validation: `pnpm typecheck`, focused Vitest, oxlint, ESLint, and oxfmt.
2026-07-16 22:08:01 +00:00
steven-comfy
f524683f3c feat: add hidden /individual-submission page for sales-form redirects (#13697)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/individual-submission`, a hidden `noindex` page intended as the
post-submit redirect target for the HubSpot sales form when the visitor
selects the "individual" option. The page thanks the visitor, points
them at the self-serve plans on `/cloud/pricing`, and offers a
`mailto:gtm-team@comfy.org` fallback for enterprise inquiries.

## Design

Mirrors the `terms-of-service.astro` template — `BaseLayout` +
`HeroSection` — so typography and spacing feel native to the marketing
site. Plan names (**Standard**, **Creator**, **Pro**, **Teams**) are
highlighted with the yellow-italic accent used elsewhere on the site.

## Hidden-page mechanics

Follows the pattern already used for `/terms-of-service` and
`/payment/*`:

- `noindex` meta tag on the page
- Path added to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts`
- English-only, with a `/zh-CN/individual-submission` →
`/individual-submission` redirect (matches `termsOfService` handling)
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`

## Next step (not in this PR)

Someone with HubSpot access needs to configure the form (portal
`244637579`, form `94e05eab-1373-47f7-ab5e-d84f9e6aa262`) to redirect to
`https://comfy.org/individual-submission` when the "individual" option
is selected. Happy to add a JS-side fallback in a follow-up PR if
HubSpot's conditional redirects aren't a fit.

## Verification

- `pnpm typecheck` → 0 errors (10 pre-existing hints unrelated to this
change)
- `pnpm build` → 499 pages built; `/individual-submission/` and
`/zh-CN/individual-submission/` (redirect stub) emitted; sitemap
excludes both
- Playwright preview at 1280×900 and 390×844 — hero title, prose,
plan-name highlighting, CTA button, and mailto link all render as
expected; no console errors; `whitespace-nowrap` keeps the email address
on one line on mobile
- `/review` (oracle) on the previous revision: 0 findings; only change
since is the URL rename
- `/zh-CN/individual-submission` redirect verified in preview

## Screenshots

![Desktop view of /individual-submission: centered hero title 'Thanks
for reaching out.', body copy with Standard/Creator/Pro/Teams in yellow
italic, yellow 'See plans and pricing' CTA button, and a smaller mailto
fallback
line.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692177-e7fe7ed9-8844-4625-9823-1dd719173c73.png)

![Mobile 390px view of /individual-submission: same layout stacks
cleanly, gtm-team@comfy.org email stays on one line, no horizontal
overflow.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692523-e1af7deb-1379-4280-9bcc-0c602bb12aff.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <michael@concreo.io>
Co-authored-by: Michael B <michael@imick.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:30 +00:00
54 changed files with 2276 additions and 341 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": {}
}

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

@@ -10,11 +10,13 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
)
),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
])
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
const { resources } = defineProps<{
resources: { label: string; href: string; display: string }[]
}>()
</script>
<template>
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
<li v-for="resource in resources" :key="resource.href">
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
<span class="text-primary-warm-gray"> </span>
<a
:href="resource.href"
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
>
{{ resource.display }}
</a>
</li>
</ul>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import Button from '../ui/button/Button.vue'
const { href, label } = defineProps<{
href: string
label: string
}>()
</script>
<template>
<div class="mt-2 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ label }}
</Button>
</div>
</template>

View File

@@ -1,12 +1,25 @@
<script setup lang="ts">
const { title } = defineProps<{ title: string }>()
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { title, class: className } = defineProps<{
title: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<section
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
>
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
<h1
:class="
cn(
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
className
)
"
>
{{ title }}
</h1>
</section>

View File

@@ -0,0 +1,54 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { externalLinks, getRoutes } from '../config/routes'
const routes = getRoutes('en')
const resources = [
{
label: 'Learning Center',
href: routes.learning,
display: 'comfy.org/learning'
},
{
label: 'Workflow templates',
href: externalLinks.workflows,
display: 'comfy.org/workflows'
},
{
label: 'Customer stories',
href: routes.customers,
display: 'comfy.org/customers'
},
{
label: 'Docs',
href: externalLinks.docs,
display: 'docs.comfy.org'
}
]
---
<BaseLayout
title="You're booked - Comfy"
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
noindex
>
<HeroSection title="You're booked." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
>
<p>Check your email for the calendar invite and meeting link!</p>
<div class="flex flex-col gap-4">
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
Resources while you wait
</h2>
<ResourceList resources={resources} />
</div>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,60 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { getRoutes } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout
title="Thanks for reaching out - Comfy"
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
noindex
>
<HeroSection title="Thanks for reaching out." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
Based on what you shared, one of our self-serve plans is probably a
better fit.
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>,
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>, and
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
> for individual creators, plus our new
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> plan for multiple users under shared billing.
</p>
<PlansPricingCta
href={routes.cloudPricing}
label="See plans and pricing →"
/>
<p class="text-primary-warm-gray mt-8 text-sm">
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
Need help with something else? Email: <a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,54 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
import { externalLinks, getRoutes } from '../../config/routes'
const routes = getRoutes('zh-CN')
const resources = [
{
label: '学习中心',
href: routes.learning,
display: 'comfy.org/learning'
},
{
label: '工作流模板',
href: externalLinks.workflows,
display: 'comfy.org/workflows'
},
{
label: '客户案例',
href: routes.customers,
display: 'comfy.org/customers'
},
{
label: '文档',
href: externalLinks.docs,
display: 'docs.comfy.org'
}
]
---
<BaseLayout
title="预约成功 - Comfy"
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
noindex
>
<HeroSection title="预约成功。" class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
>
<p>请查收邮件中的日历邀请和会议链接!</p>
<div class="flex flex-col gap-4">
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
等待期间的资源
</h2>
<ResourceList resources={resources} />
</div>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,57 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
import { getRoutes } from '../../config/routes'
const routes = getRoutes('zh-CN')
---
<BaseLayout
title="感谢您的联系 - Comfy"
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
noindex
>
<HeroSection title="感谢您的联系。" class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>、
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
>,以及我们全新的
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> 套餐,可让多位用户共享账单。
</p>
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
<p class="text-primary-warm-gray mt-8 text-sm">
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
需要其他方面的帮助?邮箱:<a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -54,6 +54,11 @@
"source": "/press",
"destination": "/about",
"permanent": true
},
{
"source": "/login",
"destination": "https://cloud.comfy.org/login",
"permanent": false
}
]
}

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

@@ -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",
@@ -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:",

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
})
})
describe('escapeVueI18nMessageSyntax', () => {
it('escapes a literal @ that would break linked-message compilation', () => {
expect(
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
})
it('escapes @ in an email address', () => {
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
"support{'@'}comfy.org"
)
})
it('escapes interpolation braces', () => {
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
"size {'{'}w{'}'}x{'{'}h{'}'}"
)
})
it('escapes the plural pipe separator', () => {
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
"foreground {'|'} background"
)
})
it('escapes the modulo percent so it cannot re-form %{', () => {
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
"50{'%'}{'{'}done{'}'}"
)
})
it('escapes every occurrence in a single pass', () => {
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
"{'@'}a {'@'}b {'@'}c"
)
})
it('leaves strings without syntax characters unchanged', () => {
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
'no special chars here'
)
expect(escapeVueI18nMessageSyntax('')).toBe('')
})
})
})

View File

@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
}
/**
* Characters that vue-i18n's message compiler treats as syntax in message text,
* so plain text has to escape them to render verbatim through `t()`/`st()`:
*
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
* `Invalid linked format`.
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
* brace throws `Unterminated/Unbalanced closing brace`.
* - `|` separates plural branches, so `a | b` silently renders as one branch.
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
*
* The set is a build-inlined `const enum` (`TokenChars`) in
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
*
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
*/
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
/**
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
*
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
* escape output itself contains `{`/`}`, so it is not idempotent.
*/
export function escapeVueI18nMessageSyntax(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}
/**
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
* @param input The dynamic prompt to process

88
pnpm-lock.yaml generated
View File

@@ -240,6 +240,9 @@ catalogs:
eslint-plugin-vue:
specifier: ^10.9.1
version: 10.9.1
fallow:
specifier: ^2.102.0
version: 2.102.0
fast-check:
specifier: ^4.5.3
version: 4.5.3
@@ -763,6 +766,9 @@ importers:
eslint-plugin-vue:
specifier: 'catalog:'
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
fallow:
specifier: 'catalog:'
version: 2.102.0
fast-check:
specifier: 'catalog:'
version: 4.5.3
@@ -1908,6 +1914,46 @@ packages:
'@exodus/crypto':
optional: true
'@fallow-cli/darwin-arm64@2.102.0':
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
cpu: [arm64]
os: [darwin]
'@fallow-cli/darwin-x64@2.102.0':
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
cpu: [x64]
os: [darwin]
'@fallow-cli/linux-arm64-gnu@2.102.0':
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-arm64-musl@2.102.0':
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-x64-gnu@2.102.0':
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
cpu: [x64]
os: [linux]
'@fallow-cli/linux-x64-musl@2.102.0':
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
cpu: [x64]
os: [linux]
'@fallow-cli/win32-arm64-msvc@2.102.0':
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
cpu: [arm64]
os: [win32]
'@fallow-cli/win32-x64-msvc@2.102.0':
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
cpu: [x64]
os: [win32]
'@firebase/analytics-compat@0.2.18':
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
peerDependencies:
@@ -5682,6 +5728,11 @@ packages:
extendable-media-recorder@9.2.27:
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
fallow@2.102.0:
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
engines: {node: '>=16'}
hasBin: true
fast-check@4.5.3:
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
engines: {node: '>=12.17.0'}
@@ -10044,6 +10095,30 @@ snapshots:
'@exodus/bytes@1.7.0': {}
'@fallow-cli/darwin-arm64@2.102.0':
optional: true
'@fallow-cli/darwin-x64@2.102.0':
optional: true
'@fallow-cli/linux-arm64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-arm64-musl@2.102.0':
optional: true
'@fallow-cli/linux-x64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-x64-musl@2.102.0':
optional: true
'@fallow-cli/win32-arm64-msvc@2.102.0':
optional: true
'@fallow-cli/win32-x64-msvc@2.102.0':
optional: true
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
dependencies:
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
@@ -14095,6 +14170,19 @@ snapshots:
subscribable-things: 2.1.53
tslib: 2.8.1
fallow@2.102.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
'@fallow-cli/darwin-arm64': 2.102.0
'@fallow-cli/darwin-x64': 2.102.0
'@fallow-cli/linux-arm64-gnu': 2.102.0
'@fallow-cli/linux-arm64-musl': 2.102.0
'@fallow-cli/linux-x64-gnu': 2.102.0
'@fallow-cli/linux-x64-musl': 2.102.0
'@fallow-cli/win32-arm64-msvc': 2.102.0
'@fallow-cli/win32-x64-msvc': 2.102.0
fast-check@4.5.3:
dependencies:
pure-rand: 7.0.1

View File

@@ -89,6 +89,7 @@ catalog:
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6

View File

@@ -3,11 +3,9 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import {
escapeVueI18nMessageSyntax,
normalizeI18nKey
} from '@/utils/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import type { WidgetLabels } from './nodeDefLocaleSerializer'
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'
@@ -17,10 +15,6 @@ interface WidgetInfo {
label?: string
}
interface WidgetLabels {
[key: string]: Record<string, { name: string }>
}
test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route
await comfyPage.page.route('**/view**', async (route) => {
@@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
)
const allDataTypesLocale = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => {
const inputDataTypes = Object.values(nodeDef.inputs).map(
(inputSpec) => inputSpec.type
)
const outputDataTypes = nodeDef.outputs.map(
(outputSpec) => outputSpec.type
)
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
(type: string) => type.split(',')
)
return allDataTypes.map((dataType) => [
normalizeI18nKey(dataType),
escapeVueI18nMessageSyntax(dataType)
])
})
.sort((a, b) => a[0].localeCompare(b[0]))
)
async function extractWidgetLabels() {
const nodeLabels: WidgetLabels = {}
@@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
[nodeDef.name, nodeDef.display_name, inputNames]
)
// Format runtime widgets
const runtimeWidgets = Object.fromEntries(
Object.entries(widgetsMappings)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => [
normalizeI18nKey(key),
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
])
.map(([key, name]) => [key, { name }])
)
if (Object.keys(runtimeWidgets).length > 0) {
@@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
const nodeDefLabels = await extractWidgetLabels()
function extractInputs(nodeDef: ComfyNodeDefImpl) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap((input) => {
const name =
input.name === undefined
? undefined
: escapeVueI18nMessageSyntax(input.name)
const tooltip = input.tooltip
if (name === undefined && tooltip === undefined) {
return []
}
return [
[
normalizeI18nKey(input.name),
{
name,
tooltip
}
]
]
})
)
return Object.keys(inputs).length > 0 ? inputs : undefined
}
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap((output, i) => {
// Ignore data types if they are already translated in allDataTypesLocale.
const name =
output.name === undefined || output.name in allDataTypesLocale
? undefined
: escapeVueI18nMessageSyntax(output.name)
const tooltip = output.tooltip
if (name === undefined && tooltip === undefined) {
return []
}
return [
[
i.toString(),
{
name,
tooltip
}
]
]
})
)
return Object.keys(outputs).length > 0 ? outputs : undefined
}
const allNodeDefsLocale = Object.fromEntries(
nodeDefs
.sort((a, b) => a.name.localeCompare(b.name))
.map((nodeDef) => {
const inputs = {
...extractInputs(nodeDef),
...(nodeDefLabels[nodeDef.name] ?? {})
}
return [
normalizeI18nKey(nodeDef.name),
{
display_name: escapeVueI18nMessageSyntax(
nodeDef.display_name ?? nodeDef.name
),
description: nodeDef.description
? escapeVueI18nMessageSyntax(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: extractOutputs(nodeDef)
}
]
})
)
const allNodeCategoriesLocale = Object.fromEntries(
nodeDefs.flatMap((nodeDef) =>
nodeDef.category
.split('/')
.map((category) => [
normalizeI18nKey(category),
escapeVueI18nMessageSyntax(category)
])
)
)
const { dataTypes, nodeCategories, nodeDefinitions } =
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
fs.writeFileSync(
@@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
JSON.stringify(
{
...locale,
dataTypes: allDataTypesLocale,
nodeCategories: allNodeCategoriesLocale
dataTypes,
nodeCategories
},
null,
2
)
)
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
})

View File

@@ -0,0 +1,130 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
function render(message: string): string {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { value: message } }
})
return i18n.global.t('value')
}
describe('serializeNodeDefLocales', () => {
it('escapes compiled fields and preserves raw tooltips', () => {
const syntax = '@ $ {value} | 50%{done}'
const inputName = `Input ${syntax}`
const outputName = `Output ${syntax}`
const dataType = `TYPE ${syntax}`
const category = `Category ${syntax}`
const nodeDef = {
name: 'Test.Node',
display_name: `Display ${syntax}`,
description: `Description ${syntax}`,
category,
inputs: {
input: {
name: inputName,
type: dataType,
tooltip: `Input tooltip ${syntax}`
}
},
outputs: [
{
name: outputName,
type: 'OTHER',
tooltip: `Output tooltip ${syntax}`
}
]
}
const { dataTypes, nodeCategories, nodeDefinitions } =
serializeNodeDefLocales([nodeDef], {
'Test.Node': {
'Runtime.Widget': { name: `Widget ${syntax}` }
}
})
const serializedNode = nodeDefinitions.Test_Node
const serializedInput =
serializedNode.inputs['Input @ $ {value} | 50%{done}']
const serializedOutput = serializedNode.outputs['0']
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
expect(render(serializedNode.description)).toBe(nodeDef.description)
expect(render(serializedInput.name)).toBe(inputName)
expect(render(serializedOutput.name)).toBe(outputName)
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
`Widget ${syntax}`
)
expect(render(dataTypes[dataType])).toBe(dataType)
expect(render(nodeCategories[category])).toBe(category)
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
})
it('preserves locale shapes and ordering', () => {
const { dataTypes, nodeCategories, nodeDefinitions } =
serializeNodeDefLocales(
[
{
name: 'Z.Node',
description: '',
category: 'group/sub.group',
inputs: {
omitted: { type: 'Z.TYPE' },
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
},
outputs: [
{ name: 'A_TYPE', type: 'A_TYPE' },
{ name: 'Custom.Output', type: 'Z.TYPE' },
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
]
},
{
name: 'A.Node',
category: 'group',
inputs: {},
outputs: []
}
],
{
'Z.Node': {
'Runtime.Widget': { name: 'Runtime.Label' }
}
}
)
expect(dataTypes).toEqual({
A_TYPE: 'A_TYPE',
Z_TYPE: 'Z.TYPE'
})
expect(nodeCategories).toEqual({
group: 'group',
sub_group: 'sub.group'
})
expect(nodeDefinitions).toEqual({
A_Node: {
display_name: 'A.Node',
description: undefined,
inputs: undefined,
outputs: undefined
},
Z_Node: {
display_name: 'Z.Node',
description: undefined,
inputs: {
'': { name: undefined, tooltip: 'raw @ tooltip' },
Runtime_Widget: { name: 'Runtime.Label' }
},
outputs: {
1: { name: 'Custom.Output', tooltip: undefined },
2: { name: undefined, tooltip: 'raw output @ tooltip' }
}
}
})
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
})
})

View File

@@ -0,0 +1,127 @@
import { normalizeI18nKey } from '@/utils/formatUtil'
interface LocalizableInput {
type: string
name?: string
tooltip?: string
}
interface LocalizableOutput {
type: string
name?: string
tooltip?: string
}
interface LocalizableNodeDef {
category: string
inputs: Record<string, LocalizableInput>
name: string
outputs: LocalizableOutput[]
description?: string
display_name?: string
}
export type WidgetLabels = Record<
string,
Record<string, { name: string | undefined }>
>
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
function escapeMessage(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}
export function serializeNodeDefLocales(
nodeDefs: readonly LocalizableNodeDef[],
widgetLabels: WidgetLabels = {}
) {
const dataTypes = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => [
...Object.values(nodeDef.inputs).map(({ type }) => type),
...nodeDef.outputs.map(({ type }) => type)
])
.flatMap((type) => type.split(','))
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
.sort((a, b) => a[0].localeCompare(b[0]))
)
function serializeInputs(nodeDef: LocalizableNodeDef) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
if (name === undefined && tooltip === undefined) return []
return [
[
normalizeI18nKey(name ?? ''),
{
name: name === undefined ? undefined : escapeMessage(name),
tooltip
}
]
]
})
)
return Object.keys(inputs).length > 0 ? inputs : undefined
}
function serializeOutputs(nodeDef: LocalizableNodeDef) {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
const serializedName =
name === undefined || name in dataTypes
? undefined
: escapeMessage(name)
if (serializedName === undefined && tooltip === undefined) return []
return [[index.toString(), { name: serializedName, tooltip }]]
})
)
return Object.keys(outputs).length > 0 ? outputs : undefined
}
function serializeWidgetLabels(nodeName: string) {
return Object.fromEntries(
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
normalizeI18nKey(name),
{
name: label.name === undefined ? undefined : escapeMessage(label.name)
}
])
)
}
const nodeDefinitions = Object.fromEntries(
[...nodeDefs]
.sort((a, b) => a.name.localeCompare(b.name))
.map((nodeDef) => {
const inputs = {
...serializeInputs(nodeDef),
...serializeWidgetLabels(nodeDef.name)
}
return [
normalizeI18nKey(nodeDef.name),
{
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
description: nodeDef.description
? escapeMessage(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: serializeOutputs(nodeDef)
}
]
})
)
const nodeCategories = Object.fromEntries(
nodeDefs.flatMap(({ category }) =>
category
.split('/')
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
)
)
return { dataTypes, nodeCategories, nodeDefinitions }
}

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

@@ -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

@@ -1,35 +0,0 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
/**
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
* sync). `collect-i18n-node-defs.ts` escapes such values with
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
* output actually compiles and renders the original literal text.
*/
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
const compile = (message: string) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { value: message } }
})
return i18n.global.t('value')
}
it.for([
'clips (tagged @Audio1-3 in the prompt)',
'support@comfy.org',
'resolution {width}x{height}',
'foreground | background',
'50%{done}',
'all of @ { } | % together',
'no special chars here'
])('renders %s as the original literal text', (raw) => {
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
})
})

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

@@ -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

@@ -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
}))
}
}