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
55 changes: 47 additions & 8 deletions demos/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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.
- Enforcing local payment policy, including a cumulative spend budget, 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).

Expand All @@ -36,22 +36,61 @@ 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
- known low-value recipient, within budget: continue automatically
- unknown recipient: return `approval_required`
- amount above the illustrative per-transaction cap: deny before payment
- amount above the per-transaction cap: deny before payment execution
- amount that would exceed the rolling spend budget: 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.

### Rolling spend budget

A per-transaction cap on its own is not a spend control: it is trivially
defeated by splitting one payment into many smaller ones (`cap × N`). The demo
policy therefore also carries a cumulative budget over a rolling window,
enforced against the small in-memory ledger in `src/spend-ledger.ts`.

- `maxAutonomousAmount` bounds a single payment.
- `budget.maxWindowAmount` bounds their sum over `budget.windowMs`, in the same
per-currency subunits.

Two details are what make the budget hold rather than merely look right:

- **The check and the reservation are one synchronous step.** The request
handler awaits token verification before policy runs, so a guard that read the
running total and then wrote to it would let two concurrent payments both
observe the pre-payment total and both pass. `SpendLedger.reserve` does both
at once.
- **Reservations are keyed by payment execution.** The Stripe path authorizes
twice for a single payment — once for the payment URL, once on the callback —
so the Payment Service mints an execution id at initiation and carries it
through the callback URL. The second authorization then re-checks the window
without counting the payment twice, while a second execution of the same
Payment Request is charged separately, since it moves money separately. A
reservation is committed once the receipt is issued, and released if signing
or the Receipt Service call fails.

> [!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.
> This is still a demo, not production spend control. The ledger lives in
> process memory, so it resets with the demo and is not shared between Payment
> Service instances; a real budget needs durable storage and an atomic
> check-and-reserve (a transactional `UPDATE ... WHERE`, or a distributed lock)
> across every instance that can authorize payments. A real service would also
> route a budget breach to human approval rather than denying outright, and
> would key the budget on its authenticated payer — ACK-Pay carries no payer
> identity on the payment execution request, so the demo tracks the single
> autonomous payer it spends as.
>
> The callback re-authorizes after the card has already been captured, so a
> denial there stops the receipt without stopping the charge. In practice this
> needs the initiation reservation to have aged out of the window and later
> payments to have filled the budget, which the one-hour window makes unlikely
> — but a real Payment Service should settle and flag an over-budget capture
> for reconciliation rather than refuse it.

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
Expand Down
153 changes: 151 additions & 2 deletions demos/payments/src/payment-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"
import { beforeEach, describe, expect, it } from "vitest"

import { evaluatePaymentPolicy } from "./payment-policy"
import {
authorizePayment,
evaluatePaymentPolicy,
type PaymentPolicy,
} from "./payment-policy"
import { createSpendLedger, type SpendLedger } from "./spend-ledger"

const basePaymentOption = {
id: "base-usdc",
Expand Down Expand Up @@ -160,3 +165,147 @@ describe("evaluatePaymentPolicy", () => {
})
})
})

describe("authorizePayment", () => {
const WINDOW_MS = 60 * 60 * 1000
const SUBJECT = "did:example:payment-service"

const budgetedPolicy: PaymentPolicy = {
allowedRecipients: [basePaymentOption.recipient],
maxAutonomousAmount: { USDC: 1_000n },
budget: {
windowMs: WINDOW_MS,
maxWindowAmount: { USDC: 2_500n },
},
}

let clock: number
let ledger: SpendLedger

function authorize(
reference: string,
amount: number,
policy: PaymentPolicy = budgetedPolicy,
) {
return authorizePayment({ ...basePaymentOption, amount }, policy, {
subject: SUBJECT,
reference,
ledger,
})
}

beforeEach(() => {
clock = 1_000_000
ledger = createSpendLedger({ now: () => clock })
})

it("approves a payment inside both the per-transaction cap and the budget", () => {
expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" })
expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(1_000n)
})

it("denies below-cap payments once they exhaust the window budget", () => {
// Every payment here passes the per-transaction cap on its own. The budget
// is what stops one payment being split into an unbounded number of them.
expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" })
expect(authorize("payment-2", 1_000)).toEqual({ status: "approved" })

expect(authorize("payment-3", 1_000)).toEqual({
status: "denied",
reason:
"Payment exceeds the autonomous spend budget for the current window",
})
expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(2_000n)
})

it("approves again once the earlier payments age out of the window", () => {
authorize("payment-1", 1_000)
authorize("payment-2", 1_000)
clock += WINDOW_MS

expect(authorize("payment-3", 1_000)).toEqual({ status: "approved" })
})

it("counts one payment attempt once across repeated authorizations", () => {
// The Stripe flow authorizes twice for a single payment: once for the
// payment URL, once on the callback.
expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" })
expect(authorize("payment-1", 1_000)).toEqual({ status: "approved" })

expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(1_000n)
})

it("does not consume budget for a payment the per-transaction cap denies", () => {
expect(authorize("payment-1", 2_000)).toEqual({
status: "denied",
reason: "Payment amount exceeds the autonomous spend limit",
})
expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n)
})

it("does not consume budget for a payment that requires approval", () => {
const decision = authorizePayment(
basePaymentOption,
{ ...budgetedPolicy, allowedRecipients: [] },
{ subject: SUBJECT, reference: "payment-1", ledger },
)

expect(decision).toEqual({
status: "approval_required",
reason: "Recipient is not on the autonomous payment allowlist",
})
expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n)
})

it("denies currencies with no configured window budget", () => {
const decision = authorizePayment(
basePaymentOption,
{
...budgetedPolicy,
maxAutonomousAmount: { USDC: 1_000n, USD: 1_000n },
budget: { windowMs: WINDOW_MS, maxWindowAmount: { USD: 2_500n } },
},
{ subject: SUBJECT, reference: "payment-1", ledger },
)

expect(decision).toEqual({
status: "denied",
reason: "No autonomous spend budget configured for currency USDC",
})
})

it("skips the budget check when the policy configures no budget", () => {
const unbudgetedPolicy: PaymentPolicy = {
allowedRecipients: [basePaymentOption.recipient],
maxAutonomousAmount: { USDC: 1_000n },
}

expect(authorize("payment-1", 1_000, unbudgetedPolicy)).toEqual({
status: "approved",
})
expect(authorize("payment-2", 1_000, unbudgetedPolicy)).toEqual({
status: "approved",
})
expect(authorize("payment-3", 1_000, unbudgetedPolicy)).toEqual({
status: "approved",
})
expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(0n)
})

it("tracks the budget per payer subject", () => {
authorize("payment-1", 1_000)
authorize("payment-2", 1_000)

const other = authorizePayment(
{ ...basePaymentOption, amount: 1_000 },
budgetedPolicy,
{
subject: "did:example:other-payer",
reference: "payment-3",
ledger,
},
)

expect(other).toEqual({ status: "approved" })
})
})
Loading