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>
384 lines
12 KiB
TypeScript
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',
|
|
],
|
|
]);
|
|
});
|
|
});
|