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..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 @@ -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..38c3c1a 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,17 +78,22 @@ 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); } }; create(); - }, [repository, params, requestApproval, onComplete]); + }, [repository, params, requestApproval, completeAndExit]); useEffect(() => { if (status !== 'success' || !outputFile || !request?.card) return; @@ -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..e440e06 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,21 @@ 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, onComplete]); if (status === 'requesting') { return ( @@ -68,6 +89,11 @@ export const RequestApproval: React.FC = ({ ✗ Failed to request approval {error} + {verificationUrl && ( + + Complete additional verification at: {verificationUrl} + + )} ); }