From 2a33097d372577df55ae16358616a67a1ceac96c Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Wed, 26 Aug 2026 15:52:33 -0400 Subject: [PATCH 1/5] feat: allow requesting write transaction source actions Committed-By-Agent: claude --- .../src/auth/__tests__/auth-resource.test.ts | 40 +++++++++++++++++++ packages/cli/src/auth/types.ts | 2 + 2 files changed, 42 insertions(+) diff --git a/packages/cli/src/auth/__tests__/auth-resource.test.ts b/packages/cli/src/auth/__tests__/auth-resource.test.ts index 4628e2c4..fec0b020 100644 --- a/packages/cli/src/auth/__tests__/auth-resource.test.ts +++ b/packages/cli/src/auth/__tests__/auth-resource.test.ts @@ -1,6 +1,7 @@ import { hostname } from 'node:os'; import { LinkApiError, LinkTransportError } from '@stripe/link-sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginOptions } from '../../commands/auth/schema'; import { LinkAuthResource } from '../auth-resource'; import { LinkAuthorizationDeclinedError } from '../errors'; @@ -167,6 +168,45 @@ describe('LinkAuthResource', () => { expect(params.getAll('authorization_details[]')).toEqual(['true']); }); + it('accepts and serializes write transaction source actions', async () => { + mockFetchResponse(200, { + device_code: 'dev_123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://link.com/verify', + verification_uri_complete: 'https://link.com/verify?code=ABCD-EFGH', + expires_in: 900, + interval: 5, + }); + + // The write actions must pass `--source-actions` validation... + const parsed = loginOptions.parse({ + sourceActions: [ + 'write_link_transactions', + 'write_external_transactions', + ], + }); + expect(parsed.sourceActions).toEqual([ + 'write_link_transactions', + 'write_external_transactions', + ]); + + // ...and round-trip into the emitted source detail's actions array. + const resource = createResource(); + await resource.initiateDeviceAuth({ + sourceActions: parsed.sourceActions, + }); + + const body = mockFetch.mock.calls[0][1].body as string; + const params = new URLSearchParams(body); + expect(params.getAll('authorization_details[][type]')).toEqual([ + 'source', + ]); + expect(params.getAll('authorization_details[][actions][]')).toEqual([ + 'write_link_transactions', + 'write_external_transactions', + ]); + }); + it('includes client name and hostname in connection_label', async () => { mockFetchResponse(200, { device_code: 'dev_123', diff --git a/packages/cli/src/auth/types.ts b/packages/cli/src/auth/types.ts index 86946543..19b44f9c 100644 --- a/packages/cli/src/auth/types.ts +++ b/packages/cli/src/auth/types.ts @@ -5,6 +5,8 @@ export const SOURCE_ACTIONS = [ 'read_external_transactions', 'read_link_transactions', 'read_source_details', + 'write_external_transactions', + 'write_link_transactions', ] as const; export type SourceAction = (typeof SOURCE_ACTIONS)[number]; From 5c226eed5285e4e7bca4568ad76cf12786a09d24 Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Wed, 26 Aug 2026 15:52:38 -0400 Subject: [PATCH 2/5] feat: add transactions update command Committed-By-Agent: claude --- packages/cli/src/__tests__/cli.test.ts | 142 ++++++++++++++++++ .../__tests__/transactions.test.tsx | 102 +++++++++++++ .../cli/src/commands/transactions/index.tsx | 77 +++++++++- .../cli/src/commands/transactions/list.tsx | 2 +- .../cli/src/commands/transactions/schema.ts | 15 ++ .../cli/src/commands/transactions/update.tsx | 85 +++++++++++ .../resources/__tests__/transactions.test.ts | 85 +++++++++++ packages/sdk/src/resources/interfaces.ts | 7 + packages/sdk/src/resources/transactions.ts | 35 ++++- packages/sdk/src/types/index.ts | 2 +- skills/financial-insights/SKILL.md | 56 ++++++- 11 files changed, 593 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/commands/transactions/update.tsx diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e042b836..e566b6e8 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1434,6 +1434,148 @@ describe('production mode', () => { }); }); + describe('transactions update', () => { + it('POSTs to /transactions/:id and returns the bare transaction', async () => { + setResponseForUrl('/transactions/lbctxn_001', 200, { + ...SAMPLE_TRANSACTION, + category: 'groceries', + description: 'Trader Joes', + }); + + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--category', + 'groceries', + '--description', + 'Trader Joes', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('POST'); + expect(lastRequest.url).toBe('/transactions/lbctxn_001'); + expect(lastRequest.headers.authorization).toBe( + 'Bearer prod_test_access_token', + ); + expect(JSON.parse(lastRequest.body)).toEqual({ + category: 'groceries', + description: 'Trader Joes', + }); + + const output = parseJson(result.stdout) as Record; + expect(output.id).toBe('lbctxn_001'); + expect(output.category).toBe('groceries'); + expect(output.description).toBe('Trader Joes'); + expect(output.data).toBeUndefined(); + }); + + it('sends only category when only --category is provided', async () => { + setResponseForUrl('/transactions/lbctxn_001', 200, { + ...SAMPLE_TRANSACTION, + category: 'groceries', + }); + + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--category', + 'groceries', + '--json', + ); + + expect(result.exitCode).toBe(0); + const sentBody = JSON.parse(lastRequest.body); + expect(sentBody).toEqual({ category: 'groceries' }); + expect('description' in sentBody).toBe(false); + }); + + it('fails locally without making a request when both flags are missing', async () => { + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--json', + ); + + expect(result.exitCode).not.toBe(0); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('MISSING_UPDATE_FIELDS'); + const txnRequest = requests.find( + (r) => r.url === '/transactions/lbctxn_001', + ); + expect(txnRequest).toBeUndefined(); + }); + + it('surfaces the server error code and message for invalid_category', async () => { + setResponseForUrl('/transactions/lbctxn_001', 400, { + error: { + code: 'invalid_category', + message: 'Invalid category: shopping', + }, + }); + + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--category', + 'shopping', + '--json', + ); + + expect(result.exitCode).not.toBe(0); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('invalid_category'); + expect(String(output.message)).toContain('Invalid category: shopping'); + }); + + it('falls back to API_ERROR when the error envelope omits code', async () => { + setResponseForUrl('/transactions/lbctxn_001', 404, { + error: { message: 'Transaction not found' }, + }); + + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--description', + 'Trader Joes', + '--json', + ); + + expect(result.exitCode).not.toBe(0); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('API_ERROR'); + expect(String(output.message)).toContain('Transaction not found'); + }); + + it('rejects unauthenticated requests before hitting the API', async () => { + storage.clearTokens(); + + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--category', + 'groceries', + '--description', + 'Trader Joes', + '--json', + ); + + expect(result.exitCode).not.toBe(0); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('NOT_AUTHENTICATED'); + const txnRequest = requests.find( + (r) => r.url === '/transactions/lbctxn_001', + ); + expect(txnRequest).toBeUndefined(); + }); + }); + const SAMPLE_SOURCE = { id: 'csmrpd_001', name: 'Checking 1234', diff --git a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx index ba4fcebc..0452dfbe 100644 --- a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx +++ b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx @@ -7,6 +7,7 @@ import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; import { sanitizeResource } from '../../../utils/resource-factory'; import { TransactionsList } from '../list'; +import { TransactionUpdate } from '../update'; const ESCAPE_PAYLOAD = '\x1b[2JEvil\rText'; const CLEAN_TEXT = 'EvilText'; @@ -17,6 +18,12 @@ function makeResource(page: TransactionsPage): ITransactionsResource { } as unknown as ITransactionsResource); } +function makeUpdateResource(result: Transaction): ITransactionsResource { + return sanitizeResource({ + update: vi.fn(async () => result), + } as unknown as ITransactionsResource); +} + function transaction(overrides: Partial = {}): Transaction { return { id: 'lbctxn_1', @@ -144,3 +151,98 @@ describe('transactions list component', () => { }); }); }); + +describe('transactions update component', () => { + it('renders the updated transaction', async () => { + const resource = makeUpdateResource( + transaction({ category: 'groceries', description: 'Trader Joes' }), + ); + + const { lastFrame } = render( + {}} + />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Transaction updated'); + expect(frame).toContain('lbctxn_1'); + expect(frame).toContain('Trader Joes'); + expect(frame).toContain('groceries'); + expect(frame).toContain('-$9.79'); + expect(frame).toContain('2026-06-08'); + expect(frame).toContain('succeeded'); + }); + }); + + it('renders null category and null status without crashing', async () => { + const resource = makeUpdateResource( + transaction({ category: null, status: null }), + ); + + const { lastFrame } = render( + {}} + />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('N/A'); + expect(frame).not.toContain('null'); + expect(frame).toContain('Chase'); + }); + }); + + it('renders an error state when the resource rejects', async () => { + const resource = sanitizeResource({ + update: vi.fn(async () => { + throw new Error('Invalid category: shopping'); + }), + } as unknown as ITransactionsResource); + + const { lastFrame } = render( + {}} + />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Failed to update transaction'); + expect(frame).toContain('Invalid category: shopping'); + }); + }); + + it('sanitizes escape sequences in the updated transaction', async () => { + const resource = makeUpdateResource( + transaction({ description: 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'); + }); + }); +}); diff --git a/packages/cli/src/commands/transactions/index.tsx b/packages/cli/src/commands/transactions/index.tsx index 7bf4bf19..85432a72 100644 --- a/packages/cli/src/commands/transactions/index.tsx +++ b/packages/cli/src/commands/transactions/index.tsx @@ -1,14 +1,18 @@ import type { ITransactionsResource, ListTransactionsParams, + Transaction, + UpdateTransactionParams, } from '@stripe/link-sdk'; -import { Cli } from 'incur'; +import { LinkApiError } from '@stripe/link-sdk'; +import { Cli, z } from 'incur'; import React from 'react'; import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { TransactionsList } from './list'; -import { listOptions } from './schema'; +import { listOptions, updateOptions } from './schema'; +import { TransactionUpdate } from './update'; export function createTransactionsCli( createResource: () => ITransactionsResource, @@ -16,7 +20,8 @@ export function createTransactionsCli( envAccessToken?: string, ) { const cli = Cli.create('transactions', { - description: '[beta] List transactions from Link and external accounts', + description: + '[beta] List and update transactions from Link and external accounts', }); cli.command('list', { @@ -56,5 +61,71 @@ export function createTransactionsCli( }, }); + cli.command('update', { + description: 'Update a transaction category or description', + args: z.object({ + id: z.string().describe('Transaction ID (e.g. lbctxn_...)'), + }), + options: updateOptions, + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage, envAccessToken)], + async run(c) { + const id = c.args.id; + const opts = c.options; + const resource = createResource(); + + const present = (v: string | undefined) => + v !== undefined && v.trim() !== '' ? v : undefined; + + const params: UpdateTransactionParams = {}; + const category = present(opts.category); + const description = present(opts.description); + if (category !== undefined) params.category = category; + if (description !== undefined) params.description = description; + + if (category === undefined && description === undefined) { + return c.error({ + code: 'MISSING_UPDATE_FIELDS', + message: + 'Must provide at least one of --category or --description. Empty values are ignored; these fields cannot be cleared.', + }); + } + + if (!c.agent && !c.formatExplicit) { + let capturedResult: Transaction | null = null; + return renderInteractive( + { + capturedResult = result; + }} + />, + () => { + if (!capturedResult) + throw new Error('Component exited without producing a result'); + return capturedResult; + }, + ); + } + + try { + return await resource.update(id, params); + } catch (err) { + if (err instanceof LinkApiError) { + const apiErr = err.details as { + error?: { code?: string; message?: string }; + }; + return c.error({ + code: apiErr?.error?.code ?? 'API_ERROR', + message: apiErr?.error?.message ?? err.message, + }); + } + throw err; + } + }, + }); + return cli; } diff --git a/packages/cli/src/commands/transactions/list.tsx b/packages/cli/src/commands/transactions/list.tsx index d458c4f3..2af06785 100644 --- a/packages/cli/src/commands/transactions/list.tsx +++ b/packages/cli/src/commands/transactions/list.tsx @@ -83,7 +83,7 @@ export const TransactionsList: React.FC = ({ [ formatCell(txn.created_date, DATE_WIDTH), formatCell(formatAmount(txn.amount, txn.currency), AMOUNT_WIDTH, 'right'), - formatCell(txn.status, STATUS_WIDTH), + formatCell(txn.status ?? '', STATUS_WIDTH), formatCell(txn.category ?? '', CATEGORY_WIDTH), formatCell(txn.description, descriptionWidth), ].join(COLUMN_GAP), diff --git a/packages/cli/src/commands/transactions/schema.ts b/packages/cli/src/commands/transactions/schema.ts index 5d266f3f..be1c0a2c 100644 --- a/packages/cli/src/commands/transactions/schema.ts +++ b/packages/cli/src/commands/transactions/schema.ts @@ -38,3 +38,18 @@ export const listOptions = z.object({ .default([]) .describe('Filter by source ID. Repeat to include multiple sources.'), }); + +export const updateOptions = z.object({ + category: z + .string() + .optional() + .describe( + 'New category for the transaction. Must be a subcategory, not a category group — e.g. groceries, restaurants, rent, flights, coffee, electronics. Group-level values like "shopping" are rejected by the server. At least one of --category or --description is required. Empty strings are treated as absent and cannot clear the field.', + ), + description: z + .string() + .optional() + .describe( + 'New description for the transaction. Replaces the existing description. At least one of --category or --description is required. Empty strings are treated as absent and cannot clear the field. Omitted fields are preserved.', + ), +}); diff --git a/packages/cli/src/commands/transactions/update.tsx b/packages/cli/src/commands/transactions/update.tsx new file mode 100644 index 00000000..5bfe1389 --- /dev/null +++ b/packages/cli/src/commands/transactions/update.tsx @@ -0,0 +1,85 @@ +import type { + ITransactionsResource, + Transaction, + UpdateTransactionParams, +} 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'; +import { formatAmount } from '../../utils/format-amount'; + +interface TransactionUpdateProps { + resource: ITransactionsResource; + id: string; + params: UpdateTransactionParams; + onComplete: (result: Transaction | null) => void; +} + +export const TransactionUpdate: React.FC = ({ + resource, + id, + params, + onComplete, +}) => { + const action = useCallback( + () => resource.update(id, params), + [resource, id, params], + ); + const { + status, + data: transaction, + error, + } = useAsyncAction(action, onComplete); + + if (status === 'loading') { + return ( + + + Updating transaction {id}... + + + ); + } + + if (status === 'error') { + return ( + + ✗ Failed to update transaction + {error} + + ); + } + + return ( + + ✓ Transaction updated + + + ID: {transaction?.id} + + + Description: {transaction?.description} + + + Category: {transaction?.category ?? 'N/A'} + + + Amount:{' '} + + {transaction + ? formatAmount(transaction.amount, transaction.currency) + : 'N/A'} + + + + Date: {transaction?.created_date} + + + Status: {transaction?.status ?? 'N/A'} + + + + ); +}; diff --git a/packages/sdk/src/resources/__tests__/transactions.test.ts b/packages/sdk/src/resources/__tests__/transactions.test.ts index efbfd40e..1ed14b7a 100644 --- a/packages/sdk/src/resources/__tests__/transactions.test.ts +++ b/packages/sdk/src/resources/__tests__/transactions.test.ts @@ -169,4 +169,89 @@ describe('TransactionsResource', () => { status: 200, }); }); + + describe('update', () => { + const bareTransaction = { + id: 'lbctxn_123', + source_id: null, + amount: -979, + currency: 'usd', + created_date: '2026-06-08', + description: 'Trader Joes', + origin: 'external_connection', + category: 'groceries', + status: 'succeeded', + }; + + it('POSTs to the transaction endpoint with the update body', async () => { + mockFetchResponse(200, bareTransaction); + + await repo.update('lbctxn_123', { + category: 'groceries', + description: 'Trader Joes', + }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://api.link.com/transactions/lbctxn_123'); + expect(opts.method).toBe('POST'); + expect(opts.headers['Content-Type']).toBe('application/json'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + expect(JSON.parse(opts.body)).toEqual({ + category: 'groceries', + description: 'Trader Joes', + }); + }); + + it('omits unset fields from the request body', async () => { + mockFetchResponse(200, bareTransaction); + + await repo.update('lbctxn_123', { category: 'groceries' }); + + const [, opts] = mockFetch.mock.calls[0]!; + const body = JSON.parse(opts.body); + expect(body).toEqual({ category: 'groceries' }); + expect('description' in body).toBe(false); + }); + + it('parses a bare (non-enveloped) transaction response', async () => { + mockFetchResponse(200, bareTransaction); + + const result = await repo.update('lbctxn_123', { + description: 'Trader Joes', + }); + + expect(result).toEqual(bareTransaction); + }); + + it('parses null category and null status', async () => { + mockFetchResponse(200, { + ...bareTransaction, + category: null, + status: null, + }); + + const result = await repo.update('lbctxn_123', { + description: 'Trader Joes', + }); + + expect(result.category).toBeNull(); + expect(result.status).toBeNull(); + }); + + it('throws API errors with the response message', async () => { + mockFetchResponse(400, { + error: { + code: 'invalid_category', + message: 'Invalid category: shopping', + }, + }); + + await expect( + repo.update('lbctxn_123', { category: 'shopping' }), + ).rejects.toThrow( + 'Failed to update transaction (400): Invalid category: shopping', + ); + }); + }); }); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index e9382d88..e601e835 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -9,6 +9,7 @@ import type { SourcesPage, SpendRequest, Total, + Transaction, TransactionOrigin, TransactionsPage, UserInfo, @@ -92,8 +93,14 @@ export interface ListTransactionsParams { sources?: string[]; } +export interface UpdateTransactionParams { + category?: string; + description?: string; +} + export interface ITransactionsResource { list(params?: ListTransactionsParams): Promise; + update(id: string, params: UpdateTransactionParams): Promise; } export interface ListSourcesParams { diff --git a/packages/sdk/src/resources/transactions.ts b/packages/sdk/src/resources/transactions.ts index 30c1ef09..6ef297d5 100644 --- a/packages/sdk/src/resources/transactions.ts +++ b/packages/sdk/src/resources/transactions.ts @@ -3,8 +3,9 @@ import { BaseResource } from '@/resources/base'; import type { ITransactionsResource, ListTransactionsParams, + UpdateTransactionParams, } from '@/resources/interfaces'; -import type { TransactionsPage } from '@/types/index'; +import type { Transaction, TransactionsPage } from '@/types/index'; import { z } from 'zod'; const transactionSchema = z.looseObject({ @@ -16,7 +17,7 @@ const transactionSchema = z.looseObject({ description: z.string(), origin: z.enum(['link', 'external_connection']), category: z.string().nullable(), - status: z.string(), + status: z.string().nullable(), }); const transactionsPageSchema = z.union([ z.array(transactionSchema).transform((data) => ({ data })), @@ -83,4 +84,34 @@ export class TransactionsResource () => transactionsPageSchema.parse(data) as TransactionsPage, ); } + + async update( + id: string, + params: UpdateTransactionParams, + ): Promise { + const body: Record = {}; + if (params.category !== undefined) { + body.category = params.category; + } + if (params.description !== undefined) { + body.description = params.description; + } + + const { status, data, rawBody } = await this.apiFetch({ + method: 'POST', + url: `${this.endpoint}/${id}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (status < 200 || status >= 300) { + this.throwApiError('update transaction', status, data, rawBody); + } + + return this.parseResponse( + 'update transaction', + status, + () => transactionSchema.parse(data) as Transaction, + ); + } } diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 1ff18f27..b7301907 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -229,7 +229,7 @@ export interface Transaction { description: string; origin: TransactionOrigin; category: string | null; - status: string; + status: string | null; } export interface TransactionsPage { diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index 614d9494..c9be6acc 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -2,7 +2,7 @@ version: 0.11.0 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 a user's Link financial data — transactions, balances, and wallet sources — so agents can answer questions about spending and available source capabilities, and can update a transaction's category or description. 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", "recategorize this transaction", "update transaction", "fix the category", "rename this transaction", or asks about their financial activity, account balances, or linked sources. allowed-tools: - Bash(link-cli:*) - Bash(npx --yes @stripe/link-cli:*) @@ -35,7 +35,7 @@ Use this skill to answer questions about a user’s Link-connected financial dat - Linked wallet sources - Basic summaries derived from the user’s financial data -All commands are read-only. They do not move money, initiate payments, modify accounts, or expose payment credentials. +Most commands are read-only. The only write command is `transactions update`, which changes user-supplied metadata on a transaction — its category or description — and nothing else. No command moves money, initiates payments, modifies accounts, or exposes payment credentials. ## Safety and privacy @@ -45,7 +45,7 @@ Only retrieve the data needed to answer the user’s request. Do not run every l Do not expose sensitive identifiers, access tokens, credentials, or payment instrument details. Summarize financial information at the level needed to answer the user’s question. -If the user asks for an action that would move money, reference `skills/create-payment-credential/SKILL.md` instead. +If the user asks for an action that would move money — a payment, purchase, or transfer — reference `skills/create-payment-credential/SKILL.md` instead. Recategorizing or annotating an existing transaction does not move money and stays in this skill. ## Authentication @@ -65,21 +65,27 @@ Use the minimum required source actions: - Transactions imported from bank connections: `read_external_transactions` - Account balances: `read_balances` - Data source details and descriptions: `read_source_details` +- Updating transactions processed through Link: `write_link_transactions` +- Updating transactions imported from bank connections: `write_external_transactions` + +A write action implies the matching read action, so a session granted `write_link_transactions` can also read Link transactions. Do not request both the read and the write action for the same resource — request the write action only when the user wants to update a transaction. If the user asks a question that requires multiple data types, request all relevant actions together. -Example for a new login that needs all financial data types: +Example for a new login that needs all financial data types and the ability to update transactions: ```bash link-cli auth login \ --client-name "" \ - --source-actions read_link_transactions \ + --source-actions write_link_transactions \ + --source-actions write_external_transactions \ --source-actions read_balances \ - --source-actions read_external_transactions \ --source-actions read_source_details \ --format json ``` +For read-only access, use `read_link_transactions` and `read_external_transactions` in place of the two `write_` actions. + Example for adding balance access to an existing session: ```bash @@ -89,6 +95,16 @@ link-cli auth upgrade \ --format json ``` +Example for adding transaction-update access to an existing read-only session: + +```bash +link-cli auth upgrade \ + --client-name "" \ + --source-actions write_link_transactions \ + --source-actions write_external_transactions \ + --format json +``` + Replace `` with a clear name for the agent or application. Present the returned `verification_url` to the user, then follow the response's `_next` instruction or poll with: ```bash @@ -106,6 +122,7 @@ Use the smallest command set that answers the user’s question. | 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` | +| Recategorize a transaction, fix or change a transaction's category or description, rename a transaction | `link-cli transactions update` | Examples: @@ -113,6 +130,7 @@ Examples: - “What is my current checking account balance?” → Use balances only. - “Which accounts are connected?” → Use sources only. - “Summarize my cash position and recent spending.” → Use balances and transactions. +- “That Trader Joes charge should be groceries, not shopping.” → Use `transactions list` to find the transaction ID, then `transactions update`. ## Output format @@ -167,6 +185,28 @@ link-cli transactions list --format json --source --source --category groceries --description "Trader Joes" --format json +``` + +| Flag | Description | +|---|---| +| `--category` | New category. Must be a **subcategory** — e.g. `groceries`, `restaurants`, `rent`, `flights`, `coffee`, `electronics`. | +| `--description` | New description. Replaces the existing description. | + +Rules: + +- At least one of `--category` or `--description` is required. +- `--category` must be a subcategory, not a category group. Group-level values such as `shopping` or `income` are rejected with `invalid_category`. +- Fields cannot be cleared. An empty string is treated as absent, so passing `--description ""` does not blank the description. +- Omitted fields are preserved — updating only the category leaves the description unchanged. +- The response is a **single transaction object**, not enveloped in `data` (unlike `transactions list`). +- Requires the `write_link_transactions` or `write_external_transactions` source action, plus ownership of the transaction. The command may return `feature_unavailable` if the feature is not enabled for the account; report that to the user rather than retrying. + ### Response fields | Field | Note | @@ -174,7 +214,7 @@ See [Pagination](#pagination) for shared list controls. | `amount` | Negative = money leaving the account (debit/purchase), positive = money entering (credit/deposit). | | `origin` | `external_connection` (from linked bank/card) or `link` (Link-native transaction). | | `category` | May be `null` if unclassified. | -| `status` | API-provided status string. Do not assume a closed set of values; observed values include `succeeded`. Interpret or filter a status only when its meaning is known. | +| `status` | API-provided status string, and may be `null` when the upstream status is unmapped — handle a missing status rather than assuming one is always present. Do not assume a closed set of values; observed values include `succeeded`. Interpret or filter a status only when its meaning is known. | For transaction summaries: @@ -308,7 +348,7 @@ Do not: Do: -- Use read-only commands. +- Use read-only commands, except `transactions update` when the user asks to change a transaction's category or description. - Authenticate before retrieval. - Request the minimum required source actions. - Use `--format json` for parsing. From 864a0b7a41254a09b2972f0c93a466bc11867bea Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Thu, 27 Aug 2026 11:41:58 -0400 Subject: [PATCH 3/5] correct docs --- .../transactions/__tests__/transactions.test.tsx | 6 ++---- packages/cli/src/commands/transactions/index.tsx | 3 +-- packages/cli/src/commands/transactions/list.tsx | 2 +- packages/cli/src/commands/transactions/schema.ts | 4 ++-- packages/cli/src/commands/transactions/update.tsx | 2 +- .../sdk/src/resources/__tests__/transactions.test.ts | 4 +--- packages/sdk/src/resources/transactions.ts | 2 +- packages/sdk/src/types/index.ts | 2 +- skills/financial-insights/SKILL.md | 12 +++++------- 9 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx index 0452dfbe..4bd7c715 100644 --- a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx +++ b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx @@ -179,10 +179,8 @@ describe('transactions update component', () => { }); }); - it('renders null category and null status without crashing', async () => { - const resource = makeUpdateResource( - transaction({ category: null, status: null }), - ); + it('renders a null category without crashing', async () => { + const resource = makeUpdateResource(transaction({ category: null })); const { lastFrame } = render( = ({ [ formatCell(txn.created_date, DATE_WIDTH), formatCell(formatAmount(txn.amount, txn.currency), AMOUNT_WIDTH, 'right'), - formatCell(txn.status ?? '', STATUS_WIDTH), + formatCell(txn.status, STATUS_WIDTH), formatCell(txn.category ?? '', CATEGORY_WIDTH), formatCell(txn.description, descriptionWidth), ].join(COLUMN_GAP), diff --git a/packages/cli/src/commands/transactions/schema.ts b/packages/cli/src/commands/transactions/schema.ts index be1c0a2c..4094ba4f 100644 --- a/packages/cli/src/commands/transactions/schema.ts +++ b/packages/cli/src/commands/transactions/schema.ts @@ -44,12 +44,12 @@ export const updateOptions = z.object({ .string() .optional() .describe( - 'New category for the transaction. Must be a subcategory, not a category group — e.g. groceries, restaurants, rent, flights, coffee, electronics. Group-level values like "shopping" are rejected by the server. At least one of --category or --description is required. Empty strings are treated as absent and cannot clear the field.', + 'New category for the transaction. Omitted fields are preserved.', ), description: z .string() .optional() .describe( - 'New description for the transaction. Replaces the existing description. At least one of --category or --description is required. Empty strings are treated as absent and cannot clear the field. Omitted fields are preserved.', + 'New description for the transaction, replacing the existing description. Omitted fields are preserved.', ), }); diff --git a/packages/cli/src/commands/transactions/update.tsx b/packages/cli/src/commands/transactions/update.tsx index 5bfe1389..c2678b83 100644 --- a/packages/cli/src/commands/transactions/update.tsx +++ b/packages/cli/src/commands/transactions/update.tsx @@ -77,7 +77,7 @@ export const TransactionUpdate: React.FC = ({ Date: {transaction?.created_date} - Status: {transaction?.status ?? 'N/A'} + Status: {transaction?.status} diff --git a/packages/sdk/src/resources/__tests__/transactions.test.ts b/packages/sdk/src/resources/__tests__/transactions.test.ts index 1ed14b7a..af3c116c 100644 --- a/packages/sdk/src/resources/__tests__/transactions.test.ts +++ b/packages/sdk/src/resources/__tests__/transactions.test.ts @@ -224,11 +224,10 @@ describe('TransactionsResource', () => { expect(result).toEqual(bareTransaction); }); - it('parses null category and null status', async () => { + it('parses a null category', async () => { mockFetchResponse(200, { ...bareTransaction, category: null, - status: null, }); const result = await repo.update('lbctxn_123', { @@ -236,7 +235,6 @@ describe('TransactionsResource', () => { }); expect(result.category).toBeNull(); - expect(result.status).toBeNull(); }); it('throws API errors with the response message', async () => { diff --git a/packages/sdk/src/resources/transactions.ts b/packages/sdk/src/resources/transactions.ts index 6ef297d5..d28e7df0 100644 --- a/packages/sdk/src/resources/transactions.ts +++ b/packages/sdk/src/resources/transactions.ts @@ -17,7 +17,7 @@ const transactionSchema = z.looseObject({ description: z.string(), origin: z.enum(['link', 'external_connection']), category: z.string().nullable(), - status: z.string().nullable(), + status: z.string(), }); const transactionsPageSchema = z.union([ z.array(transactionSchema).transform((data) => ({ data })), diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index b7301907..1ff18f27 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -229,7 +229,7 @@ export interface Transaction { description: string; origin: TransactionOrigin; category: string | null; - status: string | null; + status: string; } export interface TransactionsPage { diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index c9be6acc..8db308b5 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -35,7 +35,7 @@ Use this skill to answer questions about a user’s Link-connected financial dat - Linked wallet sources - Basic summaries derived from the user’s financial data -Most commands are read-only. The only write command is `transactions update`, which changes user-supplied metadata on a transaction — its category or description — and nothing else. No command moves money, initiates payments, modifies accounts, or exposes payment credentials. +The `transactions list` command is read-only, and `transactions update` is a write command that updates the category or description on a transaction. ## Safety and privacy @@ -122,7 +122,7 @@ Use the smallest command set that answers the user’s question. | 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` | -| Recategorize a transaction, fix or change a transaction's category or description, rename a transaction | `link-cli transactions update` | +| Recategorize a transaction, fix or change a transaction's category or description | `link-cli transactions update` | Examples: @@ -201,11 +201,9 @@ link-cli transactions update --category groceries --description Rules: - At least one of `--category` or `--description` is required. -- `--category` must be a subcategory, not a category group. Group-level values such as `shopping` or `income` are rejected with `invalid_category`. -- Fields cannot be cleared. An empty string is treated as absent, so passing `--description ""` does not blank the description. - Omitted fields are preserved — updating only the category leaves the description unchanged. - The response is a **single transaction object**, not enveloped in `data` (unlike `transactions list`). -- Requires the `write_link_transactions` or `write_external_transactions` source action, plus ownership of the transaction. The command may return `feature_unavailable` if the feature is not enabled for the account; report that to the user rather than retrying. +- Requires the `write_link_transactions` or `write_external_transactions` source action, plus ownership of the transaction. ### Response fields @@ -214,7 +212,7 @@ Rules: | `amount` | Negative = money leaving the account (debit/purchase), positive = money entering (credit/deposit). | | `origin` | `external_connection` (from linked bank/card) or `link` (Link-native transaction). | | `category` | May be `null` if unclassified. | -| `status` | API-provided status string, and may be `null` when the upstream status is unmapped — handle a missing status rather than assuming one is always present. Do not assume a closed set of values; observed values include `succeeded`. Interpret or filter a status only when its meaning is known. | +| `status` | API-provided status string. Do not assume a closed set of values; observed values include `succeeded`. Interpret or filter a status only when its meaning is known. | For transaction summaries: @@ -348,7 +346,7 @@ Do not: Do: -- Use read-only commands, except `transactions update` when the user asks to change a transaction's category or description. +- Use read-only commands, unless the user asks to change information about a transaction. - Authenticate before retrieval. - Request the minimum required source actions. - Use `--format json` for parsing. From f26fcae8eadb6118ca38cc3bc46c4875cfd60c8c Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Thu, 27 Aug 2026 11:57:40 -0400 Subject: [PATCH 4/5] update tests --- packages/cli/src/__tests__/cli.test.ts | 5 ++- .../src/auth/__tests__/auth-resource.test.ts | 40 ------------------- .../__tests__/transactions.test.tsx | 20 ---------- .../cli/src/commands/transactions/update.tsx | 2 +- .../resources/__tests__/transactions.test.ts | 29 ++------------ 5 files changed, 8 insertions(+), 88 deletions(-) diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e566b6e8..ed167f0b 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1471,7 +1471,7 @@ describe('production mode', () => { expect(output.data).toBeUndefined(); }); - it('sends only category when only --category is provided', async () => { + it('drops a blank --description instead of clearing it server-side', async () => { setResponseForUrl('/transactions/lbctxn_001', 200, { ...SAMPLE_TRANSACTION, category: 'groceries', @@ -1483,13 +1483,14 @@ describe('production mode', () => { 'lbctxn_001', '--category', 'groceries', + '--description', + ' ', '--json', ); expect(result.exitCode).toBe(0); const sentBody = JSON.parse(lastRequest.body); expect(sentBody).toEqual({ category: 'groceries' }); - expect('description' in sentBody).toBe(false); }); it('fails locally without making a request when both flags are missing', async () => { diff --git a/packages/cli/src/auth/__tests__/auth-resource.test.ts b/packages/cli/src/auth/__tests__/auth-resource.test.ts index fec0b020..4628e2c4 100644 --- a/packages/cli/src/auth/__tests__/auth-resource.test.ts +++ b/packages/cli/src/auth/__tests__/auth-resource.test.ts @@ -1,7 +1,6 @@ import { hostname } from 'node:os'; import { LinkApiError, LinkTransportError } from '@stripe/link-sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loginOptions } from '../../commands/auth/schema'; import { LinkAuthResource } from '../auth-resource'; import { LinkAuthorizationDeclinedError } from '../errors'; @@ -168,45 +167,6 @@ describe('LinkAuthResource', () => { expect(params.getAll('authorization_details[]')).toEqual(['true']); }); - it('accepts and serializes write transaction source actions', async () => { - mockFetchResponse(200, { - device_code: 'dev_123', - user_code: 'ABCD-EFGH', - verification_uri: 'https://link.com/verify', - verification_uri_complete: 'https://link.com/verify?code=ABCD-EFGH', - expires_in: 900, - interval: 5, - }); - - // The write actions must pass `--source-actions` validation... - const parsed = loginOptions.parse({ - sourceActions: [ - 'write_link_transactions', - 'write_external_transactions', - ], - }); - expect(parsed.sourceActions).toEqual([ - 'write_link_transactions', - 'write_external_transactions', - ]); - - // ...and round-trip into the emitted source detail's actions array. - const resource = createResource(); - await resource.initiateDeviceAuth({ - sourceActions: parsed.sourceActions, - }); - - const body = mockFetch.mock.calls[0][1].body as string; - const params = new URLSearchParams(body); - expect(params.getAll('authorization_details[][type]')).toEqual([ - 'source', - ]); - expect(params.getAll('authorization_details[][actions][]')).toEqual([ - 'write_link_transactions', - 'write_external_transactions', - ]); - }); - it('includes client name and hostname in connection_label', async () => { mockFetchResponse(200, { device_code: 'dev_123', diff --git a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx index 4bd7c715..cdd6475d 100644 --- a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx +++ b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx @@ -179,26 +179,6 @@ describe('transactions update component', () => { }); }); - it('renders a null category without crashing', async () => { - const resource = makeUpdateResource(transaction({ category: null })); - - const { lastFrame } = render( - {}} - />, - ); - - await vi.waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('N/A'); - expect(frame).not.toContain('null'); - expect(frame).toContain('Chase'); - }); - }); - it('renders an error state when the resource rejects', async () => { const resource = sanitizeResource({ update: vi.fn(async () => { diff --git a/packages/cli/src/commands/transactions/update.tsx b/packages/cli/src/commands/transactions/update.tsx index c2678b83..df8822e2 100644 --- a/packages/cli/src/commands/transactions/update.tsx +++ b/packages/cli/src/commands/transactions/update.tsx @@ -63,7 +63,7 @@ export const TransactionUpdate: React.FC = ({ Description: {transaction?.description} - Category: {transaction?.category ?? 'N/A'} + Category: {transaction?.category} Amount:{' '} diff --git a/packages/sdk/src/resources/__tests__/transactions.test.ts b/packages/sdk/src/resources/__tests__/transactions.test.ts index af3c116c..4430415b 100644 --- a/packages/sdk/src/resources/__tests__/transactions.test.ts +++ b/packages/sdk/src/resources/__tests__/transactions.test.ts @@ -183,10 +183,10 @@ describe('TransactionsResource', () => { status: 'succeeded', }; - it('POSTs to the transaction endpoint with the update body', async () => { + it('POSTs the update body and returns the bare transaction', async () => { mockFetchResponse(200, bareTransaction); - await repo.update('lbctxn_123', { + const result = await repo.update('lbctxn_123', { category: 'groceries', description: 'Trader Joes', }); @@ -201,6 +201,8 @@ describe('TransactionsResource', () => { category: 'groceries', description: 'Trader Joes', }); + // The update response is not paginated/enveloped, unlike `list`. + expect(result).toEqual(bareTransaction); }); it('omits unset fields from the request body', async () => { @@ -214,29 +216,6 @@ describe('TransactionsResource', () => { expect('description' in body).toBe(false); }); - it('parses a bare (non-enveloped) transaction response', async () => { - mockFetchResponse(200, bareTransaction); - - const result = await repo.update('lbctxn_123', { - description: 'Trader Joes', - }); - - expect(result).toEqual(bareTransaction); - }); - - it('parses a null category', async () => { - mockFetchResponse(200, { - ...bareTransaction, - category: null, - }); - - const result = await repo.update('lbctxn_123', { - description: 'Trader Joes', - }); - - expect(result.category).toBeNull(); - }); - it('throws API errors with the response message', async () => { mockFetchResponse(400, { error: { From 2f1941008786b455e2d580b7f05d5c44222e1d4b Mon Sep 17 00:00:00 2001 From: Selina Feng Date: Thu, 27 Aug 2026 13:11:02 -0400 Subject: [PATCH 5/5] more skill file adjustments --- skills/financial-insights/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index 8db308b5..526ff64a 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -45,7 +45,7 @@ Only retrieve the data needed to answer the user’s request. Do not run every l Do not expose sensitive identifiers, access tokens, credentials, or payment instrument details. Summarize financial information at the level needed to answer the user’s question. -If the user asks for an action that would move money — a payment, purchase, or transfer — reference `skills/create-payment-credential/SKILL.md` instead. Recategorizing or annotating an existing transaction does not move money and stays in this skill. +If the user asks for an action that would move money — a payment, purchase, or transfer — reference `skills/create-payment-credential/SKILL.md` instead. ## Authentication @@ -68,7 +68,7 @@ Use the minimum required source actions: - Updating transactions processed through Link: `write_link_transactions` - Updating transactions imported from bank connections: `write_external_transactions` -A write action implies the matching read action, so a session granted `write_link_transactions` can also read Link transactions. Do not request both the read and the write action for the same resource — request the write action only when the user wants to update a transaction. +A write action implies the matching read action, so a session granted `write_link_transactions` can also read Link transactions. If the user asks a question that requires multiple data types, request all relevant actions together.