Files
open-design/apps/web/tests/components/coding-plan-usage-model.test.ts
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

140 lines
5.6 KiB
TypeScript

// The pure reading of a Coding Plan preflight: which windows the panel draws,
// in which order, the REMAINING whole-percent share each one draws, and the
// period each one is named by.
//
// Kept apart from the component spec so the product rule — 「后端 preflight
// windows[] 逐条渲染,按 durationSeconds 升序」 — can be pinned against raw
// server payloads without rendering anything.
import { describe, expect, it } from 'vitest';
import { codingPlanQuotaView } from '../../src/components/coding-plan-usage-model';
function window(overrides: Partial<Parameters<typeof codingPlanQuotaView>[0][number]> = {}) {
return {
policyId: 'p',
durationSeconds: 18_000,
resetMode: 'activity_triggered' as const,
usedCredits: '25000',
limitCredits: '100000',
remainingCredits: '75000',
windowStart: null,
resetsAt: null,
...overrides,
};
}
describe('which windows the panel draws', () => {
// The design ships a Go panel with TWO blocks (5 小时 over 7 天) and a
// Plus/Pro/Max panel with one. Neither is hard-coded: the backend's window
// list is the whole truth, and the client renders it entry by entry.
it('draws every window the server sent, shortest first', () => {
const views = codingPlanQuotaView([
window({ policyId: 'month', durationSeconds: 2_592_000 }),
window({ policyId: 'week', durationSeconds: 604_800 }),
window({ policyId: 'five-hour', durationSeconds: 18_000 }),
]);
expect(views.map((view) => view.policyId)).toEqual(['five-hour', 'week', 'month']);
expect(views.map((view) => view.durationSeconds)).toEqual([18_000, 604_800, 2_592_000]);
});
it('draws the Go pair as two blocks, the 7-day-only plan as one', () => {
const go = codingPlanQuotaView([
window({ durationSeconds: 604_800 }),
window({ durationSeconds: 18_000 }),
]);
const plus = codingPlanQuotaView([window({ durationSeconds: 604_800 })]);
expect(go).toHaveLength(2);
expect(plus).toHaveLength(1);
});
it('has nothing to draw when the server sent no windows', () => {
expect(codingPlanQuotaView([])).toEqual([]);
});
});
describe('the period each block is named by', () => {
it.each([
{ name: 'a whole-hour window under a day', seconds: 18_000, key: 'hours', count: 5 },
{ name: 'a one-hour window', seconds: 3_600, key: 'hours', count: 1 },
{ name: 'a whole-day window', seconds: 604_800, key: 'days', count: 7 },
{ name: 'a one-day window', seconds: 86_400, key: 'days', count: 1 },
{ name: 'a 30-day window', seconds: 2_592_000, key: 'days', count: 30 },
])('names $name', ({ seconds, key, count }) => {
const [view] = codingPlanQuotaView([window({ durationSeconds: seconds })]);
expect(view?.periodLabel).toEqual({
key: key === 'hours' ? 'billing.codingPlanPeriodHours' : 'billing.codingPlanPeriodDays',
count,
});
});
// A duration the design's two units cannot name exactly (90 minutes, 36
// hours). Drawing 「1.5 天」 would invent a unit the design has no copy for,
// so the block keeps its name and drops the period suffix — the bar and the
// share still read.
it.each([
{ name: 'a window that is not a whole hour', seconds: 5_400 },
{ name: 'a multi-day window that is not a whole number of days', seconds: 129_600 },
{ name: 'a zero-length window', seconds: 0 },
])('draws no period for $name', ({ seconds }) => {
const [view] = codingPlanQuotaView([window({ durationSeconds: seconds })]);
expect(view?.periodLabel).toBeNull();
});
});
describe('the share each block draws', () => {
// The v2 spec inverts the number: the row reads 「剩余 N%」 and the bar fills
// to the REMAINING share, not the spent one.
it('reports the REMAINING share as a whole percent', () => {
const [view] = codingPlanQuotaView([
window({ usedCredits: '25000', limitCredits: '100000' }),
]);
expect(view?.remainingPercent).toBe(75);
});
it.each([
{ name: 'rounds a fraction at the half', used: '35500', limit: '100000', remaining: 64 },
{ name: 'rounds a fraction below the half', used: '35400', limit: '100000', remaining: 65 },
// A spent pool reads 0% left, never a negative — and never a red state: the
// panel has no exhausted styling by product ruling.
{ name: 'clamps an over-spent pool at 0', used: '1040000', limit: '1000000', remaining: 0 },
{ name: 'clamps an untouched pool at 100', used: '0', limit: '1000000', remaining: 100 },
])('$name', ({ used, limit, remaining }) => {
const [view] = codingPlanQuotaView([
window({ usedCredits: used, limitCredits: limit }),
]);
expect(view?.remainingPercent).toBe(remaining);
});
it('carries credit counts past Number.MAX_SAFE_INTEGER without losing the share', () => {
const [view] = codingPlanQuotaView([
window({
usedCredits: '9007199254740993000',
limitCredits: '18014398509481986000',
}),
]);
expect(view?.remainingPercent).toBe(50);
});
// An unreadable window is an ABSENCE of that block, not of the whole panel:
// a readable 5-hour pool still draws beside a broken 7-day one.
it.each([
{ name: 'a limit of zero', used: '0', limit: '0' },
{ name: 'a count the server did not write as a decimal', used: '1e6', limit: '100000' },
])('drops a block whose numbers are not a readable share — $name', ({ used, limit }) => {
const views = codingPlanQuotaView([
window({ policyId: 'broken', durationSeconds: 18_000, usedCredits: used, limitCredits: limit }),
window({ policyId: 'week', durationSeconds: 604_800 }),
]);
expect(views.map((view) => view.policyId)).toEqual(['week']);
});
});