Free preview

The Payment Service

In one line: this is the first genuinely financial subsystem in the course, and the two ideas that carry it — double-entry bookkeeping and an event log — are the same idea applied at different layers.

What it must prevent

To ensure the success of the payment system, we must prevent the following scenarios:

  • Lack of payment
  • Duplicate payments
  • Incorrect payment amounts
  • Incorrect currency conversion
  • Dangling authorizations

The platform utilizes the double-entry bookkeeping method to maintain financial accuracy.

Starting from failures rather than features is the right way to design a payment system

Notice the section is titled "what to prevent." Every other component in this chapter is specified by what it does. This one is specified by what must never happen.

That inversion is appropriate, and it is worth adopting when you present any money-handling design. The reason is asymmetry: a payment system that occasionally fails to work is annoying; one that occasionally works wrongly is a liability. Missing a charge costs one fare. Double-charging a million riders is a regulatory event.

Look at the list — every item is a correctness failure, not an availability or latency one. Speed is not on it.

Dangling authorizations is the one worth explaining because it is least familiar. When a ride is requested, the system places a hold on the rider's card — reserving funds without capturing them. If the trip is cancelled and the hold is never released, the rider's money stays frozen, sometimes for days. It is invisible to the system (no money moved, no error logged) and highly visible to the customer.

That makes it the hardest class of bug in financial systems: a failure of omission in a distributed workflow. Nothing went wrong; something merely failed to happen. Detecting it requires actively reconciling outstanding authorizations against completed trips, which is a scheduled job rather than an error handler.

Double-entry bookkeeping is an invariant you can check, not a data model

The design mentions it in one line. It deserves more, because it is the single most important idea in payment system design and it is 500 years old.

Every transaction is recorded twice — as a debit from one account and a credit to another — so that across the whole ledger:

sum of all debits  ==  sum of all credits      ALWAYS

A rider's fare is not "money disappeared from the rider." It is a debit from the rider's account and a matching credit split between the driver's account and Uber's revenue account. Money is never created or destroyed, only moved.

Why this matters more than it sounds:

It makes corruption detectable. If a process crashes halfway and writes only one side, the ledger no longer balances, and a reconciliation job finds it. Compare a single-entry design where a lost update looks exactly like a transaction that never happened.

It makes the ledger append-only. You do not edit a wrong entry — you post a reversing entry. So the history is immutable, which is what audit requires.

It survives partial failure by construction. In a distributed system you cannot guarantee both writes land atomically, but you can guarantee you will notice when they do not.

Connect this to Lesson 9's storage argument: completed trips move to Cassandra precisely because they become immutable facts. The ledger is the same instinct — financial systems record what happened rather than what is currently true, and derive current balances from the history.

The architecture

ComponentDetail
APIThe entry point for accessing the payment service
Order storePersists order details and tracks money flow between riders and drivers
Account storeManages rider and driver accounts
Risk engineAnalyzes transaction risks by checking rider history, ratings, fund availability, pending dues, and cancellation frequency
Payment profile serviceManages payment method details, such as credit and debit cards
User profile serviceProvides user-specific payment information
Payment authorization serviceHandles payment authentication
PSP gatewaysInterfaces with external payment service providers

The workflow

  1. When a rider requests a ride, the Uber API queries the risk engine to assess potential risks.
  2. The risk engine retrieves user data from the user profile service and evaluates the request.
  3. If risks are high, the request is rejected.
  4. If the risk is low, the risk engine generates an authorization token.
  5. The payment authorization service sends the request to the PSP gateway, which contacts the provider for authorization.
  6. Upon approval, the PSP gateway returns the token, and the trip request is confirmed to the application.
  7. After the trip is completed, the application sends a payment request with authorization data to the PSP gateway.
  8. The PSP gateway processes the payment and returns the status.

Authorize before the trip, capture after — and the gap between them is the design

The critical structure is that steps 1 to 6 happen before the driver is dispatched, and steps 7 to 8 happen after the trip ends.

That split exists because of a constraint identical to the one that building block met with token billing: you cannot know the amount in advance. The fare depends on the actual route, duration, and traffic.

So:

BEFORE the trip:  AUTHORIZE  - verify the card works, hold an estimated amount
AFTER the trip:   CAPTURE    - charge the actual fare

Authorization is what makes the pre-trip check meaningful. Without it, a rider with a dead card gets a driver dispatched — real fuel, real time — and the charge fails at the end, when there is no recourse.

Verify ability to pay before incurring cost; charge after the cost is known. The ChatGPT chapter reached the identical conclusion for a completely different reason, which is a good sign it is structural rather than domain-specific.

Two consequences the design does not draw out:

The hold amount is a guess. Authorize too little and capture may exceed the hold, requiring re-authorization on a card that may no longer have funds. Too much and you freeze more of the rider's money than necessary.

The gap can be long. A 45-minute trip means 45 minutes between authorization and capture, during which the rider could spend the balance elsewhere, the card could expire, or the authorization could go stale. Dangling authorizations live in exactly this gap, which is why they are on the prevention list.

The risk engine runs pre-trip, and it is doing two jobs

"Checking rider history, ratings, fund availability, pending dues, and cancellation frequency."

Two of those are financial (fund availability, pending dues) and three are behavioural (history, ratings, cancellation frequency).

So this component is simultaneously a credit check and a trust check, and they answer different questions: can this rider pay, and will this rider behave. Bundling them is defensible — both gate the same decision — but they need different data, different models, and different appeal paths when they get it wrong.

The behavioural half also has a cost the design does not mention: false positives reject legitimate riders, and a rider wrongly refused a ride will not try again soon. Lesson 12's fraud detection keeps a human in the loop for exactly this reason, and the risk engine has no such escape hatch — it "rejects" on high risk, silently and synchronously, in the request path.

Kafka

  1. The order creator receives a business event (e.g. trip completion), generates money movement metadata, and publishes it to Kafka.
  2. The order processor consumes the event, processes it, and publishes an intent back to Kafka.
  3. The order processor executes the intent by contacting the PSP.
  4. The PSP response is published to Kafka as a result.
  5. The order writer persists the result.

Every stage transition goes through the log, and that is the whole point

Look at the pattern: order to Kafka, intent to Kafka, result to Kafka. The services never call each other directly — every handoff is a durable, ordered, replayable record.

That is not decoupling for its own sake. For money it buys four things nothing else does:

Durability across a crash. If the order processor dies after publishing an intent but before contacting the PSP, the intent is still in the log. A restarted processor resumes. Nothing is lost — which directly prevents "lack of payment."

An audit trail by construction. Every state a payment passed through is recorded in order. When a rider disputes a charge, the log is the evidence. You do not build audit logging separately; the transport is the audit log.

Replay for recovery. A bug that mis-computed fares for an hour can be fixed and the events reprocessed. With synchronous calls, that hour is gone.

Backpressure absorption. PSPs are external and sometimes slow. The queue absorbs the difference without failing the trip.

Note especially that intent is published before it is executed. That ordering is deliberate: it is a write-ahead log for an external side effect. The system records what it is about to do before doing it, so that after a crash it can determine whether the action happened and reconcile with the PSP — which is the only defence against "duplicate payments" when the external call's outcome is unknown.

When an operation must survive process failure and be auditable afterwards, make the log the design of truth and the services stateless consumers of it. That is event sourcing, and payments are its canonical use case.

Exactly-once does not exist, so make it idempotent

Kafka gives at-least-once delivery in the general case. So the order processor will occasionally see the same event twice, and each duplicate is a potential double charge.

The defence is the same as Lesson 7's for endTrip: idempotency keys. Each payment intent carries a unique ID, the PSP deduplicates on it, and the order store rejects a second write for the same key.

This is why the ordering matters so much. Publishing intent before execution means a duplicate can be recognized — there is already an intent with that key, and its result can be looked up rather than re-executed.

You cannot achieve exactly-once delivery; you achieve exactly-once effect by making the operation idempotent. For payments that is not an optimization, it is the requirement.

A timeout on a payment call is the ambiguous case that matters: you do not know whether the charge happened. Retrying without an idempotency key risks double-charging a rider; not retrying risks never charging. The key turns an unsafe retry into a safe one, which is what makes the ambiguity survivable.

Key takeaway

A payment system is specified by what must never happen, not what it does — and dangling authorizations are the hardest class, a failure of omission that needs scheduled reconciliation rather than error handling. Double-entry bookkeeping is an invariant you can check: debits equal credits always, so partial failure is detectable, the ledger is append-only, and corrections are reversing entries rather than edits. Authorize before dispatch, capture after completion, because the fare is unknowable in advance — the same conclusion that building block reached for token billing. And every stage transition goes through Kafka, with intent published before execution, making the log a write-ahead log for an external side effect — the only way to reconcile after a crash and the only defence against duplicate payments.

Next: the adversary.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue