diff --git a/demos/payments/README.md b/demos/payments/README.md index 019fe26c..5a254b56 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -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). @@ -36,9 +36,10 @@ 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 @@ -46,12 +47,43 @@ 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 attempt.** The Stripe path authorizes + twice for a single payment — once for the payment URL, once on the callback — + so reservations are keyed by payment request id plus payment option id. The + second authorization then re-checks the window without counting the payment + twice. A reservation is committed once the receipt is issued, and released if + signing or the Receipt Service call fails. The Stripe callback only sets + `allowOverBudget` after consuming a pending settlement issued with the + payment URL (demo stand-in for verifying a signed Stripe event), and the + Receipt Service `fetch` uses a timeout so a hung call releases the reservation. + > [!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 today. 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 diff --git a/demos/payments/src/payment-policy.test.ts b/demos/payments/src/payment-policy.test.ts index a42a5cb0..2daec08b 100644 --- a/demos/payments/src/payment-policy.test.ts +++ b/demos/payments/src/payment-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" -import { evaluatePaymentPolicy } from "./payment-policy" +import { authorizePayment, evaluatePaymentPolicy } from "./payment-policy" +import { createSpendLedger } from "./spend-ledger" const basePaymentOption = { id: "base-usdc", @@ -160,3 +161,214 @@ describe("evaluatePaymentPolicy", () => { }) }) }) + +describe("authorizePayment", () => { + const budgetPolicy = { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + budget: { + windowMs: 60_000, + maxWindowAmount: { USDC: 3_000n }, + }, + } + + it("denies the split attack once the window budget is exhausted", () => { + const ledger = createSpendLedger() + const payment = { ...basePaymentOption, amount: 1_000 } + + for (let i = 0; i < 3; i++) { + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: `attempt-${i}`, + ledger, + }), + ).toEqual({ status: "approved" }) + } + + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: "attempt-3", + ledger, + }), + ).toEqual({ + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + }) + }) + + it("does not consume budget for denied or approval-required payments", () => { + const ledger = createSpendLedger() + + expect( + authorizePayment( + { ...basePaymentOption, amount: 10_000 }, + budgetPolicy, + { + subject: "did:example:payer", + reference: "too-large", + ledger, + }, + ).status, + ).toBe("denied") + + expect( + authorizePayment( + basePaymentOption, + { ...budgetPolicy, allowedRecipients: [] }, + { + subject: "did:example:payer", + reference: "unknown-recipient", + ledger, + }, + ).status, + ).toBe("approval_required") + + expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe(0n) + }) + + it("re-authorizes the same reference without double-counting", () => { + const ledger = createSpendLedger() + const payment = { ...basePaymentOption, amount: 1_000 } + const auth = { + subject: "did:example:payer", + reference: "stripe-attempt", + ledger, + } + + expect(authorizePayment(payment, budgetPolicy, auth)).toEqual({ + status: "approved", + }) + expect(authorizePayment(payment, budgetPolicy, auth)).toEqual({ + status: "approved", + }) + expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe( + 1_000n, + ) + }) + + it("isolates subjects and currencies", () => { + const ledger = createSpendLedger() + const payment = { ...basePaymentOption, amount: 1_000 } + + for (let i = 0; i < 3; i++) { + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer-a", + reference: `a-${i}`, + ledger, + }) + } + + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer-b", + reference: "b-0", + ledger, + }), + ).toEqual({ status: "approved" }) + + expect( + authorizePayment( + { ...payment, currency: "USD", decimals: 2, amount: 500 }, + { + ...budgetPolicy, + maxAutonomousAmount: { USDC: 1_000n, USD: 500n }, + budget: { + windowMs: 60_000, + maxWindowAmount: { USDC: 3_000n, USD: 2_000n }, + }, + }, + { + subject: "did:example:payer-a", + reference: "usd-0", + ledger, + }, + ), + ).toEqual({ status: "approved" }) + }) + + it("expires spend out of the rolling window", () => { + let now = 1_000_000 + const ledger = createSpendLedger({ now: () => now }) + const payment = { ...basePaymentOption, amount: 1_000 } + + for (let i = 0; i < 3; i++) { + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: `old-${i}`, + ledger, + }) + } + + now += 60_001 + + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: "after-expiry", + ledger, + }), + ).toEqual({ status: "approved" }) + }) + + it("skips the window check when no budget is configured", () => { + const ledger = createSpendLedger() + const decision = authorizePayment( + basePaymentOption, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: { USDC: 1_000n }, + }, + { + subject: "did:example:payer", + reference: "no-budget", + ledger, + }, + ) + + expect(decision).toEqual({ status: "approved" }) + expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe(0n) + }) + + it("records and approves an over-budget payment when allowOverBudget is set", () => { + const ledger = createSpendLedger() + const payment = { ...basePaymentOption, amount: 1_000 } + + for (let i = 0; i < 3; i++) { + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: `fill-${i}`, + ledger, + }), + ).toEqual({ status: "approved" }) + } + + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: "settled-callback", + ledger, + }), + ).toEqual({ + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + }) + + expect( + authorizePayment(payment, budgetPolicy, { + subject: "did:example:payer", + reference: "settled-callback", + ledger, + allowOverBudget: true, + }), + ).toEqual({ status: "approved" }) + + expect(ledger.spentWithin("did:example:payer", "USDC", 60_000)).toBe( + 4_000n, + ) + }) +}) diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts index b55b16f1..cdd7f95e 100644 --- a/demos/payments/src/payment-policy.ts +++ b/demos/payments/src/payment-policy.ts @@ -1,5 +1,7 @@ import type { PaymentOption } from "agentcommercekit" +import type { SpendLedger } from "./spend-ledger" + export type PaymentPolicyDecision = | { status: "approved" @@ -9,6 +11,17 @@ export type PaymentPolicyDecision = reason: string } +interface SpendBudget { + /** Length of the rolling window, in milliseconds. */ + windowMs: number + /** + * Cumulative spend allowed inside the window, in each currency's smallest + * subunit and keyed by currency code, following the same per-currency shape + * as `maxAutonomousAmount`. A currency with no configured budget is denied. + */ + maxWindowAmount: Readonly> +} + export interface PaymentPolicy { allowedRecipients: readonly string[] /** @@ -18,13 +31,21 @@ export interface PaymentPolicy { * 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. + * This bounds a single payment only. `budget` bounds their sum, which is + * what stops one payment being split into many below-cap ones. */ maxAutonomousAmount: Readonly> + /** + * Optional cumulative budget over a rolling window, enforced by + * `authorizePayment` against a `SpendLedger`. When omitted, only the + * per-transaction cap above applies. + */ + budget?: SpendBudget } +/** Rolling window the demo budget is measured over. */ +const DEMO_SPEND_WINDOW_MS = 60 * 60 * 1000 + export const demoPaymentPolicy: PaymentPolicy = { allowedRecipients: [], maxAutonomousAmount: { @@ -32,19 +53,54 @@ export const demoPaymentPolicy: PaymentPolicy = { USD: 500n, USDC: 5_000_000n, }, + budget: { + windowMs: DEMO_SPEND_WINDOW_MS, + maxWindowAmount: { + // 20.00 in each currency: four payments at the per-transaction cap, + // rather than an unbounded number of them. + USD: 2_000n, + USDC: 20_000_000n, + }, + }, +} + +/** + * Follows the repo-wide BigInt money convention (see receipt-service.ts, + * index.ts). `BigInt()` throws on fractional/malformed amounts the ACK-Pay + * schema's string branch otherwise permits. + */ +function parseSubunitAmount(amount: PaymentOption["amount"]): bigint | null { + try { + return BigInt(amount) + } catch { + return null + } +} + +/** + * `currency` is an unconstrained wire string, so guard against inherited + * prototype keys (e.g. "constructor", "toString") that would otherwise resolve + * to a non-bigint value and slip past a comparison. + */ +function currencyLimit( + limits: Readonly>, + currency: string, +): bigint | null { + if (!Object.prototype.hasOwnProperty.call(limits, currency)) { + return null + } + + const limit = limits[currency] + return typeof limit === "bigint" ? limit : null } export function evaluatePaymentPolicy( paymentOption: PaymentOption, policy: PaymentPolicy = demoPaymentPolicy, ): PaymentPolicyDecision { - let amount: bigint - try { - // Follows the repo-wide BigInt money convention (see receipt-service.ts, - // index.ts). `BigInt()` throws on fractional/malformed amounts the - // ACK-Pay schema's string branch otherwise permits. - amount = BigInt(paymentOption.amount) - } catch { + const amount = parseSubunitAmount(paymentOption.amount) + + if (amount === null) { return { status: "denied", reason: "Payment amount must be a positive integer in subunits", @@ -58,16 +114,12 @@ export function evaluatePaymentPolicy( } } - // `currency` is an unconstrained wire string, so guard against inherited - // prototype keys (e.g. "constructor", "toString") that would otherwise - // resolve to a non-bigint value and slip past the comparison below. - const limit = Object.prototype.hasOwnProperty.call( + const limit = currencyLimit( policy.maxAutonomousAmount, paymentOption.currency, ) - ? policy.maxAutonomousAmount[paymentOption.currency] - : undefined - if (typeof limit !== "bigint") { + + if (limit === null) { return { status: "denied", reason: `No autonomous spend limit configured for currency ${paymentOption.currency}`, @@ -92,3 +144,108 @@ export function evaluatePaymentPolicy( status: "approved", } } + +export interface SpendAuthorization { + /** + * The party the budget is tracked against: the payer this Payment Service + * spends on behalf of. The demo has a single autonomous payer, so this is + * the Payment Service's own DID. A multi-tenant service would key the budget + * on its authenticated payer instead — ACK-Pay does not carry a payer + * identity on the payment execution request today. + */ + subject: string + /** + * Stable identifier for one payment attempt, so the two calls of the Stripe + * flow (payment URL, then callback) reserve once rather than twice. See + * `spendReference`. + */ + reference: string + ledger: SpendLedger + /** + * When true, a rolling-window budget breach still records the spend and + * returns approved. Use on the Stripe callback after the payer has already + * been charged — withholding the receipt would leave them paying with no + * proof of settlement. + */ + allowOverBudget?: boolean +} + +/** + * Applies the full policy to a payment before it is executed or signed: the + * per-transaction checks in `evaluatePaymentPolicy`, then the cumulative + * rolling-window budget when the policy configures one. + * + * An approved decision has reserved the amount against the window. The caller + * must `commit` the reservation once the payment settles, or `release` it if + * execution fails. + * + * @param paymentOption - The verified payment option about to be executed + * @param policy - The policy to apply + * @param authorization - Budget subject, attempt reference, and ledger + * @returns The policy decision + */ +export function authorizePayment( + paymentOption: PaymentOption, + policy: PaymentPolicy, + authorization: SpendAuthorization, +): PaymentPolicyDecision { + const decision = evaluatePaymentPolicy(paymentOption, policy) + + if (decision.status !== "approved" || !policy.budget) { + return decision + } + + const amount = parseSubunitAmount(paymentOption.amount) + if (amount === null) { + // Unreachable: `evaluatePaymentPolicy` already denied unparseable amounts. + return { + status: "denied", + reason: "Payment amount must be a positive integer in subunits", + } + } + + const limit = currencyLimit( + policy.budget.maxWindowAmount, + paymentOption.currency, + ) + + if (limit === null) { + return { + status: "denied", + reason: `No autonomous spend budget configured for currency ${paymentOption.currency}`, + } + } + + const result = authorization.ledger.reserve({ + reference: authorization.reference, + subject: authorization.subject, + currency: paymentOption.currency, + amount, + windowMs: policy.budget.windowMs, + limit, + }) + + if (result.status === "exceeded") { + if (authorization.allowOverBudget) { + authorization.ledger.recordOverBudget({ + reference: authorization.reference, + subject: authorization.subject, + currency: paymentOption.currency, + amount, + windowMs: policy.budget.windowMs, + }) + return { + status: "approved", + } + } + return { + status: "denied", + reason: + "Payment exceeds the autonomous spend budget for the current window", + } + } + + return { + status: "approved", + } +} diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 713d0319..f1bd28c8 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -5,6 +5,7 @@ import { createJwt, getDidResolver, verifyPaymentRequestToken, + type DidUri, type JwtString, } from "agentcommercekit" import { jwtStringSchema } from "agentcommercekit/schemas/valibot" @@ -14,12 +15,30 @@ import { HTTPException } from "hono/http-exception" import * as v from "valibot" import { PAYMENT_SERVICE_URL } from "./constants" -import { demoPaymentPolicy, evaluatePaymentPolicy } from "./payment-policy" +import { authorizePayment, demoPaymentPolicy } from "./payment-policy" +import { createSpendLedger, spendReference } from "./spend-ledger" +import { + createStripeSettlementTracker, + fetchWithTimeout, +} from "./stripe-settlement" import { getKeypairInfo } from "./utils/keypair-info" const app = new Hono() app.use(logger()) +/** + * Tracks how much this Payment Service has already authorized inside the + * policy's rolling window. In-memory, so it resets with the demo process. + */ +const spendLedger = createSpendLedger() + +/** + * Demo stand-in for Stripe settlement verification. Payment URLs register a + * pending attempt; the callback may only set `allowOverBudget` after that + * attempt is consumed with a Stripe-shaped event id. + */ +const stripeSettlements = createStripeSettlementTracker() + const bodySchema = v.object({ paymentOptionId: v.string(), paymentRequestToken: jwtStringSchema, @@ -45,21 +64,40 @@ app.post("/", async (c): Promise> => { // Verify the payment request token and payment option are valid before // returning an execution URL. - const { paymentOption } = await validatePaymentOption( + const { paymentRequest, paymentOption } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) - enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) + const payerIdentity = await getPayerIdentity(c) + const reference = spendReference(paymentRequest.id, paymentOptionId) + await enforcePaymentPolicy(c, paymentOption, { + subject: payerIdentity.did, + reference, + }) - log(colors.dim(`${name} Generating Stripe payment URL ...`)) + try { + log(colors.dim(`${name} Generating Stripe payment URL ...`)) - // This is a placeholder for an actual Strip Payment URL which would - // have webhook callbacks already set up - const paymentUrl = `https://payments.stripe.com/payment-url/?return_to=${PAYMENT_SERVICE_URL}/stripe-callback` + // Register the attempt before returning the URL so the later callback can + // prove this payment was initiated here (demo stand-in for Stripe Events). + stripeSettlements.issue(reference, { + paymentRequestId: paymentRequest.id, + paymentOptionId, + }) - return c.json({ - paymentUrl, - }) + // This is a placeholder for an actual Strip Payment URL which would + // have webhook callbacks already set up + const paymentUrl = `https://payments.stripe.com/payment-url/?return_to=${PAYMENT_SERVICE_URL}/stripe-callback` + + return c.json({ + paymentUrl, + }) + } catch (error) { + // No payment was started, so it must not hold the window budget. + spendLedger.release(reference) + stripeSettlements.release(reference) + throw error + } }) const callbackSchema = v.object({ @@ -72,9 +110,7 @@ const callbackSchema = v.object({ app.post( "/stripe-callback", async (c): Promise> => { - const serverIdentity = await getKeypairInfo( - env(c).PAYMENT_SERVICE_PRIVATE_KEY_HEX, - ) + const payerIdentity = await getPayerIdentity(c) const { paymentOptionId, paymentRequestToken, metadata } = v.parse( callbackSchema, @@ -82,7 +118,7 @@ app.post( ) // Verify the payment request token and payment option are valid - const { paymentOption } = await validatePaymentOption( + const { paymentRequest, paymentOption } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) @@ -90,7 +126,35 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } - enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) + + // Only treat the charge as settled (and allow over-budget receipting) + // after verifying this attempt was issued a payment URL and carries a + // Stripe-shaped event id. A real service would validate a signed webhook. + const reference = spendReference(paymentRequest.id, paymentOptionId) + const settlement = stripeSettlements.consumeVerified( + reference, + metadata.eventId, + { + paymentRequestId: paymentRequest.id, + paymentOptionId, + }, + ) + if (!settlement.ok) { + log(errorMessage(`${name} ${settlement.reason}`)) + throw new HTTPException(401, { + message: settlement.reason, + }) + } + + // Re-authorizing under the same payment-attempt reference re-checks the + // window without counting this payment a second time. Settlement is + // verified above, so a rolling-window breach is recorded and receipt + // issuance continues rather than returning 403. + await enforcePaymentPolicy(c, paymentOption, { + subject: payerIdentity.did, + reference, + allowOverBudget: true, + }) const payload = { paymentRequestToken, @@ -99,31 +163,36 @@ app.post( network: "stripe", eventId: metadata.eventId, }, - payerDid: serverIdentity.did, + payerDid: payerIdentity.did, } - const signedPayload = await createJwt(payload, { - issuer: serverIdentity.did, - signer: serverIdentity.jwtSigner, - }) - log(colors.dim(`${name} Getting receipt from Receipt Service...`)) - const response = await fetch(receiptServiceUrl, { - method: "POST", - body: JSON.stringify({ - payload: signedPayload, - }), - }) - const { receipt, details } = v.parse( - receiptResponseSchema, - await response.json(), - ) + let receiptResponse: v.InferOutput + try { + const signedPayload = await createJwt(payload, { + issuer: payerIdentity.did, + signer: payerIdentity.jwtSigner, + }) - return c.json({ - receipt, - details, - }) + const response = await fetchWithTimeout(receiptServiceUrl, { + method: "POST", + body: JSON.stringify({ + payload: signedPayload, + }), + }) + + receiptResponse = v.parse(receiptResponseSchema, await response.json()) + } catch (error) { + // The payment never produced a receipt, so it should not keep consuming + // the window budget. + spendLedger.release(reference) + throw error + } + + spendLedger.commit(reference) + + return c.json(receiptResponse) }, ) @@ -159,16 +228,30 @@ async function validatePaymentOption( } } -function enforcePaymentPolicy( +async function enforcePaymentPolicy( + c: Context, paymentOption: Awaited< ReturnType >["paymentOption"], - allowedRecipients: readonly string[], + { + subject, + reference, + allowOverBudget, + }: { subject: DidUri; reference: string; allowOverBudget?: boolean }, ) { - const decision = evaluatePaymentPolicy(paymentOption, { - ...demoPaymentPolicy, - allowedRecipients, - }) + const decision = authorizePayment( + paymentOption, + { + ...demoPaymentPolicy, + allowedRecipients: await getTrustedRecipients(c), + }, + { + subject, + reference, + ledger: spendLedger, + allowOverBudget, + }, + ) if (decision.status !== "approved") { log(errorMessage(`${name} ${decision.reason}`)) @@ -178,6 +261,15 @@ function enforcePaymentPolicy( } } +/** + * The identity this Payment Service signs and spends as. The demo has a single + * autonomous payer, so it is also the subject the spend budget is tracked + * against. + */ +function getPayerIdentity(c: Context) { + return getKeypairInfo(env(c).PAYMENT_SERVICE_PRIVATE_KEY_HEX) +} + async function getTrustedRecipients(c: Context) { const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX) return [serverIdentity.did] diff --git a/demos/payments/src/spend-ledger.test.ts b/demos/payments/src/spend-ledger.test.ts new file mode 100644 index 00000000..74b422c9 --- /dev/null +++ b/demos/payments/src/spend-ledger.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest" + +import { createSpendLedger, spendReference } from "./spend-ledger" + +describe("spendReference", () => { + it("encodes request and option ids so colon-containing parts cannot collide", () => { + expect(spendReference("a:b", "c")).not.toBe(spendReference("a", "b:c")) + }) +}) + +describe("createSpendLedger", () => { + it("reserves within the window and reports spent", () => { + const ledger = createSpendLedger() + + expect( + ledger.reserve({ + reference: "r1", + subject: "payer", + currency: "USDC", + amount: 100n, + windowMs: 60_000, + limit: 250n, + }), + ).toEqual({ status: "reserved", spent: 100n }) + + expect(ledger.spentWithin("payer", "USDC", 60_000)).toBe(100n) + }) + + it("rejects when the window budget would be exceeded", () => { + const ledger = createSpendLedger() + + ledger.reserve({ + reference: "r1", + subject: "payer", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }) + + expect( + ledger.reserve({ + reference: "r2", + subject: "payer", + currency: "USDC", + amount: 100n, + windowMs: 60_000, + limit: 250n, + }), + ).toEqual({ status: "exceeded", spent: 200n, limit: 250n }) + }) + + it("re-reserves the same reference without double-counting", () => { + const ledger = createSpendLedger() + const reservation = { + reference: "same", + subject: "payer", + currency: "USDC", + amount: 100n, + windowMs: 60_000, + limit: 250n, + } + + expect(ledger.reserve(reservation)).toEqual({ + status: "reserved", + spent: 100n, + }) + expect(ledger.reserve(reservation)).toEqual({ + status: "reserved", + spent: 100n, + }) + expect(ledger.spentWithin("payer", "USDC", 60_000)).toBe(100n) + }) + + it("releases an unsettled reservation and ignores release after commit", () => { + const ledger = createSpendLedger() + + ledger.reserve({ + reference: "to-release", + subject: "payer", + currency: "USDC", + amount: 100n, + windowMs: 60_000, + limit: 250n, + }) + ledger.release("to-release") + expect(ledger.spentWithin("payer", "USDC", 60_000)).toBe(0n) + + ledger.reserve({ + reference: "to-commit", + subject: "payer", + currency: "USDC", + amount: 100n, + windowMs: 60_000, + limit: 250n, + }) + ledger.commit("to-commit") + ledger.release("to-commit") + expect(ledger.spentWithin("payer", "USDC", 60_000)).toBe(100n) + }) + + it("expires entries outside the rolling window", () => { + let now = 1_000_000 + const ledger = createSpendLedger({ now: () => now }) + + ledger.reserve({ + reference: "old", + subject: "payer", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }) + + now += 60_001 + + expect( + ledger.reserve({ + reference: "new", + subject: "payer", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }), + ).toEqual({ status: "reserved", spent: 200n }) + }) + + it("isolates subjects and currencies", () => { + const ledger = createSpendLedger() + + ledger.reserve({ + reference: "a", + subject: "payer-a", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }) + + expect( + ledger.reserve({ + reference: "b", + subject: "payer-b", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }).status, + ).toBe("reserved") + + expect( + ledger.reserve({ + reference: "c", + subject: "payer-a", + currency: "USD", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }).status, + ).toBe("reserved") + }) + + it("recordOverBudget keeps the spend even when the window is full", () => { + const ledger = createSpendLedger() + + expect( + ledger.reserve({ + reference: "first", + subject: "payer", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }).status, + ).toBe("reserved") + + expect( + ledger.reserve({ + reference: "second", + subject: "payer", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + limit: 250n, + }).status, + ).toBe("exceeded") + + ledger.recordOverBudget({ + reference: "second", + subject: "payer", + currency: "USDC", + amount: 200n, + windowMs: 60_000, + }) + + expect(ledger.spentWithin("payer", "USDC", 60_000)).toBe(400n) + }) +}) diff --git a/demos/payments/src/spend-ledger.ts b/demos/payments/src/spend-ledger.ts new file mode 100644 index 00000000..65d29342 --- /dev/null +++ b/demos/payments/src/spend-ledger.ts @@ -0,0 +1,215 @@ +/** + * A tiny in-memory spend ledger for the payments demo. + * + * The ledger records how much a subject (the party a Payment Service spends on + * behalf of) has already put at risk inside a rolling time window, so policy + * can enforce a cumulative budget instead of only a per-transaction cap. A + * per-transaction cap on its own is trivially defeated by splitting one payment + * into many smaller ones. + * + * This is a demo store, not a spend control. It lives in process memory, so it + * resets on restart and is not shared between Payment Service instances. A + * production budget needs durable storage and an atomic check-and-reserve + * (a transactional `UPDATE ... WHERE` or a distributed lock) whenever more than + * one instance can authorize payments. + */ + +interface SpendLedgerEntry { + subject: string + currency: string + /** Amount in the currency's smallest subunit. */ + amount: bigint + /** Epoch milliseconds at which the amount was reserved. */ + at: number + /** `true` once the payment this entry covers has actually settled. */ + committed: boolean +} + +interface SpendReservation { + /** + * Stable identifier for a single payment attempt. Reserving the same + * reference twice replaces the existing entry rather than adding a second + * one, so the two-phase Stripe flow (payment URL, then callback) and any + * retries never count one payment twice. + */ + reference: string + subject: string + currency: string + /** Amount in the currency's smallest subunit. */ + amount: bigint + /** Length of the rolling window, in milliseconds. */ + windowMs: number + /** Cumulative cap for this subject and currency across the window. */ + limit: bigint +} + +type SpendReservationResult = + | { + status: "reserved" + /** Window total for this subject and currency, including this reservation. */ + spent: bigint + } + | { + status: "exceeded" + /** Window total excluding the rejected reservation. */ + spent: bigint + limit: bigint + } + +export interface SpendLedger { + /** + * Checks the rolling window and records the amount in a single synchronous + * step. Callers must not check the window and reserve separately: the + * enclosing request handler awaits before policy runs, so two concurrent + * payments would both observe the pre-payment total and both pass. + */ + reserve(reservation: SpendReservation): SpendReservationResult + /** + * Records a reservation even when it exceeds the window limit. Used by the + * Stripe callback after the payer has already been charged: the overspend is + * an accounting fact to keep, not a reason to withhold the receipt. + */ + recordOverBudget(reservation: Omit): void + /** Marks a reservation as settled, so it can no longer be released. */ + commit(reference: string): void + /** Drops an unsettled reservation, e.g. when execution failed. */ + release(reference: string): void + /** Reserved and committed total for a subject and currency in the window. */ + spentWithin(subject: string, currency: string, windowMs: number): bigint +} + +export interface SpendLedgerOptions { + /** Clock override, for tests. */ + now?: () => number +} + +/** + * Builds the reservation key identifying one payment attempt, so both calls of + * the Stripe flow reserve against the same entry. + * + * The parts are unconstrained strings carried in the Payment Request, so they + * are encoded rather than concatenated: a plain `${a}:${b}` lets `("a:b", "c")` + * and `("a", "b:c")` collide, and a collision would silently overwrite an + * earlier reservation and drop its amount from the window. + * + * @param paymentRequestId - `id` of the verified Payment Request + * @param paymentOptionId - `id` of the selected payment option + * @returns A key unique to the pair + */ +export function spendReference( + paymentRequestId: string, + paymentOptionId: string, +): string { + return JSON.stringify([paymentRequestId, paymentOptionId]) +} + +/** + * Creates an in-memory spend ledger. + * + * All calls are expected to share one window length (the one configured on the + * policy). `reserve` discards entries that have aged out of the window it is + * given, which is what bounds the ledger's memory. + * + * @param options - Optional clock override + * @returns A `SpendLedger` + */ +export function createSpendLedger({ + now = () => Date.now(), +}: SpendLedgerOptions = {}): SpendLedger { + const entries = new Map() + + function totalWithin( + subject: string, + currency: string, + windowMs: number, + excludeReference?: string, + ): bigint { + const cutoff = now() - windowMs + let total = 0n + + for (const [reference, entry] of entries) { + if (reference === excludeReference) { + continue + } + if (entry.subject !== subject || entry.currency !== currency) { + continue + } + if (entry.at <= cutoff) { + continue + } + total += entry.amount + } + + return total + } + + return { + reserve({ reference, subject, currency, amount, windowMs, limit }) { + const cutoff = now() - windowMs + for (const [key, entry] of entries) { + if (entry.at <= cutoff) { + entries.delete(key) + } + } + + // Exclude any earlier reservation under this reference, otherwise the + // second phase of a single payment would be counted on top of its own + // first phase and denied. + const spent = totalWithin(subject, currency, windowMs, reference) + + if (spent + amount > limit) { + return { status: "exceeded", spent, limit } + } + + // Keep the original timestamp when re-reserving, so a payment ages out of + // the window from its first attempt and cannot be held open indefinitely. + const existing = entries.get(reference) + + entries.set(reference, { + subject, + currency, + amount, + at: existing?.at ?? now(), + committed: existing?.committed ?? false, + }) + + return { status: "reserved", spent: spent + amount } + }, + + recordOverBudget({ reference, subject, currency, amount, windowMs }) { + const cutoff = now() - windowMs + for (const [key, entry] of entries) { + if (entry.at <= cutoff) { + entries.delete(key) + } + } + + const existing = entries.get(reference) + entries.set(reference, { + subject, + currency, + amount, + at: existing?.at ?? now(), + committed: existing?.committed ?? false, + }) + }, + + commit(reference) { + const entry = entries.get(reference) + if (entry) { + entry.committed = true + } + }, + + release(reference) { + const entry = entries.get(reference) + if (entry && !entry.committed) { + entries.delete(reference) + } + }, + + spentWithin(subject, currency, windowMs) { + return totalWithin(subject, currency, windowMs) + }, + } +} diff --git a/demos/payments/src/stripe-settlement.test.ts b/demos/payments/src/stripe-settlement.test.ts new file mode 100644 index 00000000..616f3556 --- /dev/null +++ b/demos/payments/src/stripe-settlement.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + RECEIPT_FETCH_TIMEOUT_MS, + createStripeSettlementTracker, + fetchWithTimeout, + isDemoStripeEventId, +} from "./stripe-settlement" + +describe("isDemoStripeEventId", () => { + it("accepts Stripe-shaped event ids", () => { + expect(isDemoStripeEventId("evt_1Abc")).toBe(true) + expect(isDemoStripeEventId("evt_abc123XYZ")).toBe(true) + }) + + it("rejects empty or non-Stripe ids", () => { + expect(isDemoStripeEventId("")).toBe(false) + expect(isDemoStripeEventId("evt_")).toBe(false) + expect(isDemoStripeEventId("evt_abc-def")).toBe(false) + expect(isDemoStripeEventId("pi_123")).toBe(false) + expect(isDemoStripeEventId("forged")).toBe(false) + }) +}) + +describe("createStripeSettlementTracker", () => { + const expected = { + paymentRequestId: "req_1", + paymentOptionId: "stripe-usd", + } + + it("verifies only after a matching payment URL was issued", () => { + const tracker = createStripeSettlementTracker() + const reference = "req_1:stripe-usd" + + expect( + tracker.consumeVerified(reference, "evt_abc", expected).ok, + ).toBe(false) + + tracker.issue(reference, expected) + expect( + tracker.consumeVerified(reference, "evt_abc", expected), + ).toEqual({ ok: true }) + + // One-time: a second callback cannot reuse the same settlement. + expect( + tracker.consumeVerified(reference, "evt_abc", expected).ok, + ).toBe(false) + }) + + it("rejects mismatched payment request or option", () => { + const tracker = createStripeSettlementTracker() + const reference = "req_1:stripe-usd" + tracker.issue(reference, expected) + + expect( + tracker.consumeVerified(reference, "evt_abc", { + paymentRequestId: "req_other", + paymentOptionId: "stripe-usd", + }).ok, + ).toBe(false) + + expect( + tracker.consumeVerified(reference, "evt_abc", { + paymentRequestId: "req_1", + paymentOptionId: "other", + }).ok, + ).toBe(false) + }) + + it("rejects invalid event ids even when issued", () => { + const tracker = createStripeSettlementTracker() + const reference = "req_1:stripe-usd" + tracker.issue(reference, expected) + + expect(tracker.consumeVerified(reference, "bad", expected).ok).toBe(false) + // Still pending after a bad event id — a valid callback can still succeed. + expect( + tracker.consumeVerified(reference, "evt_ok", expected), + ).toEqual({ ok: true }) + }) + + it("release drops a pending settlement without verifying", () => { + const tracker = createStripeSettlementTracker() + const reference = "req_1:stripe-usd" + tracker.issue(reference, expected) + tracker.release(reference) + expect( + tracker.consumeVerified(reference, "evt_abc", expected).ok, + ).toBe(false) + }) +}) + +describe("fetchWithTimeout", () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it("passes AbortSignal to fetch", async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal) + return new Response("{}", { status: 200 }) + }) + vi.stubGlobal("fetch", fetchMock) + + await fetchWithTimeout("https://example.test/receipt", { + method: "POST", + body: "{}", + }) + + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it("aborts when the request exceeds the timeout", async () => { + vi.useFakeTimers() + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")) + }) + }) + }), + ) + + const pending = fetchWithTimeout( + "https://example.test/receipt", + { method: "POST" }, + RECEIPT_FETCH_TIMEOUT_MS, + ) + + const expectation = expect(pending).rejects.toMatchObject({ + name: "AbortError", + }) + await vi.advanceTimersByTimeAsync(RECEIPT_FETCH_TIMEOUT_MS) + await expectation + }) +}) diff --git a/demos/payments/src/stripe-settlement.ts b/demos/payments/src/stripe-settlement.ts new file mode 100644 index 00000000..98c17211 --- /dev/null +++ b/demos/payments/src/stripe-settlement.ts @@ -0,0 +1,88 @@ +/** + * Demo-only Stripe settlement tracking for the Payment Service callback. + * + * Production services should verify a signed Stripe webhook (or Events API + * object) that matches the payment request and option before treating the + * charge as settled. This tracker approximates that gate for the local demo: + * a callback may only proceed after the matching payment URL was issued. + */ + +export type PendingStripeSettlement = { + paymentRequestId: string + paymentOptionId: string +} + +export type StripeSettlementTracker = { + issue: (reference: string, settlement: PendingStripeSettlement) => void + /** + * One-time consume: validates the demo event id and that this payment + * attempt was previously issued a payment URL. + */ + consumeVerified: ( + reference: string, + eventId: string, + expected: PendingStripeSettlement, + ) => { ok: true } | { ok: false; reason: string } + release: (reference: string) => void +} + +/** Stripe event ids look like `evt_...` in the Events API. */ +export function isDemoStripeEventId(eventId: string): boolean { + return /^evt_[A-Za-z0-9]+$/.test(eventId) +} + +export function createStripeSettlementTracker(): StripeSettlementTracker { + const pending = new Map() + + return { + issue(reference, settlement) { + pending.set(reference, settlement) + }, + consumeVerified(reference, eventId, expected) { + if (!isDemoStripeEventId(eventId)) { + return { ok: false, reason: "Invalid Stripe event id" } + } + + const issued = pending.get(reference) + if ( + !issued || + issued.paymentRequestId !== expected.paymentRequestId || + issued.paymentOptionId !== expected.paymentOptionId + ) { + return { + ok: false, + reason: "No verified Stripe settlement for this payment attempt", + } + } + + pending.delete(reference) + return { ok: true } + }, + release(reference) { + pending.delete(reference) + }, + } +} + +export const RECEIPT_FETCH_TIMEOUT_MS = 10_000 + +/** + * `fetch` with an AbortController timeout so a hung Receipt Service cannot + * hold a spend reservation until the rolling window expires. + */ +export async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = RECEIPT_FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + } +}