diff --git a/demos/payments/README.md b/demos/payments/README.md index 56b3cfb..019fe26 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -20,9 +20,43 @@ This interactive command-line demo showcases a common use case: the **Server-Ini - Handling currency conversions. - Integrating compliance checks (KYC/AML). - Facilitating complex payment routing. + - Enforcing local payment policy before returning an execution URL or signing a receipt-service payload. You can learn more about the full ACK-Pay protocol at [www.agentcommercekit.com](https://www.agentcommercekit.com). +## Policy-before-signing example + +The Stripe Payment Service path includes a tiny local policy guard in +`src/payment-policy.ts`. The guard runs after the Payment Request token and +payment option are verified, but before the demo returns a payment URL or signs +the payload that asks the Receipt Service to issue a receipt. + +This is an example pattern, not a normative ACK-Pay policy engine. Production +Payment Services should replace it with their own owner, risk, compliance, or +human-approval system. The important safety boundary is that policy enforcement +happens before execution or signing: + +- known low-value recipient: continue automatically +- unknown recipient: return `approval_required` +- amount above the illustrative per-transaction cap: deny before payment + execution + +The per-currency cap is expressed in each currency's smallest subunit, so a +single flat threshold is never compared across currencies with different +decimals (e.g. USD at 2dp vs USDC at 6dp). Currencies without a configured +limit are denied outright. + +> [!IMPORTANT] +> The amount check is an **illustrative per-transaction cap, not a real spend +> control.** A per-transaction limit is trivially defeated by splitting one +> payment into many smaller ones (`cap × N`). A production policy needs a +> cumulative and/or rate-limited budget (e.g. per-payer spend over a rolling +> window), not just a single-transaction threshold. + +The demo allowlist is based on the configured server identity, not the issuer +claimed by each incoming Payment Request token. A real Payment Service should +load this allowlist from operator-controlled configuration. + ## Demo Video
diff --git a/demos/payments/src/payment-policy.test.ts b/demos/payments/src/payment-policy.test.ts
new file mode 100644
index 0000000..635b212
--- /dev/null
+++ b/demos/payments/src/payment-policy.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from "vitest"
+
+import { evaluatePaymentPolicy } from "./payment-policy"
+
+const basePaymentOption = {
+ id: "base-usdc",
+ amount: 100,
+ decimals: 6,
+ currency: "USDC",
+ recipient: "did:example:merchant",
+}
+
+describe("evaluatePaymentPolicy", () => {
+ it("approves below-threshold payments to allowed recipients", () => {
+ const decision = evaluatePaymentPolicy(basePaymentOption, {
+ allowedRecipients: [basePaymentOption.recipient],
+ maxAutonomousAmount: { USDC: 1_000n },
+ })
+
+ expect(decision).toEqual({
+ status: "approved",
+ })
+ })
+
+ it("approves string subunit amounts within the limit", () => {
+ const decision = evaluatePaymentPolicy(
+ { ...basePaymentOption, amount: "50000" },
+ {
+ allowedRecipients: [basePaymentOption.recipient],
+ maxAutonomousAmount: { USDC: 5_000_000n },
+ },
+ )
+
+ expect(decision).toEqual({
+ status: "approved",
+ })
+ })
+
+ it("does not approve self-asserted recipients without an allowlist", () => {
+ const decision = evaluatePaymentPolicy(basePaymentOption, {
+ allowedRecipients: [],
+ maxAutonomousAmount: { USDC: 1_000n },
+ })
+
+ expect(decision).toEqual({
+ status: "approval_required",
+ reason: "Recipient is not on the autonomous payment allowlist",
+ })
+ })
+
+ it("requires approval before execution for unknown recipients", () => {
+ const decision = evaluatePaymentPolicy(basePaymentOption, {
+ allowedRecipients: ["did:example:trusted-merchant"],
+ maxAutonomousAmount: { USDC: 1_000n },
+ })
+
+ expect(decision).toEqual({
+ status: "approval_required",
+ reason: "Recipient is not on the autonomous payment allowlist",
+ })
+ })
+
+ it("denies payments above the autonomous spend limit", () => {
+ const decision = evaluatePaymentPolicy(
+ {
+ ...basePaymentOption,
+ amount: 10_000,
+ },
+ {
+ allowedRecipients: [basePaymentOption.recipient],
+ maxAutonomousAmount: { USDC: 1_000n },
+ },
+ )
+
+ expect(decision).toEqual({
+ status: "denied",
+ reason: "Payment amount exceeds the autonomous spend limit",
+ })
+ })
+
+ it("applies the per-currency limit in the currency's own subunits", () => {
+ const policy = {
+ allowedRecipients: [basePaymentOption.recipient],
+ // 5.00 USD (2dp) and 5.000000 USDC (6dp) — same value, different subunits
+ maxAutonomousAmount: { USD: 500n, USDC: 5_000_000n },
+ }
+
+ // 4.00 USD is below the USD limit
+ expect(
+ evaluatePaymentPolicy(
+ { ...basePaymentOption, amount: 400, decimals: 2, currency: "USD" },
+ policy,
+ ),
+ ).toEqual({ status: "approved" })
+
+ // The same 400 subunits in USDC (0.0004) is also below the USDC limit,
+ // confirming each currency is bounded by its own threshold
+ expect(
+ evaluatePaymentPolicy({ ...basePaymentOption, amount: 400 }, policy),
+ ).toEqual({ status: "approved" })
+ })
+
+ it("denies currencies with no configured limit", () => {
+ const decision = evaluatePaymentPolicy(
+ { ...basePaymentOption, currency: "SOL", decimals: 9 },
+ {
+ allowedRecipients: [basePaymentOption.recipient],
+ maxAutonomousAmount: { USDC: 1_000n },
+ },
+ )
+
+ expect(decision).toEqual({
+ status: "denied",
+ reason: "No autonomous spend limit configured for currency SOL",
+ })
+ })
+
+ it("denies non-positive amounts", () => {
+ const decision = evaluatePaymentPolicy(
+ { ...basePaymentOption, amount: 0 },
+ {
+ allowedRecipients: [basePaymentOption.recipient],
+ maxAutonomousAmount: { USDC: 1_000n },
+ },
+ )
+
+ expect(decision).toEqual({
+ status: "denied",
+ reason: "Payment amount must be greater than zero",
+ })
+ })
+
+ it("denies fractional or malformed amounts the schema permits as strings", () => {
+ const decision = evaluatePaymentPolicy(
+ { ...basePaymentOption, amount: "1.5" },
+ {
+ allowedRecipients: [basePaymentOption.recipient],
+ maxAutonomousAmount: { USDC: 1_000n },
+ },
+ )
+
+ expect(decision).toEqual({
+ status: "denied",
+ reason: "Payment amount must be a positive integer in subunits",
+ })
+ })
+})
diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts
new file mode 100644
index 0000000..61a6976
--- /dev/null
+++ b/demos/payments/src/payment-policy.ts
@@ -0,0 +1,86 @@
+import type { PaymentOption } from "agentcommercekit"
+
+export type PaymentPolicyDecision =
+ | {
+ status: "approved"
+ }
+ | {
+ status: "approval_required" | "denied"
+ reason: string
+ }
+
+export interface PaymentPolicy {
+ allowedRecipients: readonly string[]
+ /**
+ * Per-transaction autonomous spend limits, expressed in each currency's
+ * smallest subunit (matching `PaymentOption.amount`) and keyed by currency
+ * code. Keeping the limit per-currency avoids comparing a single flat
+ * threshold across currencies with different decimals (e.g. USD at 2dp vs
+ * USDC at 6dp). A currency with no configured limit is denied.
+ *
+ * NOTE: this is a per-transaction cap only, not a cumulative or rate budget.
+ * See the demo README — a real spend control needs windowed/cumulative
+ * limits, since a per-transaction cap is trivially split-gameable.
+ */
+ maxAutonomousAmount: Readonly