Skip to content
Merged
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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo
- **Interactive** (default): Ink/React components from `packages/cli/src/commands/`
- **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1

Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval`, `payment-methods list`, `mpp pay|decode`.
Commands: `auth login|logout|status`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `mpp pay|decode`.

The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur.

Expand All @@ -67,8 +67,9 @@ Key input field notes:
- CLI input uses `payment_method_id`; mapped to `payment_details` when calling the SDK
- `context` requires min 100 characters; `amount` is in cents with max 50000
- `--test` flag creates testmode credentials (real testmode SPT from test card data) instead of livemode ones
- `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed. In JSON mode (`--format json`), they return immediately with an `_next.command` for `spend-request retrieve`.
- `retrieve --interval <seconds>` polls until approved/denied/expired/succeeded/failed. If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`.
- `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed/canceled. In JSON mode (`--format json`), they return immediately with an `_next.command` for `spend-request retrieve`.
- `retrieve --interval <seconds>` polls until approved/denied/expired/succeeded/failed/canceled. If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`.
- `cancel <id>` cancels a spend request. Can cancel from `created`, `pending_approval`, or `approved` states. Returns the spend request with `status: "canceled"`.
- `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working)
- `--output-file <path>` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file.

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ For agent polling, pass `--interval` and optionally `--max-attempts`:
link-cli spend-request retrieve lsrq_001 --interval 2 --max-attempts 150
```

Polling exits successfully only after the request reaches a terminal status such as `approved`, `denied`, or `expired`. If polling reaches `--timeout` or exhausts `--max-attempts` while the request is still non-terminal, the command exits non-zero with `code: "POLLING_TIMEOUT"` so callers do not treat a still-pending request as complete.
Polling exits successfully only after the request reaches a terminal status such as `approved`, `denied`, `expired`, or `canceled`. If polling reaches `--timeout` or exhausts `--max-attempts` while the request is still non-terminal, the command exits non-zero with `code: "POLLING_TIMEOUT"` so callers do not treat a still-pending request as complete.

If the merchant supports MPP, use `link-cli mpp pay` instead:

Expand Down Expand Up @@ -205,6 +205,9 @@ link-cli spend-request request-approval lsrq_001

# Retrieve at any time (includes card credentials after approval)
link-cli spend-request retrieve lsrq_001

# Cancel a spend request (from created, pending_approval, or approved state)
link-cli spend-request cancel lsrq_001
```

### MPP
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,63 @@ describe('production mode', () => {
});
});

describe('spend-request cancel', () => {
it('sends POST to /spend_requests/:id/cancel with auth header and no body', async () => {
setNextResponse(200, { ...BASE_REQUEST, status: 'canceled' });

const result = await runProdCli(
'spend-request',
'cancel',
'lsrq_prod_001',
'--json',
);

expect(result.exitCode).toBe(0);
expect(lastRequest.method).toBe('POST');
expect(lastRequest.url).toBe('/spend_requests/lsrq_prod_001/cancel');
expect(lastRequest.headers.authorization).toBe(
'Bearer prod_test_access_token',
);
expect(lastRequest.body).toBe('');
});

it('returns the canceled spend request', async () => {
setNextResponse(200, { ...BASE_REQUEST, status: 'canceled' });

const result = await runProdCli(
'spend-request',
'cancel',
'lsrq_prod_001',
'--json',
);

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.status).toBe('canceled');
expect(output.id).toBe('lsrq_prod_001');
});

it('surfaces API errors for cancel (409 terminal state)', async () => {
setNextResponse(409, {
error: {
message:
'Spend request is in a terminal state and cannot be canceled',
},
});

const result = await runProdCli(
'spend-request',
'cancel',
'lsrq_prod_001',
'--json',
);

expect(result.exitCode).toBe(1);
const output = result.stdout + result.stderr;
expect(output).toContain('terminal state');
});
});

describe('spend-request retrieve', () => {
it('sends GET to /spend-requests/:id', async () => {
const result = await runProdCli(
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/commands/spend-request/cancel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type React from 'react';
import { useEffect, useState } from 'react';

interface CancelSpendRequestProps {
repository: ISpendRequestResource;
id: string;
onComplete: (result: SpendRequest | null) => void;
}

export const CancelSpendRequest: React.FC<CancelSpendRequestProps> = ({
repository,
id,
onComplete,
}) => {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
'loading',
);
const [request, setRequest] = useState<SpendRequest | null>(null);
const [error, setError] = useState<string>('');

useEffect(() => {
const run = async () => {
try {
const result = await repository.cancelSpendRequest(id);
setRequest(result);
setStatus('success');
setTimeout(() => onComplete(result), 1500);
} catch (err) {
setError((err as Error).message);
setStatus('error');
setTimeout(() => onComplete(null), 1500);
}
};

run();
}, [repository, id, onComplete]);

if (status === 'loading') {
return (
<Box>
<Text color="cyan">
<Spinner type="dots" /> Canceling spend request {id}...
</Text>
</Box>
);
}

if (status === 'error') {
return (
<Box flexDirection="column">
<Text color="red">✗ Failed to cancel spend request</Text>
<Text color="red">{error}</Text>
</Box>
);
}

return (
<Box flexDirection="column">
<Text color="green">✓ Spend request canceled</Text>
<Box flexDirection="column" marginTop={1} paddingX={2}>
<Text>
ID: <Text bold>{request?.id}</Text>
</Text>
</Box>
</Box>
);
};
37 changes: 37 additions & 0 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
parseLineItemFlag,
parseTotalFlag,
} from '../../utils/line-item-parser';
import { requireAuth } from '../../utils/require-auth';
import { CancelSpendRequest } from './cancel';
import { CreateSpendRequest } from './create';
import { RequestApproval } from './request-approval';
import { RetrieveSpendRequest } from './retrieve';
Expand Down Expand Up @@ -359,6 +361,7 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
'expired',
'succeeded',
'failed',
'canceled',
]);
const deadline = Date.now() + timeout * 1000;
let attempts = 0;
Expand Down Expand Up @@ -414,5 +417,39 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
},
});

cli.command('cancel', {
description: 'Cancel a spend request',
args: z.object({
id: z.string().describe('Spend request ID'),
}),
outputPolicy: 'agent-only' as const,
async run(c) {
const authError = requireAuth(c);
if (authError) return authError;

const id = c.args.id;

if (!c.agent && !c.formatExplicit) {
return new Promise((resolve) => {
let capturedResult: SpendRequest | null = null;
const { waitUntilExit } = render(
<CancelSpendRequest
repository={repository}
id={id}
onComplete={(result) => {
capturedResult = result;
}}
/>,
);
waitUntilExit().then(() => {
resolve(capturedResult as SpendRequest);
});
});
}

return repository.cancelSpendRequest(id);
},
});

return cli;
}
22 changes: 22 additions & 0 deletions packages/cli/src/utils/require-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { storage } from '@stripe/link-sdk';

interface AuthErrorOptions {
code: string;
message: string;
cta?: { commands: { command: string; description: string }[] };
}

const NOT_AUTHENTICATED_ERROR: AuthErrorOptions = {
code: 'NOT_AUTHENTICATED',
message: 'Not authenticated. Run "link-cli auth login" first.',
cta: {
commands: [{ command: 'auth login', description: 'Log in to Link' }],
},
};

export function requireAuth(c: { error: (err: AuthErrorOptions) => never }) {
if (!storage.isAuthenticated()) {
return c.error(NOT_AUTHENTICATED_ERROR);
}
return null;
}
64 changes: 64 additions & 0 deletions packages/sdk/src/resources/__tests__/spend-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,70 @@ describe('SpendRequestResource', () => {
});
});

describe('cancelSpendRequest', () => {
it('sends POST to cancel endpoint with Bearer auth and no body', async () => {
const canceledResponse = { ...spendRequestResponse, status: 'canceled' };
mockFetchResponse(200, canceledResponse);

await repo.cancelSpendRequest('si_123');

const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.link.com/spend_requests/si_123/cancel');
expect(opts.method).toBe('POST');
expect(opts.headers.Authorization).toBe('Bearer test_token');
expect(opts.body).toBeUndefined();
});

it('returns SpendRequest with canceled status on success', async () => {
const canceledResponse = { ...spendRequestResponse, status: 'canceled' };
mockFetchResponse(200, canceledResponse);

const result = await repo.cancelSpendRequest('si_123');

expect(result.status).toBe('canceled');
expect(result.id).toBe('si_123');
});

it('throws on 404 not found', async () => {
mockFetchResponse(404, { error: { message: 'Spend request not found' } });

await expect(repo.cancelSpendRequest('si_nonexistent')).rejects.toThrow(
'Failed to cancel spend request (404): Spend request not found',
);
});

it('throws on 409 terminal state', async () => {
mockFetchResponse(409, {
error: {
message:
'Spend request is in a terminal state and cannot be canceled',
},
});

await expect(repo.cancelSpendRequest('si_123')).rejects.toThrow(
'Failed to cancel spend request (409): Spend request is in a terminal state and cannot be canceled',
);
});

it('throws on 422 expired', async () => {
mockFetchResponse(422, {
error: { message: 'Spend request expired' },
});

await expect(repo.cancelSpendRequest('si_123')).rejects.toThrow(
'Failed to cancel spend request (422): Spend request expired',
);
});

it('throws when no access token is available', async () => {
getAccessToken.mockRejectedValueOnce(new Error('Missing access token'));

await expect(repo.cancelSpendRequest('si_123')).rejects.toThrow(
'Missing access token',
);
});
});

describe('getSpendRequest', () => {
it('sends GET to retrieve endpoint', async () => {
mockFetchResponse(200, spendRequestResponse);
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/resources/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface ISpendRequestResource {
params: UpdateSpendRequestParams,
): Promise<SpendRequest>;
requestApproval(id: string): Promise<RequestApprovalResponse>;
cancel(id: string): Promise<SpendRequest>;
cancelSpendRequest(id: string): Promise<SpendRequest>;
retrieve(
id: string,
opts?: { include?: string[] },
Expand Down
20 changes: 20 additions & 0 deletions packages/sdk/src/resources/spend-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,26 @@ export class SpendRequestResource implements ISpendRequestResource {
return normalizeSpendRequest(data);
}

cancel(id: string): Promise<SpendRequest> {
return this.cancelSpendRequest(id);
}

async cancelSpendRequest(id: string): Promise<SpendRequest> {
const { status, data, rawBody } = await this.apiFetch({
method: 'POST',
url: `${this.spendRequestsEndpoint}/${id}/cancel`,
});

if (status < 200 || status >= 300) {
throw new LinkApiError(
`Failed to cancel spend request (${status}): ${extractApiError(data, rawBody)}`,
{ status, rawBody, details: data },
);
}

return normalizeSpendRequest(data);
}

async requestApproval(id: string): Promise<RequestApprovalResponse> {
const { status, data, rawBody } = await this.apiFetch({
method: 'POST',
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ export type SpendRequestStatus =
| 'approved'
| 'denied'
| 'succeeded'
| 'failed';
| 'failed'
| 'canceled';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets make sure we add this into the various readme, skill, helper text etc


export type CredentialType = 'shared_payment_token' | 'card';

Expand Down
8 changes: 6 additions & 2 deletions skills/create-payment-credential/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ link-cli spend-request create \

**`--total` keys:** `type` (required; one of: `subtotal`, `tax`, `total`), `display_text` (required), `amount` (required). Repeatable (e.g. subtotal + tax + total).

Do not proceed to payment while the request is still `created` or `pending_approval`. If polling exits with `POLLING_TIMEOUT`, keep waiting or ask the user whether to continue polling. If they deny, ask for clarification what to do next.
Do not proceed to payment while the request is still `created` or `pending_approval`. If polling exits with `POLLING_TIMEOUT`, keep waiting or ask the user whether to continue polling. If they deny, ask for clarification what to do next. If the user wants to abort, cancel the spend request:

```bash
link-cli spend-request cancel <id>
```

Recommend the user approves with the [Link app](https://link.com/download). Show the download URL.

Expand Down Expand Up @@ -200,7 +204,7 @@ All errors are output as JSON with `code` and `message` fields, with exit code 1
| `context` validation error on `spend-request create` | `context` field is under 100 characters | Rewrite `context` as a full sentence explaining what is being purchased and why; the user reads this when approving |
| API rejects `merchant_name` or `merchant_url` | These fields are forbidden when `credential_type` is `shared_payment_token` | Remove both fields from the request; SPT flows identify the merchant via `network_id` instead |
| Spend request approved but payment fails immediately | Wrong credential type for the merchant (e.g. `card` on a 402-only endpoint) | Go back to Step 2, re-evaluate the merchant, create a new spend request with the correct `credential_type` |
| Auth token expired mid-session (exit code 1 during approval polling) | Token refresh failure during background polling | Re-authenticate with `auth login`, then retrieve the existing spend request or resume polling. Only create a new spend request if the original one expired, was denied, or its shared payment token was already consumed |
| Auth token expired mid-session (exit code 1 during approval polling) | Token refresh failure during background polling | Re-authenticate with `auth login`, then retrieve the existing spend request or resume polling. Only create a new spend request if the original one expired, was denied, was canceled, or its shared payment token was already consumed |

## Further docs

Expand Down
Loading