Files
open-design/apps/web/tests/components/EntryNavRail.billing-card.test.tsx
eb27acd322 feat(billing): support Coding Plan usage and wallet fallback (#8339)
* feat(billing): support Coding Plan usage and wallet fallback

* fix(web): keep AMR send off billing preflight

Workspace send no longer waits on advisory vela billing preflight; recovery still requests it. Realign zero-wallet P0 oracles and the credits-card wallet label.

Generated-By: looper 0.15.0 (runner=fixer, agent=omp)

* fix(billing): serialize recovery preflight and bound hung vela

Skip overlapping AmrBalanceDialog recovery ticks and stop the watch after
ten minutes even if a preflight is still pending. Give vela billing
preflight a 60s runVelaCommand timeout so a hung CLI cannot pin recovery.

Generated-By: looper 0.15.0 (runner=fixer, agent=omp)

* fix(web): restore coding plan text contrast in billing card

* feat(web): render Coding Plan windows and Go tier in the credits panel

- CodingPlanUsage renders one row per preflight window (name, used %,
  remaining USD, reset countdown + absolute time) with loading/unavailable
  states; refreshes once on mount and once at resetsAt (no 30s polling,
  setTimeout delay clamped to the 32-bit limit).
- Pure model in coding-plan-usage-model.ts; creditsPerUsd comes from
  `vela billing summary` through WorkspaceBillingSummary and falls back to
  10,000 only when the backend omits it.
- PlanWordmark/EntryNavRail accept the `go` tier (placeholder wordmark,
  design asset still pending).
- i18n: 7 new billing keys + entry.billingTierGo across all 19 locales;
  real translations for the #8339 keys; drop unused codingPlanNone /
  codingPlanRemaining.
- CLI wording: unstarted window reads "not started yet".

* build(pack): pin vela-cli 0.1.4-test.0 and gate on billing preflight

The packaged client needs `vela billing preflight` for the Coding Plan
window rows. Bump the bundled CLI to the npm `test` prerelease that ships
it and add a third capability gate in tools-pack: a CLI without the
subcommand prints the parent `billing` help and exits 0, so the gate
checks the help markers (preflight, --workspace-id, --model, --format)
instead of the exit code.

TEMPORARY: replace 0.1.4-test.0 with the stable 0.1.4 once it is
released; do not merge with a `test` prerelease pinned.

* fix(web): make Coding Plan rows legible on the inverted credits card

The credits card is a hard-coded #202020/#fafafa inverted surface, but
CodingPlanUsage.module.css read page-level --text-* / --bg-fill-* tokens,
so on the card the window names rendered #202020 on #202020 (1.00:1) and
the stat/reset lines sat below WCAG AA. Derive every colour from
currentColor instead (name 100%, stat 72%, reset/note 60%, separator ~50%,
track 36%, exhausted = --red mixed toward currentColor); measured in Chrome:
name 15.6:1, stat 8.7:1, reset 6.4:1, track 3.2:1, exhausted 6.5:1.

Keep the stat on one line (nowrap) and let the name ellipsize instead of
wrapping "60% / used" onto two lines; drop the block's 12px side padding so
it aligns with the card's own rows.

Also add a minutes tier to the reset countdown: the last hour before a
window resets used to show no countdown at all. New key
billing.codingPlanResetsInMinutes in all 19 locales.

* feat(web): rebuild the plan panel 1:1 from design PR #8364

Replace the improvised Coding Plan block with the design's panel, measured
against docs/ui-previews/plan-panels/index.html in Chrome (computed styles
and a 4x pixel diff of the Plus specimen):

- Header: tier name + 16px wordmark, 升级 pill (管理 on the top tier).
- One row 「7天额度免费用 · 已用 N% ›」 (7-day window only; rounded percent;
  100% when spent, no red state) over a 5px #b1f38b track, then the
  separator and the 「钱包余额 · US$x.xx ›」 row. No headings, window names,
  remaining USD, reset time, note or unavailable line.
- Team workspaces keep the pre-existing card unchanged (all team_* tiers);
  the quota row renders only for personal workspaces.
- Free personal workspace: no quota row, but the header sells 升级 like the
  design (a personal workspace needs no billing permission; an account B
  reports without a plan upgrades as free).
- Top-right pill shows the wordmark only, no balance.
- Wallet always reads US$ regardless of locale (product ruling 2026-09-23).
- zh-CN tier names follow the design: 免费版 / 标准版 / 专业版 / 旗舰版.
- Drop the dead billing.codingPlan* keys and model helpers; add
  billing.codingPlanWeeklyAllowance and entry.creditsManage in 19 locales.

* feat(web): show 管理 on personal Max per design PR #8364

The design's Max card sells 管理 (console auto-recharge settings) where
every other tier sells 升级; product ruled on 2026-09-23 to follow the
design for personal Max too, superseding the earlier "personal tiers are
always upgradeable" ruling. The gate now asks isMaxPlanTier and 管理 wins
over 升级 when both would show.

* feat(web): route the plan card's three entries per product ruling

2026-09-23 ruling on the card's landings: 升级 opens the console's pricing
dialog (billing=plan, unchanged), the allowance row opens the console
dashboard (unchanged), the wallet row asks for the manual top-up dialog
(new billing=recharge intent; B does not honor it yet and degrades to the
dashboard), and 管理 on Max opens the plain dashboard instead of the
auto-recharge dialog.

* fix(web): align personal plan cards with v2 design and scoped loading

* fix(web): center plan wordmarks inside the top-right pill

* fix(web): reuse shared billing cache for plan hover cards

* test(web): match personal billing preflight in avatar fixture

* fix(billing): require usable quota evidence for funding recovery

* test(billing): cover scoped preflight and quota event refresh end to end

* feat(daemon): relay scoped coding plan usage events and health

* fix(billing): refresh quota caches from scoped realtime events

* test(daemon): accept capability query on mock event stream

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-24 03:22:31 +00:00

367 lines
14 KiB
TypeScript

// @vitest-environment jsdom
//
// Acceptance #146 / #112 — the account menu's billing card.
//
// #146: a workspace the user had just created, with nothing paid for, showed
// 团队版. The label was derived from `workspaceType === 'team'`, but EVERY
// user-created workspace in B is team-typed (only the auto-provisioned personal
// one is not), so the workspace kind says nothing about whether a subscription
// exists. B reports an unsubscribed workspace as `billingState: 'free'` with a
// null planId and an EMPTY membershipTier — the label has to follow that.
//
// #112 (superseded 2026-07-22): a 附加积分 (bonus/top-up credits) row used to
// live here. Product ruling: we have no such concept to show the user — 积分
// is the one number that matters — so the row was removed outright rather
// than fixed to show a real value.
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import type { WorkspaceBillingSummary, WorkspaceCollabContext } from '@open-design/contracts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EntryNavRail, resetWorkspaceDirectoryCache } from '../../src/components/EntryNavRail';
import { I18nProvider } from '../../src/i18n';
const originalFetch = globalThis.fetch;
function context(overrides: Partial<WorkspaceCollabContext> = {}): WorkspaceCollabContext {
return {
workspaceId: 'ws-new',
workspaceType: 'team',
workspaceMemberId: 'wm-1',
teamName: 'Untitled Workspace',
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
// B's entitlement for a workspace nobody has paid for.
billingState: 'free',
planId: null,
permissions: { canInviteMembers: true, canViewWorkspaceSettings: true },
...overrides,
} as unknown as WorkspaceCollabContext;
}
function billing(overrides: Partial<WorkspaceBillingSummary> = {}): WorkspaceBillingSummary {
return {
workspaceId: 'ws-new',
membershipTier: '',
totalAvailableCredits: 0,
subscriptionCredits: 0,
rechargeCredits: 0,
balanceUsd: '0',
subscriptionStatus: '',
availableActions: [],
...overrides,
} as WorkspaceBillingSummary;
}
function renderRail(props: {
context: WorkspaceCollabContext;
billing: WorkspaceBillingSummary | null;
balanceUsd?: string | null;
}) {
return render(
<I18nProvider initial="zh-CN">
<EntryNavRail
view="home"
onViewChange={() => {}}
onNewProject={() => {}}
open
context={props.context}
billing={props.billing}
balanceUsd={props.balanceUsd}
/>
</I18nProvider>,
);
}
/** Hover the top-right credits pill and scope queries to the billing card
* that hangs under it. The card used to live inside the account menu; it now
* hangs off the pill it describes (per product), so this is the gesture that
* puts it on screen. */
function billingCard() {
fireEvent.pointerEnter(screen.getByTestId('entry-top-right-credits'));
const el = document.querySelector('.entry-nav-rail__menu-credits');
if (!el) throw new Error('billing card is not rendered');
return within(el as HTMLElement);
}
beforeEach(() => {
resetWorkspaceDirectoryCache();
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })) as typeof fetch;
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
resetWorkspaceDirectoryCache();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('account menu billing card — plan label (#146)', () => {
it('does NOT label an unsubscribed team-typed workspace as 团队版', () => {
renderRail({ context: context(), billing: billing() });
const card = billingCard();
expect(card.queryByText('团队版')).toBeNull();
// `entry.billingTierFree` reads 免费 in zh-CN.
expect(card.getByText('免费版')).toBeTruthy();
});
it('labels a workspace that really holds a team subscription as 团队版', () => {
renderRail({
context: context({ billingState: 'active', planId: 'team_plus' } as Partial<WorkspaceCollabContext>),
billing: billing({ membershipTier: 'team_plus', subscriptionStatus: 'active' }),
});
expect(billingCard().getByText('团队版')).toBeTruthy();
});
// The label is a plan question, so an unknown tier must not be answered from
// the workspace kind either way — but a paid MEMBER is a case B does not yet
// report a per-workspace plan for (it sends planId only to owners), so the
// legacy workspace-type hint is all that is left there. Guard that the
// POSITIVE free entitlement is what flips the label, not the member case.
it('keeps the legacy team hint when B has not reported any entitlement', () => {
renderRail({
context: context({ role: 'member', billingState: 'active', planId: null } as Partial<WorkspaceCollabContext>),
billing: billing(),
});
expect(billingCard().getByText('团队版')).toBeTruthy();
});
});
describe('account menu billing card — workspace-aware upgrade routing', () => {
it.each([
{
name: 'personal owner',
context: {
workspaceType: 'personal',
billingState: 'free',
planId: null,
} satisfies Partial<WorkspaceCollabContext>,
billing: { membershipTier: '', subscriptionStatus: '' } satisfies Partial<WorkspaceBillingSummary>,
},
{
name: 'free team owner',
context: {
workspaceType: 'team',
billingState: 'free',
planId: null,
} satisfies Partial<WorkspaceCollabContext>,
billing: { membershipTier: '', subscriptionStatus: '' } satisfies Partial<WorkspaceBillingSummary>,
},
{
name: 'paid team owner',
context: {
workspaceType: 'team',
billingState: 'active',
planId: 'team_plus',
} satisfies Partial<WorkspaceCollabContext>,
billing: {
membershipTier: 'team_plus',
subscriptionStatus: 'active',
} satisfies Partial<WorkspaceBillingSummary>,
},
])(
'routes a $name to the console plan surface',
({ context: contextOverrides, billing: billingOverrides }) => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
renderRail({
context: context({
...contextOverrides,
permissions: {
canInviteMembers: true,
canManageBilling: true,
canViewWorkspaceSettings: true,
},
workspaceSettingsUrl:
'https://web.example.com/console/settings?workspaceId=ws-new',
} as Partial<WorkspaceCollabContext>),
billing: billing(billingOverrides),
});
fireEvent.click(billingCard().getByRole('button', { name: '升级' }));
expect(openSpy).toHaveBeenCalledTimes(1);
const target = new URL(String(openSpy.mock.calls[0]![0]));
expect(`${target.origin}${target.pathname}`).toBe(
'https://open-design.ai/cloud/dashboard',
);
expect(target.searchParams.get('billing')).toBe('plan');
},
);
it.each(['admin', 'member'] as const)(
'hides the upgrade action for a %s without billing permission',
(role) => {
renderRail({
context: context({
role,
permissions: {
canInviteMembers: true,
canManageBilling: false,
canViewWorkspaceSettings: true,
},
workspaceSettingsUrl: 'https://web.example.com/console/settings?workspaceId=ws-new',
} as Partial<WorkspaceCollabContext>),
billing: billing(),
});
expect(billingCard().queryByRole('button', { name: '升级' })).toBeNull();
},
);
});
describe('account menu billing card — 积分 row opens the web console (#62)', () => {
it.each(['paid pill', 'allowance row'] as const)(
'opens the workspace dashboard from the %s without a settings URL',
(action) => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
renderRail({
context: context({ billingState: 'active', planId: 'team_plus' }),
billing: billing({ membershipTier: 'team_plus', subscriptionStatus: 'active' }),
});
fireEvent.click(action === 'paid pill'
? screen.getByTestId('entry-top-right-credits')
: billingCard().getByTestId('entry-nav-credits-row'));
expect(openSpy).toHaveBeenCalledTimes(1);
const [url, target, features] = openSpy.mock.calls[0]!;
const destination = new URL(String(url));
expect(destination.origin).toBe('https://open-design.ai');
expect(destination.pathname).toBe('/cloud/dashboard');
expect(destination.searchParams.get('workspaceId')).toBe('ws-new');
expect(target).toBe('_blank');
expect(features).toBe('noopener,noreferrer');
},
);
// Product ruling: clicking 积分 must jump straight to B's console for the
// usage detail — there is NO intermediate credits popover in the client
// (the reference #5517 has no such panel either). The destination is the
// console dashboard: the wallet page is no longer part of B's information
// architecture (balance/top-up were rehomed onto the dashboard, vela #1055).
it('opens the console dashboard URL in a new tab instead of an in-client panel', () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
renderRail({
context: context({
workspaceSettingsUrl: 'https://web.example.com/console/settings?workspaceId=ws-new',
} as Partial<WorkspaceCollabContext>),
billing: billing({ totalAvailableCredits: 100_000 }),
});
const card = billingCard();
fireEvent.click(card.getByTestId('entry-nav-credits-row'));
expect(openSpy).toHaveBeenCalledTimes(1);
const [url, target] = openSpy.mock.calls[0]!;
// teamConsoleUrl(base, 'billing') → the console's /dashboard page, keeping
// the ?workspaceId deep-link param.
expect(String(url)).toContain('/console/dashboard');
expect(String(url)).toContain('workspaceId=ws-new');
expect(target).toBe('_blank');
// No intermediate panel appears.
expect(document.querySelector('.credits-panel')).toBeNull();
});
});
describe('account menu billing card — no 附加积分 row (#112 superseded)', () => {
it('never renders a bonus/top-up credits row, however the console splits its balance', () => {
renderRail({
context: context({ billingState: 'active', planId: 'team_plus' } as Partial<WorkspaceCollabContext>),
billing: billing({
membershipTier: 'team_plus',
totalAvailableCredits: 1_386_294,
subscriptionCredits: 1_000_000,
rechargeCredits: 386_294,
}),
});
expect(billingCard().queryByText('附加积分')).toBeNull();
});
});
describe('account menu billing card — scoped USD balance (recvqgaMLxEdZX)', () => {
it('shows the explicit workspace USD balance instead of raw credits', () => {
renderRail({
context: context(),
billing: billing({ totalAvailableCredits: 999_330 }),
balanceUsd: '9.9933',
});
const card = billingCard();
expect(card.getByText('钱包余额')).toBeTruthy();
// The design names the currency on this row (「US$9.99」, zh-CN).
expect(card.getByText('US$9.99')).toBeTruthy();
expect(card.queryByText('999,330')).toBeNull();
expect(card.queryByText('余额')).toBeNull();
expect(card.queryByText(/积分/)).toBeNull();
});
it('keeps a proven zero visible as US$0.00', () => {
renderRail({
context: context(),
billing: billing({ totalAvailableCredits: 600_000 }),
balanceUsd: '0',
});
expect(billingCard().getByText('US$0.00')).toBeTruthy();
});
it('preserves the team wallet battery icon and chevron', () => {
renderRail({
context: context(),
billing: billing({ totalAvailableCredits: 600_000 }),
balanceUsd: '12',
});
const row = billingCard().getByTestId('entry-nav-credits-row');
expect(row.querySelectorAll('svg')).toHaveLength(2);
expect(row.textContent?.trim()).toBe('钱包余额US$12.00');
});
});
describe('billing card keyboard access', () => {
it('opens on focus and keeps both actions reachable across focus and pointer transitions', () => {
vi.useFakeTimers();
renderRail({
context: context({
billingState: 'active',
planId: 'team_plus',
permissions: {
...context().permissions,
canManageBilling: true,
},
workspaceSettingsUrl: 'https://web.example.com/console/settings?workspaceId=ws-new',
}),
billing: billing({ membershipTier: 'team_plus', subscriptionStatus: 'active' }),
});
const pill = screen.getByTestId('entry-top-right-credits');
expect(screen.queryByTestId('entry-top-right-credits-panel')).toBeNull();
act(() => pill.focus());
const panel = screen.getByRole('dialog', { name: '额度' });
expect(pill.getAttribute('aria-expanded')).toBe('true');
expect(pill.getAttribute('aria-haspopup')).toBe('dialog');
expect(pill.getAttribute('aria-controls')).toBe(panel.id);
const upgrade = within(panel).getByRole('button', { name: '升级' });
const allowance = within(panel).getByTestId('entry-nav-credits-row');
act(() => upgrade.focus());
fireEvent.pointerLeave(pill.closest('.entry-top-right-credits-anchor')!);
act(() => vi.advanceTimersByTime(180));
expect(document.activeElement).toBe(upgrade);
expect(panel.isConnected).toBe(true);
act(() => allowance.focus());
expect(document.activeElement).toBe(allowance);
expect(panel.isConnected).toBe(true);
act(() => screen.getByTestId('entry-nav-account').focus());
act(() => vi.advanceTimersByTime(180));
expect(screen.queryByTestId('entry-top-right-credits-panel')).toBeNull();
expect(pill.getAttribute('aria-expanded')).toBe('false');
});
});