From facd58bf00a9a8ac0a91126ff1348fbf28a0c273 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Thu, 2 Jul 2026 19:14:52 -0400 Subject: [PATCH 1/2] feat: surface verification_url in spend-request error output When the API returns additional_verification_required, surface the verification_url so users know where to complete stepup verification. Also fixes interactive mode hanging on error by calling useApp().exit(). Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- .../__tests__/spend-request.test.tsx | 105 +++++++++++++++++- .../cli/src/commands/spend-request/create.tsx | 29 ++++- .../cli/src/commands/spend-request/index.tsx | 47 +++++++- .../spend-request/request-approval.tsx | 31 +++++- 4 files changed, 197 insertions(+), 15 deletions(-) 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 c8a0c9e..24f196c 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 @@ -1,8 +1,13 @@ -import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; +import { + type ISpendRequestResource, + LinkApiError, + type SpendRequest, +} from '@stripe/link-sdk'; import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; import { sanitizeResource } from '../../../utils/resource-factory'; import { CreateSpendRequest } from '../create'; +import { RequestApproval } from '../request-approval'; import { RetrieveSpendRequest } from '../retrieve'; import { UpdateSpendRequest } from '../update'; @@ -42,6 +47,104 @@ function makeMockRepo(result: SpendRequest) { } describe('spend-request', () => { + describe('verification_url', () => { + it('CreateSpendRequest surfaces verification_url on additional_verification_required error', async () => { + const error = new LinkApiError( + 'Consumer must complete additional verification before creating spend requests.', + { + status: 403, + code: 'additional_verification_required', + details: { + error: { + code: 'additional_verification_required', + message: + 'Consumer must complete additional verification before creating spend requests.', + verification_url: 'https://app.link.com/finish_setup', + requirements: [ + { + type: 'ssn_collection', + field: 'individual.id_number', + reason: 'spend_threshold_exceeded', + }, + ], + }, + }, + }, + ); + const repo = sanitizeResource({ + createSpendRequest: vi.fn(async () => { + throw error; + }), + getSpendRequest: vi.fn(), + updateSpendRequest: vi.fn(), + requestApproval: vi.fn(), + cancelSpendRequest: vi.fn(), + } as unknown as ISpendRequestResource); + + const { lastFrame } = render( + {}} + />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Failed to create spend request'); + expect(frame).toContain( + 'https://app.link.com/finish_setup', + ); + }); + }); + + it('RequestApproval surfaces verification_url on additional_verification_required error', async () => { + const error = new LinkApiError( + 'Consumer must complete additional verification before creating spend requests.', + { + status: 403, + code: 'additional_verification_required', + details: { + error: { + code: 'additional_verification_required', + message: + 'Consumer must complete additional verification before creating spend requests.', + verification_url: 'https://app.link.com/finish_setup', + }, + }, + }, + ); + const repo = sanitizeResource({ + createSpendRequest: vi.fn(), + getSpendRequest: vi.fn(), + updateSpendRequest: vi.fn(), + requestApproval: vi.fn(async () => { + throw error; + }), + cancelSpendRequest: vi.fn(), + } as unknown as ISpendRequestResource); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Failed to request approval'); + expect(frame).toContain( + 'https://app.link.com/finish_setup', + ); + }); + }); + }); + describe('sanitization', () => { it('CreateSpendRequest sanitizes merchant_name and line_items', async () => { const request = makeSpendRequest(); diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 09dabb0..3805fcd 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -3,7 +3,8 @@ import type { ISpendRequestResource, SpendRequest, } from '@stripe/link-sdk'; -import { Box, Text } from 'ink'; +import { LinkApiError } from '@stripe/link-sdk'; +import { Box, Text, useApp } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; @@ -35,10 +36,20 @@ export const CreateSpendRequest: React.FC = ({ >('creating'); const [request, setRequest] = useState(null); const [error, setError] = useState(''); + const [verificationUrl, setVerificationUrl] = useState(''); const [outputFilePath, setOutputFilePath] = useState(null); const [fileError, setFileError] = useState(''); const approvalUrl = request?.approval_url ?? ''; + const { exit } = useApp(); + + const completeAndExit = useCallback( + (result: SpendRequest | null) => { + onComplete(result); + exit(); + }, + [onComplete, exit], + ); const onSuccess = useCallback( (result: SpendRequest) => setRequest(result), @@ -52,7 +63,7 @@ export const CreateSpendRequest: React.FC = ({ approvalUrl, repository, requestId: request?.id ?? null, - onComplete, + onComplete: completeAndExit, onSuccess, onError, }); @@ -67,12 +78,17 @@ export const CreateSpendRequest: React.FC = ({ setStatus('waiting'); } else { setStatus('success'); - setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + setTimeout(() => completeAndExit(result), DISPLAY_DELAY_MS); } } catch (err) { setError((err as Error).message); + if (err instanceof LinkApiError) { + const url = (err.details as { error?: { verification_url?: string } }) + ?.error?.verification_url; + if (url) setVerificationUrl(url); + } setStatus('error'); - setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + setTimeout(() => completeAndExit(null), DISPLAY_DELAY_MS); } }; @@ -110,6 +126,11 @@ export const CreateSpendRequest: React.FC = ({ ✗ Failed to create spend request {error} + {verificationUrl && ( + + Complete additional verification at: {verificationUrl} + + )} ); } diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 6223c43..965bf35 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -1,3 +1,4 @@ +import { LinkApiError } from '@stripe/link-sdk'; import type { AuthStorage, CredentialType, @@ -166,7 +167,7 @@ export function createSpendRequestCli( const forceOverwrite = opts.force; if (!c.agent && !c.formatExplicit) { - let capturedResult: SpendRequest | null = null; + let capturedResult: SpendRequest | null | undefined = undefined; return renderInteractive( , () => { - if (!capturedResult) + if (capturedResult === undefined) throw new Error('Component exited without producing a result'); + if (capturedResult === null) process.exit(1); return capturedResult; }, ); @@ -188,7 +190,23 @@ export function createSpendRequestCli( // Agent mode: create, return immediately with _next polling hint. // The agent drives the polling loop via `spend-request retrieve`. - const created = await repository.createSpendRequest(createParams); + let created: SpendRequest; + try { + created = await repository.createSpendRequest(createParams); + } catch (err) { + if (err instanceof LinkApiError) { + const apiErr = err.details as { + error?: { code?: string; verification_url?: string }; + }; + if (apiErr?.error?.verification_url) { + return c.error({ + code: err.code, + message: `${err.message} Verification URL: ${apiErr.error.verification_url}`, + }); + } + } + throw err; + } if (!requestApproval) { try { yield await applyOutputFile(created, outputFile, forceOverwrite); @@ -277,7 +295,7 @@ export function createSpendRequestCli( const id = c.args.id; if (!c.agent && !c.formatExplicit) { - let capturedResult: SpendRequest | null = null; + let capturedResult: SpendRequest | null | undefined = undefined; return renderInteractive( , () => { - if (!capturedResult) + if (capturedResult === undefined) throw new Error('Component exited without producing a result'); + if (capturedResult === null) process.exit(1); return capturedResult; }, ); @@ -296,7 +315,23 @@ export function createSpendRequestCli( // Agent mode: request approval, return immediately with _next polling hint. // The agent drives the polling loop via `spend-request retrieve`. - const approval = await repository.requestApproval(id); + let approval: Awaited>; + try { + approval = await repository.requestApproval(id); + } catch (err) { + if (err instanceof LinkApiError) { + const apiErr = err.details as { + error?: { code?: string; verification_url?: string }; + }; + if (apiErr?.error?.verification_url) { + return c.error({ + code: err.code, + message: `${err.message} Verification URL: ${apiErr.error.verification_url}`, + }); + } + } + throw err; + } yield { ...approval, instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${id} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 3c0415c..1346583 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -1,15 +1,17 @@ +import { LinkApiError } from '@stripe/link-sdk'; import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; -import { Box, Text } from 'ink'; +import { Box, Text, useApp } 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 { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; interface RequestApprovalProps { repository: ISpendRequestResource; id: string; - onComplete: (result: SpendRequest) => void; + onComplete: (result: SpendRequest | null) => void; } export const RequestApproval: React.FC = ({ @@ -23,6 +25,16 @@ export const RequestApproval: React.FC = ({ const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); const [error, setError] = useState(''); + const [verificationUrl, setVerificationUrl] = useState(''); + const { exit } = useApp(); + + const completeAndExit = useCallback( + (result: SpendRequest) => { + onComplete(result); + exit(); + }, + [onComplete, exit], + ); const onSuccess = useCallback((r: SpendRequest) => setResult(r), []); const onError = useCallback((msg: string) => setError(msg), []); @@ -33,7 +45,7 @@ export const RequestApproval: React.FC = ({ approvalUrl, repository, requestId: id, - onComplete, + onComplete: completeAndExit, onSuccess, onError, }); @@ -46,12 +58,18 @@ export const RequestApproval: React.FC = ({ setStatus('waiting'); } catch (err) { setError((err as Error).message); + if (err instanceof LinkApiError) { + const url = (err.details as { error?: { verification_url?: string } }) + ?.error?.verification_url; + if (url) setVerificationUrl(url); + } setStatus('error'); + setTimeout(() => { onComplete(null); exit(); }, DISPLAY_DELAY_MS); } }; request(); - }, [repository, id]); + }, [repository, id, exit]); if (status === 'requesting') { return ( @@ -68,6 +86,11 @@ export const RequestApproval: React.FC = ({ ✗ Failed to request approval {error} + {verificationUrl && ( + + Complete additional verification at: {verificationUrl} + + )} ); } From 7c6b2cb57f42c5f4d857a7b227643568648b7b28 Mon Sep 17 00:00:00 2001 From: System Administrator Date: Tue, 7 Jul 2026 10:15:56 -0400 Subject: [PATCH 2/2] fix: resolve biome lint and format errors in spend-request commands Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- .../spend-request/__tests__/spend-request.test.tsx | 14 +++++++------- packages/cli/src/commands/spend-request/create.tsx | 2 +- .../commands/spend-request/request-approval.tsx | 7 +++++-- 3 files changed, 13 insertions(+), 10 deletions(-) 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 24f196c..bc9c7fc 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 @@ -99,9 +99,7 @@ describe('spend-request', () => { await vi.waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Failed to create spend request'); - expect(frame).toContain( - 'https://app.link.com/finish_setup', - ); + expect(frame).toContain('https://app.link.com/finish_setup'); }); }); @@ -132,15 +130,17 @@ describe('spend-request', () => { } as unknown as ISpendRequestResource); const { lastFrame } = render( - {}} />, + {}} + />, ); await vi.waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Failed to request approval'); - expect(frame).toContain( - 'https://app.link.com/finish_setup', - ); + expect(frame).toContain('https://app.link.com/finish_setup'); }); }); }); diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 3805fcd..38c3c1a 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -93,7 +93,7 @@ export const CreateSpendRequest: React.FC = ({ }; create(); - }, [repository, params, requestApproval, onComplete]); + }, [repository, params, requestApproval, completeAndExit]); useEffect(() => { if (status !== 'success' || !outputFile || !request?.card) return; diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 1346583..e440e06 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -64,12 +64,15 @@ export const RequestApproval: React.FC = ({ if (url) setVerificationUrl(url); } setStatus('error'); - setTimeout(() => { onComplete(null); exit(); }, DISPLAY_DELAY_MS); + setTimeout(() => { + onComplete(null); + exit(); + }, DISPLAY_DELAY_MS); } }; request(); - }, [repository, id, exit]); + }, [repository, id, exit, onComplete]); if (status === 'requesting') { return (