From 338682a28a11cd8369a34893c8fd051665303886 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 20 Oct 2026 21:53:01 +0000 Subject: [PATCH] feat(core): expose card approval and setup link helpers --- packages/core/CHANGELOG.md | 2 + packages/core/README.md | 33 +++++++ .../src/client/PaymentCardApprovalAPI.test.ts | 91 +++++++++++++++++++ packages/core/src/client/PaymentCardsAPI.ts | 46 +++++++++- packages/core/src/index.ts | 7 ++ packages/core/src/types/payment-card.ts | 34 +++++++ 6 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/client/PaymentCardApprovalAPI.test.ts diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 46741e87d..44725e40e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add versioned saved-card purchase-approval settings and hosted setup-link helpers. An explicitly authorized setup link can expose the approval toggle; enabling it requires Approve/Deny before the separate real-card CVV step. Requires the corresponding backend deployment and a new package publication. + - Add trusted-platform card spending-limit read/write helpers over ordinary payment Rules, with versioned settings, shared payment usage and additional applicable limits. Card spending is booked at credential release; no refund-confirmation API is provided. Requires the corresponding backend deployment; package publication is pending. ## 0.5.0 diff --git a/packages/core/README.md b/packages/core/README.md index cbc06a864..ffd7c0ec5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -314,3 +314,36 @@ MIT © [Sapiom](../../LICENSE) - [GitHub](https://github.com/sapiom/sapiom-js) - [NPM](https://www.npmjs.com/package/@sapiom/core) - [Issues](https://github.com/sapiom/sapiom-js/issues) + + +### Require approval for every card purchase + +Use a trusted backend key with `org.read` / `org.write` in the buyer's tenant scope. These methods manage one backing rule for the card. Other rules still apply. A purchasing key must not change its own governance. + +```ts +const current = await client.paymentCards.getPurchaseApproval(cardId); +const saved = await client.paymentCards.setPurchaseApproval(cardId, { + enabled: true, + version: current.version, +}); +// Persist/render saved.enabled and saved.version. A stale write returns 409; reload before editing. +``` + +To let a cardholder choose the same setting while adding their card: + +```ts +const setup = await client.paymentCards.createSetupLink({ + returnUrl: "https://your-app.example/cards", + purchaseApproval: { enabled: false, allowChanges: true }, +}); +// Send the cardholder to setup.url. Treat its fragment as a bearer secret. +// After return, retrieve the saved card and its actual approval setting: +const completed = await client.paymentCards.getSetupLink(setup.id); +if (completed.cardId) { + const approval = await client.paymentCards.getPurchaseApproval(completed.cardId); +} +``` + +Setup-link creation requires `org.transactions.write`; supplying `purchaseApproval` additionally requires `org.write`. Omit it to keep the prior default (off, with no policy-editing grant), or use `allowChanges: false` to fix the initial choice. The first submitted choice is pinned across retries, and setup keeps the card unavailable until the policy is persisted. Enabling approval adds a human Approve/Deny decision to **every** purchase on this card, before the separate real-card CVV confirmation. The purchasing agent cannot resolve that approval. + +Requires the companion backend deployment and publication of the SDK version containing these helpers. diff --git a/packages/core/src/client/PaymentCardApprovalAPI.test.ts b/packages/core/src/client/PaymentCardApprovalAPI.test.ts new file mode 100644 index 000000000..503a626bc --- /dev/null +++ b/packages/core/src/client/PaymentCardApprovalAPI.test.ts @@ -0,0 +1,91 @@ +import { SapiomClient } from "./SapiomClient"; +import type { + CardPurchaseApprovalSettings, + CreateCardSetupLinkInput, +} from "../types/payment-card"; + +describe("Saved-card purchase approval through the public SDK", () => { + const cardId = "11111111-1111-4111-8111-111111111111"; + const originalFetch = global.fetch; + let fetchMock: jest.Mock; + let client: SapiomClient; + 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" }, + }); + + it("reads and updates the saved setting, retaining its concurrency version", async () => { + fetchMock.mockResolvedValueOnce(json({ enabled: false, version: 0 })); + expect(await client.paymentCards.getPurchaseApproval(cardId)).toEqual({ + enabled: false, + version: 0, + }); + const setting: CardPurchaseApprovalSettings = { enabled: true, version: 0 }; + fetchMock.mockResolvedValueOnce(json({ enabled: true, version: 1 })); + expect( + await client.paymentCards.setPurchaseApproval(cardId, setting), + ).toEqual({ enabled: true, version: 1 }); + const [url, request] = fetchMock.mock.calls[1]; + expect(String(url)).toBe( + `https://api.test/v1/payment-cards/${cardId}/purchase-approval`, + ); + expect(request.method).toBe("PUT"); + expect(JSON.parse(request.body)).toEqual(setting); + expect(request.headers["x-api-key"]).toBe("fixture-management-key"); + }); + + it("passes the bounded approval grant to hosted setup and retrieves the resulting card", async () => { + const input: CreateCardSetupLinkInput = { + returnUrl: "https://buyer.example/cards", + purchaseApproval: { enabled: false, allowChanges: true }, + }; + fetchMock.mockResolvedValueOnce( + json({ + id: "link", + status: "open", + url: "https://app.test/add-card#private-code", + purchaseApproval: input.purchaseApproval, + }), + ); + const link = await client.paymentCards.createSetupLink(input); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(input); + fetchMock.mockResolvedValueOnce( + json({ + id: "link", + status: "completed", + cardId, + purchaseApproval: { enabled: true, allowChanges: false }, + }), + ); + expect((await client.paymentCards.getSetupLink(link.id)).cardId).toBe( + cardId, + ); + expect(String(fetchMock.mock.calls[1][0])).toBe( + "https://api.test/v1/payment-cards/setup-links/link", + ); + }); + + it("surfaces conflicts without retrying a policy overwrite", async () => { + fetchMock.mockResolvedValue(json({ code: "RULE_VERSION_CONFLICT" }, 409)); + await expect( + client.paymentCards.setPurchaseApproval(cardId, { + enabled: false, + version: 1, + }), + ).rejects.toThrow(/409.*RULE_VERSION_CONFLICT/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/client/PaymentCardsAPI.ts b/packages/core/src/client/PaymentCardsAPI.ts index 496457093..b546248a1 100644 --- a/packages/core/src/client/PaymentCardsAPI.ts +++ b/packages/core/src/client/PaymentCardsAPI.ts @@ -1,10 +1,13 @@ import type { CardSpendingLimitsSettings, + CardPurchaseApprovalSettings, + CreateCardSetupLinkInput, + CardSetupLinkView, CardSpendingLimitsSummary, } from "../types/payment-card.js"; import type { HttpClient } from "./HttpClient.js"; -/** Management methods for a trusted platform backend; purchasing keys cannot change limits. */ +/** Saved-card purchase policies for a trusted platform backend. Purchasing keys cannot edit policy. */ export class PaymentCardsAPI { constructor(private readonly httpClient: HttpClient) {} @@ -27,4 +30,45 @@ export class PaymentCardsAPI { body: settings, }); } + + /** Read the card-owned purchase approval setting. Requires org.read. */ + async getPurchaseApproval( + cardId: string, + ): Promise { + return this.httpClient.request({ + method: "GET", + url: `/payment-cards/${encodeURIComponent(cardId)}/purchase-approval`, + }); + } + + /** Requires org.write and the current version. Disabling only pauses this card's backing rule. */ + async setPurchaseApproval( + cardId: string, + settings: CardPurchaseApprovalSettings, + ): Promise { + return this.httpClient.request({ + method: "PUT", + url: `/payment-cards/${encodeURIComponent(cardId)}/purchase-approval`, + body: settings, + }); + } + + /** Mint a one-card hosted vault link. Purchase-approval options require org.write in addition to card setup permission. */ + async createSetupLink( + input: CreateCardSetupLinkInput = {}, + ): Promise { + return this.httpClient.request({ + method: "POST", + url: "/payment-cards/setup-links", + body: input, + }); + } + + /** Read completion and the created card's ID. The bearer URL is returned only when the link is minted. */ + async getSetupLink(linkId: string): Promise { + return this.httpClient.request({ + method: "GET", + url: `/payment-cards/setup-links/${encodeURIComponent(linkId)}`, + }); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4dfa75d1..da7ecc5f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,3 +137,10 @@ export type { // Default export for convenience export { SapiomClient as default } from "./client/SapiomClient.js"; + +export type { + CardPurchaseApprovalSettings, + SetupPurchaseApproval, + CreateCardSetupLinkInput, + CardSetupLinkView, +} from "./types/payment-card.js"; diff --git a/packages/core/src/types/payment-card.ts b/packages/core/src/types/payment-card.ts index b37e39f2b..45552511a 100644 --- a/packages/core/src/types/payment-card.ts +++ b/packages/core/src/types/payment-card.ts @@ -56,3 +56,37 @@ export interface PaymentLimitSummary { groupBy: Array<"agent" | "trace" | "service" | "paymentCardId">; usage: CardAllowanceUsage | null; } + +/** A versioned setting owned by this saved card. Other matching policies still apply. */ +export interface CardPurchaseApprovalSettings { + /** Every card purchase needs an authorized human decision. CVV remains a separate step. */ + enabled: boolean; + /** Use the current saved version; zero before this setting is first enabled. */ + version: number; +} + +/** An explicit org.write-authorized grant for the new card created by one setup link. */ +export interface SetupPurchaseApproval { + enabled: boolean; + /** Let the link holder change this initial choice before their first submission. */ + allowChanges: boolean; +} +export interface CreateCardSetupLinkInput { + returnUrl?: string; + cardholderEmail?: string; + expiresInMinutes?: number; + /** Omitted: off, with no permission for the link holder to change purchase policy. */ + purchaseApproval?: SetupPurchaseApproval; +} +export interface CardSetupLinkView { + id: string; + status: "open" | "completed" | "canceled" | "expired"; + expiresAt: string; + returnUrl: string | null; + cardId: string | null; + createdAt: string; + completedAt: string | null; + canceledAt: string | null; + /** Submitted choices are pinned: allowChanges becomes false after the first submission. */ + purchaseApproval: SetupPurchaseApproval; +}