From 7da094548356649886d0913a3f0ed417c5f4067c Mon Sep 17 00:00:00 2001 From: John Xu Date: Thu, 17 Sep 2026 15:41:30 -0400 Subject: [PATCH 1/4] 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 00000000..a4c42a26 --- /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 6ba88b90..28932802 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 e6d7509e..ba41abe2 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 065ea709..14a9a623 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 00000000..ae18d603 --- /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 00000000..04dc3941 --- /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 00000000..4b160e9f --- /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 a0e81762..648a7437 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 295e09b3..dc3cda70 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 385382d6..2ee5c89a 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 92c31760..f50c11b0 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 00000000..3aeb93cf --- /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 059157c4..990c9e75 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 00000000..7a1020ce --- /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 f5d7caaa..506204be 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/4] 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 14a9a623..9ceaec1b 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 2ee5c89a..8f539d26 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 f50c11b0..f6fc0b51 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/4] 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 a4c42a26..817878de 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 28932802..3c3185cf 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 ba41abe2..271d778e 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 9ceaec1b..99ea90c5 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 ae18d603..aca5fe66 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 04dc3941..83ad4af7 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 4b160e9f..97a305ec 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 648a7437..72d29a75 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 dc3cda70..25b6a1ae 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 8f539d26..e6a81e73 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 f6fc0b51..bebbbdaf 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 3aeb93cf..4b6eba0e 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 00000000..8424602e --- /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 990c9e75..48149f41 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 7a1020ce..00000000 --- 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 506204be..7126e891 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 27ab9870..c75f07f1 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 d2c3f3f3..e6d437bf 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 2b249616..7cb31d31 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 4b6de0e5..17efa6aa 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 182f244a..57a4f8f7 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/4] 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 c75f07f1..8882ad93 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" }