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
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<CreateSpendRequest
repository={repo}
params={{
payment_details: 'pm_1',
amount: 200100,
currency: 'usd',
merchant_name: 'Stripe Press',
merchant_url: 'https://press.stripe.com',
context: 'x'.repeat(100),
}}
onComplete={() => {}}
/>,
);

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(
<RequestApproval
repository={repo}
id="sr_test"
onComplete={() => {}}
/>,
);

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();
Expand Down
31 changes: 26 additions & 5 deletions packages/cli/src/commands/spend-request/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,10 +36,20 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
>('creating');
const [request, setRequest] = useState<SpendRequest | null>(null);
const [error, setError] = useState<string>('');
const [verificationUrl, setVerificationUrl] = useState<string>('');
const [outputFilePath, setOutputFilePath] = useState<string | null>(null);
const [fileError, setFileError] = useState<string>('');

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),
Expand All @@ -52,7 +63,7 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
approvalUrl,
repository,
requestId: request?.id ?? null,
onComplete,
onComplete: completeAndExit,
onSuccess,
onError,
});
Expand All @@ -67,17 +78,22 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
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;
Expand Down Expand Up @@ -110,6 +126,11 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
<Box flexDirection="column">
<Text color="red">✗ Failed to create spend request</Text>
<Text color="red">{error}</Text>
{verificationUrl && (
<Text color="red">
Complete additional verification at: {verificationUrl}
</Text>
)}
</Box>
);
}
Expand Down
47 changes: 41 additions & 6 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LinkApiError } from '@stripe/link-sdk';
import type {
AuthStorage,
CredentialType,
Expand Down Expand Up @@ -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(
<CreateSpendRequest
repository={repository}
Expand All @@ -179,16 +180,33 @@ export function createSpendRequestCli(
}}
/>,
() => {
if (!capturedResult)
if (capturedResult === undefined)
throw new Error('Component exited without producing a result');
if (capturedResult === null) process.exit(1);
return capturedResult;
},
);
}

// 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);
Expand Down Expand Up @@ -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(
<RequestApproval
repository={repository}
Expand All @@ -287,16 +305,33 @@ export function createSpendRequestCli(
}}
/>,
() => {
if (!capturedResult)
if (capturedResult === undefined)
throw new Error('Component exited without producing a result');
if (capturedResult === null) process.exit(1);
return capturedResult;
},
);
}

// 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<ReturnType<typeof repository.requestApproval>>;
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.`,
Expand Down
34 changes: 30 additions & 4 deletions packages/cli/src/commands/spend-request/request-approval.tsx
Original file line number Diff line number Diff line change
@@ -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<RequestApprovalProps> = ({
Expand All @@ -23,6 +25,16 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
const [approvalUrl, setApprovalUrl] = useState<string>('');
const [result, setResult] = useState<SpendRequest | null>(null);
const [error, setError] = useState<string>('');
const [verificationUrl, setVerificationUrl] = useState<string>('');
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), []);
Expand All @@ -33,7 +45,7 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
approvalUrl,
repository,
requestId: id,
onComplete,
onComplete: completeAndExit,
onSuccess,
onError,
});
Expand All @@ -46,12 +58,21 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
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 (
Expand All @@ -68,6 +89,11 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
<Box flexDirection="column">
<Text color="red">✗ Failed to request approval</Text>
<Text color="red">{error}</Text>
{verificationUrl && (
<Text color="red">
Complete additional verification at: {verificationUrl}
</Text>
)}
</Box>
);
}
Expand Down
Loading