mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
367 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|