Files
open-design/apps/web/tests/components/CodingPlanUsage.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

190 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import type { WorkspaceBillingResponse, WorkspaceCollabContext } from '@open-design/contracts';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingPlanUsage } from '../../src/components/CodingPlanUsage';
import { I18nProvider } from '../../src/i18n';
import { workspaceContextFixture } from '../helpers/workspace-context';
const USAGE_URL = 'https://console.example.com/dashboard?workspaceId=ws';
function personal(overrides: Partial<WorkspaceCollabContext> = {}) {
return workspaceContextFixture({ workspaceId: 'ws', workspaceMemberId: 'member',
workspaceType: 'personal', planId: 'plus', ...overrides });
}
interface WindowInput {
policyId?: string;
durationSeconds: number;
usedCredits?: string;
limitCredits?: string;
remainingCredits?: string;
resetsAt?: string | null;
}
function preflight(
options: {
member?: string;
eligible?: boolean;
tier?: 'go' | 'plus' | 'pro' | 'max' | null;
windows?: WindowInput[];
omit?: boolean;
} = {},
) {
const {
member = 'member',
eligible = true,
tier = 'pro',
windows = [{ durationSeconds: 604_800 }],
omit = false,
} = options;
if (omit) {
// An older vela CLI answers billing without a preflight at all.
return {};
}
return {
preflight: {
schemaVersion: 1,
workspaceId: 'ws',
workspaceMemberId: member,
modelId: null,
generatedAt: new Date().toISOString(),
balanceUsd: '0',
modelCovered: null,
funding: 'gateway',
codingPlan: {
workspaceId: 'ws',
generatedAt: new Date().toISOString(),
eligible,
tier,
windows: windows.map((entry) => ({
policyId: entry.policyId ?? String(entry.durationSeconds),
durationSeconds: entry.durationSeconds,
usedCredits: entry.usedCredits ?? '250000',
limitCredits: entry.limitCredits ?? '1000000',
remainingCredits: entry.remainingCredits ?? '750000',
resetsAt:
entry.resetsAt === undefined
? new Date(Date.now() + 1000).toISOString()
: entry.resetsAt,
windowStart: null,
resetMode: 'activity_triggered',
})),
},
},
};
}
function billing(options: Parameters<typeof preflight>[0] = {}): WorkspaceBillingResponse {
return { summary: null, workspaceBalance: null, ...preflight(options) } as WorkspaceBillingResponse;
}
function panel(options: {
response?: WorkspaceBillingResponse | null;
context?: WorkspaceCollabContext;
locale?: 'en' | 'zh-CN';
usageUrl?: string | null;
onUsageClick?: () => void;
} = {}) {
return <I18nProvider initial={options.locale ?? 'zh-CN'}>
<CodingPlanUsage context={options.context ?? personal()}
billing={options.response === undefined ? billing() : options.response}
usageUrl={options.usageUrl === undefined ? USAGE_URL : options.usageUrl}
onUsageClick={options.onUsageClick}
wallet={{ balanceUsd: '99', url: `${USAGE_URL}&billing=recharge` }} />
</I18nProvider>;
}
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
describe('personal plan presentation from the shared billing snapshot', () => {
it.each([
['go', [18000, 604800], 2],
['plus', [18000, 604800, 2592000], 1],
['pro', [18000, 604800], 1],
['max', [604800], 1],
] as const)('follows the design windows for %s', (tier, durations, count) => {
render(panel({ response: billing({ tier, windows: [...durations].reverse().map(durationSeconds => ({ durationSeconds })) }) }));
const blocks = screen.getAllByTestId('coding-plan-quota-block');
expect(blocks).toHaveLength(count);
expect(screen.queryByText('30 天')).toBeNull();
if (tier === 'go') expect(within(blocks[0]!).getByText('5 小时')).toBeTruthy();
else expect(screen.queryByText('5 小时')).toBeNull();
expect(screen.getByText('7 天')).toBeTruthy();
});
it.each([
['250000', '75'], ['355000', '64'], ['1000000', '0'], ['1100000', '0'],
])('shows remaining quota for %s spent credits', (usedCredits, percent) => {
render(panel({ response: billing({ windows: [{ durationSeconds: 604800, usedCredits }] }) }));
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe(percent);
expect(screen.getByTestId('coding-plan-quota-fill').style.width).toBe(`${percent}%`);
expect(screen.getByText(`剩余 ${percent}%`)).toBeTruthy();
expect(screen.queryByText('已用完')).toBeNull();
expect(screen.queryByText(/重置/)).toBeNull();
});
it('renders the English allowance and period', () => {
render(panel({ locale: 'en' }));
expect(screen.getByText('Design Plan')).toBeTruthy();
expect(screen.getByText('7 days')).toBeTruthy();
expect(screen.getByText('75% left')).toBeTruthy();
expect(screen.getByText('US$0.00')).toBeTruthy();
});
it('links the allowance and wallet to their distinct destinations', () => {
const click = vi.fn();
render(panel({ onUsageClick: click }));
const link = screen.getByTestId('coding-plan-quota-entry');
expect(link.getAttribute('href')).toBe(USAGE_URL);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
fireEvent.click(link);
expect(click).toHaveBeenCalledOnce();
expect(screen.getByTestId('entry-nav-credits-row').getAttribute('href')).toContain('billing=recharge');
});
it('keeps the allowance readable without a destination', () => {
render(panel({ usageUrl: null }));
expect(screen.getByText('剩余 75%')).toBeTruthy();
expect(screen.queryByTestId('coding-plan-quota-entry')).toBeNull();
});
it.each([
{ eligible: false, tier: null }, { eligible: true, tier: null }, { windows: [] },
{ windows: [{ durationSeconds: 604800, limitCredits: '0' }] }, { omit: true },
])('falls back to the scoped wallet when quota is unavailable: %j', options => {
render(panel({ response: billing(options) }));
expect(screen.queryByRole('progressbar')).toBeNull();
expect(screen.getByTestId('entry-nav-credits-row')).toBeTruthy();
});
it.each([{ workspaceType: 'team' as const }, { planId: 'team_pro' }])('does not render personal quota for %j', overrides => {
const { container } = render(panel({ context: personal(overrides) }));
expect(container.firstChild).toBeNull();
});
it.each([['free', 0], ['go', 2], ['plus', 1], ['pro', 1], ['max', 1]] as const)('matches the %s cold-loading skeleton', (tier, blocks) => {
render(<I18nProvider initial="zh-CN"><CodingPlanUsage context={personal({ planId: null })}
planTier={tier} billing={null} wallet={{ balanceUsd: '99', url: null }} /></I18nProvider>);
expect(screen.getByTestId('coding-plan-quota-skeleton')).toBeTruthy();
expect(screen.queryAllByTestId('coding-plan-skeleton-block')).toHaveLength(blocks);
expect(screen.getByTestId('coding-plan-wallet-skeleton')).toBeTruthy();
expect(screen.queryByText('US$99.00')).toBeNull();
});
it('renders a cached snapshot immediately without requesting on mount or remount', () => {
const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher);
const first = render(panel());
expect(screen.getByText('剩余 75%')).toBeTruthy();
first.unmount();
render(panel());
expect(screen.queryByTestId('coding-plan-quota-skeleton')).toBeNull();
expect(screen.getByText('剩余 75%')).toBeTruthy();
expect(fetcher).not.toHaveBeenCalled();
});
it('replaces quota and wallet together when the shared snapshot changes', () => {
const { rerender } = render(panel());
const next = billing({ windows: [{ durationSeconds: 604800, usedCredits: '1000000' }] });
next.preflight!.balanceUsd = '12';
rerender(panel({ response: next }));
expect(screen.getByText('剩余 0%')).toBeTruthy();
expect(screen.getByText('US$12.00')).toBeTruthy();
expect(screen.queryByTestId('coding-plan-quota-skeleton')).toBeNull();
});
it('never displays another member’s cached snapshot', () => {
render(panel({ response: billing({ member: 'other' }) }));
expect(screen.queryByRole('progressbar')).toBeNull();
expect(screen.getByText('US$99.00')).toBeTruthy();
});
});