Skip to content

feat(settings): add usage and billing balance - #468

Open
lucas77778 wants to merge 3 commits into
masterfrom
chenyu/code-608
Open

feat(settings): add usage and billing balance#468
lucas77778 wants to merge 3 commits into
masterfrom
chenyu/code-608

Conversation

@lucas77778

Copy link
Copy Markdown
Member

Summary

  • implement CODE-608 by fetching the active organization's LinkCode Credits balance through the Cloud billing summary endpoint on desktop and webview
  • add Usage & billing under the Personal settings group with a restrained Codex-style balance card
  • preserve loading, signed-out, missing-organization, and error states while keeping top-ups, subscriptions, orders, and checkout on LinkCode Cloud

Verification

  • pnpm check:ci
  • pnpm test — 2,987 passed, 1 skipped
  • pnpm -F @linkcode/webview build
  • pnpm -F @linkcode/desktop build
  • pnpm -F @linkcode/webview e2e:browser
  • visually exercised and recorded the signed-in balance flow at 1280×800

Checklist

  • pnpm check:ci and pnpm test both pass
  • I ran the affected surface and observed the change working
  • No wire message changed
  • New code and assets are original work
  • No documentation changes are required for this settings addition

Copilot AI lite review requested due to automatic review settings August 21, 2026 14:07
@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

CODE-608

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 hookpackages/client/workbench/src/cloud/billing.ts adds CloudBillingSummarySchema, the CloudBillingSummary / CloudBillingSource types and useCloudBillingSummary(scopeKey, source), keyed as ['cloud/billing/summary', scopeKey] with revalidateOnFocus: true.
  • Desktop main-process sourcemain/cloud-auth/billing.ts reads activeOrganizationId off authClient.getSession(), fetches /organizations/{id}/billing/summary with the keychain cookie, validates, and returns null when the session has no active organization; wired through a new CLOUD_GET_BILLING_SUMMARY_CHANNEL, the preload linkcodeCloud bridge and cloudDataBridge.
  • Browser sourceapps/webview/src/cloud/billing.ts fetches the same endpoint with credentials: 'include', and organizationClient() is registered on the webview auth client so session.session.activeOrganizationId is typed.
  • Panel rewriteBillingSettingsPanel now takes a BillingBalanceView discriminated union (loading / signed-out / missing-organization / error / ready), rendered through an exhaustive switch closed by never(), and moves to SettingsSection / SettingsCard.
  • Settings navigation and copy — the billing entry moves into the Personal group on both desktop and webview, and the settings.billing i18n 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 @webview alias 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 — the summary.data === null arm is unreachable on this surface: fetchCloudBillingSummary is typed Promise<CloudBillingSummary> and never resolves null, and !organizationId is already handled two lines above.
  • packages/presentation/ui/src/shell/billing-settings-panel.tsx:37onSignIn is optional but both call sites pass it, so the onSignIn && half of the guard is dead; a signed-out balance 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 — getBillingSummary ignores the scopeKey it 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 until revalidateOnFocus completes.\n\n
    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\n
    "}, {"path": "packages/client/workbench/src/cloud/billing.ts", "line": 13, "body": "The strictness is inverted relative to what the UI consumes: availableAmount and reservedAmount carry exact integer regexes but no consumer in this diff ever reads them, while displayBalance.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 the error state, which apps/desktop/src/main/__tests__/cloud-billing.test.ts:68 encodes as intended behavior; meanwhile a malformed amount renders 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
    "}]

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

@pullfrog

pullfrog Bot commented Aug 21, 2026

Copy link
Copy Markdown

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

apps/desktop/src/renderer/src/settings/billing-tab.tsx:11

The SWR key identifies the user (cloud.account?.email) while the data is scoped to whatever organization the main-process session considers active. getBillingSummary at line 7 also drops the scopeKey it 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 until revalidateOnFocus completes.

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

packages/client/workbench/src/cloud/billing.ts:13

The strictness is inverted relative to what the UI consumes: availableAmount and reservedAmount carry exact integer regexes but no consumer in this diff ever reads them, while displayBalance.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 the error state, which apps/desktop/src/main/__tests__/cloud-billing.test.ts:68 encodes as intended behavior; meanwhile a malformed amount renders verbatim next to "USD".

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.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants