Skip to content

demo(payments): enforce a rolling spend budget, not just a per-payment cap - #139

Open
mehmetkr-31 wants to merge 3 commits into
agentcommercekit:mainfrom
mehmetkr-31:demo/ack-pay-windowed-budget
Open

demo(payments): enforce a rolling spend budget, not just a per-payment cap#139
mehmetkr-31 wants to merge 3 commits into
agentcommercekit:mainfrom
mehmetkr-31:demo/ack-pay-windowed-budget

Conversation

@mehmetkr-31

@mehmetkr-31 mehmetkr-31 commented Aug 10, 2026

Copy link
Copy Markdown

Summary

  • Add a cumulative spend budget over a rolling window to the payments demo policy guard, backed by a small in-memory ledger in src/spend-ledger.ts
  • Make the check-and-reserve a single synchronous step, so concurrent payments cannot both pass on the pre-payment total
  • Key reservations by payment attempt, so the two calls of the Stripe flow authorize one payment once; commit on receipt issuance, release on failure
  • Keep evaluatePaymentPolicy unchanged and put the new behaviour in an additive authorizePayment layer

Fixes #138.

This closes the gap #97 documented in its own code and README rather than adding anything new to the protocol surface:

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.

Everything stays inside demos/payments. No package, schema, or protocol change; no new dependency.

What changed

src/spend-ledger.ts (new) — records what a subject has put at risk inside a rolling window, keyed by reference.

reserve() checks the window and writes in one synchronous step. This is the part that actually matters: the request handler awaits verifyPaymentRequestToken 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. commit() marks a reservation settled; release() drops an unsettled one.

src/payment-policy.tsPaymentPolicy gains an optional budget: { windowMs, maxWindowAmount }, using the same per-currency subunit shape as the existing maxAutonomousAmount. New authorizePayment() runs evaluatePaymentPolicy first and then the window check, so a payment rejected by the per-transaction cap or the allowlist never consumes budget. evaluatePaymentPolicy keeps its exact signature, behaviour, and reasons — the amount-parsing and per-currency lookup are extracted into two local helpers now shared by both paths.

src/payment-service.ts — both handlers authorize under spendReference(paymentRequest.id, paymentOptionId), so the payment-URL call and the callback reserve once. The callback commits after the receipt is issued and releases if the Receipt Service call fails. The local serverIdentity in the callback is renamed to payerIdentity: it holds PAYMENT_SERVICE_PRIVATE_KEY_HEX, while getTrustedRecipients uses the same name for SERVER_PRIVATE_KEY_HEX two functions below.

README.md — replaces the "not a real spend control" warning with a "Rolling spend budget" section, and a new warning that scopes what is still demo-grade: in-memory storage, single-instance atomicity, denying rather than escalating to human approval, and tracking the demo's single autonomous payer because ACK-Pay carries no payer identity on the execution request.

Notes on scope

Verification

pnpm run build
pnpm run check                                     # 29/29 tasks, 0 lint warnings/errors
pnpm --filter ./demos/payments exec vitest run     # 34 passed (19 policy, 15 ledger)
pnpm run knip                                      # clean
git diff --check

Tests cover the split attack (three below-cap payments approved, the fourth denied), window expiry, per-subject and per-currency isolation, idempotent re-authorization, reservations not being consumed by denied or approval-required payments, commit/release semantics, and the no-budget-configured path.

I also ran the real handlers over HTTP with the Receipt Service live, driving the Stripe path end to end:

result
happy path at the demo's 5-subunit amount initiate=200 callback=200, receipt issued
three payments at the per-transaction cap 200, 200, 200
fourth payment, crossing the window budget 403 Payment exceeds the autonomous spend budget for the current window
re-authorizing an already-reserved attempt 200, no additional budget consumed
the denied payment after that replay still 403

AI Usage Disclosure

This contribution was AI-assisted using Claude Code (Opus). AI assistance was used for repository and history navigation, reviewing the existing policy guard and its review discussion, drafting the ledger and tests, and running verification. I reviewed the final diff, understand how the reserve/commit/release path interacts with the two-phase Stripe flow and why the check and reserve must not be separated by an await, and take responsibility for the submitted changes.

Summary by CodeRabbit

  • New Features

    • Added rolling spend budgets for payment subjects, with configurable currency-specific and per-transaction limits.
    • Payment authorization now supports approval requirements for unknown recipients and prevents duplicate budget consumption.
    • Payment receipts are securely tied to payer identity, with reserved funds released when receipt retrieval fails.
    • Added support for committing successful payment reservations and tracking spending within a time window.
  • Documentation

    • Updated the payment demo policy documentation with budget and reservation behavior.

…t cap

The policy guard added in agentcommercekit#97 caps a single payment. Its own comment and the
demo README call out that this is not a spend control: splitting one payment
into N below-cap payments defeats it entirely. This closes that gap in the
demo.

- Add `src/spend-ledger.ts`, a small in-memory ledger that records what a payer
  has put at risk inside a rolling window.
- Add an optional `budget` to `PaymentPolicy` and an `authorizePayment` layer
  that runs the existing per-transaction checks and then the cumulative window
  check. `evaluatePaymentPolicy` keeps its current signature and behaviour.
- Reserve inside the same synchronous step as the check. The handler awaits
  token verification before policy runs, so a read-then-write guard would let
  two concurrent payments both observe the pre-payment total and both pass.
- Key reservations by payment request id plus payment option id, so the two
  calls of the Stripe flow authorize one payment once. Commit the reservation
  once the receipt is issued; release it if the Receipt Service call fails.
- Rename the Payment Service's `serverIdentity` local to `payerIdentity`; it
  holds the payment service key, not the server key that `getTrustedRecipients`
  reads under the same name.
- Document what is still demo-grade: in-memory storage, single-instance
  atomicity, deny rather than escalate to human approval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mehmetkr-31, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b5367cd-9806-47ff-95c8-5e154528c5fe

📥 Commits

Reviewing files that changed from the base of the PR and between 5414824 and 6100986.

📒 Files selected for processing (4)
  • demos/payments/README.md
  • demos/payments/src/payment-service.ts
  • demos/payments/src/spend-ledger.test.ts
  • demos/payments/src/spend-ledger.ts

Walkthrough

The payments demo adds a rolling-window spend ledger, budget-aware payment authorization, and payer-scoped reservation handling across payment initiation and callbacks.

Changes

Payment spend control

Layer / File(s) Summary
Rolling-window spend ledger
demos/payments/src/spend-ledger.ts, demos/payments/src/spend-ledger.test.ts
Adds synchronous reservations with subject and currency isolation, expiration, idempotent references, and commit or release operations. Tests cover ledger limits, time windows, lifecycle behavior, and reference collisions.
Budget-aware policy authorization
demos/payments/src/payment-policy.ts, demos/payments/src/payment-policy.test.ts, demos/payments/README.md
Adds optional rolling budgets and authorizePayment. The policy validates amounts and currency limits before reserving approved payments. Tests and documentation cover authorization rules and demo limitations.
Payment-service reservation lifecycle
demos/payments/src/payment-service.ts
Uses payer identity and shared payment references during initiation and callbacks. Successful receipt retrieval commits reservations. Failed retrieval releases reservations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • agentcommercekit/ack#97: This PR extends the payment-policy and payment-service enforcement introduced there with cumulative spend authorization and reservation lifecycle handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: enforcing a rolling cumulative spend budget in the payments demo.
Linked Issues check ✅ Passed The changes implement the linked issue objectives, including rolling budgets, atomic reservations, idempotency, commit/release handling, tests, and documentation [#138].
Out of Scope Changes check ✅ Passed The changes are limited to the payments demo, its tests, and documentation, with no unrelated package, schema, protocol, or dependency changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Both parts of the key come from the Payment Request as unconstrained strings,
so `${paymentRequestId}:${paymentOptionId}` lets `("a:b", "c")` and
`("a", "b:c")` produce the same reference. A collision overwrites the earlier
reservation, dropping its amount from the window and under-enforcing the
budget. A payment request that clears the recipient allowlist can choose both
ids, so this is reachable rather than theoretical.

Encode the pair instead, and move `spendReference` next to the ledger it keys
so it can be tested without importing the Payment Service, which starts a
server on import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
demos/payments/src/spend-ledger.test.ts (1)

72-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for re-reserving a reference with a larger amount.

reserve replaces the entry for an existing reference. The current tests only re-reserve the same amount, so the limit check on the replacement path is untested. A regression that skipped the limit check for known references would still pass.

💚 Proposed additional test
   it("counts a re-reserved reference once", () => {
     reserve("payment-1", 600n)
 
     expect(reserve("payment-1", 600n)).toEqual({
       status: "reserved",
       spent: 600n,
     })
     expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(600n)
   })
+
+  it("rejects a re-reservation that raises the amount above the limit", () => {
+    reserve("payment-1", 600n)
+    reserve("payment-2", 300n)
+
+    expect(reserve("payment-1", 800n)).toEqual({
+      status: "exceeded",
+      spent: 300n,
+      limit: 1_000n,
+    })
+    expect(ledger.spentWithin(SUBJECT, "USDC", WINDOW_MS)).toBe(900n)
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demos/payments/src/spend-ledger.test.ts` around lines 72 - 80, Add a test
alongside “counts a re-reserved reference once” that reserves the same reference
again with a larger amount and verifies the replacement still enforces the
spending limit. Assert the operation’s rejected/limit-exceeded result and
confirm the ledger total remains unchanged after the failed re-reservation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@demos/payments/src/payment-service.ts`:
- Around line 104-110: Document the post-capture denial scenario in the README
limitations list or add distinct logging around enforcePaymentPolicy in the
re-authorization flow identified by spendReference and paymentRequest.id. Make
the message clearly state that Stripe captured funds but settlement was denied
because the original reservation expired and the budget was consumed by later
payments.
- Around line 56-64: Update the callback handler’s reservation-release flow to
include createJwt in the existing try block, ensuring any JWT creation rejection
triggers reservation release before propagating the error. Keep receipt
generation behavior unchanged for successful JWT creation.

---

Nitpick comments:
In `@demos/payments/src/spend-ledger.test.ts`:
- Around line 72-80: Add a test alongside “counts a re-reserved reference once”
that reserves the same reference again with a larger amount and verifies the
replacement still enforces the spending limit. Assert the operation’s
rejected/limit-exceeded result and confirm the ledger total remains unchanged
after the failed re-reservation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 335aacc2-e822-4346-b966-b335b9d7262f

📥 Commits

Reviewing files that changed from the base of the PR and between 0b8fdaa and a6ce66e.

📒 Files selected for processing (6)
  • demos/payments/README.md
  • demos/payments/src/payment-policy.test.ts
  • demos/payments/src/payment-policy.ts
  • demos/payments/src/payment-service.ts
  • demos/payments/src/spend-ledger.test.ts
  • demos/payments/src/spend-ledger.ts

Comment thread demos/payments/src/payment-service.ts
Comment thread demos/payments/src/payment-service.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6ce66e5e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread demos/payments/src/payment-service.ts Outdated
Comment on lines +188 to +189
function spendReference(paymentRequestId: string, paymentOptionId: string) {
return `${paymentRequestId}:${paymentOptionId}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a per-execution key for Stripe budget reservations

When the same PaymentRequest token and option are submitted for another Stripe initiation or callback within the window, this reference is identical, so SpendLedger.reserve treats the new execution as a re-authorization and excludes/replaces the existing entry instead of adding to the rolling total. That lets repeated Stripe payments for a reused request stay charged against the budget only once; include a payment-execution identifier such as a generated session/payment-intent id in the reservation key while still reusing that key for the matching callback.

Useful? React with 👍 / 👎.

Addresses three review findings.

Keying the reservation on the Payment Request meant a request presented for
execution twice consumed the budget once, so an agent could bypass the budget
by re-executing one request. The Payment Service now mints an execution id at
initiation and carries it through the callback URL: the two calls of one
execution still reserve once, while a second execution is charged separately.
The demo client needs no change — it forwards whatever `return_to` it is given
— and `return_to` is now URL-encoded, since it carries a query of its own.

Move `createJwt` inside the try that releases the reservation. Signing failed
outside it, leaving a reservation with no receipt to commit or release.

Document the post-capture denial path in the README: the callback re-authorizes
after the card is captured, so a denial there stops the receipt without
stopping the charge. It needs the initiation reservation to have aged out and
later payments to have filled the budget, which the one-hour window makes
unlikely, but a real service should settle and flag rather than refuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mehmetkr-31

Copy link
Copy Markdown
Author

Thanks both — all three findings were real. Pushed 5414824 and 6100986; the reviews above were against a6ce66e.

Reserve per payment execution, not per Payment Request (@chatgpt-codex-connector)

This was the important one, and it undercut the point of the PR. Keying on the Payment Request meant a request presented for execution twice consumed the budget once, so the agent the budget is meant to bound could bypass it by re-executing one request.

The Payment Service now mints an execution id at initiation and carries it through the callback URL, so the two calls of one execution still reserve once while a second execution is charged separately. The demo client needs no change — it forwards whatever return_to it is given — and return_to is now encodeURIComponent'd since it carries a query of its own. A callback that arrives with an unknown execution id reserves again rather than escaping the budget, so the unhappy path fails closed; a real Payment Service would resolve the execution from the provider's session id server-side instead of trusting the caller to echo it back, which is noted at the call site.

Release the reservation when JWT creation fails (@coderabbitai)

Correct — createJwt sat outside the try, so a signing failure left a reservation with nothing to commit or release. It is inside now.

Document the post-capture denial path (@coderabbitai)

Also correct, and documentation rather than a code change is the right call here: the callback re-authorizes after the card is captured, so a denial there stops the receipt without stopping the charge. Added to the README limitations block, including that a real service should settle and flag an over-budget capture for reconciliation rather than refuse it.

Also fixed, self-reported

The reservation key was ${paymentRequestId}:${paymentOptionId}. Both parts arrive as unconstrained strings on the Payment Request, so ("a:b", "c") and ("a", "b:c") collided onto one entry and the second amount fell out of the window — reachable by any payment request that clears the recipient allowlist. The key is encoded now, and spendReference moved next to the ledger so it is testable without importing the Payment Service, which starts a server on import.

Verification

pnpm run build
pnpm run check                                     # 29/29 tasks, 0 lint warnings/errors
pnpm --filter ./demos/payments exec vitest run     # 40 passed (19 policy, 21 ledger)
pnpm run knip                                      # clean
git diff --check

Re-ran the three services and drove the Stripe path over HTTP, extracting return_to from the payment URL exactly as demos/payments/src/index.ts does:

result
happy path at the demo's 5-subunit amount initiate=200, execution id present in return_to, callback=200, receipt issued
replaying the callback for one execution 200, no additional budget consumed
three payments at the per-transaction cap 200, 200, 200
re-executing an already-executed request 403 — a 200 here would mean re-execution is free, which is the bug above
a fresh payment on the exhausted budget 403

AI Usage Disclosure

These follow-ups were prepared with AI assistance (Claude Code, Opus), used for navigating the review threads, drafting the changes and tests, and running verification. I reviewed the final diff and take responsibility for it.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Changed payment demo files ---'
git diff --name-only a6ce66e..HEAD -- demos/payments

printf '%s\n' '--- payment-service structure ---'
ast-grep outline demos/payments/src/payment-service.ts --items all

printf '%s\n' '--- JWT, reservation, and failure handling ---'
rg -n -C 12 -e '\bcreateJwt\s*\(' -e '\.release\s*\(' -e '\.commit\s*\(' -e '\bauthorizePayment\s*\(' demos/payments/src/payment-service.ts

printf '%s\n' '--- Documented capture / denial limitation ---'
rg -n -i -C 5 'capture|post-capture|over-budget|reconciliation|receipt' demos/payments/README.md

printf '%s\n' '--- Execution and reference tests ---'
rg -n -C 4 -e 'execution|spendReference|createJwt|release' demos/payments/src -g '*test.ts'

Length of output: 19201


@mehmetkr-31 Verified the follow-up changes.

createJwt(...) is now inside the try block. A signing failure releases the reservation through spendLedger.release(reference).

The README now documents the post-capture denial path. It correctly states that production services should settle and flag an over-budget capture for reconciliation.

The execution-specific, collision-safe spendReference tests also cover the reported budget-bypass cases.

🐇 ✅


✏️ Learnings added
Learnt from: mehmetkr-31
URL: https://github.com/agentcommercekit/ack/pull/139

Timestamp: 2026-08-10T22:23:54.436Z
Learning: In `demos/payments`, the Stripe callback can re-authorize after card capture. If that authorization denies the payment, the demo stops receipt issuance but cannot stop the charge. A production Payment Service should settle the captured payment and flag it for reconciliation. Payment execution identifiers should be resolved from a provider-side session rather than trusted from a caller-supplied callback parameter.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

demo(payments): add a cumulative spend budget to the policy guard

1 participant