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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ Key input field notes:
- `--metadata` (create only) is a repeatable `key:value` flag (CLI) or a `{ key: value }` object (MCP/agent), merged into a single `metadata` string→string map. Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. Reuses `parseKvString` from `line-item-parser.ts`.
- `--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/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`.
- `retrieve --interval <seconds>` polls until approved/denied/expired/succeeded/failed/canceled, or until `requires_action` with a non-`auto_resume` resolution (`auto_resume` is polled through transparently). If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`.
- Both `create` and `retrieve` (including `--request-approval`/`request-approval` polling and `retrieve --interval` polling) can return `status: 'requires_action'` with `status_details.requires_action.next_action` (`type`, `display_message`, `action_url`, `resolution`). `resolution: 'auto_resume'` (currently only `next_action.type: 'three_d_secure'`) means polling continues transparently — the request resolves on its own. Any other resolution stops polling immediately; the caller must have the user complete the action, then create a new spend request.
- `cancel <id>` cancels a spend request. Can cancel from `created`, `pending_approval`, or `approved` states. Returns the spend request with `status: "canceled"`.
- `--approval-detail` — optional JSON object (MCP/agent) or JSON string (CLI) with approval details for delegated flows. Required fields: `approved_at` (unix timestamp int), `approval_method` (`click`|`programmatic`|`voice`), `app_name`, `external_user_id`. Optional: `ip_address`, `user_agent`, `device_type` (`mobile`|`web`), `agent_log_id`, `external_user_name`, `external_session_id`, `authentication_method` (`biometric_face`|`biometric_fingerprint`|`passkey`). Sent as `approval_details` in the API request body.
- `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)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ The `--request-approval` flag triggers a push notification to the user for appro

Easily approve requests with the [Link app](https://link.com/download).

If the created spend request comes back with `status: "requires_action"`, no approval is needed yet — the payment method or account needs attention first. Check `status_details.requires_action.next_action` for `type`, `display_message`, `action_url`, and `resolution`. For 3D Secure (`resolution: "auto_resume"`), keep polling `spend-request retrieve` — the request resolves on its own once the challenge is completed. For any other resolution, complete the indicated action and create a new spend request.

#### Line items and totals

`--line-item` and `--total` use repeatable `key:value` format.
Expand Down Expand Up @@ -223,7 +225,7 @@ For agent polling, pass `--interval` and optionally `--max-attempts`:
link-cli spend-request retrieve lsrq_001 --interval 2 --max-attempts 300
```

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.
Polling exits successfully only after the request reaches a terminal status such as `approved`, `denied`, `expired`, or `canceled`. If the status becomes `requires_action`, behavior depends on `next_action.resolution`: `auto_resume` (used for 3D Secure) means polling continues automatically — the request resolves on its own once the user completes the challenge. Any other resolution stops polling immediately and the command exits with the `next_action` details instead of waiting for a terminal status; the caller must have the user act, then create a new spend request. If `--timeout` is reached or `--max-attempts` is exhausted 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
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,32 @@ function makeMockRepo(result: SpendRequest) {
} as unknown as ISpendRequestResource);
}

// Returns each entry in `getSpendRequestResults` in order on successive
// `getSpendRequest` calls (repeating the last entry once exhausted), so tests
// can simulate a status transitioning across polls.
function makeSequentialMockRepo(
createResult: SpendRequest,
getSpendRequestResults: SpendRequest[],
) {
let call = 0;
const getSpendRequest = vi.fn(async () => {
const result =
getSpendRequestResults[Math.min(call, getSpendRequestResults.length - 1)];
call++;
return result;
});
return sanitizeResource({
createSpendRequest: vi.fn(async () => createResult),
getSpendRequest,
updateSpendRequest: vi.fn(async () => createResult),
requestApproval: vi.fn(async () => ({
id: createResult.id,
approval_link: 'https://app.link.com/approve/sr_test',
})),
cancelSpendRequest: vi.fn(async () => createResult),
} as unknown as ISpendRequestResource);
}

describe('spend-request', () => {
describe('verification_url', () => {
it('CreateSpendRequest surfaces verification_url on additional_verification_required error', async () => {
Expand Down Expand Up @@ -100,6 +126,7 @@ describe('spend-request', () => {
const frame = lastFrame();
expect(frame).toContain('Failed to create spend request');
expect(frame).toContain('https://app.link.com/finish_setup');
expect(frame).toContain('Press Enter to open in browser');
});
});

Expand Down Expand Up @@ -148,6 +175,7 @@ describe('spend-request', () => {
const frame = lastFrame();
expect(frame).toContain('Failed to create spend request');
expect(frame).toContain('https://support.link.com');
expect(frame).toContain('Press Enter to open in browser');
});
});

Expand Down Expand Up @@ -255,6 +283,7 @@ describe('spend-request', () => {
const frame = lastFrame();
expect(frame).toContain('Failed to request approval');
expect(frame).toContain('https://app.link.com/finish_setup');
expect(frame).toContain('Press Enter to open in browser');
});
});

Expand Down Expand Up @@ -296,8 +325,271 @@ describe('spend-request', () => {
const frame = lastFrame();
expect(frame).toContain('Failed to request approval');
expect(frame).toContain('https://support.link.com');
expect(frame).toContain('Press Enter to open in browser');
});
});
});

describe('requires_action', () => {
it('CreateSpendRequest shows next_action details for a non-auto_resume type', async () => {
const request = makeSpendRequest({
status: 'requires_action',
status_details: {
requires_action: {
next_action: {
type: 'add_payment_method',
resolution: 'create_new_spend_request',
display_message: 'Add a payment method to continue.',
action_url: 'https://app.link.com/add_payment_method',
},
},
},
});
const repo = makeMockRepo(request);

const { lastFrame } = render(
<CreateSpendRequest
repository={repo}
params={{
payment_details: 'pm_1',
amount: 1000,
currency: 'usd',
merchant_name: 'Acme',
merchant_url: 'https://example.com',
context: 'x'.repeat(100),
}}
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Action required before payment can proceed');
expect(frame).toContain('add_payment_method');
expect(frame).toContain('Add a payment method to continue.');
expect(frame).toContain('https://app.link.com/add_payment_method');
expect(frame).toContain('Press Enter to open in browser');
expect(frame).toContain(
'Complete this step, then create a new spend request.',
);
});
});

it('CreateSpendRequest resumes polling for auto_resume (three_d_secure) and resolves to success', async () => {
const requiresAction = makeSpendRequest({
status: 'requires_action',
status_details: {
requires_action: {
next_action: {
type: 'three_d_secure',
resolution: 'auto_resume',
display_message: 'Complete 3D Secure verification.',
action_url: 'https://app.link.com/finish_setup?verify=3ds',
},
},
},
});
const approved = makeSpendRequest({ status: 'approved' });
const repo = makeSequentialMockRepo(requiresAction, [approved]);

const { lastFrame } = render(
<CreateSpendRequest
repository={repo}
params={{
payment_details: 'pm_1',
amount: 1000,
currency: 'usd',
merchant_name: 'Acme',
merchant_url: 'https://example.com',
context: 'x'.repeat(100),
}}
onComplete={() => {}}
/>,
);

await vi.waitFor(
() => {
const frame = lastFrame();
expect(frame).toContain(
'Waiting for 3D Secure verification to complete',
);
},
{ timeout: 3000 },
);

await vi.waitFor(
() => {
const frame = lastFrame();
expect(frame).toContain('Spend request created');
expect(frame).toContain('approved');
},
{ timeout: 5000 },
);
}, 8000);

it('CreateSpendRequest surfaces requires_action reached via --request-approval polling (not conflated with denied)', async () => {
const created = makeSpendRequest({
status: 'created',
approval_url: 'https://app.link.com/approve/sr_test',
});
const requiresAction = makeSpendRequest({
status: 'requires_action',
status_details: {
requires_action: {
next_action: {
type: 're_authorize',
resolution: 'create_new_spend_request',
display_message: 'Re-authorize this payment method.',
action_url: null,
},
},
},
});
const repo = makeSequentialMockRepo(created, [requiresAction]);

const { lastFrame } = render(
<CreateSpendRequest
repository={repo}
params={{
payment_details: 'pm_1',
amount: 1000,
currency: 'usd',
merchant_name: 'Acme',
merchant_url: 'https://example.com',
context: 'x'.repeat(100),
}}
requestApproval
onComplete={() => {}}
/>,
);

await vi.waitFor(
() => {
const frame = lastFrame();
expect(frame).toContain('Action required before payment can proceed');
expect(frame).toContain('re_authorize');
expect(frame).toContain('Re-authorize this payment method.');
expect(frame).not.toContain('denied');
},
{ timeout: 3000 },
);
});

it('RequestApproval shows a minimal requires_action message reached via polling', async () => {
const requiresAction = makeSpendRequest({
status: 'requires_action',
status_details: {
requires_action: {
next_action: {
type: 'update_payment_method',
resolution: 'create_new_spend_request',
display_message: 'Update your payment method.',
action_url: 'https://app.link.com/update_payment_method',
},
},
},
});
const repo = makeSequentialMockRepo(requiresAction, [requiresAction]);

const { lastFrame } = render(
<RequestApproval
repository={repo}
id="sr_test"
onComplete={() => {}}
/>,
);

await vi.waitFor(
() => {
const frame = lastFrame();
expect(frame).toContain('Action required before payment can proceed');
expect(frame).toContain('Update your payment method.');
expect(frame).toContain('https://app.link.com/update_payment_method');
expect(frame).not.toContain('denied');
},
{ timeout: 3000 },
);
});

it('RetrieveSpendRequest shows the requires_action phase for a non-auto_resume type', async () => {
const request = makeSpendRequest({
status: 'requires_action',
status_details: {
requires_action: {
next_action: {
type: 'select_payment_method',
resolution: 'create_new_spend_request',
display_message: 'Select a different payment method.',
action_url: 'https://app.link.com/select_payment_method',
},
},
},
});
const repo = makeMockRepo(request);

const { lastFrame } = render(
<RetrieveSpendRequest
repository={repo}
id="sr_test"
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Action required before payment can proceed');
expect(frame).toContain('select_payment_method');
expect(frame).toContain('Select a different payment method.');
expect(frame).toContain('https://app.link.com/select_payment_method');
expect(frame).toContain(
'Complete this step, then create a new spend request.',
);
});
});

it('RetrieveSpendRequest polls through an auto_resume requires_action and resolves to success', async () => {
const requiresAction = makeSpendRequest({
status: 'requires_action',
status_details: {
requires_action: {
next_action: {
type: 'three_d_secure',
resolution: 'auto_resume',
display_message: 'Complete 3D Secure verification.',
action_url: 'https://app.link.com/finish_setup?verify=3ds',
},
},
},
});
const approved = makeSpendRequest({ status: 'approved' });
const repo = makeSequentialMockRepo(requiresAction, [
requiresAction,
approved,
]);

const { lastFrame } = render(
<RetrieveSpendRequest
repository={repo}
id="sr_test"
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain(
'Waiting for 3D Secure verification to complete',
);
});

await vi.waitFor(
() => {
const frame = lastFrame();
expect(frame).toContain('Spend request approved');
},
{ timeout: 5000 },
);
}, 8000);
});

describe('activity_url', () => {
Expand Down
Loading
Loading