diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index ef94f15..a6e0a8e 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1037,6 +1037,86 @@ describe('production mode', () => { }); }); + const SAMPLE_SOURCE = { + id: 'csmrpd_001', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'eligible' }, + }, + external_connection: { status: 'active' }, + }; + + describe('sources list', () => { + it('GETs the Link API endpoint with bearer auth', async () => { + setResponseForUrl('/sources', 200, { + data: [SAMPLE_SOURCE], + has_more: true, + }); + + const result = await runProdCli('sources', 'list', '--json'); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('GET'); + expect(lastRequest.url).toBe('/sources'); + 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].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( + 'sources', + 'list', + '--limit', + '5', + '--starting-after', + 'csmrpd_cursor', + '--ending-before', + 'csmrpd_prev', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(lastRequest.url).toContain('/sources?'); + 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('sources', '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 sourcesRequest = requests.find((r) => r.url === '/sources'); + expect(sourcesRequest).toBeUndefined(); + }); + + it('does not show sources in root help', async () => { + const result = await runProdCli('--help'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).not.toContain('sources'); + }); + }); + 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..f958c0a 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 { createSourcesCli } from './commands/sources'; import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; @@ -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 === 'sources' + ? createSourcesCli( + () => factory.createSourcesResource(), + 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/sources/__tests__/sources.test.tsx b/packages/cli/src/commands/sources/__tests__/sources.test.tsx new file mode 100644 index 0000000..a8c12cf --- /dev/null +++ b/packages/cli/src/commands/sources/__tests__/sources.test.tsx @@ -0,0 +1,160 @@ +import type { ISourcesResource, SourcesPage } from '@stripe/link-sdk'; +import { render } from 'ink-testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sanitizeResource } from '../../../utils/resource-factory'; +import { SourcesList } from '../list'; + +const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden'; +const CLEAN_TEXT = 'EvilHidden'; + +function makeResource(page: SourcesPage): ISourcesResource { + return sanitizeResource({ + listSources: vi.fn(async () => page), + } as unknown as ISourcesResource); +} + +function setTerminalWidth(columns: number) { + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: columns, + }); +} + +describe('sources list component', () => { + afterEach(() => { + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: undefined, + }); + }); + + it('renders source details', async () => { + setTerminalWidth(160); + const resource = makeResource({ + data: [ + { + id: 'csmrpd_1', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + balances: { status: 'eligible' }, + transactions: { status: 'pending' }, + }, + external_connection: { status: 'active' }, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Name'); + expect(frame).toContain('Type'); + expect(frame).toContain('ID'); + expect(frame).toContain('Checking 1234'); + expect(frame).toContain('bank_account'); + expect(frame).toContain('csmrpd_1'); + expect(frame).toContain('balances:eligible'); + expect(frame).toContain('transactions'); + expect(frame).toContain('active'); + }); + }); + + it('clips wide table columns in narrow terminals', async () => { + setTerminalWidth(72); + const resource = makeResource({ + data: [ + { + id: 'csmrpd_1', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'pending' }, + }, + external_connection: { status: 'active' }, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Checking 1234'); + expect(frame).toContain('Name'); + expect(frame).toContain('Type'); + expect(frame).toContain('ID'); + expect(frame).toContain('Capabi'); + expect(frame).not.toContain('Details'); + + const tableLines = frame + ?.split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0 && line !== 'Sources'); + expect(tableLines?.every((line) => line.length <= 72)).toBe(true); + }); + }); + + it('renders an empty state when there are no sources', async () => { + const resource = makeResource({ data: [] }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('No sources found'); + }); + }); + + it('sanitizes escape sequences in source fields', async () => { + const resource = makeResource({ + data: [ + { + 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: [ + { + 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/sources/index.tsx b/packages/cli/src/commands/sources/index.tsx new file mode 100644 index 0000000..76d3fde --- /dev/null +++ b/packages/cli/src/commands/sources/index.tsx @@ -0,0 +1,54 @@ +import type { + AuthStorage, + ISourcesResource, + ListSourcesParams, +} 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 { SourcesList } from './list'; +import { listOptions } from './schema'; + +export function createSourcesCli( + createResource: () => ISourcesResource, + authStorage?: AuthStorage, + envAccessToken?: string, +) { + const cli = Cli.create('sources', { + description: 'List sources from your Link wallet', + }); + + cli.command('list', { + description: 'List sources 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: ListSourcesParams = {}; + 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.listSources(params), + ); + } + + return resource.listSources(params); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/sources/list.tsx b/packages/cli/src/commands/sources/list.tsx new file mode 100644 index 0000000..88dd5f5 --- /dev/null +++ b/packages/cli/src/commands/sources/list.tsx @@ -0,0 +1,250 @@ +import type { + ISourcesResource, + ListSourcesParams, + Source, + SourcesPage, +} 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 SourcesListProps { + resource: ISourcesResource; + params?: ListSourcesParams; + onComplete: (result: SourcesPage | null) => void; +} + +const COLUMN_GAP = ' '; +const HORIZONTAL_PADDING = 4; + +interface SourceRow { + key: string; + name: string; + type: string; + id: string; + capabilities: string; + external: string; +} + +interface TableColumn { + label: string; + value: (row: SourceRow) => string; + minWidth: number; + maxWidth: number; +} + +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 sourceId(source: Source, index: number): string { + return typeof source.id === 'string' && source.id.length > 0 + ? source.id + : `source-${index + 1}`; +} + +function statusFromValue(value: unknown): string | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const status = (value as Record).status; + return typeof status === 'string' ? status : null; + } + return null; +} + +function formatCapabilities(source: Source): string { + const capabilities = source.capabilities; + if (!capabilities || typeof capabilities !== 'object') { + return '-'; + } + + const entries = Object.entries(capabilities) + .map(([capability, value]) => { + const status = statusFromValue(value); + return status ? `${capability}:${status}` : capability; + }) + .sort(); + + return entries.length > 0 ? entries.join(', ') : '-'; +} + +function formatExternalConnection(source: Source): string { + const status = statusFromValue(source.external_connection); + return status ?? '-'; +} + +function sourceRow(source: Source, index: number): SourceRow { + const capabilities = formatCapabilities(source); + const external = formatExternalConnection(source); + + return { + key: sourceId(source, index), + name: source.name ?? 'Source', + type: source.type ?? '-', + id: sourceId(source, index), + capabilities, + external, + }; +} + +function tableColumns(): TableColumn[] { + return [ + { label: 'Name', value: (row) => row.name, minWidth: 14, maxWidth: 24 }, + { label: 'Type', value: (row) => row.type, minWidth: 10, maxWidth: 14 }, + { label: 'ID', value: (row) => row.id, minWidth: 16, maxWidth: 48 }, + { + label: 'Capabilities', + value: (row) => row.capabilities, + minWidth: 8, + maxWidth: 48, + }, + { + label: 'External connection status', + value: (row) => row.external, + minWidth: 8, + maxWidth: 36, + }, + ]; +} + +function distributeWidths( + columns: TableColumn[], + availableWidth: number, +): number[] { + const gapWidth = COLUMN_GAP.length * Math.max(0, columns.length - 1); + const contentWidth = Math.max(columns.length, availableWidth - gapWidth); + const widths = columns.map((column) => column.minWidth); + let remaining = + contentWidth - widths.reduce((total, width) => total + width, 0); + + while (remaining > 0) { + let changed = false; + + for (let index = 0; index < columns.length && remaining > 0; index += 1) { + if (widths[index] >= columns[index].maxWidth) { + continue; + } + + widths[index] += 1; + remaining -= 1; + changed = true; + } + + if (!changed) { + break; + } + } + + return widths; +} + +function renderTableRows(rows: SourceRow[], terminalWidth: number) { + const columns = tableColumns(); + const availableWidth = contentWidth(terminalWidth); + const widths = distributeWidths(columns, availableWidth); + const headerRow = columns + .map((column, index) => formatCell(column.label, widths[index])) + .join(COLUMN_GAP) + .slice(0, availableWidth); + const separatorRow = '-'.repeat(headerRow.length).slice(0, availableWidth); + const bodyRows = rows.map((row) => + columns + .map((column, index) => formatCell(column.value(row), widths[index])) + .join(COLUMN_GAP) + .slice(0, availableWidth), + ); + + return { headerRow, separatorRow, bodyRows }; +} + +function contentWidth(terminalWidth: number): number { + return Math.max(1, terminalWidth - HORIZONTAL_PADDING); +} + +export const SourcesList: React.FC = ({ + resource, + params, + onComplete, +}) => { + const action = useCallback( + () => resource.listSources(params), + [resource, params], + ); + const { + status, + data: page, + error, + } = useAsyncAction(action, onComplete); + const sources = page?.data ?? []; + const nextCursor = + page?.has_more && sources.length > 0 + ? sources[sources.length - 1].id + : null; + const rows = sources.map(sourceRow); + const terminalWidth = process.stdout.columns ?? 140; + const { headerRow, separatorRow, bodyRows } = renderTableRows( + rows, + terminalWidth, + ); + + if (status === 'loading') { + return ( + + + Loading sources... + + + ); + } + + if (status === 'error') { + return ( + + Failed to load sources + {error} + + ); + } + + if (sources.length === 0) { + return ( + + No sources found + + ); + } + + return ( + + Sources + + {headerRow} + {separatorRow} + {bodyRows.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/sources/schema.ts b/packages/cli/src/commands/sources/schema.ts new file mode 100644 index 0000000..4a08cfd --- /dev/null +++ b/packages/cli/src/commands/sources/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 sources to return (1-100).'), + startingAfter: z + .string() + .optional() + .describe('Cursor: return sources after this source ID.'), + endingBefore: z + .string() + .optional() + .describe('Cursor: return sources before this source ID.'), +}); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 3b0bfa4..8af63b0 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -3,6 +3,7 @@ import { type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, + type ISourcesResource, type ISpendRequestResource, type ITransactionsResource, type IUserInfoResource, @@ -11,6 +12,7 @@ import { PaymentMethodsResource, ReportResource, ShippingAddressResource, + SourcesResource, SpendRequestResource, TransactionsResource, UserInfoResource, @@ -82,6 +84,7 @@ export class ResourceFactory { private shippingAddressResource?: IShippingAddressResource; private userInfoResource?: IUserInfoResource; private transactionsResource?: ITransactionsResource; + private sourcesResource?: ISourcesResource; private webBotAuthResource?: IWebBotAuthResource; private reportResource?: IReportResource; @@ -237,6 +240,23 @@ export class ResourceFactory { return this.transactionsResource; } + createSourcesResource(): ISourcesResource { + if (this.sourcesResource) { + return this.sourcesResource; + } + + const getAccessToken = this.createSdkAccessTokenProvider(); + this.sourcesResource = sanitizeResource( + new SourcesResource({ + verbose: this.verbose, + defaultHeaders: this.defaultHeaders, + getAccessToken, + }), + ); + + return this.sourcesResource; + } + createWebBotAuthResource(): IWebBotAuthResource { if (this.webBotAuthResource) { return this.webBotAuthResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 901aba9..cb8c0ae 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2,12 +2,14 @@ import type { LinkOptions } from '@/config'; import type { IPaymentMethodsResource, IShippingAddressResource, + ISourcesResource, ISpendRequestResource, ITransactionsResource, IUserInfoResource, } from '@/resources/interfaces'; import { PaymentMethodsResource } from '@/resources/payment-methods'; import { ShippingAddressResource } from '@/resources/shipping-address'; +import { SourcesResource } from '@/resources/sources'; import { SpendRequestResource } from '@/resources/spend-request'; import { TransactionsResource } from '@/resources/transactions'; import { UserInfoResource } from '@/resources/user-info'; @@ -18,6 +20,7 @@ export class Link { readonly shippingAddresses: IShippingAddressResource; readonly userInfo: IUserInfoResource; readonly transactions: ITransactionsResource; + readonly sources: ISourcesResource; 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.sources = new SourcesResource(options); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ad84de8..caa52b4 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/sources'; export * from './resources/report'; export { MemoryStorage, Storage, storage } from './utils/storage'; export type { diff --git a/packages/sdk/src/resources/__tests__/sources.test.ts b/packages/sdk/src/resources/__tests__/sources.test.ts new file mode 100644 index 0000000..caf5c9d --- /dev/null +++ b/packages/sdk/src/resources/__tests__/sources.test.ts @@ -0,0 +1,162 @@ +import { SourcesResource } from '@/resources/sources'; +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('SourcesResource', () => { + let repo: SourcesResource; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + vi.stubEnv('LINK_API_BASE_URL', undefined); + getAccessToken.mockResolvedValue('test_token'); + repo = new SourcesResource({ getAccessToken }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('GETs the Link API sources endpoint with bearer auth', async () => { + mockFetchResponse(200, { + data: [ + { + id: 'csmrpd_123', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'eligible' }, + }, + }, + ], + has_more: true, + }); + + const result = await repo.listSources(); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.link.com/sources'); + expect(opts.method).toBe('GET'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + + expect(result).toEqual({ + data: [ + { + id: 'csmrpd_123', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'eligible' }, + }, + }, + ], + has_more: true, + }); + }); + + it('encodes optional list params in the query string', async () => { + mockFetchResponse(200, { data: [] }); + + await repo.listSources({ + 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 SourcesResource({ getAccessToken }); + mockFetchResponse(200, { data: [] }); + + await repo.listSources(); + + expect(mockFetch.mock.calls[0][0]).toBe( + 'https://api.qa.link.com/sources', + ); + }); + + 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.listSources(); + + 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.listSources()).rejects.toThrow( + 'Failed to list sources (500): boom', + ); + }); + + it('formats nested API error messages', async () => { + mockFetchResponse(401, { + error: { + message: 'Access token is missing required scopes: source_details:read', + }, + }); + getAccessToken + .mockResolvedValueOnce('test_token') + .mockResolvedValueOnce('fresh_token'); + + await expect(repo.listSources()).rejects.toThrow( + 'Failed to list sources (401): Access token is missing required scopes: source_details:read', + ); + }); + + it('throws when no access token is available', async () => { + getAccessToken.mockRejectedValueOnce(new Error('Missing access token')); + + await expect(repo.listSources()).rejects.toThrow('Missing access token'); + }); + + it('throws when the response shape is invalid', async () => { + mockFetchResponse(200, { data: 'not an array' }); + + await expect(repo.listSources()).rejects.toThrow( + 'Failed to list sources (200): invalid response shape: Expected sources to be an array', + ); + }); +}); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 0628fdb..8877c19 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -6,6 +6,7 @@ import type { PaymentMethod, RequestApprovalResponse, ShippingAddressRecord, + SourcesPage, SpendRequest, Total, TransactionOrigin, @@ -115,6 +116,17 @@ export interface ITransactionsResource { listTransactions(params?: ListTransactionsParams): Promise; } +export interface ListSourcesParams { + limit?: number; + starting_after?: string; + ending_before?: string; +} + +export interface ISourcesResource { + list(params?: ListSourcesParams): Promise; + listSources(params?: ListSourcesParams): Promise; +} + export const REPORT_OUTCOMES = ['success', 'blocked', 'abandoned'] as const; export type ReportOutcome = (typeof REPORT_OUTCOMES)[number]; diff --git a/packages/sdk/src/resources/sources.ts b/packages/sdk/src/resources/sources.ts new file mode 100644 index 0000000..3a22d6b --- /dev/null +++ b/packages/sdk/src/resources/sources.ts @@ -0,0 +1,85 @@ +import type { LinkOptions } from '@/config'; +import { LinkApiError } from '@/errors'; +import { BaseResource, isRecord, requireBoolean } from '@/resources/base'; +import type { + ISourcesResource, + ListSourcesParams, +} from '@/resources/interfaces'; +import type { Source, SourcesPage } from '@/types/index'; + +function normalizeSources(value: unknown): Source[] { + if (!Array.isArray(value)) { + throw new TypeError('Expected sources to be an array'); + } + + return value.map((item, index) => { + if (!isRecord(item)) { + throw new TypeError(`Expected sources[${index}] to be an object`); + } + return item as Source; + }); +} + +function normalizeSourcesPage(value: unknown): SourcesPage { + if (!isRecord(value)) { + throw new TypeError('Expected response body to be an object'); + } + + const { data, has_more, ...rest } = value; + const normalized = normalizeSources(data); + + return { + ...rest, + data: normalized, + ...(has_more !== undefined + ? { has_more: requireBoolean(has_more, 'has_more') } + : {}), + }; +} + +export class SourcesResource extends BaseResource implements ISourcesResource { + constructor(options: LinkOptions = {}) { + super(options, '/sources'); + } + + private buildUrl(params: ListSourcesParams): 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: ListSourcesParams = {}): Promise { + return this.listSources(params); + } + + async listSources(params: ListSourcesParams = {}): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: this.buildUrl(params), + }); + + if (status < 200 || status >= 300) { + this.throwApiError('list sources', status, data, rawBody); + } + + try { + return normalizeSourcesPage(data); + } catch (error) { + const reason = error instanceof Error ? `: ${error.message}` : ''; + throw new LinkApiError( + `Failed to list sources (${status}): invalid response shape${reason}`, + { status, rawBody, details: data, cause: error }, + ); + } + } +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 83fddbc..8ca1233 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -192,6 +192,24 @@ export interface TransactionsPage { [key: string]: unknown; } +export interface Source { + id?: string | null; + name?: string | null; + type?: string | null; + capabilities?: Record | null; + external_connection?: Record | null; + granted_actions?: string[] | null; + bank_account?: Record | null; + card?: Record | null; + [key: string]: unknown; +} + +export interface SourcesPage { + data: Source[]; + has_more?: boolean; + [key: string]: unknown; +} + export interface WebBotAuthBlock { signature: string; signature_input: string;