@agentbadge/payments β a payment domain model that separates Payment/Attempt/Receipt/Event entities from whatever rail moves the money. Guarded state transitions, a 9-check verification verdict as the only door to CONFIRMED, two-level idempotency, decimal-string money β stress-tested by two adversarial AI audits and a live Stripe test-mode dogfood: CONFIRMED β REFUNDED, concurrent refunds β exactly one rail refund. 114/114 tests green.
We built @agentbadge/payments β a payment domain model that treats Payment, PaymentAttempt, PaymentReceipt and PaymentEvent as durable entities separate from whatever rail actually moves the money. All state changes flow through two transition maps with guarded writes, every CONFIRMED requires a 9-check verification verdict, idempotency runs at two levels (request fingerprint + claim-first side effects), and money is a decimal string end-to-end. Two adversarial AI audits (5 Critical + 6 High + 11 Medium, then 1 Critical + 6 High + 8 Medium) and a live Stripe test-mode run later: CONFIRMED β REFUNDED, concurrent refunds β one rail refund, 114/114 tests green.
Every payment integration we've written or reviewed eventually grows the same knot: the business entity that means "this customer owes us $5" gets tangled with the adapter that talks to Stripe, or a blockchain, or a PSP. The rail's status enum leaks into your domain. Your "refund" code calls the rail before it records that a refund is happening. Your "confirmed" flag is whatever the rail said last.
The failure mode isn't hypothetical. It's the standard one: two state machines β yours and theirs β drifting apart, while your code pretends there's one.
@agentbadge/payments (EPIC-220) draws the boundary explicitly:
| Entity | What it means | Owned by |
|---|---|---|
Payment |
The obligation: reference, amount, customer, status | domain |
PaymentAttempt |
One try at settling it, on one provider+network | domain |
PaymentReceipt |
Append-only proof that money moved, keyed to evidence | domain |
PaymentEvent |
Append-only audit trail: fromβto, actor, reason | domain |
PaymentEvidence |
Normalized rail output: status, tx hash, amount, finality | adapter |
The rail (PaymentProvider interface: createPayment, verifyPayment, optional refundPayment) produces evidence, never verdicts. Only the domain writes domain state.
Why separate the money layer from the money rail #
Wait β that table is the why. So let me put the argument differently: the split isn't an abstraction fetish, it's a failure-isolation decision. When the rail's enum becomes your domain's truth, every retry, every race, every provider quirk becomes a domain bug you can't see. Keeping evidence and verdict in different hands is what makes the rest of this article testable at all.
Controlled transitions: two maps, no free-form UPDATEs #
Payment and Attempt each get a transition map. Every status change goes through a guarded applyStatus(expectedFrom, to) at the store level β a concurrent writer who moved the row first returns null, and the loser re-reads and reports what is actually persisted.
The payment machine (post-audit):
PENDING βββ PROCESSING βββ CONFIRMED
β β ββββββββ DISPUTED βββ CONFIRMED / REFUNDED
β β ββββββββ FAILED / EXPIRED
ββββββββββββ EXPIRED
FAILED, EXPIRED, REFUNDED, CONFIRMED = terminal
Two details earned their place in the map:
FAILEDis terminal. The audit found aFAILED β PENDINGresurrection edge β a payment declared dead could quietly come back and hop to CONFIRMED with no attempt or receipt attached. Retry is now expressed as a new create, not a resurrection.DISPUTEDdoes double duty, deliberately. It's the review state for "rail says SUCCESS but our checks disagree" β captured funds that need a human β and it's the claim state for refunds:CONFIRMED β DISPUTEDis takenbefore the rail refund call, so a concurrent second refund loses the claim instead of charging twice.
Verification: nine checks between evidence and CONFIRMED #
verifyPayment() in the service never trusts a rail status. It normalizes evidence and runs nine checks β reference, amount, currency, provider, recipient, network, finality, transaction-reuse, plus the rail's own terminal signal:
| Check | Catches |
|---|---|
reference_match |
confirming the wrong payment entirely |
amount_match /currency_match |
partial payments, cross-asset confusion |
provider_match |
evidence from rail A applied to rail B's attempt |
recipient_match |
money arrived at an attacker-controlled address |
network_match |
same tx hash, wrong chain |
finality |
tx exists but isn't confirmed enough |
tx_not_reused |
one onchain tx confirming two payments |
status_success |
evidence claiming success it can't back |
A clean verdict is the only path to CONFIRMED. Not a rail callback. Not a create-time status. Not a raw transitionPayment β the audit noticed those helpers were exported from the package barrel, so we un-exported them; the only public doors are the guarded persistence paths and the service.
What happens when evidence and checks disagree #
The subtle part: a failed check means different things depending on whether money moved.
- Retryable-only failures (today:
finality) βPENDING. The tx exists, it just isn't deep enough yet. Confirming this as FAILED would lose a good payment; confirming it as CONFIRMED would front-run consensus. - Non-retryable failure on captured funds β payment
DISPUTED. The money is there but something is wrong β wrong recipient, replayed tx, mismatched amount. Marking FAILED would lie (the funds exist); confirming would compound it. DISPUTED says "review me". - Failed check + no captured funds β
FAILEDorEXPIREDper evidence.
Idempotency at two levels #
Level 1 β entity dedupe. createPayment looks up by reference then idempotencyKey before inserting. The audit's sharper point: the old code deduped on the key only β a caller reusing "ord-1" with a different amount silently got the first payment back. Now each row stores a JSON request fingerprint in payment.payload.request; a dedupe hit with a mismatched fingerprint returns conflict, not a silent swap. Rail metadata (checkout URL, session id) is persisted on the attempt and replayed on dedupe, so a lost first response doesn't lose the checkout link.
Level 2 β claim-first side effects. The refund path used to call the rail then try to update state. Two concurrent refunds = two rail charges. Now the service takes a guarded CONFIRMED β DISPUTED claim first, calls the rail with a deterministic idempotency key (refund_<paymentId>_<amount>), then resolves: REFUNDED on success, rollback to CONFIRMED on failure, claim held on PENDING for resume.
Same pattern in confirmPayment: the guarded write loses a race? Re-read and return the persisted status β never fabricate a CONFIRMED response. And if an attempt is CONFIRMED but the payment/receipt lagged (non-atomic triple write), the next confirm call self-heals: completes the payment transition and appends the missing receipt idempotently.
Decimal-string money #
Amounts are "5.00" strings everywhere in the domain β assertDecimalAmount guards format and scale, comparisons go through decimalAmountsEqual, and conversion to cents happens only at the adapter boundary (usdToCents/ centsToUsd are BigInt-exact). The audit's M-4 sharpened this: zero amounts and >15 integer digits are rejected at create, and an unparseable evidence amount now fails the amount check instead of throwing.
The audit: 5 Critical, and what they taught us #
We pointed an adversarial reviewer (Arc Studio, external AI audit) at the package. Verdict: BLOCK β 5 Critical, 6 High, 11 Medium. The findings that made us glad we ran it before wiring this to money:
- C-1 β the create path drove the attempt to the rail-reported status, so a rail returning
CONFIRMEDat create skipped the entire verification pipeline. Now clamped toPROCESSING; CONFIRMED exists only behind the 9-check verdict. - C-2 β a lost guarded-write race returned
status: "CONFIRMED"without checking what actually won. A sweeper expiring the attempt in the same tick would be silently overwritten by the response. - C-3 β attemptβpaymentβreceipt were three separate writes; a crash between them left a CONFIRMED attempt on a PROCESSING payment forever. The self-healing completion path repairs it on the next read.
- C-4 β refund called the rail before claiming state: the classic double-refund.
- C-5 β
FakeProviderdefaulted to auto-confirm and echoed input back as evidence β a fail-open test rail that could be registered as a real provider. It now defaults to"pending"and carries anisTestProvidermarker.
The Highs were the same shape β places where the code assumed instead of checking: expiry that never asked the rail whether it was still alive, expectations re-supplied by the caller instead of persisted on the attempt, dedupe without fingerprints, raw transitions in the export surface. The full finding-by-finding resolution is in docs/EPICS/220-payment-infrastructure-foundation/reviews/220-payments-REVIEW.md.
Then we ran it on real money (test mode) #
Unit tests are hypothesis; the rail is the experiment. We pointed the service at Stripe test mode (sk_test_*), generated a real Checkout Session, paid it with 4242β¦, and let the full lifecycle run β create β dedupe edges β pay β confirm β receipt β refund β re-refund.
The first run caught a live bug the unit suite missed: we passed a network expectation on a fiat rail. Stripe evidence carries no network tag, so network_match failed on a fully paid session β the payment landed in DISPUTED with captured funds β and had no API way out (the refund path expected a CONFIRMED attempt the dispute never produced). We refunded it manually via the Stripe API and logged it as finding 220-10-F1. This is exactly the class of failure the design was supposed to prevent β and the live rail found it in five minutes.
So we ran the adversarial audit again, this time against the live-verified code. Verdict: REQUEST CHANGES β 1 Critical, 6 High, 8 Medium. The second-round findings were nastier: the local expiry clock could bury a paid-but-unconfirmed session without ever asking the rail; caller input could override the expectations persisted at create (griefing on fiat, bypass on onchain); the refund claim key compared raw "5.00" vs "5.0" strings as different amounts; and a routine re-confirm could silently cancel an in-flight refund claim.
All of it got fixed and re-verified: the sweeper and lazy reads now consult the rail and defer on any uncertainty; providers declare which expectations they can satisfy and createPayment rejects the rest before a row exists; persisted expectations are authoritative; the refund path validates before claiming, canonicalizes the idempotency key, and rolls back on rail errors.
The proof run, on the same Stripe test-mode rail:
edge-fiat-network-guardβguardRejected:trueβ the 220-10-F1 bug is now unreachable, rejected at create.CONFIRMEDβ 1 receipt (external ref = Checkout Session,transactionHash= PaymentIntentpi_3UZzR1K84Am2sMrt1Y6iuI1j), re-confirm deduped with no second receipt.refundβREFUNDED(re_3UZzR1K84Am2sMrt1uxL6SzD); twoconcurrent refund calls both returned REFUNDED and the rail shows exactlyone refund βrailRefundCount:1. Exactly-once, measured on the rail, not asserted in a mock.
Transcripts and screenshots (test-mode ids, safe to publish) are in docs/EPICS/220-payment-infrastructure-foundation/evidence/; the finding-by-finding resolution is reviews/220-payments-REAUDIT.md. 114/114 tests green.
Honest scope #
What exists: the domain model, both state machines, the verification pipeline, guarded persistence ports (Map-backed in-memory + @agentbadge/database Prisma), the PaymentService facade, the expiry sweeper, FakeProvider, StripePaymentProvider (Checkout Sessions, evidence mapping, refunds with idempotency keys).
What does not exist yet:
- USDC / x402 rails β the provider interface is ready for them; the adapters are the next epics.
- HTTP routes / webhook ingestion β the service is a library; the commerce epics on top will expose it.
- Live checkout migration β the existing Stripe checkout path is untouched (D-220-1); this is the foundation underneath, not a swap.
- Refund ledger β partial-refund accounting is a deferred follow-up (M-6 tail).
- DB-level transition enforcement β the maps are enforced at the persistence layer in app code; a CHECK/trigger in
@agentbadge/databaseis the belt-and-suspenders follow-up.
What's next #
The foundation earns its keep when a second rail lands: USDC/x402 adapters, the payments-commerce epics (hosted checkout, webhook ingestion, agent-facing create/confirm flows), and refund accounting. The thesis we'll keep testing: the rail reports evidence; the domain decides truth β and nothing reaches CONFIRMED that didn't survive all nine checks.
Series: this is the payment-foundation writeup behind the agent commerce stack. Previously: Economic Ledger (where money events land), Payment Forensics (reading the graph it produces), Keyless Signer (who signs onchain). Live at agentbadge.xyz β source and specs on GitHub.