Skip to main content
Back to DevlogsEngineering field notes
ConcurrencyPaymentsRedisPostgreSQL

The Last Payer Wins

Designing split-bill payments so duplicate clicks, concurrent callbacks, and delayed gateways cannot corrupt an order.

Published on September 9, 2026
~3 min read

A Social Feature with Financial Invariants

Split billing looks friendly in the interface: choose a number of guests, share a link, and watch each contribution arrive. Underneath, it is a concurrency problem. Two people can pay at the same time. One person can tap twice. A gateway callback can be retried after the browser has closed. The final payment can settle while another request still believes the order is unpaid.

I started with invariants rather than screens: every share belongs to one split group; the share amounts equal the order total; a paid share cannot be paid again; and the order becomes paid exactly once when the funded total reaches its price. Those rules became the boundary for every service method and database transition.

Serialize the Critical Path, Not the Product

Payment initiation runs inside a distributed lock keyed by the split group: split:pay:{token}. While holding it, the service reloads the share with its order, verifies that the token matches, rejects paid shares and cancelled orders, and only then asks the establishment's gateway to create a payment.

The lock is deliberately narrow. Guests can still read the shared session and the interface can continue polling; only the state transition that creates a competing charge is serialized. The lock has a bounded TTL and retry policy, and release uses a value-checked Redis Lua script so an expired lock can never be deleted by an older owner. Concurrency control is useful only when the lock itself is safe.

Persist the Intent Before the Return

A gateway redirect divides payment into two different moments: initiation and verification. Sofreh records the gateway authority and the exact initiated amount on the share before sending the guest away. The callback can then compare external evidence with internal intent instead of trusting query parameters or reconstructing history.

This also makes retries understandable. A callback may arrive more than once, but a share already marked paid is a known state rather than a second payment event. The client treats the callback redirect as presentation—it refreshes the authoritative session and removes transient query parameters—while the server remains the only place where money changes state.

The Final Share Is a Transaction

When verification completes, the service updates the share and evaluates all shares inside a database transaction. If the group is fully funded, the order transitions to paid in that same consistency boundary. The update is written so a previously paid order becomes a no-op, preventing two near-simultaneous callbacks from finalizing it twice.

Rewards and realtime delivery happen after the financial state is secure. Loyalty failure is logged without rolling back a valid payment; a failed broadcast does not make the order unpaid. This separation matters: money is the durable fact, while notifications are recoverable consequences. The last payer does not trigger a pile of hopeful side effects—they complete a carefully defined state machine.