diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index ef94f15..9810b0b 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -932,6 +932,14 @@ describe('production mode', () => { status: 'succeeded', }; + const SAMPLE_BALANCE = { + source_id: 'csmrpd_001', + name: 'Checking 1234', + type: 'bank_account', + available: { amount: 12500, currency: 'usd' }, + current: { amount: 13000, currency: 'usd' }, + }; + describe('transactions list', () => { it('GETs the Link API endpoint with bearer auth', async () => { setResponseForUrl('/transactions', 200, { @@ -1037,6 +1045,76 @@ describe('production mode', () => { }); }); + 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 ecddba9..763e53a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,6 +1,7 @@ import { type AuthStorage, Storage, storage } from '@stripe/link-sdk'; import { Cli } from 'incur'; import { createAuthCli } from './commands/auth'; +import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; @@ -62,20 +63,26 @@ const authRepo = factory.createAuthResource(); const spendRequestRepo = factory.createSpendRequestResource(); const requestedCommand = process.argv[2]; -const transactionsCli = +const hiddenCli = requestedCommand === 'transactions' ? createTransactionsCli( () => factory.createTransactionsResource(), authStorage, envAccessToken, ) - : null; -if (transactionsCli) { + : requestedCommand === 'balances' + ? createBalancesCli( + () => factory.createBalancesResource(), + authStorage, + envAccessToken, + ) + : null; +if (hiddenCli) { process.argv.splice(2, 1); } const cli = - transactionsCli ?? + hiddenCli ?? Cli.create('link-cli', { description: 'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.', @@ -98,7 +105,7 @@ if (!isAgent && process.stdout.isTTY) { } } -if (!transactionsCli) { +if (!hiddenCli) { cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), ); 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 3b0bfa4..71a3fbd 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,5 +1,7 @@ import { + BalancesResource, type AuthStorage, + type IBalancesResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -82,6 +84,7 @@ export class ResourceFactory { private shippingAddressResource?: IShippingAddressResource; private userInfoResource?: IUserInfoResource; private transactionsResource?: ITransactionsResource; + private balancesResource?: IBalancesResource; private webBotAuthResource?: IWebBotAuthResource; private reportResource?: IReportResource; @@ -237,6 +240,23 @@ export class ResourceFactory { return this.transactionsResource; } + 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 901aba9..0bd11ea 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,11 +1,13 @@ import type { LinkOptions } from '@/config'; import type { + IBalancesResource, IPaymentMethodsResource, IShippingAddressResource, ISpendRequestResource, ITransactionsResource, IUserInfoResource, } from '@/resources/interfaces'; +import { BalancesResource } from '@/resources/balances'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ShippingAddressResource } from '@/resources/shipping-address'; import { SpendRequestResource } from '@/resources/spend-request'; @@ -18,6 +20,7 @@ export class Link { readonly shippingAddresses: IShippingAddressResource; readonly userInfo: IUserInfoResource; readonly transactions: ITransactionsResource; + readonly balances: IBalancesResource; constructor(options: LinkOptions = {}) { this.spendRequests = new SpendRequestResource(options); @@ -25,6 +28,7 @@ export class Link { this.shippingAddresses = new ShippingAddressResource(options); this.userInfo = new UserInfoResource(options); this.transactions = new TransactionsResource(options); + this.balances = new BalancesResource(options); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ad84de8..a4a7d58 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,6 +11,7 @@ export * from './resources/shipping-address'; export * from './resources/user-info'; export * from './resources/web-bot-auth'; export * from './resources/transactions'; +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..feec369 --- /dev/null +++ b/packages/sdk/src/resources/balances.ts @@ -0,0 +1,210 @@ +import { + type LinkOptions, + requireFetchImplementation, + resolveLinkSdkConfig, +} from '@/config'; +import { LinkApiError, LinkTransportError } from '@/errors'; +import type { + AccessTokenProvider, + IBalancesResource, + ListBalancesParams, +} from '@/resources/interfaces'; +import type { Balance, BalancesPage } from '@/types/index'; + +interface ApiFetchOptions { + method: string; + url: string; + headers?: Record; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new TypeError(`Expected ${field} to be a boolean`); + } + return value; +} + +function extractErrorMessage(data: unknown, rawBody: string): string { + if (isRecord(data)) { + if (typeof data.error === 'string') { + return data.error; + } + if (isRecord(data.error)) { + const nested = data.error; + if (typeof nested.message === 'string') { + return nested.message; + } + if (typeof nested.code === 'string') { + return nested.code; + } + } + if (typeof data.message === 'string') { + return data.message; + } + } + + return rawBody || 'unknown error'; +} + +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 implements IBalancesResource { + private readonly verbose: boolean; + private readonly getAccessToken: AccessTokenProvider; + private readonly fetchImpl: typeof globalThis.fetch; + private readonly endpoint: string; + private readonly logger: { debug(message: string): void }; + + constructor(options: LinkOptions = {}) { + const config = resolveLinkSdkConfig(options); + this.verbose = config.verbose; + this.getAccessToken = config.getAccessToken; + this.fetchImpl = requireFetchImplementation(config); + this.endpoint = `${config.apiBaseUrl}/balances`; + this.logger = config.logger; + } + + private async rawFetch( + opts: ApiFetchOptions, + ): Promise<{ status: number; data: unknown; rawBody: string }> { + if (this.verbose) { + const redactedHeaders = { ...opts.headers }; + if (redactedHeaders.Authorization) + redactedHeaders.Authorization = 'Bearer '; + this.logger.debug(`> ${opts.method} ${opts.url}`); + this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); + } + + let response: Response; + try { + response = await this.fetchImpl(opts.url, { + method: opts.method, + headers: opts.headers, + }); + } catch (error) { + throw new LinkTransportError( + `Request failed: ${opts.method} ${opts.url}`, + { cause: error }, + ); + } + const rawBody = await response.text(); + + let data: unknown = null; + try { + data = JSON.parse(rawBody); + } catch { + // non-JSON response (e.g., from load balancer) + } + + if (this.verbose) { + this.logger.debug(`< ${response.status} ${response.statusText}`); + response.headers.forEach((value, key) => { + this.logger.debug(` ${key}: ${value}`); + }); + this.logger.debug(rawBody); + } + + return { status: response.status, data, rawBody }; + } + + private async apiFetch( + opts: ApiFetchOptions, + ): Promise<{ status: number; data: unknown; rawBody: string }> { + const token = await this.getAccessToken(); + const authedOpts = { + ...opts, + headers: { + ...opts.headers, + Authorization: `Bearer ${token}`, + }, + }; + + const res = await this.rawFetch(authedOpts); + + if (res.status === 401) { + const refreshedToken = await this.getAccessToken({ forceRefresh: true }); + authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; + return this.rawFetch(authedOpts); + } + + return res; + } + + 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) { + const msg = extractErrorMessage(data, rawBody); + throw new LinkApiError(`Failed to list balances (${status}): ${msg}`, { + status, + rawBody, + details: data, + }); + } + + try { + return normalizeBalancesPage(data); + } catch (error) { + const reason = error instanceof Error ? `: ${error.message}` : ''; + throw new LinkApiError( + `Failed to list balances (200): 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 0628fdb..fc6d9f6 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, @@ -115,6 +116,17 @@ export interface ITransactionsResource { listTransactions(params?: ListTransactionsParams): 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 83fddbc..e27d633 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -192,6 +192,28 @@ export interface TransactionsPage { [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;