Worked problems · Payment system

Worked problems

Payment system

Move money with a ledger, idempotent charges, and a clear story for the provider timing out.

3 min read system-design

What you are designing

Customers pay for something you sell. You record the intent, talk to a card or bank provider, and update an internal ledger. Assume you are not a bank with a banking license’s full problem set, but you are responsible for not double-charging and for being able to explain a balance. Peak is bursty (a sale). The provider is slow and sometimes silent.

Requirements

  • Create a payment once, despite client retries.
  • Know whether it is pending, authorized, captured, failed, or unknown.
  • Refund a captured payment once.
  • Produce a balance and a history that survive a bug in the cache.

Losing a request is acceptable. Losing track of a request is not.

Entities and API

Payment(id, idem_key, amount, state, provider_ref), LedgerEntry(id, payment_id, account, amount, direction), WebhookEvent.

POST /v1/payments          Idempotency-Key, { amount, currency, method }
POST /v1/payments/{id}/refund
GET  /v1/payments/{id}
POST /v1/provider/webhooks

Create requires Idempotency-Key. The same key and the same body replay the stored response, including a decline. The same key and a different body is 409, not a second charge. Refund uses its own key. Reusing the capture key would make a retry of the refund look like a replay of the capture. Webhooks are authenticated and deduped on the provider event id. A duplicate webhook returns 200 and does not append another ledger line. GET is safe to poll while the state is unknown.

High-level design

flowchart LR
  Client --> API
  API --> DB[(Payments + ledger)]
  API --> Prov[Card provider]
  Prov --> WH[Webhook inbox]
  WH --> DB
  Recon[Reconciler] --> Prov
  Recon --> DB

Create inserts the payment in pending with the idempotency key, commits, then calls the provider with that key. You update state from the response. Webhooks are a second delivery of the same fact, deduped by provider event id. A reconciler lists provider payments that are newer than your last sync and repairs unknown.

Data model

CREATE TABLE payments (
  id           BIGSERIAL PRIMARY KEY,
  owner_id     BIGINT NOT NULL,
  idem_key     TEXT NOT NULL,
  amount_cents BIGINT NOT NULL,
  currency     TEXT NOT NULL,
  state        TEXT NOT NULL,
  provider_ref TEXT,
  UNIQUE (owner_id, idem_key)
);
CREATE TABLE ledger_entries (
  id          BIGSERIAL PRIMARY KEY,
  payment_id  BIGINT NOT NULL,
  account     TEXT NOT NULL,
  amount_cents BIGINT NOT NULL,
  direction   TEXT NOT NULL, -- debit | credit
  UNIQUE (payment_id, account, direction)
);

Amounts are integers of minor units. Never floats. The unique keys make the retry and the webhook safe.

Deep dives

The unknown state. On timeout, set unknown and call the provider’s fetch-by-key before you try again. If they have it, adopt that result. If they do not, you may retry the same key. A new key is a second charge. Fulfillment (shipping, granting credits) subscribes to captured, not to “we sent the HTTP request.”

Ledger. On capture, insert the balanced pair in the same transaction as the state change. A refund inserts the opposite pair and a refund payment row. A balance query sums entries or reads a snapshot that stores the last entry id it includes. If the snapshot disagrees with the sum, the sum wins and you alarm.

Webhooks. Verify the signature. Insert the event id. If the insert conflicts, ack and do nothing. Apply the state transition with a conditional (WHERE state IN (...)) so an old webhook cannot move a refunded payment back to captured.

Internals you refuse. Do not hold locks across the provider call. Do not let the client pick the amount from a hidden field you did not recompute. Do not log the full card number. PCI scope stays at the provider’s hosted fields if you can; say that you tokenize.

Levels

  • Mid-level. Idempotency key, payment states, a provider call outside the DB transaction, integer money.
  • Senior. Unknown and reconcile, ledger entries, webhook dedup, refunds as new movements.
  • Staff. Fulfillment coupling, multi-currency rounding, dispute and chargeback as another state, and what you do when the provider is down (queue captures you already authorized, stop new checkouts).

Further reading

Check yourself

Answer the questions. Multiple choice is scored on this device only.

1. The provider times out. What state is the payment in?

2. A balance should be:

3. What is a double-entry movement?