diff --git a/.changeset/rare-numbers-invent.md b/.changeset/rare-numbers-invent.md new file mode 100644 index 0000000..29075cf --- /dev/null +++ b/.changeset/rare-numbers-invent.md @@ -0,0 +1,5 @@ +--- +"@stripe/link-cli": minor +--- + +Surface duplicate spend request error messaging in interactive mode diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 0f8fbd6..b1d8e8c 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -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; + 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', () => { 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 0c7831b..096e3e4 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 @@ -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( + {}} + />, + ); + + 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.', diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 74892d4..2d6005d 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -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'; @@ -44,6 +46,9 @@ export const CreateSpendRequest: React.FC = ({ | 'opened' >('creating'); const [request, setRequest] = useState(null); + const [duplicateRequest, setDuplicateRequest] = useState( + null, + ); const [error, setError] = useState(''); const [verificationUrl, setVerificationUrl] = useState(''); const [supportUrl, setSupportUrl] = useState(''); @@ -129,6 +134,8 @@ export const CreateSpendRequest: React.FC = ({ setStatus('verification_required'); return; } + const duplicate = getDuplicateSpendRequest(err); + if (duplicate) setDuplicateRequest(sanitizeDeep(duplicate)); } setStatus('error'); setTimeout(() => completeAndExit(null), DISPLAY_DELAY_MS); @@ -206,6 +213,54 @@ export const CreateSpendRequest: React.FC = ({ ✗ Failed to create spend request {error} + {duplicateRequest && ( + + + A matching spend request already exists + + + + ID: {duplicateRequest.id} + + + Status: {duplicateRequest.status} + + + Amount:{' '} + + {duplicateRequest.amount != null + ? formatAmount( + duplicateRequest.amount, + duplicateRequest.currency ?? '', + ) + : 'N/A'} + + + + Merchant: {duplicateRequest.merchant_name} + + + {duplicateRequest.status !== 'expired' && + duplicateRequest.status !== 'canceled' && + duplicateRequest.status !== 'failed' && ( + <> + + {'\n'}Retrieve it to resume instead of creating a new one: + + + spend-request retrieve {duplicateRequest.id} + + + )} + + )} ); } @@ -225,7 +280,7 @@ export const CreateSpendRequest: React.FC = ({ Amount:{' '} {request?.amount != null - ? `${request.amount} ${request.currency?.toUpperCase() ?? ''}`.trim() + ? formatAmount(request.amount, request.currency ?? '') : 'N/A'} diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 8224f47..cee587b 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -1,4 +1,4 @@ -import { LinkApiError } from '@stripe/link-sdk'; +import { LinkApiError, getDuplicateSpendRequest } from '@stripe/link-sdk'; import type { AuthStorage, CredentialType, @@ -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; } diff --git a/packages/cli/src/commands/transactions/list.tsx b/packages/cli/src/commands/transactions/list.tsx index 1c32c91..a5bcaa3 100644 --- a/packages/cli/src/commands/transactions/list.tsx +++ b/packages/cli/src/commands/transactions/list.tsx @@ -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; @@ -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; diff --git a/packages/cli/src/utils/format-amount.ts b/packages/cli/src/utils/format-amount.ts new file mode 100644 index 0000000..948cf3a --- /dev/null +++ b/packages/cli/src/utils/format-amount.ts @@ -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}`; + } +} diff --git a/packages/sdk/src/resources/__tests__/spend-request.test.ts b/packages/sdk/src/resources/__tests__/spend-request.test.ts index 2c9bbd6..d7a9eaa 100644 --- a/packages/sdk/src/resources/__tests__/spend-request.test.ts +++ b/packages/sdk/src/resources/__tests__/spend-request.test.ts @@ -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(); @@ -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(); + }); +}); diff --git a/packages/sdk/src/resources/spend-request.ts b/packages/sdk/src/resources/spend-request.ts index fed08fc..7e84e3a 100644 --- a/packages/sdk/src/resources/spend-request.ts +++ b/packages/sdk/src/resources/spend-request.ts @@ -38,6 +38,20 @@ function normalizeSpendRequest(data: unknown): SpendRequest { return sr; } +/** + * Extracts the conflicting spend request returned alongside a + * `spend_request_rate_limited` (429) error. + */ +export function getDuplicateSpendRequest(error: unknown): SpendRequest | null { + if (!(error instanceof LinkApiError)) return null; + const details = error.details as + | { error?: { duplicate_spend_request?: unknown } } + | undefined; + const duplicate = details?.error?.duplicate_spend_request; + if (!duplicate || typeof duplicate !== 'object') return null; + return normalizeSpendRequest(duplicate); +} + function extractApiError(data: unknown, rawBody: string): string { if (data && typeof data === 'object') { const body = data as Record;