From 11f17248307046dda64c95182b7447dbd4590a88 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Thu, 13 Aug 2026 13:38:50 -0400 Subject: [PATCH] Handle spend request requires_action state across create/retrieve/approval flows --- CLAUDE.md | 3 +- README.md | 4 +- .../__tests__/spend-request.test.tsx | 292 ++++++++++++++++++ .../cli/src/commands/spend-request/create.tsx | 177 ++++++++++- .../cli/src/commands/spend-request/index.tsx | 42 ++- .../cli/src/commands/spend-request/list.tsx | 3 +- .../spend-request/request-approval.tsx | 32 +- .../src/commands/spend-request/retrieve.tsx | 83 ++++- .../cli/src/commands/spend-request/schema.ts | 4 +- .../spend-request/use-approval-polling.ts | 18 +- packages/cli/src/utils/constants.ts | 7 + packages/sdk/src/types/index.ts | 35 ++- skills/create-payment-credential/SKILL.md | 7 + 13 files changed, 690 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7152641..f1472b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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 ` 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 ` 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) diff --git a/README.md b/README.md index 095f53a..e8d2e60 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: diff --git a/packages/cli/src/commands/spend-request/__tests__/spend-request.test.tsx b/packages/cli/src/commands/spend-request/__tests__/spend-request.test.tsx index 096e3e4..098a2ab 100644 --- a/packages/cli/src/commands/spend-request/__tests__/spend-request.test.tsx +++ b/packages/cli/src/commands/spend-request/__tests__/spend-request.test.tsx @@ -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 () => { @@ -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'); }); }); @@ -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'); }); }); @@ -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'); }); }); @@ -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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + 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', () => { diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 2d6005d..1601b82 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -1,6 +1,7 @@ import type { CreateSpendRequestParams, ISpendRequestResource, + NextAction, SpendRequest, } from '@stripe/link-sdk'; import { LinkApiError, getDuplicateSpendRequest } from '@stripe/link-sdk'; @@ -8,7 +9,11 @@ import { Box, Text, useApp, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; -import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { + DISPLAY_DELAY_MS, + RESUME_POLL_INTERVAL_MS, + RESUME_TIMEOUT_MS, +} from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; import { formatAmount } from '../../utils/format-amount'; import { openUrl } from '../../utils/open-url'; @@ -44,6 +49,9 @@ export const CreateSpendRequest: React.FC = ({ | 'error' | 'verification_required' | 'opened' + | 'requires_action' + | 'resuming' + | 'resume_timeout' >('creating'); const [request, setRequest] = useState(null); const [duplicateRequest, setDuplicateRequest] = useState( @@ -55,6 +63,7 @@ export const CreateSpendRequest: React.FC = ({ const [countdown, setCountdown] = useState(30); const [outputFilePath, setOutputFilePath] = useState(null); const [fileError, setFileError] = useState(''); + const [nextAction, setNextAction] = useState(null); const approvalUrl = request?.approval_url ?? ''; @@ -71,6 +80,10 @@ export const CreateSpendRequest: React.FC = ({ [], ); const onError = useCallback((msg: string) => setError(msg), []); + const onRequiresAction = useCallback((result: SpendRequest) => { + setRequest(result); + setNextAction(result.status_details?.requires_action?.next_action ?? null); + }, []); useApprovalPolling({ status, @@ -81,8 +94,78 @@ export const CreateSpendRequest: React.FC = ({ onComplete: completeAndExit, onSuccess, onError, + onRequiresAction, }); + useInput( + (_input, key) => { + if (key.return && nextAction?.action_url) { + openUrl(nextAction.action_url); + completeAndExit(request); + } + }, + { + isActive: + status === 'requires_action' && + nextAction?.resolution !== 'auto_resume', + }, + ); + + useEffect(() => { + if (status !== 'requires_action') return; + if (nextAction?.resolution === 'auto_resume') { + setStatus('resuming'); + } + }, [status, nextAction]); + + useEffect(() => { + if (status !== 'resuming' || !request?.id) return; + + let cancelled = false; + const requestId = request.id; + const deadline = Date.now() + RESUME_TIMEOUT_MS; + + const poll = async () => { + while (!cancelled) { + if (Date.now() > deadline) { + setStatus('resume_timeout'); + setTimeout(() => completeAndExit(request), DISPLAY_DELAY_MS); + return; + } + + await new Promise((r) => setTimeout(r, RESUME_POLL_INTERVAL_MS)); + if (cancelled) return; + + let latest: SpendRequest | null; + try { + latest = await repository.getSpendRequest(requestId); + } catch { + continue; + } + if (cancelled || !latest) continue; + + setRequest(latest); + if (latest.status === 'requires_action') continue; + + if (latest.status === 'approved' || latest.status === 'succeeded') { + setStatus('success'); + } else { + setError( + `Spend request did not resolve after 3D Secure (status: ${latest.status})`, + ); + setStatus('error'); + } + setTimeout(() => completeAndExit(latest), DISPLAY_DELAY_MS); + return; + } + }; + + poll(); + return () => { + cancelled = true; + }; + }, [status, request, repository, completeAndExit]); + useInput((_, key) => { if ( key.return && @@ -105,13 +188,29 @@ export const CreateSpendRequest: React.FC = ({ return () => clearTimeout(timer); }, [status, countdown, completeAndExit]); + useEffect(() => { + if (status !== 'requires_action') return; + if (nextAction?.resolution === 'auto_resume') return; + if (countdown <= 0) { + completeAndExit(request); + return; + } + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [status, nextAction, countdown, completeAndExit, request]); + useEffect(() => { const create = async () => { try { const result = await repository.createSpendRequest(params); setRequest(result); - if (requestApproval) { + if (result.status === 'requires_action') { + setNextAction( + result.status_details?.requires_action?.next_action ?? null, + ); + setStatus('requires_action'); + } else if (requestApproval) { setStatus('waiting'); } else { setStatus('success'); @@ -198,6 +297,80 @@ export const CreateSpendRequest: React.FC = ({ ); } + if (status === 'requires_action') { + return ( + + ⚠ Action required before payment can proceed + + + ID: {request?.id} + + + Type: {nextAction?.type} + + {nextAction?.display_message} + + {nextAction?.action_url && ( + + + Open:{' '} + + {nextAction.action_url} + + + Press Enter to open in browser + Exiting in {countdown}s... + + )} + + + Complete this step, then create a new spend request. + + + + ); + } + + if (status === 'resuming') { + return ( + + + Waiting for 3D Secure verification to + complete... + + + {nextAction?.display_message} + {nextAction?.action_url && ( + + URL: {nextAction.action_url} + + )} + + + ); + } + + if (status === 'resume_timeout') { + return ( + + + ✗ Timed out waiting for 3D Secure verification to resolve + + + Run `spend-request retrieve {request?.id}` to check the current + status. + + + ); + } + if (status === 'creating') { return ( diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index c3bd3d6..3b640c9 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -31,6 +31,24 @@ import { } from './schema'; import { UpdateSpendRequest } from './update'; +function buildRequiresActionResult(request: SpendRequest) { + const nextAction = request.status_details?.requires_action?.next_action; + const isAutoResume = nextAction?.resolution === 'auto_resume'; + + return { + ...request, + instruction: isAutoResume + ? `The spend request requires 3D Secure verification. Present action_url (${nextAction?.action_url}) to the user, then call \`spend-request retrieve ${request.id} --interval 2 --max-attempts 300\` to poll until it resolves. Do not create a new spend request — this one resumes automatically once the challenge is completed.` + : `The spend request requires action (${nextAction?.type}): ${nextAction?.display_message}${nextAction?.action_url ? ` URL: ${nextAction.action_url}` : ''} Have the user complete this, then create a new spend request.`, + _next: isAutoResume + ? { + command: `spend-request retrieve ${request.id} --interval 2 --max-attempts 300`, + until: 'status changes from requires_action', + } + : undefined, + }; +} + async function applyOutputFile( request: SpendRequest, outputFile: string | undefined, @@ -326,6 +344,10 @@ export function createSpendRequestCli( } throw err; } + if (created.status === 'requires_action') { + yield buildRequiresActionResult(created); + return; + } if (!requestApproval) { try { yield await applyOutputFile(created, outputFile, forceOverwrite); @@ -523,9 +545,22 @@ export function createSpendRequestCli( 'canceled', ]); + // `requires_action` stops polling unless resolution is `auto_resume` + // (e.g. 3D Secure), which resolves on its own — keep polling through it. + const isPollTerminal = (req: SpendRequest): boolean => { + if (terminalStatuses.has(req.status)) return true; + if (req.status === 'requires_action') { + return ( + req.status_details?.requires_action?.next_action?.resolution !== + 'auto_resume' + ); + } + return false; + }; + for await (const result of pollUntil({ fn: () => repository.getSpendRequest(id, { include }), - isTerminal: (req) => req === null || terminalStatuses.has(req.status), + isTerminal: (req) => req === null || isPollTerminal(req), interval, maxAttempts, timeout, @@ -538,6 +573,11 @@ export function createSpendRequestCli( } if (result.terminal) { + if (result.value.status === 'requires_action' && !result.reason) { + yield buildRequiresActionResult(result.value); + return; + } + // Terminal due to isTerminal or interval <= 0 — apply output file if (terminalStatuses.has(result.value.status) || !result.reason) { try { diff --git a/packages/cli/src/commands/spend-request/list.tsx b/packages/cli/src/commands/spend-request/list.tsx index bb21e3d..a7ef2b1 100644 --- a/packages/cli/src/commands/spend-request/list.tsx +++ b/packages/cli/src/commands/spend-request/list.tsx @@ -75,7 +75,8 @@ export const SpendRequestList: React.FC = ({ const statusColor = sr.status === 'approved' ? 'green' - : sr.status === 'pending_approval' + : sr.status === 'pending_approval' || + sr.status === 'requires_action' ? 'yellow' : 'white'; const amount = diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 9dd8dc5..15e9f7b 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -1,4 +1,8 @@ -import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; +import type { + ISpendRequestResource, + NextAction, + SpendRequest, +} from '@stripe/link-sdk'; import { LinkApiError } from '@stripe/link-sdk'; import { Box, Text, useApp, useInput } from 'ink'; import Spinner from 'ink-spinner'; @@ -38,6 +42,7 @@ export const RequestApproval: React.FC = ({ | 'error' | 'verification_required' | 'opened' + | 'requires_action' >('requesting'); const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); @@ -45,9 +50,14 @@ export const RequestApproval: React.FC = ({ const [verificationUrl, setVerificationUrl] = useState(''); const [supportUrl, setSupportUrl] = useState(''); const [countdown, setCountdown] = useState(30); + const [nextAction, setNextAction] = useState(null); const onSuccess = useCallback((r: SpendRequest) => setResult(r), []); const onError = useCallback((msg: string) => setError(msg), []); + const onRequiresAction = useCallback((r: SpendRequest) => { + setResult(r); + setNextAction(r.status_details?.requires_action?.next_action ?? null); + }, []); useApprovalPolling({ status, @@ -58,6 +68,7 @@ export const RequestApproval: React.FC = ({ onComplete: completeAndExit, onSuccess, onError, + onRequiresAction, }); useInput((_, key) => { @@ -154,6 +165,25 @@ export const RequestApproval: React.FC = ({ ); } + if (status === 'requires_action') { + return ( + + ⚠ Action required before payment can proceed + + + ID: {result?.id} + + {nextAction?.display_message} + {nextAction?.action_url && ( + + URL: {nextAction.action_url} + + )} + + + ); + } + if (status === 'requesting') { return ( diff --git a/packages/cli/src/commands/spend-request/retrieve.tsx b/packages/cli/src/commands/spend-request/retrieve.tsx index 1ff41c5..bd5dd6e 100644 --- a/packages/cli/src/commands/spend-request/retrieve.tsx +++ b/packages/cli/src/commands/spend-request/retrieve.tsx @@ -22,6 +22,7 @@ type Phase = | 'success' | 'declined' | 'finalized' + | 'requires_action' | 'timeout' | 'error'; @@ -37,6 +38,15 @@ const TERMINAL_STATUSES: ReadonlySet = new Set([ 'canceled', ]); +// `requires_action` with an `auto_resume` resolution (e.g. 3D Secure) will +// resolve on its own — keep polling through it rather than stopping. +function isAutoResume(request: SpendRequest): boolean { + return ( + request.status_details?.requires_action?.next_action?.resolution === + 'auto_resume' + ); +} + export const RetrieveSpendRequest: React.FC = ({ repository, id, @@ -100,6 +110,12 @@ export const RetrieveSpendRequest: React.FC = ({ } else if (result.status === 'denied') { setPhase('declined'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + } else if ( + result.status === 'requires_action' && + !isAutoResume(result) + ) { + setPhase('requires_action'); + setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); } else if (TERMINAL_STATUSES.has(result.status)) { setPhase('finalized'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); @@ -152,6 +168,14 @@ export const RetrieveSpendRequest: React.FC = ({ if (timerRef.current) clearInterval(timerRef.current); setPhase('declined'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + } else if ( + result.status === 'requires_action' && + !isAutoResume(result) + ) { + if (pollRef.current) clearInterval(pollRef.current); + if (timerRef.current) clearInterval(timerRef.current); + setPhase('requires_action'); + setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); } else if (TERMINAL_STATUSES.has(result.status)) { if (pollRef.current) clearInterval(pollRef.current); if (timerRef.current) clearInterval(timerRef.current); @@ -208,19 +232,38 @@ export const RetrieveSpendRequest: React.FC = ({ } if (phase === 'polling') { + const resumingNextAction = + request?.status === 'requires_action' + ? request.status_details?.requires_action?.next_action + : undefined; return ( - Awaiting approval... ({elapsed}s elapsed) + {' '} + {resumingNextAction + ? 'Waiting for 3D Secure verification to complete...' + : 'Awaiting approval...'}{' '} + ({elapsed}s elapsed) - {request?.approval_url && ( - - - Approval URL: {request.approval_url} - + {resumingNextAction ? ( + + {resumingNextAction.display_message} + {resumingNextAction.action_url && ( + + URL: {resumingNextAction.action_url} + + )} + ) : ( + request?.approval_url && ( + + + Approval URL: {request.approval_url} + + + ) )} ); @@ -309,6 +352,34 @@ export const RetrieveSpendRequest: React.FC = ({ ); } + if (phase === 'requires_action') { + const nextAction = request?.status_details?.requires_action?.next_action; + return ( + + ⚠ Action required before payment can proceed + + + ID: {request?.id} + + + Type: {nextAction?.type} + + {nextAction?.display_message} + {nextAction?.action_url && ( + + URL: {nextAction.action_url} + + )} + + + + Complete this step, then create a new spend request. + + + + ); + } + if (phase === 'declined') { return ( diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index 69f9dd2..a1a04a5 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -66,7 +66,9 @@ export const createOptions = z.object({ requestApproval: z .boolean() .default(true) - .describe('Request approval and poll until approved/denied/expired'), + .describe( + 'Request approval and poll until approved/denied/expired, or until requires_action with a non-auto_resume resolution', + ), test: z .boolean() .default(false) diff --git a/packages/cli/src/commands/spend-request/use-approval-polling.ts b/packages/cli/src/commands/spend-request/use-approval-polling.ts index 599a3dd..5283f7f 100644 --- a/packages/cli/src/commands/spend-request/use-approval-polling.ts +++ b/packages/cli/src/commands/spend-request/use-approval-polling.ts @@ -5,17 +5,23 @@ import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { openUrl } from '../../utils/open-url'; import { pollUntilApproved } from '../../utils/poll-until-approved'; -export type ApprovalStatus = 'waiting' | 'polling' | 'success' | 'error'; +export type ApprovalStatus = + | 'waiting' + | 'polling' + | 'success' + | 'error' + | 'requires_action'; interface UseApprovalPollingOptions { status: string; - setStatus: (s: 'polling' | 'success' | 'error') => void; + setStatus: (s: 'polling' | 'success' | 'error' | 'requires_action') => void; approvalUrl: string; repository: ISpendRequestResource; requestId: string | null; onComplete: (result: SpendRequest) => void; onSuccess: (result: SpendRequest) => void; onError: (msg: string) => void; + onRequiresAction: (result: SpendRequest) => void; } export function useApprovalPolling({ @@ -27,6 +33,7 @@ export function useApprovalPolling({ onComplete, onSuccess, onError, + onRequiresAction, }: UseApprovalPollingOptions): void { const isWaiting = status === 'waiting' || status === 'polling'; @@ -52,6 +59,12 @@ export function useApprovalPolling({ try { const final = await pollUntilApproved(repository, requestId); if (cancelled) return; + if (final.status === 'requires_action') { + onRequiresAction(final); + setStatus('requires_action'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + return; + } if (final.status !== 'approved') { onError( `Spend request did not reach approved (status: ${final.status})`, @@ -82,6 +95,7 @@ export function useApprovalPolling({ onComplete, onSuccess, onError, + onRequiresAction, setStatus, ]); } diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index fd7ccee..2d45e7b 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -3,3 +3,10 @@ * a success or error message, giving the user time to read it. */ export const DISPLAY_DELAY_MS = 1500; + +/** + * Interval and max wait time for polling a spend request stuck in + * `requires_action` with an `auto_resume` resolution (e.g. 3D Secure). + */ +export const RESUME_POLL_INTERVAL_MS = 2000; +export const RESUME_TIMEOUT_MS = 600_000; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 415a185..8335a1b 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -73,7 +73,39 @@ export type SpendRequestStatus = | 'denied' | 'succeeded' | 'failed' - | 'canceled'; + | 'canceled' + | 'requires_action'; + +export type NextActionType = + | 'ssn_verification' + | 'identity_verification' + | 'contact_support' + | 'select_payment_method' + | 'add_payment_method' + | 'update_payment_method' + | 're_authorize' + | 'three_d_secure' + | 'three_d_secure_retry'; + +export type NextActionResolution = + | 'auto_resume' + | 'create_new_spend_request' + | 'create_new_spend_request_after_completion'; + +export interface NextAction { + type: NextActionType; + resolution: NextActionResolution; + display_message: string; + action_url: string | null; + expires_at?: number | null; +} + +export interface SpendRequestStatusDetails { + requires_action?: { + failure_code?: string; + next_action: NextAction; + }; +} export type CredentialType = 'shared_payment_token' | 'card'; @@ -138,6 +170,7 @@ export interface SpendRequest { shared_payment_token?: SharedPaymentToken; link_pay_token?: string; payment_status_details?: PaymentStatusDetails | null; + status_details?: SpendRequestStatusDetails | null; link_transaction_id?: string; activity_url?: string; metadata?: Record; diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index bf150a6..58d954b 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -185,6 +185,12 @@ Recommend the user approves with the [Link app](https://link.com/download). Show **Metadata:** Attach arbitrary string data with the repeatable `--metadata "key:value"` flag (CLI) or a `{ key: value }` object (MCP/agent). Max 50 keys, key ≤ 40 chars, value ≤ 500 chars. Example: `--metadata "order_id:ord_123" --metadata "team:growth"`. +If the response has `status: "requires_action"`, read `status_details.requires_action.next_action` (`type`, `display_message`, `action_url`, `resolution`). Show `display_message` to the user; present `action_url` clearly if present. +- If `resolution` is `auto_resume` (currently only `three_d_secure`), run the returned `_next.command` (poll `spend-request retrieve --interval 2 --max-attempts 300`) yourself — do not create a new spend request. The same request resumes to `approved`/`succeeded` once the user completes the bank's challenge. +- Otherwise (`resolution` is `create_new_spend_request` or `create_new_spend_request_after_completion` — covers `ssn_verification`, `identity_verification`, `contact_support`, `select_payment_method`, `add_payment_method`, `update_payment_method`, `re_authorize`, `three_d_secure_retry`), have the user complete the indicated action, then create a **new** spend request — the old one will expire on its own. + +This same `requires_action` status can also appear later from `spend-request retrieve` in Step 5 — `update_payment_method`, `re_authorize`, and `three_d_secure_retry` only ever surface this way, and they all use `create_new_spend_request`. Apply the same `resolution`-based branching there. + ### Step 5: Complete payment **Card:** Run `link-cli spend-request retrieve --include card` to get the `card` object with `number`, `cvc`, `exp_month`, `exp_year`, `billing_address` (name, line1, line2, city, state, postal_code, country), and `valid_until` (Unix timestamp — the card stops working after this time). Enter these details into the merchant's checkout form. @@ -343,6 +349,7 @@ All errors are output as JSON with `code` and `message` fields, with exit code 1 | 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, was canceled, or its shared payment token was already consumed | +| `spend-request create` or `spend-request retrieve` returns `status: "requires_action"` | Payment method, identity verification, or authorization issue requires action before the request can proceed | Read `next_action.type`/`resolution`/`display_message`. If `resolution` is `auto_resume`, poll `spend-request retrieve` (via the returned `_next.command`) until resolved. Otherwise complete the indicated action, then create a new spend request | ## Reporting outcomes