diff --git a/.changeset/restore-financial-insights.md b/.changeset/restore-financial-insights.md new file mode 100644 index 00000000..a311a7ce --- /dev/null +++ b/.changeset/restore-financial-insights.md @@ -0,0 +1,6 @@ +--- +'@stripe/link-cli': minor +'@stripe/link-sdk': minor +--- + +Add `insights list-available-types` and `insights list` for discovering and retrieving Link financial insights. diff --git a/README.md b/README.md index 85335632..146b2699 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Link CLI can also read a consumer's financial data -- transactions, balances, an ### Authentication -Financial Insights requires additional authorization beyond the default; request access to each type of data you want to access on financial data sources: +Discovering insight types only requires an authenticated session. To retrieve insight data, transactions, balances, or source details, request the source actions needed for that data: ```bash link-cli auth login \ @@ -280,6 +280,15 @@ link-cli auth login \ ``` If already authenticated for payments, use `auth upgrade` to add financial data access without dropping existing scopes. +#### Discover and list insights + +```bash +link-cli insights list-available-types --format json +link-cli insights list --insight --format json +``` + +The first command lists available insight IDs, descriptions, and any access needed to retrieve them. The second returns the selected insight's status and data. Omit `--insight` to list all insights, or repeat it for several IDs. Both commands support `--limit` and `--starting-after`. A `no_data` result can include `authorization_remediation` for missing permissions; `pending` means the insight is still being computed. Insight values are tagged by type, and unknown types remain available in JSON output. + #### List sources ```bash diff --git a/docs/link-cli-skill.md b/docs/link-cli-skill.md index 65dc037c..09eb45d1 100644 --- a/docs/link-cli-skill.md +++ b/docs/link-cli-skill.md @@ -12,7 +12,7 @@ Set up and authenticate Link CLI for the user's intended use case. After setup, Infer the use case only when the user's intent is explicit: - **Agent payments**: buying, paying, checking out, or obtaining a payment credential. -- **Financial insights**: reading transactions, balances, connected accounts, or spending patterns. +- **Financial insights**: reading precomputed purchase-pattern insights, transactions, balances, connected accounts, or spending patterns. - **Both**: enabling payments and financial insights. If the intended use case is unclear, present **Both** first and explicitly recommend it as the default before installing, authenticating, or choosing permissions: @@ -74,6 +74,7 @@ Map financial-insight needs to source actions: |---|---| | Link-processed transactions | `read_link_transactions` | | Transactions imported from connected banks | `read_external_transactions` | +| Precomputed shopping insights | Discover requirements with `insights list-available-types`; the current top-brand insight can use `read_link_transactions` or `read_external_transactions` on a source. | | Account balances | `read_balances` | | Connected source details and descriptions | `read_source_details` | @@ -115,5 +116,5 @@ If approval is denied, expires, or times out, report that outcome. Do not repeat Authentication alone does not authorize an individual purchase and does not answer a financial-data question. - For purchases and payment credentials, use the `create-payment-credential` skill. -- For transactions, balances, and sources, use the `financial-insights` skill. +- For precomputed insights, transactions, balances, and sources, use the `financial-insights` skill. Discover available insight IDs with `insights list-available-types` before retrieving relevant ones with `insights list`. - For users who selected both, load the relevant downstream skill for each subsequent task. diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 916a1185..ef20e2fa 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1876,6 +1876,85 @@ describe('production mode', () => { status: 'succeeded', }; + describe('insights commands', () => { + it('discovers insight types with their authorization remediation', async () => { + const page = { + data: [ + { + id: 'top_brand_by_transaction_count_per_category_t180d', + description: 'Top brands', + authorization_remediation: { + authorization_details: [ + { type: 'source', actions: ['read_link_transactions'] }, + ], + }, + }, + ], + has_more: false, + }; + setNextResponse(200, page); + + const result = await runProdCli( + 'insights', + 'list-available-types', + '--limit', + '1', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('GET'); + expect(lastRequest.url).toBe('/insights/available_types?limit=1'); + expect(parseJson(result.stdout)).toEqual(page); + }); + + it('lists filtered insights and retains unknown value types in JSON output', async () => { + const page = { + data: [ + { + id: 'top_brand_by_transaction_count_per_category_t180d', + description: 'Top brands', + status: 'ready', + as_of: 1790723779, + data: [ + { + label: 'Future metric', + value: { type: 'percentile', percentile: { value: 92 } }, + }, + ], + }, + ], + has_more: false, + }; + setNextResponse(200, page); + + const result = await runProdCli( + 'insights', + 'list', + '--insight', + 'top_brand_by_transaction_count_per_category_t180d', + '--insight', + 'another_insight', + '--limit', + '5', + '--starting-after', + 'earlier', + '--json', + ); + + expect(result.exitCode).toBe(0); + const url = new URL(lastRequest.url, 'https://api.link.com'); + expect(url.pathname).toBe('/insights'); + expect(url.searchParams.getAll('insights[]')).toEqual([ + 'top_brand_by_transaction_count_per_category_t180d', + 'another_insight', + ]); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.get('starting_after')).toBe('earlier'); + expect(parseJson(result.stdout)).toEqual(page); + }); + }); + describe('transactions list', () => { it('GETs the Link API endpoint with bearer auth', async () => { setResponseForUrl('/transactions', 200, { diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 400b5c7a..3909ce98 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -5,6 +5,7 @@ import { createAuthCli } from './commands/auth'; import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; import { createIdentityCli } from './commands/identity'; +import { createInsightsCli } from './commands/insights'; import { createMppCli } from './commands/mpp'; import { createOnboardCli } from './commands/onboard'; import { createPaymentMethodsCli } from './commands/payment-methods'; @@ -182,6 +183,13 @@ cli.command( envAccessToken, ), ); +cli.command( + createInsightsCli( + () => factory.createInsightsResource(), + authStorage, + envAccessToken, + ), +); cli.command( createUcpCli(() => factory.createUcpResource(), authStorage, envAccessToken), ); diff --git a/packages/cli/src/commands/insights/__tests__/list.test.tsx b/packages/cli/src/commands/insights/__tests__/list.test.tsx new file mode 100644 index 00000000..33850a14 --- /dev/null +++ b/packages/cli/src/commands/insights/__tests__/list.test.tsx @@ -0,0 +1,66 @@ +import type { IInsightsResource, InsightsPage } from '@stripe/link-sdk'; +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; +import { InsightsList } from '../list'; + +function resource(page: InsightsPage): IInsightsResource { + return { + list: vi.fn(async () => page), + listAvailableTypes: vi.fn(), + }; +} + +describe('InsightsList', () => { + it('renders known values, future values, and remediation without losing the page', async () => { + const { lastFrame } = render( + {}} + />, + ); + + await vi.waitFor(() => { + const frame = lastFrame() ?? ''; + expect(frame).toContain('Marine Layer (count: 2)'); + expect(frame).toContain('"type":"percentile"'); + expect(frame).toContain('Additional authorization is required'); + expect(frame).toContain('Next page: --starting-after another_insight'); + }); + }); +}); diff --git a/packages/cli/src/commands/insights/available-types.tsx b/packages/cli/src/commands/insights/available-types.tsx new file mode 100644 index 00000000..bee2ff98 --- /dev/null +++ b/packages/cli/src/commands/insights/available-types.tsx @@ -0,0 +1,62 @@ +import type { + AvailableInsightTypesPage, + IInsightsResource, + ListInsightTypesParams, +} 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 AvailableTypesProps { + resource: IInsightsResource; + params: ListInsightTypesParams; + onComplete: (result: AvailableInsightTypesPage | null) => void; +} + +export const AvailableTypes: React.FC = ({ + resource, + params, + onComplete, +}) => { + const action = useCallback( + () => resource.listAvailableTypes(params), + [resource, params], + ); + const { status, data: page, error } = useAsyncAction(action, onComplete); + + if (status === 'loading') + return ( + + Loading available insight types... + + ); + if (status === 'error') + return ( + Failed to load available insight types: {error} + ); + if (!page?.data.length) + return No insight types available; + + return ( + + Available insight types + {page.data.map((insight) => ( + + {insight.description} + ID: {insight.id} + {insight.authorization_remediation ? ( + + Additional authorization is required; see --format json for + details. + + ) : null} + + ))} + {page.has_more ? ( + Next page: --starting-after {page.data.at(-1)?.id} + ) : null} + + ); +}; diff --git a/packages/cli/src/commands/insights/index.tsx b/packages/cli/src/commands/insights/index.tsx new file mode 100644 index 00000000..c2398472 --- /dev/null +++ b/packages/cli/src/commands/insights/index.tsx @@ -0,0 +1,115 @@ +import type { + AvailableInsightTypesPage, + IInsightsResource, + InsightsPage, + ListInsightsParams, + ListInsightTypesParams, +} from '@stripe/link-sdk'; +import { Cli, z } from 'incur'; +import type { CliAuthStorage } from '../../auth/storage'; +import { renderInteractive } from '../../utils/render-interactive'; +import { requireAuth } from '../../utils/require-auth'; +import { AvailableTypes } from './available-types'; +import { InsightsList } from './list'; + +const paginationOptions = z.object({ + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Maximum number of results to return (1-100).'), + startingAfter: z + .string() + .optional() + .describe('Return results after this insight ID.'), +}); + +const listOptions = paginationOptions.extend({ + insight: z + .array(z.string()) + .default([]) + .describe('Filter by insight ID. Repeat to include multiple insights.'), +}); + +export function createInsightsCli( + createResource: () => IInsightsResource, + authStorage?: CliAuthStorage, + envAccessToken?: string, +) { + const cli = Cli.create('insights', { + description: 'Discover and retrieve financial insights', + }); + + cli.command('list', { + description: 'List financial insights, optionally filtered by insight ID', + options: listOptions, + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage, envAccessToken)], + async run(c) { + const params: ListInsightsParams = {}; + if (c.options.limit !== undefined) params.limit = c.options.limit; + if (c.options.startingAfter !== undefined) + params.starting_after = c.options.startingAfter; + if (c.options.insight.length > 0) params.insights = c.options.insight; + const resource = createResource(); + if (!c.agent && !c.formatExplicit) { + let capturedResult: InsightsPage | null | undefined; + return renderInteractive( + { + capturedResult = result; + }} + />, + () => { + if (capturedResult === undefined) + throw new Error('Component exited without producing a result'); + if (capturedResult === null) + throw new Error('Failed to load insights'); + return capturedResult; + }, + ); + } + return resource.list(params); + }, + }); + + cli.command('list-available-types', { + description: 'List insight IDs, descriptions, and access requirements', + options: paginationOptions, + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage, envAccessToken)], + async run(c) { + const params: ListInsightTypesParams = {}; + if (c.options.limit !== undefined) params.limit = c.options.limit; + if (c.options.startingAfter !== undefined) + params.starting_after = c.options.startingAfter; + const resource = createResource(); + if (!c.agent && !c.formatExplicit) { + let capturedResult: AvailableInsightTypesPage | null | undefined; + return renderInteractive( + { + capturedResult = result; + }} + />, + () => { + if (capturedResult === undefined) + throw new Error('Component exited without producing a result'); + if (capturedResult === null) + throw new Error('Failed to load available insight types'); + return capturedResult; + }, + ); + } + return resource.listAvailableTypes(params); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/insights/list.tsx b/packages/cli/src/commands/insights/list.tsx new file mode 100644 index 00000000..337f52d4 --- /dev/null +++ b/packages/cli/src/commands/insights/list.tsx @@ -0,0 +1,131 @@ +import type { + IInsightsResource, + Insight, + InsightsPage, + ListInsightsParams, +} 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 InsightsListProps { + resource: IInsightsResource; + params: ListInsightsParams; + onComplete: (result: InsightsPage | null) => void; +} + +function formatAsOf(timestamp: number | null | undefined): string | null { + if (timestamp == null) return null; + const date = new Date(timestamp * 1000); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }).format(date); +} + +export function formatInsightValue(value: unknown): string { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const tagged = value as Record; + if (tagged.type === 'number_of_items') { + const items = tagged.number_of_items; + if ( + items !== null && + typeof items === 'object' && + !Array.isArray(items) + ) { + const fields = items as Record; + if ( + typeof fields.label === 'string' && + typeof fields.count === 'number' + ) + return `${fields.label} (count: ${fields.count.toLocaleString()})`; + } + } + } + // Future value types should remain visible even before this client learns to format them. + return JSON.stringify(value) ?? String(value); +} + +function InsightCard({ insight }: { insight: Insight }) { + const asOf = formatAsOf(insight.as_of); + const title = asOf + ? `${insight.description} (as of ${asOf})` + : insight.description; + const entries = insight.data ?? []; + return ( + + + {title} + + ID: {insight.id} + {insight.status === 'pending' ? ( + Pending; check again later. + ) : insight.status === 'no_data' ? ( + <> + + {insight.error_message ?? 'No data is available.'} + + {insight.authorization_remediation ? ( + + Additional authorization is required; see --format json for + details. + + ) : null} + + ) : insight.status === 'ready' ? ( + entries.length ? ( + entries.map((entry, index) => ( + + {index + 1}. {entry.label}: {formatInsightValue(entry.value)} + + )) + ) : ( + No entries returned. + ) + ) : ( + + Status: {insight.status}. See --format json for the full response. + + )} + + ); +} + +export const InsightsList: React.FC = ({ + resource, + params, + onComplete, +}) => { + const action = useCallback(() => resource.list(params), [resource, params]); + const { status, data: page, error } = useAsyncAction(action, onComplete); + + if (status === 'loading') + return ( + + Loading insights... + + ); + if (status === 'error') + return Failed to load insights: {error}; + if (!page?.data.length) return No insights found; + + return ( + + Insights + {page.data.map((insight) => ( + + ))} + {page.has_more ? ( + Next page: --starting-after {page.data.at(-1)?.id} + ) : null} + + ); +}; diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index ff9dcabe..29483cdd 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -4,6 +4,7 @@ import { type IAttestationsResource, type IBalancesResource, type IIdentityCredentialsResource, + type IInsightsResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, @@ -120,6 +121,7 @@ export class ResourceFactory { private userInfoResource?: IUserInfoResource; private approvalPolicyResource?: IApprovalPolicyResource; private transactionsResource?: ITransactionsResource; + private insightsResource?: IInsightsResource; private sourcesResource?: ISourcesResource; private balancesResource?: IBalancesResource; private webBotAuthResource?: IWebBotAuthResource; @@ -310,6 +312,16 @@ export class ResourceFactory { return resource; } + createInsightsResource(): IInsightsResource { + if (this.insightsResource) { + return this.insightsResource; + } + + const resource = sanitizeResource(this.createSdkClient().insights); + this.insightsResource = resource; + return resource; + } + createSourcesResource(): ISourcesResource { if (this.sourcesResource) { return this.sourcesResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8c17f68c..87b4654b 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -3,11 +3,13 @@ import { ApprovalPolicyResource } from '@/resources/approval-policy'; import { AttestationsResource } from '@/resources/attestations'; import { BalancesResource } from '@/resources/balances'; import { IdentityCredentialsResource } from '@/resources/identity-credentials'; +import { InsightsResource } from '@/resources/insights'; import type { IApprovalPolicyResource, IAttestationsResource, IBalancesResource, IIdentityCredentialsResource, + IInsightsResource, IPaymentMethodsResource, IReportResource, IShippingAddressResource, @@ -37,6 +39,7 @@ export class Link { readonly userInfo: IUserInfoResource; readonly approvalPolicy: IApprovalPolicyResource; readonly transactions: ITransactionsResource; + readonly insights: IInsightsResource; readonly sources: ISourcesResource; readonly balances: IBalancesResource; readonly webBotAuth: IWebBotAuthResource; @@ -52,6 +55,7 @@ export class Link { this.userInfo = new UserInfoResource(options); this.approvalPolicy = new ApprovalPolicyResource(options); this.transactions = new TransactionsResource(options); + this.insights = new InsightsResource(options); this.sources = new SourcesResource(options); this.balances = new BalancesResource(options); this.webBotAuth = new WebBotAuthResource(options); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 610361d9..4e4aa554 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -14,6 +14,7 @@ export { holderJwkThumbprint, parseHolderPublicJwk, } from './resources/holder-jwk'; +export { InsightsResource } from './resources/insights'; export * from './resources/interfaces'; export { getDuplicateSpendRequest } from './resources/spend-request'; export * from './types/index'; diff --git a/packages/sdk/src/resources/__tests__/insights.test.ts b/packages/sdk/src/resources/__tests__/insights.test.ts new file mode 100644 index 00000000..913a3b26 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/insights.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InsightsResource } from '@/resources/insights'; + +const mockFetch = vi.fn(); + +function respond(status: number, body: unknown) { + mockFetch.mockResolvedValue({ + status, + statusText: '', + text: async () => JSON.stringify(body), + }); +} + +describe('InsightsResource', () => { + let resource: InsightsResource; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + vi.stubEnv('LINK_API_BASE_URL', undefined); + resource = new InsightsResource({ getAccessToken: () => 'test_token' }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('lists available insight types with pagination and remediation', async () => { + const page = { + data: [ + { + id: 'top_brand_by_transaction_count_per_category_t180d', + description: 'Top brands', + authorization_remediation: { + authorization_details: [ + { + type: 'source', + actions: [ + 'read_link_transactions', + 'read_external_transactions', + ], + }, + ], + }, + }, + ], + has_more: true, + }; + respond(200, page); + + await expect( + resource.listAvailableTypes({ limit: 1, starting_after: 'previous' }), + ).resolves.toEqual(page); + const [rawUrl, init] = mockFetch.mock.calls[0]!; + const url = new URL(rawUrl); + expect(url.origin + url.pathname).toBe( + 'https://api.link.com/insights/available_types', + ); + expect(url.searchParams.get('limit')).toBe('1'); + expect(url.searchParams.get('starting_after')).toBe('previous'); + expect(init.method).toBe('GET'); + expect(init.headers.Authorization).toBe('Bearer test_token'); + }); + + it('rejects an invalid page and preserves API errors', async () => { + respond(200, { data: [{ id: 'abc' }], has_more: false }); + await expect(resource.listAvailableTypes()).rejects.toMatchObject({ + code: 'invalid_response', + }); + + respond(400, { error: { message: 'Invalid limit' } }); + await expect(resource.listAvailableTypes()).rejects.toThrow( + 'Invalid limit', + ); + }); + + it('lists filtered insights and preserves future value formats', async () => { + const page = { + data: [ + { + id: 'top_brand_by_transaction_count_per_category_t180d', + description: 'Top brands', + status: 'ready', + as_of: 1790723779, + data: [ + { + label: 'Clothing', + value: { + type: 'number_of_items', + number_of_items: { label: 'Marine Layer', count: 2 }, + }, + }, + { + label: 'Future metric', + value: { type: 'percentile', percentile: { value: 92 } }, + }, + ], + }, + ], + has_more: false, + }; + respond(200, page); + + await expect( + resource.list({ + limit: 5, + starting_after: 'earlier', + insights: [ + 'top_brand_by_transaction_count_per_category_t180d', + 'another_insight', + ], + }), + ).resolves.toEqual(page); + const url = new URL(mockFetch.mock.calls[0]![0]); + expect(url.origin + url.pathname).toBe('https://api.link.com/insights'); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.get('starting_after')).toBe('earlier'); + expect(url.searchParams.getAll('insights[]')).toEqual([ + 'top_brand_by_transaction_count_per_category_t180d', + 'another_insight', + ]); + }); + + it('preserves per-insight missing-permission errors', async () => { + const page = { + data: [ + { + id: 'top_brand_by_transaction_count_per_category_t180d', + description: 'Top brands', + status: 'no_data', + as_of: 1790723779, + error_code: 'missing_permissions', + error_message: 'Additional authorization is required', + authorization_remediation: { + authorization_details: [ + { type: 'source', actions: ['read_link_transactions'] }, + ], + }, + }, + ], + has_more: false, + }; + respond(200, page); + await expect(resource.list()).resolves.toEqual(page); + }); +}); diff --git a/packages/sdk/src/resources/insights.ts b/packages/sdk/src/resources/insights.ts new file mode 100644 index 00000000..90755093 --- /dev/null +++ b/packages/sdk/src/resources/insights.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; +import type { LinkOptions } from '@/config'; +import { BaseResource } from '@/resources/base'; +import type { + IInsightsResource, + ListInsightsParams, + ListInsightTypesParams, +} from '@/resources/interfaces'; +import type { AvailableInsightTypesPage, InsightsPage } from '@/types/index'; + +const authorizationRemediationSchema = z.looseObject({ + scope: z.array(z.string()).optional(), + authorization_details: z + .array(z.looseObject({ type: z.string(), actions: z.array(z.string()) })) + .optional(), +}); + +const availableInsightTypesPageSchema = z.looseObject({ + data: z.array( + z.looseObject({ + id: z.string(), + description: z.string(), + authorization_remediation: authorizationRemediationSchema + .nullable() + .optional(), + }), + ), + has_more: z.boolean(), +}); + +const insightsPageSchema = z.looseObject({ + data: z.array( + z.looseObject({ + id: z.string(), + description: z.string(), + status: z.string(), + as_of: z.number().nullable().optional(), + data: z + .array(z.looseObject({ label: z.string(), value: z.unknown() })) + .nullable() + .optional(), + error_code: z.string().nullable().optional(), + error_message: z.string().nullable().optional(), + authorization_remediation: authorizationRemediationSchema + .nullable() + .optional(), + }), + ), + has_more: z.boolean(), +}); + +export class InsightsResource + extends BaseResource + implements IInsightsResource +{ + constructor(options: LinkOptions) { + super(options, '/insights'); + } + + async listAvailableTypes( + params: ListInsightTypesParams = {}, + ): Promise { + const url = new URL(`${this.endpoint}/available_types`); + if (params.limit !== undefined) + url.searchParams.set('limit', String(params.limit)); + if (params.starting_after !== undefined) + url.searchParams.set('starting_after', params.starting_after); + + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: url.toString(), + }); + if (status < 200 || status >= 300) + this.throwApiError('list available insight types', status, data, rawBody); + return this.parseResponse( + 'list available insight types', + status, + () => + availableInsightTypesPageSchema.parse( + data, + ) as AvailableInsightTypesPage, + ); + } + + async list(params: ListInsightsParams = {}): Promise { + 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.insights !== undefined) + for (const insight of params.insights) + url.searchParams.append('insights[]', insight); + + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: url.toString(), + }); + if (status < 200 || status >= 300) + this.throwApiError('list insights', status, data, rawBody); + return this.parseResponse( + 'list insights', + status, + () => insightsPageSchema.parse(data) as InsightsPage, + ); + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 748e738a..599ef883 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -1,8 +1,10 @@ import type { ApprovalDetail, ApprovalPolicy, + AvailableInsightTypesPage, BalancesPage, CredentialType, + InsightsPage, LineItem, PaymentMethod, RequestApprovalResponse, @@ -144,6 +146,22 @@ export interface ITransactionsResource { list(params?: ListTransactionsParams): Promise; } +export interface ListInsightTypesParams { + limit?: number; + starting_after?: string; +} + +export interface ListInsightsParams extends ListInsightTypesParams { + insights?: string[]; +} + +export interface IInsightsResource { + listAvailableTypes( + params?: ListInsightTypesParams, + ): Promise; + list(params?: ListInsightsParams): Promise; +} + export interface ListSourcesParams { limit?: number; starting_after?: string; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 0992d749..818be115 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -316,6 +316,49 @@ export interface TransactionsPage { [key: string]: unknown; } +export interface InsightAuthorizationRemediation { + scope?: string[]; + authorization_details?: Array<{ type: string; actions: string[] }>; +} + +export interface AvailableInsightType { + id: string; + description: string; + authorization_remediation?: InsightAuthorizationRemediation | null; + [key: string]: unknown; +} + +export interface AvailableInsightTypesPage { + data: AvailableInsightType[]; + has_more: boolean; + [key: string]: unknown; +} + +export interface InsightEntry { + label: string; + /** Value payloads are tagged by `type`; preserve future payloads unchanged. */ + value: unknown; + [key: string]: unknown; +} + +export interface Insight { + id: string; + description: string; + status: string; + as_of?: number | null; + data?: InsightEntry[] | null; + error_code?: string | null; + error_message?: string | null; + authorization_remediation?: InsightAuthorizationRemediation | null; + [key: string]: unknown; +} + +export interface InsightsPage { + data: Insight[]; + has_more: boolean; + [key: string]: unknown; +} + export interface Source { id?: string | null; name?: string | null; diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 766d616c..b0f66f06 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -78,12 +78,21 @@ _Recommended_: Run `link-cli --llms` to understand all the available commands. T Copy this checklist and track progress: +- Step 0: Decide whether merchant selection needs purchase-pattern insights - Step 1: Authenticate with Link for the whole task - Step 2: Evaluate merchant site (determine credential type) - Step 3: Get payment methods - Step 4: Create spend request with correct credential type - Step 5: Complete payment +### Step 0: Decide whether merchant selection needs purchase-pattern insights + +Respect a merchant the user explicitly names. If the merchant is unspecified and the task involves personal shopping, repeat purchasing, or the user's usual or preferred store, use the `financial-insights` skill to inform merchant selection. Once authenticated, call `link-cli insights list-available-types --format json`, choose a relevant ID from its descriptions, then call `link-cli insights list --insight --format json`. Follow that skill's guidance for access remediation and `ready`, `pending`, or `no_data` responses. + +For example, “order flour from my usual store” may benefit from a relevant purchase-pattern insight; “order flour from Smith's Store” already specifies the merchant. An observed top brand is a clue, not proof that it is the right merchant for this purchase. Do not retrieve raw transactions unless the available insights cannot answer the question and transaction-level detail is needed. + +If Financial Insights is unavailable in the user's country, no accounts are shared, or the user declines access, continue merchant research without insight commands. If this is known before authentication, do not request financial source actions. Do not imply that a researched merchant is the user's usual choice. If identifying that usual merchant is essential to the request, ask the user to choose one before purchasing. + ### Step 1: Authenticate with Link for the whole task Check auth status: @@ -94,7 +103,7 @@ link-cli auth status When authenticated, the response also reports the session's granted `scope` and `authorization_details` (when the token endpoint returned them). If the response includes an `update` field, a newer version of `link-cli` is available — run the `update_command` from that field to upgrade before proceeding. -If not authenticated: +If not authenticated, include any source actions needed for the whole task in this login; the `financial-insights` skill describes the current insight requirements: ```bash link-cli auth login --client-name "" @@ -114,6 +123,8 @@ Always check the current authentication status before starting a new login flow If the user is already authenticated but you need broader access (an additional `scope`, `--source-actions`, or `--authorization-detail`), use `auth upgrade` instead of `auth login`. It takes the same flags but, rather than stopping with an "already logged in" message, merges what you request with the current `scope`/`authorization_details` and starts a new approval for the superset — so existing access is never dropped. Check `auth status` first so you know what's already granted. The current session stays valid during the approval and is only replaced once the user approves the new one, so an abandoned upgrade leaves the existing session working. +When `LINK_ACCESS_TOKEN` is set, CLI commands keep using that environment token after `auth upgrade`. To gain insight access, ask for an updated token or direction to switch to CLI-managed authentication; do not start an upgrade that will leave requests on the old token. + Optionally, before a purchase, run `link-cli user-info retrieve` to inspect balance eligibility, address, applicable spend limits, and verification requirements. The optional `eligible_for_balance` field says whether the user's balance is available for Agent Wallet usage. Finite limit values are cents, while `null` limit or remaining values mean unlimited. When `agent_wallet_verification_requirement.action_url` is present, direct the user there to complete the required action. ### Step 2: Evaluate the merchant site BEFORE creating a spend request diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index d47b4bb8..ab580cea 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -2,7 +2,7 @@ version: 0.15.1 name: financial-insights description: | - Reads a user's Link financial data — transactions, balances, and wallet sources — so agents can answer questions about spending and available source capabilities. Use when the user says "check my balance", "how much did I spend", "show my transactions", "what accounts are connected", "summarize my spending", "recent purchases", or asks about their financial activity, account balances, or linked sources. + Reads Link financial insights, transactions, balances, and wallet sources to answer questions about spending and shopping preferences. Use for balance checks, transaction history, connected accounts, favorite brands, or an unspecified merchant in a personal shopping request. allowed-tools: - Bash(link-cli:*) - Bash(npx --yes @stripe/link-cli:*) @@ -33,13 +33,13 @@ Use this skill to answer questions about a user’s Link-connected financial dat - Spending patterns - Account balances - Linked wallet sources -- Basic summaries derived from the user’s financial data +- Available precomputed insights about purchase patterns and preferences All commands are read-only. They do not move money, initiate payments, modify accounts, or expose payment credentials. ## Safety and privacy -Do not retrieve financial data until the user is authenticated with the required source actions. +Authenticate before any insight or financial-data command. `insights list-available-types` requires a session but no financial source actions; the response identifies access needed for individual insights. Only retrieve the data needed to answer the user’s request. Do not run every list command by default. @@ -49,7 +49,7 @@ If the user asks for an action that would move money, reference `skills/create-p ## Authentication -Before retrieving financial data, check whether the user is authenticated and whether the current session has the required source actions. +Before retrieving financial data, check whether the user is authenticated and whether the current session has the required source actions for the selected command. ```bash link-cli auth status --format json @@ -57,7 +57,7 @@ link-cli auth status --format json When present, inspect `authorization_details` in the response for entries with `type: "source"` and the required actions. The field may be absent when the token endpoint did not return authorization details or when authentication comes from `LINK_ACCESS_TOKEN`; in that case, run only the minimum data command needed and handle a permission error as described below. -If the user is not authenticated, start a login that requests only the source actions needed for the requested data. If the user is already authenticated but one or more required source actions are missing, use `auth upgrade` instead of `auth login`. `auth upgrade` preserves the current session while the user approves the additional access and replaces it only after approval succeeds. +If the user is not authenticated, start a login that requests only the source actions needed for the requested data. For a CLI-managed session, use `auth upgrade` with an insight's `authorization_remediation` when more access is needed; the session remains valid until the user approves the upgrade. When `LINK_ACCESS_TOKEN` is set, insight requests keep using that environment token even after a CLI upgrade. Do not start an ineffective upgrade; explain the missing access and ask for an updated environment token or direction to switch to CLI-managed authentication. Use the minimum required source actions: @@ -68,6 +68,8 @@ Use the minimum required source actions: If the user asks a question that requires multiple data types, request all relevant actions together. +If this skill supports a purchase and the merchant is unspecified, identify the needed Link capabilities before login. Request transaction access with the payment scopes in one login when a purchase-pattern insight is likely to help. The current top-brand insight can use a source granting either `read_link_transactions` or `read_external_transactions`; requesting both covers both kinds of source. If already authenticated, consult `insights list-available-types` and request only the missing access it reports. + Example for a new login that needs all financial data types: ```bash @@ -103,12 +105,16 @@ Use the smallest command set that answers the user’s question. | User asks about | Command | |---|---| +| Available purchase-pattern insights and their access requirements | `link-cli insights list-available-types` | +| A specific available insight or observed shopping preference | `link-cli insights list` | | Recent purchases, merchants, spend, transaction history, income, deposits, subscriptions | `link-cli transactions list` | | Current available balance, account balance, cash position | `link-cli balances list` | | Connected accounts, cards, banks, wallet sources, source metadata | `link-cli sources list` | Examples: +- “Which brands do I tend to shop at?” → Discover available insight types, then retrieve the relevant insight. +- “Buy flour from my usual store.” → Use a relevant available insight to inform merchant selection; treat the result as a clue, not a guaranteed preference. - “How much did I spend on restaurants last month?” → Use transactions only. - “What is my current checking account balance?” → Use balances only. - “Which accounts are connected?” → Use sources only. @@ -119,6 +125,8 @@ Examples: Use JSON for agent-readable structured output. ```bash +link-cli insights list-available-types --format json +link-cli insights list --insight --format json link-cli transactions list --format json link-cli balances list --format json link-cli sources list --format json @@ -130,6 +138,32 @@ All monetary amounts across all endpoints are integers in the currency's smalles Keep sign interpretation field-specific. Only `transactions.amount` uses negative for money leaving the account and positive for money entering it. Do not apply transaction sign semantics to balance fields; interpret `current`, `cash.available`, and `credit.used` according to the balance type. +## Insights + +Use precomputed insights when their descriptions match the user's question. First discover IDs and access requirements without computing every insight: + +```bash +link-cli insights list-available-types --format json +``` + +The response contains `data` entries with `id`, `description`, and optionally `authorization_remediation`, plus `has_more`. Continue with `--starting-after ` while `has_more` is true. An insight may be listed even when the current session lacks the access needed to retrieve its data. If the insight is needed, use the reported remediation with `auth upgrade` for a CLI-managed session; follow the environment-token rule above for `LINK_ACCESS_TOKEN`. Pass missing `scope` values as one space-separated `--scope` argument; for a source `authorization_details` entry, pass each listed action as a separate `--source-actions` flag. Do not guess insight IDs or assume that every listed insight has data for this user. + +Retrieve only relevant IDs (repeat `--insight` for more than one), or omit the filter when the user needs all available insights: + +```bash +link-cli insights list --insight --format json +``` + +Both commands support `--limit` (1–100) and `--starting-after` for pagination. Each page has `data` and `has_more`; use the last item's `id` as the next cursor, keeping the filter unchanged. Stop if `has_more` is true but no usable cursor is returned. + +Each retrieved insight has a `status`: + +- `ready`: use its `data` entries and mention `as_of` (Unix seconds) when giving an answer. A `number_of_items` value contains `number_of_items.label` and `number_of_items.count`. +- `pending`: the computation is not ready; suggest checking later. +- `no_data`: inspect `error_code`, `error_message`, and `authorization_remediation`. For `missing_permissions`, follow the authentication guidance above; otherwise report that data is unavailable. In a purchase flow, continue without the insight when access is unavailable or the user declines to share data. + +Other value types or statuses may appear in future responses. Preserve their JSON rather than inventing a meaning. Treat purchase-pattern results as observed history, not a definitive statement of the user's preference. Use raw transactions only if the available insights cannot answer the question and transaction-level detail is needed. + ## Sources (concept) A **source** is a financial account connected to the user's Link wallet — a bank account, credit card, savings account, etc. Each source has a unique `id` (e.g. `csmrpd_abc123`) that other endpoints may expose as `source_id`: