Skip to content
Open
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
143 changes: 143 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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> = {}): Transaction {
return {
id: 'lbctxn_1',
Expand Down Expand Up @@ -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(
<TransactionUpdate
resource={resource}
id="lbctxn_1"
params={{ category: 'groceries', description: 'Trader Joes' }}
onComplete={() => {}}
/>,
);

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(
<TransactionUpdate
resource={resource}
id="lbctxn_1"
params={{ category: 'shopping' }}
onComplete={() => {}}
/>,
);

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(
<TransactionUpdate
resource={resource}
id="lbctxn_1"
params={{ description: ESCAPE_PAYLOAD }}
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});
});
76 changes: 73 additions & 3 deletions packages/cli/src/commands/transactions/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
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,
authStorage?: CliAuthStorage,
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', {
Expand Down Expand Up @@ -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(
<TransactionUpdate
resource={resource}
id={id}
params={params}
onComplete={(result) => {
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;
}
15 changes: 15 additions & 0 deletions packages/cli/src/commands/transactions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
),
});
Loading
Loading