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
5 changes: 5 additions & 0 deletions .changeset/rare-numbers-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stripe/link-cli": minor
---

Surface duplicate spend request error messaging in interactive mode
38 changes: 38 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,44 @@ describe('production mode', () => {
const output = result.stdout + result.stderr;
expect(output).toContain('Invalid payment details');
});

it('surfaces the duplicate spend request on spend_request_rate_limited error', async () => {
setNextResponse(429, {
error: {
code: 'spend_request_rate_limited',
message:
'You cannot submit duplicate spend requests within a short period of time. Please try again later.',
retry_after: 1699999999,
duplicate_spend_request: {
...BASE_REQUEST,
id: 'lsrq_duplicate',
status: 'created',
},
},
});

const result = await runProdCli(
'spend-request',
'create',
'--payment-method-id',
'pd_prod_test',
'-m',
'Test Merchant',
'--merchant-url',
'https://example.com',
'--context',
VALID_CONTEXT,
'--amount',
'5000',
'--json',
);

expect(result.exitCode).toBe(1);
const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.code).toBe('spend_request_rate_limited');
expect(String(output.message)).toContain('lsrq_duplicate');
expect(String(output.message)).toContain('created');
});
});

describe('spend-request update', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,72 @@ describe('spend-request', () => {
});
});

it('CreateSpendRequest surfaces the duplicate spend request on spend_request_rate_limited error', async () => {
const error = new LinkApiError(
'Failed to create spend request (429): You cannot submit duplicate spend requests within a short period of time.',
{
status: 429,
code: 'api_error',
details: {
error: {
code: 'spend_request_rate_limited',
message:
'You cannot submit duplicate spend requests within a short period of time.',
retry_after: 1699999999,
duplicate_spend_request: {
id: 'sr_duplicate',
status: 'created',
amount: 5000,
currency: 'usd',
merchant_name: ESCAPE_PAYLOAD,
context: 'x'.repeat(100),
payment_details: 'pm_1',
line_items: [],
totals: [],
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
},
},
},
);
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: 5000,
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('A matching spend request already exists');
expect(frame).toContain('sr_duplicate');
expect(frame).toContain('spend-request retrieve sr_duplicate');
// Duplicate fields are sanitized before rendering.
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
});
});

it('RequestApproval surfaces verification_url on additional_verification_required error', async () => {
const error = new LinkApiError(
'Consumer must complete additional verification before creating spend requests.',
Expand Down
59 changes: 57 additions & 2 deletions packages/cli/src/commands/spend-request/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import type {
ISpendRequestResource,
SpendRequest,
} from '@stripe/link-sdk';
import { LinkApiError } from '@stripe/link-sdk';
import { LinkApiError, getDuplicateSpendRequest } from '@stripe/link-sdk';
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 { writeCredentialFile } from '../../utils/credential-output';
import { formatAmount } from '../../utils/format-amount';
import { openUrl } from '../../utils/open-url';
import { sanitizeDeep } from '../../utils/sanitize-text';
import { AppDownloadQrCodes } from './app-download-qr-codes';
import { ApprovalWaitingView } from './approval-waiting-view';
import { useApprovalPolling } from './use-approval-polling';
Expand Down Expand Up @@ -44,6 +46,9 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
| 'opened'
>('creating');
const [request, setRequest] = useState<SpendRequest | null>(null);
const [duplicateRequest, setDuplicateRequest] = useState<SpendRequest | null>(
null,
);
const [error, setError] = useState<string>('');
const [verificationUrl, setVerificationUrl] = useState<string>('');
const [supportUrl, setSupportUrl] = useState<string>('');
Expand Down Expand Up @@ -129,6 +134,8 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
setStatus('verification_required');
return;
}
const duplicate = getDuplicateSpendRequest(err);
if (duplicate) setDuplicateRequest(sanitizeDeep(duplicate));
}
setStatus('error');
setTimeout(() => completeAndExit(null), DISPLAY_DELAY_MS);
Expand Down Expand Up @@ -206,6 +213,54 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
<Box flexDirection="column">
<Text color="red">✗ Failed to create spend request</Text>
<Text color="red">{error}</Text>
{duplicateRequest && (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
paddingX={2}
paddingY={1}
marginTop={1}
>
<Text bold color="yellow">
A matching spend request already exists
</Text>
<Box flexDirection="column" marginTop={1}>
<Text>
ID: <Text bold>{duplicateRequest.id}</Text>
</Text>
<Text>
Status: <Text bold>{duplicateRequest.status}</Text>
</Text>
<Text>
Amount:{' '}
<Text bold>
{duplicateRequest.amount != null
? formatAmount(
duplicateRequest.amount,
duplicateRequest.currency ?? '',
)
: 'N/A'}
</Text>
</Text>
<Text>
Merchant: <Text bold>{duplicateRequest.merchant_name}</Text>
</Text>
</Box>
{duplicateRequest.status !== 'expired' &&
duplicateRequest.status !== 'canceled' &&
duplicateRequest.status !== 'failed' && (
<>
<Text dimColor>
{'\n'}Retrieve it to resume instead of creating a new one:
</Text>
<Text color="cyan">
spend-request retrieve {duplicateRequest.id}
</Text>
</>
)}
</Box>
)}
</Box>
);
}
Expand All @@ -225,7 +280,7 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
Amount:{' '}
<Text bold>
{request?.amount != null
? `${request.amount} ${request.currency?.toUpperCase() ?? ''}`.trim()
? formatAmount(request.amount, request.currency ?? '')
: 'N/A'}
</Text>
</Text>
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LinkApiError } from '@stripe/link-sdk';
import { LinkApiError, getDuplicateSpendRequest } from '@stripe/link-sdk';
import type {
AuthStorage,
CredentialType,
Expand Down Expand Up @@ -239,6 +239,24 @@ export function createSpendRequestCli(
message: `${err.message} Support URL: ${apiErr.error.support_url}`,
});
}
const duplicate = getDuplicateSpendRequest(err);
if (duplicate) {
return c.error({
code: apiErr?.error?.code ?? 'spend_request_rate_limited',
message: `${err.message} A matching spend request already exists: ${duplicate.id} (status: ${duplicate.status}). Retrieve it to resume instead of creating a new one.`,
cta: {
description:
'Retrieve the conflicting spend request to inspect its status and resume it if valid.',
commands: [
{
command: `spend-request retrieve ${duplicate.id}`,
description:
'Retrieve the conflicting spend request to resume it',
},
],
},
});
}
}
throw err;
}
Expand Down
17 changes: 1 addition & 16 deletions packages/cli/src/commands/transactions/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Spinner from 'ink-spinner';
import type React from 'react';
import { useCallback } from 'react';
import { useAsyncAction } from '../../hooks/use-async-action';
import { formatAmount } from '../../utils/format-amount';

interface TransactionsListProps {
resource: ITransactionsResource;
Expand All @@ -23,22 +24,6 @@ const CATEGORY_WIDTH = 16;
const MIN_DESCRIPTION_WIDTH = 16;
const HORIZONTAL_PADDING = 4;

function formatAmount(amount: number, currency: string): string {
const currencyCode = currency.toUpperCase();

try {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
});
const fractionDigits =
formatter.resolvedOptions().maximumFractionDigits ?? 2;
return formatter.format(amount / 10 ** fractionDigits);
} catch {
return `${amount} ${currency}`;
}
}

function truncateCell(value: string, width: number): string {
if (value.length <= width) {
return value;
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/utils/format-amount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Format a minor-unit amount (e.g. cents) into a human-readable major-unit
* currency string. For example, 5 with currency "usd" becomes "$0.05".
*
* Falls back to `${amount} ${currency}` for unknown currency codes.
*/
export function formatAmount(amount: number, currency: string): string {
const currencyCode = currency.toUpperCase();

try {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
});
const fractionDigits =
formatter.resolvedOptions().maximumFractionDigits ?? 2;
return formatter.format(amount / 10 ** fractionDigits);
} catch {
return `${amount} ${currency}`;
}
}
74 changes: 73 additions & 1 deletion packages/sdk/src/resources/__tests__/spend-request.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { LinkApiError } from '@/errors';
import type { CreateSpendRequestParams } from '@/resources/interfaces';
import { SpendRequestResource } from '@/resources/spend-request';
import {
SpendRequestResource,
getDuplicateSpendRequest,
} from '@/resources/spend-request';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockFetch = vi.fn();
Expand Down Expand Up @@ -532,3 +536,71 @@ describe('SpendRequestResource', () => {
});
});
});

describe('getDuplicateSpendRequest', () => {
const duplicate = {
id: 'si_duplicate',
status: 'created',
amount: 5000,
currency: 'usd',
merchant_name: 'Test Store',
context: 'buying a widget',
payment_details: 'pd_test123',
line_items: [],
totals: [],
created_at: '2026-03-10T00:00:00Z',
updated_at: '2026-03-10T00:00:00Z',
};

it('extracts the duplicate spend request from a rate-limited error', () => {
const err = new LinkApiError('Failed to create spend request (429): ...', {
status: 429,
details: {
error: {
code: 'spend_request_rate_limited',
message: 'You cannot submit duplicate spend requests...',
retry_after: 1699999999,
duplicate_spend_request: duplicate,
},
},
});

expect(getDuplicateSpendRequest(err)).toEqual(duplicate);
});

it('normalizes a legacy string shared_payment_token on the duplicate', () => {
const err = new LinkApiError('Failed to create spend request (429): ...', {
status: 429,
details: {
error: {
code: 'spend_request_rate_limited',
duplicate_spend_request: {
...duplicate,
credential_type: 'shared_payment_token',
shared_payment_token: 'spt_legacy',
},
},
},
});

expect(getDuplicateSpendRequest(err)?.shared_payment_token).toEqual({
id: 'spt_legacy',
});
});

it('returns null when the error carries no duplicate', () => {
const err = new LinkApiError('Failed to create spend request (429): ...', {
status: 429,
details: {
error: { code: 'spend_request_rate_limited', retry_after: 123 },
},
});

expect(getDuplicateSpendRequest(err)).toBeNull();
});

it('returns null for a non-LinkApiError', () => {
expect(getDuplicateSpendRequest(new Error('boom'))).toBeNull();
expect(getDuplicateSpendRequest(undefined)).toBeNull();
});
});
Loading
Loading