Skip to content

Commit 5d2e3a4

Browse files
authored
feat(settings): add usage and billing balance (#468)
* feat(billing): add cloud credits balance sources Amp-Thread-ID: https://ampcode.com/threads/T-01a023a0-1972-706f-8c12-c1ca6fc0a641 * feat(settings): show cloud credits balance Amp-Thread-ID: https://ampcode.com/threads/T-01a023a0-1972-706f-8c12-c1ca6fc0a641 * feat(settings): refine usage and billing Amp-Thread-ID: https://ampcode.com/threads/T-01a023a0-1972-706f-8c12-c1ca6fc0a641
1 parent 63b3813 commit 5d2e3a4

23 files changed

Lines changed: 521 additions & 60 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
handlers: new Map<string, () => unknown>(),
5+
fetch: vi.fn(),
6+
getCookie: vi.fn(() => 'better-auth.session=token'),
7+
getSession: vi.fn(),
8+
}));
9+
10+
vi.mock('electron', () => ({
11+
ipcMain: {
12+
handle: (channel: string, handler: () => unknown) => mocks.handlers.set(channel, handler),
13+
},
14+
}));
15+
16+
vi.mock('../cloud-auth/client', () => ({
17+
authClient: {
18+
getCookie: mocks.getCookie,
19+
getSession: mocks.getSession,
20+
},
21+
CLOUD_API_URL: 'https://api.linkcode.test',
22+
}));
23+
24+
const summary = {
25+
denomination: 'nano_usd',
26+
availableAmount: '12500000000',
27+
reservedAmount: '500000000',
28+
displayBalance: { currency: 'USD', amount: '12.50' },
29+
};
30+
31+
beforeEach(() => {
32+
mocks.handlers.clear();
33+
mocks.fetch.mockReset();
34+
mocks.getSession.mockReset();
35+
mocks.getSession.mockResolvedValue({
36+
data: { session: { activeOrganizationId: 'org/1' } },
37+
error: null,
38+
});
39+
vi.stubGlobal('fetch', mocks.fetch);
40+
});
41+
42+
afterEach(() => vi.unstubAllGlobals());
43+
44+
describe('desktop Cloud billing bridge', () => {
45+
it('reads and validates the active organization balance with the main-process session', async () => {
46+
mocks.fetch.mockResolvedValue(Response.json(summary, { status: 200, statusText: 'OK' }));
47+
const { registerCloudBillingBridge } = await import('../cloud-auth/billing');
48+
const { CLOUD_GET_BILLING_SUMMARY_CHANNEL } = await import('../../shared/cloud');
49+
registerCloudBillingBridge();
50+
51+
await expect(mocks.handlers.get(CLOUD_GET_BILLING_SUMMARY_CHANNEL)?.()).resolves.toEqual(
52+
summary,
53+
);
54+
expect(mocks.fetch).toHaveBeenCalledWith(
55+
'https://api.linkcode.test/organizations/org%2F1/billing/summary',
56+
{ headers: { cookie: 'better-auth.session=token' } },
57+
);
58+
});
59+
60+
it('returns null without an active organization', async () => {
61+
mocks.getSession.mockResolvedValue({ data: { session: {} }, error: null });
62+
const { getCloudBillingSummary } = await import('../cloud-auth/billing');
63+
64+
await expect(getCloudBillingSummary()).resolves.toBeNull();
65+
expect(mocks.fetch).not.toHaveBeenCalled();
66+
});
67+
68+
it('rejects a non-integer nano-USD balance', async () => {
69+
mocks.fetch.mockResolvedValue(
70+
Response.json({ ...summary, availableAmount: '12.50' }, { status: 200 }),
71+
);
72+
const { getCloudBillingSummary } = await import('../cloud-auth/billing');
73+
74+
await expect(getCloudBillingSummary()).rejects.toThrow();
75+
});
76+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { CloudBillingSummary } from '@linkcode/workbench';
2+
import { ipcMain } from 'electron';
3+
import { z } from 'zod';
4+
import { CLOUD_GET_BILLING_SUMMARY_CHANNEL } from '../../shared/cloud';
5+
import { authClient, CLOUD_API_URL } from './client';
6+
7+
const billingSummarySchema = z.object({
8+
denomination: z.literal('nano_usd'),
9+
availableAmount: z.string().regex(/^-?\d+$/),
10+
reservedAmount: z.string().regex(/^\d+$/),
11+
displayBalance: z.object({
12+
currency: z.literal('USD'),
13+
amount: z.string(),
14+
}),
15+
});
16+
17+
export async function getCloudBillingSummary(): Promise<CloudBillingSummary | null> {
18+
const session = await authClient.getSession();
19+
if (session.error) throw new Error(session.error.message);
20+
const organizationId = session.data?.session.activeOrganizationId;
21+
if (!organizationId) return null;
22+
23+
const res = await fetch(
24+
`${CLOUD_API_URL}/organizations/${encodeURIComponent(organizationId)}/billing/summary`,
25+
{ headers: { cookie: authClient.getCookie() } },
26+
);
27+
if (!res.ok) throw new Error(`getCloudBillingSummary: ${res.status} ${res.statusText}`);
28+
return billingSummarySchema.parse(await res.json());
29+
}
30+
31+
export function registerCloudBillingBridge(): void {
32+
ipcMain.handle(CLOUD_GET_BILLING_SUMMARY_CHANNEL, () => getCloudBillingSummary());
33+
}

‎apps/desktop/src/main/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Sentry from '@sentry/electron/main';
1010
import { app, BrowserWindow, dialog, Menu } from 'electron';
1111
import { DESKTOP_SPAN_NAMES, DESKTOP_TRANSACTION_NAMES } from '../sentry-privacy';
1212
import { applyThemePreference } from './appearance';
13+
import { registerCloudBillingBridge } from './cloud-auth/billing';
1314
import { authClient, setupCloudAuth } from './cloud-auth/client';
1415
import { registerCloudImBridge } from './cloud-auth/im';
1516
import { registerCloudTunnelBridge } from './cloud-auth/tunnel';
@@ -62,8 +63,9 @@ if (app.requestSingleInstanceLock()) {
6263
// Wire the LinkCode Cloud auth protocol + IPC bridges. Must run BEFORE app is ready: the plugin
6364
// registers a privileged scheme via protocol.registerSchemesAsPrivileged, which throws once ready.
6465
setupCloudAuth();
65-
// Cloud data bridges (online hosts, IM Channel). Not scheme-related, but registered here
66+
// Cloud data bridges (billing, online hosts, IM Channel). Not scheme-related, but registered here
6667
// alongside the rest of the cloud wiring; ipcMain.handle is safe before the app is ready.
68+
registerCloudBillingBridge();
6769
registerCloudTunnelBridge();
6870
registerCloudImBridge();
6971

‎apps/desktop/src/preload/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { contextBridge, ipcRenderer } from 'electron';
44
import {
55
CLOUD_CLAIM_DEEP_LINK_CHANNEL,
66
CLOUD_CREATE_GATEWAY_KEY_CHANNEL,
7+
CLOUD_GET_BILLING_SUMMARY_CHANNEL,
78
CLOUD_IM_BINDINGS_CHANNEL,
89
CLOUD_IM_CREATE_BINDING_CHANNEL,
910
CLOUD_IM_DELETE_BINDING_CHANNEL,
@@ -54,10 +55,11 @@ contextBridge.exposeInMainWorld('linkcodeConfig', configBridge);
5455
// coexists with the bridge above.
5556
setupRenderer();
5657

57-
// Cloud data bridge: the renderer lists the account's online hosts through main (which holds the
58-
// keychain session). Kept off the SystemBridge — it's cloud-account data, not a window/OS capability.
58+
// Cloud data bridge: main holds the keychain session for these account-scoped requests. Kept off
59+
// the SystemBridge — this is Cloud account data, not a window/OS capability.
5960
contextBridge.exposeInMainWorld('linkcodeCloud', {
6061
listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL),
62+
billingSummary: () => ipcRenderer.invoke(CLOUD_GET_BILLING_SUMMARY_CHANNEL),
6163
claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL),
6264
openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL),
6365
createGatewayKey: (name: string) => ipcRenderer.invoke(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, name),

‎apps/desktop/src/renderer/src/cloud-auth/bridges.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* process's inferred types across the process boundary, so the vendor's stable shape is mirrored here.
55
*/
66

7-
import type { CloudHost, CloudImSource } from '@linkcode/workbench';
7+
import type { CloudBillingSummary, CloudHost, CloudImSource } from '@linkcode/workbench';
88
import { traceRendererIpc } from '../ipc';
99

1010
/** The authenticated user, as normalized by the electron plugin. Extra IdP fields are preserved. */
@@ -31,6 +31,8 @@ export interface CloudDataBridges {
3131
linkcodeCloud: {
3232
/** Lists the signed-in account's online hosts; main attaches the session and validates. */
3333
listHosts: () => Promise<CloudHost[]>;
34+
/** Reads the active organization's balance; null means the session has no active organization. */
35+
billingSummary: () => Promise<CloudBillingSummary | null>;
3436
/**
3537
* Re-asserts this app as the scheme's OS default so the OAuth callback routes back here;
3638
* called right before a sign-in. Resolves to whether the OS accepted it.
@@ -50,6 +52,8 @@ const cloudSource = window.linkcodeCloud;
5052
/** First-party cloud IPC with fixed span names and no payload/result attributes. */
5153
export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = {
5254
listHosts: () => traceRendererIpc('cloud.list-hosts', () => cloudSource.listHosts()),
55+
billingSummary: () =>
56+
traceRendererIpc('cloud.get-billing-summary', () => cloudSource.billingSummary()),
5357
claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()),
5458
openHostedBilling: () =>
5559
traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()),

‎apps/desktop/src/renderer/src/cloud-auth/use-cloud-account.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function openAccountCenter(): void {
1818

1919
export interface CloudAccountView {
2020
account: CloudAccount | null;
21+
loaded: boolean;
2122
authenticating: boolean;
2223
signIn: () => void;
2324
signOut: () => void;
@@ -32,9 +33,10 @@ export interface CloudAccountView {
3233
* issuing a throttled bust token to re-request the same URL.
3334
*/
3435
export function useCloudAccount(): CloudAccountView {
35-
const { user, authenticating, signIn, signOut } = useCloudAuthStore(
36+
const { user, loaded, authenticating, signIn, signOut } = useCloudAuthStore(
3637
useShallow((state) => ({
3738
user: state.user,
39+
loaded: state.loaded,
3840
authenticating: state.authenticating,
3941
signIn: state.signIn,
4042
signOut: state.signOut,
@@ -54,7 +56,7 @@ export function useCloudAccount(): CloudAccountView {
5456
? { name: user.name, email: user.email, image: bustAvatar(user.image, avatarBust) }
5557
: null;
5658

57-
return { account, authenticating, signIn, signOut, manageAccount: openAccountCenter };
59+
return { account, loaded, authenticating, signIn, signOut, manageAccount: openAccountCenter };
5860
}
5961

6062
/** Appends a focus-scoped cache-bust token to the stable avatar URL so a new avatar re-renders. */

‎apps/desktop/src/renderer/src/settings/billing-tab.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1+
import type { BillingBalanceView } from '@linkcode/ui';
12
import { BillingSettingsPanel } from '@linkcode/ui';
3+
import { useCloudBillingSummary } from '@linkcode/workbench';
24
import { cloudDataBridge } from '../cloud-auth/bridges';
5+
import { useCloudAccount } from '../cloud-auth/use-cloud-account';
6+
7+
const getBillingSummary = () => cloudDataBridge.billingSummary();
38

49
export function BillingTab(): React.ReactNode {
10+
const cloud = useCloudAccount();
11+
const summary = useCloudBillingSummary(cloud.account?.email, getBillingSummary);
12+
let balance: BillingBalanceView;
13+
if (!cloud.loaded) balance = { status: 'loading' };
14+
else if (!cloud.account) balance = { status: 'signed-out' };
15+
else if (summary.data === undefined) {
16+
balance = { status: summary.error === undefined ? 'loading' : 'error' };
17+
} else if (summary.data === null) balance = { status: 'missing-organization' };
18+
else {
19+
balance = {
20+
status: 'ready',
21+
amount: summary.data.displayBalance.amount,
22+
currency: summary.data.displayBalance.currency,
23+
};
24+
}
25+
526
return (
627
<BillingSettingsPanel
28+
balance={balance}
29+
onSignIn={cloud.signIn}
730
onOpenBilling={() => {
831
void cloudDataBridge.openHostedBilling();
932
}}

‎apps/desktop/src/renderer/src/settings/settings-view.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ export function SettingsView(): React.ReactNode {
129129
active: category === 'notifications',
130130
onClick: () => setCategory('notifications'),
131131
},
132+
{
133+
key: 'billing',
134+
icon: <CreditCardIcon className="size-4" />,
135+
label: t('tabs.billing'),
136+
keywords: searchKeywords.billing,
137+
active: category === 'billing',
138+
onClick: () => setCategory('billing'),
139+
},
132140
],
133141
},
134142
{
@@ -151,14 +159,6 @@ export function SettingsView(): React.ReactNode {
151159
active: category === 'providers',
152160
onClick: () => setCategory('providers'),
153161
},
154-
{
155-
key: 'billing',
156-
icon: <CreditCardIcon className="size-4" />,
157-
label: t('tabs.billing'),
158-
keywords: searchKeywords.billing,
159-
active: category === 'billing',
160-
onClick: () => setCategory('billing'),
161-
},
162162
{
163163
key: 'plugins',
164164
icon: <PuzzleIcon className="size-4" />,

‎apps/desktop/src/shared/cloud.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
*/
55
export const CLOUD_LIST_HOSTS_CHANNEL = 'linkcode.cloud.list-hosts';
66

7+
// Reads the active Cloud organization's credits summary in main, where the session is held.
8+
export const CLOUD_GET_BILLING_SUMMARY_CHANNEL = 'linkcode.cloud.get-billing-summary';
9+
710
// Re-asserts this app as the OS default handler for the channel's `linkcode(-dev)://` scheme; the
811
// renderer invokes it right before a sign-in so the OAuth deep-link callback comes back here.
912
export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link';

‎apps/webview/e2e/browser-smoke.e2e.mts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,15 +352,23 @@ async function verifyProductionEntry(browser: Browser): Promise<void> {
352352
},
353353
{ daemonUrl: daemon.origin },
354354
);
355+
await context.route('**/auth/get-session', async (route) => {
356+
await route.fulfill({ body: 'null', contentType: 'application/json', status: 200 });
357+
});
355358
const page = await context.newPage();
356359
monitorApplicationErrors(page, server.origin, appErrors);
357360
await page.goto(server.origin, { waitUntil: 'domcontentloaded' });
358361
await page.locator('#root > *').waitFor();
359362
await page.getByRole('link', { name: 'Open settings' }).click();
360363
await page.waitForURL(`${server.origin}/settings`);
361364
await page.goto(`${server.origin}/settings/billing`, { waitUntil: 'domcontentloaded' });
362-
await page.getByText('LinkCode does not read or process billing or checkout data.').waitFor();
363-
await page.getByRole('button', { name: 'Manage on the web' }).waitFor();
365+
await page
366+
.getByText(
367+
'To manage top-ups, orders, subscriptions, and checkout, use LinkCode Cloud on the web.',
368+
)
369+
.waitFor();
370+
await page.getByText('Sign in to LinkCode Cloud to view your balance.').waitFor();
371+
await page.getByRole('button', { name: 'Sign in to LinkCode Cloud' }).waitFor();
364372
await page.getByRole('link', { name: 'Back' }).waitFor();
365373
await page.getByRole('link', { name: 'Back' }).click();
366374
await page.waitForURL(`${server.origin}/`);

0 commit comments

Comments
 (0)