From b68f75654a3786b43f8514f7deedab6cd2b5de7e Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 21 Aug 2026 12:43:20 +0000 Subject: [PATCH 1/3] feat(billing): add cloud credits balance sources Amp-Thread-ID: https://ampcode.com/threads/T-01a023a0-1972-706f-8c12-c1ca6fc0a641 --- .../src/main/__tests__/cloud-billing.test.ts | 76 +++++++++++++++++++ apps/desktop/src/main/cloud-auth/billing.ts | 33 ++++++++ apps/desktop/src/main/index.ts | 4 +- apps/desktop/src/preload/index.ts | 6 +- .../src/renderer/src/cloud-auth/bridges.ts | 6 +- apps/desktop/src/shared/cloud.ts | 3 + .../src/cloud/__tests__/billing.test.ts | 48 ++++++++++++ apps/webview/src/cloud/auth.ts | 2 + apps/webview/src/cloud/billing.ts | 14 ++++ .../client/workbench/src/cloud/billing.ts | 33 ++++++++ packages/client/workbench/src/cloud/index.ts | 1 + 11 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/cloud-billing.test.ts create mode 100644 apps/desktop/src/main/cloud-auth/billing.ts create mode 100644 apps/webview/src/cloud/__tests__/billing.test.ts create mode 100644 apps/webview/src/cloud/billing.ts create mode 100644 packages/client/workbench/src/cloud/billing.ts diff --git a/apps/desktop/src/main/__tests__/cloud-billing.test.ts b/apps/desktop/src/main/__tests__/cloud-billing.test.ts new file mode 100644 index 000000000..aae7b866d --- /dev/null +++ b/apps/desktop/src/main/__tests__/cloud-billing.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + fetch: vi.fn(), + getCookie: vi.fn(() => 'better-auth.session=token'), + getSession: vi.fn(), +})); + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: () => unknown) => mocks.handlers.set(channel, handler), + }, +})); + +vi.mock('../cloud-auth/client', () => ({ + authClient: { + getCookie: mocks.getCookie, + getSession: mocks.getSession, + }, + CLOUD_API_URL: 'https://api.linkcode.test', +})); + +const summary = { + denomination: 'nano_usd', + availableAmount: '12500000000', + reservedAmount: '500000000', + displayBalance: { currency: 'USD', amount: '12.50' }, +}; + +beforeEach(() => { + mocks.handlers.clear(); + mocks.fetch.mockReset(); + mocks.getSession.mockReset(); + mocks.getSession.mockResolvedValue({ + data: { session: { activeOrganizationId: 'org/1' } }, + error: null, + }); + vi.stubGlobal('fetch', mocks.fetch); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe('desktop Cloud billing bridge', () => { + it('reads and validates the active organization balance with the main-process session', async () => { + mocks.fetch.mockResolvedValue(Response.json(summary, { status: 200, statusText: 'OK' })); + const { registerCloudBillingBridge } = await import('../cloud-auth/billing'); + const { CLOUD_GET_BILLING_SUMMARY_CHANNEL } = await import('../../shared/cloud'); + registerCloudBillingBridge(); + + await expect(mocks.handlers.get(CLOUD_GET_BILLING_SUMMARY_CHANNEL)?.()).resolves.toEqual( + summary, + ); + expect(mocks.fetch).toHaveBeenCalledWith( + 'https://api.linkcode.test/organizations/org%2F1/billing/summary', + { headers: { cookie: 'better-auth.session=token' } }, + ); + }); + + it('returns null without an active organization', async () => { + mocks.getSession.mockResolvedValue({ data: { session: {} }, error: null }); + const { getCloudBillingSummary } = await import('../cloud-auth/billing'); + + await expect(getCloudBillingSummary()).resolves.toBeNull(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it('rejects a non-integer nano-USD balance', async () => { + mocks.fetch.mockResolvedValue( + Response.json({ ...summary, availableAmount: '12.50' }, { status: 200 }), + ); + const { getCloudBillingSummary } = await import('../cloud-auth/billing'); + + await expect(getCloudBillingSummary()).rejects.toThrow(); + }); +}); diff --git a/apps/desktop/src/main/cloud-auth/billing.ts b/apps/desktop/src/main/cloud-auth/billing.ts new file mode 100644 index 000000000..1803727e6 --- /dev/null +++ b/apps/desktop/src/main/cloud-auth/billing.ts @@ -0,0 +1,33 @@ +import type { CloudBillingSummary } from '@linkcode/workbench'; +import { ipcMain } from 'electron'; +import { z } from 'zod'; +import { CLOUD_GET_BILLING_SUMMARY_CHANNEL } from '../../shared/cloud'; +import { authClient, CLOUD_API_URL } from './client'; + +const billingSummarySchema = z.object({ + denomination: z.literal('nano_usd'), + availableAmount: z.string().regex(/^-?\d+$/), + reservedAmount: z.string().regex(/^\d+$/), + displayBalance: z.object({ + currency: z.literal('USD'), + amount: z.string(), + }), +}); + +export async function getCloudBillingSummary(): Promise { + const session = await authClient.getSession(); + if (session.error) throw new Error(session.error.message); + const organizationId = session.data?.session.activeOrganizationId; + if (!organizationId) return null; + + const res = await fetch( + `${CLOUD_API_URL}/organizations/${encodeURIComponent(organizationId)}/billing/summary`, + { headers: { cookie: authClient.getCookie() } }, + ); + if (!res.ok) throw new Error(`getCloudBillingSummary: ${res.status} ${res.statusText}`); + return billingSummarySchema.parse(await res.json()); +} + +export function registerCloudBillingBridge(): void { + ipcMain.handle(CLOUD_GET_BILLING_SUMMARY_CHANNEL, () => getCloudBillingSummary()); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 5bfc10c15..35c8e0fd7 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -10,6 +10,7 @@ import * as Sentry from '@sentry/electron/main'; import { app, BrowserWindow, dialog, Menu } from 'electron'; import { DESKTOP_SPAN_NAMES, DESKTOP_TRANSACTION_NAMES } from '../sentry-privacy'; import { applyThemePreference } from './appearance'; +import { registerCloudBillingBridge } from './cloud-auth/billing'; import { authClient, setupCloudAuth } from './cloud-auth/client'; import { registerCloudImBridge } from './cloud-auth/im'; import { registerCloudTunnelBridge } from './cloud-auth/tunnel'; @@ -62,8 +63,9 @@ if (app.requestSingleInstanceLock()) { // Wire the LinkCode Cloud auth protocol + IPC bridges. Must run BEFORE app is ready: the plugin // registers a privileged scheme via protocol.registerSchemesAsPrivileged, which throws once ready. setupCloudAuth(); - // Cloud data bridges (online hosts, IM Channel). Not scheme-related, but registered here + // Cloud data bridges (billing, online hosts, IM Channel). Not scheme-related, but registered here // alongside the rest of the cloud wiring; ipcMain.handle is safe before the app is ready. + registerCloudBillingBridge(); registerCloudTunnelBridge(); registerCloudImBridge(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 5dbdb052f..013db3ebe 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -4,6 +4,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import { CLOUD_CLAIM_DEEP_LINK_CHANNEL, CLOUD_CREATE_GATEWAY_KEY_CHANNEL, + CLOUD_GET_BILLING_SUMMARY_CHANNEL, CLOUD_IM_BINDINGS_CHANNEL, CLOUD_IM_CREATE_BINDING_CHANNEL, CLOUD_IM_DELETE_BINDING_CHANNEL, @@ -54,10 +55,11 @@ contextBridge.exposeInMainWorld('linkcodeConfig', configBridge); // coexists with the bridge above. setupRenderer(); -// Cloud data bridge: the renderer lists the account's online hosts through main (which holds the -// keychain session). Kept off the SystemBridge — it's cloud-account data, not a window/OS capability. +// Cloud data bridge: main holds the keychain session for these account-scoped requests. Kept off +// the SystemBridge — this is Cloud account data, not a window/OS capability. contextBridge.exposeInMainWorld('linkcodeCloud', { listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL), + billingSummary: () => ipcRenderer.invoke(CLOUD_GET_BILLING_SUMMARY_CHANNEL), claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL), openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL), createGatewayKey: (name: string) => ipcRenderer.invoke(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, name), diff --git a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts index b23479e53..a9cc7fc99 100644 --- a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts +++ b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts @@ -4,7 +4,7 @@ * process's inferred types across the process boundary, so the vendor's stable shape is mirrored here. */ -import type { CloudHost, CloudImSource } from '@linkcode/workbench'; +import type { CloudBillingSummary, CloudHost, CloudImSource } from '@linkcode/workbench'; import { traceRendererIpc } from '../ipc'; /** The authenticated user, as normalized by the electron plugin. Extra IdP fields are preserved. */ @@ -31,6 +31,8 @@ export interface CloudDataBridges { linkcodeCloud: { /** Lists the signed-in account's online hosts; main attaches the session and validates. */ listHosts: () => Promise; + /** Reads the active organization's balance; null means the session has no active organization. */ + billingSummary: () => Promise; /** * Re-asserts this app as the scheme's OS default so the OAuth callback routes back here; * called right before a sign-in. Resolves to whether the OS accepted it. @@ -50,6 +52,8 @@ const cloudSource = window.linkcodeCloud; /** First-party cloud IPC with fixed span names and no payload/result attributes. */ export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = { listHosts: () => traceRendererIpc('cloud.list-hosts', () => cloudSource.listHosts()), + billingSummary: () => + traceRendererIpc('cloud.get-billing-summary', () => cloudSource.billingSummary()), claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()), openHostedBilling: () => traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()), diff --git a/apps/desktop/src/shared/cloud.ts b/apps/desktop/src/shared/cloud.ts index 429202b90..0355417d8 100644 --- a/apps/desktop/src/shared/cloud.ts +++ b/apps/desktop/src/shared/cloud.ts @@ -4,6 +4,9 @@ */ export const CLOUD_LIST_HOSTS_CHANNEL = 'linkcode.cloud.list-hosts'; +// Reads the active Cloud organization's credits summary in main, where the session is held. +export const CLOUD_GET_BILLING_SUMMARY_CHANNEL = 'linkcode.cloud.get-billing-summary'; + // Re-asserts this app as the OS default handler for the channel's `linkcode(-dev)://` scheme; the // renderer invokes it right before a sign-in so the OAuth deep-link callback comes back here. export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link'; diff --git a/apps/webview/src/cloud/__tests__/billing.test.ts b/apps/webview/src/cloud/__tests__/billing.test.ts new file mode 100644 index 000000000..5f83ed796 --- /dev/null +++ b/apps/webview/src/cloud/__tests__/billing.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ fetch: vi.fn() })); + +vi.mock('../auth', () => ({ CLOUD_API_URL: 'https://api.linkcode.test' })); + +import { fetchCloudBillingSummary } from '../billing'; + +afterEach(() => { + vi.unstubAllGlobals(); + mocks.fetch.mockReset(); +}); + +describe('browser Cloud billing source', () => { + it('fetches and validates the organization balance with the session cookie', async () => { + const summary = { + denomination: 'nano_usd', + availableAmount: '-500000000', + reservedAmount: '0', + displayBalance: { currency: 'USD', amount: '-0.50' }, + }; + mocks.fetch.mockResolvedValue(Response.json(summary, { status: 200 })); + vi.stubGlobal('fetch', mocks.fetch); + + await expect(fetchCloudBillingSummary('org/1')).resolves.toEqual(summary); + expect(mocks.fetch).toHaveBeenCalledWith( + 'https://api.linkcode.test/organizations/org%2F1/billing/summary', + { credentials: 'include' }, + ); + }); + + it('rejects a malformed raw balance', async () => { + mocks.fetch.mockResolvedValue( + Response.json( + { + denomination: 'nano_usd', + availableAmount: 12_500_000_000, + reservedAmount: '0', + displayBalance: { currency: 'USD', amount: '12.50' }, + }, + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', mocks.fetch); + + await expect(fetchCloudBillingSummary('org_1')).rejects.toThrow(); + }); +}); diff --git a/apps/webview/src/cloud/auth.ts b/apps/webview/src/cloud/auth.ts index 811e8b269..6c805da86 100644 --- a/apps/webview/src/cloud/auth.ts +++ b/apps/webview/src/cloud/auth.ts @@ -1,3 +1,4 @@ +import { organizationClient } from 'better-auth/client/plugins'; import { createAuthClient } from 'better-auth/react'; /** @@ -13,6 +14,7 @@ export const authClient = createAuthClient({ // The API mounts better-auth at /auth, not the client default /api/auth. basePath: '/auth', fetchOptions: { credentials: 'include' }, + plugins: [organizationClient()], }); /** diff --git a/apps/webview/src/cloud/billing.ts b/apps/webview/src/cloud/billing.ts new file mode 100644 index 000000000..e8073d382 --- /dev/null +++ b/apps/webview/src/cloud/billing.ts @@ -0,0 +1,14 @@ +import type { CloudBillingSummary } from '@linkcode/workbench'; +import { CloudBillingSummarySchema } from '@linkcode/workbench'; +import { CLOUD_API_URL } from './auth'; + +export async function fetchCloudBillingSummary( + organizationId: string, +): Promise { + const res = await fetch( + `${CLOUD_API_URL}/organizations/${encodeURIComponent(organizationId)}/billing/summary`, + { credentials: 'include' }, + ); + if (!res.ok) throw new Error(`fetchCloudBillingSummary: ${res.status} ${res.statusText}`); + return CloudBillingSummarySchema.parse(await res.json()); +} diff --git a/packages/client/workbench/src/cloud/billing.ts b/packages/client/workbench/src/cloud/billing.ts new file mode 100644 index 000000000..7ca088aac --- /dev/null +++ b/packages/client/workbench/src/cloud/billing.ts @@ -0,0 +1,33 @@ +import type { SWRResponse } from 'swr'; +import useSWR from 'swr'; +import { z } from 'zod'; + +const nanoUsdAmountSchema = z.string().regex(/^-?\d+$/); + +export const CloudBillingSummarySchema = z.object({ + denomination: z.literal('nano_usd'), + availableAmount: nanoUsdAmountSchema, + reservedAmount: z.string().regex(/^\d+$/), + displayBalance: z.object({ + currency: z.literal('USD'), + amount: z.string(), + }), +}); + +export type CloudBillingSummary = z.infer; + +/** Returns null when the signed-in Cloud session has no active organization. */ +export type CloudBillingSource = (scopeKey: string) => Promise; + +const BILLING_SUMMARY_KEY = 'cloud/billing/summary'; + +export function useCloudBillingSummary( + scopeKey: string | null | undefined, + source: CloudBillingSource | null, +): SWRResponse { + return useSWR( + scopeKey && source ? [BILLING_SUMMARY_KEY, scopeKey] : null, + source ? ([, key]: [string, string]) => source(key) : null, + { revalidateOnFocus: true }, + ); +} diff --git a/packages/client/workbench/src/cloud/index.ts b/packages/client/workbench/src/cloud/index.ts index e42b196fc..2e99d196a 100644 --- a/packages/client/workbench/src/cloud/index.ts +++ b/packages/client/workbench/src/cloud/index.ts @@ -1,3 +1,4 @@ +export * from './billing'; export * from './hosts'; export * from './im'; export * from './im-source'; From 3edec9635ff875f092892b1e71a0ddef8cf48805 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 21 Aug 2026 12:44:13 +0000 Subject: [PATCH 2/3] feat(settings): show cloud credits balance Amp-Thread-ID: https://ampcode.com/threads/T-01a023a0-1972-706f-8c12-c1ca6fc0a641 --- .../src/cloud-auth/use-cloud-account.ts | 6 +- .../src/renderer/src/settings/billing-tab.tsx | 23 ++++ apps/webview/e2e/browser-smoke.e2e.mts | 10 +- .../settings/__tests__/billing.test.tsx | 103 ++++++++++++++++-- apps/webview/src/routes/settings/billing.tsx | 24 ++++ packages/presentation/i18n/src/locales/en.ts | 10 +- .../presentation/i18n/src/locales/zh-cn.ts | 9 +- .../__tests__/billing-settings-panel.test.tsx | 35 +++++- .../ui/src/shell/billing-settings-panel.tsx | 74 +++++++++++-- vitest.config.ts | 4 +- 10 files changed, 270 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/renderer/src/cloud-auth/use-cloud-account.ts b/apps/desktop/src/renderer/src/cloud-auth/use-cloud-account.ts index c62552e14..81b50a6e3 100644 --- a/apps/desktop/src/renderer/src/cloud-auth/use-cloud-account.ts +++ b/apps/desktop/src/renderer/src/cloud-auth/use-cloud-account.ts @@ -18,6 +18,7 @@ function openAccountCenter(): void { export interface CloudAccountView { account: CloudAccount | null; + loaded: boolean; authenticating: boolean; signIn: () => void; signOut: () => void; @@ -32,9 +33,10 @@ export interface CloudAccountView { * issuing a throttled bust token to re-request the same URL. */ export function useCloudAccount(): CloudAccountView { - const { user, authenticating, signIn, signOut } = useCloudAuthStore( + const { user, loaded, authenticating, signIn, signOut } = useCloudAuthStore( useShallow((state) => ({ user: state.user, + loaded: state.loaded, authenticating: state.authenticating, signIn: state.signIn, signOut: state.signOut, @@ -54,7 +56,7 @@ export function useCloudAccount(): CloudAccountView { ? { name: user.name, email: user.email, image: bustAvatar(user.image, avatarBust) } : null; - return { account, authenticating, signIn, signOut, manageAccount: openAccountCenter }; + return { account, loaded, authenticating, signIn, signOut, manageAccount: openAccountCenter }; } /** Appends a focus-scoped cache-bust token to the stable avatar URL so a new avatar re-renders. */ diff --git a/apps/desktop/src/renderer/src/settings/billing-tab.tsx b/apps/desktop/src/renderer/src/settings/billing-tab.tsx index eae83c9d0..26a2ade77 100644 --- a/apps/desktop/src/renderer/src/settings/billing-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/billing-tab.tsx @@ -1,9 +1,32 @@ +import type { BillingBalanceView } from '@linkcode/ui'; import { BillingSettingsPanel } from '@linkcode/ui'; +import { useCloudBillingSummary } from '@linkcode/workbench'; import { cloudDataBridge } from '../cloud-auth/bridges'; +import { useCloudAccount } from '../cloud-auth/use-cloud-account'; + +const getBillingSummary = () => cloudDataBridge.billingSummary(); export function BillingTab(): React.ReactNode { + const cloud = useCloudAccount(); + const summary = useCloudBillingSummary(cloud.account?.email, getBillingSummary); + let balance: BillingBalanceView; + if (!cloud.loaded) balance = { status: 'loading' }; + else if (!cloud.account) balance = { status: 'signed-out' }; + else if (summary.data === undefined) { + balance = { status: summary.error === undefined ? 'loading' : 'error' }; + } else if (summary.data === null) balance = { status: 'missing-organization' }; + else { + balance = { + status: 'ready', + amount: summary.data.displayBalance.amount, + currency: summary.data.displayBalance.currency, + }; + } + return ( { void cloudDataBridge.openHostedBilling(); }} diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index 3ce76d84f..d3bc5fedd 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -352,6 +352,9 @@ async function verifyProductionEntry(browser: Browser): Promise { }, { daemonUrl: daemon.origin }, ); + await context.route('**/auth/get-session', async (route) => { + await route.fulfill({ body: 'null', contentType: 'application/json', status: 200 }); + }); const page = await context.newPage(); monitorApplicationErrors(page, server.origin, appErrors); await page.goto(server.origin, { waitUntil: 'domcontentloaded' }); @@ -359,7 +362,12 @@ async function verifyProductionEntry(browser: Browser): Promise { await page.getByRole('link', { name: 'Open settings' }).click(); await page.waitForURL(`${server.origin}/settings`); await page.goto(`${server.origin}/settings/billing`, { waitUntil: 'domcontentloaded' }); - await page.getByText('LinkCode does not read or process billing or checkout data.').waitFor(); + await page + .getByText( + 'LinkCode only displays the balance summary; top-ups, orders, subscriptions, and checkout remain on the web.', + ) + .waitFor(); + await page.getByText('Sign in to LinkCode Cloud to view your balance.').waitFor(); await page.getByRole('button', { name: 'Manage on the web' }).waitFor(); await page.getByRole('link', { name: 'Back' }).waitFor(); await page.getByRole('link', { name: 'Back' }).click(); diff --git a/apps/webview/src/routes/settings/__tests__/billing.test.tsx b/apps/webview/src/routes/settings/__tests__/billing.test.tsx index dd93587ed..fda7f838e 100644 --- a/apps/webview/src/routes/settings/__tests__/billing.test.tsx +++ b/apps/webview/src/routes/settings/__tests__/billing.test.tsx @@ -3,34 +3,100 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ - createHostedBillingUrl: vi.fn(() => 'https://console.linkcode.ai/billing'), -})); +const mocks = vi.hoisted(() => { + const session: { + data: { + session: { activeOrganizationId?: string }; + user: { email: string }; + } | null; + isPending: boolean; + } = { + data: { + session: { activeOrganizationId: 'org_1' }, + user: { email: 'user@example.test' }, + }, + isPending: false, + }; + return { + createHostedBillingUrl: vi.fn(() => 'https://console.linkcode.ai/billing'), + session, + signIn: vi.fn(), + summary: { + data: { + denomination: 'nano_usd', + availableAmount: '12500000000', + reservedAmount: '0', + displayBalance: { currency: 'USD', amount: '12.50' }, + }, + error: undefined, + }, + }; +}); vi.mock('@linkcode/cloud', () => ({ createHostedBillingUrl: mocks.createHostedBillingUrl, })); vi.mock('@linkcode/ui', () => ({ - BillingSettingsPanel: ({ onOpenBilling }: { onOpenBilling: () => void }) => ( - + BillingSettingsPanel: ({ + balance, + onSignIn, + onOpenBilling, + }: { + balance: { status: string; amount?: string }; + onSignIn?: () => void; + onOpenBilling: () => void; + }) => ( +
+ {balance.status} + {balance.amount ? {balance.amount} : null} + {onSignIn ? ( + + ) : null} + +
), })); +vi.mock('@linkcode/workbench', () => ({ + useCloudBillingSummary: () => mocks.summary, +})); + +vi.mock('@webview/cloud/auth', () => ({ + authClient: { useSession: () => mocks.session }, + signInWithCloud: mocks.signIn, +})); + import { BillingSettings } from '../billing'; afterEach(() => { cleanup(); vi.restoreAllMocks(); + mocks.session.data = { + session: { activeOrganizationId: 'org_1' }, + user: { email: 'user@example.test' }, + }; + mocks.session.isPending = false; + mocks.summary.data = { + denomination: 'nano_usd', + availableAmount: '12500000000', + reservedAmount: '0', + displayBalance: { currency: 'USD', amount: '12.50' }, + }; + mocks.summary.error = undefined; }); -describe('web hosted billing handoff', () => { - it('opens the SDK URL without a desktop return target', () => { +describe('web billing settings', () => { + it('shows the active organization balance and keeps the hosted handoff', () => { const open = vi.spyOn(window, 'open').mockImplementation(() => null); render(); + expect(screen.getByText('ready')).toBeTruthy(); + expect(screen.getByText('12.50')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'billing' })); expect(mocks.createHostedBillingUrl).toHaveBeenCalledWith(); @@ -40,4 +106,23 @@ describe('web hosted billing handoff', () => { 'noopener,noreferrer', ); }); + + it('offers sign-in when the Cloud session is signed out', () => { + mocks.session.data = null; + render(); + + expect(screen.getByText('signed-out')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'sign-in' })); + expect(mocks.signIn).toHaveBeenCalledOnce(); + }); + + it('shows the missing organization state without an active organization', () => { + mocks.session.data = { + session: {}, + user: { email: 'user@example.test' }, + }; + render(); + + expect(screen.getByText('missing-organization')).toBeTruthy(); + }); }); diff --git a/apps/webview/src/routes/settings/billing.tsx b/apps/webview/src/routes/settings/billing.tsx index 13b10dff3..eb7c6b02a 100644 --- a/apps/webview/src/routes/settings/billing.tsx +++ b/apps/webview/src/routes/settings/billing.tsx @@ -1,11 +1,35 @@ import { createHostedBillingUrl } from '@linkcode/cloud'; +import type { BillingBalanceView } from '@linkcode/ui'; import { BillingSettingsPanel } from '@linkcode/ui'; +import { useCloudBillingSummary } from '@linkcode/workbench'; +import { authClient, signInWithCloud } from '@webview/cloud/auth'; +import { fetchCloudBillingSummary } from '@webview/cloud/billing'; const HOSTED_BILLING_URL = createHostedBillingUrl(); export function BillingSettings(): React.ReactNode { + const session = authClient.useSession(); + const organizationId = session.data?.session.activeOrganizationId; + const summary = useCloudBillingSummary(organizationId, fetchCloudBillingSummary); + let balance: BillingBalanceView; + if (session.isPending) balance = { status: 'loading' }; + else if (!session.data) balance = { status: 'signed-out' }; + else if (!organizationId) balance = { status: 'missing-organization' }; + else if (summary.data === undefined) { + balance = { status: summary.error === undefined ? 'loading' : 'error' }; + } else if (summary.data === null) balance = { status: 'missing-organization' }; + else { + balance = { + status: 'ready', + amount: summary.data.displayBalance.amount, + currency: summary.data.displayBalance.currency, + }; + } + return ( { window.open(HOSTED_BILLING_URL, '_blank', 'noopener,noreferrer'); }} diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 8351ea6f9..70562b422 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1178,8 +1178,14 @@ export const en = { }, billing: { title: 'Billing', - description: 'Balance, top-ups, orders, and subscriptions are managed on LinkCode Cloud.', - hostedHint: 'LinkCode does not read or process billing or checkout data.', + description: "View the active organization's LinkCode Credits balance.", + hostedHint: + 'LinkCode only displays the balance summary; top-ups, orders, subscriptions, and checkout remain on the web.', + availableBalance: 'Available balance', + signedOut: 'Sign in to LinkCode Cloud to view your balance.', + signIn: 'Sign in to LinkCode Cloud', + missingOrganization: 'The current Cloud session has no active organization.', + loadError: 'The balance is temporarily unavailable. Try again later.', openOnWeb: 'Manage on the web', }, imChannel: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 82660b602..ee08eaf98 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1147,8 +1147,13 @@ export const zhCN = { }, billing: { title: '账单', - description: '余额、充值、订单和订阅均由 LinkCode Cloud 网页端管理。', - hostedHint: 'LinkCode 不读取或处理账单与结账数据。', + description: '查看当前组织的 LinkCode Credits 余额。', + hostedHint: 'LinkCode 仅显示余额摘要;充值、订单、订阅和结账仍在网页端处理。', + availableBalance: '可用余额', + signedOut: '登录 LinkCode Cloud 后查看余额。', + signIn: '登录 LinkCode Cloud', + missingOrganization: '当前 Cloud 会话没有活动组织。', + loadError: '暂时无法加载余额,请稍后重试。', openOnWeb: '前往网页端管理', }, imChannel: { diff --git a/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx b/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx index 2eb52dfe4..a9d231226 100644 --- a/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx @@ -15,13 +15,44 @@ vi.mock('use-intl', () => ({ afterEach(cleanup); describe('BillingSettingsPanel', () => { - it('offers only the hosted billing handoff', () => { + it('shows the balance and keeps the hosted billing handoff', () => { const onOpenBilling = vi.fn(); - render(); + render( + , + ); expect(screen.getByText('description')).toBeTruthy(); expect(screen.getByText('hostedHint')).toBeTruthy(); + expect(screen.getByText('12.50')).toBeTruthy(); + expect(screen.getByText('USD')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'openOnWeb' })); expect(onOpenBilling).toHaveBeenCalledOnce(); }); + + it('offers Cloud sign-in when signed out', () => { + const onSignIn = vi.fn(); + render( + , + ); + + expect(screen.getByText('signedOut')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'signIn' })); + expect(onSignIn).toHaveBeenCalledOnce(); + }); + + it.each([ + ['missing-organization', 'missingOrganization'], + ['error', 'loadError'], + ] as const)('renders the %s state', (status, message) => { + render(); + + expect(screen.getByText(message)).toBeTruthy(); + }); }); diff --git a/packages/presentation/ui/src/shell/billing-settings-panel.tsx b/packages/presentation/ui/src/shell/billing-settings-panel.tsx index 566967ff4..f9b9a90ad 100644 --- a/packages/presentation/ui/src/shell/billing-settings-panel.tsx +++ b/packages/presentation/ui/src/shell/billing-settings-panel.tsx @@ -1,29 +1,87 @@ import { Button } from 'coss-ui/components/button'; import { Card, CardPanel } from 'coss-ui/components/card'; +import { Skeleton } from 'coss-ui/components/skeleton'; +import { never } from 'foxts/guard'; import { ExternalLinkIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; +export type BillingBalanceView = + | { status: 'loading' } + | { status: 'signed-out' } + | { status: 'missing-organization' } + | { status: 'error' } + | { status: 'ready'; amount: string; currency: 'USD' }; + export interface BillingSettingsPanelProps { + balance: BillingBalanceView; + onSignIn?: () => void; onOpenBilling: () => void; } export function BillingSettingsPanel({ + balance, + onSignIn, onOpenBilling, }: BillingSettingsPanelProps): React.ReactNode { const t = useTranslations('settings.billing'); return ( - -
-

{t('description')}

-

{t('hostedHint')}

+ +
+
+

{t('description')}

+

{t('hostedHint')}

+
+ +
+
+

{t('availableBalance')}

+
-
); } + +function BalanceValue({ + balance, + onSignIn, +}: { + balance: BillingBalanceView; + onSignIn?: () => void; +}): React.ReactNode { + const t = useTranslations('settings.billing'); + + switch (balance.status) { + case 'loading': + return ; + case 'signed-out': + return ( +
+

{t('signedOut')}

+ {onSignIn ? ( + + ) : null} +
+ ); + case 'missing-organization': + return

{t('missingOrganization')}

; + case 'error': + return

{t('loadError')}

; + case 'ready': + return ( +

+ {balance.amount}{' '} + {balance.currency} +

+ ); + default: + return never(balance, 'billing balance view'); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 6db4d2af5..704c9c58e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,10 +19,10 @@ export default defineConfig({ }), ], resolve: { - // Mirror apps/desktop's `@renderer` path alias (apps/desktop/tsconfig.json + - // electron.vite.config.ts) so the desktop unit tests resolve under this runner. + // Mirror app source aliases so renderer unit tests resolve under this runner. alias: { '@renderer': fileURLToPath(new URL('./apps/desktop/src/renderer/src', import.meta.url)), + '@webview': fileURLToPath(new URL('./apps/webview/src', import.meta.url)), }, }, test: { From 741082017e3588a219377d3e223b58cc12ec3288 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Fri, 21 Aug 2026 14:01:38 +0000 Subject: [PATCH 3/3] feat(settings): refine usage and billing Amp-Thread-ID: https://ampcode.com/threads/T-01a023a0-1972-706f-8c12-c1ca6fc0a641 --- .../renderer/src/settings/settings-view.tsx | 16 ++-- apps/webview/e2e/browser-smoke.e2e.mts | 4 +- .../src/routes/settings/settings-layout.tsx | 16 ++-- packages/presentation/i18n/src/locales/en.ts | 11 +-- .../presentation/i18n/src/locales/zh-cn.ts | 11 +-- .../__tests__/billing-settings-panel.test.tsx | 2 + .../ui/src/shell/billing-settings-panel.tsx | 79 +++++++++---------- 7 files changed, 70 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index a3c836802..51b59a5f1 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -129,6 +129,14 @@ export function SettingsView(): React.ReactNode { active: category === 'notifications', onClick: () => setCategory('notifications'), }, + { + key: 'billing', + icon: , + label: t('tabs.billing'), + keywords: searchKeywords.billing, + active: category === 'billing', + onClick: () => setCategory('billing'), + }, ], }, { @@ -151,14 +159,6 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, - { - key: 'billing', - icon: , - label: t('tabs.billing'), - keywords: searchKeywords.billing, - active: category === 'billing', - onClick: () => setCategory('billing'), - }, { key: 'plugins', icon: , diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index d3bc5fedd..42e8d30ea 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -364,11 +364,11 @@ async function verifyProductionEntry(browser: Browser): Promise { await page.goto(`${server.origin}/settings/billing`, { waitUntil: 'domcontentloaded' }); await page .getByText( - 'LinkCode only displays the balance summary; top-ups, orders, subscriptions, and checkout remain on the web.', + 'To manage top-ups, orders, subscriptions, and checkout, use LinkCode Cloud on the web.', ) .waitFor(); await page.getByText('Sign in to LinkCode Cloud to view your balance.').waitFor(); - await page.getByRole('button', { name: 'Manage on the web' }).waitFor(); + await page.getByRole('button', { name: 'Sign in to LinkCode Cloud' }).waitFor(); await page.getByRole('link', { name: 'Back' }).waitFor(); await page.getByRole('link', { name: 'Back' }).click(); await page.waitForURL(`${server.origin}/`); diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index 057c1bbaf..df47593ea 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -74,6 +74,14 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'notifications'), render: , }, + { + key: 'billing', + icon: , + label: t('tabs.billing'), + keywords: searchKeywords.billing, + active: isActive(pathname, 'billing'), + render: , + }, ], }, { @@ -96,14 +104,6 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, - { - key: 'billing', - icon: , - label: t('tabs.billing'), - keywords: searchKeywords.billing, - active: isActive(pathname, 'billing'), - render: , - }, { key: 'plugins', icon: , diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 70562b422..7d2b9125f 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -798,7 +798,7 @@ export const en = { notifications: 'Notifications', about: 'About', providers: 'Providers', - billing: 'Billing', + billing: 'Usage & billing', agents: 'Agents', imChannel: 'Messaging', plugins: 'Plugins & Skills', @@ -1177,16 +1177,17 @@ export const en = { 'The browser has blocked notifications. Re-allow them in the browser site settings.', }, billing: { - title: 'Billing', - description: "View the active organization's LinkCode Credits balance.", + title: 'Usage & billing', + description: 'View the LinkCode Credits available to the active organization.', hostedHint: - 'LinkCode only displays the balance summary; top-ups, orders, subscriptions, and checkout remain on the web.', + 'To manage top-ups, orders, subscriptions, and checkout, use LinkCode Cloud on the web.', + creditsBalance: 'Credits balance', availableBalance: 'Available balance', signedOut: 'Sign in to LinkCode Cloud to view your balance.', signIn: 'Sign in to LinkCode Cloud', missingOrganization: 'The current Cloud session has no active organization.', loadError: 'The balance is temporarily unavailable. Try again later.', - openOnWeb: 'Manage on the web', + openOnWeb: 'Manage credits', }, imChannel: { title: 'Messaging', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index ee08eaf98..15bd16124 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -783,7 +783,7 @@ export const zhCN = { notifications: '通知', about: '关于', providers: 'Providers', - billing: '账单', + billing: '使用与账单', agents: '智能体', imChannel: 'IM 渠道', plugins: '插件与技能', @@ -1146,15 +1146,16 @@ export const zhCN = { permissionDenied: '浏览器已拦截通知。请在浏览器的站点设置中重新允许。', }, billing: { - title: '账单', - description: '查看当前组织的 LinkCode Credits 余额。', - hostedHint: 'LinkCode 仅显示余额摘要;充值、订单、订阅和结账仍在网页端处理。', + title: '使用与账单', + description: '查看当前组织可用的 LinkCode Credits。', + hostedHint: '如需充值、查看订单、管理订阅或结账,请前往 LinkCode Cloud 网页端。', + creditsBalance: 'Credits 余额', availableBalance: '可用余额', signedOut: '登录 LinkCode Cloud 后查看余额。', signIn: '登录 LinkCode Cloud', missingOrganization: '当前 Cloud 会话没有活动组织。', loadError: '暂时无法加载余额,请稍后重试。', - openOnWeb: '前往网页端管理', + openOnWeb: '管理 Credits', }, imChannel: { title: 'IM 渠道', diff --git a/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx b/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx index a9d231226..355ebccf7 100644 --- a/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx @@ -26,6 +26,8 @@ describe('BillingSettingsPanel', () => { expect(screen.getByText('description')).toBeTruthy(); expect(screen.getByText('hostedHint')).toBeTruthy(); + expect(screen.getByText('creditsBalance')).toBeTruthy(); + expect(screen.getByText('availableBalance')).toBeTruthy(); expect(screen.getByText('12.50')).toBeTruthy(); expect(screen.getByText('USD')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'openOnWeb' })); diff --git a/packages/presentation/ui/src/shell/billing-settings-panel.tsx b/packages/presentation/ui/src/shell/billing-settings-panel.tsx index f9b9a90ad..10c7f3c36 100644 --- a/packages/presentation/ui/src/shell/billing-settings-panel.tsx +++ b/packages/presentation/ui/src/shell/billing-settings-panel.tsx @@ -1,9 +1,8 @@ import { Button } from 'coss-ui/components/button'; -import { Card, CardPanel } from 'coss-ui/components/card'; import { Skeleton } from 'coss-ui/components/skeleton'; import { never } from 'foxts/guard'; -import { ExternalLinkIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; +import { SettingsCard, SettingsSection } from './settings-page'; export type BillingBalanceView = | { status: 'loading' } @@ -26,60 +25,58 @@ export function BillingSettingsPanel({ const t = useTranslations('settings.billing'); return ( - - -
-
-

{t('description')}

-

{t('hostedHint')}

-
- -
-
-

{t('availableBalance')}

- +
+

{t('hostedHint')}

+ + +
+

{t('description')}

+ +
+ + {onSignIn && balance.status === 'signed-out' ? ( + + ) : ( + + )} +
+
- - +
+
); } -function BalanceValue({ - balance, - onSignIn, -}: { - balance: BillingBalanceView; - onSignIn?: () => void; -}): React.ReactNode { +function BalanceValue({ balance }: { balance: BillingBalanceView }): React.ReactNode { const t = useTranslations('settings.billing'); switch (balance.status) { case 'loading': - return ; - case 'signed-out': return ( -
-

{t('signedOut')}

- {onSignIn ? ( - - ) : null} +
+ + {t('availableBalance')}
); + case 'signed-out': + return

{t('signedOut')}

; case 'missing-organization': - return

{t('missingOrganization')}

; + return

{t('missingOrganization')}

; case 'error': - return

{t('loadError')}

; + return

{t('loadError')}

; case 'ready': return ( -

- {balance.amount}{' '} - {balance.currency} -

+
+

+ {balance.amount}{' '} + {balance.currency} +

+ {t('availableBalance')} +
); default: return never(balance, 'billing balance view');