Files
open-design/apps/daemon/tests/vela-billing.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

384 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
VelaWorkspaceBillingSnapshotUnsupportedError,
fetchBillingCheckoutUrl,
fetchVelaBillingCatalog,
fetchVelaBillingSummary,
fetchVelaWorkspaceBillingProjection,
fetchVelaWorkspaceBalance,
isVelaWorkspaceAuthorizationError,
parseBillingCatalog,
parseBillingSummary,
parseWorkspaceBillingSnapshot,
parseWorkspaceWalletBalance,
} from '../src/integrations/vela-billing.js';
// A representative `vela billing summary --format json` payload.
const SAMPLE = JSON.stringify({
balanceUsd: '1.2500',
creditsPerUsd: 10000,
balances: { subscriptionCredits: '5000', rechargeCredits: '7500', totalAvailableCredits: '12500' },
membershipTier: 'team',
billingInterval: 'monthly',
subscriptionStatus: 'active',
availableActions: ['subscription_checkout', 'billing_portal'],
});
const CATALOG_SAMPLE = JSON.stringify({
workspaceId: 'ws_team',
billingInterval: 'monthly',
plans: [
{
planId: 'team_plus',
seatUnitAmountCents: 3900,
currency: 'usd',
minSeats: 1,
status: 'active',
},
],
});
const WORKSPACE_BALANCE_SAMPLE = JSON.stringify({
balanceUsd: '7.8900',
expiresAt: null,
updatedAt: '2026-07-26T12:00:00Z',
billingScopeVersion: 2,
workspaceId: 'ws_team',
workspaceMemberId: 'member_team',
});
const WORKSPACE_SNAPSHOT_SAMPLE = JSON.stringify({
schemaVersion: 1,
workspaceId: 'ws_team',
workspaceMemberId: 'member_team',
billingScopeVersion: 2,
billing: {
billingState: 'active',
planId: 'team_plus',
},
wallet: {
balanceUsd: '7.8900',
expiresAt: null,
updatedAt: '2026-07-26T12:00:00Z',
},
revisions: {
billing: 'billing-rev-1',
wallet: 'wallet-rev-1',
},
revisionClocks: {
billing: { epoch: 'billing-epoch-a', counter: '12' },
wallet: { epoch: 'wallet-epoch-a', counter: '34' },
},
});
describe('vela billing 收口', () => {
// Acceptance #112: B splits the wallet into a subscription grant bucket and
// a top-up bucket (`balances.subscriptionCredits` / `balances.rechargeCredits`,
// summing to `totalAvailableCredits`). The mapper kept only the total, so the
// menu's 附加积分 row had no field to read and could only ever print 0.
it('maps the vela billing summary JSON, including BOTH credit buckets', () => {
expect(parseBillingSummary(SAMPLE)).toEqual({
workspaceId: null,
membershipTier: 'team',
totalAvailableCredits: 12500,
subscriptionCredits: 5000,
rechargeCredits: 7500,
balanceUsd: '1.2500',
creditsPerUsd: 10000,
subscriptionStatus: 'active',
availableActions: ['subscription_checkout', 'billing_portal'],
workspaceBalance: null,
});
});
// The credits-per-dollar rate is B's, and the Coding Plan quota panel is the
// only place that turns a window's credit count into the money the user
// thinks in. Dropping the field here forces the client to keep a copy of a
// server-owned exchange rate, which goes wrong silently the day B changes it.
it('passes the summary creditsPerUsd through to the client', () => {
expect(parseBillingSummary(SAMPLE)?.creditsPerUsd).toBe(10000);
// B has shipped decimal strings for money-adjacent fields before.
expect(
parseBillingSummary(JSON.stringify({ balanceUsd: '1', creditsPerUsd: '100000' }))
?.creditsPerUsd,
).toBe(100000);
});
it.each([
{ name: 'absent', raw: {} },
{ name: 'zero', raw: { creditsPerUsd: 0 } },
{ name: 'negative', raw: { creditsPerUsd: -10000 } },
{ name: 'not a number', raw: { creditsPerUsd: 'lots' } },
{ name: 'null', raw: { creditsPerUsd: null } },
])('omits a $name creditsPerUsd rather than shipping a bad rate', ({ raw }) => {
const summary = parseBillingSummary(JSON.stringify({ balanceUsd: '1', ...raw }));
expect(summary).not.toBeNull();
expect(summary?.creditsPerUsd).toBeUndefined();
});
it('returns null on empty or malformed output (clean "no summary")', () => {
expect(parseBillingSummary('')).toBeNull();
expect(parseBillingSummary('not json')).toBeNull();
});
it('degrades to null when the CLI throws — no billing session', async () => {
const out = await fetchVelaBillingSummary({
run: async () => {
throw new Error('no vela session');
},
});
expect(out).toBeNull();
});
it('drives the injected runner and maps its output', async () => {
const seen: string[][] = [];
const out = await fetchVelaBillingSummary({
run: async (args) => {
seen.push(args);
return SAMPLE;
},
});
expect(out?.membershipTier).toBe('team');
expect(out?.totalAvailableCredits).toBe(12500);
expect(out?.rechargeCredits).toBe(7500);
expect(out?.availableActions).toContain('billing_portal');
expect(out?.workspaceId).toBeNull();
expect(seen).toEqual([['summary', '--format', 'json']]);
});
it('fetches one explicit workspace balance and preserves backend scope identity', async () => {
const seen: string[][] = [];
const out = await fetchVelaWorkspaceBalance('ws_team', {
run: async (args) => {
seen.push(args);
return WORKSPACE_BALANCE_SAMPLE;
},
});
expect(out).toEqual({
balanceUsd: '7.8900',
expiresAt: null,
updatedAt: '2026-07-26T12:00:00Z',
billingScopeVersion: 2,
workspaceId: 'ws_team',
workspaceMemberId: 'member_team',
});
expect(seen).toEqual([
['workspace-balance', '--workspace-id', 'ws_team', '--format', 'json'],
]);
});
it('rejects an unscoped or foreign workspace balance instead of self-stamping it', () => {
expect(parseWorkspaceWalletBalance(JSON.stringify({ balanceUsd: '7.89' }), 'ws_team')).toBeNull();
expect(
parseWorkspaceWalletBalance(
JSON.stringify({
balanceUsd: '7.89',
billingScopeVersion: 2,
workspaceId: 'ws_other',
workspaceMemberId: 'member_other',
}),
'ws_team',
),
).toBeNull();
});
it('maps one authoritative workspace billing snapshot without changing its scope', () => {
expect(parseWorkspaceBillingSnapshot(WORKSPACE_SNAPSHOT_SAMPLE, 'ws_team')).toEqual({
schemaVersion: 1,
workspaceId: 'ws_team',
workspaceMemberId: 'member_team',
billingScopeVersion: 2,
billing: {
billingState: 'active',
planId: 'team_plus',
},
wallet: {
balanceUsd: '7.8900',
expiresAt: null,
updatedAt: '2026-07-26T12:00:00Z',
},
revisions: {
billing: 'billing-rev-1',
wallet: 'wallet-rev-1',
},
revisionClocks: {
billing: { epoch: 'billing-epoch-a', counter: '12' },
wallet: { epoch: 'wallet-epoch-a', counter: '34' },
},
});
expect(parseWorkspaceBillingSnapshot(WORKSPACE_SNAPSHOT_SAMPLE, 'ws_other')).toBeNull();
expect(
parseWorkspaceBillingSnapshot(
JSON.stringify({
...JSON.parse(WORKSPACE_SNAPSHOT_SAMPLE),
billing: { billingState: 'mystery', planId: 'team_plus' },
}),
'ws_team',
),
).toBeNull();
});
it('drops malformed additive revision clocks without rejecting the legacy snapshot', () => {
const raw = JSON.parse(WORKSPACE_SNAPSHOT_SAMPLE);
raw.revisionClocks = {
billing: { epoch: 'billing-epoch-a', counter: '-1' },
wallet: { epoch: '', counter: '34' },
};
const parsed = parseWorkspaceBillingSnapshot(JSON.stringify(raw), 'ws_team');
expect(parsed).not.toBeNull();
expect(parsed).not.toHaveProperty('revisionClocks');
expect(parsed?.revisions).toEqual({
billing: 'billing-rev-1',
wallet: 'wallet-rev-1',
});
});
it('uses the additive workspace snapshot command when the CLI supports it', async () => {
const seen: string[][] = [];
const out = await fetchVelaWorkspaceBillingProjection('ws_team', {
run: async (args) => {
seen.push(args);
return WORKSPACE_SNAPSHOT_SAMPLE;
},
});
expect(seen).toEqual([
['workspace-snapshot', '--workspace-id', 'ws_team', '--format', 'json'],
]);
expect(out.snapshot?.billing.planId).toBe('team_plus');
expect(out.workspaceBalance).toMatchObject({
workspaceId: 'ws_team',
workspaceMemberId: 'member_team',
balanceUsd: '7.8900',
});
});
it('falls back to the legacy balance command only for typed unsupported CLIs', async () => {
const seen: string[][] = [];
const out = await fetchVelaWorkspaceBillingProjection('ws_team', {
run: async (args) => {
seen.push(args);
if (args[0] === 'workspace-snapshot') {
throw new VelaWorkspaceBillingSnapshotUnsupportedError();
}
return WORKSPACE_BALANCE_SAMPLE;
},
});
expect(seen).toEqual([
['workspace-snapshot', '--workspace-id', 'ws_team', '--format', 'json'],
['workspace-balance', '--workspace-id', 'ws_team', '--format', 'json'],
]);
expect(out.snapshot).toBeNull();
expect(out.workspaceBalance?.balanceUsd).toBe('7.8900');
});
it('falls back when the installed old CLI rejects the new snapshot flags', async () => {
const seen: string[][] = [];
const out = await fetchVelaWorkspaceBillingProjection('ws_team', {
run: async (args) => {
seen.push(args);
if (args[0] === 'workspace-snapshot') {
throw new Error('unknown flag: --workspace-id');
}
return WORKSPACE_BALANCE_SAMPLE;
},
});
expect(seen).toEqual([
['workspace-snapshot', '--workspace-id', 'ws_team', '--format', 'json'],
['workspace-balance', '--workspace-id', 'ws_team', '--format', 'json'],
]);
expect(out.snapshot).toBeNull();
expect(out.workspaceBalance?.balanceUsd).toBe('7.8900');
});
it('does not turn an auth/network snapshot failure into a successful empty balance', async () => {
await expect(
fetchVelaWorkspaceBillingProjection('ws_team', {
run: async () => {
throw new Error('control key expired');
},
}),
).rejects.toThrow('control key expired');
});
it('recognizes old and new CLI authorization envelopes without classifying timeouts', () => {
expect(
isVelaWorkspaceAuthorizationError(
new Error(
'fetch workspace billing snapshot: API request failed with status 403: workspace_not_authorized',
),
),
).toBe(true);
expect(
isVelaWorkspaceAuthorizationError(
Object.assign(new Error('denied'), { code: 'auth_required' }),
),
).toBe(true);
expect(isVelaWorkspaceAuthorizationError(new Error('request timed out'))).toBe(false);
});
it('maps the vela team billing catalog JSON into client catalog data', () => {
expect(parseBillingCatalog(CATALOG_SAMPLE)).toEqual({
workspaceId: 'ws_team',
billingInterval: 'monthly',
plans: [
{
planId: 'team_plus',
seatUnitAmountCents: 3900,
currency: 'usd',
minSeats: 1,
status: 'active',
},
],
});
expect(parseBillingCatalog('not json')).toBeNull();
});
it('fetches team billing catalog through the vela CLI workspace route', async () => {
const seen: string[][] = [];
const out = await fetchVelaBillingCatalog('ws_team', {
run: async (args) => {
seen.push(args);
return CATALOG_SAMPLE;
},
});
expect(out?.plans[0]?.planId).toBe('team_plus');
expect(seen).toEqual([
['team-catalog', '--workspace-id', 'ws_team', '--format', 'json'],
]);
});
it('starts checkout with workspace and selected team plan through the vela CLI', async () => {
const seen: string[][] = [];
const url = await fetchBillingCheckoutUrl({
workspaceId: 'ws_team',
planId: 'team_pro',
seats: 4,
run: async (args) => {
seen.push(args);
return JSON.stringify({
checkoutSessionId: 'cs_team',
checkoutUrl: 'https://checkout.stripe.test/cs_team',
});
},
});
expect(url).toBe('https://checkout.stripe.test/cs_team');
expect(seen).toEqual([
[
'checkout',
'--workspace-id',
'ws_team',
'--plan-id',
'team_pro',
'--seats',
'4',
'--format',
'json',
],
]);
});
});