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
6 changes: 6 additions & 0 deletions .changeset/recurring-spend-request-terms.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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 <unix_seconds>` 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 <unix_seconds>` 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 <day|week|month|year>` and `--recurring-interval-count <n>` (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

Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/spend-request/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -464,6 +465,11 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
: 'N/A'}
</Text>
</Text>
{request?.recurring && (
<Text>
Recurring: <Text bold>{formatRecurring(request.recurring)}</Text>
</Text>
)}
<Text>
Merchant: <Text bold>{request?.merchant_name}</Text>
</Text>
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -222,6 +223,11 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
: 'N/A'}
</Text>
</Text>
{result?.recurring && (
<Text>
Recurring: <Text bold>{formatRecurring(result.recurring)}</Text>
</Text>
)}
<Text>
Merchant: <Text bold>{result?.merchant_name}</Text>
</Text>
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/spend-request/retrieve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -386,6 +387,11 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
{request?.amount != null ? String(request.amount) : 'N/A'}
</Text>
</Text>
{request?.recurring && (
<Text>
Recurring: <Text bold>{formatRecurring(request.recurring)}</Text>
</Text>
)}
<Text>
Merchant: <Text bold>{request?.merchant_name}</Text>
</Text>
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/commands/spend-request/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/utils/__tests__/format-recurring.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
11 changes: 11 additions & 0 deletions packages/cli/src/utils/format-recurring.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
24 changes: 24 additions & 0 deletions packages/sdk-go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions packages/sdk-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"`
}
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-python/src/link/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
NextActionResolution,
NextActionType,
PaymentOutcome,
RecurringInterval,
ReportOutcome,
ReportTag,
SpendRequestStatus,
Expand Down Expand Up @@ -61,6 +62,7 @@
SourcesPage,
SpendLimit,
SpendRequest,
SpendRequestRecurring,
SpendRequestStatusDetails,
Total,
Transaction,
Expand Down Expand Up @@ -92,6 +94,7 @@
"NextActionType",
"NextActionResolution",
"CredentialType",
"RecurringInterval",
"ApprovalMethod",
"DeviceType",
"AuthenticationMethod",
Expand All @@ -117,6 +120,7 @@
"RefundDetails",
"PaymentStatusDetails",
"SpendRequest",
"SpendRequestRecurring",
"RequestApprovalResponse",
"CardDetails",
"BankAccountDetails",
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-python/src/link/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk-python/src/link/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
NextActionResolution,
NextActionType,
PaymentOutcome,
RecurringInterval,
SpendRequestStatus,
TransactionOrigin,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions packages/sdk-python/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading