Projects v0.3.0

PaySentry

Jan 2026 to present

The PaySentry test console after a capture, a refund and a ledger check Open the live demo
274
Tests on real Postgres
1 of 50
Duplicate requests that run
INR 0.00
Sum of all ledger entries

Overview

PaySentry takes card payments for merchants and records every paisa in a double-entry ledger. It charges each card exactly once.

Payments go wrong in a few familiar ways. Clients retry. Two copies of a request arrive at the same moment. A gateway times out and nobody knows if it charged. PaySentry handles each of these, and each one has a test that runs against a real Postgres.

The live demo is a test-mode console. You run one payment through authorize, replay, capture and refund, watch each request and its ledger entries, then check that the ledger still balances. It takes test cards only, so no real money moves.

Key Features

  • Double-entry ledger. Every transfer writes balanced entries, and a database trigger keeps entries append-only.
  • Idempotency keys scoped to each merchant. A retry gets the stored answer. A changed request under the same key is rejected.
  • Authorize, capture, void and partial refunds, each with clear rules on which states it may start from.
  • Recovery for payments stuck after a gateway timeout or a crash. A background job asks the gateway what happened and finishes the payment once.
  • A Stripe connector for test mode, next to two simulated gateways with different API styles. Card numbers never reach Stripe: each test card maps to Stripe's own test payment method.
  • Routing across gateways with rules, circuit breakers and success-rate ranking. A LinUCB contextual bandit is there as an experiment, tried only in simulation.
  • Rate limits and routing health kept in Redis, with a fallback when Redis is down.

How It's Built

FastAPI serves the API. Postgres holds the ledger, accessed with plain SQL through asyncpg.

  • Ledger: a transfer claims its idempotency key, locks the account rows in id order, then writes its entries in one transaction
  • Connectors: one per gateway API, Stripe included. Each one turns that gateway's replies, errors and timeouts into a small set of outcomes
  • Recovery: settles payments whose gateway outcome is unknown, using leases so two workers never finish the same payment
  • Demo: the browser page runs the same payment code with mock gateways inside the API process. It is hosted on Render with Postgres on Neon

Interesting Challenges

  • Racing duplicates: 50 copies of one transfer arrive at once. One runs, and the other 49 get the stored answer
  • Competing spends: 50 spends of 30 from a balance of 100. Exactly 3 go through
  • Deadlocks: 100 transfers from A to B and from B to A at once. Locking rows in a fixed order means none deadlock
  • Refund limits: 20 refunds of 100 against a capture of 1000. Exactly 10 go through
  • Unknown outcomes: a gateway timeout during any operation, or a crash after the gateway acted. The recovery job finishes each one exactly once
  • Model-based tests: seeded random sequences of operations are checked against an independent model of the expected balances

One Failure, Step by Step

The hardest case is a gateway that charges the card and then goes quiet. Here is what PaySentry does.

  1. A merchant asks for a payment with an idempotency key. PaySentry records the attempt, then sends the gateway our attempt ID as its reference.
  2. The gateway charges the card, but its reply misses the 2-second deadline. PaySentry cannot tell a charge from no charge, so it does neither of the easy things. Marking the payment failed would lose money. Trying another gateway could charge the card twice.
  3. The merchant gets 202 and the payment stays processing. A retry with the same key gets 409 until the outcome is known.
  4. A minute later the recovery job claims the attempt under a lease, so no other worker takes it. It asks the gateway what happened to that reference. It never sends the charge again, because PaySentry does not keep the card number.
  5. The gateway says it charged. Recovery finishes the payment through the same code the request would have used, so the ledger entries are written once. It stores the final response under the key.
  6. The merchant retries with the same key and gets the real result, marked as a replay. The gateway holds exactly one charge.

The test test_unknown_authorization_is_recovered_and_the_key_replays_it runs this against a real Postgres. Its neighbours do the same for capture, void and refund, and for a crash right after the gateway acted.

Screenshots