llm-driven-system-design / payment-system
CLAUDE.md60 starsChanged 10 months agoMITView on GitHub
A merchant payment platform: merchants authenticate with an API key and drive payments through the full lifecycle — authorize, capture, void, refund, chargeback — while every balance change is recorded in a double-entry ledger. The core hard problem is money correctness under retries: a network timeout must never double-charge a customer, and the sum of debits must always equal the sum of credits. That forces two disciplines everywhere — idempotency on every mutation, and paired ledger entries inside a single…
Files
Payment System — Development with Claude
Project Context
A merchant payment platform: merchants authenticate with an API key and drive payments through the full lifecycle — authorize, capture, void, refund, chargeback — while every balance change is recorded in a double-entry ledger. The core hard problem is money correctness under retries: a network timeout must never double-charge a customer, and the sum of debits must always equal the sum of credits. That forces two disciplines everywhere — idempotency on every mutation, and paired ledger entries inside a single database transaction.
Learning goals: double-entry bookkeeping as a correctness invariant, idempotency-key design, circuit-breaker isolation of an unreliable external processor, and async fan-out (webhooks, fraud scoring) without blocking the payment path.
Architecture at a Glance (what actually runs)
Three infrastructure services in docker-compose.yml, each on the hot path for a different reason:
| Store | Role | Why this one |
PostgreSQL (pg) | Transactions, ledger_entries, merchants, refunds, chargebacks, webhook deliveries | ACID — the ledger's debit=credit invariant only holds if paired entries commit atomically inside one transaction |
Valkey/Redis (ioredis) | Idempotency key → cached response (24h TTL), distributed processing locks | Sub-ms GET on every mutation before business logic runs; DB is the durable fallback |
RabbitMQ (amqplib) | webhook.delivery + fraud.scoring queues consumed by background workers | Decouples slow/failure-prone side effects (merchant webhook endpoints, deep fraud analysis) from the synchronous payment response |
Backend is a single Express app (backend/src/index.ts) plus two standalone workers (workers/webhook-worker.ts, workers/fraud-worker.ts). Routes: payments, refunds, chargebacks, merchants, ledger. Services: payment/ (authorize/capture/void/refund/processor), ledger.service, merchant.service, fraud.service, refund.service. Frontend: React 19 + TanStack Router + Zustand (merchant dashboard).
Key Design Decisions
1. Double-entry ledger as the source of truth
Every captured payment writes three balanced ledger_entries rows in one PG transaction (ledger.service.ts recordPaymentCapture): debit Accounts Receivable for the gross amount, credit the merchant account for the net, credit Platform Revenue for the fee. Refunds and chargebacks reverse the same pattern (chargebacks additionally charge a processing fee). The transactions row is the business event; the ledger rows are the accounting impact. verifyLedgerBalance() reconciles by summing debits vs credits over a window — if they ever diverge, something is wrong. Trade-off given up: writing 3 rows + a balance update per capture is more expensive than a single UPDATE balance, but a single mutable balance has no audit trail and can't be reconciled after a bug.
2. Idempotency in Redis with a PG uniqueness backstop
Mutations carry an Idempotency-Key header (middleware/auth.ts copies it into the body). shared/idempotency.ts checks Redis first (24h TTL, keyed per merchant); a hit returns the cached response without re-running business logic. The transactions and refunds tables also carry a UNIQUE(idempotency_key) constraint as the durable fallback if Redis restarts without persistence. Trade-off: two systems to keep consistent, but the Redis check keeps the common-case retry off the database, and the DB constraint guarantees correctness even if the cache is cold.
3. Circuit breaker around the external processor
Processor calls (services/payment/processor.ts) run through a cockatiel circuit breaker (shared/circuit-breaker.ts), not raw calls. After a failure threshold the breaker opens and fails fast instead of letting requests pile up against a dead processor and exhaust connection pools. The processor itself is simulated locally (95% approve, forced decline on card last_four = '0000', higher decline over $10k). Trade-off: fail-fast means some payments are rejected during a processor blip that a longer timeout might have ridden out — acceptable because a responsive "try again" beats a hung request pool.
4. bcrypt API keys, linear scan (local simplification)
Merchant keys are generated as pk_<uuid> and stored as bcrypt hashes (merchant.service.ts). Because bcrypt hashes aren't reversible or indexable, authenticateByApiKey loads active merchants and bcrypt.compares against each — fine for a handful of local merchants, explicitly flagged in-code as needing a key-id lookup at scale. This is the auth model to remember: API-key merchant auth, not username/password sessions.
5. Async side effects over inline processing
After a payment commits, webhook delivery and deep fraud scoring are published to RabbitMQ and handled by workers with retry/backoff, rather than run inline. Trade-off: the merchant's webhook and the final fraud verdict are eventually-consistent with the payment, but the customer-facing authorize/capture response isn't held hostage to a slow merchant endpoint.
Current State
Implemented end to end: API-key auth, payment authorize/capture/void, full + partial refunds with proportional fee reversal, chargeback recording with ledger impact, double-entry ledger with reconciliation/summary queries, idempotency (Redis + DB backstop), circuit-breaker-wrapped simulated processor, RabbitMQ webhook + fraud workers, Prometheus metrics (prom-client), structured logging (pino), and a merchant dashboard frontend with volume/stats charts.
Seeded from backend/db-seed/seed.sql: merchant Acme Payments, API key pk_test_5a8f3c2e1b9d4f6a7c0e2d1b (paste it on the login page — auth is an API key, not a username/password), with nine transactions spanning every lifecycle state, balanced ledger entries, refunds, a chargeback and webhook deliveries.
Intentionally omitted / simulated: real processor integration (Stripe/Adyen), a settlement/payout worker (referenced in architecture but not built as a worker), customer/payment-method vaulting tables (schema focuses on transactions), multi-currency FX conversion, and admin session auth (architecture describes it; code ships only API-key merchant auth).
Iteration & Repair Log
- 2026-07 (CLAUDE.md rewrite): This file was the untouched repo template — "Phase 1: Requirements Not started", "Decisions will be documented here". Replaced with the real architecture, decisions, and state grounded in
docker-compose.yml,backend/package.json, and the service code. - 2026-07 (architecture drift fix):
architecture.mdclaimed API keys were SHA-256 hashed withsk_live_*/sk_test_*prefixes andapi_key_hash VARCHAR(64). The code uses bcrypt with apk_prefix (merchant.service.ts) andVARCHAR(255)(init.sql). Corrected the auth section, the responsibilities list, and the schema comment to match. - 2026-07 (README drift fix): README described the Docker stack as "PostgreSQL and Redis" and never mentioned RabbitMQ, which docker-compose starts and the webhook/fraud workers consume. Added RabbitMQ (management UI + guest/guest) and clarified the core API runs without it.
- 2026-07-30 — the dashboard was empty and the transaction detail page was unreachable.
db-seed/seed.sqlhad three system accounts and one merchant, and nothing else — no transactions, no ledger entries, no refunds. Every page read "No transactions". The seed now covers the whole lifecycle the dashboard filters on (captured, authorized, failed, voided, refunded, partially refunded, disputed), with balanced three-row ledger entries per capture, a full and a partial refund plus one still pending, a chargeback inpending_response, and webhook deliveries including one failed inside its retry budget.- The merchant's
account_idpointed ataccounts_receivable.recordPaymentCapturedebits AR for the gross and credits the merchant account for the net — sharing one account means debiting and crediting the same row, and the merchant's payable balance is never tracked. Added amerchant:acmeaccount and repointed the merchant at it; the dashboard's Available Balance is now a real number derived from the ledger. /transactions/:idrendered the transactions list. With TanStack's flat file routing,transactions.$id.tsxbecomes a child oftransactions.tsx, andtransactions.tsxrenders no<Outlet />— so the 334-line detail page could never mount, no matter how you navigated to it. Renamed the list totransactions.index.tsxso both are siblings under the root. The detail page shows the idempotency key, risk score, fee/net split, and state-appropriate actions (Capture/Void when authorized, Refund when captured) — none of which had ever been reachable.
- Answer doc drift + length:
system-design-answer-backend.mdstill described API keys as SHA-256 (the code uses bcrypt, asarchitecture.mdwas already corrected to say) and was 309 lines, under the 350 floor. Corrected the hashing, noted the O(n)-bcrypt-scan consequence and thepk_<key_id>_<secret>fix, and added deep dives on the authorize/capture split (why only capture writes ledger entries) and on why the processor gets a circuit breaker rather than a timeout. Now 351. - Repo-wide: part of the DB/Redis connection-string fallback and ESM hardening passes; the CI smoke-test workflow was removed repo-wide (no Docker in CI).
Open Questions
authenticateByApiKeyscans all active merchants per request — at what merchant count does this need akey_idprefix lookup (storepk_<keyid>_<secret>, index onkey_id)?- Ledger writes use a normal PG transaction; the architecture calls for SERIALIZABLE isolation on balance updates. Is the current isolation level sufficient under concurrent captures against the same merchant account, or do we need explicit row locks?
- Idempotency responses are cached for 24h — what's the right behavior when a retry arrives after expiry for a payment that did commit? (The DB
UNIQUEcatches the duplicate, but the cached response body is gone.) - Chargebacks are recorded but there's no evidence-submission/dispute-lifecycle worker — where should that state machine live?
Resources
In this repository94 other files it gives its agents.
- AGENTS.mdAGENTS.md
- 20forms-20designs/CLAUDE.mdCLAUDE.md
- ad-click-aggregator/CLAUDE.mdCLAUDE.md
- ai-code-assistant/CLAUDE.mdCLAUDE.md
- airbnb/CLAUDE.mdCLAUDE.md
- airtag/CLAUDE.mdCLAUDE.md
- amazon/CLAUDE.mdCLAUDE.md
- apns/CLAUDE.mdCLAUDE.md
- apple-maps/CLAUDE.mdCLAUDE.md
- apple-music/CLAUDE.mdCLAUDE.md
- apple-pay/CLAUDE.mdCLAUDE.md
- apple-tv/CLAUDE.mdCLAUDE.md
- app-store/CLAUDE.mdCLAUDE.md
- bitly/CLAUDE.mdCLAUDE.md
- calendly/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- coinbase/CLAUDE.mdCLAUDE.md
- collaborative-editor/CLAUDE.mdCLAUDE.md
- confluence/CLAUDE.mdCLAUDE.md
- dashboarding/CLAUDE.mdCLAUDE.md
- discord/CLAUDE.mdCLAUDE.md
- distributed-cache/CLAUDE.mdCLAUDE.md
- docusign/CLAUDE.mdCLAUDE.md
- doordash/CLAUDE.mdCLAUDE.md
- dropbox/CLAUDE.mdCLAUDE.md
- etsy/CLAUDE.mdCLAUDE.md
- excalidraw/CLAUDE.mdCLAUDE.md
- facetime/CLAUDE.mdCLAUDE.md
- fb-live-comments/CLAUDE.mdCLAUDE.md
- fb-news-feed/CLAUDE.mdCLAUDE.md
- fb-post-search/CLAUDE.mdCLAUDE.md
- figma/CLAUDE.mdCLAUDE.md
- gallery/CLAUDE.mdCLAUDE.md
- github/CLAUDE.mdCLAUDE.md
- gmail/CLAUDE.mdCLAUDE.md
- google-calendar/CLAUDE.mdCLAUDE.md
- google-docs/CLAUDE.mdCLAUDE.md
- google-search/CLAUDE.mdCLAUDE.md
- google-sheets/CLAUDE.mdCLAUDE.md
- health-data-pipeline/CLAUDE.mdCLAUDE.md
- hotel-booking/CLAUDE.mdCLAUDE.md
- icloud/CLAUDE.mdCLAUDE.md
- imessage/CLAUDE.mdCLAUDE.md
- instagram/CLAUDE.mdCLAUDE.md
- jira/CLAUDE.mdCLAUDE.md
- job-scheduler/CLAUDE.mdCLAUDE.md
- kindle-highlights/CLAUDE.mdCLAUDE.md
- leetcode/CLAUDE.mdCLAUDE.md
- linkedin/CLAUDE.mdCLAUDE.md
- local-delivery/CLAUDE.mdCLAUDE.md
- loom/CLAUDE.mdCLAUDE.md
- mcplator/CLAUDE.mdCLAUDE.md
- mdreader/CLAUDE.mdCLAUDE.md
- microsoft-teams/CLAUDE.mdCLAUDE.md
- netflix/CLAUDE.mdCLAUDE.md
- news-aggregator/CLAUDE.mdCLAUDE.md
- notification-system/CLAUDE.mdCLAUDE.md
- notion/CLAUDE.mdCLAUDE.md
- online-auction/CLAUDE.mdCLAUDE.md
- paypal/CLAUDE.mdCLAUDE.md
- The first 60 shown.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

