From 3c12d0de7ef72e91849df3e31f2c7f61ce6e56f2 Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Tue, 14 Jul 2026 10:54:41 -0400 Subject: [PATCH 1/4] feat: add balances list command Committed-By-Agent: claude --- packages/cli/src/__tests__/cli.test.ts | 78 ++++++ packages/cli/src/cli.tsx | 9 +- .../balances/__tests__/balances.test.tsx | 106 ++++++++ packages/cli/src/commands/balances/index.tsx | 54 ++++ packages/cli/src/commands/balances/list.tsx | 237 ++++++++++++++++++ packages/cli/src/commands/balances/schema.ts | 19 ++ .../utils/__tests__/resource-factory.test.ts | 5 + packages/cli/src/utils/resource-factory.ts | 20 ++ packages/sdk/src/client.ts | 4 + packages/sdk/src/index.ts | 1 + .../src/resources/__tests__/balances.test.ts | 158 ++++++++++++ packages/sdk/src/resources/balances.ts | 85 +++++++ packages/sdk/src/resources/interfaces.ts | 12 + packages/sdk/src/types/index.ts | 22 ++ 14 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/balances/__tests__/balances.test.tsx create mode 100644 packages/cli/src/commands/balances/index.tsx create mode 100644 packages/cli/src/commands/balances/list.tsx create mode 100644 packages/cli/src/commands/balances/schema.ts create mode 100644 packages/sdk/src/resources/__tests__/balances.test.ts create mode 100644 packages/sdk/src/resources/balances.ts diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index a6e0a8e..0a21bb8 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1117,6 +1117,84 @@ describe('production mode', () => { }); }); + const SAMPLE_BALANCE = { + source_id: 'csmrpd_001', + name: 'Checking 1234', + type: 'bank_account', + available: { amount: 12500, currency: 'usd' }, + current: { amount: 13000, currency: 'usd' }, + }; + + describe('balances list', () => { + it('GETs the Link API endpoint with bearer auth', async () => { + setResponseForUrl('/balances', 200, { + data: [SAMPLE_BALANCE], + has_more: true, + }); + + const result = await runProdCli('balances', 'list', '--json'); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('GET'); + expect(lastRequest.url).toBe('/balances'); + expect(lastRequest.headers.authorization).toBe( + 'Bearer prod_test_access_token', + ); + + const output = parseJson(result.stdout) as Record; + expect(output.has_more).toBe(true); + expect(Array.isArray(output.data)).toBe(true); + const data = output.data as Record[]; + expect(data).toHaveLength(1); + expect(data[0].source_id).toBe('csmrpd_001'); + expect(data[0].type).toBe('bank_account'); + }); + + it('forwards pagination flags into the query string', async () => { + setNextResponse(200, { + data: [], + }); + + const result = await runProdCli( + 'balances', + 'list', + '--limit', + '5', + '--starting-after', + 'csmrpd_cursor', + '--ending-before', + 'csmrpd_prev', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(lastRequest.url).toContain('/balances?'); + expect(lastRequest.url).toContain('limit=5'); + expect(lastRequest.url).toContain('starting_after=csmrpd_cursor'); + expect(lastRequest.url).toContain('ending_before=csmrpd_prev'); + }); + + it('rejects unauthenticated requests before hitting the API', async () => { + storage.clearAuth(); + + const result = await runProdCli('balances', 'list', '--json'); + + expect(result.exitCode).toBe(1); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('NOT_AUTHENTICATED'); + expect(String(output.message)).toMatch(/auth login/i); + const balancesRequest = requests.find((r) => r.url === '/balances'); + expect(balancesRequest).toBeUndefined(); + }); + + it('does not show balances in root help', async () => { + const result = await runProdCli('--help'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).not.toContain('balances'); + }); + }); + describe('auth login', () => { const DEVICE_CODE_RESPONSE = { device_code: 'test_device_code', diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index f958c0a..08dfa07 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -8,6 +8,7 @@ import { createPaymentMethodsCli } from './commands/payment-methods'; import { createReportCli } from './commands/report'; import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; +import { createBalancesCli } from './commands/balances'; import { createSourcesCli } from './commands/sources'; import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; @@ -76,7 +77,13 @@ const hiddenCli = authStorage, envAccessToken, ) - : null; + : requestedCommand === 'balances' + ? createBalancesCli( + () => factory.createBalancesResource(), + authStorage, + envAccessToken, + ) + : null; if (hiddenCli) { process.argv.splice(2, 1); } diff --git a/packages/cli/src/commands/balances/__tests__/balances.test.tsx b/packages/cli/src/commands/balances/__tests__/balances.test.tsx new file mode 100644 index 0000000..192fda0 --- /dev/null +++ b/packages/cli/src/commands/balances/__tests__/balances.test.tsx @@ -0,0 +1,106 @@ +import type { BalancesPage, IBalancesResource } from '@stripe/link-sdk'; +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; +import { sanitizeResource } from '../../../utils/resource-factory'; +import { BalancesList } from '../list'; + +const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden'; +const CLEAN_TEXT = 'EvilHidden'; + +function makeResource(page: BalancesPage): IBalancesResource { + return sanitizeResource({ + listBalances: vi.fn(async () => page), + } as unknown as IBalancesResource); +} + +describe('balances list component', () => { + it('renders balance details', async () => { + const resource = makeResource({ + data: [ + { + source_id: 'csmrpd_1', + name: 'Checking 1234', + type: 'bank_account', + available: { amount: 12500, currency: 'usd' }, + current: { amount: 13000, currency: 'usd' }, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Source'); + expect(frame).toContain('Type'); + expect(frame).toContain('ID'); + expect(frame).toContain('Available'); + expect(frame).toContain('Current'); + expect(frame).toContain('Checking 1234'); + expect(frame).toContain('bank_account'); + expect(frame).toContain('csmrpd_1'); + expect(frame).toContain('12500 usd'); + expect(frame).toContain('13000 usd'); + }); + }); + + it('renders an empty state when there are no balances', async () => { + const resource = makeResource({ data: [] }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('No balances found'); + }); + }); + + it('sanitizes escape sequences in balance fields', async () => { + const resource = makeResource({ + data: [ + { + source_id: 'csmrpd_1', + name: ESCAPE_PAYLOAD, + type: ESCAPE_PAYLOAD, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain(CLEAN_TEXT); + expect(frame).not.toContain('\x1b[2J'); + expect(frame).not.toContain('\r'); + }); + }); + + it('renders pagination metadata when available', async () => { + const resource = makeResource({ + data: [ + { + source_id: 'csmrpd_2', + name: 'Savings', + type: 'bank_account', + }, + ], + has_more: true, + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('has_more: true'); + expect(frame).toContain('--starting-after csmrpd_2'); + }); + }); +}); diff --git a/packages/cli/src/commands/balances/index.tsx b/packages/cli/src/commands/balances/index.tsx new file mode 100644 index 0000000..79d2a87 --- /dev/null +++ b/packages/cli/src/commands/balances/index.tsx @@ -0,0 +1,54 @@ +import type { + AuthStorage, + IBalancesResource, + ListBalancesParams, +} from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import React from 'react'; +import { renderInteractive } from '../../utils/render-interactive'; +import { requireAuth } from '../../utils/require-auth'; +import { BalancesList } from './list'; +import { listOptions } from './schema'; + +export function createBalancesCli( + createResource: () => IBalancesResource, + authStorage?: AuthStorage, + envAccessToken?: string, +) { + const cli = Cli.create('balances', { + description: 'List balances from your Link wallet', + }); + + cli.command('list', { + description: 'List balances from your Link wallet', + options: listOptions, + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage, envAccessToken)], + async run(c) { + const opts = c.options; + const resource = createResource(); + + const params: ListBalancesParams = {}; + if (opts.limit !== undefined) params.limit = opts.limit; + if (opts.startingAfter !== undefined) + params.starting_after = opts.startingAfter; + if (opts.endingBefore !== undefined) + params.ending_before = opts.endingBefore; + + if (!c.agent && !c.formatExplicit) { + return renderInteractive( + {}} + />, + () => resource.listBalances(params), + ); + } + + return resource.listBalances(params); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/balances/list.tsx b/packages/cli/src/commands/balances/list.tsx new file mode 100644 index 0000000..26ed60d --- /dev/null +++ b/packages/cli/src/commands/balances/list.tsx @@ -0,0 +1,237 @@ +import type { + Balance, + BalancesPage, + IBalancesResource, + ListBalancesParams, +} from '@stripe/link-sdk'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; + +interface BalancesListProps { + resource: IBalancesResource; + params?: ListBalancesParams; + onComplete: (result: BalancesPage | null) => void; +} + +const COLUMN_GAP = ' '; +const SOURCE_WIDTH = 13; +const TYPE_WIDTH = 12; +const ID_WIDTH = 15; +const AVAILABLE_WIDTH = 12; +const CURRENT_WIDTH = 12; +const CURRENCY_WIDTH = 6; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function truncateCell(value: string, width: number): string { + if (value.length <= width) { + return value; + } + + if (width <= 3) { + return value.slice(0, width); + } + + return `${value.slice(0, width - 3)}...`; +} + +function formatCell(value: string, width: number): string { + return truncateCell(value, width).padEnd(width); +} + +function formatScalar(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return null; +} + +function getNested(balance: Balance, key: string): unknown { + if (balance[key] !== undefined) { + return balance[key]; + } + + const balances = balance.balances; + if (isRecord(balances) && balances[key] !== undefined) { + return balances[key]; + } + + return undefined; +} + +function formatAmountValue(value: unknown, fallbackCurrency?: string): string { + const scalar = formatScalar(value); + if (scalar) { + return fallbackCurrency ? `${scalar} ${fallbackCurrency}` : scalar; + } + + if (!isRecord(value)) { + return '-'; + } + + const amount = formatScalar(value.amount); + if (!amount) { + return '-'; + } + + const currency = formatScalar(value.currency) ?? fallbackCurrency; + return currency ? `${amount} ${currency}` : amount; +} + +function formatAmount(balance: Balance, primaryKey: string): string { + const fallbackCurrency = formatScalar(balance.currency) ?? undefined; + const candidates = [ + primaryKey, + `${primaryKey}_balance`, + primaryKey === 'available' ? 'available_balance' : 'current_balance', + ]; + + for (const key of candidates) { + const value = getNested(balance, key); + if (value !== undefined && value !== null) { + return formatAmountValue(value, fallbackCurrency); + } + } + + return '-'; +} + +function sourceRecord(balance: Balance): Record | null { + return isRecord(balance.source) ? balance.source : null; +} + +function sourceName(balance: Balance): string { + const source = sourceRecord(balance); + return ( + formatScalar(balance.name) ?? + (source ? formatScalar(source.name) : null) ?? + 'Source' + ); +} + +function sourceType(balance: Balance): string { + const source = sourceRecord(balance); + return ( + formatScalar(balance.type) ?? + (source ? formatScalar(source.type) : null) ?? + '-' + ); +} + +function balanceId(balance: Balance, index: number): string { + const source = sourceRecord(balance); + return ( + formatScalar(balance.source_id) ?? + (source ? formatScalar(source.id) : null) ?? + formatScalar(balance.id) ?? + `balance-${index + 1}` + ); +} + +function currency(balance: Balance): string { + if (formatScalar(balance.currency)) { + return formatScalar(balance.currency) ?? '-'; + } + + for (const key of ['available', 'current', 'amount']) { + const value = getNested(balance, key); + if (isRecord(value) && formatScalar(value.currency)) { + return formatScalar(value.currency) ?? '-'; + } + } + + return '-'; +} + +export const BalancesList: React.FC = ({ + resource, + params, + onComplete, +}) => { + const action = useCallback( + () => resource.listBalances(params), + [resource, params], + ); + const { status, data: page, error } = useAsyncAction(action, onComplete); + const balances = page?.data ?? []; + const nextCursor = + page?.has_more && balances.length > 0 + ? balanceId(balances[balances.length - 1], balances.length - 1) + : null; + + const headerRow = [ + formatCell('Source', SOURCE_WIDTH), + formatCell('Type', TYPE_WIDTH), + formatCell('ID', ID_WIDTH), + formatCell('Available', AVAILABLE_WIDTH), + formatCell('Current', CURRENT_WIDTH), + formatCell('Currency', CURRENCY_WIDTH), + ].join(COLUMN_GAP); + const separatorRow = '-'.repeat(headerRow.length); + const rows = balances.map((balance, index) => + [ + formatCell(sourceName(balance), SOURCE_WIDTH), + formatCell(sourceType(balance), TYPE_WIDTH), + formatCell(balanceId(balance, index), ID_WIDTH), + formatCell(formatAmount(balance, 'available'), AVAILABLE_WIDTH), + formatCell(formatAmount(balance, 'current'), CURRENT_WIDTH), + formatCell(currency(balance), CURRENCY_WIDTH), + ].join(COLUMN_GAP), + ); + + if (status === 'loading') { + return ( + + + Loading balances... + + + ); + } + + if (status === 'error') { + return ( + + Failed to load balances + {error} + + ); + } + + if (balances.length === 0) { + return ( + + No balances found + + ); + } + + return ( + + Balances + + {headerRow} + {separatorRow} + {rows.map((row, index) => ( + {row} + ))} + + {page?.has_more !== undefined ? ( + + has_more: {String(page.has_more)} + {typeof nextCursor === 'string' && nextCursor.length > 0 ? ( + {`next page: --starting-after ${nextCursor}`} + ) : null} + + ) : null} + + ); +}; diff --git a/packages/cli/src/commands/balances/schema.ts b/packages/cli/src/commands/balances/schema.ts new file mode 100644 index 0000000..4d6e517 --- /dev/null +++ b/packages/cli/src/commands/balances/schema.ts @@ -0,0 +1,19 @@ +import { z } from 'incur'; + +export const listOptions = z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(100) + .optional() + .describe('Maximum number of balances to return (1-100).'), + startingAfter: z + .string() + .optional() + .describe('Cursor: return balances after this balance ID.'), + endingBefore: z + .string() + .optional() + .describe('Cursor: return balances before this balance ID.'), +}); diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index 92d57f5..acab355 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -1,4 +1,5 @@ import { + BalancesResource, LinkAuthenticationError, PaymentMethodsResource, SpendRequestResource, @@ -36,6 +37,9 @@ describe('ResourceFactory', () => { expect(factory.createPaymentMethodsResource()).toBe( factory.createPaymentMethodsResource(), ); + expect(factory.createBalancesResource()).toBe( + factory.createBalancesResource(), + ); expect(factory.createWebBotAuthResource()).toBe( factory.createWebBotAuthResource(), ); @@ -46,6 +50,7 @@ describe('ResourceFactory', () => { expect(factory.createPaymentMethodsResource()).toBeInstanceOf( PaymentMethodsResource, ); + expect(factory.createBalancesResource()).toBeInstanceOf(BalancesResource); expect(factory.createWebBotAuthResource()).toBeInstanceOf( WebBotAuthResource, ); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 8af63b0..107c35f 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,5 +1,7 @@ import { type AuthStorage, + BalancesResource, + type IBalancesResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -85,6 +87,7 @@ export class ResourceFactory { private userInfoResource?: IUserInfoResource; private transactionsResource?: ITransactionsResource; private sourcesResource?: ISourcesResource; + private balancesResource?: IBalancesResource; private webBotAuthResource?: IWebBotAuthResource; private reportResource?: IReportResource; @@ -257,6 +260,23 @@ export class ResourceFactory { return this.sourcesResource; } + createBalancesResource(): IBalancesResource { + if (this.balancesResource) { + return this.balancesResource; + } + + const getAccessToken = this.createSdkAccessTokenProvider(); + this.balancesResource = sanitizeResource( + new BalancesResource({ + verbose: this.verbose, + defaultHeaders: this.defaultHeaders, + getAccessToken, + }), + ); + + return this.balancesResource; + } + createWebBotAuthResource(): IWebBotAuthResource { if (this.webBotAuthResource) { return this.webBotAuthResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index cb8c0ae..814d570 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,5 +1,6 @@ import type { LinkOptions } from '@/config'; import type { + IBalancesResource, IPaymentMethodsResource, IShippingAddressResource, ISourcesResource, @@ -7,6 +8,7 @@ import type { ITransactionsResource, IUserInfoResource, } from '@/resources/interfaces'; +import { BalancesResource } from '@/resources/balances'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ShippingAddressResource } from '@/resources/shipping-address'; import { SourcesResource } from '@/resources/sources'; @@ -21,6 +23,7 @@ export class Link { readonly userInfo: IUserInfoResource; readonly transactions: ITransactionsResource; readonly sources: ISourcesResource; + readonly balances: IBalancesResource; constructor(options: LinkOptions = {}) { this.spendRequests = new SpendRequestResource(options); @@ -29,6 +32,7 @@ export class Link { this.userInfo = new UserInfoResource(options); this.transactions = new TransactionsResource(options); this.sources = new SourcesResource(options); + this.balances = new BalancesResource(options); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index caa52b4..d655757 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,6 +12,7 @@ export * from './resources/user-info'; export * from './resources/web-bot-auth'; export * from './resources/transactions'; export * from './resources/sources'; +export * from './resources/balances'; export * from './resources/report'; export { MemoryStorage, Storage, storage } from './utils/storage'; export type { diff --git a/packages/sdk/src/resources/__tests__/balances.test.ts b/packages/sdk/src/resources/__tests__/balances.test.ts new file mode 100644 index 0000000..ff5f44c --- /dev/null +++ b/packages/sdk/src/resources/__tests__/balances.test.ts @@ -0,0 +1,158 @@ +import { BalancesResource } from '@/resources/balances'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +const getAccessToken = vi.fn(); + +function mockFetchResponse(status: number, body: Record) { + mockFetch.mockResolvedValue({ + status, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify(body), + }); +} + +describe('BalancesResource', () => { + let repo: BalancesResource; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + vi.stubEnv('LINK_API_BASE_URL', undefined); + getAccessToken.mockResolvedValue('test_token'); + repo = new BalancesResource({ getAccessToken }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('GETs the Link API balances endpoint with bearer auth', async () => { + mockFetchResponse(200, { + data: [ + { + source_id: 'csmrpd_123', + name: 'Checking 1234', + type: 'bank_account', + available: { amount: 12500, currency: 'usd' }, + current: { amount: 13000, currency: 'usd' }, + }, + ], + has_more: true, + }); + + const result = await repo.listBalances(); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.link.com/balances'); + expect(opts.method).toBe('GET'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + + expect(result).toEqual({ + data: [ + { + source_id: 'csmrpd_123', + name: 'Checking 1234', + type: 'bank_account', + available: { amount: 12500, currency: 'usd' }, + current: { amount: 13000, currency: 'usd' }, + }, + ], + has_more: true, + }); + }); + + it('encodes optional list params in the query string', async () => { + mockFetchResponse(200, { data: [] }); + + await repo.listBalances({ + limit: 50, + starting_after: 'cursor_a', + ending_before: 'cursor_b', + }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('limit')).toBe('50'); + expect(url.searchParams.get('starting_after')).toBe('cursor_a'); + expect(url.searchParams.get('ending_before')).toBe('cursor_b'); + }); + + it('resolves the base URL from LINK_API_BASE_URL when set', async () => { + vi.stubEnv('LINK_API_BASE_URL', 'https://api.qa.link.com'); + repo = new BalancesResource({ getAccessToken }); + mockFetchResponse(200, { data: [] }); + + await repo.listBalances(); + + expect(mockFetch.mock.calls[0][0]).toBe('https://api.qa.link.com/balances'); + }); + + it('refreshes the token and retries once on 401', async () => { + mockFetch + .mockResolvedValueOnce({ + status: 401, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify({ error: 'expired_token' }), + }) + .mockResolvedValueOnce({ + status: 200, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify({ data: [] }), + }); + getAccessToken + .mockResolvedValueOnce('test_token') + .mockResolvedValueOnce('fresh_token'); + + const result = await repo.listBalances(); + + expect(result).toEqual({ data: [] }); + expect(getAccessToken).toHaveBeenNthCalledWith(1); + expect(getAccessToken).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe( + 'Bearer fresh_token', + ); + }); + + it('throws API errors with the response message', async () => { + mockFetchResponse(500, { message: 'boom' }); + + await expect(repo.listBalances()).rejects.toThrow( + 'Failed to list balances (500): boom', + ); + }); + + it('formats nested API error messages', async () => { + mockFetchResponse(401, { + error: { + message: 'Access token is missing required scopes: balances:read', + }, + }); + getAccessToken + .mockResolvedValueOnce('test_token') + .mockResolvedValueOnce('fresh_token'); + + await expect(repo.listBalances()).rejects.toThrow( + 'Failed to list balances (401): Access token is missing required scopes: balances:read', + ); + }); + + it('throws when no access token is available', async () => { + getAccessToken.mockRejectedValueOnce(new Error('Missing access token')); + + await expect(repo.listBalances()).rejects.toThrow('Missing access token'); + }); + + it('throws when the response shape is invalid', async () => { + mockFetchResponse(200, { data: 'not an array' }); + + await expect(repo.listBalances()).rejects.toThrow( + 'Failed to list balances (200): invalid response shape: Expected balances to be an array', + ); + }); +}); diff --git a/packages/sdk/src/resources/balances.ts b/packages/sdk/src/resources/balances.ts new file mode 100644 index 0000000..40d9f35 --- /dev/null +++ b/packages/sdk/src/resources/balances.ts @@ -0,0 +1,85 @@ +import type { LinkOptions } from '@/config'; +import { LinkApiError } from '@/errors'; +import { BaseResource, isRecord, requireBoolean } from '@/resources/base'; +import type { + IBalancesResource, + ListBalancesParams, +} from '@/resources/interfaces'; +import type { Balance, BalancesPage } from '@/types/index'; + +function normalizeBalances(value: unknown): Balance[] { + if (!Array.isArray(value)) { + throw new TypeError('Expected balances to be an array'); + } + + return value.map((item, index) => { + if (!isRecord(item)) { + throw new TypeError(`Expected balances[${index}] to be an object`); + } + return item as Balance; + }); +} + +function normalizeBalancesPage(value: unknown): BalancesPage { + if (!isRecord(value)) { + throw new TypeError('Expected response body to be an object'); + } + + const { data, has_more, ...rest } = value; + const normalized = normalizeBalances(data); + + return { + ...rest, + data: normalized, + ...(has_more !== undefined + ? { has_more: requireBoolean(has_more, 'has_more') } + : {}), + }; +} + +export class BalancesResource extends BaseResource implements IBalancesResource { + constructor(options: LinkOptions = {}) { + super(options, '/balances'); + } + + private buildUrl(params: ListBalancesParams): string { + const url = new URL(this.endpoint); + + if (params.limit !== undefined) { + url.searchParams.set('limit', String(params.limit)); + } + if (params.starting_after !== undefined) { + url.searchParams.set('starting_after', params.starting_after); + } + if (params.ending_before !== undefined) { + url.searchParams.set('ending_before', params.ending_before); + } + + return url.toString(); + } + + list(params: ListBalancesParams = {}): Promise { + return this.listBalances(params); + } + + async listBalances(params: ListBalancesParams = {}): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: this.buildUrl(params), + }); + + if (status < 200 || status >= 300) { + this.throwApiError('list balances', status, data, rawBody); + } + + try { + return normalizeBalancesPage(data); + } catch (error) { + const reason = error instanceof Error ? `: ${error.message}` : ''; + throw new LinkApiError( + `Failed to list balances (${status}): invalid response shape${reason}`, + { status, rawBody, details: data, cause: error }, + ); + } + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 8877c19..c593ed1 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -1,5 +1,6 @@ import type { AuthTokens, + BalancesPage, CredentialType, DeviceAuthRequest, LineItem, @@ -127,6 +128,17 @@ export interface ISourcesResource { listSources(params?: ListSourcesParams): Promise; } +export interface ListBalancesParams { + limit?: number; + starting_after?: string; + ending_before?: string; +} + +export interface IBalancesResource { + list(params?: ListBalancesParams): Promise; + listBalances(params?: ListBalancesParams): Promise; +} + export const REPORT_OUTCOMES = ['success', 'blocked', 'abandoned'] as const; export type ReportOutcome = (typeof REPORT_OUTCOMES)[number]; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 8ca1233..8bb2006 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -210,6 +210,28 @@ export interface SourcesPage { [key: string]: unknown; } +export interface Balance { + id?: string | null; + source_id?: string | null; + source?: Record | null; + name?: string | null; + type?: string | null; + amount?: number | Record | null; + available?: number | Record | null; + current?: number | Record | null; + available_balance?: number | Record | null; + current_balance?: number | Record | null; + currency?: string | null; + balances?: Record | null; + [key: string]: unknown; +} + +export interface BalancesPage { + data: Balance[]; + has_more?: boolean; + [key: string]: unknown; +} + export interface WebBotAuthBlock { signature: string; signature_input: string; From 1f0e280556ca2a156646c2ee2b48a1acbc56629c Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Tue, 14 Jul 2026 11:07:39 -0400 Subject: [PATCH 2/4] feat: update Balance type to match API schema Use typed cash/credit balance structure with currency maps instead of generic amount/currency objects. Committed-By-Agent: claude --- packages/cli/src/__tests__/cli.test.ts | 11 +- .../balances/__tests__/balances.test.tsx | 59 +++++-- packages/cli/src/commands/balances/list.tsx | 146 ++++-------------- .../src/resources/__tests__/balances.test.ts | 18 ++- packages/sdk/src/types/index.ts | 27 ++-- 5 files changed, 105 insertions(+), 156 deletions(-) diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 0a21bb8..6681969 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1119,10 +1119,11 @@ describe('production mode', () => { const SAMPLE_BALANCE = { source_id: 'csmrpd_001', - name: 'Checking 1234', - type: 'bank_account', - available: { amount: 12500, currency: 'usd' }, - current: { amount: 13000, currency: 'usd' }, + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', }; describe('balances list', () => { @@ -1147,7 +1148,7 @@ describe('production mode', () => { const data = output.data as Record[]; expect(data).toHaveLength(1); expect(data[0].source_id).toBe('csmrpd_001'); - expect(data[0].type).toBe('bank_account'); + expect(data[0].type).toBe('cash'); }); it('forwards pagination flags into the query string', async () => { diff --git a/packages/cli/src/commands/balances/__tests__/balances.test.tsx b/packages/cli/src/commands/balances/__tests__/balances.test.tsx index 192fda0..1dfdb43 100644 --- a/packages/cli/src/commands/balances/__tests__/balances.test.tsx +++ b/packages/cli/src/commands/balances/__tests__/balances.test.tsx @@ -19,10 +19,11 @@ describe('balances list component', () => { data: [ { source_id: 'csmrpd_1', - name: 'Checking 1234', - type: 'bank_account', - available: { amount: 12500, currency: 'usd' }, - current: { amount: 13000, currency: 'usd' }, + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', }, ], }); @@ -33,16 +34,40 @@ describe('balances list component', () => { await vi.waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('Source'); + expect(frame).toContain('Source ID'); expect(frame).toContain('Type'); - expect(frame).toContain('ID'); - expect(frame).toContain('Available'); expect(frame).toContain('Current'); - expect(frame).toContain('Checking 1234'); - expect(frame).toContain('bank_account'); + expect(frame).toContain('Currency'); expect(frame).toContain('csmrpd_1'); + expect(frame).toContain('cash'); + expect(frame).toContain('13000'); + expect(frame).toContain('usd'); expect(frame).toContain('12500 usd'); - expect(frame).toContain('13000 usd'); + }); + }); + + it('renders credit balance with used amount', async () => { + const resource = makeResource({ + data: [ + { + source_id: 'csmrpd_2', + type: 'credit', + credit: { used: { usd: 5000 } }, + current: 10000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('credit'); + expect(frame).toContain('used: 5000 usd'); }); }); @@ -62,9 +87,11 @@ describe('balances list component', () => { const resource = makeResource({ data: [ { - source_id: 'csmrpd_1', - name: ESCAPE_PAYLOAD, - type: ESCAPE_PAYLOAD, + source_id: ESCAPE_PAYLOAD, + type: 'cash', + current: 0, + currency: ESCAPE_PAYLOAD, + as_of: '2026-07-14T00:00:00Z', }, ], }); @@ -86,8 +113,10 @@ describe('balances list component', () => { data: [ { source_id: 'csmrpd_2', - name: 'Savings', - type: 'bank_account', + type: 'cash', + current: 5000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', }, ], has_more: true, diff --git a/packages/cli/src/commands/balances/list.tsx b/packages/cli/src/commands/balances/list.tsx index 26ed60d..614a276 100644 --- a/packages/cli/src/commands/balances/list.tsx +++ b/packages/cli/src/commands/balances/list.tsx @@ -17,16 +17,12 @@ interface BalancesListProps { } const COLUMN_GAP = ' '; -const SOURCE_WIDTH = 13; -const TYPE_WIDTH = 12; -const ID_WIDTH = 15; -const AVAILABLE_WIDTH = 12; -const CURRENT_WIDTH = 12; -const CURRENCY_WIDTH = 6; - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} +const SOURCE_ID_WIDTH = 16; +const TYPE_WIDTH = 8; +const CURRENT_WIDTH = 10; +const CURRENCY_WIDTH = 8; +const AVAILABLE_WIDTH = 20; +const AS_OF_WIDTH = 20; function truncateCell(value: string, width: number): string { if (value.length <= width) { @@ -44,110 +40,26 @@ function formatCell(value: string, width: number): string { return truncateCell(value, width).padEnd(width); } -function formatScalar(value: unknown): string | null { - if (typeof value === 'string' && value.length > 0) { - return value; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - return null; -} - -function getNested(balance: Balance, key: string): unknown { - if (balance[key] !== undefined) { - return balance[key]; - } - - const balances = balance.balances; - if (isRecord(balances) && balances[key] !== undefined) { - return balances[key]; - } - - return undefined; -} - -function formatAmountValue(value: unknown, fallbackCurrency?: string): string { - const scalar = formatScalar(value); - if (scalar) { - return fallbackCurrency ? `${scalar} ${fallbackCurrency}` : scalar; - } - - if (!isRecord(value)) { +function formatCurrencyMap(map: Record | null | undefined): string { + if (!map || typeof map !== 'object') { return '-'; } - const amount = formatScalar(value.amount); - if (!amount) { + const entries = Object.entries(map); + if (entries.length === 0) { return '-'; } - const currency = formatScalar(value.currency) ?? fallbackCurrency; - return currency ? `${amount} ${currency}` : amount; + return entries.map(([cur, amount]) => `${amount} ${cur}`).join(', '); } -function formatAmount(balance: Balance, primaryKey: string): string { - const fallbackCurrency = formatScalar(balance.currency) ?? undefined; - const candidates = [ - primaryKey, - `${primaryKey}_balance`, - primaryKey === 'available' ? 'available_balance' : 'current_balance', - ]; - - for (const key of candidates) { - const value = getNested(balance, key); - if (value !== undefined && value !== null) { - return formatAmountValue(value, fallbackCurrency); - } +function formatAvailable(balance: Balance): string { + if (balance.type === 'cash' && balance.cash) { + return formatCurrencyMap(balance.cash.available); } - - return '-'; -} - -function sourceRecord(balance: Balance): Record | null { - return isRecord(balance.source) ? balance.source : null; -} - -function sourceName(balance: Balance): string { - const source = sourceRecord(balance); - return ( - formatScalar(balance.name) ?? - (source ? formatScalar(source.name) : null) ?? - 'Source' - ); -} - -function sourceType(balance: Balance): string { - const source = sourceRecord(balance); - return ( - formatScalar(balance.type) ?? - (source ? formatScalar(source.type) : null) ?? - '-' - ); -} - -function balanceId(balance: Balance, index: number): string { - const source = sourceRecord(balance); - return ( - formatScalar(balance.source_id) ?? - (source ? formatScalar(source.id) : null) ?? - formatScalar(balance.id) ?? - `balance-${index + 1}` - ); -} - -function currency(balance: Balance): string { - if (formatScalar(balance.currency)) { - return formatScalar(balance.currency) ?? '-'; - } - - for (const key of ['available', 'current', 'amount']) { - const value = getNested(balance, key); - if (isRecord(value) && formatScalar(value.currency)) { - return formatScalar(value.currency) ?? '-'; - } + if (balance.type === 'credit' && balance.credit) { + return `used: ${formatCurrencyMap(balance.credit.used)}`; } - return '-'; } @@ -164,26 +76,26 @@ export const BalancesList: React.FC = ({ const balances = page?.data ?? []; const nextCursor = page?.has_more && balances.length > 0 - ? balanceId(balances[balances.length - 1], balances.length - 1) + ? balances[balances.length - 1].source_id : null; const headerRow = [ - formatCell('Source', SOURCE_WIDTH), + formatCell('Source ID', SOURCE_ID_WIDTH), formatCell('Type', TYPE_WIDTH), - formatCell('ID', ID_WIDTH), - formatCell('Available', AVAILABLE_WIDTH), formatCell('Current', CURRENT_WIDTH), formatCell('Currency', CURRENCY_WIDTH), + formatCell('Available/Used', AVAILABLE_WIDTH), + formatCell('As Of', AS_OF_WIDTH), ].join(COLUMN_GAP); const separatorRow = '-'.repeat(headerRow.length); - const rows = balances.map((balance, index) => + const rows = balances.map((balance) => [ - formatCell(sourceName(balance), SOURCE_WIDTH), - formatCell(sourceType(balance), TYPE_WIDTH), - formatCell(balanceId(balance, index), ID_WIDTH), - formatCell(formatAmount(balance, 'available'), AVAILABLE_WIDTH), - formatCell(formatAmount(balance, 'current'), CURRENT_WIDTH), - formatCell(currency(balance), CURRENCY_WIDTH), + formatCell(balance.source_id ?? '-', SOURCE_ID_WIDTH), + formatCell(balance.type ?? '-', TYPE_WIDTH), + formatCell(String(balance.current ?? '-'), CURRENT_WIDTH), + formatCell(balance.currency ?? '-', CURRENCY_WIDTH), + formatCell(formatAvailable(balance), AVAILABLE_WIDTH), + formatCell(balance.as_of ?? '-', AS_OF_WIDTH), ].join(COLUMN_GAP), ); @@ -221,7 +133,9 @@ export const BalancesList: React.FC = ({ {headerRow} {separatorRow} {rows.map((row, index) => ( - {row} + + {row} + ))} {page?.has_more !== undefined ? ( diff --git a/packages/sdk/src/resources/__tests__/balances.test.ts b/packages/sdk/src/resources/__tests__/balances.test.ts index ff5f44c..7326eb3 100644 --- a/packages/sdk/src/resources/__tests__/balances.test.ts +++ b/packages/sdk/src/resources/__tests__/balances.test.ts @@ -34,10 +34,11 @@ describe('BalancesResource', () => { data: [ { source_id: 'csmrpd_123', - name: 'Checking 1234', - type: 'bank_account', - available: { amount: 12500, currency: 'usd' }, - current: { amount: 13000, currency: 'usd' }, + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', }, ], has_more: true, @@ -55,10 +56,11 @@ describe('BalancesResource', () => { data: [ { source_id: 'csmrpd_123', - name: 'Checking 1234', - type: 'bank_account', - available: { amount: 12500, currency: 'usd' }, - current: { amount: 13000, currency: 'usd' }, + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', }, ], has_more: true, diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 8bb2006..397390f 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -210,19 +210,22 @@ export interface SourcesPage { [key: string]: unknown; } +export interface CashBalance { + available: Record; +} + +export interface CreditBalance { + used: Record; +} + export interface Balance { - id?: string | null; - source_id?: string | null; - source?: Record | null; - name?: string | null; - type?: string | null; - amount?: number | Record | null; - available?: number | Record | null; - current?: number | Record | null; - available_balance?: number | Record | null; - current_balance?: number | Record | null; - currency?: string | null; - balances?: Record | null; + source_id: string; + type: 'cash' | 'credit'; + cash?: CashBalance | null; + credit?: CreditBalance | null; + current: number; + currency: string; + as_of: string; [key: string]: unknown; } From ecd02887b92b32d398b8d055a29f744794dcc6ae Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Tue, 14 Jul 2026 11:23:41 -0400 Subject: [PATCH 3/4] feat: simplify balances table to 4 columns Show only source_id, type, current, and currency in the interactive table view. Other fields (cash, credit, as_of) remain in JSON output. Committed-By-Agent: claude --- .../balances/__tests__/balances.test.tsx | 5 ++-- packages/cli/src/commands/balances/list.tsx | 29 ------------------- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/balances/__tests__/balances.test.tsx b/packages/cli/src/commands/balances/__tests__/balances.test.tsx index 1dfdb43..92ff33e 100644 --- a/packages/cli/src/commands/balances/__tests__/balances.test.tsx +++ b/packages/cli/src/commands/balances/__tests__/balances.test.tsx @@ -42,7 +42,6 @@ describe('balances list component', () => { expect(frame).toContain('cash'); expect(frame).toContain('13000'); expect(frame).toContain('usd'); - expect(frame).toContain('12500 usd'); }); }); @@ -66,8 +65,10 @@ describe('balances list component', () => { await vi.waitFor(() => { const frame = lastFrame(); + expect(frame).toContain('csmrpd_2'); expect(frame).toContain('credit'); - expect(frame).toContain('used: 5000 usd'); + expect(frame).toContain('10000'); + expect(frame).toContain('usd'); }); }); diff --git a/packages/cli/src/commands/balances/list.tsx b/packages/cli/src/commands/balances/list.tsx index 614a276..e082a97 100644 --- a/packages/cli/src/commands/balances/list.tsx +++ b/packages/cli/src/commands/balances/list.tsx @@ -21,8 +21,6 @@ const SOURCE_ID_WIDTH = 16; const TYPE_WIDTH = 8; const CURRENT_WIDTH = 10; const CURRENCY_WIDTH = 8; -const AVAILABLE_WIDTH = 20; -const AS_OF_WIDTH = 20; function truncateCell(value: string, width: number): string { if (value.length <= width) { @@ -40,29 +38,6 @@ function formatCell(value: string, width: number): string { return truncateCell(value, width).padEnd(width); } -function formatCurrencyMap(map: Record | null | undefined): string { - if (!map || typeof map !== 'object') { - return '-'; - } - - const entries = Object.entries(map); - if (entries.length === 0) { - return '-'; - } - - return entries.map(([cur, amount]) => `${amount} ${cur}`).join(', '); -} - -function formatAvailable(balance: Balance): string { - if (balance.type === 'cash' && balance.cash) { - return formatCurrencyMap(balance.cash.available); - } - if (balance.type === 'credit' && balance.credit) { - return `used: ${formatCurrencyMap(balance.credit.used)}`; - } - return '-'; -} - export const BalancesList: React.FC = ({ resource, params, @@ -84,8 +59,6 @@ export const BalancesList: React.FC = ({ formatCell('Type', TYPE_WIDTH), formatCell('Current', CURRENT_WIDTH), formatCell('Currency', CURRENCY_WIDTH), - formatCell('Available/Used', AVAILABLE_WIDTH), - formatCell('As Of', AS_OF_WIDTH), ].join(COLUMN_GAP); const separatorRow = '-'.repeat(headerRow.length); const rows = balances.map((balance) => @@ -94,8 +67,6 @@ export const BalancesList: React.FC = ({ formatCell(balance.type ?? '-', TYPE_WIDTH), formatCell(String(balance.current ?? '-'), CURRENT_WIDTH), formatCell(balance.currency ?? '-', CURRENCY_WIDTH), - formatCell(formatAvailable(balance), AVAILABLE_WIDTH), - formatCell(balance.as_of ?? '-', AS_OF_WIDTH), ].join(COLUMN_GAP), ); From 33ee6d4bb4efd1108ba5bb8494d1289a2bb5d925 Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Tue, 14 Jul 2026 11:28:27 -0400 Subject: [PATCH 4/4] update table styling --- .../cli/src/commands/balances/__tests__/balances.test.tsx | 2 +- packages/cli/src/commands/balances/list.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/balances/__tests__/balances.test.tsx b/packages/cli/src/commands/balances/__tests__/balances.test.tsx index 92ff33e..8be54ca 100644 --- a/packages/cli/src/commands/balances/__tests__/balances.test.tsx +++ b/packages/cli/src/commands/balances/__tests__/balances.test.tsx @@ -36,7 +36,7 @@ describe('balances list component', () => { const frame = lastFrame(); expect(frame).toContain('Source ID'); expect(frame).toContain('Type'); - expect(frame).toContain('Current'); + expect(frame).toContain('Current balance'); expect(frame).toContain('Currency'); expect(frame).toContain('csmrpd_1'); expect(frame).toContain('cash'); diff --git a/packages/cli/src/commands/balances/list.tsx b/packages/cli/src/commands/balances/list.tsx index e082a97..06794df 100644 --- a/packages/cli/src/commands/balances/list.tsx +++ b/packages/cli/src/commands/balances/list.tsx @@ -19,7 +19,7 @@ interface BalancesListProps { const COLUMN_GAP = ' '; const SOURCE_ID_WIDTH = 16; const TYPE_WIDTH = 8; -const CURRENT_WIDTH = 10; +const CURRENT_WIDTH = 15; const CURRENCY_WIDTH = 8; function truncateCell(value: string, width: number): string { @@ -57,7 +57,7 @@ export const BalancesList: React.FC = ({ const headerRow = [ formatCell('Source ID', SOURCE_ID_WIDTH), formatCell('Type', TYPE_WIDTH), - formatCell('Current', CURRENT_WIDTH), + formatCell('Current balance', CURRENT_WIDTH), formatCell('Currency', CURRENCY_WIDTH), ].join(COLUMN_GAP); const separatorRow = '-'.repeat(headerRow.length);