Repository navigation
feat(core): expose card settings and shared payment usage [SAP-4057] [SAP-4073] #1239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a5ed847
96e761c
d1a69a3
2d272fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@sapiom/core": minor | ||
| --- | ||
|
|
||
| Add card spending-limit management and trusted confirmed-refund recording. These methods require the corresponding backend API deployment and management permissions. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,35 +19,50 @@ Core SDK for Sapiom API providing HTTP client adapters, transaction management, | |
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| npm install @sapiom/core | ||
| # or | ||
| pnpm add @sapiom/core | ||
| # or | ||
| yarn add @sapiom/core | ||
| ### Card spending limits | ||
|
|
||
| Use a trusted platform credential with `org.read` / `org.write` to manage a card's common spending rules. Keep write permissions outside the purchasing agent. These methods require the corresponding backend deployment. | ||
|
|
||
| ```typescript | ||
| const current = await client.paymentCards.getSpendingLimits(cardId); | ||
| const saved = await client.paymentCards.setSpendingLimits(cardId, { | ||
| version: current.version, | ||
| purchaseLimit: { amount: "100.00", currency: "USD" }, | ||
| recurringLimit: { amount: "500.00", currency: "USD", period: "month" }, | ||
| customLimit: null, | ||
| }); | ||
| console.log(saved.usage.recurringLimit?.remaining); | ||
| ``` | ||
|
|
||
| The helper owns one ordinary `payment_spending_limit` rule scoped to this card. Other Rules remain independent and appear in `additionalLimits`; transaction-specific conditions still require the full purchase context. A rule grouped by `paymentCardId` gives every card its own allowance. An ungrouped rule can share a budget across cards. | ||
|
|
||
| Periods are UTC calendar weeks (Monday), months, years, or inclusive custom dates. A custom-date budget applies only within its dates. No rule means no spending-limit prerequisite. Other applicable Rules and card readiness checks still apply. | ||
|
|
||
| Pending payments reserve capacity. Releasing the raw card credential books the full payment amount once; later merchant failures or cancellation do not restore it. M0 does not infer charges or refunds from merchant outcomes and exposes no refund-confirmation API. | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| Unchanged saves preserve version and usage. Changing a cumulative budget starts a fresh allowance for that slot; other slots retain usage. Submit the latest version to avoid conflicting updates. Quantities are null when accounting is unavailable. `configurationActive` describes this helper's configuration, not permission to purchase. | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ### Direct API Client | ||
|
|
||
| ```typescript | ||
| import { SapiomClient } from '@sapiom/core'; | ||
| import { SapiomClient } from "@sapiom/core"; | ||
|
|
||
| const client = new SapiomClient({ | ||
| apiKey: process.env.SAPIOM_API_KEY, | ||
| baseURL: 'https://api.sapiom.ai' // optional | ||
| baseURL: "https://api.sapiom.ai", // optional | ||
| }); | ||
|
Comment on lines
49
to
55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Unrelated example formatting obscures feature changes The Learn moreThe repository's focused-change rule asks contributors to avoid drive-by cleanup and unrelated formatting churn. Existing examples throughout the README changed quote style, commas, and wrapping even though the card-limit feature does not affect them. Keeping those lines unchanged isolates the feature documentation for review. Example: The direct-client quick start now uses double quotes and trailing commas; the Axios, Fetch, Node HTTP, and vault examples receive similar changes without behavior changes. Recommended fix: Restore the original formatting in existing README examples while retaining the new card-limit documentation. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| // Create a transaction | ||
| const transaction = await client.transactions.create({ | ||
| service: 'api', | ||
| action: 'call', | ||
| resource: 'completion', | ||
| service: "api", | ||
| action: "call", | ||
| resource: "completion", | ||
| qualifiers: { | ||
| model: 'gpt-4', | ||
| tokens: 1000 | ||
| } | ||
| model: "gpt-4", | ||
| tokens: 1000, | ||
| }, | ||
| }); | ||
|
|
||
| // Check transaction status | ||
|
|
@@ -63,17 +78,20 @@ npm install @sapiom/axios axios | |
| ``` | ||
|
|
||
| ```typescript | ||
| import axios from 'axios'; | ||
| import { withSapiom } from '@sapiom/axios'; | ||
|
|
||
| const client = withSapiom(axios.create({ | ||
| baseURL: 'https://api.example.com' | ||
| }), { | ||
| apiKey: process.env.SAPIOM_API_KEY | ||
| }); | ||
| import axios from "axios"; | ||
| import { withSapiom } from "@sapiom/axios"; | ||
|
|
||
| const client = withSapiom( | ||
| axios.create({ | ||
| baseURL: "https://api.example.com", | ||
| }), | ||
| { | ||
| apiKey: process.env.SAPIOM_API_KEY, | ||
| }, | ||
| ); | ||
|
|
||
| // Automatically handles 402 payment flows | ||
| const response = await client.get('/premium-endpoint'); | ||
| const response = await client.get("/premium-endpoint"); | ||
| ``` | ||
|
|
||
| See [@sapiom/axios](../axios/README.md) for complete documentation. | ||
|
|
@@ -87,14 +105,14 @@ npm install @sapiom/fetch | |
| ``` | ||
|
|
||
| ```typescript | ||
| import { createFetch } from '@sapiom/fetch'; | ||
| import { createFetch } from "@sapiom/fetch"; | ||
|
|
||
| const safeFetch = createFetch({ | ||
| apiKey: process.env.SAPIOM_API_KEY | ||
| apiKey: process.env.SAPIOM_API_KEY, | ||
| }); | ||
|
|
||
| // Drop-in replacement for native fetch | ||
| const response = await safeFetch('https://api.example.com/data'); | ||
| const response = await safeFetch("https://api.example.com/data"); | ||
| ``` | ||
|
|
||
| See [@sapiom/fetch](../fetch/README.md) for complete documentation. | ||
|
|
@@ -108,15 +126,15 @@ npm install @sapiom/node-http | |
| ``` | ||
|
|
||
| ```typescript | ||
| import { createClient } from '@sapiom/node-http'; | ||
| import { createClient } from "@sapiom/node-http"; | ||
|
|
||
| const client = createClient({ | ||
| apiKey: process.env.SAPIOM_API_KEY | ||
| apiKey: process.env.SAPIOM_API_KEY, | ||
| }); | ||
|
|
||
| const response = await client.request({ | ||
| method: 'GET', | ||
| url: 'https://api.example.com/data' | ||
| method: "GET", | ||
| url: "https://api.example.com/data", | ||
| }); | ||
| ``` | ||
|
|
||
|
|
@@ -133,6 +151,7 @@ new SapiomClient(config: SapiomClientConfig) | |
| ``` | ||
|
|
||
| **Config options:** | ||
|
|
||
| - `apiKey: string` - Required: Your Sapiom API key | ||
| - `baseURL?: string` - Optional: API base URL (default: https://api.sapiom.ai) | ||
| - `servicesDomain?: string` - Optional: Services domain for gateway clients (default: services.sapiom.ai) | ||
|
|
@@ -173,21 +192,21 @@ client.transactions.getPaymentDetails(transaction): PaymentDetails | null | |
|
|
||
| ```typescript | ||
| // Store one secret under an external ref | ||
| await client.vault.set('agent-123', 'OPENAI_API_KEY', { | ||
| value: 'sk-...' | ||
| await client.vault.set("agent-123", "OPENAI_API_KEY", { | ||
| value: "sk-...", | ||
| }); | ||
|
|
||
| // Read one secret. Missing keys return null. | ||
| const openaiKey = await client.vault.get('agent-123', 'OPENAI_API_KEY'); | ||
| const openaiKey = await client.vault.get("agent-123", "OPENAI_API_KEY"); | ||
|
|
||
| // Read a subset of secrets from the same ref | ||
| const secrets = await client.vault.getMany('agent-123', [ | ||
| 'OPENAI_API_KEY', | ||
| 'ANTHROPIC_API_KEY' | ||
| const secrets = await client.vault.getMany("agent-123", [ | ||
| "OPENAI_API_KEY", | ||
| "ANTHROPIC_API_KEY", | ||
| ]); | ||
|
|
||
| // Remove one key | ||
| await client.vault.deleteKey('agent-123', 'OPENAI_API_KEY'); | ||
| await client.vault.deleteKey("agent-123", "OPENAI_API_KEY"); | ||
| ``` | ||
|
|
||
| ## HTTP Integrations | ||
|
|
@@ -247,7 +266,11 @@ try { | |
| ### Custom HTTP Adapter | ||
|
|
||
| ```typescript | ||
| import type { HttpClientAdapter, HttpRequest, HttpResponse } from '@sapiom/core'; | ||
| import type { | ||
| HttpClientAdapter, | ||
| HttpRequest, | ||
| HttpResponse, | ||
| } from "@sapiom/core"; | ||
|
|
||
| class MyCustomAdapter implements HttpClientAdapter { | ||
| async request(config: HttpRequest): Promise<HttpResponse> { | ||
|
|
@@ -259,11 +282,11 @@ class MyCustomAdapter implements HttpClientAdapter { | |
| ### Payment Error Detection | ||
|
|
||
| ```typescript | ||
| import { isPaymentRequiredError, extractX402Response } from '@sapiom/core'; | ||
| import { isPaymentRequiredError, extractX402Response } from "@sapiom/core"; | ||
|
|
||
| if (isPaymentRequiredError(error)) { | ||
| const x402Response = extractX402Response(error); | ||
| console.log('Payment required:', x402Response); | ||
| console.log("Payment required:", x402Response); | ||
| } | ||
| ``` | ||
|
|
||
|
|
@@ -278,7 +301,7 @@ import type { | |
| HttpResponse, | ||
| X402Response, | ||
| X402PaymentRequirement, | ||
| } from '@sapiom/core'; | ||
| } from "@sapiom/core"; | ||
| ``` | ||
|
|
||
| ## License | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { SapiomClient } from "@sapiom/core"; | ||
|
|
||
| /** Run from a trusted platform backend after the matching API and SDK have been released. */ | ||
| async function configureCard() { | ||
| const apiKey = process.env.SAPIOM_MANAGEMENT_API_KEY; | ||
| const cardId = process.env.SAPIOM_CARD_ID; | ||
| if (!apiKey || !cardId) | ||
| throw new Error("Set SAPIOM_MANAGEMENT_API_KEY and SAPIOM_CARD_ID"); | ||
| const client = new SapiomClient({ | ||
| apiKey, | ||
| baseURL: process.env.SAPIOM_API_URL, | ||
| backgroundRefresh: false, | ||
| }); | ||
| const current = await client.paymentCards.getSpendingLimits(cardId); | ||
| const saved = await client.paymentCards.setSpendingLimits(cardId, { | ||
| version: current.version, | ||
| purchaseLimit: { amount: "100.00", currency: "USD" }, | ||
| recurringLimit: { amount: "500.00", currency: "USD", period: "month" }, | ||
| customLimit: null, | ||
| }); | ||
| console.log( | ||
| JSON.stringify( | ||
| { cardId, version: saved.version, usage: saved.usage }, | ||
| null, | ||
| 2, | ||
| ), | ||
| ); | ||
| } | ||
| void configureCard().catch((error: Error) => { | ||
| console.error(error.message); | ||
| process.exitCode = 1; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { SapiomClient } from "./SapiomClient"; | ||
| import type { CardSpendingLimitsSettings } from "../types/payment-card"; | ||
|
|
||
| describe("Card-limit management through the public client", () => { | ||
| const cardId = "11111111-1111-4111-8111-111111111111"; | ||
| let client: SapiomClient; | ||
| let fetchMock: jest.Mock; | ||
| const originalFetch = global.fetch; | ||
| beforeEach(() => { | ||
| fetchMock = jest.fn(); | ||
| global.fetch = fetchMock; | ||
| client = new SapiomClient({ | ||
| apiKey: "fixture-management-key", | ||
| baseURL: "https://api.test", | ||
| backgroundRefresh: false, | ||
| }); | ||
| }); | ||
| afterEach(() => { | ||
| global.fetch = originalFetch; | ||
| }); | ||
| const json = (body: unknown, status = 200) => | ||
| new Response(JSON.stringify(body), { | ||
| status, | ||
| headers: { "content-type": "application/json" }, | ||
| }); | ||
| const settings: CardSpendingLimitsSettings = { | ||
| version: 0, | ||
| purchaseLimit: { amount: "100.00", currency: "USD" }, | ||
| recurringLimit: { amount: "500.00", currency: "USD", period: "month" }, | ||
| customLimit: null, | ||
| }; | ||
|
|
||
| it("uses versioned routes and preserves explicit slots and decimal amounts", async () => { | ||
| fetchMock.mockResolvedValueOnce(json({ ...settings, version: 1 })); | ||
| expect( | ||
| (await client.paymentCards.setSpendingLimits(cardId, settings)).version, | ||
| ).toBe(1); | ||
| const [url, request] = fetchMock.mock.calls[0]; | ||
| expect(String(url)).toBe( | ||
| `https://api.test/v1/payment-cards/${cardId}/spending-limits`, | ||
| ); | ||
| expect(request.method).toBe("PUT"); | ||
| expect(JSON.parse(request.body)).toEqual(settings); | ||
| expect(request.headers["x-api-key"]).toBe("fixture-management-key"); | ||
| fetchMock.mockResolvedValueOnce( | ||
| json({ | ||
| ...settings, | ||
| version: 1, | ||
| usage: { recurringLimit: { remaining: "450.00" } }, | ||
| }), | ||
| ); | ||
| expect( | ||
| (await client.paymentCards.getSpendingLimits(cardId)).usage.recurringLimit | ||
| ?.remaining, | ||
| ).toBe("450.00"); | ||
| }); | ||
| it("propagates a stale-version conflict without silently retrying a reset", async () => { | ||
| fetchMock.mockResolvedValue(json({ code: "RULE_VERSION_CONFLICT" }, 409)); | ||
| await expect( | ||
| client.paymentCards.setSpendingLimits(cardId, settings), | ||
| ).rejects.toThrow(/409.*RULE_VERSION_CONFLICT/); | ||
| expect(fetchMock).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import type { | ||
| CardSpendingLimitsSettings, | ||
| CardSpendingLimitsSummary, | ||
| } from "../types/payment-card.js"; | ||
| import type { HttpClient } from "./HttpClient.js"; | ||
|
|
||
| /** Management methods for a trusted platform backend; purchasing keys cannot change limits. */ | ||
| export class PaymentCardsAPI { | ||
| constructor(private readonly httpClient: HttpClient) {} | ||
|
|
||
| /** Read a card's versioned spending limits and current usage. Requires org.read. */ | ||
| async getSpendingLimits(cardId: string): Promise<CardSpendingLimitsSummary> { | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| return this.httpClient.request({ | ||
| method: "GET", | ||
| url: `/payment-cards/${encodeURIComponent(cardId)}/spending-limits`, | ||
| }); | ||
| } | ||
|
|
||
| /** All slots are explicit. Changed cumulative settings start fresh; unchanged saves preserve usage. */ | ||
| async setSpendingLimits( | ||
| cardId: string, | ||
| settings: CardSpendingLimitsSettings, | ||
| ): Promise<CardSpendingLimitsSummary> { | ||
| return this.httpClient.request({ | ||
| method: "PUT", | ||
| url: `/payment-cards/${encodeURIComponent(cardId)}/spending-limits`, | ||
| body: settings, | ||
| }); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Restore the core package installation commands.
The
Installationsection now starts with card spending limits and no longer tells readers how to install@sapiom/core. Restore the npm, pnpm, and yarn commands before this subsection so new users can follow the package setup instructions.🤖 Prompt for AI Agents