Compare commits

..

59 Commits

Author SHA1 Message Date
dante01yoon
446a430b97 feat(billing): team-plan Stripe checkout deep link
Adds /cloud/subscribe?tier=team&stop=<id>&cycle=<monthly|yearly> so the
marketing pricing page's Team button can send users straight to Stripe, like
the personal tiers. Team routes through the workspace billing endpoint
(POST /api/billing/subscribe with the per-credit plan slug + stop) because the
per-credit Team plan lives there and the backend lets any workspace, personal
included, subscribe to it. A needs_payment_method response is a full-page
redirect to Stripe; otherwise the user lands back in the app.
2026-06-24 15:50:51 +09:00
dante01yoon
00822616b4 Merge remote-tracking branch 'origin/main' into jaewon/fe-1104-pricing-table-deep-link
# Conflicts:
#	src/locales/en/main.json
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
#	src/platform/workspace/components/UnifiedPricingTable.test.ts
#	src/platform/workspace/components/UnifiedPricingTable.vue
#	src/storybook/mocks/useBillingContext.ts
2026-06-24 11:28:05 +09:00
jaeone94
a82a3afa63 test: stabilize mask editor screenshot e2e (#13011)
<img width="1155" height="648" alt="스크린샷 2026-06-19 오후 11 24 27"
src="https://github.com/user-attachments/assets/01ed2607-662f-4735-b0c2-2f1a2c8a8811"
/>

## Summary

Stabilizes the Mask Editor screenshot E2E by hiding the transient brush
cursor before capturing the dialog.

## Changes

- **What**: Moves the pointer from the mask editor pointer zone to the
Brush Settings panel before the screenshot and asserts that the brush
cursor is hidden.
- **Dependencies**: None.

## Review Focus

Please check that the test still exercises the dialog UI while excluding
only cursor-position noise from the screenshot. The PR also includes the
existing browser-test `AppMode` type import fix needed for
`typecheck:browser` on branches that touch `browser_tests/**`.

Validation:
- `pnpm exec oxfmt --check browser_tests/tests/maskEditor.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm exec eslint browser_tests/tests/maskEditor.spec.ts
browser_tests/fixtures/helpers/WorkflowHelper.ts`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm
exec playwright test browser_tests/tests/maskEditor.spec.ts --grep
"opens mask editor from image preview button" --project=chromium
--repeat-each=10`

---------

Co-authored-by: github-actions <github-actions@github.com>
(cherry picked from commit 444dc3fccd)
2026-06-20 09:22:42 +09:00
dante01yoon
2652de6635 fix(test): repoint AppMode type import to @/utils/appMode
WorkflowHelper.ts still imported AppMode from @/composables/useAppMode
after #12925 moved it to @/utils/appMode. typecheck:browser only runs
when a PR touches browser_tests/; this PR adds a browser test and trips
the latent TS2459. Same fix as c2f30f94e6, which is on main but not this
stack's base.
2026-06-20 08:47:45 +09:00
dante01yoon
20b41a02ac fix(billing): guarantee pricing deep-link URL cleanup before await
Collapse the merge + strip into a single replace that runs before any
await, so the pricing param is always stripped and the preserved query
cleared even if the gate denies the user or members-fetch rejects.
Add tests for the members-fetch failure and unrecognized-value paths.
2026-06-20 08:28:09 +09:00
dante01yoon
4cd6f2f8b9 fix(billing): await members fetch before the deep-link gate (FE-1104) 2026-06-19 22:15:39 +09:00
dante01yoon
c9babcdb85 test(billing): add @cloud e2e for the pricing deep link (FE-1104) 2026-06-19 21:26:00 +09:00
dante01yoon
0708c01f79 feat(billing): deep link to open the pricing table (FE-1104)
Add a /?pricing=1 (and /?pricing=team / =personal) deep link that opens the
pricing table on app load, gated to the original owner via
canManageSubscriptionLifecycle. Members and promoted owners are a silent
no-op (param stripped, app loads normally). Mirrors the existing preserved-
query URL-loader pattern; survives the login redirect. Eligible opens emit
the subscription modal_opened telemetry with a new deep_link reason.
2026-06-19 20:44:45 +09:00
dante01yoon
0f28e1b3b1 Merge remote-tracking branch 'origin/jaewon/fe-770-creator-lifecycle-permission' into jaewon/fe-1104-pricing-table-deep-link 2026-06-19 19:45:59 +09:00
dante01yoon
c7faa2dbff fix(billing): keep pricing dialog size constant across plan toggle (FE-934)
The unified pricing dialog hugged its content, so switching For Personal / For Teams resized it (personal ~1108px, team grew to 95vw, with differing heights). Pin the pricing step's content root to a fixed width (1280) and a min-height floor so both plan scopes render at the same size; the compact confirm/success steps still shrink to their content.
2026-06-19 15:36:40 +09:00
dante01yoon
74f47e0fd6 test(billing): stub useSubscriptionDialog in members panel test
useMembersPanel now calls useSubscriptionDialog() for the team-plan
upsell, dragging the real i18n module into the test's import graph. The
existing vue-i18n mock omits createI18n, so the suite crashed at load.
Mock the new dependency like the panel's other collaborators.
2026-06-19 15:09:41 +09:00
dante01yoon
e0d12a33a2 Merge remote-tracking branch 'origin/main' into jaewon/fe-934-unified-pricing-table
# Conflicts:
#	apps/website/src/components/common/maskRevealButton.variants.ts
#	apps/website/src/components/common/pillButton.variants.ts
2026-06-19 12:17:06 +09:00
dante01yoon
dde38af722 fix(workspace): only preload members for team workspaces
The watch now gates ensureMembersLoaded on activeWorkspace.type === 'team'
so personal/undefined workspaces never invoke the store loader. This keeps
consumer composables that mock the store minimally (useSubscriptionDialog)
working, while the store method stays the idempotent safe loader.
2026-06-19 10:41:37 +09:00
dante01yoon
8278c9d874 refactor(workspace): move members preload into store ensureMembersLoaded
Address review: own the team members-preload coordination in the store
instead of the composable. ensureMembersLoaded no-ops for personal /
already-loaded workspaces, dedupes in-flight calls, and logs failures so a
later call retries. Inline the redundant isCurrentUserOriginalOwner computed.
2026-06-19 10:02:46 +09:00
Dante
2d109b233d feat(billing): gate cancel/reactivate to the original owner (FE-978) (#12830)
Stacked on the permission infra #12829. Gates owner **Cancel** (plan
menu) + **Resubscribe** (panel button + workspace popover re-activate
path) on `canManageSubscriptionLifecycle` (original owner only), per the
billing-permission matrix (FE SSOT). Promoted owners keep manage-payment
/ upgrade / top-up; members get none.

**Original-owner signal (repointed).** Rebased onto the updated #12829
(`a51183ae`), so the gate now resolves from the member-list
`is_original_owner` field that the cloud cutover (Comfy-Org/cloud #4359)
ships: the store getter `isCurrentUserOriginalOwner` matches the current
user's member self-row by email, and `useWorkspaceUI` eagerly
`fetchMembers()` so billing surfaces have the roster loaded. This
replaces the earlier `is_creator`-on-`/api/workspaces` assumption — BE
standardized on a per-member `is_original_owner` instead. No
`is_creator` remains.

**Merge-gated** on cutover cloud#4359 reaching cloud main: until `GET
/api/workspace/members` returns `is_original_owner` in prod, the gate
fails closed (lifecycle actions hidden for every owner). Fail-closed =
no regression pre-deploy, but do not merge until the field is live.

**Follow-up (non-blocking).** Exposing `is_original_owner`
(current-user-relative, sibling of `role`) on `/api/workspaces` — Hunter
pre-approved 2026-06-17 — would let us read it directly and drop the
eager member fetch. Tracked on #12829 / FE-770.

Part of FE-978 (member run-lock modal shipped separately in #12786).
2026-06-19 09:37:00 +09:00
dante01yoon
739b873dd6 fix(billing): show the cycle-discounted team price in the confirm step
UnifiedPricingTable already imports getDiscountedMonthlyUsd and emits discountedUsd, but the helper and the TeamPlanSelection.discountedUsd field were missing (broke typecheck) and the confirm step still showed the list price. Add the helper, carry discountedUsd on the selection, and display it.

FE-934.
2026-06-19 09:35:59 +09:00
dante01yoon
18eced3ea0 fix(billing): open team pricing tab from Upgrade to Team CTAs (FE-934) 2026-06-19 09:02:37 +09:00
dante01yoon
54b869db95 fix(billing): drop member count from enterprise upsell copy (FE-934) 2026-06-19 09:02:10 +09:00
dante01yoon
ffc7643e9e test(billing): replace double type-cast with typed preview fixture 2026-06-18 20:32:52 +09:00
dante01yoon
0092a45983 fix(workspace): type is_original_owner as optional to match the BE contract (FE-770) 2026-06-18 19:16:02 +09:00
dante01yoon
a51183ae8a fix(workspace): gate creator lifecycle on members-list original owner (FE-770) 2026-06-18 15:25:09 +09:00
dante01yoon
96ac29b871 docs(workspace): reword ingest-types TODO without PR-scoped phrasing (FE-770)
Drop the confusing "this PR" reference per review; the comment outlives the PR,
so describe the swap condition (OpenAPI exposes is_creator) as a plain TODO.
2026-06-18 09:55:23 +09:00
GitHub Action
a0b254b982 [automated] Apply ESLint and Oxfmt fixes 2026-06-17 13:48:51 +00:00
dante01yoon
98e558cf3d Merge remote-tracking branch 'origin/main' into jaewon/fe-934-unified-pricing-table
# Conflicts:
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
2026-06-17 22:43:03 +09:00
dante01yoon
8c098ee213 docs(workspace): note generated ingest-types swap once is_creator ships (FE-770)
WorkspaceWithRole + WorkspaceWithRoleSchema are hand-rolled only because the
cloud ingest OpenAPI doesn't expose is_creator yet. @comfyorg/ingest-types
already generates the type + zWorkspaceWithRole; swap to them in this PR once
the spec ships the field.
2026-06-17 22:32:27 +09:00
dante01yoon
1bc10fa6dc docs(workspace): mark is_creator shape confirmed by BE (FE-770)
BE confirmed the current-user-relative is_creator boolean and that the
creator is tracked explicitly (not by creation date). Field stays optional
and gating fails closed until it actually ships on /api/workspaces.
2026-06-17 19:58:33 +09:00
dante01yoon
65f5c02b16 fix(billing): pricing dialog opens on the matching plan tab (FE-934)
A team workspace lands on For Teams, a personal workspace on For Personal,
instead of always defaulting to For Personal.

(cherry picked from commit 6f2ed144e7)
2026-06-17 19:39:11 +09:00
dante01yoon
c0f3f6fb69 fix(billing): inactive plan-scope tab = active tab at 50% opacity (FE-934)
The For Personal / For Teams inactive tab used a half-opacity fill plus a
separate muted text colour, so it wasn't a faded copy of the active tab.
Render the inactive tab with the active tab's fill and text at opacity-50
(hover restores full), per the DES "50% opacity of the active tab" note.
2026-06-17 14:50:05 +09:00
dante01yoon
a7c8f3ad82 fix(billing): drop underline on the pricing footnote "see details" link (FE-934)
The "see details" link is the only <a> in the footnote, so it picked up the
browser-default underline while the sibling <button> links (questions /
enterprise discussions / click here) render none. Add no-underline to match.
2026-06-17 11:23:39 +09:00
dante01yoon
76422f6bc2 fix(billing): address DES pricing-table link feedback (FE-934)
- Make the "To add teammates..." personal subtitle a button that switches to the For Teams tab (was a non-interactive span)
- Point the pricing footnote "questions" link to the Pylon question form
- Fade the inactive plan-scope tab to 50% opacity of the active tab, keeping muted text
- Align the "see details" footnote link styling with the other links
2026-06-17 11:05:31 +09:00
dante01yoon
d9f54420b6 fix(billing): complete the storybook BillingContext mock (FE-934)
The mock fell behind the extended BillingContext interface
(billingStatus, subscriptionStatus, tier, renewalDate, resubscribe,
topup) after the main merge, breaking `pnpm typecheck`. Return the same
inert values the real facade exposes so the stub stays in lockstep.
2026-06-17 10:39:36 +09:00
dante01yoon
1c97eb016c fix(billing): DES QA polish on the unified pricing table (FE-934)
Design feedback from the FE-991 verification pass:

- Match the team-plan CTA font to the personal CTAs (text-sm/normal; it
  was inheriting text-xs from the default button size).
- Give the Yearly/Monthly toggle buttons a min-width so the active pill
  doesn't resize when switching — the discount badge made Yearly wider
  than Monthly, which read as a shift.
- Left-align the credit-slider "Save X% ($Y)" badge when the team column
  wraps on narrow/mobile widths (was right-aligned via ms-auto), with
  more spacing above "Billed yearly".
- Free-tier users now see "Subscribe to {plan}" instead of "Change to
  {plan}"; only an active paid plan offers a change.

Adds a UnifiedPricingTable regression test for the free-tier label.
2026-06-17 10:39:36 +09:00
dante01yoon
548e260be0 fix(billing): apply DES QA polish to the unified pricing table (FE-934)
Designer QA (Alex, 2026-06-13/14) on the team-workspaces pricing dialog:
- reduce dialog/content padding to spec (16px sides / 24px top)
- one base-background content well (no border) holds the description, billing
  toggle and cards on a uniform surface; plan-scope tabs sit flush on top of it
  (no pill container, flat-bottom active tab)
- description moved above the billing toggle; add the missing personal
  description ('Personal plans are for individual use only. To add teammates,
  subscribe to the team plan.')
- 'Save up to 20%' for teams / 'Save 20%' for personal
- tier cards fixed at 320px and centered; 24px gap between cards
- remove the green outline on the popular card; footer links drop the underline
  and use the base-foreground token
- mute feature/perk and enterprise body text; tabular-nums on figures
- white credit-slider range/handle; fixed dialog height (constant across the
  personal/team toggle) that scrolls instead of clipping at narrow widths;
  16px footnote spacing
2026-06-14 10:06:39 +09:00
dante01yoon
b4ee092fd3 fix(workspace): pass is_creator through the shared type + auth schema (FE-770)
CodeRabbit (#12829): is_creator was declared on api/workspaceApi WorkspaceWithRole
but the duplicate type in workspaceTypes.ts and the Zod schema in
workspaceAuthStore stripped it, so the flag could be dropped on the auth/session
parse path. Align both so the original-owner flag survives. Still ASSUMED / BE
spec not finalized (FE-770 Q3 / BE-1337); optional, fails closed.
2026-06-13 21:00:12 +09:00
dante01yoon
6583e65dca feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770)
Adds a creator-only subscription-lifecycle permission so cancel / reactivate /
downgrade can be gated to the workspace's original owner (any owner keeps
canManageSubscription for manage-payment / top-up / change-commit), per the
billing-permission matrix in the FE SSOT.

ASSUMES /api/workspaces exposes a current-user-relative is_creator flag — BE
spec is NOT finalized (field shape + original-owner determination are open,
FE-770 Q3 / BE-1337). Fails closed: when is_creator is absent the lifecycle
permission is false, so no behavior changes until the BE signal lands. Code
comments mark every assumption point for revisit. Consumers (FE-978 cancel/
reactivate, FE-977 downgrade) wire to this once it is available.
2026-06-13 20:34:55 +09:00
Dante
b1d5ff8094 Merge branch 'main' into jaewon/fe-934-unified-pricing-table 2026-06-12 23:52:17 +09:00
dante01yoon
8526c5c872 feat(billing): add Yearly/Monthly toggle to the team plan (FE-934)
Team pricing was yearly-only (slider). Per PRD: GA Team Billing the team
plan has both cycles; monthly halves the yearly discount (0/2.5/5/7.5/10%
vs 0/5/10/15/20%). Ungate the cycle toggle for team, thread the cycle into
CreditSlider, and switch the CTA/billed line by cycle.
2026-06-12 22:28:44 +09:00
dante01yoon
9c6dd9c5a5 fix(billing): place struck 'before' price to the right of the current price (FE-934) 2026-06-12 19:38:51 +09:00
dante01yoon
c5fd2a4117 test(billing): type checkout-step story fixtures to the preview contract (FE-934 review) 2026-06-12 15:57:48 +09:00
dante01yoon
61085d5134 Merge remote-tracking branch 'origin/main' into jaewon/fe-934-unified-pricing-table
# Conflicts:
#	src/platform/cloud/subscription/constants/teamPlanCreditStops.ts
2026-06-12 10:27:41 +09:00
dante01yoon
c7da4241e5 feat(billing): render team 'Confirm your payment' step from the slider stop (FE-934)
Team subscribe previously dead-ended in a 'coming soon' toast at the
pricing table. It now advances to the DES-197 confirm step, rendered
display-only from the selected slider stop ({usd, credits}) via a
teamPlan prop on SubscriptionAddPaymentPreviewWorkspace — team perks in
the expandable features list, credits/month under the price, total due
today from the stop. The final subscribe CTA stays stubbed until the BE
slider contract lands (doc Open Q#2).
2026-06-12 08:43:44 +09:00
dante01yoon
64b4410365 fix(billing): align plan-change confirm to DES-197 — equal plan columns, next-cycle section, shared terms note (FE-934)
The current-plan column was fixed at w-[250px], squeezing the new-plan
column against the right edge and wrapping its start date (the Creator
side misalignment). Both columns are now flex-1 around a fixed arrow,
matching the Figma two-column grid.

Also restructures the middle section to the DES-197 layout — 'Every
month starting {date}' header with 'Credits refill to' and 'You'll be
charged' rows — and extracts the terms-agreement note into
SubscriptionTermsNote, now shown on both confirm screens.
2026-06-12 08:43:16 +09:00
dante01yoon
920e43aabd test(billing): storybook stories for checkout confirm/success steps (FE-934) 2026-06-10 23:30:59 +09:00
dante01yoon
68a548eb9b feat(billing): add 'You're all set' success step to unified checkout (FE-934) 2026-06-10 21:00:33 +09:00
dante01yoon
250a617d6c feat(billing): align confirm screens to DES-197 — drop /member, comfy credits icon, plan-specific CTAs (FE-934) 2026-06-10 20:29:18 +09:00
dante01yoon
8b5e7d4ec5 fix(billing): match DES-197 dialog surfaces + personal price unit
- pricing dialog shell uses secondary-background (elevated) so the
  base-background cover panel + cards read as the darker well per DES-197;
  drop the translucent base/60 + backdrop-blur
- cover panel gets solid bg-base-background
- personal tier price unit "USD / mo / member" -> "USD / mo" (matches DES-197
  and the team slider; fixes awkward wrap on mobile)
2026-06-10 01:12:44 +09:00
dante01yoon
0c6ca27823 feat(billing): close DES-197 design gaps in UnifiedPricingTable (FE-934)
- personal cards: replace uniform attribute matrix with progressive
  "What's included:" / "Everything in {prev}, plus:" bullets + credit block
  (monthly credits + "Generates ~N 5s videos*"); drop dead per-card popover
- unify monthly-credits / video-estimate / "Everything in {plan}" into shared
  subscription.* i18n keys reused by both personal and team
- team Details checks use foreground color, "Invite team members" copy
- billing-cycle badge "-20%" -> "Save 20%"
- Creator CTA to inverted (white) to match DES-197 and legacy PricingTable
2026-06-09 23:19:27 +09:00
Dante
7c4ffb3485 Merge branch 'jaewon/fe-935-team-plan-credit-slider' into jaewon/fe-934-unified-pricing-table 2026-06-09 11:21:35 +09:00
dante01yoon
ff4a690496 fix(billing): clamp CreditSlider fallback index, add disabled-state test
Guard selectedIndex so a shorter-than-expected backend `stops` array (or an
out-of-bounds `defaultStopIndex`) can't make `current` undefined and crash the
price computeds; floor `lastIndex` at 0 so the slider max is never negative.
`stops` is documented as required non-empty. Add a unit test asserting no
update:modelValue / change events fire when the slider is disabled.

Addresses CodeRabbit review on #12644 (undefined-`current` runtime path;
disabled-state test coverage).
2026-06-08 18:27:17 +09:00
dante01yoon
2fd8f685ed feat(billing): make CreditSlider stops prop-driven (stops + defaultStopIndex)
- Accept `stops` + `defaultStopIndex` props (default to the hardcoded DES-197 set) so the slider can be fed from GET /api/billing/plans (BE-1254) without code changes; selectedIndex falls back to defaultStopIndex
- Add a prop-driven unit test and a "Backend-driven stops" story that maps the team_credit_stops API shape into the props
2026-06-05 23:59:46 +09:00
dante01yoon
72507cf225 fix(billing): keep CreditSlider price on one row, wrap the Save badge when narrow
- The price (amount + struck + "USD / mo") is now an unbreakable unit (shrink-0 + whitespace-nowrap); the Save badge wraps to its own line on a narrow card instead of breaking "USD / mo" mid-word at the widest stop
- Story previews at the real DES-197 width: 512px card column with 32px padding -> 448px content
2026-06-05 23:57:19 +09:00
dante01yoon
6cadc4eb5a feat(billing): align CreditSlider price + Save badge with DES-197, animate count
- Save badge -> outlined primary pill, right-aligned (border-2 border-primary-background, text-primary-background, rounded-full, text-sm bold); bump price to 32px, per Figma 2983:29834
- Count the price up/down between stops via @vueuse useTransition (easeOutCubic, 350ms); honor prefers-reduced-motion
- Derive billed-yearly from the displayed monthly so it stays exactly 12x the shown price even mid-count
2026-06-05 23:56:21 +09:00
dante01yoon
a013d4b123 test(storybook): mock useBillingContext so UnifiedPricingTable renders
The real facade pulls in Firebase auth via the legacy adapter and crashes in
Storybook (TypeError: setPersistence). Alias it to a static stub so the new
table renders against TIER_PRICING / DES-197 fallbacks.
2026-06-05 18:06:49 +09:00
dante01yoon
f25ef177b8 feat(billing): add UnifiedPricingTable stories + initialPlanMode prop
Adds Personal / TeamPlan stories so the new table renders on the deployed
Storybook preview. initialPlanMode (default personal) lets the story show the
team view without the server flag (the toggle itself is flag-gated).
2026-06-05 17:59:38 +09:00
GitHub Action
dfd08cd13d [automated] Apply ESLint and Oxfmt fixes 2026-06-05 08:00:56 +00:00
dante01yoon
e1bad829e7 feat(billing): add UnifiedPricingTable + dispatch behind teamWorkspacesEnabled
B4 (FE-934): a single pricing table for the Jun-5 model — one workspace with a
personal/team PLAN toggle (Gamma-style), per DES-197. New, flag-gated component
that will replace PricingTable.vue + PricingTableWorkspace.vue at cutover
(strangler).

- UnifiedPricingTable.vue: plan toggle, personal tier cards (facade plans with
  TIER_PRICING fallback), team column hosting CreditSlider, Enterprise card.
  Reuses useBillingContext; emits subscribe/resubscribe (personal) +
  subscribeTeam (team).
- SubscriptionRequiredDialogContentUnified.vue: host wiring the table to
  useSubscriptionCheckout for personal checkout; team checkout stubbed (toast)
  pending the BE slider contract (doc Open Q#2).
- showPricingTable: render the unified host when teamWorkspacesEnabled; the
  legacy PricingTable stays for the flag-off build.
- i18n: subscription.planScope / teamPlan / enterprise keys.

Stacked on the FE-935 CreditSlider branch. Personal checkout fully wired; team
checkout + live /api/billing/plans discount data deferred to the BE contract.
vue-tsc + oxlint clean; dispatcher tests pass (11).
2026-06-05 16:56:36 +09:00
dante01yoon
39854ae51b feat(billing): align CreditSlider with DES-197 (discount, credit ticks, yearly total)
Bring the team-plan slider up to the settled DES-197 / Slack design:
- discounted monthly price + struck pre-discount price + Save% badge
- '$N billed yearly' annual total
- stop labels now show CREDITS (42.2K..527.5K) with a credit icon and a
  gold highlight on the selected stop (was raw USD amounts)
- add threshold-based yearly discounts to TEAM_PLAN_CREDIT_STOPS
  (0/5/10/15/20%, $700=10% per design; others per the agreed sequence, TBD)
- USD / mo unit, drop the separate credits readout (credits live on the ticks)
2026-06-05 13:19:24 +09:00
dante01yoon
a0f2445a01 fix(billing): i18n CreditSlider labels to satisfy no-raw-text lint
lint-and-format failed on @intlify/vue-i18n/no-raw-text for the hardcoded
'/ month' and 'credits' template strings. Route both through vue-i18n
(subscription.perMonth, credits.credits — same key UserCredit.vue uses) and
inject an i18n instance in the component test. Also normalize tailwind class
order flagged by better-tailwindcss.
2026-06-04 19:30:31 +09:00
dante01yoon
30e0e0b031 feat(billing): add team-plan CreditSlider component (5 fixed stops)
Standalone presentational slider for the team-plan credit subscription. It
snaps to the 5 fixed DES-197 stops (200/400/700/1400/2500 USD) by driving the
shared reka-ui Slider in index space (min=0, max=4, step=1) — the user can
never land on a value in between. v-model carries the selected USD value; a
`change` event also emits { index, usd, credits }.

Thresholds live in a typed constant (teamPlanCreditStops.ts) hardcoded per
Figma DES-197 until the backend slider contract lands; a test guards that the
credit figures stay equal to usdToCredits(usd).

B4 standalone slice (FE-935). Not yet wired into PricingTableWorkspace
(deferred to FE-934, blocked on the BE slider contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:18:19 +09:00
17 changed files with 797 additions and 342 deletions

View File

@@ -0,0 +1,243 @@
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 { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* 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'
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; enable team workspaces so the
// unified pricing table (and the lifecycle gate) are live.
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))
)
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' }))
)
// 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.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.Assets.UseAssetAPI': false }))
)
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({})))
// Queue/prompt status: a missing exec_info throws on boot and aborts the
// GraphCanvas onMounted chain before the deep-link loader runs.
await page.route('**/api/prompt', (r) =>
r.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
)
await page.route('**/api/queue', (r) =>
r.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
)
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([])))
}
async function mockBilling(page: Page) {
// Minimal valid shapes so the billing facade resolves while the dialog mounts.
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' }))
)
}
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'
}
}
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 }
})
)
)
}
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')
})
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
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
}
}
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('personal', 'owner'), [])
await bootCloud(page)
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.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('personal', 'owner'), [])
await bootCloud(page)
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.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await bootCloud(page)
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.setTimeout(60_000)
await mockCloudBoot(page)
await mockBilling(page)
await mockWorkspace(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await bootCloud(page)
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

@@ -197,6 +197,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
@@ -461,6 +462,7 @@ 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 pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -587,6 +589,19 @@ onMounted(async () => {
}
}
// 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(
'[GraphCanvas] Failed to load pricing table from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

View File

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -10,7 +10,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
@@ -146,17 +145,6 @@ vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: mockGetOutputAssetMetadata
}))
const mockResolveOutputAssetItems = vi.hoisted(() =>
vi.fn().mockResolvedValue([])
)
vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal<typeof outputAssetUtilModule>()
return {
...actual,
resolveOutputAssetItems: mockResolveOutputAssetItems
}
})
const mockDeleteAsset = vi.hoisted(() => vi.fn())
const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
@@ -295,8 +283,6 @@ describe('useMediaAssetActions', () => {
mockGetOutputAssetMetadata.mockReset()
mockGetOutputAssetMetadata.mockReturnValue(null)
mockGetAssetType.mockReset()
mockResolveOutputAssetItems.mockReset()
mockResolveOutputAssetItems.mockResolvedValue([])
})
describe('addWorkflow', () => {
@@ -596,236 +582,6 @@ describe('useMediaAssetActions', () => {
})
})
describe('downloadAssets - OSS multi-output expansion', () => {
beforeEach(() => {
mockIsCloud.value = false
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockImplementation(
(meta: Record<string, unknown> | undefined) =>
meta && 'jobId' in meta ? meta : null
)
})
function createOutputAsset(
id: string,
name: string,
jobId: string,
outputCount?: number,
previewUrl?: string
): AssetItem {
return createMockAsset({
id,
name,
tags: ['output'],
preview_url: previewUrl ?? `https://example.com/${name}`,
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
})
}
it('expands a grouped asset into individual downloads', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockResolvedValueOnce([
createOutputAsset('g1-out1', 'out1.png', 'job1'),
createOutputAsset('g1-out2', 'out2.png', 'job1'),
createOutputAsset('g1-out3', 'out3.png', 'job1')
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job1', outputCount: 3 }),
expect.objectContaining({ createdAt: expect.any(String) })
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
1,
'https://example.com/out1.png',
'out1.png'
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
2,
'https://example.com/out2.png',
'out2.png'
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
3,
'https://example.com/out3.png',
'out3.png'
)
expect(mockCreateAssetExport).not.toHaveBeenCalled()
})
it('mixes grouped and single-output assets in one selection', async () => {
const grouped = createOutputAsset('g1', 'cover.png', 'job1', 2)
const single = createOutputAsset('s1', 'solo.png', 'job2')
mockResolveOutputAssetItems.mockResolvedValueOnce([
createOutputAsset('g1-a', 'a.png', 'job1'),
createOutputAsset('g1-b', 'b.png', 'job1')
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped, single])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['a.png', 'b.png', 'solo.png'])
})
it('falls back to the original asset when resolveOutputAssetItems returns empty', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockResolvedValueOnce([])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/cover.png',
'cover.png'
)
})
it('does not call resolveOutputAssetItems when no grouped assets are selected', () => {
const single1 = createOutputAsset(
's1',
'a.png',
'job1',
undefined,
'https://example.com/a.png'
)
const single2 = createOutputAsset(
's2',
'b.png',
'job2',
1,
'https://example.com/b.png'
)
const actions = useMediaAssetActions()
actions.downloadAssets([single1, single2])
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
})
it('deduplicates downloads when an expanded child is also selected alongside its parent', async () => {
const grouped = createOutputAsset('job1-cover', 'cover.png', 'job1', 3)
const child = createMockAsset({
id: 'job1-child-a',
name: 'out1.png',
tags: ['output'],
preview_url: 'https://example.com/out1.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
})
mockResolveOutputAssetItems.mockResolvedValueOnce([
createMockAsset({
id: 'job1-child-a',
name: 'out1.png',
tags: ['output'],
preview_url: 'https://example.com/out1.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
}),
createMockAsset({
id: 'job1-child-b',
name: 'out2.png',
tags: ['output'],
preview_url: 'https://example.com/out2.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
})
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped, child])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
})
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['out1.png', 'out2.png'])
})
it('falls back to the preview download when resolveOutputAssetItems rejects', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockRejectedValueOnce(new Error('boom'))
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/cover.png',
'cover.png'
)
})
it('still downloads resolvable assets when one grouped asset fails to expand', async () => {
const failingGrouped = createOutputAsset(
'g1',
'cover1.png',
'job1',
3,
'https://example.com/cover1.png'
)
const okGrouped = createOutputAsset('g2', 'cover2.png', 'job2', 2)
mockResolveOutputAssetItems.mockImplementation(
(metadata: { jobId: string }) => {
if (metadata.jobId === 'job1') {
return Promise.reject(new Error('job1 lookup failed'))
}
return Promise.resolve([
createOutputAsset('g2-a', 'out2a.png', 'job2'),
createOutputAsset('g2-b', 'out2b.png', 'job2')
])
}
)
const actions = useMediaAssetActions()
actions.downloadAssets([failingGrouped, okGrouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['cover1.png', 'out2a.png', 'out2b.png'])
})
})
describe('downloadAssets - cloud zip filters', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -27,10 +27,7 @@ import { getAssetUrl } from '../utils/assetUrlUtil'
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import {
getAssetOutputCount,
resolveOutputAssetItems
} from '../utils/outputAssetUtil'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { isResultItemType } from '@/utils/typeGuardUtil'
@@ -112,9 +109,8 @@ export function useMediaAssetActions() {
* Download one or more assets.
* In cloud mode, creates a ZIP export via the backend when called with
* 2+ assets or with any asset whose job has `outputCount > 1`.
* In OSS mode, downloads each file directly, expanding grouped assets
* (`outputCount > 1`) into their individual outputs.
* With no argument, uses the asset from `MediaAssetKey` context.
* Falls back to direct downloads in OSS mode and for single single-output
* assets. With no argument, uses the asset from `MediaAssetKey` context.
*/
const downloadAssets = (assets?: AssetItem[]) => {
const targetAssets =
@@ -131,13 +127,13 @@ export function useMediaAssetActions() {
return
}
if (hasMultiOutputJobs) {
void downloadAssetsIndividually(targetAssets)
return
}
try {
targetAssets.forEach((asset) => downloadSingleAsset(asset))
targetAssets.forEach((asset) => {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
toast.add({
severity: 'success',
summary: t('g.success'),
@@ -154,66 +150,6 @@ export function useMediaAssetActions() {
}
}
function downloadSingleAsset(asset: AssetItem) {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
}
async function expandAssetForDownload(
asset: AssetItem
): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (
!metadata ||
typeof metadata.outputCount !== 'number' ||
metadata.outputCount <= 1
) {
return [asset]
}
try {
const resolved = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
return resolved.length > 0 ? resolved : [asset]
} catch (error) {
console.error('Failed to expand grouped asset for download:', error)
return [asset]
}
}
async function downloadAssetsIndividually(assets: AssetItem[]) {
try {
const expanded = await Promise.all(assets.map(expandAssetForDownload))
const seenAssetIds = new Set<string>()
const filesToDownload = expanded.flat().filter((asset) => {
if (seenAssetIds.has(asset.id)) return false
seenAssetIds.add(asset.id)
return true
})
filesToDownload.forEach((asset) => downloadSingleAsset(asset))
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t(
'mediaAsset.selection.downloadsStarted',
filesToDownload.length
),
life: 2000
})
} catch (error) {
console.error('Failed to download assets:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
}
async function downloadAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()

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,6 +10,7 @@ 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'
@@ -35,6 +36,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,7 +65,34 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
// Only paid tiers can be checked out via redirect
const validCycles: BillingCycle[] = ['monthly', 'yearly']
const billingCycle: BillingCycle = (validCycles as string[]).includes(
cycleParam
)
? (cycleParam as BillingCycle)
: '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
}
// Only paid personal tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
await router.push('/')
@@ -69,11 +103,6 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
selectedTierKey.value = tierKey
const validCycles: BillingCycle[] = ['monthly', 'yearly']
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
cycleParam = 'monthly'
}
if (!isInitialized.value) {
await initialize()
}
@@ -81,11 +110,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(
tierKey,
cycleParam as BillingCycle,
false
)
await performSubscriptionCheckout(tierKey, billingCycle, false)
}
}, reportError)
@@ -105,18 +130,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,235 @@
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('ignores empty param', async () => {
mockRouteQuery.value = { pricing: '' }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('ignores non-string param', async () => {
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
})
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,67 @@
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 || typeof param !== 'string') return
// Strip the param (even for ineligible users) 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 the user.
const cleanQuery = { ...query }
delete cleanQuery.pricing
router.replace({ query: cleanQuery }).catch((error) => {
console.warn(
'[usePricingTableUrlLoader] Failed to clean URL params:',
error
)
})
clearPreservedQuery(NAMESPACE)
// Fetch members (no-ops for personal) so the original-owner self-row loads
// before the gate; fetchMembers awaits, ensureMembersLoaded can return early.
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

@@ -16,6 +16,7 @@ export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
export interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason

View File

@@ -49,6 +49,17 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
/**
* 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 getTeamPlanSlug(billingCycle: 'monthly' | 'yearly'): string {
return billingCycle === 'yearly'
? 'team_per_credit_annual'
: 'team_per_credit_monthly'
}
/**
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by

View File

@@ -0,0 +1,82 @@
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',
'https://app.test/payment/success',
'https://app.test/payment/failed',
'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',
expect.any(String),
expect.any(String),
'team_1400'
)
expect(assignedHref).toBe('/')
})
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,46 @@
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,
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`,
teamCreditStopId
)
if (
response.status === 'needs_payment_method' &&
response.payment_method_url
) {
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

@@ -156,6 +156,8 @@ interface SubscribeRequest {
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
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
@@ -603,7 +605,8 @@ export const workspaceApi = {
async subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
cancelUrl?: string,
teamCreditStopId?: string
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
@@ -612,7 +615,8 @@ export const workspaceApi = {
{
plan_slug: planSlug,
return_url: returnUrl,
cancel_url: cancelUrl
cancel_url: cancelUrl,
team_credit_stop_id: teamCreditStopId
} satisfies SubscribeRequest,
{ headers }
)

View File

@@ -116,6 +116,10 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.OAUTH,
keys: ['oauth_request_id']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
keys: ['pricing']
}
])