System design · Payments · Part 1
Payment system basics
What a payment service does, who takes part in a card payment, how a payment moves through states, and how to store money safely.
The scenario
Imagine an ecommerce site. A customer sees a product that costs $100 and clicks Buy now.
Wireless headphones
$100.00
Behind that one click, the request has to travel all the way to the customer's bank:
Customer
Website
Backend
Payment provider
Card network
Customer's bank
The path of a payment, from the click to the customer's bank.
What is a payment service
A large application like Amazon is split into many services, each with one job:
- UserService
- ProductService
- CartService
- OrderService
- InventoryService
- PaymentService
- ShippingService
- NotificationService
The payment service handles everything related to payments, for example:
- Create a payment
- Confirm a payment
- Track a payment
- Refund a payment
- Handle responses from the payment provider
- Store payment history
- Prevent duplicate charges
Payment providers
A payment service does not necessarily move money itself. Usually it talks to another company that processes the card, such as Stripe, PayPal or Braintree.
These companies go by several names: payment processor, payment service provider (PSP) or payment gateway. This note uses Stripe as the example.
Stripe
PayPal
Braintree
Customer
Frontend
Payment service
Stripe
Visa / Mastercard
Customer's bank
The same path, with Stripe as the payment provider.
1.Why not talk to the bank directly?
It's possible, but payment processing is extremely complicated. It involves:
- Banks and card networks
- Fraud systems
- PCI requirements
- 3D Secure
- Different countries and currencies
- Refunds and chargebacks
- Authorization rules and settlements
- Compliance, and more
A provider takes on that complexity. The application only says “Please charge this customer $100”, and the provider handles the complicated parts under the hood. Companies like OpenAI and Anthropic use Stripe for exactly this reason.
The actors
Before the architecture, it helps to know who takes part. Imagine Nithin buys a laptop for $1,000. Several parties are involved:
| Actor | Role | Example |
|---|---|---|
| Customer | The person buying the laptop | Nithin |
| Merchant | The company selling something | Amazon |
| Payment processor | Helps process the payment | Stripe |
| Card network | Routes the transaction | Visa, Mastercard |
| Issuing bank | The customer's bank, which issued the card | Chase, Huntington |
| Acquiring bank | The merchant's bank, involved in accepting payments | — |
The request travels from the customer to the issuing bank, and the answer travels back the same way:
Request
Customer
Merchant
Payment processor
Card network
Issuing bank
Response
Issuing bank
Approved / declined
Card network
Payment processor
Merchant
Customer
The bank approves or declines, and the answer retraces the path.
What happens on Pay
Suppose a customer buys headphones for $100, enters their card details and clicks Pay. The frontend might send a request like this:
{
"orderId": "123",
"amount": 10000,
"currency": "usd"
}Notice the amount is 10000, not 100. Money is stored in the currency's smallest unit, and for USD that's cents: $100 = 10,000 cents. The last section explains why.
1.Create a payment record
The backend receives the request and first creates a payment record:
payment_id: "pay_123"
order_id: "123"
amount: 10000
currency: "usd"
status: CREATED2.Ask the provider
Then it asks Stripe: “Please process $100 for this customer.” Stripe contacts the rest of the payment network, and eventually the answer comes back as success or declined.
3.Update the status
The payment's status changes depending on the answer:
Success
CREATED
PROCESSING
SUCCEEDED
Declined
CREATED
PROCESSING
FAILED
This idea is called a state machine.
What is a state machine
A state machine is an object that can be in one of a fixed set of states, and moves between those states according to rules. It works much like a value held in React's useState, except only certain changes are allowed.
Think of a food delivery:
Placed
Preparing
Picked up
Delivered
An order can't jump from Placed to Delivered, or go from Delivered back to Preparing. A payment works the same way. In its simplest form:
CREATED
PROCESSING
SUCCEEDED
Approved
FAILED
Declined
A payment moves forward only, and ends in exactly one final state.
Real payment systems usually have more states:
| State | Meaning |
|---|---|
| CREATED | The payment record exists |
| REQUIRES_ACTION | The customer must do something, like 3D Secure |
| PROCESSING | The provider is working on it |
| AUTHORIZED | The bank approved and reserved the money |
| SUCCEEDED | The money was charged |
| FAILED | The payment was declined or errored |
| CANCELED | The payment was stopped before completing |
| REFUNDED | All of the money was returned |
| PARTIALLY_REFUNDED | Some of the money was returned |
Why a database
Stripe already knows about the payment, so why store it again? When Stripe reports “Payment succeeded”, the application needs that fact itself so it can:
- Mark the order as paid
- Start shipping
- Send a receipt
- Show payment history
- Allow refunds
- Handle customer support
- Perform accounting
So the payment service keeps its own payment records, for example in a payments table:
| Field | Meaning |
|---|---|
| id | The payment's own ID |
| order_id | Which order |
| user_id | Which customer |
| amount | Money amount, in the smallest unit |
| currency | USD, EUR, etc. |
| status | Current state |
| provider | Stripe |
| provider_payment_id | Stripe's ID for the payment |
| created_at | Creation time |
| updated_at | Last update |
id pay_123
order_id order_456
user_id user_789
amount 10000
currency USD
status SUCCEEDED
provider STRIPE
provider_payment_id pi_abc123
created_at ...
updated_at ...Now the system knows what happened, even when customer support or reporting needs it later.
Why $100 is stored as 10000
The short answer: avoid storing money as decimal (floating-point) numbers like 100.00. Store whole numbers of cents instead.
1.Computers store decimals approximately
Try writing 1/3 as a decimal: 0.3333333…. The 3s never end, so any written version is slightly off.
Computers have the same problem, just with different numbers. They count in binary (base 2), and in binary, simple-looking numbers like 0.1 and 0.2 never end either. So the computer stores the closest value it can, which is very slightly wrong.
Usually the error is too small to see. But add those numbers, and it shows up:
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false2.Why that breaks money
Imagine a cart with three items at $0.10 each. The total should be exactly $0.30:
Dollars as decimals
- The total is 0.30000000000000004, not 0.3.
- A check like total === 0.30 fails, so the order can look unpaid.
- Across millions of payments, tiny errors add up to real money.
Cents as integers
- Whole numbers are stored exactly, so 10 + 10 + 10 is always 30.
- 30 cents is shown to the customer as $0.30.
3.The fix: count in cents
Represent USD in cents, the smallest unit, so every amount is a whole number:
| Price | Stored as |
|---|---|
| $1.00 | 100 |
| $10.50 | 1050 |
| $100.00 | 10000 |
Calculations then use integers only:
10000 + 5000 = 15000 // $100 + $50 = $150Convert back to dollars only when showing the amount to a person: 15000 cents is displayed as $150.00.
4.Not every currency has cents
Not every currency has exactly two decimal places. Some have zero and some have three, so a production system needs rules for each currency:
| Currency | Decimal places | 100 units stored as |
|---|---|---|
| USD (US dollar) | 2 | 10000 |
| JPY (Japanese yen) | 0 | 100 |
| KWD (Kuwaiti dinar) | 3 | 100000 |