From 7da094548356649886d0913a3f0ed417c5f4067c Mon Sep 17 00:00:00 2001 From: John Xu Date: Thu, 17 Sep 2026 15:41:30 -0400 Subject: [PATCH 1/5] add link-cli spending-policy retrieve route --- .changeset/curvy-wombats-allow.md | 6 ++ README.md | 11 +++ packages/cli/src/__tests__/cli.test.ts | 42 ++++++++++ packages/cli/src/cli.tsx | 8 ++ .../src/commands/spending-policy/index.tsx | 36 ++++++++ .../spending-policy/retrieve.test.tsx | 57 +++++++++++++ .../src/commands/spending-policy/retrieve.tsx | 83 +++++++++++++++++++ packages/cli/src/utils/resource-factory.ts | 12 +++ packages/sdk/README.md | 6 ++ packages/sdk/src/client.ts | 4 + packages/sdk/src/index.ts | 1 + .../__tests__/spending-policy.test.ts | 83 +++++++++++++++++++ packages/sdk/src/resources/interfaces.ts | 5 ++ packages/sdk/src/resources/spending-policy.ts | 49 +++++++++++ packages/sdk/src/types/index.ts | 29 +++++++ 15 files changed, 432 insertions(+) create mode 100644 .changeset/curvy-wombats-allow.md create mode 100644 packages/cli/src/commands/spending-policy/index.tsx create mode 100644 packages/cli/src/commands/spending-policy/retrieve.test.tsx create mode 100644 packages/cli/src/commands/spending-policy/retrieve.tsx create mode 100644 packages/sdk/src/resources/__tests__/spending-policy.test.ts create mode 100644 packages/sdk/src/resources/spending-policy.ts diff --git a/.changeset/curvy-wombats-allow.md b/.changeset/curvy-wombats-allow.md new file mode 100644 index 0000000..a4c42a2 --- /dev/null +++ b/.changeset/curvy-wombats-allow.md @@ -0,0 +1,6 @@ +--- +'@stripe/link-cli': minor +'@stripe/link-sdk': minor +--- + +Add the `spending-policy retrieve` CLI command and the SDK spending policy resource. diff --git a/README.md b/README.md index 6ba88b9..2893280 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,17 @@ response does not include a currency. A null limit or remaining amount means unlimited. The verification requirement's action_url is null when no action is available. +### Retrieve spending policy + +Retrieve the rules that govern spend requests for the current app and user: + +```bash +link-cli spending-policy retrieve --format json +``` + +Each rule includes an action and can include an approval type, a per-purchase +limit, and an ordered list of allowed payment method IDs. + ### List payment methods ```bash diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e6d7509..ba41abe 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1861,6 +1861,48 @@ describe('production mode', () => { }); }); + describe('spending-policy retrieve', () => { + it('GETs and returns the spending policy', async () => { + const policy = { + rules: [ + { + action: 'allow', + approval_type: 'automatic', + limits: { + per_purchase: { amount: 5000, currency: 'usd' }, + }, + allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'], + }, + { action: 'allow' }, + ], + }; + setResponseForUrl('/spending-policy', 200, policy); + + const result = await runProdCli('spending-policy', 'retrieve', '--json'); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('GET'); + expect(lastRequest.url).toBe('/spending-policy'); + expect(lastRequest.headers.authorization).toBe( + 'Bearer prod_test_access_token', + ); + expect(parseJson(result.stdout)).toEqual(policy); + }); + + it('rejects unauthenticated requests before hitting the API', async () => { + storage.clearTokens(); + + const result = await runProdCli('spending-policy', 'retrieve', '--json'); + + expect(result.exitCode).toBe(1); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('NOT_AUTHENTICATED'); + expect( + requests.find((request) => request.url === '/spending-policy'), + ).toBeUndefined(); + }); + }); + const SAMPLE_BALANCE = { source_id: 'csmrpd_001', type: 'cash', diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 065ea70..14a9a62 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -13,6 +13,7 @@ import { createShippingAddressCli } from './commands/shipping-address'; import { createSourcesCli } from './commands/sources'; import { createSpendRequestCli } from './commands/spend-request'; import { createSummariesCli } from './commands/summaries'; +import { createSpendingPolicyCli } from './commands/spending-policy'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; @@ -132,6 +133,13 @@ cli.command( envAccessToken, ), ); +cli.command( + createSpendingPolicyCli( + () => factory.createSpendingPolicyResource(), + authStorage, + envAccessToken, + ), +); cli.command( createMppCli( spendRequestRepo, diff --git a/packages/cli/src/commands/spending-policy/index.tsx b/packages/cli/src/commands/spending-policy/index.tsx new file mode 100644 index 0000000..ae18d60 --- /dev/null +++ b/packages/cli/src/commands/spending-policy/index.tsx @@ -0,0 +1,36 @@ +import type { ISpendingPolicyResource } from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import type { CliAuthStorage } from '../../auth/storage'; +import { renderInteractive } from '../../utils/render-interactive'; +import { requireAuth } from '../../utils/require-auth'; +import { SpendingPolicyRetrieve } from './retrieve'; + +export function createSpendingPolicyCli( + createResource: () => ISpendingPolicyResource, + authStorage?: CliAuthStorage, + envAccessToken?: string, +) { + const cli = Cli.create('spending-policy', { + description: 'Spending policy commands', + }); + + cli.command('retrieve', { + description: 'Retrieve the spending policy for the current app and user', + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage, envAccessToken)], + async run(c) { + const resource = createResource(); + + if (!c.agent && !c.formatExplicit) { + return renderInteractive( + {}} />, + () => resource.retrieve(), + ); + } + + return resource.retrieve(); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/spending-policy/retrieve.test.tsx b/packages/cli/src/commands/spending-policy/retrieve.test.tsx new file mode 100644 index 0000000..04dc394 --- /dev/null +++ b/packages/cli/src/commands/spending-policy/retrieve.test.tsx @@ -0,0 +1,57 @@ +import type { ISpendingPolicyResource, SpendingPolicy } from '@stripe/link-sdk'; +import { render } from 'ink-testing-library'; +import { describe, expect, it, vi } from 'vitest'; +import { SpendingPolicyRetrieve } from './retrieve'; + +function makeResource(policy: SpendingPolicy): ISpendingPolicyResource { + return { + retrieve: vi.fn(async () => policy), + }; +} + +describe('spending-policy retrieve component', () => { + it('renders all policy rule fields in order', async () => { + const resource = makeResource({ + rules: [ + { + action: 'allow', + approval_type: 'automatic', + limits: { + per_purchase: { amount: 5000, currency: 'usd' }, + }, + allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'], + }, + { action: 'allow' }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Spending Policy'); + expect(frame).toContain('Rule 1'); + expect(frame).toContain('Action: allow'); + expect(frame).toContain('Approval: automatic'); + expect(frame).toContain('Per-purchase limit: $50.00'); + expect(frame).toContain('Allowed payment methods: csmrpd_2, csmrpd_1'); + expect(frame).toContain('Rule 2'); + }); + }); + + it('renders an explicitly empty payment method allowlist', async () => { + const resource = makeResource({ + rules: [{ action: 'allow', allowed_payment_methods: [] }], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('Allowed payment methods: None'); + }); + }); +}); diff --git a/packages/cli/src/commands/spending-policy/retrieve.tsx b/packages/cli/src/commands/spending-policy/retrieve.tsx new file mode 100644 index 0000000..4b160e9 --- /dev/null +++ b/packages/cli/src/commands/spending-policy/retrieve.tsx @@ -0,0 +1,83 @@ +// biome-ignore-all lint/suspicious/noArrayIndexKey: Policy rules are ordered and have no identifiers. +import type { ISpendingPolicyResource, SpendingPolicy } from '@stripe/link-sdk'; +import { Box, Text } from 'ink'; +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 SpendingPolicyRetrieveProps { + resource: ISpendingPolicyResource; + onComplete: (result: SpendingPolicy | null) => void; +} + +export const SpendingPolicyRetrieve: React.FC = ({ + resource, + onComplete, +}) => { + const action = useCallback(() => resource.retrieve(), [resource]); + const { status, data: policy, error } = useAsyncAction(action, onComplete); + + if (status === 'loading') { + return ( + + + Loading spending policy... + + + ); + } + + if (status === 'error') { + return ( + + ✗ Failed to load spending policy + {error} + + ); + } + + return ( + + Spending Policy + {policy?.rules.map((rule, index) => ( + + Rule {index + 1} + + Action: + {rule.action} + + {rule.approval_type ? ( + + Approval: + {rule.approval_type} + + ) : null} + {rule.limits ? ( + + Per-purchase limit: + {formatAmount( + rule.limits.per_purchase.amount, + rule.limits.per_purchase.currency, + )} + + ) : null} + {rule.allowed_payment_methods ? ( + + Allowed payment methods: + {rule.allowed_payment_methods.length > 0 + ? rule.allowed_payment_methods.join(', ') + : 'None'} + + ) : null} + + ))} + + ); +}; diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index a0e8176..648a743 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -6,6 +6,7 @@ import { type IReportResource, type IShippingAddressResource, type ISourcesResource, + type ISpendingPolicyResource, type ISpendRequestResource, type ISummariesResource, type ITransactionsResource, @@ -115,6 +116,7 @@ export class ResourceFactory { private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; private userInfoResource?: IUserInfoResource; + private spendingPolicyResource?: ISpendingPolicyResource; private transactionsResource?: ITransactionsResource; private sourcesResource?: ISourcesResource; private summariesResource?: ISummariesResource; @@ -274,6 +276,16 @@ export class ResourceFactory { return resource; } + createSpendingPolicyResource(): ISpendingPolicyResource { + if (this.spendingPolicyResource) { + return this.spendingPolicyResource; + } + + const resource = sanitizeResource(this.createSdkClient().spendingPolicy); + this.spendingPolicyResource = resource; + return resource; + } + createTransactionsResource(): ITransactionsResource { if (this.transactionsResource) { return this.transactionsResource; diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 295e09b..dc3cda7 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -27,6 +27,12 @@ const link = new Link({ accessToken: process.env.LINK_ACCESS_TOKEN! }); const paymentMethods = await link.paymentMethods.list(); ``` +Retrieve the spending policy for the current app and user: + +```ts +const spendingPolicy = await link.spendingPolicy.retrieve(); +``` + Use a fixed token for a short-lived job or when the caller replaces the entire client as credentials change. diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 385382d..2ee5c89 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -8,6 +8,7 @@ import type { IReportResource, IShippingAddressResource, ISourcesResource, + ISpendingPolicyResource, ISpendRequestResource, ISummariesResource, ITransactionsResource, @@ -20,6 +21,7 @@ import { ShippingAddressResource } from '@/resources/shipping-address'; import { SourcesResource } from '@/resources/sources'; import { SpendRequestResource } from '@/resources/spend-request'; import { SummariesResource } from '@/resources/summaries'; +import { SpendingPolicyResource } from '@/resources/spending-policy'; import { TransactionsResource } from '@/resources/transactions'; import { UserInfoResource } from '@/resources/user-info'; import { WebBotAuthResource } from '@/resources/web-bot-auth'; @@ -30,6 +32,7 @@ export class Link { readonly paymentMethods: IPaymentMethodsResource; readonly shippingAddresses: IShippingAddressResource; readonly userInfo: IUserInfoResource; + readonly spendingPolicy: ISpendingPolicyResource; readonly transactions: ITransactionsResource; readonly sources: ISourcesResource; readonly balances: IBalancesResource; @@ -43,6 +46,7 @@ export class Link { this.paymentMethods = new PaymentMethodsResource(options); this.shippingAddresses = new ShippingAddressResource(options); this.userInfo = new UserInfoResource(options); + this.spendingPolicy = new SpendingPolicyResource(options); this.transactions = new TransactionsResource(options); this.sources = new SourcesResource(options); this.balances = new BalancesResource(options); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 92c3176..f50c11b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,4 +11,5 @@ export * from './resources/attestations'; export * from './resources/interfaces'; export { getDuplicateSpendRequest } from './resources/spend-request'; export * from './resources/summaries'; +export { SpendingPolicyResource } from './resources/spending-policy'; export * from './types/index'; diff --git a/packages/sdk/src/resources/__tests__/spending-policy.test.ts b/packages/sdk/src/resources/__tests__/spending-policy.test.ts new file mode 100644 index 0000000..3aeb93c --- /dev/null +++ b/packages/sdk/src/resources/__tests__/spending-policy.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SpendingPolicyResource } from '@/resources/spending-policy'; + +const mockFetch = vi.fn(); +const getAccessToken = vi.fn(); + +function mockFetchResponse(status: number, body: unknown) { + mockFetch.mockResolvedValue({ + status, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify(body), + }); +} + +describe('SpendingPolicyResource', () => { + let resource: SpendingPolicyResource; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + getAccessToken.mockResolvedValue('test_token'); + resource = new SpendingPolicyResource({ getAccessToken }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('retrieves and parses the spending policy', async () => { + const policy = { + rules: [ + { + action: 'allow', + approval_type: 'automatic', + limits: { + per_purchase: { amount: 5000, currency: 'usd' }, + }, + allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'], + }, + { action: 'allow' }, + ], + }; + mockFetchResponse(200, policy); + + await expect(resource.retrieve()).resolves.toEqual(policy); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://api.link.com/spending-policy'); + expect(opts.method).toBe('GET'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + }); + + it('accepts the manual-only default policy', async () => { + mockFetchResponse(200, { + rules: [{ action: 'allow', approval_type: 'manual' }], + }); + + await expect(resource.retrieve()).resolves.toEqual({ + rules: [{ action: 'allow', approval_type: 'manual' }], + }); + }); + + it('throws API errors with the response message', async () => { + mockFetchResponse(403, { + error: { code: 'feature_unavailable' }, + }); + + await expect(resource.retrieve()).rejects.toThrow( + 'Failed to retrieve spending policy (403): feature_unavailable', + ); + }); + + it('throws when the response shape is invalid', async () => { + mockFetchResponse(200, { rules: 'not an array' }); + + await expect(resource.retrieve()).rejects.toMatchObject({ + code: 'invalid_response', + status: 200, + }); + }); +}); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 059157c..990c9e7 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -7,6 +7,7 @@ import type { RequestApprovalResponse, ShippingAddressRecord, SourcesPage, + SpendingPolicy, SpendRequest, SummariesPage, Total, @@ -94,6 +95,10 @@ export interface IUserInfoResource { retrieve(): Promise; } +export interface ISpendingPolicyResource { + retrieve(): Promise; +} + export interface IWebBotAuthResource { signUrl(url: string): Promise; } diff --git a/packages/sdk/src/resources/spending-policy.ts b/packages/sdk/src/resources/spending-policy.ts new file mode 100644 index 0000000..7a1020c --- /dev/null +++ b/packages/sdk/src/resources/spending-policy.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import type { LinkOptions } from '@/config'; +import { BaseResource } from '@/resources/base'; +import type { ISpendingPolicyResource } from '@/resources/interfaces'; +import type { SpendingPolicy } from '@/types/index'; + +const spendingPolicySchema = z.looseObject({ + rules: z.array( + z.looseObject({ + action: z.string(), + approval_type: z.string().optional(), + limits: z + .looseObject({ + per_purchase: z.looseObject({ + amount: z.number().int(), + currency: z.string(), + }), + }) + .optional(), + allowed_payment_methods: z.array(z.string()).optional(), + }), + ), +}); + +export class SpendingPolicyResource + extends BaseResource + implements ISpendingPolicyResource +{ + constructor(options: LinkOptions) { + super(options, '/spending-policy'); + } + + async retrieve(): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: this.endpoint, + }); + + if (status < 200 || status >= 300) { + this.throwApiError('retrieve spending policy', status, data, rawBody); + } + + return this.parseResponse( + 'retrieve spending policy', + status, + () => spendingPolicySchema.parse(data) as SpendingPolicy, + ); + } +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index f5d7caa..506204b 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -226,6 +226,35 @@ export interface UserInfo { agent_wallet_verification_requirement?: AgentWalletVerificationRequirement; } +/** Known actions, while remaining forward-compatible with new API values. */ +export type SpendingPolicyAction = 'allow' | (string & Record); + +/** Known approval types, while remaining forward-compatible with new API values. */ +export type SpendingPolicyApprovalType = + | 'manual' + | 'automatic' + | (string & Record); + +export interface SpendingPolicyAmount { + amount: number; + currency: string; +} + +export interface SpendingPolicyLimits { + per_purchase: SpendingPolicyAmount; +} + +export interface SpendingPolicyRule { + action: SpendingPolicyAction; + approval_type?: SpendingPolicyApprovalType; + limits?: SpendingPolicyLimits; + allowed_payment_methods?: string[]; +} + +export interface SpendingPolicy { + rules: SpendingPolicyRule[]; +} + export interface ProductCapability { eligible: boolean; ineligibility_reasons: string[]; From 0893cb49cde0c045fb932ffdb44b3dc1a41c99b4 Mon Sep 17 00:00:00 2001 From: John Xu Date: Mon, 21 Sep 2026 12:35:59 -0400 Subject: [PATCH 2/5] fix ci --- packages/cli/src/cli.tsx | 2 +- packages/sdk/src/client.ts | 2 +- packages/sdk/src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 14a9a62..9ceaec1 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -12,8 +12,8 @@ import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; import { createSourcesCli } from './commands/sources'; import { createSpendRequestCli } from './commands/spend-request'; -import { createSummariesCli } from './commands/summaries'; import { createSpendingPolicyCli } from './commands/spending-policy'; +import { createSummariesCli } from './commands/summaries'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 2ee5c89..8f539d2 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -20,8 +20,8 @@ import { ReportResource } from '@/resources/report'; import { ShippingAddressResource } from '@/resources/shipping-address'; import { SourcesResource } from '@/resources/sources'; import { SpendRequestResource } from '@/resources/spend-request'; -import { SummariesResource } from '@/resources/summaries'; import { SpendingPolicyResource } from '@/resources/spending-policy'; +import { SummariesResource } from '@/resources/summaries'; import { TransactionsResource } from '@/resources/transactions'; import { UserInfoResource } from '@/resources/user-info'; import { WebBotAuthResource } from '@/resources/web-bot-auth'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f50c11b..f6fc0b5 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,6 +10,6 @@ export { export * from './resources/attestations'; export * from './resources/interfaces'; export { getDuplicateSpendRequest } from './resources/spend-request'; -export * from './resources/summaries'; export { SpendingPolicyResource } from './resources/spending-policy'; +export * from './resources/summaries'; export * from './types/index'; From d261cf85c4c9a629da1e8f749a2767e4352ae025 Mon Sep 17 00:00:00 2001 From: John Xu Date: Mon, 21 Sep 2026 13:11:15 -0400 Subject: [PATCH 3/5] change endpoint to approval-policy --- .changeset/curvy-wombats-allow.md | 2 +- README.md | 12 ++-- packages/cli/src/__tests__/cli.test.ts | 35 +++++++---- packages/cli/src/cli.tsx | 6 +- .../index.tsx | 16 ++--- .../retrieve.test.tsx | 32 +++++----- .../retrieve.tsx | 38 +++++------- packages/cli/src/utils/resource-factory.ts | 14 ++--- packages/sdk/README.md | 4 +- packages/sdk/src/client.ts | 8 +-- packages/sdk/src/index.ts | 2 +- ...policy.test.ts => approval-policy.test.ts} | 60 ++++++++++++------- packages/sdk/src/resources/approval-policy.ts | 46 ++++++++++++++ packages/sdk/src/resources/interfaces.ts | 6 +- packages/sdk/src/resources/spending-policy.ts | 49 --------------- packages/sdk/src/types/index.ts | 25 ++++---- .../cursor-link/.cursor-plugin/plugin.json | 10 +++- plugins/link/.claude-plugin/plugin.json | 2 +- plugins/link/.codex-plugin/plugin.json | 2 +- skills/create-payment-credential/SKILL.md | 2 +- skills/financial-insights/SKILL.md | 2 +- 21 files changed, 202 insertions(+), 171 deletions(-) rename packages/cli/src/commands/{spending-policy => approval-policy}/index.tsx (62%) rename packages/cli/src/commands/{spending-policy => approval-policy}/retrieve.test.tsx (59%) rename packages/cli/src/commands/{spending-policy => approval-policy}/retrieve.tsx (63%) rename packages/sdk/src/resources/__tests__/{spending-policy.test.ts => approval-policy.test.ts} (55%) create mode 100644 packages/sdk/src/resources/approval-policy.ts delete mode 100644 packages/sdk/src/resources/spending-policy.ts diff --git a/.changeset/curvy-wombats-allow.md b/.changeset/curvy-wombats-allow.md index a4c42a2..817878d 100644 --- a/.changeset/curvy-wombats-allow.md +++ b/.changeset/curvy-wombats-allow.md @@ -3,4 +3,4 @@ '@stripe/link-sdk': minor --- -Add the `spending-policy retrieve` CLI command and the SDK spending policy resource. +Add the `approval-policy retrieve` CLI command and the SDK approval policy resource. diff --git a/README.md b/README.md index 2893280..3c3185c 100644 --- a/README.md +++ b/README.md @@ -153,16 +153,18 @@ response does not include a currency. A null limit or remaining amount means unlimited. The verification requirement's action_url is null when no action is available. -### Retrieve spending policy +### Retrieve approval policy -Retrieve the rules that govern spend requests for the current app and user: +Retrieve the rules that grant the current app authority to create spend requests +without manual approval: ```bash -link-cli spending-policy retrieve --format json +link-cli approval-policy retrieve --format json ``` -Each rule includes an action and can include an approval type, a per-purchase -limit, and an ordered list of allowed payment method IDs. +Each rule includes an action, a per-purchase limit, and optionally an ordered +list of allowed payment method IDs. The API returns an error when no approval +policy has been configured. ### List payment methods diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index ba41abe..271d778 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1861,44 +1861,59 @@ describe('production mode', () => { }); }); - describe('spending-policy retrieve', () => { - it('GETs and returns the spending policy', async () => { + describe('approval-policy retrieve', () => { + it('GETs and returns the approval policy', async () => { const policy = { rules: [ { - action: 'allow', - approval_type: 'automatic', + action: 'spend_request_create', limits: { per_purchase: { amount: 5000, currency: 'usd' }, }, allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'], }, - { action: 'allow' }, ], }; - setResponseForUrl('/spending-policy', 200, policy); + setResponseForUrl('/approval-policy', 200, policy); - const result = await runProdCli('spending-policy', 'retrieve', '--json'); + const result = await runProdCli('approval-policy', 'retrieve', '--json'); expect(result.exitCode).toBe(0); expect(lastRequest.method).toBe('GET'); - expect(lastRequest.url).toBe('/spending-policy'); + expect(lastRequest.url).toBe('/approval-policy'); expect(lastRequest.headers.authorization).toBe( 'Bearer prod_test_access_token', ); expect(parseJson(result.stdout)).toEqual(policy); }); + it('surfaces the configured-policy not-found error', async () => { + setResponseForUrl('/approval-policy', 404, { + error: { + message: 'No approval policy has been configured', + code: 'approval_policy_not_found', + }, + }); + + const result = await runProdCli('approval-policy', 'retrieve', '--json'); + + expect(result.exitCode).toBe(1); + expect(parseJson(result.stdout)).toMatchObject({ + message: + 'Failed to retrieve approval policy (404): No approval policy has been configured', + }); + }); + it('rejects unauthenticated requests before hitting the API', async () => { storage.clearTokens(); - const result = await runProdCli('spending-policy', 'retrieve', '--json'); + const result = await runProdCli('approval-policy', 'retrieve', '--json'); expect(result.exitCode).toBe(1); const output = parseJson(result.stdout) as Record; expect(output.code).toBe('NOT_AUTHENTICATED'); expect( - requests.find((request) => request.url === '/spending-policy'), + requests.find((request) => request.url === '/approval-policy'), ).toBeUndefined(); }); }); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 9ceaec1..99ea90c 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,5 +1,6 @@ import { Cli } from 'incur'; import { type CliAuthStorage, Storage, storage } from './auth/storage'; +import { createApprovalPolicyCli } from './commands/approval-policy'; import { createAuthCli } from './commands/auth'; import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; @@ -12,7 +13,6 @@ import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; import { createSourcesCli } from './commands/sources'; import { createSpendRequestCli } from './commands/spend-request'; -import { createSpendingPolicyCli } from './commands/spending-policy'; import { createSummariesCli } from './commands/summaries'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; @@ -134,8 +134,8 @@ cli.command( ), ); cli.command( - createSpendingPolicyCli( - () => factory.createSpendingPolicyResource(), + createApprovalPolicyCli( + () => factory.createApprovalPolicyResource(), authStorage, envAccessToken, ), diff --git a/packages/cli/src/commands/spending-policy/index.tsx b/packages/cli/src/commands/approval-policy/index.tsx similarity index 62% rename from packages/cli/src/commands/spending-policy/index.tsx rename to packages/cli/src/commands/approval-policy/index.tsx index ae18d60..aca5fe6 100644 --- a/packages/cli/src/commands/spending-policy/index.tsx +++ b/packages/cli/src/commands/approval-policy/index.tsx @@ -1,21 +1,21 @@ -import type { ISpendingPolicyResource } from '@stripe/link-sdk'; +import type { IApprovalPolicyResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; -import { SpendingPolicyRetrieve } from './retrieve'; +import { ApprovalPolicyRetrieve } from './retrieve'; -export function createSpendingPolicyCli( - createResource: () => ISpendingPolicyResource, +export function createApprovalPolicyCli( + createResource: () => IApprovalPolicyResource, authStorage?: CliAuthStorage, envAccessToken?: string, ) { - const cli = Cli.create('spending-policy', { - description: 'Spending policy commands', + const cli = Cli.create('approval-policy', { + description: 'Approval policy commands', }); cli.command('retrieve', { - description: 'Retrieve the spending policy for the current app and user', + description: 'Retrieve the approval policy for the current app and user', outputPolicy: 'agent-only' as const, middleware: [requireAuth(authStorage, envAccessToken)], async run(c) { @@ -23,7 +23,7 @@ export function createSpendingPolicyCli( if (!c.agent && !c.formatExplicit) { return renderInteractive( - {}} />, + {}} />, () => resource.retrieve(), ); } diff --git a/packages/cli/src/commands/spending-policy/retrieve.test.tsx b/packages/cli/src/commands/approval-policy/retrieve.test.tsx similarity index 59% rename from packages/cli/src/commands/spending-policy/retrieve.test.tsx rename to packages/cli/src/commands/approval-policy/retrieve.test.tsx index 04dc394..83ad4af 100644 --- a/packages/cli/src/commands/spending-policy/retrieve.test.tsx +++ b/packages/cli/src/commands/approval-policy/retrieve.test.tsx @@ -1,53 +1,57 @@ -import type { ISpendingPolicyResource, SpendingPolicy } from '@stripe/link-sdk'; +import type { ApprovalPolicy, IApprovalPolicyResource } from '@stripe/link-sdk'; import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; -import { SpendingPolicyRetrieve } from './retrieve'; +import { ApprovalPolicyRetrieve } from './retrieve'; -function makeResource(policy: SpendingPolicy): ISpendingPolicyResource { +function makeResource(policy: ApprovalPolicy): IApprovalPolicyResource { return { retrieve: vi.fn(async () => policy), }; } -describe('spending-policy retrieve component', () => { +describe('approval-policy retrieve component', () => { it('renders all policy rule fields in order', async () => { const resource = makeResource({ rules: [ { - action: 'allow', - approval_type: 'automatic', + action: 'spend_request_create', limits: { per_purchase: { amount: 5000, currency: 'usd' }, }, allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'], }, - { action: 'allow' }, ], }); const { lastFrame } = render( - {}} />, + {}} />, ); await vi.waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('Spending Policy'); + expect(frame).toContain('Approval Policy'); expect(frame).toContain('Rule 1'); - expect(frame).toContain('Action: allow'); - expect(frame).toContain('Approval: automatic'); + expect(frame).toContain('Action: spend_request_create'); expect(frame).toContain('Per-purchase limit: $50.00'); expect(frame).toContain('Allowed payment methods: csmrpd_2, csmrpd_1'); - expect(frame).toContain('Rule 2'); }); }); it('renders an explicitly empty payment method allowlist', async () => { const resource = makeResource({ - rules: [{ action: 'allow', allowed_payment_methods: [] }], + rules: [ + { + action: 'spend_request_create', + limits: { + per_purchase: { amount: 5000, currency: 'usd' }, + }, + allowed_payment_methods: [], + }, + ], }); const { lastFrame } = render( - {}} />, + {}} />, ); await vi.waitFor(() => { diff --git a/packages/cli/src/commands/spending-policy/retrieve.tsx b/packages/cli/src/commands/approval-policy/retrieve.tsx similarity index 63% rename from packages/cli/src/commands/spending-policy/retrieve.tsx rename to packages/cli/src/commands/approval-policy/retrieve.tsx index 4b160e9..97a305e 100644 --- a/packages/cli/src/commands/spending-policy/retrieve.tsx +++ b/packages/cli/src/commands/approval-policy/retrieve.tsx @@ -1,5 +1,5 @@ // biome-ignore-all lint/suspicious/noArrayIndexKey: Policy rules are ordered and have no identifiers. -import type { ISpendingPolicyResource, SpendingPolicy } from '@stripe/link-sdk'; +import type { ApprovalPolicy, IApprovalPolicyResource } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; @@ -7,12 +7,12 @@ import { useCallback } from 'react'; import { useAsyncAction } from '../../hooks/use-async-action'; import { formatAmount } from '../../utils/format-amount'; -interface SpendingPolicyRetrieveProps { - resource: ISpendingPolicyResource; - onComplete: (result: SpendingPolicy | null) => void; +interface ApprovalPolicyRetrieveProps { + resource: IApprovalPolicyResource; + onComplete: (result: ApprovalPolicy | null) => void; } -export const SpendingPolicyRetrieve: React.FC = ({ +export const ApprovalPolicyRetrieve: React.FC = ({ resource, onComplete, }) => { @@ -23,7 +23,7 @@ export const SpendingPolicyRetrieve: React.FC = ({ return ( - Loading spending policy... + Loading approval policy... ); @@ -32,7 +32,7 @@ export const SpendingPolicyRetrieve: React.FC = ({ if (status === 'error') { return ( - ✗ Failed to load spending policy + ✗ Failed to load approval policy {error} ); @@ -40,7 +40,7 @@ export const SpendingPolicyRetrieve: React.FC = ({ return ( - Spending Policy + Approval Policy {policy?.rules.map((rule, index) => ( = ({ Action: {rule.action} - {rule.approval_type ? ( - - Approval: - {rule.approval_type} - - ) : null} - {rule.limits ? ( - - Per-purchase limit: - {formatAmount( - rule.limits.per_purchase.amount, - rule.limits.per_purchase.currency, - )} - - ) : null} + + Per-purchase limit: + {formatAmount( + rule.limits.per_purchase.amount, + rule.limits.per_purchase.currency, + )} + {rule.allowed_payment_methods ? ( Allowed payment methods: diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 648a743..72d29a7 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,12 +1,12 @@ import { type AccessTokenProvider, + type IApprovalPolicyResource, type IAttestationsResource, type IBalancesResource, type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, type ISourcesResource, - type ISpendingPolicyResource, type ISpendRequestResource, type ISummariesResource, type ITransactionsResource, @@ -116,7 +116,7 @@ export class ResourceFactory { private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; private userInfoResource?: IUserInfoResource; - private spendingPolicyResource?: ISpendingPolicyResource; + private approvalPolicyResource?: IApprovalPolicyResource; private transactionsResource?: ITransactionsResource; private sourcesResource?: ISourcesResource; private summariesResource?: ISummariesResource; @@ -276,13 +276,13 @@ export class ResourceFactory { return resource; } - createSpendingPolicyResource(): ISpendingPolicyResource { - if (this.spendingPolicyResource) { - return this.spendingPolicyResource; + createApprovalPolicyResource(): IApprovalPolicyResource { + if (this.approvalPolicyResource) { + return this.approvalPolicyResource; } - const resource = sanitizeResource(this.createSdkClient().spendingPolicy); - this.spendingPolicyResource = resource; + const resource = sanitizeResource(this.createSdkClient().approvalPolicy); + this.approvalPolicyResource = resource; return resource; } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index dc3cda7..25b6a1a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -27,10 +27,10 @@ const link = new Link({ accessToken: process.env.LINK_ACCESS_TOKEN! }); const paymentMethods = await link.paymentMethods.list(); ``` -Retrieve the spending policy for the current app and user: +Retrieve the approval policy for the current app and user: ```ts -const spendingPolicy = await link.spendingPolicy.retrieve(); +const approvalPolicy = await link.approvalPolicy.retrieve(); ``` Use a fixed token for a short-lived job or when the caller replaces the entire diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8f539d2..e6a81e7 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,14 +1,15 @@ import type { LinkOptions } from '@/config'; +import { ApprovalPolicyResource } from '@/resources/approval-policy'; import { AttestationsResource } from '@/resources/attestations'; import { BalancesResource } from '@/resources/balances'; import type { + IApprovalPolicyResource, IAttestationsResource, IBalancesResource, IPaymentMethodsResource, IReportResource, IShippingAddressResource, ISourcesResource, - ISpendingPolicyResource, ISpendRequestResource, ISummariesResource, ITransactionsResource, @@ -20,7 +21,6 @@ import { ReportResource } from '@/resources/report'; import { ShippingAddressResource } from '@/resources/shipping-address'; import { SourcesResource } from '@/resources/sources'; import { SpendRequestResource } from '@/resources/spend-request'; -import { SpendingPolicyResource } from '@/resources/spending-policy'; import { SummariesResource } from '@/resources/summaries'; import { TransactionsResource } from '@/resources/transactions'; import { UserInfoResource } from '@/resources/user-info'; @@ -32,7 +32,7 @@ export class Link { readonly paymentMethods: IPaymentMethodsResource; readonly shippingAddresses: IShippingAddressResource; readonly userInfo: IUserInfoResource; - readonly spendingPolicy: ISpendingPolicyResource; + readonly approvalPolicy: IApprovalPolicyResource; readonly transactions: ITransactionsResource; readonly sources: ISourcesResource; readonly balances: IBalancesResource; @@ -46,7 +46,7 @@ export class Link { this.paymentMethods = new PaymentMethodsResource(options); this.shippingAddresses = new ShippingAddressResource(options); this.userInfo = new UserInfoResource(options); - this.spendingPolicy = new SpendingPolicyResource(options); + this.approvalPolicy = new ApprovalPolicyResource(options); this.transactions = new TransactionsResource(options); this.sources = new SourcesResource(options); this.balances = new BalancesResource(options); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f6fc0b5..bebbbda 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -7,9 +7,9 @@ export { LinkSdkError, LinkTransportError, } from './errors'; +export { ApprovalPolicyResource } from './resources/approval-policy'; export * from './resources/attestations'; export * from './resources/interfaces'; export { getDuplicateSpendRequest } from './resources/spend-request'; -export { SpendingPolicyResource } from './resources/spending-policy'; export * from './resources/summaries'; export * from './types/index'; diff --git a/packages/sdk/src/resources/__tests__/spending-policy.test.ts b/packages/sdk/src/resources/__tests__/approval-policy.test.ts similarity index 55% rename from packages/sdk/src/resources/__tests__/spending-policy.test.ts rename to packages/sdk/src/resources/__tests__/approval-policy.test.ts index 3aeb93c..4b6eba0 100644 --- a/packages/sdk/src/resources/__tests__/spending-policy.test.ts +++ b/packages/sdk/src/resources/__tests__/approval-policy.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SpendingPolicyResource } from '@/resources/spending-policy'; +import { ApprovalPolicyResource } from '@/resources/approval-policy'; const mockFetch = vi.fn(); const getAccessToken = vi.fn(); @@ -13,32 +13,30 @@ function mockFetchResponse(status: number, body: unknown) { }); } -describe('SpendingPolicyResource', () => { - let resource: SpendingPolicyResource; +describe('ApprovalPolicyResource', () => { + let resource: ApprovalPolicyResource; beforeEach(() => { vi.stubGlobal('fetch', mockFetch); vi.clearAllMocks(); getAccessToken.mockResolvedValue('test_token'); - resource = new SpendingPolicyResource({ getAccessToken }); + resource = new ApprovalPolicyResource({ getAccessToken }); }); afterEach(() => { vi.unstubAllGlobals(); }); - it('retrieves and parses the spending policy', async () => { + it('retrieves and parses the approval policy', async () => { const policy = { rules: [ { - action: 'allow', - approval_type: 'automatic', + action: 'spend_request_create', limits: { per_purchase: { amount: 5000, currency: 'usd' }, }, allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'], }, - { action: 'allow' }, ], }; mockFetchResponse(200, policy); @@ -47,31 +45,53 @@ describe('SpendingPolicyResource', () => { expect(mockFetch).toHaveBeenCalledOnce(); const [url, opts] = mockFetch.mock.calls[0]!; - expect(url).toBe('https://api.link.com/spending-policy'); + expect(url).toBe('https://api.link.com/approval-policy'); expect(opts.method).toBe('GET'); expect(opts.headers.Authorization).toBe('Bearer test_token'); }); - it('accepts the manual-only default policy', async () => { - mockFetchResponse(200, { - rules: [{ action: 'allow', approval_type: 'manual' }], - }); - - await expect(resource.retrieve()).resolves.toEqual({ - rules: [{ action: 'allow', approval_type: 'manual' }], - }); - }); - it('throws API errors with the response message', async () => { mockFetchResponse(403, { error: { code: 'feature_unavailable' }, }); await expect(resource.retrieve()).rejects.toThrow( - 'Failed to retrieve spending policy (403): feature_unavailable', + 'Failed to retrieve approval policy (403): feature_unavailable', ); }); + it('throws the configured-policy not-found error', async () => { + mockFetchResponse(404, { + error: { + message: 'No approval policy has been configured', + code: 'approval_policy_not_found', + }, + }); + + await expect(resource.retrieve()).rejects.toMatchObject({ + status: 404, + message: + 'Failed to retrieve approval policy (404): No approval policy has been configured', + }); + }); + + it('accepts an explicitly empty policy', async () => { + mockFetchResponse(200, { rules: [] }); + + await expect(resource.retrieve()).resolves.toEqual({ rules: [] }); + }); + + it('requires limits on every rule', async () => { + mockFetchResponse(200, { + rules: [{ action: 'spend_request_create' }], + }); + + await expect(resource.retrieve()).rejects.toMatchObject({ + code: 'invalid_response', + status: 200, + }); + }); + it('throws when the response shape is invalid', async () => { mockFetchResponse(200, { rules: 'not an array' }); diff --git a/packages/sdk/src/resources/approval-policy.ts b/packages/sdk/src/resources/approval-policy.ts new file mode 100644 index 0000000..8424602 --- /dev/null +++ b/packages/sdk/src/resources/approval-policy.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import type { LinkOptions } from '@/config'; +import { BaseResource } from '@/resources/base'; +import type { IApprovalPolicyResource } from '@/resources/interfaces'; +import type { ApprovalPolicy } from '@/types/index'; + +const approvalPolicySchema = z.looseObject({ + rules: z.array( + z.looseObject({ + action: z.string(), + limits: z.looseObject({ + per_purchase: z.looseObject({ + amount: z.number().int(), + currency: z.string(), + }), + }), + allowed_payment_methods: z.array(z.string()).optional(), + }), + ), +}); + +export class ApprovalPolicyResource + extends BaseResource + implements IApprovalPolicyResource +{ + constructor(options: LinkOptions) { + super(options, '/approval-policy'); + } + + async retrieve(): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: this.endpoint, + }); + + if (status < 200 || status >= 300) { + this.throwApiError('retrieve approval policy', status, data, rawBody); + } + + return this.parseResponse( + 'retrieve approval policy', + status, + () => approvalPolicySchema.parse(data) as ApprovalPolicy, + ); + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 990c9e7..48149f4 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -1,5 +1,6 @@ import type { ApprovalDetail, + ApprovalPolicy, BalancesPage, CredentialType, LineItem, @@ -7,7 +8,6 @@ import type { RequestApprovalResponse, ShippingAddressRecord, SourcesPage, - SpendingPolicy, SpendRequest, SummariesPage, Total, @@ -95,8 +95,8 @@ export interface IUserInfoResource { retrieve(): Promise; } -export interface ISpendingPolicyResource { - retrieve(): Promise; +export interface IApprovalPolicyResource { + retrieve(): Promise; } export interface IWebBotAuthResource { diff --git a/packages/sdk/src/resources/spending-policy.ts b/packages/sdk/src/resources/spending-policy.ts deleted file mode 100644 index 7a1020c..0000000 --- a/packages/sdk/src/resources/spending-policy.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from 'zod'; -import type { LinkOptions } from '@/config'; -import { BaseResource } from '@/resources/base'; -import type { ISpendingPolicyResource } from '@/resources/interfaces'; -import type { SpendingPolicy } from '@/types/index'; - -const spendingPolicySchema = z.looseObject({ - rules: z.array( - z.looseObject({ - action: z.string(), - approval_type: z.string().optional(), - limits: z - .looseObject({ - per_purchase: z.looseObject({ - amount: z.number().int(), - currency: z.string(), - }), - }) - .optional(), - allowed_payment_methods: z.array(z.string()).optional(), - }), - ), -}); - -export class SpendingPolicyResource - extends BaseResource - implements ISpendingPolicyResource -{ - constructor(options: LinkOptions) { - super(options, '/spending-policy'); - } - - async retrieve(): Promise { - const { status, data, rawBody } = await this.apiFetch({ - method: 'GET', - url: this.endpoint, - }); - - if (status < 200 || status >= 300) { - this.throwApiError('retrieve spending policy', status, data, rawBody); - } - - return this.parseResponse( - 'retrieve spending policy', - status, - () => spendingPolicySchema.parse(data) as SpendingPolicy, - ); - } -} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 506204b..7126e89 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -227,32 +227,27 @@ export interface UserInfo { } /** Known actions, while remaining forward-compatible with new API values. */ -export type SpendingPolicyAction = 'allow' | (string & Record); - -/** Known approval types, while remaining forward-compatible with new API values. */ -export type SpendingPolicyApprovalType = - | 'manual' - | 'automatic' +export type ApprovalPolicyAction = + | 'spend_request_create' | (string & Record); -export interface SpendingPolicyAmount { +export interface ApprovalPolicyAmount { amount: number; currency: string; } -export interface SpendingPolicyLimits { - per_purchase: SpendingPolicyAmount; +export interface ApprovalPolicyLimits { + per_purchase: ApprovalPolicyAmount; } -export interface SpendingPolicyRule { - action: SpendingPolicyAction; - approval_type?: SpendingPolicyApprovalType; - limits?: SpendingPolicyLimits; +export interface ApprovalPolicyRule { + action: ApprovalPolicyAction; + limits: ApprovalPolicyLimits; allowed_payment_methods?: string[]; } -export interface SpendingPolicy { - rules: SpendingPolicyRule[]; +export interface ApprovalPolicy { + rules: ApprovalPolicyRule[]; } export interface ProductCapability { diff --git a/plugins/cursor-link/.cursor-plugin/plugin.json b/plugins/cursor-link/.cursor-plugin/plugin.json index 27ab987..c75f07f 100644 --- a/plugins/cursor-link/.cursor-plugin/plugin.json +++ b/plugins/cursor-link/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "link", "displayName": "Link", - "version": "0.15.1", + "version": "0.21.0", "description": "Complete purchases with one-time-use payment credentials from a Link wallet, using spend requests the user has approved.", "author": { "name": "Stripe" @@ -10,7 +10,13 @@ "repository": "https://github.com/stripe/link-cli", "license": "MIT", "logo": "./assets/link.svg", - "keywords": ["payment", "link", "stripe", "agentic-commerce", "mcp"], + "keywords": [ + "payment", + "link", + "stripe", + "agentic-commerce", + "mcp" + ], "skills": "./skills/", "mcpServers": "./.mcp.json" } diff --git a/plugins/link/.claude-plugin/plugin.json b/plugins/link/.claude-plugin/plugin.json index d2c3f3f..e6d437b 100644 --- a/plugins/link/.claude-plugin/plugin.json +++ b/plugins/link/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.15.1", + "version": "0.21.0", "description": "Authenticate with Link, create spend requests, and retrieve one-time-use card or shared payment token credentials for user-approved purchases.", "author": { "name": "Stripe" diff --git a/plugins/link/.codex-plugin/plugin.json b/plugins/link/.codex-plugin/plugin.json index 2b24961..7cb31d3 100644 --- a/plugins/link/.codex-plugin/plugin.json +++ b/plugins/link/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.15.1", + "version": "0.21.0", "description": "Secure, one-time-use payment credentials from Link", "author": { "name": "Stripe", diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 4b6de0e..17efa6a 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.15.1 +version: 0.21.0 name: create-payment-credential description: | Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account. diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index 182f244..57a4f8f 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.15.1 +version: 0.21.0 name: financial-insights description: | Reads Link financial data to answer questions about spending, balances, transactions, linked sources, and shopping preferences. Also use alongside a purchase skill when acting as a personal shopper and the user has not specified a merchant, or asks for their usual, favorite, or preferred store. From e2ce60a991f6d54034fba21747e04efd1398b9b2 Mon Sep 17 00:00:00 2001 From: John Xu Date: Mon, 21 Sep 2026 13:20:32 -0400 Subject: [PATCH 4/5] fix ci --- plugins/cursor-link/.cursor-plugin/plugin.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/plugins/cursor-link/.cursor-plugin/plugin.json b/plugins/cursor-link/.cursor-plugin/plugin.json index c75f07f..8882ad9 100644 --- a/plugins/cursor-link/.cursor-plugin/plugin.json +++ b/plugins/cursor-link/.cursor-plugin/plugin.json @@ -10,13 +10,7 @@ "repository": "https://github.com/stripe/link-cli", "license": "MIT", "logo": "./assets/link.svg", - "keywords": [ - "payment", - "link", - "stripe", - "agentic-commerce", - "mcp" - ], + "keywords": ["payment", "link", "stripe", "agentic-commerce", "mcp"], "skills": "./skills/", "mcpServers": "./.mcp.json" } From 601e5e282fcf25a5f282caee476a2e389264380b Mon Sep 17 00:00:00 2001 From: John Xu Date: Tue, 22 Sep 2026 15:14:57 -0400 Subject: [PATCH 5/5] update --- plugins/cursor-link/.cursor-plugin/plugin.json | 2 +- plugins/link/.claude-plugin/plugin.json | 2 +- plugins/link/.codex-plugin/plugin.json | 2 +- skills/create-payment-credential/SKILL.md | 2 +- skills/financial-insights/SKILL.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/cursor-link/.cursor-plugin/plugin.json b/plugins/cursor-link/.cursor-plugin/plugin.json index 8882ad9..27ab987 100644 --- a/plugins/cursor-link/.cursor-plugin/plugin.json +++ b/plugins/cursor-link/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "link", "displayName": "Link", - "version": "0.21.0", + "version": "0.15.1", "description": "Complete purchases with one-time-use payment credentials from a Link wallet, using spend requests the user has approved.", "author": { "name": "Stripe" diff --git a/plugins/link/.claude-plugin/plugin.json b/plugins/link/.claude-plugin/plugin.json index e6d437b..d2c3f3f 100644 --- a/plugins/link/.claude-plugin/plugin.json +++ b/plugins/link/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.21.0", + "version": "0.15.1", "description": "Authenticate with Link, create spend requests, and retrieve one-time-use card or shared payment token credentials for user-approved purchases.", "author": { "name": "Stripe" diff --git a/plugins/link/.codex-plugin/plugin.json b/plugins/link/.codex-plugin/plugin.json index 7cb31d3..2b24961 100644 --- a/plugins/link/.codex-plugin/plugin.json +++ b/plugins/link/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.21.0", + "version": "0.15.1", "description": "Secure, one-time-use payment credentials from Link", "author": { "name": "Stripe", diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 17efa6a..4b6de0e 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.21.0 +version: 0.15.1 name: create-payment-credential description: | Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account. diff --git a/skills/financial-insights/SKILL.md b/skills/financial-insights/SKILL.md index 57a4f8f..182f244 100644 --- a/skills/financial-insights/SKILL.md +++ b/skills/financial-insights/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.21.0 +version: 0.15.1 name: financial-insights description: | Reads Link financial data to answer questions about spending, balances, transactions, linked sources, and shopping preferences. Also use alongside a purchase skill when acting as a personal shopper and the user has not specified a merchant, or asks for their usual, favorite, or preferred store.