diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e042b836..ed167f0b 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1434,6 +1434,149 @@ 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('drops a blank --description instead of clearing it server-side', async () => { + setResponseForUrl('/transactions/lbctxn_001', 200, { + ...SAMPLE_TRANSACTION, + category: 'groceries', + }); + + const result = await runProdCli( + 'transactions', + 'update', + 'lbctxn_001', + '--category', + 'groceries', + '--description', + ' ', + '--json', + ); + + expect(result.exitCode).toBe(0); + const sentBody = JSON.parse(lastRequest.body); + expect(sentBody).toEqual({ category: 'groceries' }); + }); + + 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/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]; diff --git a/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx b/packages/cli/src/commands/transactions/__tests__/transactions.test.tsx index ba4fcebc..cdd6475d 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,76 @@ 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 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..2a88c782 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,70 @@ 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.', + }); + } + + 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/schema.ts b/packages/cli/src/commands/transactions/schema.ts index 5d266f3f..4094ba4f 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. Omitted fields are preserved.', + ), + description: z + .string() + .optional() + .describe( + '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 new file mode 100644 index 00000000..df8822e2 --- /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} + + + Amount:{' '} + + {transaction + ? formatAmount(transaction.amount, transaction.currency) + : 'N/A'} + + + + Date: {transaction?.created_date} + + + Status: {transaction?.status} + + + + ); +}; diff --git a/packages/sdk/src/resources/__tests__/transactions.test.ts b/packages/sdk/src/resources/__tests__/transactions.test.ts index efbfd40e..4430415b 100644 --- a/packages/sdk/src/resources/__tests__/transactions.test.ts +++ b/packages/sdk/src/resources/__tests__/transactions.test.ts @@ -169,4 +169,66 @@ 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 the update body and returns the bare transaction', async () => { + mockFetchResponse(200, bareTransaction); + + const result = 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', + }); + // The update response is not paginated/enveloped, unlike `list`. + expect(result).toEqual(bareTransaction); + }); + + 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('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..d28e7df0 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({ @@ -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/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index 614d9494..526ff64a 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. +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 @@ -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. ## 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. 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 | `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,26 @@ 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. +- 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. + ### Response fields | Field | Note | @@ -308,7 +346,7 @@ Do not: Do: -- Use read-only commands. +- 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.