diff --git a/.changeset/recurring-spend-request-terms.md b/.changeset/recurring-spend-request-terms.md new file mode 100644 index 00000000..7d794bfd --- /dev/null +++ b/.changeset/recurring-spend-request-terms.md @@ -0,0 +1,6 @@ +--- +'@stripe/link-sdk': minor +'@stripe/link-cli': minor +--- + +Spend requests now include their recurring terms (`recurring.interval` and `recurring.interval_count`) when set, and interactive spend request output shows them. diff --git a/CLAUDE.md b/CLAUDE.md index 6c96ce63..aee49181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,8 @@ Key input field notes: - `--approval-detail` — optional JSON object (MCP/agent) or JSON string (CLI) with approval details for delegated flows. Required fields: `approved_at` (unix timestamp int), `approval_method` (`click`|`programmatic`|`voice`), `app_name`, `external_user_id`. Optional: `ip_address`, `user_agent`, `device_type` (`mobile`|`web`), `agent_log_id`, `external_user_name`, `external_session_id`, `device_id`, `authentication_method` (`biometric_face`|`biometric_fingerprint`|`passkey`). Sent as `approval_details` in the API request body. - `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working) - `--output-file ` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file. -- `create` also accepts an undocumented `--expires-at ` to override the default 12-hour spend request expiration (3 hours to 7 days in the future). It's deliberately excluded from `--schema`/`--llms-full` output and from README/SKILL.md: it's gated to an allow-list of OAuth clients server-side, and most callers get a 400 (`"expires_at is not supported for this client"`) if they try it — don't document or suggest it to general agents. +- `create` also accepts an undocumented `--expires-at ` to override the default 12-hour spend request expiration (3 hours to 7 days in the future). It's deliberately excluded from README/SKILL.md and has a minimal schema description; incur can't hide options, so it still appears in `--help`, `--schema`, `--llms-full`, and MCP. It's gated to an allow-list of OAuth clients server-side, and most callers get a 400 (`"expires_at is not supported for this client"`) if they try it — don't document or suggest it to general agents. +- `create` also accepts undocumented `--recurring-interval ` and `--recurring-interval-count ` (defaults to 1 server-side), sent as `recurring: { interval, interval_count }`. Works with `card` and `shared_payment_token`. Handled like `--expires-at`: minimal descriptions, excluded from README/SKILL.md, SDK request field only on the internal `InternalCreateSpendRequestParams`. It's gated per Link account server-side (`enable_spend_request_recurring`); others get a 400 (`"recurring spend requests are not enabled"`) — don't document or suggest it to general agents until GA. Recurring requests stay `approved` (not `submitted`) because the credential can be charged every period; responses include `recurring` when set, shown in interactive create/retrieve/request-approval output. ### payment-methods command diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 916a1185..6db1e9d8 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -888,6 +888,65 @@ describe('production mode', () => { expect(sentBody.expires_at).toBeUndefined(); }); + it('sends recurring terms in POST body when --recurring-interval is used', async () => { + setNextResponse(200, BASE_REQUEST); + + const result = await runProdCli( + 'spend-request', + 'create', + '--payment-method-id', + 'pd_prod_test', + '--merchant-name', + 'Test Merchant', + '--merchant-url', + 'https://example.com', + '--context', + VALID_CONTEXT, + '--amount', + '5000', + '--recurring-interval', + 'month', + '--recurring-interval-count', + '2', + '--no-request-approval', + '--json', + ); + + expect(result.exitCode).toBe(0); + const sentBody = JSON.parse(lastRequest.body); + expect(sentBody.recurring).toEqual({ + interval: 'month', + interval_count: 2, + }); + }); + + it('rejects --recurring-interval-count without --recurring-interval', async () => { + const result = await runProdCli( + 'spend-request', + 'create', + '--payment-method-id', + 'pd_prod_test', + '--merchant-name', + 'Test Merchant', + '--merchant-url', + 'https://example.com', + '--context', + VALID_CONTEXT, + '--amount', + '5000', + '--recurring-interval-count', + '2', + '--no-request-approval', + '--json', + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain( + 'recurring-interval is required with recurring-interval-count', + ); + expect(requests).toHaveLength(0); + }); + it('sends test flag in POST body when --test is used', async () => { setNextResponse(200, BASE_REQUEST); diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 396de4b8..9f5f0cac 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -16,6 +16,7 @@ import { } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; import { formatAmount } from '../../utils/format-amount'; +import { formatRecurring } from '../../utils/format-recurring'; import { openUrl } from '../../utils/open-url'; import { sanitizeDeep } from '../../utils/sanitize-text'; import { shouldPollSpendRequest } from '../../utils/should-poll-spend-request'; @@ -464,6 +465,11 @@ export const CreateSpendRequest: React.FC = ({ : 'N/A'} + {request?.recurring && ( + + Recurring: {formatRecurring(request.recurring)} + + )} Merchant: {request?.merchant_name} diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 8fa4295b..f6c62127 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -219,6 +219,17 @@ export function createSpendRequestCli( }); } + if ( + opts.recurringIntervalCount !== undefined && + !opts.recurringInterval + ) { + return c.error({ + code: 'INVALID_INPUT', + message: + 'recurring-interval is required with recurring-interval-count', + }); + } + // Parse line items/totals: strings from flags need parsing, objects from MCP pass through const lineItems = opts.lineItem?.length ? opts.lineItem.map((item: unknown) => @@ -273,6 +284,12 @@ export function createSpendRequestCli( approval_details: approvalDetails, metadata, expires_at: opts.expiresAt, + recurring: opts.recurringInterval + ? { + interval: opts.recurringInterval, + interval_count: opts.recurringIntervalCount, + } + : undefined, }; const outputFile = opts.outputFile; diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 9751f680..82cf0dd8 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -9,6 +9,7 @@ import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { formatRecurring } from '../../utils/format-recurring'; import { openUrl } from '../../utils/open-url'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -222,6 +223,11 @@ export const RequestApproval: React.FC = ({ : 'N/A'} + {result?.recurring && ( + + Recurring: {formatRecurring(result.recurring)} + + )} Merchant: {result?.merchant_name} diff --git a/packages/cli/src/commands/spend-request/retrieve.tsx b/packages/cli/src/commands/spend-request/retrieve.tsx index e9426e6b..11a914c0 100644 --- a/packages/cli/src/commands/spend-request/retrieve.tsx +++ b/packages/cli/src/commands/spend-request/retrieve.tsx @@ -5,6 +5,7 @@ import type React from 'react'; import { useEffect, useRef, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; +import { formatRecurring } from '../../utils/format-recurring'; import { shouldPollSpendRequest } from '../../utils/should-poll-spend-request'; interface RetrieveSpendRequestProps { @@ -386,6 +387,11 @@ export const RetrieveSpendRequest: React.FC = ({ {request?.amount != null ? String(request.amount) : 'N/A'} + {request?.recurring && ( + + Recurring: {formatRecurring(request.recurring)} + + )} Merchant: {request?.merchant_name} diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index f2cf12fc..10f7f90a 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -108,6 +108,16 @@ export const createOptions = z.object({ .int() .optional() .describe('Unix timestamp (seconds).'), + recurringInterval: z + .enum(['day', 'week', 'month', 'year']) + .optional() + .describe('Recurring interval.'), + recurringIntervalCount: z.coerce + .number() + .int() + .positive() + .optional() + .describe('Recurring interval count.'), }); export const listOptions = z.object({ diff --git a/packages/cli/src/utils/__tests__/format-recurring.test.ts b/packages/cli/src/utils/__tests__/format-recurring.test.ts new file mode 100644 index 00000000..cb93e85c --- /dev/null +++ b/packages/cli/src/utils/__tests__/format-recurring.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { formatRecurring } from '../format-recurring'; + +describe('formatRecurring', () => { + it('omits a count of one and pluralizes larger counts', () => { + expect(formatRecurring({ interval: 'month', interval_count: 1 })).toBe( + 'every month', + ); + expect(formatRecurring({ interval: 'week', interval_count: 2 })).toBe( + 'every 2 weeks', + ); + }); +}); diff --git a/packages/cli/src/utils/format-recurring.ts b/packages/cli/src/utils/format-recurring.ts new file mode 100644 index 00000000..698ff79a --- /dev/null +++ b/packages/cli/src/utils/format-recurring.ts @@ -0,0 +1,11 @@ +import type { SpendRequestRecurring } from '@stripe/link-sdk'; + +/** + * Format recurring spend request terms, e.g. "every month" or "every 2 weeks". + */ +export function formatRecurring(recurring: SpendRequestRecurring): string { + const count = recurring.interval_count; + return count === 1 + ? `every ${recurring.interval}` + : `every ${count} ${recurring.interval}s`; +} diff --git a/packages/sdk-go/client_test.go b/packages/sdk-go/client_test.go index ac1506bf..e20873e7 100644 --- a/packages/sdk-go/client_test.go +++ b/packages/sdk-go/client_test.go @@ -348,6 +348,30 @@ func TestRetrieveReturnsNilOnNotFound(t *testing.T) { } } +func TestRetrieveDecodesRecurringTerms(t *testing.T) { + for name, tc := range map[string]struct { + recurring string + want SpendRequestRecurring + }{ + "explicit count": {`{"interval":"month","interval_count":2}`, SpendRequestRecurring{Interval: RecurringIntervalMonth, IntervalCount: 2}}, + "omitted count": {`{"interval":"day"}`, SpendRequestRecurring{Interval: RecurringIntervalDay, IntervalCount: 1}}, + } { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _, _ = response.Write([]byte(`{"id":"lsrq_123","status":"approved","recurring":` + tc.recurring + `,"created_at":"now","updated_at":"now"}`)) + })) + defer server.Close() + client, err := NewClient(Options{AccessToken: "token", SpendRequestBaseURL: server.URL}) + assertNoError(t, err) + result, err := client.SpendRequests.Retrieve(context.Background(), "lsrq_123", nil) + assertNoError(t, err) + if result.Recurring == nil || *result.Recurring != tc.want { + t.Fatalf("got recurring %#v, want %#v", result.Recurring, tc.want) + } + }) + } +} + func TestWebBotAuthCachesByAuthority(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { diff --git a/packages/sdk-go/types.go b/packages/sdk-go/types.go index aa013312..c9ae8ad9 100644 --- a/packages/sdk-go/types.go +++ b/packages/sdk-go/types.go @@ -132,6 +132,33 @@ const ( CredentialTypeCard CredentialType = "card" ) +// RecurringInterval identifies how often a recurring spend request can be charged. +type RecurringInterval string + +const ( + RecurringIntervalDay RecurringInterval = "day" + RecurringIntervalWeek RecurringInterval = "week" + RecurringIntervalMonth RecurringInterval = "month" + RecurringIntervalYear RecurringInterval = "year" +) + +// SpendRequestRecurring contains the terms of a recurring spend request. +type SpendRequestRecurring struct { + Interval RecurringInterval `json:"interval"` + IntervalCount int64 `json:"interval_count"` +} + +// UnmarshalJSON defaults IntervalCount to 1, matching the API, when it is omitted. +func (r *SpendRequestRecurring) UnmarshalJSON(data []byte) error { + type spendRequestRecurring SpendRequestRecurring + decoded := spendRequestRecurring{IntervalCount: 1} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = SpendRequestRecurring(decoded) + return nil +} + // ApprovalMethod describes how delegated approval was obtained. type ApprovalMethod string @@ -255,6 +282,7 @@ type SpendRequest struct { ActivityURL *string `json:"activity_url,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"` + Recurring *SpendRequestRecurring `json:"recurring,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/packages/sdk-python/src/link/__init__.py b/packages/sdk-python/src/link/__init__.py index 65859616..850001d4 100644 --- a/packages/sdk-python/src/link/__init__.py +++ b/packages/sdk-python/src/link/__init__.py @@ -13,6 +13,7 @@ NextActionResolution, NextActionType, PaymentOutcome, + RecurringInterval, ReportOutcome, ReportTag, SpendRequestStatus, @@ -61,6 +62,7 @@ SourcesPage, SpendLimit, SpendRequest, + SpendRequestRecurring, SpendRequestStatusDetails, Total, Transaction, @@ -92,6 +94,7 @@ "NextActionType", "NextActionResolution", "CredentialType", + "RecurringInterval", "ApprovalMethod", "DeviceType", "AuthenticationMethod", @@ -117,6 +120,7 @@ "RefundDetails", "PaymentStatusDetails", "SpendRequest", + "SpendRequestRecurring", "RequestApprovalResponse", "CardDetails", "BankAccountDetails", diff --git a/packages/sdk-python/src/link/_types.py b/packages/sdk-python/src/link/_types.py index f22fbd5e..939e808f 100644 --- a/packages/sdk-python/src/link/_types.py +++ b/packages/sdk-python/src/link/_types.py @@ -38,6 +38,7 @@ "create_new_spend_request_after_completion", ] CredentialType: TypeAlias = Literal["shared_payment_token", "card"] +RecurringInterval: TypeAlias = Literal["day", "week", "month", "year"] ApprovalMethod: TypeAlias = Literal["click", "programmatic", "voice"] DeviceType: TypeAlias = Literal["mobile", "web"] AuthenticationMethod: TypeAlias = Literal[ diff --git a/packages/sdk-python/src/link/models.py b/packages/sdk-python/src/link/models.py index ab76d4e4..dfe476af 100644 --- a/packages/sdk-python/src/link/models.py +++ b/packages/sdk-python/src/link/models.py @@ -14,6 +14,7 @@ NextActionResolution, NextActionType, PaymentOutcome, + RecurringInterval, SpendRequestStatus, TransactionOrigin, ) @@ -109,6 +110,11 @@ def _legacy_string(cls, value: Any) -> Any: return value +class SpendRequestRecurring(LinkModel): + interval: RecurringInterval | str + interval_count: int = 1 + + class RefundDetails(LinkModel): amount: int currency: str @@ -152,6 +158,7 @@ class SpendRequest(LinkModel): activity_url: str | None = None metadata: dict[str, str] | None = None expires_at: int | None = None + recurring: SpendRequestRecurring | None = None created_at: str updated_at: str diff --git a/packages/sdk-python/tests/test_models.py b/packages/sdk-python/tests/test_models.py index 3839ff6e..a4538470 100644 --- a/packages/sdk-python/tests/test_models.py +++ b/packages/sdk-python/tests/test_models.py @@ -95,6 +95,7 @@ def test_spend_request_full_response() -> None: "activity_url": "https://link.com/activity", "metadata": {}, "expires_at": 789, + "recurring": {"interval": "month", "interval_count": 2}, "created_at": "now", "updated_at": "later", } @@ -137,6 +138,10 @@ def test_optional_fields_remain_optional() -> None: method = PaymentMethod(id="pm_1", type="card", is_default=False, name="Visa") assert method.nickname is None assert SpendRequest.model_validate(SPEND).amount is None + recurring = SpendRequest.model_validate( + {**SPEND, "recurring": {"interval": "month"}} + ).recurring + assert recurring is not None and recurring.interval_count == 1 assert TransactionsPage(data=[]).data == [] assert UserInfo.model_validate({}).agent_wallet_spend_limits is None diff --git a/packages/sdk/src/resources/__tests__/spend-request.test.ts b/packages/sdk/src/resources/__tests__/spend-request.test.ts index f31d464f..60a63766 100644 --- a/packages/sdk/src/resources/__tests__/spend-request.test.ts +++ b/packages/sdk/src/resources/__tests__/spend-request.test.ts @@ -207,6 +207,22 @@ describe('SpendRequestResource', () => { }); }); + it('serializes recurring terms in POST body', async () => { + mockFetchResponse(200, spendRequestResponse); + + await repo.create({ + ...validParams, + recurring: { interval: 'month', interval_count: 2 }, + }); + + const [, opts] = mockFetch.mock.calls[0]!; + const sentBody = JSON.parse(opts.body); + expect(sentBody.recurring).toEqual({ + interval: 'month', + interval_count: 2, + }); + }); + it('serializes idempotency_key in the normal create body', async () => { mockFetchResponse(200, spendRequestResponse); @@ -526,6 +542,17 @@ describe('SpendRequestResource', () => { expect(result).toEqual(spendRequestResponse); }); + it('defaults an omitted recurring interval_count to 1', async () => { + mockFetchResponse(200, { + ...spendRequestResponse, + recurring: { interval: 'day' }, + }); + + const result = await repo.retrieve('si_123'); + + expect(result?.recurring).toEqual({ interval: 'day', interval_count: 1 }); + }); + it('returns SpendRequest with card after approval', async () => { const approvedResponse = { ...spendRequestResponse, diff --git a/packages/sdk/src/resources/spend-request.ts b/packages/sdk/src/resources/spend-request.ts index 1e988e00..1bfc5c52 100644 --- a/packages/sdk/src/resources/spend-request.ts +++ b/packages/sdk/src/resources/spend-request.ts @@ -7,7 +7,11 @@ import type { ISpendRequestResource, UpdateSpendRequestParams, } from '@/resources/interfaces'; -import type { RequestApprovalResponse, SpendRequest } from '@/types/index'; +import type { + RecurringInterval, + RequestApprovalResponse, + SpendRequest, +} from '@/types/index'; const sharedPaymentTokenSchema = z.union([ z.string().transform((id) => ({ id })), @@ -23,6 +27,14 @@ const spendRequestSchema = z.looseObject({ created_at: z.string(), updated_at: z.string(), shared_payment_token: sharedPaymentTokenSchema.nullable().optional(), + // interval_count defaults to 1 server-side and may be omitted. + recurring: z + .looseObject({ + interval: z.string(), + interval_count: z.number().default(1), + }) + .nullable() + .optional(), }); const spendRequestsResponseSchema = z.looseObject({ @@ -48,6 +60,7 @@ const duplicateSpendRequestErrorSchema = z.looseObject({ type InternalCreateSpendRequestParams = CreateSpendRequestParams & { approve?: boolean; expires_at?: number; + recurring?: { interval: RecurringInterval; interval_count?: number }; }; type InternalUpdateSpendRequestParams = UpdateSpendRequestParams & { diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 0992d749..5035f5e7 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -90,6 +90,13 @@ export interface SpendRequestStatusDetails { export type CredentialType = 'shared_payment_token' | 'card'; +export type RecurringInterval = 'day' | 'week' | 'month' | 'year'; + +export interface SpendRequestRecurring { + interval: RecurringInterval; + interval_count: number; +} + export interface ApprovalDetail { approved_at: number; approval_method: 'click' | 'programmatic' | 'voice'; @@ -157,6 +164,7 @@ export interface SpendRequest { activity_url?: string; metadata?: Record; expires_at?: number; + recurring?: SpendRequestRecurring | null; created_at: string; updated_at: string; }