diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index a6e0a8e..6681969 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1117,6 +1117,85 @@ describe('production mode', () => { }); }); + const SAMPLE_BALANCE = { + source_id: 'csmrpd_001', + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', + }; + + 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('cash'); + }); + + 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..8be54ca --- /dev/null +++ b/packages/cli/src/commands/balances/__tests__/balances.test.tsx @@ -0,0 +1,136 @@ +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', + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Source ID'); + expect(frame).toContain('Type'); + expect(frame).toContain('Current balance'); + expect(frame).toContain('Currency'); + expect(frame).toContain('csmrpd_1'); + expect(frame).toContain('cash'); + expect(frame).toContain('13000'); + expect(frame).toContain('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('csmrpd_2'); + expect(frame).toContain('credit'); + expect(frame).toContain('10000'); + expect(frame).toContain('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: ESCAPE_PAYLOAD, + type: 'cash', + current: 0, + currency: ESCAPE_PAYLOAD, + as_of: '2026-07-14T00:00:00Z', + }, + ], + }); + + 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', + type: 'cash', + current: 5000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', + }, + ], + 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..06794df --- /dev/null +++ b/packages/cli/src/commands/balances/list.tsx @@ -0,0 +1,122 @@ +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_ID_WIDTH = 16; +const TYPE_WIDTH = 8; +const CURRENT_WIDTH = 15; +const CURRENCY_WIDTH = 8; + +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); +} + +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 + ? balances[balances.length - 1].source_id + : null; + + const headerRow = [ + formatCell('Source ID', SOURCE_ID_WIDTH), + formatCell('Type', TYPE_WIDTH), + formatCell('Current balance', CURRENT_WIDTH), + formatCell('Currency', CURRENCY_WIDTH), + ].join(COLUMN_GAP); + const separatorRow = '-'.repeat(headerRow.length); + const rows = balances.map((balance) => + [ + formatCell(balance.source_id ?? '-', SOURCE_ID_WIDTH), + formatCell(balance.type ?? '-', TYPE_WIDTH), + formatCell(String(balance.current ?? '-'), CURRENT_WIDTH), + formatCell(balance.currency ?? '-', 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..7326eb3 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/balances.test.ts @@ -0,0 +1,160 @@ +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', + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', + }, + ], + 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', + type: 'cash', + cash: { available: { usd: 12500 } }, + current: 13000, + currency: 'usd', + as_of: '2026-07-14T00:00:00Z', + }, + ], + 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..397390f 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -210,6 +210,31 @@ export interface SourcesPage { [key: string]: unknown; } +export interface CashBalance { + available: Record; +} + +export interface CreditBalance { + used: Record; +} + +export interface Balance { + source_id: string; + type: 'cash' | 'credit'; + cash?: CashBalance | null; + credit?: CreditBalance | null; + current: number; + currency: string; + as_of: string; + [key: string]: unknown; +} + +export interface BalancesPage { + data: Balance[]; + has_more?: boolean; + [key: string]: unknown; +} + export interface WebBotAuthBlock { signature: string; signature_input: string;