Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/restore-financial-insights.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 <insight_id> --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
Expand Down
5 changes: 3 additions & 2 deletions docs/link-cli-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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` |

Expand Down Expand Up @@ -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.
79 changes: 79 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -182,6 +183,13 @@ cli.command(
envAccessToken,
),
);
cli.command(
createInsightsCli(
() => factory.createInsightsResource(),
authStorage,
envAccessToken,
),
);
cli.command(
createUcpCli(() => factory.createUcpResource(), authStorage, envAccessToken),
);
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/commands/insights/__tests__/list.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InsightsList
resource={resource({
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: 'New metric',
value: { type: 'percentile', percentile: { value: 92 } },
},
],
},
{
id: 'another_insight',
description: 'Other insight',
status: 'no_data',
error_code: 'missing_permissions',
error_message: 'Additional authorization is required',
authorization_remediation: {
authorization_details: [
{ type: 'source', actions: ['read_link_transactions'] },
],
},
},
],
has_more: true,
})}
params={{}}
onComplete={() => {}}
/>,
);

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');
});
});
});
62 changes: 62 additions & 0 deletions packages/cli/src/commands/insights/available-types.tsx
Original file line number Diff line number Diff line change
@@ -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<AvailableTypesProps> = ({
resource,
params,
onComplete,
}) => {
const action = useCallback(
() => resource.listAvailableTypes(params),
[resource, params],
);
const { status, data: page, error } = useAsyncAction(action, onComplete);

if (status === 'loading')
return (
<Text color="cyan">
<Spinner type="dots" /> Loading available insight types...
</Text>
);
if (status === 'error')
return (
<Text color="red">Failed to load available insight types: {error}</Text>
);
if (!page?.data.length)
return <Text dimColor>No insight types available</Text>;

return (
<Box flexDirection="column">
<Text bold>Available insight types</Text>
{page.data.map((insight) => (
<Box key={insight.id} flexDirection="column" marginTop={1}>
<Text>{insight.description}</Text>
<Text dimColor>ID: {insight.id}</Text>
{insight.authorization_remediation ? (
<Text color="yellow">
Additional authorization is required; see --format json for
details.
</Text>
) : null}
</Box>
))}
{page.has_more ? (
<Text dimColor>Next page: --starting-after {page.data.at(-1)?.id}</Text>
) : null}
</Box>
);
};
Loading
Loading