Compare commits

...

11 Commits

Author SHA1 Message Date
jaeone94
5a01c5b3b4 Remove redundant Enter Subgraph action from Errors tab (#13136)
## Summary

Remove the redundant **Enter Subgraph** action from Errors tab node
cards. This button should not be part of the updated Errors tab design;
it remained from the previous implementation and its removal was missed
when the new interaction model was introduced.

## Changes

- **What**: Removed the Errors tab `Enter Subgraph` button from
`ErrorNodeCard`, along with the `enterSubgraph` event plumbing in
`TabErrors`.
- Removed the now-unused `useFocusNode().enterSubgraph()` helper path,
since the Errors tab no longer has a separate subgraph-only action.
- Removed the `ErrorCardData.isSubgraphNode` flag and its population in
`useErrorGroups`, because it only existed to decide whether to show this
button.
- Removed the Storybook story and unit-test expectations that were
specifically tied to the removed button/flag.
- Removed the now-unused English `rightSidePanel.enterSubgraph` i18n
entry. Non-English locale files are intentionally left untouched per the
repo's localization update policy.

## Why

The Errors tab already has a **Locate node on canvas** action. For
errors inside subgraphs, that action navigates into the relevant
subgraph and centers the target node on the canvas. The removed **Enter
Subgraph** action was therefore a weaker duplicate: it entered the
subgraph and fit the view, but did not provide the same direct
target-node positioning.

Keeping both actions made the card UI more crowded and exposed two very
similar navigation paths with overlapping intent. The updated design
should only keep the more useful locate action, so this PR removes the
stale duplicate surface rather than adding another hidden/negative
assertion around it.

## Review Focus

Please verify that this only removes the Errors tab-specific action. The
normal node footer/canvas subgraph navigation behavior remains
untouched.

Validation run locally:

- `pnpm exec vitest run
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`
- `pnpm knip`


## Screenshot 

Before
<img width="335" height="595" alt="스크린샷 2026-06-25 오후 5 33 37"
src="https://github.com/user-attachments/assets/545f80e9-68bb-45ef-a4da-0a41012269f6"
/>

After
<img width="344" height="591" alt="스크린샷 2026-06-25 오후 5 34 24"
src="https://github.com/user-attachments/assets/7c1f1bf6-c5fd-4a43-9b5c-1392246070a8"
/>
2026-06-25 11:25:49 +00:00
Dante
e3049e7c31 feat(billing): single billing path — collapse personal/team dispatch to flag-only (B1 / FE-966) (#12953)
## What this does

Collapses the personal-vs-team billing dispatch so it keys ONLY on the
build/flag (`teamWorkspacesEnabled ? 'workspace' : 'legacy'`). Personal
now flows through `useWorkspaceBilling` (`/api/billing/*`), same as team
("personal plan = single-seat workspace"). This converges status /
balance / subscribe / preview / cancel / portal in one change.

Dispatch sites collapsed:
- `useBillingContext.ts` — `type` computed: dropped the
`store.isInPersonalWorkspace` branch → flag-only.
- `useBillingContext.ts` — D3 subscription→store mirror watch: dropped
the `isInPersonalWorkspace` early-return guard so personal also mirrors
into the workspace store.
- `useSubscriptionDialog.ts` — `useWorkspaceVariant` compound predicate
→ flag-only (personal + flag-on now uses the workspace required-dialog
variant).
- `SubscriptionPanel.vue` — already flag-only
(`v-if="teamWorkspacesEnabled"`); no change needed.

## Kept (Risk #6)

- The ~11 raw `workspace.type === 'personal'` checks in
`teamWorkspaceStore.ts` — workspace-TYPE membership logic
(can-delete/leave, fetch-members, switcher), NOT billing dispatch.
Untouched.
- `useLegacyBilling` / `useSubscription` / authStore billing methods
kept intact for the flag-OFF (OSS/Desktop) path.

## Flag-off unchanged

Flag-OFF (OSS/Desktop) still selects `legacy` (`/customers/*`). Verified
by unit test.

## Tests

- `useBillingContext`: flag-ON → personal selects `workspace`; flag-OFF
→ `legacy`; D3 mirror now fires for personal under flag-on.
- `useSubscriptionDialog`: flag-ON → workspace required-dialog variant
for personal; flag-OFF → legacy personal variant.

## Follow-up (deferred, not in this PR)

Post-flip cutover deletion of `useLegacyBilling`-only components:
`PricingTable.vue`, `SubscriptionPanelContentLegacy.vue`,
`TopUpCreditsDialogContentLegacy.vue`, `CurrentUserPopoverLegacy.vue`,
`subscriptionCheckoutUtil.ts`, `useSubscriptionCancellationWatcher.ts`.

- Fixes part of FE-903 (B1)
2026-06-25 06:59:00 +00:00
Dante
87e84e7280 feat(billing): inline Invite-your-team block on team-upgrade success (FE-965 / DES-394) (#12954)
Renders the inline **"Invite your team"** block in the team variant of
FE-934's "You're all set" success card
(`SubscriptionSuccessWorkspace.vue`), so a buyer can invite teammates
right after a team-plan upgrade (DES-394, Figma 3084-18651).

- New shared `InviteMembersForm.vue` (chips / `TagsInput` multi-email
form); seats capped via `useBillingContext.getMaxSeats(tierKey)`; submit
via `workspaceApi.createInvite`.
- Team upgrades only — personal / single-seat plans show the plain
success card; gated on `teamWorkspacesEnabled` + a team plan.
- `workspace_invite_sent` telemetry distinguishing a post-upgrade invite
from a Settings invite; success-card i18n + Storybook story.

**Stacked on FE-934 #12975** (`jaewon/fe-934-team-subscribe-wire`, the
success-card host). The PR base is that branch, so this diff is FE-965's
delta only. Re-using the same form in the Settings invite dialog is out
of scope here (belongs with FE-768 / a follow-up).

## Screenshots

| Team upgrade — invite block | Personal / non-team — no invite block |
|---|---|
| <img
src="https://github.com/user-attachments/assets/be5450fe-2b83-46bd-afbc-00e6d33590b7"
width="420" /> | <img
src="https://github.com/user-attachments/assets/a91909c7-7629-42ef-80b6-45fdb070a0e8"
width="420" /> |

Storybook: `Components/SubscriptionCheckoutSteps` →
`TeamSuccessWithInvite` (with block) / `SuccessAllSet` (without).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 05:54:04 +00:00
Dante
67009dcda2 feat(workspace): promote/demote members via Change role menu (FE-770) (#12782)
Promote / demote workspace members ↔ owners in Settings ▸ Members, per
[DES-222 / Figma
2993-15512](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=2993-15512)
and the [permissions section
3343-22966](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3343-22966).

- Fixes
[FE-770](https://linear.app/comfyorg/issue/FE-770/promote-demote-workspace-members-owners-settings-members)
- Stacked on #12759 (`jaewon/fe-768-members-invite-ui`)

## Changes

- Per-member row (…) menu → **Change role** submenu (Owner / Member,
current role check-marked) + existing **Remove member**, replacing the
shared PrimeVue `Menu` with the Reka `DropdownMenu`/`DropdownItem`
(submenu opens right of parent, flips on collision; scalable for future
roles).
- **Make [name] an owner?** / **Demote [name] to member?** confirm
dialogs (single `ChangeMemberRoleDialogContent`, copy 1:1 from Figma).
- `workspaceApi.updateMemberRole` → `PATCH
/api/workspace/members/:userId {role}` +
`teamWorkspaceStore.changeMemberRole` (local role map update; Role
column re-sorts).
- **Original-owner guards** (Figma annotations): creator pinned to the
top of the list, no row actions for anyone on that row; own row also has
no actions. Creator inferred as earliest `joined_at` until BE exposes an
explicit flag (tracked as the FE-770 BE blocker — same applies to the
endpoint itself, which does not exist yet; UI is wired to the proposed
contract).
- `DropdownMenu` raised to `z-3000` so the row menu sits above the
Settings modal (the Reka popper wrapper copies the content's computed
z-index; static `z-1700` lost to dialogs in the `@primeuix` modal
sequence). Also drops the always-rendered icon slot in `DropdownItem` so
icon-less items (Change role / Remove member) align flush-left.

## User stories verified

Viewer = an **owner** (promoted, not the workspace creator), so the
creator guard and the self guard are exercised separately.

| # | Click → action → expected |
| --- | --- |
| US1 | Member row (…) → menu shows **Change role ›** + **Remove
member** |
| US2 | Hover **Change role** → Owner / Member submenu, **current role
check-marked** |
| US3 | Click the current role (✓) → no dialog, no PATCH (no-op) |
| US4 | Member row → **Owner** → "Make {name} an owner?" + "They'll have
the same access as you — managing members, billing, and workspace
settings." + Cancel / **Make owner** |
| US5 | **Cancel** (or ✕) → dialog closes, role unchanged, no PATCH |
| US6 | **Make owner** → `PATCH /api/workspace/members/:id
{role:'owner'}` → Role column → Owner, row **re-sorts under the
creator**, "Role updated" toast, the promoted row keeps its (…) menu |
| US7 | Promoted owner row → **Member** → "Demote {name} to member?" +
"They'll lose admin access." → **Demote to member** → Role column back
to Member |
| US8 | **Creator row (earliest joined) has no (…) button** — even for
another owner |
| US9 | **Own (You) row has no (…) button** — even when not the creator
|
| US10 | PATCH 500 → "Failed to update role" toast, **dialog stays
open**, role unchanged |
| US11 | Viewer with `member` role → no row actions anywhere |
| US12 | **Remove member** → existing FE-768 "Remove this member?"
dialog |

## Tests

Each user story is covered by automated tests and confirmed by a manual
CDP pass driving the real cloud app (mocked auth + boot +
workspace/billing API).

| Story | Unit / Component | E2E (Playwright) | CDP (live app) |
| --- | :---: | :---: | :---: |
| US1 row menu shows Change role + Remove member |  |  |  |
| US2 submenu checkmark follows current role |  |  |  |
| US3 picking the current role is a no-op |  |  |  |
| US4 promote dialog copy (Make owner) |  |  |  |
| US5 Cancel leaves role unchanged, no PATCH |  |  |  |
| US6 Make owner → PATCH, re-sort under creator, toast, stays demotable
|  |  |  |
| US7 demote dialog (Demote to member) → role reverts |  |  |  |
| US8 creator row has no (…) menu |  |  |  |
| US9 own (You) row has no (…) menu |  |  |  |
| US10 PATCH 500 → error toast, dialog stays open |  |  |  |
| US11 member-role viewer sees no row actions |  | — | — |
| US12 Remove member → FE-768 remove dialog |  |  |  |

| Layer | File | What it covers | Result |
| --- | --- | --- | --- |
| E2E (`@cloud`) |
`browser_tests/tests/dialogs/memberRoleChange.spec.ts` | 3 tests — guard
rows (US1/US8/US9/US12), promote→re-sort→demote round trip (US3–US7),
failed PATCH (US10). FE-964 boot pattern: `CloudAuthHelper` +
remote-config flag mock + stateful route mocks capturing PATCH args.
Reka submenu driven via `ArrowRight` (synthetic hover doesn't open it).
| 3 / 3 green |
| Component | `ChangeMemberRoleDialogContent.test.ts` | promote/demote
copy, confirm → store + success toast + close, error keeps dialog open,
cancel | green |
| Component | `MembersPanelContent.test.ts` | creator/self rows hide the
menu (US8/US9), member-viewer gating (US11) | green |
| Composable | `useMembersPanel.test.ts` | menu factory
labels/checkmarks/commands, same-role no-op, creator pin in
`sortMembers`, `isOriginalOwner` | green |
| Store | `teamWorkspaceStore.test.ts` | `changeMemberRole`
success/failure, `originalOwnerId` inference | green |
| CDP live | full cloud app on `local.comfy.org` (mocked auth + boot) |
promote→re-sort→demote round trip with PATCH applied to mock state,
guard rows, submenu checkmark, dialog copy, menu/dialog z-index above
Settings, forced PATCH 500 → error toast | verified |

⚠️ Merge-gated on the BE role-change endpoint (no `PATCH
/workspace/members/:userId` in cloud OpenAPI as of 2026-06-10; see
FE-770 BE-blocker comment).

## Screenshots (local dev, workspace/billing API stubbed; vs Figma
2993-15512)

| Members (before) | Change role submenu |
| --- | --- |
| <img alt="members"
src="https://github.com/user-attachments/assets/686fec86-fcb5-4942-a745-50f367022ab0"
/> | <img alt="submenu"
src="https://github.com/user-attachments/assets/d6adeea8-7001-4c8d-91b7-f5bfc47a50d6"
/> |

| Promote dialog | After promote (Jane → Owner, still demotable) |
Demote dialog |
| --- | --- | --- |
| <img alt="promote"
src="https://github.com/user-attachments/assets/af638cde-2fd6-4c37-b203-78801eeb2785"
/> | <img alt="after"
src="https://github.com/user-attachments/assets/f47dc7af-6b1b-422c-8a9a-5ec889b9af11"
/> | <img alt="demote"
src="https://github.com/user-attachments/assets/9a861d04-a23b-4cd4-bc54-1ed3a66c6429"
/> |
2026-06-25 05:04:48 +00:00
Dante
026b2c4795 feat(billing): unify credits into a facade-driven CreditsTile (FE-964) (#12734)
## Summary

### AS IS 
<img width="1340" height="798" alt="Screenshot 2026-06-10 at 12 22 36
AM"
src="https://github.com/user-attachments/assets/61636fa3-e80c-427b-855b-499e1eca67da"
/>

### TO BE

<img width="1301" height="793" alt="Screenshot 2026-06-10 at 12 22 39
AM"
src="https://github.com/user-attachments/assets/62d9f5a6-da92-45df-94e7-cd3c244249f9"
/>

### Empty states ([added to DES-247 on
2026-06-11](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3349-29750))

| 0 monthly credits | 0 credits |
| --- | --- |
| <img alt="credits-empty-monthly"
src="https://github.com/user-attachments/assets/b3c55d3b-79b0-47b1-9795-c8bf69d5efe2"
/> | <img alt="credits-empty-all"
src="https://github.com/user-attachments/assets/919081d6-64e1-483b-9c04-6b085243ebc1"
/> |

Consolidate the divergent Settings credits surfaces into one
facade-driven **CreditsTile**, implementing the DES-247 redesign so
personal and team modes always render the same balance from
`useBillingContext`.

## Changes

- **What**:
- New `CreditsTile.vue` — total + `remaining`, a stacked
monthly/additional progress bar, colored breakdown rows (`Monthly
(refills …)` / `Additional`), refresh, and a permission-gated *Add
credits* / *Upgrade to add credits* action. Owns the post-checkout
(`focus` / `pending_topup`) balance refresh.
- Extracted the duplicated inline credits card out of
`SubscriptionPanelContentWorkspace.vue` **and**
`SubscriptionPanelContentLegacy.vue` onto the shared tile.
- Replaced `LegacyCreditsPanel.vue` (read `authStore.balance` directly)
with `CreditsPanel.vue` routed through the tile; repointed
`useSettingUI` and deleted the legacy panel.
- `creditsProgress.ts` pure helper for the bar math + numeric credit
getters on `useSubscriptionCredits`.
  - i18n keys for the unified tile labels.
- DES-247 responsive variants via CSS container queries: below ~350px
tile width the `{used} used` label, `remaining` suffix, and breakdown
subtitle drop and the additional-credits value stacks under its label;
below ~230px the monthly summary compacts (`105K left of 200K`).
Additional-credits tooltip copy aligned with the updated design (per
design feedback).
- Empty states (added to DES-247 via Slack on 2026-06-11, low priority):
once the monthly allowance is depleted, an info notice renders under the
total (`Monthly credits are used up. Refills {date}` / `You're now
spending additional credits.`), the monthly bar section dims to 30%
opacity, and an `IN USE` pill marks *Additional credits*; once
everything is depleted the notice switches to `You're out of credits.
Credits refill {date}` and *Add credits* swaps to the `inverted`
(filled-white) Button variant. Gated on a loaded balance so the notice
never flashes while fetching.
- **Dependencies**: Stacked on **#12622 (FE-904 / B2)** for the facade
`tier` / `renewalDate` fields — base this PR against that branch;
retarget to `main` once FE-904 merges.

## Review Focus

- The tile reads everything from the facade (`balance.*Micros` as cents
→ credits, `subscription.tier`/`renewalDate`), so legacy and workspace
modes share one source.
- Monthly allowance still comes from `getTierCredits` (hardcoded tier
nominal). With real data the monthly *remaining* can exceed the nominal
(rolled-over credits), so the bar clamps to a full segment — same
semantics as the prior `{monthly} / {planTotal}` display; the canonical
allowance is a BE-1047 follow-up.
- `LegacyCreditsPanel` deletion: `CreditsPanel.vue` retains the
usage-history table + help links and reads the facade.

## Testing

- Unit/component (36 green): `CreditsTile.test.ts` (render, zero-state,
free-tier, permission gating, add-credits, mount+manual refresh, plus
the empty states: depletion notice copy, `IN USE` badge, `inverted`
button when fully out, no-flash-while-loading guard),
`creditsProgress.test.ts` (clamping/stacking math),
`useSubscriptionCredits.test.ts` (`*_micros`-as-cents), and
`SubscriptionPanel.test.ts` updated for the extracted tile.
- E2E (`@cloud`): `browser_tests/tests/dialogs/creditsTile.spec.ts`
boots the cloud app against mocked Firebase auth + stubbed boot
endpoints (no backend) and asserts the tile's total / progress bar /
monthly+additional breakdown / add-credits in Settings ▸ Workspace ▸
Plan & Credits, then resizes to a narrow viewport and asserts the
responsive variant (labels hidden, compacted `11K left of 21K`). A
second test boots with a drained monthly balance (0-monthly notice + `IN
USE` badge), then re-mocks a fully drained balance and refreshes the
tile in place to assert the out-of-credits state. Both pass locally
against a cloud dev server; runs in the `cloud` CI project. Drives a raw
page because the shared `comfyPage` fixture expects the OSS devtools
backend.
- Screenshot-verified the tile at the three DES-247 reference widths
(448 / 235 / 204px) against the Figma Responsiveness section — 1:1.
- Verified live in the running app (Settings ▸ Workspace ▸ Plan &
Credits) against the authenticated backend — renders 1:1 with DES-247.
The empty-state screenshots above were captured the same way
(authenticated app, real Pro subscription, balance endpoint stubbed to
the depleted values via CDP).
- `pnpm typecheck` / `typecheck:browser` / `lint` / `knip` green.

Implements FE-964 (DES-247).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:44:59 +00:00
Dante
d60260ac3c feat(billing): team-plan subscribe + checkout/confirm screen redesign (FE-934) (#12975)
## Summary

Two related streams of FE-934 checkout/billing work on this branch:



https://github.com/user-attachments/assets/af629def-543e-4bcd-894d-b35aa032fe0a



1. **Team-plan subscribe** wired to the BE-1254 credit-stop contract
(replaces the "coming soon" toast stub).
2. **Checkout / confirm screen redesign** aligned to the updated DES
mockup — yearly pricing display, dialog navigation, and the plan-change
confirm.

<img width="1078" height="427" alt="team subscribe"
src="https://github.com/user-attachments/assets/2b5f7192-3c91-4e2d-b495-832ca5a26657"
/>

## Changes

### Team-plan subscribe (credit-stop contract)

- Team subscribe sends `{ plan_slug: team_per_credit_monthly |
team_per_credit_annual, team_credit_stop_id, billing_cycle }` to `POST
/api/billing/subscribe` and handles the response like the personal path
(`subscribed` → success, `needs_payment_method` → payment URL,
`pending_payment` → poll). Slider stops come from `GET
/api/billing/plans → team_credit_stops` via `mapApiTeamCreditStops`,
falling back to the hardcoded DES-197 stops so OSS / pre-deploy still
render. `preview-subscribe` is unchanged — the team confirm step is
display-only.
- **Internal API change**: `workspaceApi.subscribe(planSlug, returnUrl?,
cancelUrl?)` → `subscribe(planSlug, options?: SubscribeOptions)`; the
billing facade (`useBillingContext` / `useWorkspaceBilling` /
`useLegacyBilling`) and callers were updated to match.

### Checkout / confirm screen redesign (DES mockup)

- **Yearly confirm**: headline is now the ÷12 monthly-equivalent with a
`{total} Billed yearly` (yearly) / `Billed monthly` (monthly) subtitle;
credits show `Each year credits refill to` (×12) for yearly; `Starting
today` → `Starts today`. The team confirm now receives the active
billing cycle (the subtitle was missing because it wasn't passed), and
the redundant header credits/month line was dropped.
- **Navigation**: the pricing table stays mounted (`v-show`, not `v-if`)
so the plan / billing-cycle / credit-stop selection survives a round
trip to the confirm step and back; **Backspace** mirrors the back arrow
(ignored while an input/textarea/contenteditable is focused).
- **Plan-change confirm**: rewritten from the 2-column current→new
comparison to the **single-plan layout**, branching on
`previewData.is_immediate` — immediate upgrades show prorated line items
(`new_plan.price_cents − cost_today_cents` = credit) + upfront (yearly)
/ monthly credit refill + a prorated total ("Confirm upgrade");
scheduled downgrades show `Starts {date}`, $0 due today, and an "After
that" block ("Confirm change").
- **Storybook**: `SubscriptionCheckoutSteps` stories for each new-sub /
upgrade / downgrade variation (props-driven, no API).

## Review Focus

- **Merge gate (team subscribe)** —  **resolved**: BE-1254 is now
merged in cloud `main`. The live `GET /api/billing/plans` returns
`team_credit_stops` (`TeamCreditStop` struct + validation in
`common/billing/catalog/catalog.go`, served for every workspace,
asserted by the `billing_credit_stops_contract` smoke test). The gate is
lifted.
- **Fallback safety**: with the contract live, the hardcoded DES-197
stops are now purely the OSS / pre-deploy fallback — they render only
when the API doesn't supply `team_credit_stops`. In that window a real
subscribe is still impossible (no stop `id`) and surfaces a
`teamPlan.unavailable` toast. Open question retained: hide/disable the
team CTA in that window instead of toasting?
- **Plan-change scope**: the single-plan confirm redesign covers
**personal** changes (the real `previewSubscribe` path). **Team** plan
changes route through the display-only team confirm and aren't wired to
`previewSubscribe` (BE left `PreviewSubscribeRequest` as `plan_slug`
only) — team-change proration is a follow-up.
- **Dead locale keys**: the old 2-column transition keys
(`everyMonthStarting`, `youllBeCharged`, `proratedRefund`,
`proratedCharge`, `creditsRefillTo`, `switchToPlan`, `starting`, `ends`,
`confirmPlanChange`) are now unused — can be removed in a follow-up
cleanup.
- **Out of scope**: the success "Your change is scheduled" variant from
the mockup.
- Based on `fe-934-unified-pricing-table`; base + `main` merged in to
resolve conflicts (PR is now mergeable).

## Verification

- Confirm / transition component tests green
(`SubscriptionAddPaymentPreviewWorkspace`,
`SubscriptionTransitionPreviewWorkspace`); oxlint / oxfmt clean locally;
full typecheck runs in CI.
- Each variation viewable in Storybook →
`Components/SubscriptionCheckoutSteps`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:27:44 +00:00
Dante
0c89f5a3a7 feat: route cloud auth through the single Cloud JWT under unified_cloud_auth (FE-950) - step 3 (#12708)
## Summary

Behind `unified_cloud_auth` (default OFF), flip the two token accessors
so every cloud request rides the single Cloud JWT minted in PR2. This is
the consumer-flip phase of FE-950: PR2 built the dormant `unifiedToken`
slot; this PR makes consumers read it — and surfaces the
permanent-auth-failure path that the flip turns from graceful
degradation into a hard stop.

Stacked on #12704 (PR2), now merged; base is `main`.

## Changes

- **What**:
- `getAuthHeader()` — flag ON returns `{ Authorization: Bearer
<unifiedToken> }` (or `null` if unminted), with **no** Firebase/API-key
fallback. Flag OFF keeps the exact workspace → Firebase → API-key
cascade.
- `getAuthToken()` — flag ON returns the unified Cloud JWT (or
`undefined`); flag OFF keeps workspace → Firebase.
- Both accessors are the single seam every cloud consumer already routes
through, so the flip propagates automatically with **no edits** to
`fetchApi` (`scripts/api.ts`), `/customers/*` (authStore),
`workspaceApi`, the WebSocket (`api.ts:568`), or backend-node auth
(`app.ts:1593`).
- **Surface permanent auth failures** (answers @pythongosssss's review
on PR2). Under the flag there is no Firebase fallback, so a silent
`clearUnifiedContext()` wipe would strand every cloud request until
manual re-login — unlike the legacy path, which degrades to the Firebase
token. `refreshUnified()` and `mintAtLogin()` now emit a user-facing
error toast (keyed by error code off the existing `workspaceAuth.errors`
i18n) on the permanent codes (`ACCESS_DENIED` / `WORKSPACE_NOT_FOUND` /
`INVALID_FIREBASE_TOKEN` / `NOT_AUTHENTICATED`). `mintAtLogin()` now
resolves `false` on a permanent failure instead of rejecting an
unhandled `void`ed promise. Transient failures stay silent (proactive
refresh still retries). Also trims the verbose unified-lifecycle
comments flagged in review.
- **Breaking**: None. Flag OFF is byte-for-byte the current cascade.

## Review Focus

- **Single token, no fallback under the flag.** Tests assert
`getAuthHeader`/`getAuthToken` return only the unified token and never
call `getIdToken` or the API-key store; they return `null`/`undefined`
(not a fallback) when the token is unminted.
- **Surfacing, not recovery.** This PR makes the terminal state
*visible* (toast); the existing router auth-guard still redirects to
login on the next navigation. Active recovery (automatic re-mint on 401)
stays in the deferred safety-net PR so the toast is never a dead-end
"please re-login" with the fix one PR away.
- **Flag-OFF parity.** The full existing cascade suite runs with
`unifiedCloudAuthEnabled = false` (the `beforeEach` default) and stays
green.

## Deferred (intentional)

- **`acceptInvite` is left unchanged — still Firebase-authed.** It is
the one cloud call that intentionally keeps the raw Firebase token,
because the invite is accepted *before* the user is a member of the
target workspace. Promoting it to the unified Cloud JWT first needs a
quick check that `POST /invites/:token/accept` accepts a personal-scoped
Cloud JWT for a not-yet-member; deferred until that is verified.
`getFirebaseAuthHeader` / `getFirebaseAuthHeaderOrThrow` stay defined
(their removal belongs to the later cleanup ticket, FE-951). No
`workspaceApi.ts` change in this PR.
- **The reactive 401 re-mint + retry safety net is a follow-up.** A
clean place to intercept a `401` and re-mint once does not exist yet:
cloud requests use raw `fetch` (`/customers/*`, `/auth/token`) plus
several independent axios clients (`workspaceApi`,
`customerEventsService`, registry, manager), with no shared response
interceptor. PR2's `remintUnifiedOnce()` primitive is ready, and the
proactive buffer-based refresh (`refreshUnified`) already covers the
common token-*expiry* case, so this cross-cutting safety net (plus
deciding whether the surfacing toast escalates to a guided re-login CTA
once remint exists) lands in its own focused PR before any production
rollout. Note this is orthogonal to the surfacing above: proactive
refresh prevents expiry; it cannot prevent *revocation*, which is
exactly what triggers the now-surfaced permanent-error path.

## Tests

- Extended `authTokenPriority.test.ts`: flag-ON `getAuthHeader` returns
only the unified JWT (Firebase + API-key + workspace untouched) and
`null` when unminted; flag-ON `getAuthToken` returns the unified JWT
(not Firebase) and `undefined` when unminted. Existing cascade tests
prove flag-OFF parity.
- Added to `useWorkspaceAuth.test.ts` (red-green + regression lock): a
permanent refresh error toasts the **correct i18n key for each of the
four permanent codes** (`it.for` over 403/404/401 + a
lost-Firebase-token `NOT_AUTHENTICATED` case) and clears the slot; a
permanent login-mint error toasts and resolves `false`. Negative guards
prove the surfacing is **error-only and flag-scoped**: a transient (5xx)
refresh does **not** toast and keeps the slot, a **successful** re-mint
does not toast, and the unified lifecycle **never toasts when the flag
is OFF** (even against a rejecting backend).

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| `test: cover permanent unified-auth error surfacing` | 🔴
Red
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27455949404))
| Proves the tests catch the silent-failure gap |
| `fix: surface permanent unified-auth errors instead of failing
silently` | 🟢 Green
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27456200098))
| Proves the surfacing resolves it |

Part of FE-950 (single Cloud-JWT provider at login, Phase 1).
2026-06-25 03:42:57 +00:00
Dante
f19597ce81 feat(billing): deep link to open the pricing table (FE-1104) (#13001)
## Summary

Adds an in-app deep link that opens the pricing table directly, for
driving pilot users straight to subscribe (request from nav/Alex).
Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104).

- `/?pricing=1` — on app load, open the pricing table.
- `/?pricing=team` / `/?pricing=personal` — open it on the Team /
Personal plan tab (via the existing `UnifiedPricingTable`
`initialPlanMode`).
- Gated to the **original owner** via
`useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal
user, or a team workspace's original owner). A member or a promoted
owner is a **silent no-op**: the app loads normally, the param is
stripped, no 404 / error / toast.
- Off-cloud (OSS): the loader isn't instantiated, so the param is
ignored.
- Survives the login redirect via the preserved-query system, same as
`?invite` / `?create_workspace`.

## How

Mirrors the established URL-loader pattern (`useInviteUrlLoader` /
`useCreateWorkspaceUrlLoader`):

- `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing`
namespace + tracker key.
- New `usePricingTableUrlLoader.ts` — hydrate preserved query, read
`pricing`, strip the param + `clearPreservedQuery` in a single replace
before any await, then `await fetchMembers()` (resolves the
original-owner gate; no-ops for personal) and open the table only when
the gate allows.
- `GraphCanvas.vue` — call the loader in `onMounted` after the
create-workspace loader (cloud only; not gated on the team-workspaces
flag so it also drives personal/legacy users).
- `useSubscriptionDialog.ts` — new `'deep_link'` value on
`SubscriptionDialogReason`.

## Telemetry

Eligible opens emit the existing `subscription_required_modal_opened`
PostHog event with the new `reason: 'deep_link'`. Ineligible-click
bounce rate is derivable from the autocaptured pageview URL
(`?pricing=…`), so no new event plumbing.

## Stacking / dependencies

This feature needs two sibling stacks off `main`:

- **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` +
`showPricingTable({ planMode })`.
- **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate.
**Merged into this branch**, so the diff against the FE-934 base
includes FE-770's changes until it lands. Review the single
`feat(billing): deep link…` commit. Once both land on `main`, rebase
onto `main` and the diff collapses to just this feature.

Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up.
End-state: swap the FE original-owner heuristic for the BE
workspace-level `is_original_owner` flag when it lands (removes the
members-fetch).

## Tests

- Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an
original owner; `team`/`personal` tab preselect; silent no-op +
param-strip for a member/promoted owner; proves the gate is read only
after `fetchMembers` resolves; preserved-query restore;
empty/non-string/absent/unrecognized param; members-fetch failure
strips+clears without opening.
- E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`,
`@cloud`, 4 cases, verified locally): personal owner opens + URL
stripped; `?pricing=team` lands on the active Team tab; team original
owner opens (real `is_original_owner` + email gate); team member is a
silent no-op + URL stripped.
- Typecheck + related unit suites (`useSubscriptionDialog`,
`useWorkspaceUI`, `teamWorkspaceStore`) green.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-25 02:30:07 +00:00
Dante
988dc71955 feat(workspace): redesign Members invite UI to DES-186 (FE-768) (#12759)
## Summary

Redesigns the Settings ▸ Workspaces Members invite flow to DES-186:
email-chips invite dialog, Resend/Cancel pending-invite actions, and
explicit team-plan gating.

## Changes

- **What**:
- `InviteMemberDialogContent`: single-email link-generation flow →
comma/Enter/paste-separated email chips (`TagsInput`); batch
`createInvite` via `Promise.allSettled` — on partial failure, failed
emails stay as chips with an error toast; success shows an Invited
state.
- `PendingInvitesList`: inline Copy-link/Revoke buttons → `⋯` overflow
menu with **Resend invite** / **Cancel invite**. New
`teamWorkspaceStore.resendInvite` issues the fresh invite *before*
revoking the old one, so a failed resend never destroys the original.
- FE-built invite links removed
(`getInviteLink`/`createInviteLink`/`copyInviteLink`,
`PendingInvite.token`) — invite delivery is BE email per DES-186.
- New `useTeamPlan` composable: explicit `isOnTeamPlan` (seat-based
`maxSeats > 1` proxy until BE-1254 exposes the plan signal) replaces
`isSingleSeatPlan` branching in
`WorkspacePanelContent`/`useMembersPanel`.
- Personal/no-team-plan state per Figma 2993-14604: "To add teammates,
upgrade your plan." banner + **Upgrade to Team**; "Need more members?
Contact us" footer; upsell dialog copy → Team-plan framing; members sort
by Role column (was Join date).
- **Header layout per design (2nd commit)**: `[Search][Invite +][⋯
workspace menu]` moved from the dialog tab row into the members card
header row (the "N of M members" row) — the tab row right side is empty
in every DES-186 frame. Workspace menu extracted to
`WorkspaceMenuButton.vue`.
- **Single-member gating per design annotations**: when the owner is
alone — search hidden ("Show if more than 1 members"), Active/Pending
segmented tabs hidden, role column hidden, and the Members tab label
drops its count ("Remove '(0)' as well if it's just 1 member"). Tabs
reappear if pending invites exist so they stay manageable.
- **Breaking**: `teamWorkspaceStore` invite-link API removed (no
external consumers found in-repo).

## Review Focus

- Resend semantics (create-first, then revoke) and pending-invites state
update in `teamWorkspaceStore.resendInvite`.
- Invite-link removal assumes BE sends invite emails (DES-186 annotation
"Resends the invite email"). If BE email delivery is not yet live, this
PR should wait on that confirmation.
- Gating matrix: team-active owner → enabled; team at seat cap →
disabled+tooltip; non-team plan → upsell dialog; personal workspace →
disabled.
- Workspace `⋯` menu (Edit/Delete/Leave) now lives only on the Members
tab card header, matching DES-186 — the Plan & Credits tab no longer
exposes it (design shows the plan-card `⋯` there instead, shipped via
FE-964/FE-768b).

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session; team-plan states use client-side XHR-level API stubs for
subscription/members/invites since the test workspaces are
unsubscribed).

| Flow | Before (main) | After (this PR) |
|---|---|---|
| Members list — team plan | <img width="400" alt="before members"
src="https://github.com/user-attachments/assets/be214b95-4783-47ff-9539-59c8a33b5eb9"
/> | <img width="400" alt="after: Search/Invite/menu in the card header
row, Role column, design demo data"
src="https://github.com/user-attachments/assets/841c89d5-2a29-4eed-9c72-4a5ee8bee9f4"
/> |
| Pending invites — row actions | <img width="400" alt="before pending:
inline copy-link/revoke icons"
src="https://github.com/user-attachments/assets/1703850e-86bc-4735-81b3-7530c01ad46f"
/> | <img width="400" alt="after pending: overflow menu with
Resend/Cancel invite"
src="https://github.com/user-attachments/assets/9b1bbe03-d82b-4cf6-86f2-e01d7b898ae7"
/> |
| Team workspace with 1 member | (same chrome as members list: search +
tabs always shown) | <img width="400" alt="after: no search/tabs/role,
tab label without count, Invite + menu in card row"
src="https://github.com/user-attachments/assets/22c7f68a-eb0f-49a9-a203-516cd9b7e02d"
/> |
| Invite dialog | <img width="400" alt="before: single email, Create
link"
src="https://github.com/user-attachments/assets/c19d8bbb-feb5-4fe4-8541-6d52e6ab6600"
/> | <img width="400" alt="after: comma-separated email chips"
src="https://github.com/user-attachments/assets/101fdc7b-d6e0-4f7d-8966-894bbf16b4aa"
/> |
| Invite dialog — submit result | (copies a link) | <img width="400"
alt="after: Invited success state"
src="https://github.com/user-attachments/assets/f539a88c-0250-434c-bf4a-6ce714b30398"
/> |
| Upsell — invite without team plan | <img width="400" alt="before:
subscription required, Creator plan copy"
src="https://github.com/user-attachments/assets/bb2cb9fd-f298-4cb0-b39a-6d59061dcea1"
/> | <img width="400" alt="after: Team plan required, Upgrade to Team"
src="https://github.com/user-attachments/assets/45170ed5-63bd-469a-af12-197a8b7e09ee"
/> |
| Personal workspace — Members tab | (create-workspace hint text) | <img
width="400" alt="after: upgrade banner + disabled Invite"
src="https://github.com/user-attachments/assets/a0ae664b-2d20-4d87-900d-7a36872ecde3"
/> |

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Members half; Plan & Credits panel ships separately stacked on FE-964)
2026-06-25 02:29:46 +00:00
Benjamin Lu
da55529d23 GTM-93 point Windows download at comfy.org proxy (#12974)
## Summary

- Point the website Windows desktop download URL at
`https://comfy.org/download/windows/nsis/x64`.
- Keep macOS on the existing ToDesktop URL.
- Update the download page smoke test to expect the new Windows href.

## Context

This is the frontend leg of the GTM-93 Windows MVP. ToDesktop still
controls `download.comfy.org`; instead of changing DNS, the website
sends Windows users to a controlled `comfy.org` proxy path that the
router PR handles. The proxy forwards to ToDesktop and adds a tokenized
`Content-Disposition` filename for Desktop to consume on Windows.

Linear:
https://linear.app/comfyorg/issue/GTM-93/fix-posthog-identify-call-unblock-funnel-attribution-desktop-funnel
Router PR: https://github.com/Comfy-Org/comfy-router/pull/33
Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1149

## Validation

- `pnpm --filter @comfyorg/website run typecheck`
- `pnpm --filter @comfyorg/website run build`
- `pnpm --filter @comfyorg/website exec playwright test
e2e/download.spec.ts`
- pre-commit: `pnpm typecheck`, `pnpm typecheck:website`
2026-06-24 17:29:53 -07:00
Dante
52d430d1b6 fix(billing): repoint direct-bypass billing consumers to the facade (B3) (FE-933) (#12643)
## What
**B3 — Repoint direct-bypass billing consumers to the facade.** Billing
data was read from the legacy `useSubscription` store / `authStore`
directly (empty or personal-only for team workspaces) instead of the
workspace-aware `useBillingContext` facade.

FE-933 (parent FE-903).

> **Stacked on #12622 (B2 / FE-904)** — depends on the facade `tier` /
`renewalDate` fields added there. Base is the B2 branch; retarget to
`main` once B2 merges.

## Repointed consumers
- **T3 — `SubscribeButton.vue`**: `subscribe_clicked` telemetry
`current_tier` ← facade `tier` (was wrong/empty for team users)
- **T4 — `PostHogTelemetryProvider.ts`**: PostHog `subscription_tier`
person property ← facade `tier` watch (tier-segmented analytics was
polluted for team users)
- **T5 — `FreeTierDialogContent.vue`**: next-refresh date ← facade raw
ISO `renewalDate`, formatted at the display site (the line silently
disappeared for team users)
- **`useSubscriptionActions.handleRefresh` + `SettingDialog`
credits-nav**: balance refresh ← facade `fetchBalance()` (was legacy
`/customers`-only `authActions.fetchBalance`)
- **`CurrentUserPopoverLegacy.vue`**: tier badge / balance / skeleton /
refreshes ← facade (`tier`, `balance`, `isLoading`, `fetchStatus`,
`fetchBalance`); tier name via shared `useWorkspaceTierLabel` instead of
a duplicated mapping
- **`PricingTable.vue`**: `isActiveSubscription` / `isFreeTier` / `tier`
/ yearly-vs-monthly ← facade; the billing-portal flow
(`accessBillingPortal` deep-links + proration) is intentionally
unchanged — facade `manageSubscription` is not behavior-identical

## Out of scope (triaged)
- `TopUpCreditsDialogContentLegacy` / `SubscriptionPanelContentLegacy` /
`useSubscriptionDialog` / cancellation watcher — legacy-mode-only
surfaces decommissioned by B1 (FE-966); repointing is churn, and
`useSubscriptionDialog` would create a legacy↔facade cycle
- `LegacyCreditsPanel` / `UserCredit` — deleted/orphaned by FE-964
(#12734); its successor `CreditsPanel.vue` keeps an
`authStore.lastBalanceUpdateTime` watch (no facade equivalent yet) —
follow-up after FE-964 lands

## Known semantic deltas (intentional, match shipped facade consumers)
- Balance-refresh failures no longer toast: legacy
`authActions.fetchBalance` wrapped errors with a toast; facade
`fetchBalance` rejections are void-ed, same as
`CurrentUserPopoverWorkspace` / `SubscriptionPanelContentWorkspace`.
Facade-level error surfacing is a follow-up.
- Popover skeleton keys on facade `isLoading` (init-time) rather than
per-fetch `isFetchingBalance`, matching the workspace popover.

## Tests
- New behavioral coverage: FreeTier renewal-date render/disappear,
popover tier badge + balance from facade, current-plan highlight from
facade tier+duration, facade-vs-legacy fetchBalance tripwire, PostHog
`subscription_tier` from facade tier.
- Local gates clean (typecheck / lint / format / dead-code); touched
unit files 71/71 pass.

## E2E coverage
Browser regression tests live in the stacked #12760
(`billingFacadeConsumers.spec.ts`, `@cloud`): avatar popover tier badge
+ balance, and the free-tier dialog renewal-date line (T5) rendered from
the facade. The team-user telemetry fixes (PostHog person property,
telemetry payload) are non-UI observables covered by unit tests that
mock only the facade and fail on revert.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-25 00:09:14 +00:00
162 changed files with 10467 additions and 2507 deletions

View File

@@ -83,6 +83,16 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
'a[href="https://comfy.org/download/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)

View File

@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
windows: 'https://comfy.org/download/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const

View File

@@ -0,0 +1,96 @@
import type {
BillingStatusResponse,
Member,
Plan,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
// `/api/features` is the remote-config source: production builds resolve the
// workspaces flag from it (the `ff:` localStorage override is dev-only).
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
team_workspaces_enabled: true
}
export const TEAM_WORKSPACE: WorkspaceWithRole = {
id: 'ws-team',
name: 'Team Comfy',
type: 'team',
created_at: '2025-01-01T00:00:00Z',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
subscription_tier: 'PRO'
}
export const CREATOR: Member = {
id: 'u-liz',
name: 'Liz',
email: 'liz@test.comfy.org',
joined_at: '2025-01-01T00:00:00Z',
role: 'owner',
is_original_owner: true
}
// Identity must match the CloudAuthHelper mock user so this row counts as
// "(You)".
export const VIEWER: Member = {
id: 'u-me',
name: 'E2E Test User',
email: 'e2e@test.comfy.org',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
is_original_owner: false
}
export const MEMBER_JANE: Member = {
id: 'u-jane',
name: 'Jane',
email: 'jane@test.comfy.org',
joined_at: '2025-01-03T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const MEMBER_JOHN: Member = {
id: 'u-john',
name: 'John',
email: 'john@test.comfy.org',
joined_at: '2025-01-04T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const DEFAULT_TEAM_MEMBERS: Member[] = [
CREATOR,
VIEWER,
MEMBER_JANE,
MEMBER_JOHN
]
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
plan_slug: 'pro-monthly',
billing_status: 'paid',
has_funds: true,
renewal_date: '2099-02-20T00:00:00Z'
}
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
// the whole role-management UI.
export const TEAM_PRO_PLAN: Plan = {
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 21100,
max_seats: 30,
availability: { available: true },
seat_summary: {
seat_count: 4,
total_cost_cents: 40000,
total_credits_cents: 0
}
}

View File

@@ -0,0 +1,150 @@
import type { Page, Route } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import {
DEFAULT_TEAM_MEMBERS,
TEAM_BILLING_STATUS,
TEAM_PRO_PLAN,
TEAM_WORKSPACE,
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
interface RoleChangeRequest {
url: string
role: string
}
interface MemberMockState {
members: Member[]
patches: RoleChangeRequest[]
}
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
/**
* Boots the cloud app against fully mocked workspace + billing endpoints so
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
* to reach the OSS devtools backend during setup).
*
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
* `patches` records every role-change request for assertion.
*/
export class CloudWorkspaceMockHelper {
constructor(private readonly page: Page) {}
async setup(
members: Member[] = DEFAULT_TEAM_MEMBERS
): Promise<MemberMockState> {
const state = await this.mockBoot(members)
await new CloudAuthHelper(this.page).mockAuth()
await this.page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
})
return state
}
private async mockBoot(members: Member[]): Promise<MemberMockState> {
const state: MemberMockState = {
members: members.map((m) => ({ ...m })),
patches: []
}
const { page } = this
await page.route('**/api/features', (r) =>
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// A non-empty settings payload with TutorialCompleted marks the user as
// returning, so the new-user Templates dialog never auto-opens to block the
// Settings button. Errors tab off suppresses the model-folder 401 toast.
await page.route('**/api/settings', (r) =>
r.fulfill(
jsonRoute({
'Comfy.TutorialCompleted': true,
'Comfy.RightSidePanel.ShowErrorsTab': false
})
)
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
)
await page.route('**/api/workspace/members**', (route: Route) => {
const request = route.request()
if (request.method() === 'PATCH') {
const url = request.url()
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
const { role } = request.postDataJSON() as { role: Member['role'] }
state.patches.push({ url, role })
const member = state.members.find((m) => m.id === id)
if (member) member.role = role
// Echo the updated row like the real BE; the store merges only the role
// locally, so the response body shape is not load-bearing.
return route.fulfill(jsonRoute(member))
}
return route.fulfill(
jsonRoute({
members: state.members,
pagination: { offset: 0, limit: 50, total: state.members.length }
})
)
})
await page.route('**/api/workspace/invites', (r) =>
r.fulfill(jsonRoute({ invites: [] }))
)
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000,
currency: 'usd',
effective_balance_micros: 6000,
cloud_credit_balance_micros: 5000,
prepaid_balance_micros: 1000
})
)
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
)
)
return state
}
}

View File

@@ -0,0 +1,34 @@
import type { Page } from '@playwright/test'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Minimal valid billing shapes so the billing facade resolves while a
* subscription dialog mounts. Active personal sub with zero balance.
*/
export async function mockBilling(page: Page) {
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'pro',
subscription_duration: 'MONTHLY',
billing_status: 'paid'
})
)
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute({ is_active: false }))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
}

View File

@@ -0,0 +1,64 @@
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
interface CloudBootOptions {
/** Remote-config payload for `/api/features` (enables the flags under test). */
features: RemoteConfig
/** Body for `/api/settings` (defaults to `{}`). */
settings?: unknown
}
/**
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
* working app without falling through to the OSS devtools backend. Specs layer
* their own feature- or flow-specific routes on top.
*/
export async function mockCloudBoot(
page: Page,
{ features, settings = {} }: CloudBootOptions
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
/**
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
* original-owner gate matches against the members self-row.
*/
export async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}

View File

@@ -0,0 +1,12 @@
/**
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
* drift against the real API shape.
*/
export function jsonRoute<T>(body: T) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}

View File

@@ -0,0 +1,68 @@
import type { Page } from '@playwright/test'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
export function workspace(
type: 'personal' | 'team',
role: 'owner' | 'member'
): WorkspaceWithRole {
return {
id: `ws-${type}`,
name: type === 'team' ? 'My Team' : 'Personal Workspace',
type,
role,
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
}
}
export function member(
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
): Member {
return {
id: `user-${overrides.email}`,
name: overrides.email,
joined_at: '2026-01-01T00:00:00Z',
is_original_owner: false,
...overrides
}
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
*/
export async function mockWorkspace(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
)
)
}

View File

@@ -0,0 +1,194 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
BillingBalanceResponse,
BillingStatusResponse
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
* selects its backend by flag: `team_workspaces_enabled: false` routes through
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// The workspace `/api/billing/status` shape mirrors the legacy subscription
// status; map the fields so a single test fixture drives both backends.
const toWorkspaceStatus = (
s: CloudSubscriptionStatusResponse
): BillingStatusResponse => ({
is_active: s.is_active ?? false,
subscription_tier: s.subscription_tier ?? undefined,
subscription_duration: s.subscription_duration ?? undefined,
renewal_date: s.renewal_date ?? undefined,
cancel_at: s.end_date ?? undefined,
has_funds: s.has_fund ?? true
})
const mockBalance: BillingBalanceResponse = {
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
}
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay would otherwise intercept clicks on the topbar.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy backend (team_workspaces_enabled: false).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
// now routes through `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Boots with team workspaces enabled (production shape); the facade routes a
// personal workspace through the workspace `/api/billing/*` endpoints. With
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
// overwrites window.__CONFIG__ from /api/features, so the flags must come
// from the features mock, not an init script.)
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
await page.getByTestId('subscribe-to-run-button').click()
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
const BOOT_FEATURES = {
onboarding_survey_enabled: true
} satisfies RemoteConfig
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
test.slow()
await mockCloudBoot(page)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyTransient401(page)
await bootCloud(page)
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
test.slow()
await mockCloudBoot(page)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyNotCompleted(page)
await bootCloud(page)

View File

@@ -2,7 +2,10 @@ import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
end_date: FUTURE_DATE
}
// With team workspaces enabled, the facade routes a personal workspace through
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
// visible (free tier would swap it for "Upgrade to add credits").
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_status: 'canceled',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
has_funds: true,
cancel_at: FUTURE_DATE,
renewal_date: FUTURE_DATE
}
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
// of the popover before the fix.
const mockBalance: CustomerBalanceResponse = {
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
})
)
// Flag-on (team workspaces enabled) routes a personal workspace through the
// workspace billing endpoints, so the popover sources its data from here.
await page.route('**/api/billing/status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBillingStatus)
})
)
await page.route('**/api/billing/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await page.route('**/api/billing/plans', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plans: [] })
})
)
await use(page)
}
})

View File

@@ -0,0 +1,264 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
*
* The credits tile only lives inside the authenticated cloud app, which the
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
* the cloud app initializes against fully stubbed data. With team workspaces
* enabled the facade routes a personal workspace through the workspace
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
* should then render its total / progress bar / monthly+additional breakdown /
* add-credits.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
// same response shape, so one body fulfills both endpoints.
const balanceRoute = (balance: {
amount: number
monthly: number
prepaid: number
}) =>
jsonRoute({
amount_micros: balance.amount,
currency: 'usd',
effective_balance_micros: balance.amount,
cloud_credit_balance_micros: balance.monthly,
prepaid_balance_micros: balance.prepaid
})
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
has_funds: true
}
async function mockCloudBoot(page: Page) {
// Frontend-origin boot endpoints (proxied to the backend in production).
// `/api/features` is the remote-config source: production builds resolve
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
// dev-only), and the flag gates the Workspace settings panel.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
// Include the mock user so the multi-user select screen auto-selects it
// (paired with the `Comfy.userId` localStorage seed below).
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Non-empty settings with a completed tutorial keep the cloud app from
// booting as a new user, whose Workflow Templates dialog would otherwise
// auto-open and intercept the Settings click behind its modal backdrop.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy billing (flag-off path, api.comfy.org/customers/*).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
end_date: null
})
)
)
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
// Workspace billing (flag-on path) — a personal workspace now routes through
// `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(mockBillingStatus))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function mockBalance(
page: Page,
balance: { amount: number; monthly: number; prepaid: number }
) {
await page.unroute('**/customers/balance')
await page.unroute('**/api/billing/balance')
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
}
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
async function openPlanAndCredits(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
// Open Settings ▸ Workspace.
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
return dialog.getByRole('main')
}
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
test('renders the unified tile with breakdown and add-credits', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
const content = await openPlanAndCredits(page)
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
// 10,550 -> used 10,550).
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
// Monthly allowance fully spent; additional credits keep generation going.
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
})
})

View File

@@ -0,0 +1,264 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
CREATOR,
MEMBER_JANE,
MEMBER_JOHN,
VIEWER
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
*
* The viewer is a promoted owner (not the workspace creator), so the spec can
* distinguish the creator guard from the self guard: the creator row and the
* viewer's own row hide the row menu, every other row exposes
* "Change role " (Owner / Member) plus "Remove member". Promoting a member
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
* re-sorts the row under the creator, and the promoted owner stays demotable.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
async function openMembersTab(page: Page): Promise<Locator> {
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: /Members/ }).click()
await expect(content.getByText('4 of 30 members')).toBeVisible()
return content
}
function memberRow(content: Locator, email: string): Locator {
return content
.locator('div.grid')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
function menuButton(row: Locator): Locator {
return row.getByRole('button', { name: 'More Options' })
}
// Reka submenus open on real pointer travel or keyboard; Playwright's
// synthetic hover doesn't trigger the pointermove handler, so drive the
// subtrigger with ArrowRight instead.
async function openChangeRoleSubmenu(page: Page) {
const trigger = page.getByRole('menuitem', { name: 'Change role' })
await expect(trigger).toBeVisible()
await trigger.press('ArrowRight')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toBeVisible()
}
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('row menus respect creator and self guards', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
// own row; the two plain members each expose a menu.
await expect(
menuButton(memberRow(content, MEMBER_JOHN.email))
).toBeVisible()
await expect(
menuButton(memberRow(content, MEMBER_JANE.email))
).toBeVisible()
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
await expect(
page.getByRole('menuitem', { name: 'Change role' })
).toBeVisible()
await page.getByRole('menuitem', { name: 'Remove member' }).click()
await expect(page.getByText('Remove this member?')).toBeVisible()
})
test('selecting the current role is a no-op', async ({ page }) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
// The current role is a checked radio item so assistive tech can announce
// which role is active.
await expect(
page.getByRole('menuitemradio', { name: 'Member', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toHaveAttribute('aria-checked', 'false')
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
})
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await expect(page.getByText("They'll be able to:")).toBeVisible()
await expect(page.getByText('Add additional credits')).toBeVisible()
await expect(
page.getByText('Manage members, payment methods, and workspace settings')
).toBeVisible()
await expect(
page.getByText(
'Promote and demote other owners (except the workspace creator).'
)
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toHaveCount(0)
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toHaveLength(0)
})
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const emails = content.getByText(/@test\.comfy\.org/)
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JOHN.email,
MEMBER_JANE.email
])
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
await expect(page.getByText('Role updated')).toBeVisible()
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JANE.email,
MEMBER_JOHN.email
])
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'owner'
}
])
// The promoted owner keeps its row menu (still demotable).
await expect(menuButton(janeRow)).toBeVisible()
})
test('demoting an owner returns them to member', async ({ page }) => {
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
const state = await new CloudWorkspaceMockHelper(page).setup([
CREATOR,
VIEWER,
ownerJane,
MEMBER_JOHN
])
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Demote Jane to member?' })
).toBeVisible()
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
await page.getByRole('button', { name: 'Demote to member' }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'member'
}
])
})
test('failed role change keeps the dialog open with an error toast', async ({
page
}) => {
await new CloudWorkspaceMockHelper(page).setup()
// Override the member route so PATCH fails after boot succeeds.
await page.route('**/api/workspace/members/**', (route) =>
route.request().method() === 'PATCH'
? route.fulfill({ status: 500, body: '{}' })
: route.fallback()
)
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
})
})

View File

@@ -0,0 +1,128 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import {
member,
mockWorkspace,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_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
// GraphCanvas onMounted chain before the deep-link loader.
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
// chain must not throw before it: a missing settings subpath, prompt exec_info,
// or queue status each abort that chain.
async function mockGraphBootExtras(page: Page) {
// Boot only reads these; fall back on any write so an unexpected POST/PUT
// surfaces instead of being masked by a blanket 200.
await page.route('**/api/settings/**', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({}))
})
await page.route('**/api/prompt', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
})
await page.route('**/api/queue', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
})
}
async function setupCloudApp(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await mockGraphBootExtras(page)
await mockBilling(page)
await mockWorkspace(page, ws, members)
await bootCloud(page)
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]pricing=/)
})
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=team`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(
page.getByRole('button', { name: 'For Teams' })
).toHaveAttribute('aria-pressed', 'true')
})
test('opens for a team original owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
})
test('is a silent no-op for a team member', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await page.goto(`${APP_URL}/?pricing=1`)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/[?&]pricing=/)
await expect(pricingHeading(page)).toBeHidden()
})
})

View File

@@ -52,6 +52,8 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
--color-coral-700: #b33a3a;
@@ -236,6 +238,8 @@
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--tertiary-background: var(--color-smoke-400);
--tertiary-background-hover: var(--color-smoke-500);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
--primary-background-hover: var(--color-cobalt-800);
@@ -384,6 +388,8 @@
--secondary-background: var(--color-charcoal-600);
--secondary-background-hover: var(--color-charcoal-400);
--secondary-background-selected: var(--color-charcoal-200);
--tertiary-background: var(--color-charcoal-400);
--tertiary-background-hover: var(--color-charcoal-300);
--base-background: var(--color-charcoal-800);
--primary-background: var(--color-azure-600);
--primary-background-hover: var(--color-azure-400);
@@ -554,6 +560,8 @@
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-tertiary-background: var(--tertiary-background);
--color-tertiary-background-hover: var(--tertiary-background-hover);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);

View File

@@ -11,6 +11,8 @@ import {
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
defineOptions({
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
v-tooltip="
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
"
:class="
cn(
itemClass,
String(item.class ?? ''),
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
)
"
v-bind="
'checked' in item
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
: {}
"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -7,13 +8,16 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
contentProp
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
:style="contentStyle"
>
<slot :item-class>
<DropdownItem

View File

@@ -0,0 +1,56 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import DropdownMenu from './DropdownMenu.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderMenu() {
return render(DropdownMenu, {
props: { entries: [{ label: 'Item A' }] },
global: { plugins: [i18n], directives: { tooltip: {} } }
})
}
let openModal: HTMLElement | undefined
afterEach(() => {
if (openModal) {
ZIndex.clear(openModal)
openModal = undefined
}
})
describe('DropdownMenu z-index', () => {
it('opens above a dialog registered with the modal z-index counter', async () => {
openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
const dialogZ = Number(openModal.style.zIndex)
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
})
it('leaves the static z-index untouched when no dialog is open', async () => {
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(menu.style.zIndex).toBe('')
expect(menu.className).toContain('z-1700')
})
})

View File

@@ -0,0 +1,97 @@
<template>
<div class="credits-container flex h-full flex-col gap-4">
<div>
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
</div>
<CreditsTile />
<div class="flex items-center justify-between">
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import { ref, watch } from 'vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'
const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
void usageLogsTableRef.value.refresh()
}
}
)
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
</script>

View File

@@ -1,195 +0,0 @@
<template>
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
<div class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-muted">
{{ $t('credits.yourCreditBalance') }}
</h3>
<div class="flex items-center justify-between">
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
:loading="loading"
@click="handlePurchaseCreditsClick"
>
{{ $t('credits.purchaseCredits') }}
</Button>
</div>
<div class="flex flex-row items-center">
<Skeleton
v-if="balanceLoading"
width="12rem"
height="1rem"
class="text-xs"
/>
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.refresh')"
@click="() => authActions.fetchBalance()"
>
<i class="pi pi-refresh" />
</Button>
</div>
</div>
<div class="flex items-center justify-between">
<h3>{{ $t('credits.activity') }}</h3>
<Button
variant="muted-textonly"
:loading="loading"
@click="handleCreditsHistoryClick"
>
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<template v-if="creditHistory.length > 0">
<div class="grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-center text-base font-medium',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
timestamp: string
amount: number
isPositive: boolean
}
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
usageLogsTableRef.value.refresh()
}
}
)
const handlePurchaseCreditsClick = () => {
// Track purchase credits entry from Settings > Credits panel
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -195,10 +195,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
const { t } = useI18n()
const emit = defineEmits<{
@@ -457,10 +454,7 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
const { runUrlActionLoaders } = useUrlActionLoaders()
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -569,23 +563,8 @@ onMounted(async () => {
() => canvasStore.updateSelectedItems()
)
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
await runUrlActionLoaders()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =

View File

@@ -26,7 +26,6 @@ const singleErrorCard: ErrorCardData = {
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
@@ -40,7 +39,6 @@ const multipleErrorsCard: ErrorCardData = {
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
@@ -58,7 +56,6 @@ const runtimeErrorCard: ErrorCardData = {
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
@@ -73,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([3, 15]),
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
@@ -104,13 +87,6 @@ export const SingleValidationError: Story = {
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {

View File

@@ -79,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:

View File

@@ -21,15 +21,6 @@
</span>
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
@@ -233,12 +223,6 @@ function handleLocateNode() {
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getCopyMessage(card.errors[idx])

View File

@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
focusNode: mockFocusNode
}))
}))

View File

@@ -249,7 +249,6 @@
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { focusNode } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
</script>

View File

@@ -16,7 +16,6 @@ export interface ErrorCardData {
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}

View File

@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -130,7 +130,6 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: nodeId.includes(':'),
errors: []
}
}

View File

@@ -1,37 +1,18 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -50,7 +30,6 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -127,7 +96,6 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
default: defineComponent({
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
}
}
})
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
currency: 'usd'
}
mockAuthStoreState.isFetchingBalance = false
mockIsLoading.value = false
})
function renderComponent() {
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
currency: 'usd'
}
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
currency: 'usd'
}
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
currency: 'usd'
}
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
currency: 'usd'
}
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
renderComponent()
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,12 +32,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
const currentTier = tier.value
return (
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
)
})
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void authActions.fetchBalance()
void fetchBalance()
})
</script>

View File

@@ -22,6 +22,8 @@ export const buttonVariants = cva({
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -54,6 +56,7 @@ const variants = [
'destructive-textonly',
'link',
'base',
'tertiary',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -13,7 +13,8 @@ import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
TEAM_PLAN_CREDIT_STOPS,
getStopDiscountedMonthlyUsd
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
@@ -83,7 +84,7 @@ const effectiveDiscountPercent = computed(() =>
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
getStopDiscountedMonthlyUsd(current.value, cycle)
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)

View File

@@ -7,7 +7,9 @@ import type {
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier,
@@ -21,9 +23,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
/** ISO 8601; format at the display site. */
renewalDate: string | null
/** ISO 8601 */
/** ISO 8601; format at the display site. */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -43,16 +45,27 @@ export interface BillingActions {
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
options?: SubscribeOptions
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
planSlug: string,
options?: PreviewSubscribeOptions
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
/**
* Reactivates a cancelled-but-still-active subscription. Legacy has no
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
* The workspace adapter refreshes status and balance internally on success.
*/
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
/**
* Purchases additional credits. Standardized on **whole-dollar cents**
* (multiples of 100); the legacy adapter divides by 100 for the
* dollar-based /customers/credit endpoint.
* Pass-through by design: the caller owns the completed/pending follow-up
* (balance refresh or billing-op polling), so this does not refresh.
*/
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
@@ -80,8 +93,11 @@ export interface BillingState {
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
/** Reflects the active workspace's tier, not the user's personal tier. */
isFreeTier: ComputedRef<boolean>
/** Coarse funding state (`billing_status`); legacy reports null. */
billingStatus: ComputedRef<BillingStatus | null>
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>

View File

@@ -1,6 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
@@ -20,12 +22,14 @@ const {
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
mockUpdateActiveWorkspace,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
mockUpdateActiveWorkspace: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
get: () => teamWorkspacesEnabledRef.value,
set: (value: boolean) => {
teamWorkspacesEnabledRef.value = value
}
})
}))
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
updateActiveWorkspace: mockUpdateActiveWorkspace
})
}))
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
it('selects legacy type when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
@@ -206,6 +237,14 @@ describe('useBillingContext', () => {
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
const { topup } = useBillingContext()
await expect(topup(550)).rejects.toThrow()
await expect(topup(0)).rejects.toThrow()
await expect(topup(-100)).rejects.toThrow()
await expect(topup(99.5)).rejects.toThrow()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
@@ -221,6 +260,42 @@ describe('useBillingContext', () => {
expect(() => showSubscriptionDialog()).not.toThrow()
})
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
const { type, initialize } = useBillingContext()
await initialize()
await nextTick()
expect(type.value).toBe('legacy')
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: true,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()

View File

@@ -7,6 +7,10 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
PreviewSubscribeOptions,
SubscribeOptions
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -27,11 +31,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
* Unified billing context that selects the billing implementation by build/flag.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for both
* personal (single-seat workspace) and team workspaces
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -92,16 +96,14 @@ function useBillingContextInternal(): BillingContext {
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
@@ -173,7 +175,7 @@ function useBillingContextInternal(): BillingContext {
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
if (!sub) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
@@ -183,26 +185,28 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Initialize billing when workspace changes
function resetBillingState() {
isInitialized.value = false
error.value = null
}
// type can flip after setup when the team-workspaces flag resolves from
// authenticated config, swapping the active backend; a fresh init is needed.
// The watch fires only when id or type actually changes, so any fire with a
// workspace selected warrants a reinit.
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
resetBillingState()
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
isInitialized.value = false
try {
await initialize()
} catch (err) {
console.error('Failed to initialize billing context:', err)
}
},
{ immediate: true }
@@ -233,16 +237,15 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
async function subscribe(planSlug: string, options?: SubscribeOptions) {
return activeContext.value.subscribe(planSlug, options)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
async function previewSubscribe(
planSlug: string,
options?: PreviewSubscribeOptions
) {
return activeContext.value.previewSubscribe(planSlug, options)
}
async function manageSubscription() {
@@ -258,6 +261,15 @@ function useBillingContextInternal(): BillingContext {
}
async function topup(amountCents: number) {
if (
!Number.isInteger(amountCents) ||
amountCents <= 0 ||
amountCents % 100 !== 0
) {
throw new Error(
'Top-up amount must be a positive whole-dollar cent value'
)
}
return activeContext.value.topup(amountCents)
}

View File

@@ -5,7 +5,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useAuthStore } from '@/stores/authStore'
@@ -147,15 +149,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
_options?: SubscribeOptions
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
_planSlug: string,
_options?: PreviewSubscribeOptions
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null

View File

@@ -8,7 +8,6 @@ import type {
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
@@ -49,23 +48,7 @@ export function useFocusNode() {
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
if (!canvasStore.canvas) return
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode,
enterSubgraph
focusNode
}
}

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUrlActionLoaders } from './useUrlActionLoaders'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags.value })
}))
const mocks = vi.hoisted(() => ({
loadInvite: vi.fn().mockResolvedValue(undefined),
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
loadPricingTable: vi.fn().mockResolvedValue(undefined),
useInvite: vi.fn(),
useCreateWorkspace: vi.fn(),
usePricingTable: vi.fn()
}))
mocks.useInvite.mockImplementation(() => ({
loadInviteFromUrl: mocks.loadInvite
}))
mocks.useCreateWorkspace.mockImplementation(() => ({
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
}))
mocks.usePricingTable.mockImplementation(() => ({
loadPricingTableFromUrl: mocks.loadPricingTable
}))
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
useInviteUrlLoader: mocks.useInvite
}))
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
}))
vi.mock(
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
)
describe('useUrlActionLoaders', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockFlags.value = { teamWorkspacesEnabled: true }
})
it('does not instantiate or run any loader off cloud', async () => {
mockIsCloud.value = false
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.useInvite).not.toHaveBeenCalled()
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.usePricingTable).not.toHaveBeenCalled()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
})
it('runs all loaders on cloud when team workspaces are enabled', async () => {
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
mockFlags.value = { teamWorkspacesEnabled: false }
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
const { runUrlActionLoaders } = useUrlActionLoaders()
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,55 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { isCloud } from '@/platform/distribution/types'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
/**
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
* from `onMounted` once the app is ready.
*/
export function useUrlActionLoaders() {
const { flags } = useFeatureFlags()
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud
? useCreateWorkspaceUrlLoader()
: null
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
async function runUrlActionLoaders() {
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1).
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[UrlActionLoaders] Failed to load create workspace from URL:',
error
)
}
}
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
if (pricingTableUrlLoader) {
try {
await pricingTableUrlLoader.loadPricingTableFromUrl()
} catch (error) {
console.error(
'[UrlActionLoaders] Failed to load pricing table from URL:',
error
)
}
}
}
return { runUrlActionLoaders }
}

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
"yearly": "سنوي",
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
"yearlyDiscount": "خصم 20%",
"saveYearly": "وفّر 20%",
"yourPlanIncludes": "خطتك تشمل:"
},
"tabMenu": {

View File

@@ -2514,6 +2514,7 @@
"resubscribe": "Resubscribe",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"subscribeFailed": "Failed to subscribe",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
@@ -2553,6 +2554,23 @@
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"remaining": "remaining",
"refillsDate": "Refills {date}",
"refillsNextCycle": "Refills next cycle",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"monthlyUsageProgress": "{used} of {total} monthly credits used",
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
"outOfCreditsTitleNoDate": "You're out of credits",
"outOfCreditsDescription": "Add more credits to continue generating.",
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
@@ -2563,6 +2581,7 @@
"billedYearly": "{total} Billed yearly",
"monthly": "Monthly",
"yearly": "Yearly",
"saveYearly": "Save 20%",
"tierNameYearly": "{name} Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
@@ -2573,7 +2592,6 @@
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"
@@ -2650,9 +2668,9 @@
"perkProjectAssets": "Project & asset management",
"cta": "Subscribe to Team Yearly",
"ctaMonthly": "Subscribe to Team Monthly",
"unavailable": "This team plan is not available right now.",
"changePlan": "Change plan",
"currentPlan": "Current plan",
"checkoutComingSoon": "Team plan checkout is coming soon."
"currentPlan": "Current plan"
},
"enterprise": {
"name": "Enterprise",
@@ -2721,10 +2739,11 @@
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starting today",
"startingToday": "Starts today",
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"eachYearCreditsRefill": "Each year credits refill to",
"everyMonthStarting": "Every month starting {date}",
"creditsRefillTo": "Credits refill to",
"youllBeCharged": "You'll be charged",
@@ -2735,6 +2754,24 @@
"proratedCharge": "Prorated charge for {plan}",
"totalDueToday": "Total due today",
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
"confirmUpgradeTitle": "Confirm your upgrade",
"confirmUpgradeCta": "Confirm upgrade",
"confirmChange": "Confirm change",
"confirmChangeTitle": "Review your scheduled change",
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
"switchesToday": "Switches today",
"startsOn": "Starts {date}",
"yearlySubscription": "Yearly subscription",
"newMonthlySubscription": "New monthly subscription",
"creditFromCurrent": "Credit from current {plan}",
"currentMonthly": "monthly plan",
"commitment": "commitment",
"creditsYoullGetToday": "Credits you'll get today",
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
"afterThat": "After that",
"creditsRefillMonthlyTo": "Credits refill monthly to",
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
"stayOnUntil": "You'll stay on {plan} until {date}.",
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
"terms": "Terms",
"privacyPolicy": "Privacy Policy",
@@ -2746,8 +2783,12 @@
},
"success": {
"allSet": "You're all set",
"inviteEmailsPlaceholder": "Enter emails separated by commas",
"inviteSubtext": "You can also invite people later from Settings",
"inviteTitle": "Invite your team",
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you."
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"userSettings": {
@@ -2763,7 +2804,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"inviteLimitReached": "You've reached the maximum of {count} members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2773,7 +2814,8 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/{maxSeats} Members",
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2782,26 +2824,30 @@
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
"role": "Role"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"resendInvite": "Resend invite",
"cancelInvite": "Cancel invite",
"changeRole": "Change role",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"upsellBanner": "To add teammates, upgrade your plan.",
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
"upgradeToTeam": "Upgrade to Team",
"reactivateTeam": "Reactivate Team",
"needMoreMembers": "Need more members?",
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
"searchPlaceholder": "Search..."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2825,32 +2871,38 @@
"success": "Member removed",
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
"success": "Role updated",
"error": "Failed to update role"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleNotSubscribed": "A Team plan is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
"upgradeToTeam": "Upgrade to Team"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
"title": "Invite members to this workspace",
"placeholder": "Enter emails separated by commas",
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
@@ -2877,6 +2929,8 @@
"title": "Left workspace",
"message": "You have left the workspace."
},
"inviteResent": "Invite resent",
"inviteResendFailed": "Failed to resend invite",
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
@@ -3787,7 +3841,6 @@
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuales",
"yearlyDiscount": "20% DESCUENTO",
"saveYearly": "Ahorra 20%",
"yourPlanIncludes": "Tu plan incluye:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
"yearly": "سالانه",
"yearlyCreditsLabel": "کل اعتبار سالانه",
"yearlyDiscount": "٪۲۰ تخفیف",
"saveYearly": "٪۲۰ صرفه‌جویی",
"yourPlanIncludes": "طرح شما شامل:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Cet espace de travail na pas dabonnement",
"yearly": "Annuel",
"yearlyCreditsLabel": "Crédits annuels totaux",
"yearlyDiscount": "20% DE RÉDUCTION",
"saveYearly": "Économisez 20 %",
"yourPlanIncludes": "Votre forfait comprend :"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
"yearly": "年額",
"yearlyCreditsLabel": "年間合計クレジット",
"yearlyDiscount": "20%割引",
"saveYearly": "20%お得",
"yourPlanIncludes": "ご利用プランに含まれるもの:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
"yearly": "연간",
"yearlyCreditsLabel": "연간 총 크레딧",
"yearlyDiscount": "20% 할인",
"saveYearly": "20% 절감",
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuais",
"yearlyDiscount": "20% DE DESCONTO",
"saveYearly": "Economize 20%",
"yourPlanIncludes": "Seu plano inclui:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
"yearly": "Ежегодно",
"yearlyCreditsLabel": "Годовые кредиты",
"yearlyDiscount": "СКИДКА 20%",
"saveYearly": "Экономия 20%",
"yourPlanIncludes": "Ваш план включает:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil",
"yearly": "Yıllık",
"yearlyCreditsLabel": "Toplam yıllık krediler",
"yearlyDiscount": "%20 İNDİRİM",
"saveYearly": "%20 tasarruf",
"yourPlanIncludes": "Planınız şunları içerir:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "此工作區尚未訂閱",
"yearly": "每年",
"yearlyCreditsLabel": "年度總點數",
"yearlyDiscount": "八折優惠",
"saveYearly": "節省 20%",
"yourPlanIncludes": "您的方案包含:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "此工作区未订阅",
"yearly": "年度",
"yearlyCreditsLabel": "总共年度积分",
"yearlyDiscount": "20% 减免",
"saveYearly": "立省 20%",
"yourPlanIncludes": "您的计划包括:"
},
"tabMenu": {

View File

@@ -0,0 +1,377 @@
import type { AxiosAdapter } from 'axios'
import axios, { AxiosError } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
attachUnifiedRemintInterceptor,
fetchWithUnifiedRemint
} from '@/platform/auth/unified/remintRetry'
const { mockRemint, flagState } = vi.hoisted(() => ({
mockRemint: vi.fn(),
flagState: { unifiedCloudAuthEnabled: true }
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ remintUnifiedOnce: mockRemint })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get unifiedCloudAuthEnabled() {
return flagState.unifiedCloudAuthEnabled
}
}
})
}))
// The axios interceptor gates on shouldRemintCloudRequest(), which is a no-op
// off-cloud; the unit env is not a cloud build, so force it on.
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
describe('fetchWithUnifiedRemint', () => {
const ok = { status: 200 } as Response
const unauthorized = { status: 401 } as Response
let mockFetch: ReturnType<typeof vi.fn>
beforeEach(() => {
mockRemint.mockReset()
flagState.unifiedCloudAuthEnabled = true
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('re-mints once and retries with the fresh token on a 401 (AC1)', async () => {
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' } },
true
)
expect(result).toBe(ok)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
const retryHeaders = new Headers(mockFetch.mock.calls[1][1].headers)
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
expect(retryHeaders.get('Comfy-User')).toBe('u1')
})
it('surfaces a persistent 401 after exactly one retry (AC2)', async () => {
const secondUnauthorized = { status: 401 } as Response
mockFetch
.mockResolvedValueOnce(unauthorized)
.mockResolvedValueOnce(secondUnauthorized)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA' } },
true
)
expect(result).toBe(secondUnauthorized)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('does not re-mint or retry when the caller gate is false (AC3)', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer tokenA' } },
false
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('does not retry a non-401 response', async () => {
const serverError = { status: 500 } as Response
mockFetch.mockResolvedValueOnce(serverError)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(serverError)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('surfaces the original 401 when the re-mint yields no token', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockResolvedValue(null)
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('surfaces the original 401 when the re-mint throws a permanent auth error', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockRejectedValue(new Error('INVALID_FIREBASE_TOKEN'))
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{ headers: { Authorization: 'Bearer t' } },
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('surfaces the original 401 without re-minting when the body is a non-replayable stream', async () => {
mockFetch.mockResolvedValueOnce(unauthorized)
mockRemint.mockResolvedValue('tokenB')
const result = await fetchWithUnifiedRemint(
'https://cloud/x',
{
method: 'POST',
body: new ReadableStream<Uint8Array>(),
headers: { Authorization: 'Bearer tokenA' }
},
true
)
expect(result).toBe(unauthorized)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it.for([
{
shape: 'object',
headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' }
},
{
shape: 'array of tuples',
headers: [
['Authorization', 'Bearer tokenA'],
['Comfy-User', 'u1']
]
},
{
shape: 'Headers',
headers: new Headers({
Authorization: 'Bearer tokenA',
'Comfy-User': 'u1'
})
}
] as { shape: string; headers: HeadersInit }[])(
'preserves method/body and replaces Authorization on a POST retry ($shape headers)',
async ({ headers }) => {
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
mockRemint.mockResolvedValue('tokenB')
const body = JSON.stringify({ amount: 5 })
await fetchWithUnifiedRemint(
'https://cloud/x',
{ method: 'POST', body, headers },
true
)
const retryInit = mockFetch.mock.calls[1][1]
expect(retryInit.method).toBe('POST')
expect(retryInit.body).toBe(body)
const retryHeaders = new Headers(retryInit.headers)
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
expect(retryHeaders.get('Comfy-User')).toBe('u1')
}
)
})
describe('attachUnifiedRemintInterceptor', () => {
beforeEach(() => {
mockRemint.mockReset()
flagState.unifiedCloudAuthEnabled = true
})
// A custom axios adapter is responsible for its own status handling (axios
// only applies validateStatus inside its built-in adapters), so reject
// non-2xx with a real AxiosError to mirror a live response.
function makeAdapter(statuses: number[]): ReturnType<typeof vi.fn> {
let call = 0
return vi.fn<AxiosAdapter>(async (config) => {
const status = statuses[Math.min(call, statuses.length - 1)]
call++
const response = {
data: status === 200 ? { ok: true } : { message: 'unauthorized' },
status,
statusText: String(status),
headers: {},
config
}
if (status >= 200 && status < 300) {
return response
}
throw new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
null,
response
)
})
}
function makeClient(statuses: number[]) {
const adapter = makeAdapter(statuses)
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
attachUnifiedRemintInterceptor(client)
return { client, adapter }
}
it('re-mints once and retries the request with the fresh token (AC1)', async () => {
const { client, adapter } = makeClient([401, 200])
mockRemint.mockResolvedValue('tokenB')
const res = await client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
expect(res.status).toBe(200)
expect(adapter).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
expect(String(adapter.mock.calls[1][0].headers.Authorization)).toBe(
'Bearer tokenB'
)
})
it('retries once then surfaces a persistent 401 (AC2)', async () => {
const { client, adapter } = makeClient([401, 401])
mockRemint.mockResolvedValue('tokenB')
await expect(
client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(2)
expect(mockRemint).toHaveBeenCalledTimes(1)
})
it('does not re-mint when the flag is OFF (AC3)', async () => {
flagState.unifiedCloudAuthEnabled = false
const { client, adapter } = makeClient([401])
await expect(
client.get('https://cloud/x', {
headers: { Authorization: 'Bearer tokenA' }
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('does not re-mint a request flagged __skipUnifiedRemint (acceptInvite)', async () => {
const { client, adapter } = makeClient([401])
mockRemint.mockResolvedValue('tokenB')
await expect(
client.post('https://cloud/invites/x/accept', null, {
headers: { Authorization: 'Bearer firebase' },
__skipUnifiedRemint: true
})
).rejects.toMatchObject({ response: { status: 401 } })
expect(adapter).toHaveBeenCalledTimes(1)
expect(mockRemint).not.toHaveBeenCalled()
})
it('passes a non-401 error through without re-minting', async () => {
const { client } = makeClient([500])
await expect(
client.get('https://cloud/x', { headers: { Authorization: 'Bearer t' } })
).rejects.toMatchObject({ response: { status: 500 } })
expect(mockRemint).not.toHaveBeenCalled()
})
it('preserves the POST body and method on a retry, with the fresh token', async () => {
const { client, adapter } = makeClient([401, 200])
mockRemint.mockResolvedValue('tokenB')
const res = await client.post(
'https://cloud/topup',
{ amount: 5 },
{ headers: { Authorization: 'Bearer tokenA' } }
)
expect(res.status).toBe(200)
expect(adapter).toHaveBeenCalledTimes(2)
const firstConfig = adapter.mock.calls[0][0]
const retryConfig = adapter.mock.calls[1][0]
expect(retryConfig.method).toBe('post')
expect(retryConfig.data).toBe(firstConfig.data)
expect(String(retryConfig.headers.Authorization)).toBe('Bearer tokenB')
})
it('latches per request — a second request still retries once (no shared latch)', async () => {
// Per-URL: first call 401, second (the retry) 200. The latch lives on each
// request's config, so a second request must retry independently.
const callsByUrl = new Map<string, number>()
const adapter = vi.fn<AxiosAdapter>(async (config) => {
const url = config.url ?? ''
const nth = (callsByUrl.get(url) ?? 0) + 1
callsByUrl.set(url, nth)
const okStatus = nth >= 2
const response = {
data: okStatus ? { ok: true } : { message: 'unauthorized' },
status: okStatus ? 200 : 401,
statusText: okStatus ? '200' : '401',
headers: {},
config
}
if (okStatus) return response
throw new AxiosError(
'Request failed with status code 401',
AxiosError.ERR_BAD_REQUEST,
config,
null,
response
)
})
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
attachUnifiedRemintInterceptor(client)
mockRemint.mockResolvedValue('tokenB')
const a = await client.get('https://cloud/a', {
headers: { Authorization: 'Bearer tokenA' }
})
const b = await client.get('https://cloud/b', {
headers: { Authorization: 'Bearer tokenA' }
})
expect(a.status).toBe(200)
expect(b.status).toBe(200)
// Each request: initial 401 + one retry = 4 adapter calls, one re-mint each.
expect(adapter).toHaveBeenCalledTimes(4)
expect(mockRemint).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,131 @@
import type {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig
} from 'axios'
import axios, { AxiosHeaders } from 'axios'
import { isCloud } from '@/platform/distribution/types'
let cachedUnifiedFlags:
| { readonly unifiedCloudAuthEnabled: boolean }
| undefined
/**
* Single gate for the reactive guard: a cloud build with `unified_cloud_auth`
* ON. Memoizes the feature-flag accessor so the hot `fetchApi` path does not
* build a fresh reactive proxy per request (the cached getter still reflects
* live flag changes), and is reused at every cloud request seam so the gate
* cannot be forgotten on a new call site.
*/
export async function shouldRemintCloudRequest(): Promise<boolean> {
if (!isCloud) return false
if (!cachedUnifiedFlags) {
const { useFeatureFlags } = await import('@/composables/useFeatureFlags')
cachedUnifiedFlags = useFeatureFlags().flags
}
return cachedUnifiedFlags.unifiedCloudAuthEnabled
}
/**
* Re-mints the unified Cloud JWT once from the current Firebase identity and
* returns the fresh token, or `null` when there is nothing to retry with: no
* active unified session, or the re-mint failed. A permanent auth failure is
* surfaced + torn down inside `remintUnifiedOnce` (error toast + session clear,
* matching the proactive refresh path); the `catch` here only guards an
* unexpected throw (e.g. a chunk-load failure or no active Pinia), which it
* logs. Either way `null` makes the caller surface its original 401 unchanged.
*/
async function tryRemintToken(): Promise<string | null> {
try {
const { useWorkspaceAuthStore } =
await import('@/platform/workspace/stores/workspaceAuthStore')
return await useWorkspaceAuthStore().remintUnifiedOnce()
} catch (err) {
console.warn('Unified re-mint primitive threw unexpectedly:', err)
return null
}
}
/**
* Issues a `fetch` and, on a `401`, re-mints the unified Cloud JWT once and
* retries the request exactly once with the fresh token. A persistent `401`
* (or a `null` re-mint) surfaces the original Response unchanged — no retry
* loop. Requires a replayable body: a one-shot `ReadableStream` body cannot be
* replayed, so such a request surfaces its original `401` without a retry (no
* current cloud caller sends one).
*
* `shouldRetryOn401` is the caller's gate (see {@link shouldRemintCloudRequest}):
* flag-OFF traffic returns after a single `fetch` and never enters the re-mint
* path, so the legacy cascade stays untouched for instant rollback.
*/
export async function fetchWithUnifiedRemint(
input: RequestInfo | URL,
init: RequestInit,
shouldRetryOn401: boolean
): Promise<Response> {
const response = await fetch(input, init)
if (!shouldRetryOn401 || response.status !== 401) {
return response
}
if (init.body instanceof ReadableStream) {
console.warn(
'fetchWithUnifiedRemint: a ReadableStream body is not replayable; surfacing the original 401'
)
return response
}
const token = await tryRemintToken()
if (!token) {
return response
}
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
return fetch(input, { ...init, headers })
}
function isRetriableUnauthorized(
error: unknown
): error is AxiosError & { config: InternalAxiosRequestConfig } {
if (!axios.isAxiosError(error)) return false
const config = error.config
if (!config || config.__unifiedRetried || config.__skipUnifiedRemint) {
return false
}
return error.response?.status === 401
}
/**
* Installs a response interceptor that gives a cloud axios client the same
* reactive 401 guard as {@link fetchWithUnifiedRemint}: a single re-mint + a
* single retry on `401`, surfacing a persistent `401` unchanged. A strict
* no-op while `unified_cloud_auth` is OFF — the original error rejects exactly
* as it does today.
*/
export function attachUnifiedRemintInterceptor(client: AxiosInstance): void {
client.interceptors.response.use(
(response) => response,
async (error: unknown) => {
if (
!isRetriableUnauthorized(error) ||
!(await shouldRemintCloudRequest())
) {
throw error
}
const token = await tryRemintToken()
if (!token) {
throw error
}
// Clone (don't mutate) the caller's config so the re-minted Bearer never
// leaks into a caller-retained reference, matching fetchWithUnifiedRemint.
const { config } = error
const headers = new AxiosHeaders(config.headers)
headers.set('Authorization', `Bearer ${token}`)
return client.request({ ...config, headers, __unifiedRetried: true })
}
)
}

View File

@@ -59,6 +59,15 @@ vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
mockPerformSubscriptionCheckout(...args)
}))
const mockPerformTeamSubscriptionCheckout = vi.fn()
vi.mock(
'@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil',
() => ({
performTeamSubscriptionCheckout: (...args: unknown[]) =>
mockPerformTeamSubscriptionCheckout(...args)
})
)
const createI18nInstance = () =>
createI18n({
legacy: false,
@@ -73,6 +82,7 @@ const createI18nInstance = () =>
},
subscription: {
subscribeTo: 'Subscribe to {plan}',
teamPlan: { name: 'Team Plan' },
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
@@ -162,4 +172,24 @@ describe('CloudSubscriptionRedirectView', () => {
false
)
})
test('checks out the team plan via the workspace path with the chosen stop and cycle', async () => {
await mountView({ tier: 'team', stop: 'team_700', cycle: 'yearly' })
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly'
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
})
test('redirects to home for a team link with no stop', async () => {
await mountView({ tier: 'team', cycle: 'yearly' })
expect(mockRouterPush).toHaveBeenCalledWith('/')
expect(mockPerformTeamSubscriptionCheckout).not.toHaveBeenCalled()
})
})

View File

@@ -10,9 +10,19 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { performTeamSubscriptionCheckout } from '@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil'
import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
function isBillingCycle(value: string): value is BillingCycle {
return value === 'monthly' || value === 'yearly'
}
// Only paid personal tiers can be checked out via this redirect.
function isCheckoutTierKey(value: string): value is TierKey {
return ['standard', 'creator', 'pro', 'founder'].includes(value)
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
@@ -35,6 +45,12 @@ const tierDisplayName = computed(() => {
return names[selectedTierKey.value]
})
const isTeamCheckout = ref(false)
const planLabel = computed(() =>
isTeamCheckout.value ? t('subscription.teamPlan.name') : tierDisplayName.value
)
const runRedirect = wrapWithErrorHandlingAsync(async () => {
const rawType = route.query.tier
const rawCycle = route.query.cycle
@@ -58,21 +74,36 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
// Only paid tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
const billingCycle: BillingCycle = isBillingCycle(cycleParam)
? cycleParam
: 'monthly'
// Team is a per-credit plan picked on a slider, so it carries a `stop` (the
// chosen credit commitment) instead of a tier and checks out through the
// workspace billing endpoint rather than the personal one.
if (tierKeyParam === 'team') {
const rawStop = route.query.stop
const stopId =
typeof rawStop === 'string'
? rawStop
: Array.isArray(rawStop)
? rawStop[0]
: null
if (!stopId) {
await router.push('/')
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle)
return
}
if (!isCheckoutTierKey(tierKeyParam)) {
await router.push('/')
return
}
const tierKey = tierKeyParam as TierKey
selectedTierKey.value = tierKey
const validCycles: BillingCycle[] = ['monthly', 'yearly']
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
cycleParam = 'monthly'
}
selectedTierKey.value = tierKeyParam
if (!isInitialized.value) {
await initialize()
@@ -81,11 +112,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(
tierKey,
cycleParam as BillingCycle,
false
)
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
}
}, reportError)
@@ -105,18 +132,18 @@ onMounted(() => {
class="size-16"
/>
<p
v-if="selectedTierKey"
v-if="planLabel"
class="font-inter text-base/normal font-normal text-base-foreground"
>
{{
t('subscription.subscribeTo', {
plan: tierDisplayName
plan: planLabel
})
}}
</p>
<ProgressSpinner v-if="selectedTierKey" class="size-8" stroke-width="4" />
<ProgressSpinner v-if="planLabel" class="size-8" stroke-width="4" />
<Button
v-if="selectedTierKey"
v-if="planLabel"
as="a"
href="/"
link

View File

@@ -0,0 +1,373 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceApi'
type Balance = Pick<
BalanceInfo,
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
>
type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
tier: SubscriptionInfo['tier'] | 'TEAM'
}
type TeamStop = CurrentTeamCreditStop
const state = vi.hoisted(() => ({
balance: null as Balance | null,
subscription: null as Subscription | null,
isActiveSubscription: false,
isFreeTier: false,
currentTeamCreditStop: null as TeamStop | null,
isLoading: false,
canTopUp: true,
fetchBalance: vi.fn(),
fetchStatus: vi.fn(),
showPricingTable: vi.fn(),
showTopUpCreditsDialog: vi.fn(),
trackAddApiCreditButtonClicked: vi.fn(),
toastErrorHandler: vi.fn()
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) =>
async (...args: TArgs): Promise<TReturn | undefined> => {
try {
return await action(...args)
} catch (e) {
state.toastErrorHandler(e)
}
}
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
subscription: computed(() => state.subscription),
isActiveSubscription: computed(() => state.isActiveSubscription),
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
isLoading: computed(() => state.isLoading),
fetchBalance: state.fetchBalance,
fetchStatus: state.fetchStatus
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({ canTopUp: state.canTopUp }))
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ showPricingTable: state.showPricingTable })
})
)
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: state.showTopUpCreditsDialog
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
totalCredits: 'Total credits',
remaining: 'remaining',
refreshCredits: 'Refresh credits',
monthly: 'Monthly',
refillsDate: 'Refills {date}',
refillsNextCycle: 'Refills next cycle',
creditsUsed: '{used} used',
creditsLeftOfTotal: '{remaining} left of {total}',
monthlyUsageProgress: '{used} of {total} monthly credits used',
additionalCreditsInfo: 'About additional credits',
additionalCreditsTooltip: 'Credits you add on top of your plan.',
additionalCredits: 'Additional credits',
additionalCreditsInUse: 'In use',
usedAfterMonthly: 'Used after monthly runs out',
monthlyCreditsUsedUpTitle:
'Monthly credits are used up. Refills {date}',
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
monthlyCreditsUsedUpDescription:
"You're now spending additional credits.",
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
}
}
}
})
function renderTile(props: Record<string, unknown> = {}) {
return render(CreditsTile, {
props,
global: {
plugins: [i18n],
directives: { tooltip: () => {} },
stubs: {
Button: {
template:
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
},
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
}
}
})
}
function activeProSubscription() {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: '2026-02-20T12:00:00Z'
}
// amountMicros are cents; centsToCredits multiplies by 2.11.
state.balance = {
amountMicros: 500, // -> 1,055 total
cloudCreditBalanceMicros: 200, // -> 422 monthly remaining
prepaidBalanceMicros: 300 // -> 633 additional
}
}
describe('CreditsTile', () => {
beforeEach(() => {
state.balance = null
state.subscription = null
state.isActiveSubscription = false
state.isFreeTier = false
state.currentTeamCreditStop = null
state.isLoading = false
state.canTopUp = true
vi.clearAllMocks()
})
it('renders the total balance (cents converted to credits) with the remaining suffix', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('1,055')
expect(container.textContent).toContain('remaining')
})
it('renders the monthly usage bar and additional breakdown', () => {
activeProSubscription()
const { container } = renderTile()
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
expect(container.textContent).toContain('Monthly')
expect(container.textContent).toMatch(/Refills Feb/)
expect(container.textContent).toContain('20,678 used')
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).toContain('Additional credits')
expect(container.textContent).toContain('633')
expect(container.textContent).toContain('Used after monthly runs out')
})
it('renders a compact monthly summary for narrow containers', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('422 left of 21K')
})
it('uses the team credit stop monthly grant for the monthly total', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'TEAM',
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.currentTeamCreditStop = {
id: 'team_2500',
credits_monthly: 527500,
stop_usd: 2500
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Monthly total is the stop's raw monthly grant, not the tier fallback,
// and is not multiplied by 12 for annual billing.
expect(container.textContent).toContain('422 left of 527,500')
})
it('uses the per-month nominal grant for an annual personal tier', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Annual billing still grants the monthly nominal (21,100), not 12x.
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).not.toContain('253,200')
})
it('falls back to a dateless refills label when renewal date is missing', () => {
activeProSubscription()
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
const { container } = renderTile()
expect(container.textContent).toContain('Refills next cycle')
expect(container.textContent).not.toContain('Refills Feb')
})
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
activeProSubscription()
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: 'not-a-date'
}
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain("You're out of credits")
expect(container.textContent).not.toContain('Credits refill')
})
it('hides the breakdown and forces zeros in the zero state', () => {
activeProSubscription()
const { container } = renderTile({ zeroState: true })
expect(container.textContent).toContain('0')
expect(container.textContent).not.toContain('left of')
expect(container.textContent).not.toContain('Additional credits')
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows only the balance with no breakdown when there is no active subscription', () => {
state.isActiveSubscription = false
state.balance = { amountMicros: 500 }
const { container } = renderTile()
expect(container.textContent).toContain('1,055')
expect(container.textContent).not.toContain('left of')
expect(container.textContent).not.toContain('Additional credits')
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
expect(screen.queryByText('In use')).toBeNull()
})
it('flags spending of additional credits once the monthly allowance is depleted', () => {
activeProSubscription()
state.balance = {
amountMicros: 300,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 300
}
const { container } = renderTile()
expect(container.textContent).toContain(
'Monthly credits are used up. Refills Feb 20'
)
expect(container.textContent).toContain(
"You're now spending additional credits."
)
expect(screen.getByText('In use')).toBeTruthy()
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
})
it('emphasizes add-credits when fully out of credits', () => {
activeProSubscription()
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain(
"You're out of credits. Credits refill Feb 20"
)
expect(container.textContent).toContain(
'Add more credits to continue generating.'
)
expect(screen.queryByText('In use')).toBeNull()
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
})
it('suppresses the depletion notice until the balance has loaded', () => {
activeProSubscription()
state.balance = null
state.isLoading = true
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
})
it('routes add-credits through telemetry + the top-up dialog', async () => {
activeProSubscription()
renderTile()
await userEvent.click(screen.getByText('Add credits'))
expect(state.trackAddApiCreditButtonClicked).toHaveBeenCalledOnce()
expect(state.showTopUpCreditsDialog).toHaveBeenCalledOnce()
})
it('offers the upgrade path instead of add-credits on the free tier', async () => {
activeProSubscription()
state.isFreeTier = true
renderTile()
expect(screen.queryByText('Add credits')).toBeNull()
await userEvent.click(screen.getByText('Upgrade to add credits'))
expect(state.showPricingTable).toHaveBeenCalledOnce()
})
it('hides the action button when the user lacks the top-up permission', () => {
activeProSubscription()
state.canTopUp = false
renderTile()
expect(screen.queryByText('Add credits')).toBeNull()
expect(screen.queryByText('Upgrade to add credits')).toBeNull()
})
it('refreshes balance and status from the facade on mount and on demand', async () => {
activeProSubscription()
renderTile()
expect(state.fetchBalance).toHaveBeenCalledOnce()
expect(state.fetchStatus).toHaveBeenCalledOnce()
await userEvent.click(
screen.getByRole('button', { name: 'Refresh credits' })
)
expect(state.fetchBalance).toHaveBeenCalledTimes(2)
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
})
it('surfaces a failure toast when a refresh rejects', async () => {
activeProSubscription()
const failure = new Error('network down')
state.fetchBalance.mockRejectedValueOnce(failure)
renderTile()
await waitFor(() =>
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
)
})
})

View File

@@ -0,0 +1,371 @@
<template>
<div
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
:aria-label="$t('subscription.refreshCredits')"
@click="handleRefresh"
>
<i class="icon-[lucide--refresh-cw] size-4 text-text-secondary" />
</Button>
<div class="flex flex-col gap-1">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>
</div>
</div>
<template v-if="showBreakdown">
<div
v-if="emptyStateNotice"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-1">
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
<span class="text-muted">{{ emptyStateNotice.description }}</span>
</div>
</div>
<div
v-if="showBar"
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
>
<div class="flex items-center justify-between text-sm">
<span class="text-text-primary">{{
$t('subscription.monthly')
}}</span>
<span class="text-muted">
{{ refillsLabel }}
</span>
</div>
<div
role="progressbar"
:aria-valuenow="usage.used"
:aria-valuemin="0"
:aria-valuemax="monthlyTotalCredits ?? 0"
:aria-valuetext="monthlyUsageLabel"
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
class="h-full rounded-full bg-credit"
:style="{ width: usedBarWidth }"
/>
</div>
<div class="flex items-center justify-between gap-2 text-sm">
<Skeleton
v-if="isLoadingBalance"
class="@max-[300px]:hidden"
width="5rem"
height="1rem"
/>
<span v-else class="text-muted @max-[300px]:hidden">
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
</span>
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyBonusCredits,
total: monthlyTotalDisplay
})
}}
</span>
<span class="hidden @max-[180px]:inline">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyRemainingCompact,
total: monthlyTotalCompact
})
}}
</span>
</span>
</div>
</div>
<div class="h-px w-full bg-interface-stroke" />
<div class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-2 text-sm @max-[300px]:flex-col @max-[300px]:items-start"
>
<span class="flex items-center gap-1 text-text-primary">
{{ $t('subscription.additionalCredits') }}
<button
v-tooltip="{
value: $t('subscription.additionalCreditsTooltip'),
showDelay: 300
}"
type="button"
:aria-label="$t('subscription.additionalCreditsInfo')"
class="flex items-center text-muted"
>
<i class="icon-[lucide--info] size-4" />
</button>
<span
v-if="isSpendingAdditional"
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-2xs/none font-semibold text-base-background uppercase"
>
{{ $t('subscription.additionalCreditsInUse') }}
</span>
</span>
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
{{ displayPrepaid }}
</span>
</div>
<span class="text-sm text-muted @max-[300px]:hidden">
{{ $t('subscription.usedAfterMonthly') }}
</span>
</div>
</template>
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
variant="gradient"
size="lg"
class="w-full font-normal"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
size="lg"
:class="
cn(
'w-full font-normal',
!isOutOfCredits &&
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
zeroState?: boolean
}>()
const { locale, t } = useI18n()
const {
subscription,
balance,
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
fetchBalance,
fetchStatus
} = useBillingContext()
const {
monthlyBonusCredits,
prepaidCredits,
totalCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
} = useSubscriptionCredits()
const { permissions } = useWorkspaceUI()
const { showPricingTable } = useSubscriptionDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const tierKey = computed(() => {
const tier = subscription.value?.tier
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const monthlyTotalCredits = computed<number | null>(() => {
const teamStop = currentTeamCreditStop.value
if (teamStop) return teamStop.credits_monthly
return getTierCredits(tierKey.value)
})
const usage = computed(() =>
computeMonthlyUsage(
monthlyBonusCreditsValue.value,
monthlyTotalCredits.value ?? 0
)
)
const refillsDateShort = computed(() => {
const raw = subscription.value?.renewalDate
if (!raw) return ''
const date = new Date(raw)
return Number.isNaN(date.getTime())
? ''
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
})
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
const refillsLabel = computed(() =>
hasRefillsDate.value
? t('subscription.refillsDate', { date: refillsDateShort.value })
: t('subscription.refillsNextCycle')
)
const formatCreditCount = (value: number) =>
formatCredits({
value,
locale: locale.value,
numberOptions: { maximumFractionDigits: 0 }
})
const monthlyTotalDisplay = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : formatCreditCount(total)
})
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
const compactNumber = computed(
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
)
const monthlyRemainingCompact = computed(() =>
compactNumber.value.format(monthlyBonusCreditsValue.value)
)
const monthlyTotalCompact = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : compactNumber.value.format(total)
})
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
const usedBarWidth = computed(
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
)
const monthlyUsageLabel = computed(() =>
t('subscription.monthlyUsageProgress', {
used: usedDisplay.value,
total: monthlyTotalDisplay.value
})
)
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
const showBar = computed(
() =>
showBreakdown.value &&
monthlyTotalCredits.value !== null &&
monthlyTotalCredits.value > 0
)
const showActionButton = computed(
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
)
const isMonthlyDepleted = computed(
() =>
showBar.value &&
!isLoadingBalance.value &&
balance.value != null &&
monthlyBonusCreditsValue.value <= 0
)
const isOutOfCredits = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
)
const isSpendingAdditional = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
)
const emptyStateNotice = computed(() => {
if (isOutOfCredits.value) {
return {
title: hasRefillsDate.value
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
: t('subscription.outOfCreditsTitleNoDate'),
description: t('subscription.outOfCreditsDescription')
}
}
if (isMonthlyDepleted.value) {
return {
title: hasRefillsDate.value
? t('subscription.monthlyCreditsUsedUpTitle', {
date: refillsDateShort.value
})
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
description: t('subscription.monthlyCreditsUsedUpDescription')
}
}
return null
})
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
}
async function handleWindowFocus() {
if (consumePendingTopup()) {
await handleRefresh()
}
}
useEventListener(window, 'focus', () => void handleWindowFocus())
onMounted(handleRefresh)
</script>

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
renewalDate: mockRenewalDate
}))
}))
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(FreeTierDialogContent, {
global: {
plugins: [i18n]
}
})
}
describe('FreeTierDialogContent', () => {
it('renders the next refresh line formatted from the facade renewalDate', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent()
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('hides the next refresh line when renewalDate is null', () => {
mockRenewalDate.value = null
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -102,9 +102,9 @@
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
@@ -116,7 +116,17 @@ defineEmits<{
upgrade: []
}>()
const { formattedRenewalDate } = useSubscription()
const { renewalDate } = useBillingContext()
const formattedRenewalDate = computed(() => {
if (!renewalDate.value) return ''
return new Date(renewalDate.value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
async function flushPromises() {
@@ -23,10 +24,8 @@ function createDeferredPromise<T>() {
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
@@ -65,13 +64,25 @@ Object.defineProperty(globalThis, 'localStorage', {
writable: true
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null)
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
tier: computed(() => mockSubscriptionTier.value),
subscription: computed(() =>
mockSubscriptionTier.value
? {
isActive: mockIsActiveSubscription.value,
tier: mockSubscriptionTier.value,
duration: mockSubscriptionDuration.value,
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true
}
: null
)
})
}))
@@ -217,7 +228,7 @@ describe('PricingTable', () => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockSubscriptionDuration.value = 'MONTHLY'
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
@@ -362,6 +373,7 @@ describe('PricingTable', () => {
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'ANNUAL'
renderComponent()
await flushPromises()
@@ -370,12 +382,29 @@ describe('PricingTable', () => {
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeDefined()
expect(currentPlanButton).toBeDisabled()
await userEvent.click(currentPlanButton!)
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'MONTHLY'
renderComponent()
await flushPromises()
const currentPlanButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeUndefined()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false

View File

@@ -30,9 +30,9 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-white"
>
-20%
{{ t('subscription.saveYearly') }}
</div>
</div>
</template>
@@ -67,15 +67,15 @@
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground"
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground tabular-nums"
>
${{ getPrice(tier) }}
<span
v-show="currentBillingCycle === 'yearly'"
class="text-2xl text-muted-foreground line-through"
>
${{ tier.pricing.monthly }}
</span>
${{ getPrice(tier) }}
</span>
<span class="font-inter text-xl/normal text-base-foreground">
{{ t('subscription.usdPerMonth') }}
@@ -122,9 +122,12 @@
}}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
aria-hidden="true"
/>
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
>
{{ n(getCreditsDisplay(tier)) }}
</span>
@@ -136,7 +139,7 @@
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
>
{{ tier.maxDuration }}
</span>
@@ -186,7 +189,7 @@
</div>
</div>
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
>
~{{ n(tier.pricing.videoEstimate) }}
</span>
@@ -263,8 +266,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
TIER_PRICING,
TIER_TO_KEY
@@ -361,9 +364,13 @@ const tiers: PricingTierConfig[] = [
const {
isActiveSubscription,
isFreeTier,
subscriptionTier,
isYearlySubscription
} = useSubscription()
tier: subscriptionTier,
subscription
} = useBillingContext()
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const telemetry = useTelemetry()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()

View File

@@ -16,7 +16,6 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@comfyorg/tailwind-utils'
@@ -38,8 +37,8 @@ const emit = defineEmits<{
subscribed: []
}>()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const { isActiveSubscription, showSubscriptionDialog, tier } =
useBillingContext()
const isAwaitingStripeSubscription = ref(false)
watch(
@@ -54,7 +53,7 @@ watch(
const handleSubscribe = () => {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
current_tier: tier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()

View File

@@ -132,6 +132,19 @@ const i18n = createI18n({
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
monthlyCreditsLabel: 'monthly credits',
maxDurationLabel: 'max run duration',
maxDuration: {
free: '5 min',
standard: '30 min',
creator: '30 min',
pro: '1 hr',
founder: '30 min'
},
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs',
membersLabel: '{count} members',
tiers: {
founder: {
name: "Founder's Edition",
@@ -200,6 +213,7 @@ function createComponent(overrides = {}) {
CloudBadge: true,
SubscribeButton: true,
SubscriptionBenefits: true,
CreditsTile: true,
Button: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
@@ -240,7 +254,6 @@ describe('SubscriptionPanel', () => {
mockIsActiveSubscription.value = true
const { container } = createComponent()
expect(container.textContent).toContain('Manage Subscription')
expect(container.textContent).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
@@ -249,7 +262,6 @@ describe('SubscriptionPanel', () => {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('subscribe-button-stub')).not.toBeNull()
expect(container.textContent).not.toContain('Manage Subscription')
expect(container.textContent).not.toContain('Add Credits')
})
it('shows renewal date for active non-cancelled subscription', () => {
@@ -266,58 +278,19 @@ describe('SubscriptionPanel', () => {
expect(container.textContent).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier correctly', () => {
it('displays FOUNDERS_EDITION tier without the custom-LoRA perk', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const { container } = createComponent()
expect(container.textContent).toContain("Founder's Edition")
expect(container.textContent).toContain('5,460')
expect(container.textContent).toContain('RTX 6000 Pro (96GB VRAM)')
expect(container.textContent).not.toContain('Import your own LoRAs')
})
it('displays CREATOR tier correctly', () => {
it('displays CREATOR tier with the custom-LoRA perk', () => {
mockSubscriptionTier.value = 'CREATOR'
const { container } = createComponent()
expect(container.textContent).toContain('Creator')
expect(container.textContent).toContain('7,400')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const { container } = createComponent()
expect(container.textContent).toContain('10.00 Credits')
expect(container.textContent).toContain('5.00 Credits')
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
createComponent()
expect(
screen.getAllByRole('status', { name: 'Loading' }).length
).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
createComponent()
expect(screen.queryAllByRole('status', { name: 'Loading' })).toHaveLength(
0
)
})
it('renders refill date with literal slashes', () => {
vi.useFakeTimers()
vi.stubEnv('TZ', 'UTC')
try {
mockIsActiveSubscription.value = true
const { container } = createComponent()
expect(container.textContent).toMatch(
/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/
)
expect(container.textContent).not.toContain('&#x2F;')
} finally {
vi.useRealTimers()
vi.unstubAllEnvs()
}
expect(container.textContent).toContain('Import your own LoRAs')
})
})
@@ -335,15 +308,6 @@ describe('SubscriptionPanel', () => {
await userEvent.click(supportButton)
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
createComponent()
const refreshButton = screen.getByRole('button', {
name: 'Refresh credits'
})
await userEvent.click(refreshButton)
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe('loading states', () => {
@@ -353,14 +317,5 @@ describe('SubscriptionPanel', () => {
const supportButton = findButtonByText('Message Support')
expect(supportButton).toBeDisabled()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
createComponent()
const refreshButton = screen.getByRole('button', {
name: 'Refresh credits'
})
expect(refreshButton).toBeDisabled()
})
})
})

View File

@@ -65,100 +65,8 @@
</div>
<div class="flex flex-col gap-6 pt-9 lg:flex-row">
<div class="flex shrink-0 flex-col">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
:aria-label="$t('subscription.refreshCredits')"
@click="handleRefresh"
>
<i class="pi pi-sync text-sm text-text-secondary" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 text-left align-middle font-bold">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 text-left align-middle font-bold">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex flex-col gap-3">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-muted underline"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription && isFreeTier"
variant="gradient"
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else-if="isActiveSubscription"
variant="secondary"
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
<div class="w-full lg:max-w-md">
<CreditsTile />
</div>
<div class="flex flex-col gap-2">
@@ -207,26 +115,23 @@
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { cn } from '@comfyorg/tailwind-utils'
const authActions = useAuthActions()
const { t, n } = useI18n()
@@ -239,12 +144,10 @@ const {
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog, showPricingTable } =
useSubscriptionDialog()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
@@ -255,89 +158,11 @@ const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
function handleUpgradeToAddCredits() {
showPricingTable()
}
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
const { handleRefresh } = useSubscriptionActions()
</script>
<style scoped>

View File

@@ -81,6 +81,42 @@ describe('useBillingPlans', () => {
expect(currentPlanSlug.value).toBeNull()
})
it('populates teamCreditStops from the response', async () => {
const stops = {
default_stop_index: 2,
stops: [
{
id: 'team_700',
credits: 147_700,
monthly: { list_price_cents: 70_000, price_cents: 66_500 },
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
}
]
}
mockGetBillingPlans.mockResolvedValue({
plans: [buildPlan()],
team_credit_stops: stops
})
const useBillingPlans = await importUseBillingPlans()
const { fetchPlans, teamCreditStops } = useBillingPlans()
await fetchPlans()
expect(teamCreditStops.value).toEqual(stops)
})
it('leaves teamCreditStops null when the response omits it', async () => {
mockGetBillingPlans.mockResolvedValue({ plans: [buildPlan()] })
const useBillingPlans = await importUseBillingPlans()
const { fetchPlans, teamCreditStops } = useBillingPlans()
await fetchPlans()
expect(teamCreditStops.value).toBeNull()
})
it('dedupes concurrent calls while a fetch is in flight', async () => {
let resolveFetch: (value: { plans: Plan[] }) => void = () => {}
mockGetBillingPlans.mockImplementation(

View File

@@ -0,0 +1,239 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePricingTableUrlLoader } from './usePricingTableUrlLoader'
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
const mockShowPricingTable = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
showPricingTable: mockShowPricingTable
})
})
)
const mockPermissions = vi.hoisted(() => ({
value: { canManageSubscriptionLifecycle: true }
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({ permissions: mockPermissions })
}))
const mockFetchMembers = vi.hoisted(() => vi.fn().mockResolvedValue([]))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
fetchMembers: mockFetchMembers
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
mockPermissions.value = { canManageSubscriptionLifecycle: true }
// clearAllMocks resets calls, not implementations, so restore the default
// (a test overrides fetchMembers to flip the gate mid-await).
mockFetchMembers.mockResolvedValue([])
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('does nothing when no pricing param present', async () => {
mockRouteQuery.value = {}
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('opens the pricing table for an original owner', async () => {
mockRouteQuery.value = { pricing: '1' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
it('reads the gate only after members finish loading', async () => {
mockRouteQuery.value = { pricing: '1' }
// The original owner becomes known only once the members list resolves;
// proves the loader awaits fetchMembers before reading the gate.
mockPermissions.value = { canManageSubscriptionLifecycle: false }
mockFetchMembers.mockImplementation(async () => {
mockPermissions.value = { canManageSubscriptionLifecycle: true }
return []
})
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).toHaveBeenCalledOnce()
})
it('opens on the team tab for ?pricing=team', async () => {
mockRouteQuery.value = { pricing: 'team' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: 'team'
})
})
it('opens on the personal tab for ?pricing=personal', async () => {
mockRouteQuery.value = { pricing: 'personal' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: 'personal'
})
})
it('is a silent no-op for a member or promoted owner', async () => {
mockRouteQuery.value = { pricing: '1' }
mockPermissions.value = { canManageSubscriptionLifecycle: false }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('denies, strips, and clears together when the user is not eligible', async () => {
mockRouteQuery.value = { pricing: '1', other: 'param' }
mockPermissions.value = { canManageSubscriptionLifecycle: false }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'
)
})
it('restores preserved query and opens the table', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
pricing: '1'
})
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'pricing'
)
expect(mockShowPricingTable).toHaveBeenCalledOnce()
})
it('strips but does not open for an empty param', async () => {
mockRouteQuery.value = { pricing: '' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'
)
})
it('strips but does not open for a non-string param', async () => {
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
it('opens the default tab for an unrecognized pricing value', async () => {
mockRouteQuery.value = { pricing: 'garbage' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).toHaveBeenCalledWith({
reason: 'deep_link',
planMode: undefined
})
})
it('strips and clears, then propagates a members-fetch failure', async () => {
mockRouteQuery.value = { pricing: '1' }
mockFetchMembers.mockRejectedValue(new Error('listMembers failed'))
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await expect(loadPricingTableFromUrl()).rejects.toThrow(
'listMembers failed'
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'
)
})
})

View File

@@ -0,0 +1,72 @@
import { useRoute, useRouter } from 'vue-router'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.PRICING
/**
* Opens the pricing table from a `?pricing=` deep link, to send pilot users
* straight to subscribe. Values: `1` (default tab), `team`, `personal`.
*
* Gated to the original owner (`canManageSubscriptionLifecycle`); a member or
* promoted owner is a silent no-op with the param stripped. Survives the login
* redirect via the preserved-query system, like the invite URL loader.
*/
export function usePricingTableUrlLoader() {
const route = useRoute()
const router = useRouter()
const subscriptionDialog = useSubscriptionDialog()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()
/** Reads `?pricing=`, strips it, and opens the table when the gate allows. */
async function loadPricingTableFromUrl() {
hydratePreservedQuery(NAMESPACE)
const query =
mergePreservedQueryIntoQuery(NAMESPACE, route.query) ?? route.query
const param = query.pricing
if (param === undefined) return
// Strip any present pricing param (even ineligible or malformed values) and
// write the clean URL in a single replace before any await, so a clean URL
// is guaranteed even if the replace rejects or the gate later denies.
const cleanQuery = { ...query }
delete cleanQuery.pricing
router.replace({ query: cleanQuery }).catch((error) => {
console.warn(
'[usePricingTableUrlLoader] Failed to clean URL params:',
error
)
})
clearPreservedQuery(NAMESPACE)
// Only a non-empty string value opens the table; an empty/array param just
// gets stripped above.
if (typeof param !== 'string' || !param) return
// Load members before reading the gate so the original-owner self-row is
// present. fetchMembers always awaits the request; ensureMembersLoaded can
// early-return on a cached/in-flight load and let the gate read empty members.
await workspaceStore.fetchMembers()
if (!permissions.value.canManageSubscriptionLifecycle) return
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}
return {
loadPricingTableFromUrl
}
}

View File

@@ -9,8 +9,10 @@ import {
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
@@ -54,6 +56,7 @@ function useSubscriptionInternal() {
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const { flags } = useFeatureFlags()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -247,7 +250,7 @@ function useSubscriptionInternal() {
/**
* Whether cloud subscription mode is enabled (cloud distribution with subscription_required config).
* Use to determine which UI to show (SubscriptionPanel vs LegacyCreditsPanel).
* Use to determine which UI to show (SubscriptionPanel vs CreditsPanel).
*/
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
@@ -324,11 +327,12 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetch(
const response = await fetchWithUnifiedRemint(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers
}
},
isCloud && flags.unifiedCloudAuthEnabled
)
if (!response.ok) {
@@ -414,13 +418,14 @@ function useSubscriptionInternal() {
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
const response = await fetchWithUnifiedRemint(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
body: JSON.stringify(checkoutAttribution)
}
},
isCloud && flags.unifiedCloudAuthEnabled
)
if (!response.ok) {

View File

@@ -2,26 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
fetchBalance: mockAuthFetchBalance
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockBillingFetchBalance,
fetchStatus: mockFetchStatus
})
}))
@@ -119,20 +119,21 @@ describe('useSubscriptionActions', () => {
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
it('should refresh balance and status through the billing facade', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
it('swallows refresh failures without surfacing a toast', async () => {
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,6 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -11,10 +10,9 @@ import { useCommandStore } from '@/stores/commandStore'
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus } = useBillingContext()
const { fetchBalance, fetchStatus } = useBillingContext()
const isLoadingSupport = ref(false)
@@ -44,7 +42,7 @@ export function useSubscriptionActions() {
const handleRefresh = async () => {
try {
await Promise.all([authActions.fetchBalance(), fetchStatus()])
await Promise.all([fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}

View File

@@ -102,6 +102,28 @@ describe('useSubscriptionCredits', () => {
})
})
describe('numeric credit values (micros-as-cents)', () => {
it('converts the monthly and prepaid balance fields from cents to credits (×2.11)', () => {
mockBillingBalance = {
amountMicros: 500,
cloudCreditBalanceMicros: 200,
prepaidBalanceMicros: 300
}
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
useSubscriptionCredits()
expect(monthlyBonusCreditsValue.value).toBe(422)
expect(prepaidCreditsValue.value).toBe(633)
})
it('defaults missing fields to zero', () => {
mockBillingBalance = { amountMicros: 100 }
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
useSubscriptionCredits()
expect(monthlyBonusCreditsValue.value).toBe(0)
expect(prepaidCreditsValue.value).toBe(0)
})
})
describe('isLoadingBalance', () => {
it('should reflect billingContext.isLoading', () => {
mockBillingIsLoading = true

View File

@@ -1,7 +1,10 @@
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import {
centsToCredits,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useBillingContext } from '@/composables/billing/useBillingContext'
/**
@@ -50,10 +53,23 @@ export function useSubscriptionCredits() {
const isLoadingBalance = computed(() => toValue(billingContext.isLoading))
const creditsFromMicros = (maybeCents: number | undefined): number =>
centsToCredits(maybeCents ?? 0)
const monthlyBonusCreditsValue = computed(() =>
creditsFromMicros(toValue(billingContext.balance)?.cloudCreditBalanceMicros)
)
const prepaidCreditsValue = computed(() =>
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
)
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
}
}

View File

@@ -43,12 +43,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isFreeTier: mockIsFreeTier
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
@@ -65,6 +59,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
@@ -115,10 +110,8 @@ describe('useSubscriptionDialog', () => {
expect(mockShowLayoutDialog).toHaveBeenCalled()
})
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
mockTeamWorkspacesEnabled.value = true
// Unified table is workspace-type-agnostic (Jun-5 model): same path for
// a personal-plan or team-plan workspace.
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -207,6 +200,43 @@ describe('useSubscriptionDialog', () => {
})
})
describe('show', () => {
it('opens the free-tier dialog for a free-tier personal user', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'free-tier-info' })
)
})
it('falls back to the pricing table for a non-free-tier user', () => {
mockIsFreeTier.value = false
const { show } = useSubscriptionDialog()
show()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'subscription-required' })
)
})
it('falls back to the pricing table for a free-tier team workspace', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = false
const { show } = useSubscriptionDialog()
show()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'subscription-required' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {
it('closes existing dialogs before opening team workspace dialog', () => {
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)

View File

@@ -3,7 +3,6 @@ import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -16,6 +15,7 @@ export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
export interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
@@ -33,7 +33,6 @@ export const useSubscriptionDialog = () => {
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()
const { isFreeTier } = useSubscription()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -159,6 +158,10 @@ export const useSubscriptionDialog = () => {
}
function show(options?: SubscriptionDialogOptions) {
// Free-tier state comes from the unified facade so it works on both the
// legacy (/customers) and workspace (/api/billing) paths. Resolved lazily
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
const component = defineAsyncComponent(
() =>

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
getStopDiscountedMonthlyUsd,
getTeamPlanSlug,
mapApiTeamCreditStops
} from './teamPlanCreditStops'
describe('mapApiTeamCreditStops', () => {
it('derives usd, credits, discount and carries the backend id', () => {
const mapped = mapApiTeamCreditStops([
{
id: 'team_700',
credits: 147_700,
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
}
])
expect(mapped).toEqual([
{
id: 'team_700',
usd: 700,
credits: 147_700,
discountPercentYearly: 10
}
])
})
it('returns a 0% discount when the list price is zero', () => {
const mapped = mapApiTeamCreditStops([
{
id: 'team_free',
credits: 0,
yearly: { list_price_cents: 0, price_cents: 0 }
}
])
expect(mapped[0].discountPercentYearly).toBe(0)
})
})
describe('getStopDiscountedMonthlyUsd', () => {
it('applies the full yearly discount for the yearly cycle', () => {
expect(
getStopDiscountedMonthlyUsd(
{ usd: 700, discountPercentYearly: 10 },
'yearly'
)
).toBe(630)
})
it('halves the discount for the monthly cycle', () => {
expect(
getStopDiscountedMonthlyUsd(
{ usd: 700, discountPercentYearly: 10 },
'monthly'
)
).toBe(665)
})
it('reads the stop discount so backend-driven stops are honored', () => {
expect(
getStopDiscountedMonthlyUsd(
{ usd: 1000, discountPercentYearly: 25 },
'yearly'
)
).toBe(750)
})
})
describe('getTeamPlanSlug', () => {
it('maps the billing cycle to the per-credit team plan slug', () => {
expect(getTeamPlanSlug('monthly')).toBe('team_per_credit_monthly')
expect(getTeamPlanSlug('yearly')).toBe('team_per_credit_annual')
})
})

View File

@@ -1,4 +1,7 @@
export interface CreditStop {
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Present for
* API-sourced stops; absent only for the hardcoded OSS / pre-deploy fallback. */
id?: string
/** Monthly subscription price in USD (pre-discount). */
usd: number
/** Monthly credit grant at this stop. */
@@ -17,6 +20,9 @@ export interface CreditStop {
/** A selected slider stop, as emitted by the pricing table's team column. */
export interface TeamPlanSelection {
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Present for
* API-sourced stops; absent only for the hardcoded OSS / pre-deploy fallback. */
id?: string
/** Pre-discount monthly price in USD (the struck-through list price). */
usd: number
/** Monthly credit grant at this stop. */
@@ -26,17 +32,14 @@ export interface TeamPlanSelection {
}
/**
* Team-plan credit-subscription slider stops.
* Team-plan credit-subscription slider stops — OSS / pre-deploy fallback.
*
* Hardcoded per Figma DES-197 (Updates to PricingTable dialog): the team-plan
* credit slider snaps to exactly these 5 fixed breakpoints — the user cannot
* select a value in between. The `credits` figures equal `usdToCredits(usd)` at
* the current rate (`CREDITS_PER_USD = 211`); a unit test guards against rate
* drift silently changing the designed values.
*
* TODO(FE-934): once the backend slider contract lands, these stops (and their
* discount tiers) will come from `GET /api/billing/plans` instead of being
* hardcoded here.
* The live set comes from `GET /api/billing/plans → team_credit_stops` (mapped
* via `mapApiTeamCreditStops`); these hardcoded DES-197 breakpoints render only
* when the API doesn't supply them. The slider snaps to exactly these 5 fixed
* breakpoints — the user cannot select a value in between. The `credits` figures
* equal `usdToCredits(usd)` at the current rate (`CREDITS_PER_USD = 211`); a unit
* test guards against rate drift silently changing the designed values.
*/
export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
{ usd: 200, credits: 42_200, discountPercentYearly: 0 },
@@ -50,20 +53,58 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
/**
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
* the slider display and the checkout confirm step so the two never drift.
* Falls back to the list price when `usd` is not a known stop.
* Per-credit Team plan slug for a billing cadence (cloud catalog). The slug
* encodes the cadence; `POST /api/billing/subscribe` reads `plan_slug` +
* `team_credit_stop_id` and resolves all amounts server-side from the stop.
*/
export function getDiscountedMonthlyUsd(
usd: number,
export function getTeamPlanSlug(billingCycle: 'monthly' | 'yearly'): string {
return billingCycle === 'yearly'
? 'team_per_credit_annual'
: 'team_per_credit_monthly'
}
/**
* Map the backend `team_credit_stops` payload to the slider's `CreditStop[]`.
* The pre-discount monthly `usd` is the yearly list price; the yearly discount
* percent is derived from the struck (`list_price_cents`) vs discounted
* (`price_cents`) yearly figures. The backend `id` is carried so a selected stop
* can be sent on subscribe.
*/
export function mapApiTeamCreditStops(
stops: readonly {
id: string
credits: number
yearly: { list_price_cents: number; price_cents: number }
}[]
): CreditStop[] {
return stops.map((stop) => {
const listCents = stop.yearly.list_price_cents
const discountPercentYearly =
listCents > 0
? Math.round(((listCents - stop.yearly.price_cents) / listCents) * 100)
: 0
return {
id: stop.id,
usd: Math.round(listCents / 100),
credits: stop.credits,
discountPercentYearly
}
})
}
/**
* Discounted monthly price for a credit stop, applying the billing-cycle
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
* the slider display and the checkout confirm step so the two never drift, and
* it reads the stop's own discount so backend-driven stops are honored.
*/
export function getStopDiscountedMonthlyUsd(
stop: Pick<CreditStop, 'usd' | 'discountPercentYearly'>,
cycle: 'monthly' | 'yearly'
): number {
const stop = TEAM_PLAN_CREDIT_STOPS.find((s) => s.usd === usd)
if (!stop) return usd
const percent =
cycle === 'monthly'
? stop.discountPercentYearly / 2
: stop.discountPercentYearly
return Math.round(usd * (1 - percent / 100))
return Math.round(stop.usd * (1 - percent / 100))
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
describe('computeMonthlyUsage', () => {
it('reports the consumed portion of the monthly allowance', () => {
expect(computeMonthlyUsage(105_450, 200_000)).toEqual({
used: 94_550,
usedFraction: 0.47275
})
})
it('returns zero usage when the monthly allowance is unknown', () => {
expect(computeMonthlyUsage(100, 0)).toEqual({ used: 0, usedFraction: 0 })
})
it('treats a balance above the allowance (rollover) as nothing used', () => {
expect(computeMonthlyUsage(503_805, 253_200)).toEqual({
used: 0,
usedFraction: 0
})
})
it('caps the fill at a full bar once the allowance is exhausted', () => {
expect(computeMonthlyUsage(0, 200_000)).toEqual({
used: 200_000,
usedFraction: 1
})
})
it('caps used at the allowance when the remaining balance is negative', () => {
expect(computeMonthlyUsage(-50_000, 200_000)).toEqual({
used: 200_000,
usedFraction: 1
})
})
})

View File

@@ -0,0 +1,28 @@
export interface MonthlyCreditsUsage {
/** Credits consumed from the monthly allowance (never negative). */
used: number
/** Fraction (01) of the monthly allowance consumed — drives the bar fill. */
usedFraction: number
}
/**
* Computes monthly credit usage for the credits bar. The bar fills with the
* consumed portion of the monthly allowance; `used` clamps at zero so a balance
* that exceeds the nominal allowance (rolled-over credits) reads as nothing used.
*/
export function computeMonthlyUsage(
monthlyRemaining: number,
monthlyTotal: number
): MonthlyCreditsUsage {
if (monthlyTotal <= 0) {
return { used: 0, usedFraction: 0 }
}
const used = Math.min(
monthlyTotal,
Math.max(0, monthlyTotal - monthlyRemaining)
)
const usedFraction = Math.min(1, used / monthlyTotal)
return { used, usedFraction }
}

View File

@@ -0,0 +1,21 @@
import type { SubscriptionDuration } from '@/platform/workspace/api/workspaceApi'
import type { BillingCycle } from './subscriptionTierRank'
/** Backend plan duration `'ANNUAL'` maps to the FE's yearly billing cycle. */
export const isAnnualDuration = (
duration: SubscriptionDuration | undefined
): boolean => duration === 'ANNUAL'
/**
* Whether a checkout step renders as yearly. The preview's resolved plan
* duration wins; absent a preview (fresh subscribe with no proration) it falls
* back to the user's selected billing cycle.
*/
export const isYearlyCheckout = (
planDuration: SubscriptionDuration | undefined,
billingCycle: BillingCycle
): boolean =>
planDuration !== undefined
? isAnnualDuration(planDuration)
: billingCycle === 'yearly'

View File

@@ -1,7 +1,9 @@
import { storeToRefs } from 'pinia'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
@@ -70,13 +72,14 @@ export async function performSubscriptionCheckout(
}
const checkoutPayload = { ...checkoutAttribution }
const response = await fetch(
const response = await fetchWithUnifiedRemint(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(checkoutPayload)
}
},
isCloud && useFeatureFlags().flags.unifiedCloudAuthEnabled
)
if (!response.ok) {

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/config/comfyApi', () => ({
getComfyPlatformBaseUrl: () => 'https://app.test'
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { subscribe: mockSubscribe }
}))
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
describe('performTeamSubscriptionCheckout', () => {
let assignedHref: string | undefined
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
assignedHref = undefined
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: {
set href(value: string) {
assignedHref = value
}
}
})
})
it('subscribes at the stop with the yearly slug and redirects to the Stripe payment page', async () => {
mockSubscribe.mockResolvedValue({
status: 'needs_payment_method',
payment_method_url: 'https://stripe.test/pay',
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
cancelUrl: 'https://app.test/payment/failed',
teamCreditStopId: 'team_700'
})
expect(assignedHref).toBe('https://stripe.test/pay')
})
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
mockSubscribe.mockResolvedValue({
status: 'subscribed',
billing_op_id: 'op_2'
})
await performTeamSubscriptionCheckout('team_1400', 'monthly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_monthly', {
returnUrl: expect.any(String),
cancelUrl: expect.any(String),
teamCreditStopId: 'team_1400'
})
expect(assignedHref).toBe('/')
})
it('throws when payment is needed but no payment URL is returned', async () => {
mockSubscribe.mockResolvedValue({
status: 'needs_payment_method',
billing_op_id: 'op_3'
})
await expect(
performTeamSubscriptionCheckout('team_700', 'yearly')
).rejects.toThrow(/payment URL/)
expect(assignedHref).toBeUndefined()
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).not.toHaveBeenCalled()
expect(assignedHref).toBeUndefined()
})
})

View File

@@ -0,0 +1,50 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { isCloud } from '@/platform/distribution/types'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { BillingCycle } from './subscriptionTierRank'
/**
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
* link: subscribes to the per-credit Team plan at the chosen slider stop and
* sends the user straight to the Stripe payment page.
*
* Mirrors `performSubscriptionCheckout` (personal) but routes through the
* workspace billing endpoint (`POST /api/billing/subscribe`), because the
* per-credit Team plan lives there and the backend lets any workspace — personal
* included — subscribe to it. The slug encodes the cadence; the stop id is
* validated and priced server-side.
*
* Caller guards on `isCloud`, owns loading state, and wraps error handling. A
* `needs_payment_method` response is a full-page redirect to Stripe; the other
* statuses land back in the app, which polls the billing op to completion.
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle
): Promise<void> {
if (!isCloud) return
const planSlug = getTeamPlanSlug(billingCycle)
const response = await workspaceApi.subscribe(planSlug, {
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`,
teamCreditStopId
})
if (response.status === 'needs_payment_method') {
// A needs_payment_method response without a URL is unusable: surface it to
// the caller's error handling rather than silently dropping the user home
// with a subscription stuck mid-payment.
if (!response.payment_method_url) {
throw new Error(
'Team subscription needs a payment method but no payment URL was returned'
)
}
globalThis.location.href = response.payment_method_url
return
}
globalThis.location.href = '/'
}

View File

@@ -4,5 +4,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
SHARE: 'share',
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth'
OAUTH: 'oauth',
PRICING: 'pricing'
} as const

View File

@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -130,7 +130,7 @@ const {
getSearchResults
} = useSettingSearch()
const authActions = useAuthActions()
const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
@@ -238,7 +238,7 @@ watch(activeCategoryKey, (newKey, oldKey) => {
activeCategoryKey.value = oldKey
}
if (newKey === 'credits') {
void authActions.fetchBalance()
void fetchBalance()
}
if (newKey) {
void nextTick(() => {

View File

@@ -133,7 +133,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
)
}

View File

@@ -35,7 +35,8 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from './types'
/**
@@ -112,6 +113,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
this.dispatch((provider) => provider.trackWorkspaceInviteSent?.(metadata))
}
trackRunButton(properties: RunButtonProperties): void {
this.dispatch((provider) => provider.trackRunButton?.(properties))
}

View File

@@ -27,7 +27,8 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -182,6 +183,12 @@ export class GtmTelemetryProvider implements TelemetryProvider {
)
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
// GA4 names must be bare snake_case; the TelemetryEvents enum carries an
// `app:` prefix for Mixpanel/PostHog that dataLayer would forward verbatim.
this.pushEvent('workspace_invite_sent', metadata)
}
trackRunButton(properties: RunButtonProperties): void {
this.pushEvent('run_workflow', {
subscribe_to_run: properties.subscribe_to_run,

View File

@@ -39,7 +39,8 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -258,6 +259,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
this.trackEvent(TelemetryEvents.WORKSPACE_INVITE_SENT, metadata)
}
// Credit top-up tracking methods (composition with utility functions)
startTopupTracking(): void {
startTopupUtil()

View File

@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueModule from 'vue'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import { TelemetryEvents } from '../../types'
@@ -12,6 +15,10 @@ const hoisted = vi.hoisted(() => {
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
const refs = {
tier: null as unknown as Ref<string | null>,
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>
}
return {
mockCapture,
@@ -23,6 +30,7 @@ const hoisted = vi.hoisted(() => {
mockReset,
mockOnUserResolved,
mockOnUserLogout,
refs,
mockPosthog: {
default: {
init: mockInit,
@@ -36,14 +44,6 @@ const hoisted = vi.hoisted(() => {
}
})
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
watch: vi.fn()
}
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved,
@@ -51,21 +51,19 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const mockRemoteConfig = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
vi.mock('@/platform/remoteConfig/remoteConfig', async () => {
const { ref } = await vi.importActual<typeof VueModule>('vue')
hoisted.refs.remoteConfig = ref<Record<string, unknown> | null>(null)
return { remoteConfig: hoisted.refs.remoteConfig }
})
vi.mock('posthog-js', () => hoisted.mockPosthog)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
subscriptionTier: { value: null }
})
}))
vi.mock('@/composables/billing/useBillingContext', async () => {
const { ref } = await vi.importActual<typeof VueModule>('vue')
hoisted.refs.tier = ref<string | null>(null)
return { useBillingContext: () => ({ tier: hoisted.refs.tier }) }
})
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
@@ -82,7 +80,10 @@ function createProvider(
describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRemoteConfig.value = null
hoisted.refs.remoteConfig.value = null
// Fresh tier ref per test: each provider registers an undisposed tier
// watch, so a shared ref would leak watchers across tests.
hoisted.refs.tier = ref<string | null>(null)
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
@@ -116,7 +117,7 @@ describe('PostHogTelemetryProvider', () => {
})
it('applies posthog_config overrides from remote config', async () => {
mockRemoteConfig.value = {
hoisted.refs.remoteConfig.value = {
posthog_config: {
debug: true,
api_host: 'https://custom.host.com'
@@ -150,6 +151,48 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
})
function tierPropertySets(): unknown[] {
return hoisted.mockPeopleSet.mock.calls
.map(([props]) => props)
.filter((props) => props && 'subscription_tier' in props)
}
it('sets subscription_tier reactively when the facade tier resolves', async () => {
createProvider()
await vi.dynamicImportSettled()
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
onResolved({ id: 'user-123' })
// Unresolved tier (null) does not set the property
expect(tierPropertySets()).toHaveLength(0)
hoisted.refs.tier.value = 'PRO'
await nextTick()
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
subscription_tier: 'PRO'
})
hoisted.refs.tier.value = null
await nextTick()
expect(tierPropertySets()).toHaveLength(1)
})
it('keeps a single tier watcher across repeated user resolutions', async () => {
createProvider()
await vi.dynamicImportSettled()
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
onResolved({ id: 'user-1' })
onResolved({ id: 'user-1' })
onResolved({ id: 'user-2' })
hoisted.refs.tier.value = 'PRO'
await nextTick()
expect(tierPropertySets()).toHaveLength(1)
})
})
describe('desktop entry capture', () => {
@@ -670,7 +713,7 @@ describe('PostHogTelemetryProvider', () => {
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
const remoteBefore_send = vi.fn()
mockRemoteConfig.value = {
hoisted.refs.remoteConfig.value = {
posthog_config: {
before_send: remoteBefore_send,
person_profiles: 'always'

View File

@@ -1,10 +1,11 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -41,7 +42,8 @@ import type {
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
@@ -98,6 +100,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private isInitialized = false
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
private desktopEntryProps: DesktopEntryProps | null = null
private stopSubscriptionTierWatch: WatchStopHandle | null = null
constructor() {
this.configureDisabledEvents(
@@ -307,12 +310,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
private setSubscriptionProperties(): void {
const { subscriptionTier } = useSubscription()
watch(
subscriptionTier,
(tier) => {
if (tier && this.posthog) {
this.posthog.people.set({ subscription_tier: tier })
if (this.stopSubscriptionTierWatch) return
const { tier } = useBillingContext()
this.stopSubscriptionTierWatch = watch(
tier,
(value) => {
if (value && this.posthog) {
this.posthog.people.set({ subscription_tier: value })
}
},
{ immediate: true }
@@ -370,6 +374,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
this.trackEvent(TelemetryEvents.WORKSPACE_INVITE_SENT, metadata)
}
trackRunButton(properties: RunButtonProperties): void {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
consumePendingTopup,
startTopupTracking,
checkForCompletedTopup,
clearTopupTracking
@@ -227,4 +228,35 @@ describe('topupTracker', () => {
)
})
})
describe('consumePendingTopup', () => {
it('returns false and clears nothing when no marker exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)
expect(consumePendingTopup()).toBe(false)
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
})
it('clears and returns true for a fresh marker', () => {
mockLocalStorage.getItem.mockReturnValue(
(Date.now() - 5 * 60 * 1000).toString()
)
expect(consumePendingTopup()).toBe(true)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
})
it('clears and returns false for a marker older than 24 hours', () => {
mockLocalStorage.getItem.mockReturnValue(
(Date.now() - 25 * 60 * 60 * 1000).toString()
)
expect(consumePendingTopup()).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
})
})
})

View File

@@ -61,3 +61,16 @@ export function checkForCompletedTopup(
export function clearTopupTracking(): void {
localStorage.removeItem(STORAGE_KEY)
}
/**
* Consume a pending top-up marker on window focus. Clears the marker and
* reports whether a non-expired purchase was awaiting a balance refresh.
*/
export function consumePendingTopup(): boolean {
const timestampStr = localStorage.getItem(STORAGE_KEY)
if (!timestampStr) return false
localStorage.removeItem(STORAGE_KEY)
const timestamp = parseInt(timestampStr, 10)
return Date.now() - timestamp <= MAX_AGE_MS
}

View File

@@ -464,6 +464,11 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
ecommerce: EcommerceMetadata
}
export interface WorkspaceInviteMetadata extends Record<string, unknown> {
source: 'post_upgrade_success' | 'settings_members'
count: number
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -487,6 +492,7 @@ export interface TelemetryProvider {
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
trackRunButton?(properties: RunButtonProperties): void
// Credit top-up tracking (composition with internal utilities)
@@ -590,6 +596,7 @@ export const TelemetryEvents = {
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
WORKSPACE_INVITE_SENT: 'app:workspace_invite_sent',
BEGIN_CHECKOUT: 'begin_checkout',
// Onboarding Survey

View File

@@ -9,7 +9,8 @@ const {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn()
delete: vi.fn(),
interceptors: { response: { use: vi.fn() } }
},
mockGetAuthHeaderOrThrow: vi.fn(),
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
@@ -211,6 +212,27 @@ describe('workspaceApi', () => {
{ headers: AUTH_HEADER }
)
})
it('updateMemberRole() sends PATCH /workspace/members/:userId with the role', async () => {
const updated = {
id: 'user-42',
name: 'Jane',
email: 'jane@test.comfy.org',
joined_at: '2025-01-03T00:00:00Z',
role: 'owner',
is_original_owner: false
}
mockAxiosInstance.patch.mockResolvedValue({ data: updated })
const result = await workspaceApi.updateMemberRole('user-42', 'owner')
expect(mockAxiosInstance.patch).toHaveBeenCalledWith(
'/api/workspace/members/user-42',
{ role: 'owner' },
{ headers: AUTH_HEADER }
)
expect(result).toEqual(updated)
})
})
describe('invite management', () => {
@@ -265,7 +287,7 @@ describe('workspaceApi', () => {
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/invites/abc-token/accept',
null,
{ headers: AUTH_HEADER }
{ headers: AUTH_HEADER, __skipUnifiedRemint: true }
)
expect(result).toEqual(data)
})
@@ -334,18 +356,42 @@ describe('workspaceApi', () => {
const data = { billing_op_id: 'op-1', status: 'subscribed' }
mockAxiosInstance.post.mockResolvedValue({ data })
const result = await workspaceApi.subscribe(
'pro-monthly',
'https://return.url',
'https://cancel.url'
)
const result = await workspaceApi.subscribe('pro-monthly', {
returnUrl: 'https://return.url',
cancelUrl: 'https://cancel.url'
})
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/subscribe',
{
plan_slug: 'pro-monthly',
return_url: 'https://return.url',
cancel_url: 'https://cancel.url'
cancel_url: 'https://cancel.url',
team_credit_stop_id: undefined,
billing_cycle: undefined
},
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)
})
it('subscribe() sends team_credit_stop_id and billing_cycle for team plans', async () => {
const data = { billing_op_id: 'op-1b', status: 'needs_payment_method' }
mockAxiosInstance.post.mockResolvedValue({ data })
const result = await workspaceApi.subscribe('team_per_credit_annual', {
teamCreditStopId: 'team_700',
billingCycle: 'yearly'
})
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/subscribe',
{
plan_slug: 'team_per_credit_annual',
return_url: undefined,
cancel_url: undefined,
team_credit_stop_id: 'team_700',
billing_cycle: 'yearly'
},
{ headers: AUTH_HEADER }
)

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
WorkspaceId,
@@ -149,13 +150,32 @@ type SubscriptionTransitionType =
interface PreviewSubscribeRequest {
plan_slug: string
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
}
type SubscribeBillingCycle = 'monthly' | 'yearly'
interface SubscribeRequest {
plan_slug: string
idempotency_key?: string
return_url?: string
cancel_url?: string
/** Required for the per-credit Team plan; selects the slider stop. */
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
}
export interface SubscribeOptions {
returnUrl?: string
cancelUrl?: string
teamCreditStopId?: string
billingCycle?: SubscribeBillingCycle
}
export interface PreviewSubscribeOptions {
teamCreditStopId?: string
billingCycle?: SubscribeBillingCycle
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
@@ -321,6 +341,9 @@ const workspaceApiClient = axios.create({
}
})
// acceptInvite opts out via __skipUnifiedRemint (it is deliberately Firebase-authed).
attachUnifiedRemintInterceptor(workspaceApiClient)
async function getAuthHeaderOrThrow() {
return useAuthStore().getAuthHeaderOrThrow()
}
@@ -457,6 +480,24 @@ export const workspaceApi = {
}
},
/**
* Change a member's role (member ↔ owner).
* PATCH /api/workspace/members/:userId
*/
async updateMemberRole(userId: UserId, role: WorkspaceRole): Promise<Member> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.patch<Member>(
api.apiURL(`/workspace/members/${userId}`),
{ role },
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
@@ -519,7 +560,7 @@ export const workspaceApi = {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),
null,
{ headers }
{ headers, __skipUnifiedRemint: true }
)
return response.data
} catch (err) {
@@ -582,12 +623,19 @@ export const workspaceApi = {
* Preview subscription change
* POST /api/billing/preview-subscribe
*/
async previewSubscribe(planSlug: string): Promise<PreviewSubscribeResponse> {
async previewSubscribe(
planSlug: string,
options: PreviewSubscribeOptions = {}
): Promise<PreviewSubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PreviewSubscribeResponse>(
api.apiURL('/billing/preview-subscribe'),
{ plan_slug: planSlug } satisfies PreviewSubscribeRequest,
{
plan_slug: planSlug,
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
} satisfies PreviewSubscribeRequest,
{ headers }
)
return response.data
@@ -602,8 +650,7 @@ export const workspaceApi = {
*/
async subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
options: SubscribeOptions = {}
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
@@ -611,8 +658,10 @@ export const workspaceApi = {
api.apiURL('/billing/subscribe'),
{
plan_slug: planSlug,
return_url: returnUrl,
cancel_url: cancelUrl
return_url: options.returnUrl,
cancel_url: options.cancelUrl,
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
} satisfies SubscribeRequest,
{ headers }
)

View File

@@ -0,0 +1,192 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InviteMembersForm from './InviteMembersForm.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
const { mockCreateInvite, mockToastAdd, mockTrackInviteSent } = vi.hoisted(
() => ({
mockCreateInvite: vi.fn(),
mockToastAdd: vi.fn(),
mockTrackInviteSent: vi.fn()
})
)
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
createInvite: mockCreateInvite as (email: string) => Promise<PendingInvite>
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackWorkspaceInviteSent: mockTrackInviteSent
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function pendingInviteFor(email: string): PendingInvite {
return {
id: `inv-${email}`,
email,
inviteDate: new Date(0),
expiryDate: new Date(0)
}
}
function renderForm(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const result = render(InviteMembersForm, {
props: {
submitLabel: 'Send invites',
placeholder: 'Enter emails',
source: 'post_upgrade_success',
...props
},
global: { plugins: [i18n] }
})
return { ...result, user }
}
function emailInput() {
return screen.getByRole('textbox')
}
function submitButton() {
return screen.getByRole('button', { name: 'Send invites' })
}
describe('InviteMembersForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateInvite.mockImplementation(async (email: string) =>
pendingInviteFor(email)
)
})
it('turns comma- and enter-delimited input into chips', async () => {
const { user } = renderForm()
await user.type(emailInput(), 'a@b.com,')
await user.type(emailInput(), 'c@d.com{Enter}')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
})
it('disables submit with no chips and flags invalid emails', async () => {
const { user } = renderForm()
expect(submitButton()).toBeDisabled()
await user.type(emailInput(), 'not-an-email{Enter}')
expect(screen.getByText('not-an-email')).toBeInTheDocument()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.invalidEmailCount')
).toBeInTheDocument()
expect(submitButton()).toBeDisabled()
})
it('creates an invite per email, tracks telemetry, and emits submitted', async () => {
const { user, emitted } = renderForm()
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
await user.click(submitButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(mockCreateInvite).toHaveBeenCalledWith('a@b.com')
expect(mockCreateInvite).toHaveBeenCalledWith('c@d.com')
expect(mockTrackInviteSent).toHaveBeenCalledWith({
source: 'post_upgrade_success',
count: 2
})
expect(emitted().submitted).toEqual([[['a@b.com', 'c@d.com']]])
})
it('keeps failed emails as chips, toasts, and emits the invited subset on partial failure', async () => {
mockCreateInvite.mockImplementation(async (email: string) => {
if (email === 'fail@x.com') throw new Error('nope')
return pendingInviteFor(email)
})
const { user, emitted } = renderForm()
await user.type(emailInput(), 'ok@x.com,fail@x.com{Enter}')
await user.click(submitButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(screen.getByText('fail@x.com')).toBeInTheDocument()
expect(screen.queryByText('ok@x.com')).not.toBeInTheDocument()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(emitted().submitted).toEqual([[['ok@x.com']]])
expect(mockTrackInviteSent).toHaveBeenCalledWith({
source: 'post_upgrade_success',
count: 1
})
})
it('keeps all chips, toasts, and emits nothing when every invite fails', async () => {
mockCreateInvite.mockRejectedValue(new Error('nope'))
const { user, emitted } = renderForm()
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
await user.click(submitButton())
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('c@d.com')).toBeInTheDocument()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(emitted().submitted).toBeUndefined()
expect(mockTrackInviteSent).not.toHaveBeenCalled()
})
it('caps the number of chips at maxSeats', async () => {
const { user } = renderForm({ maxSeats: 2 })
await user.type(emailInput(), 'a@b.com,b@b.com,c@b.com{Enter}')
expect(screen.getByText('a@b.com')).toBeInTheDocument()
expect(screen.getByText('b@b.com')).toBeInTheDocument()
expect(screen.queryByText('c@b.com')).not.toBeInTheDocument()
expect(
screen.getByText('workspacePanel.inviteMemberDialog.seatLimitReached')
).toBeInTheDocument()
})
it('emits cancel when a cancel label is provided', async () => {
const { user, emitted } = renderForm({ cancelLabel: 'Cancel' })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(emitted().cancel).toBeTruthy()
expect(mockCreateInvite).not.toHaveBeenCalled()
})
it('hides the built-in submit row when showSubmit is false', () => {
renderForm({ showSubmit: false })
expect(
screen.queryByRole('button', { name: 'Send invites' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,220 @@
<template>
<div class="flex flex-col gap-2">
<TagsInput
always-editing
add-on-paste
add-on-blur
:delimiter="EMAIL_DELIMITER"
:convert-value="normalizeEmail"
:model-value="emails"
class="min-h-10 w-full bg-tertiary-background px-3 focus-within:bg-tertiary-background hover:bg-tertiary-background-hover"
@update:model-value="onEmailsUpdate"
>
<TagsInputItem
v-for="email in emails"
:key="email"
:value="email"
:class="
cn('rounded-full', !isValidEmail(email) && 'bg-danger/20 text-danger')
"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:auto-focus="autoFocus"
class="min-w-0 text-sm"
:aria-label="placeholder"
:aria-describedby="describedBy"
:placeholder="emails.length === 0 ? placeholder : undefined"
/>
</TagsInput>
<p
v-if="invalidEmails.length > 0"
:id="invalidEmailsHintId"
role="alert"
class="text-danger m-0 text-xs"
>
{{
$t(
'workspacePanel.inviteMemberDialog.invalidEmailCount',
invalidEmails.length
)
}}
</p>
<p
v-if="isAtSeatLimit"
:id="seatLimitHintId"
aria-live="polite"
class="m-0 text-xs text-muted-foreground"
>
{{ $t('workspacePanel.inviteMemberDialog.seatLimitReached', maxSeats) }}
</p>
<div
v-if="showSubmit"
:class="
cn('flex', cancelLabel ? 'items-center justify-end gap-4' : 'flex-col')
"
>
<Button
v-if="cancelLabel"
variant="muted-textonly"
size="lg"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</Button>
<Button
variant="secondary"
size="lg"
:class="cn(!cancelLabel && 'w-full rounded-lg')"
:loading
:disabled="!canSubmit"
:aria-busy="loading"
:aria-label="loading ? $t('g.loading') : submitLabel"
@click="onSubmit"
>
{{ submitLabel }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import { useTelemetry } from '@/platform/telemetry'
import type { WorkspaceInviteMetadata } from '@/platform/telemetry/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import {
EMAIL_DELIMITER,
isValidEmail,
normalizeEmail,
sanitizeInviteEmails
} from '@/platform/workspace/utils/inviteEmails'
import { cn } from '@comfyorg/tailwind-utils'
const {
submitLabel,
placeholder,
source,
cancelLabel,
maxSeats = Number.POSITIVE_INFINITY,
showSubmit = true,
autoFocus = false
} = defineProps<{
submitLabel: string
placeholder: string
source: WorkspaceInviteMetadata['source']
cancelLabel?: string
maxSeats?: number
/** Hide the built-in submit row so a parent can place the action elsewhere
* (e.g. the team-upgrade success footer); drive it via the exposed submit. */
showSubmit?: boolean
/** Focus the email input on mount. Off by default so an embedding dialog
* keeps control of its own focus order. */
autoFocus?: boolean
}>()
const emit = defineEmits<{
submitted: [emails: string[]]
cancel: []
}>()
const { t } = useI18n()
const toast = useToast()
const telemetry = useTelemetry()
const workspaceStore = useTeamWorkspaceStore()
const emails = ref<string[]>([])
const loading = ref(false)
const invalidEmailsHintId = useId()
const seatLimitHintId = useId()
const invalidEmails = computed(() =>
emails.value.filter((email) => !isValidEmail(email))
)
const isAtSeatLimit = computed(() => emails.value.length >= maxSeats)
const canSubmit = computed(
() =>
emails.value.length > 0 &&
emails.value.length <= maxSeats &&
invalidEmails.value.length === 0
)
const describedBy = computed(
() =>
[
invalidEmails.value.length > 0 ? invalidEmailsHintId : undefined,
isAtSeatLimit.value ? seatLimitHintId : undefined
]
.filter(Boolean)
.join(' ') || undefined
)
function onEmailsUpdate(value: string[]) {
emails.value = sanitizeInviteEmails(value, maxSeats)
}
async function onSubmit() {
if (loading.value) return
loading.value = true
if (!canSubmit.value) {
loading.value = false
return
}
try {
const emailSnapshot = [...emails.value]
const results = await Promise.allSettled(
emailSnapshot.map((email) => workspaceStore.createInvite(email))
)
const failedEmails = emailSnapshot.filter(
(_, index) => results[index].status === 'rejected'
)
const invitedCount = emailSnapshot.length - failedEmails.length
if (invitedCount > 0) {
telemetry?.trackWorkspaceInviteSent({ source, count: invitedCount })
emit(
'submitted',
emailSnapshot.filter((email) => !failedEmails.includes(email))
)
}
if (failedEmails.length === 0) return
emails.value = failedEmails
toast.add({
severity: 'error',
summary: t(
'workspacePanel.inviteMemberDialog.failedCount',
failedEmails.length
),
life: 5000
})
} finally {
loading.value = false
}
}
defineExpose({
submit: onSubmit,
get canSubmit() {
return canSubmit.value
},
get loading() {
return loading.value
}
})
</script>

Some files were not shown because too many files have changed in this diff Show More