feat(settings): add usage and billing balance - #468
Conversation
There was a problem hiding this comment.
ℹ️ Nothing blocking — two informational notes inline and a couple of scope questions below.
Reviewed changes — full review of all 23 files across the three commits on chenyu/code-608, plus the surrounding Cloud-bridge, auth-store, i18n and settings-navigation code the diff leans on.
- New shared balance hook —
packages/client/workbench/src/cloud/billing.tsaddsCloudBillingSummarySchema, theCloudBillingSummary/CloudBillingSourcetypes anduseCloudBillingSummary(scopeKey, source), keyed as['cloud/billing/summary', scopeKey]withrevalidateOnFocus: true. - Desktop main-process source —
main/cloud-auth/billing.tsreadsactiveOrganizationIdoffauthClient.getSession(), fetches/organizations/{id}/billing/summarywith the keychain cookie, validates, and returnsnullwhen the session has no active organization; wired through a newCLOUD_GET_BILLING_SUMMARY_CHANNEL, the preloadlinkcodeCloudbridge andcloudDataBridge. - Browser source —
apps/webview/src/cloud/billing.tsfetches the same endpoint withcredentials: 'include', andorganizationClient()is registered on the webview auth client sosession.session.activeOrganizationIdis typed. - Panel rewrite —
BillingSettingsPanelnow takes aBillingBalanceViewdiscriminated union (loading/signed-out/missing-organization/error/ready), rendered through an exhaustiveswitchclosed bynever(), and moves toSettingsSection/SettingsCard. - Settings navigation and copy — the billing entry moves into the Personal group on both desktop and webview, and the
settings.billingi18n namespace is reworked in both locales ("Usage & billing", credits/sign-in/missing-org/error strings). - Tests — new unit suites for both fetch paths and the panel's states, an expanded webview route test, updated browser smoke assertions with a stubbed
/auth/get-session, and a@webviewalias added to the root vitest project.
I checked the things this kind of change usually breaks and they are clean: both BillingSettingsPanel call sites are updated and apps/mobile has no billing surface; both locales (the only two) received every new key and no removed key is still referenced; searchKeywords.billing still resolves; the three strings the E2E asserts match en.ts verbatim; the nav move is symmetric across surfaces; loaded already existed on the auth store; and the new @webview alias lands in the vitest project whose include covers apps/**/src/**/__tests__. The duplicated zod schema in desktop main matches the existing im.ts / im-source.ts pattern (main cannot import the SWR-bearing workbench barrel at runtime), and adding organizationClient() to the webview client is types-only — activeOrganizationId is a server-side session column and the client plugin does not change useSession()'s fetch behavior.
ℹ️ "Usage & billing" ships no usage
The tab is renamed on both surfaces and settings.billing.title is updated to "Usage & billing", but the panel shows a credits balance only — there is no usage breakdown, and description scopes the tab to credits. If a usage panel is a follow-up this is fine as forward-looking naming; if not, the label promises something the screen does not deliver.
Technical details
# "Usage & billing" tab has no usage surface
## Affected sites
- `packages/presentation/i18n/src/locales/en.ts:801,1180` — `tabs.billing` and `billing.title` renamed to "Usage & billing"
- `packages/presentation/i18n/src/locales/zh-cn.ts:786,1149` — same rename
- `packages/presentation/ui/src/shell/billing-settings-panel.tsx:27-51` — panel renders `hostedHint` + the credits card only
## Open questions for the human
- Is a usage panel landing in a follow-up issue, or should the tab stay "Billing" until it does?
- `settings.billing.title` is edited here but referenced nowhere — settings page headings come from `settings.tabs.*`. It was already dead before this PR; worth deleting from both locales while the namespace is open, unless the panel is meant to render it.ℹ️ A negative balance renders like a healthy one
Both schemas deliberately admit negative amounts (^-?\d+$) and apps/webview/src/cloud/__tests__/billing.test.ts covers a -0.50 display balance, so arrears are a real state. The ready branch renders it in the same weight and colour as a positive balance, with no signal that the organization may be blocked from spending.
Technical details
# Arrears are indistinguishable from a healthy balance
## Affected sites
- `packages/presentation/ui/src/shell/billing-settings-panel.tsx:71-80` — the `ready` branch renders `balance.amount` with fixed styling regardless of sign
- `packages/client/workbench/src/cloud/billing.ts:5` — `nanoUsdAmountSchema` explicitly permits a leading `-`
- `apps/webview/src/cloud/__tests__/billing.test.ts:18-20` — a negative summary is an expected response shape
## Required outcome
- A user in arrears can tell from the card that their balance is negative, not merely small.
## Open questions for the human
- Is a negative credits balance actually reachable for a LinkCode Cloud organization, or does the Cloud API clamp at zero? If it is reachable, does the design call for a destructive-toned amount, a badge, or nothing at all?ℹ️ Nitpicks
apps/webview/src/routes/settings/billing.tsx:20— thesummary.data === nullarm is unreachable on this surface:fetchCloudBillingSummaryis typedPromise<CloudBillingSummary>and never resolvesnull, and!organizationIdis already handled two lines above.packages/presentation/ui/src/shell/billing-settings-panel.tsx:37—onSignInis optional but both call sites pass it, so theonSignIn &&half of the guard is dead; asigned-outbalance without a handler would silently render "Manage credits" next to "Sign in to LinkCode Cloud to view your balance." Making the prop required drops the branch.
<comments">[{"path": "apps/desktop/src/renderer/src/settings/billing-tab.tsx", "line": 11, "body": "The SWR key identifies the user (email) while the data is scoped to whatever organization the main-process session considers active —getBillingSummaryignores thescopeKeyit is handed, so the key and the value it caches have no relationship. Neither app has an in-app organization switcher, so this only bites when the active org changes on the web while the desktop app is open: the key is unchanged, and the card serves the previous organization's balance from cache untilrevalidateOnFocuscompletes.\n\n"}, {"path": "packages/client/workbench/src/cloud/billing.ts", "line": 13, "body": "The strictness is inverted relative to what the UI consumes:Technical details
\n\nmarkdown\n# Desktop billing SWR key does not track the organization the balance belongs to\n\n## Affected sites\n- `apps/desktop/src/renderer/src/settings/billing-tab.tsx:7,11` — `getBillingSummary` drops the `scopeKey` argument; the key passed in is `cloud.account?.email`\n- `apps/desktop/src/main/cloud-auth/billing.ts:20` — the org actually queried comes from `session.data.session.activeOrganizationId` in main, invisible to the renderer\n- `packages/client/workbench/src/cloud/billing.ts:20,29-30` — `CloudBillingSource` declares `(scopeKey: string)` and the hook calls `source(key)`, so the contract expects the key to select the data\n\n## Required outcome\n- The desktop cache entry is invalidated when the active organization changes, not only when the account changes.\n\n## Suggested approach (optional)\n- Return the organization id alongside the summary (or expose it on the existing cloud session bridge) and use it as the `scopeKey`, matching what `apps/webview/src/routes/settings/billing.tsx:13` already does. That also makes `getBillingSummary` honour the `CloudBillingSource` signature instead of ignoring it.\n\n## Open questions for the human\n- Can a LinkCode Cloud account belong to more than one organization today? If the active org is effectively fixed per account, this is theoretical and a comment on the key choice is enough.\n\n\navailableAmountandreservedAmountcarry exact integer regexes but no consumer in this diff ever reads them, whiledisplayBalance.amount— the only value rendered — is an unconstrained string. So a representation change to a field nobody displays (a number instead of a string, a decimal) sends the whole panel to theerrorstate, whichapps/desktop/src/main/__tests__/cloud-billing.test.ts:68encodes as intended behavior; meanwhile a malformedamountrenders verbatim next to "USD".\n\n"}]Technical details
\n\nmarkdown\n# Balance schema validates the fields it ignores and not the one it renders\n\n## Affected sites\n- `packages/client/workbench/src/cloud/billing.ts:5,9,10` — strict regexes on `availableAmount` / `reservedAmount`, neither of which is read by `billing-tab.tsx`, `billing.tsx`, or `billing-settings-panel.tsx`\n- `packages/client/workbench/src/cloud/billing.ts:13` — `amount: z.string()`, the only field rendered\n- `apps/desktop/src/main/cloud-auth/billing.ts:9,10,13` — the duplicated main-process schema has the same asymmetry\n\n## Required outcome\n- The rendered `displayBalance.amount` is validated at least as tightly as the fields that are not rendered, and a change to an unused field cannot blank the card on its own.\n\n## Suggested approach (optional)\n- Constrain `amount` to a decimal shape (e.g. `/^-?\\d+(\\.\\d+)?$/`) and relax the unread raw amounts to plain strings, or keep them strict and start using them (`availableAmount - reservedAmount` is the number a \"available balance\" label arguably promises).\n\n## Open questions for the human\n- Is `displayBalance.amount` derived from `availableAmount` alone or from `availableAmount - reservedAmount`? The card labels it \"Available balance\", so if reservations are excluded server-side the label is right and the raw fields are genuinely dead weight here.\n\n\n
Claude Opus | 𝕏
|
Two findings from the review above failed to attach as inline comments on submission (my mistake, not yours). Reposting them here with explicit file references — both are informational, neither blocks the merge. ℹ️ The desktop SWR key does not track the organization the balance belongs to
The SWR key identifies the user ( Technical details# Desktop billing SWR key does not track the organization the balance belongs to
## Affected sites
- `apps/desktop/src/renderer/src/settings/billing-tab.tsx:7,11` — `getBillingSummary` drops the `scopeKey` argument; the key passed in is `cloud.account?.email`
- `apps/desktop/src/main/cloud-auth/billing.ts:20` — the org actually queried comes from `session.data.session.activeOrganizationId` in main, invisible to the renderer
- `packages/client/workbench/src/cloud/billing.ts:20,29-30` — `CloudBillingSource` declares `(scopeKey: string)` and the hook calls `source(key)`, so the contract expects the key to select the data
## Required outcome
- The desktop cache entry is invalidated when the active organization changes, not only when the account changes.
## Suggested approach (optional)
- Return the organization id alongside the summary (or expose it on the existing cloud session bridge) and use it as the `scopeKey`, matching what `apps/webview/src/routes/settings/billing.tsx:13` already does. That also makes `getBillingSummary` honour the `CloudBillingSource` signature instead of ignoring it.
## Open questions for the human
- Can a LinkCode Cloud account belong to more than one organization today? If the active org is effectively fixed per account, this is theoretical and a comment on the key choice is enough.ℹ️ The balance schema validates the fields it ignores and not the one it renders
The strictness is inverted relative to what the UI consumes: Technical details# Balance schema validates the fields it ignores and not the one it renders
## Affected sites
- `packages/client/workbench/src/cloud/billing.ts:5,9,10` — strict regexes on `availableAmount` / `reservedAmount`, neither of which is read by `billing-tab.tsx`, `billing.tsx`, or `billing-settings-panel.tsx`
- `packages/client/workbench/src/cloud/billing.ts:13` — `amount: z.string()`, the only field rendered
- `apps/desktop/src/main/cloud-auth/billing.ts:9,10,13` — the duplicated main-process schema has the same asymmetry
## Required outcome
- The rendered `displayBalance.amount` is validated at least as tightly as the fields that are not rendered, and a change to an unused field cannot blank the card on its own.
## Suggested approach (optional)
- Constrain `amount` to a decimal shape (e.g. `/^-?\d+(\.\d+)?$/`) and relax the unread raw amounts to plain strings, or keep them strict and start using them (`availableAmount - reservedAmount` is the number an "available balance" label arguably promises).
## Open questions for the human
- Is `displayBalance.amount` derived from `availableAmount` alone or from `availableAmount - reservedAmount`? The card labels it "Available balance", so if reservations are excluded server-side the label is right and the raw fields are genuinely dead weight here.
|

Summary
Verification
pnpm check:cipnpm test— 2,987 passed, 1 skippedpnpm -F @linkcode/webview buildpnpm -F @linkcode/desktop buildpnpm -F @linkcode/webview e2e:browserChecklist
pnpm check:ciandpnpm testboth pass