Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
91 changes: 91 additions & 0 deletions packages/core/src/client/PaymentCardApprovalAPI.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 45 additions & 1 deletion packages/core/src/client/PaymentCardsAPI.ts
Original file line number Diff line number Diff line change
@@ -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) {}

Expand All @@ -27,4 +30,45 @@ export class PaymentCardsAPI {
body: settings,
});
}

/** Read the card-owned purchase approval setting. Requires org.read. */
async getPurchaseApproval(
cardId: string,
): Promise<CardPurchaseApprovalSettings> {
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<CardPurchaseApprovalSettings> {
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(
Comment on lines +56 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Card approval release note omitted

When paymentCards gains these methods, no new Changeset records their release note. The pending core Changeset covers spending limits only, so the generated release omits the new helpers.

Learn more

Published packages use Changesets to generate their version updates and release notes. The existing core Changeset predates this feature and describes only spending-limit management. This PR adds public card approval and setup-link methods but adds no corresponding Changeset, so the generated release note does not describe them.

Example: When the pending core Changeset produces the next minor release, its generated entry mentions spending limits but not getPurchaseApproval or createSetupLink.

Recommended fix: Add a Changeset for @sapiom/core describing the new methods and backend rollout requirement; keep the manual unreleased changelog entry only if that is also part of the project's release process.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

input: CreateCardSetupLinkInput = {},
): Promise<CardSetupLinkView & { url: string }> {
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<CardSetupLinkView> {
return this.httpClient.request({
method: "GET",
url: `/payment-cards/setup-links/${encodeURIComponent(linkId)}`,
});
}
}
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
34 changes: 34 additions & 0 deletions packages/core/src/types/payment-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading