31.8. Payments
Table of Contents
Understanding Payments in an E‑Commerce Backend
Handling payments is one of the most sensitive parts of an e‑commerce backend. Money is involved, users expect reliability, and security requirements are strict. In this chapter you will learn how payments fit into your e‑commerce system, how to integrate with payment providers, and how to design the payment flow safely and predictably.
We assume you already have basic order, product, and user management in place from earlier chapters of the project.
Key Concepts in E‑Commerce Payments
Payment vs Order
An order is the business record of what the customer wants to buy. A payment is how the customer pays for that order.
You should treat them as related but separate concepts:
- An order can exist before any payment attempt.
- A payment can fail or be retried while the order is still pending.
- An order can be:
PENDING_PAYMENTPAIDCANCELLEDREFUNDED- A payment can be:
REQUIRES_PAYMENT_METHODPENDINGAUTHORIZEDCAPTUREDFAILEDREFUNDEDCANCELED
Keeping these states separate prevents confusion and makes error handling easier.
Authorization vs Capture
In card payments there are usually two steps:
- Authorization: The bank reserves the money on the customer card, but does not move it yet.
- Capture: The money is actually transferred to the merchant.
Common patterns:
| Business case | Pattern |
|---|---|
| Digital goods (instant delivery) | Authorize and capture together |
| Physical goods (ship later) | Authorize at checkout, capture when shipping |
Important rule: Never mark an order as finally paid until the payment is captured or fully settled by your payment provider.
One-Time Payments vs Recurring
For an e‑commerce store you often handle:
- One-time payments: Customer pays for a single order.
- Recurring payments / subscriptions: Customer is charged regularly.
In this project we focus mainly on one-time payments, but you should design your payment model so it could be extended to recurring payments in the future.
Payment Provider Integration Basics
You almost never handle raw card numbers in your backend. Instead, you integrate with a Payment Service Provider (PSP) like Stripe, Braintree, Adyen, PayPal, etc.
Why You Do Not Handle Card Data Directly
Handling card data directly means you must comply with strict PCI DSS rules. These are hard and expensive to meet. PSPs provide:
- Secure card collection in the browser (hosted fields, payment pages, or JavaScript SDKs).
- Tokenization of card data, so your backend only sees a token.
- Webhooks for payment events (succeeded, failed, refunded, etc.).
- Fraud checks and dispute handling.
Your backend’s job is mainly to:
- Create a payment intent / charge request with the PSP.
- Receive the result (succeeded, failed, requires further action).
- Update your internal order and payment records.
- React to asynchronous events through webhooks.
Typical Payment Flow Overview
A simple card payment flow usually looks like this:
- Client calls your backend: Create payment for order.
- Backend creates a payment record in your database and calls the PSP API.
- PSP returns a client secret / redirect URL / token.
- Backend returns necessary data to the client (front end).
- Front end uses PSP SDK or hosted page to complete payment.
- PSP:
- Either returns result immediately (succeeded / failed), or
- Sends asynchronous webhook to your backend when payment status changes.
- Backend updates:
- Payment status
- Order status
- Inventory, emails, etc.
Designing Payment Data Models
Payment Table
You will usually have a payments table linked to orders.
Example schema (simplified):
| Column | Type | Example |
|---|---|---|
| id | UUID / int | pmt_123 |
| order_id | UUID / int | ord_456 |
| user_id | UUID / int | usr_789 |
| provider | text | "stripe" |
| provider_payment_id | text | "pi_3Nl..." |
| amount_cents | integer | 2599 |
| currency | text | "USD" |
| status | text / enum | "PENDING", "CAPTURED", "FAILED" |
| error_code | text | "card_declined" |
| error_message | text | "Your card was declined." |
| created_at | timestamp | |
| updated_at | timestamp |
You may also store:
authorized_atcaptured_atrefunded_amount_centsmetadataas JSON (e.g. user agent, IP, risk scores)
Order Fields Related to Payments
In your orders table you might have:
| Field | Purpose |
|---|---|
| total_amount | Final amount to be paid |
| currency | Currency code, e.g. "USD" |
| payment_status | "UNPAID", "PAID", "PARTIALLY_REFUNDED" |
| payment_id | Primary payment record (if 1:1) |
You can design one order to many payments if you want to support multiple attempts:
- Attempt 1: Declined
- Attempt 2: Succeeded
In that case do not put payment_id on orders, but instead query payments by order_id and choose the last successful one.
Important rule: Never trust any amount or currency from the client when creating a payment. Always use the order total stored in your database.
Creating a Payment
Backend Endpoint Design
You might expose an endpoint like:
POST /orders/{order_id}/payments
Body from client could be very minimal, for example:
{
"payment_method": "card"
}The server should:
- Authenticate the user.
- Fetch the order by
order_idand ensure: - It belongs to the user.
- It is not already fully paid or cancelled.
- Calculate the final amount:
- Sum of line items.
- Taxes and shipping.
- Discounts, vouchers.
- Create
paymentrecord in your DB with statusPENDING. - Call PSP API to create a payment entity with:
amountcurrency- A unique internal reference (e.g.
order.id). - Store
provider_payment_id. - Return client-specific data for the PSP SDK.
Example response (Stripe-like):
{
"payment_id": "pmt_123",
"client_secret": "pi_3Nl..."
}
The front end will use client_secret with Stripe JS to collect card details and confirm the payment, without card data ever passing through your backend.
Idempotency for Payment Creation
Payments are sensitive to double charges. Network issues or client retries can cause the same request to be sent twice.
To prevent this, many PSPs support idempotency keys. You also need idempotency in your own backend.
Common pattern:
- Client sends a unique
request_idwhen creating a payment. - You store
request_idand enforce uniqueness in DB. - If you receive the same
request_idagain: - Return the existing payment instead of creating a new one.
At the PSP level you also send an idempotency key with the API request.
Important rule: Use idempotency for payment creation to avoid double charges.
Handling Payment Confirmation and Status
Synchronous vs Asynchronous Results
Depending on the PSP, confirmation may be:
- Synchronous: The API response immediately says "succeeded" or "failed".
- Asynchronous: The user is redirected to a bank page (3D Secure, etc), and you only get the final status from a webhook later.
You must support both.
A typical approach:
- After the front end confirms the payment via PSP SDK, it calls your backend:
POST /payments/{payment_id}/confirm- Or
GET /payments/{payment_id}to fetch current status. - However, the source of truth for payment status is often the webhook (covered below). The API call from the front end is optional but useful for UX.
Updating Order and Payment States
When you know a payment succeeded, your backend should:
- Set
payment.status = "CAPTURED"(or provider-specific final state). - Set
order.payment_status = "PAID". - Reserve or decrease inventory (if not already done at checkout).
- Trigger:
- Order confirmation email.
- Task to send invoice.
- Task to start fulfillment flow.
On failure:
- Set
payment.status = "FAILED". - Store
error_codeanderror_message. - Keep order in
PENDING_PAYMENTor allow new payment attempts. - Inform front end to show error message.
Example Payment Status Update Code (Pseudo Python)
Assuming a webhook or callback handler:
def handle_payment_succeeded(provider_payment_id: str):
payment = payment_repo.get_by_provider_id(provider_payment_id)
if not payment or payment.status == "CAPTURED":
# Already handled or unknown
return
payment.status = "CAPTURED"
payment.captured_at = now()
payment_repo.save(payment)
order = order_repo.get(payment.order_id)
if order.payment_status != "PAID":
order.payment_status = "PAID"
order_repo.save(order)
inventory_service.reserve_for_order(order.id)
email_service.send_order_confirmation(order.id)You always check current status before applying side effects. This makes the handler idempotent, so if you process the same event twice nothing bad happens.
Webhooks: Listening to Payment Events
What Is a Webhook
A webhook is an HTTP callback from the PSP to your backend, telling you that something happened:
- A payment succeeded or failed.
- A refund was applied.
- A dispute was created.
Your backend must expose a publicly reachable endpoint, for example:
POST /webhooks/payments/stripe
The PSP sends JSON payloads to this endpoint.
Verifying Webhooks
Never trust any request to your webhook without verification. PSPs provide ways to:
- Validate a signature header.
- Validate a shared secret.
- Allow TLS only.
You should:
- Read the raw body of the request.
- Use the PSP SDK to verify signature with your secret.
- If verification fails, return HTTP
400or401.
Example (conceptual):
def stripe_webhook(request):
signature = request.headers.get("Stripe-Signature")
payload = request.body
try:
event = stripe.Webhook.construct_event(
payload=payload,
sig_header=signature,
secret=WEBHOOK_SECRET,
)
except InvalidSignatureError:
return Response(status_code=400)
handle_stripe_event(event)
return Response(status_code=200)Important rule: Always verify webhook signatures. Do not accept unauthenticated data that can change payment or order status.
Handling Common Webhook Events
Some common events:
payment_intent.succeededorcharge.succeeded:- Mark payment as captured.
- Mark order as paid.
payment_intent.payment_failed:- Mark payment as failed.
- Keep order pending payment.
charge.refunded:- Mark payment as refunded (fully or partially).
- Update order payment status.
Remember to make your handlers idempotent by checking existing status before applying updates.
Webhooks and Frontend UX
Since webhooks may arrive slightly later than the user’s payment attempt, front end can:
- Show a "Processing payment..." state.
- Poll an endpoint like
GET /orders/{order_id}every few seconds. - When the order becomes
PAID, show confirmation page.
Example polling logic:
- Every 3 seconds call
/orders/{order_id}. - Stop after 30 seconds or when
payment_status = "PAID"orCANCELLED.
Refunds and Cancellations
Payments are not only about charging money. You also must handle refunds.
Full vs Partial Refund
- Full refund: Return all money from a payment.
- Partial refund: Return part of the amount. For example, a single item from a multi-item order.
In your database:
- Store
refunded_amount_centsonpayments. - Possibly a
refundstable with: payment_idamount_centsreasonstatus(PENDING,COMPLETED,FAILED)
Refund Flow
- Admin or system requests refund:
POST /admin/payments/{payment_id}/refund- Backend:
- Validates that the allowed refundable amount is nonzero:
refundable = payment.amount_cents - payment.refunded_amount_cents- Calls PSP refund API.
- Creates
refundrecord in DB withPENDINGstatus. - PSP processes the refund:
- Often instantly, but confirmation usually via webhook.
- Webhook handler:
- Confirms refund succeeded.
- Updates
refund.status = "COMPLETED". - Updates
payment.refunded_amount_cents. - Sets
order.payment_statusto: REFUNDEDif fully refunded.PARTIALLY_REFUNDEDif partial.
Important rule: Never change your internal payment or order refund status to "completed" until you receive confirmation from the payment provider (API response or webhook).
Calculating Refundable Amount
You must ensure you never refund more than you charged.
If $A$ is total amount captured, and $R$ is the sum of all completed refunds, the remaining refundable amount $L$ is:
$$
L = A - R
$$
You must enforce:
$$
0 \leq \text{requested\_refund\_amount} \leq L
$$
Security and Compliance Considerations
Never Store Sensitive Card Data
You should never store:
- Full card numbers (PAN).
- Card verification codes (CVV/CVC).
- Unencrypted cardholder data.
You may store the last 4 digits and card brand that the PSP gives you, for example:
card_brand = "visa"card_last4 = "4242"
These are not secret and are useful for support and receipts.
Use HTTPS Everywhere
All communication that touches payment-related endpoints must use HTTPS:
- Checkout pages.
- Payment creation endpoints.
- Webhook endpoints.
If an attacker can intercept traffic, they might tamper with amounts or order IDs.
Validate Amounts and Ownership on Every Request
For any payment-related endpoints, always:
- Authenticate the user.
- Verify that the order belongs to the user.
- Recalculate prices on the backend.
- Ignore any client-sent price values.
Example checks when creating a payment for order_id:
order.user_id == current_user.idorder.statusis not cancelled.order.payment_statusis not already paid.- Use
order.total_amountfrom DB.
Handling Race Conditions
Example race condition:
- User double clicks "Pay" quickly.
- Front end sends two requests to
POST /orders/{id}/payments.
Prevention strategies:
- Use DB transaction and row-level locking on the order while creating a payment.
- Or use a unique constraint on something like
(order_id, active=True)for payments. - Combine with idempotency keys.
Example End-to-End Flow for the Project
Let us walk through a concrete, simplified flow that fits your e‑commerce project.
1. User Checks Out
- User has items in cart.
- Backend creates an
orderwith items, shipping address, and calculates totals. - Order is
PENDING_PAYMENT.
2. Client Requests Payment
- Front end calls
POST /orders/{order_id}/payments. - Backend:
- Validates order and user.
- Creates
paymentin DB withPENDINGstatus. - Calls PSP to create a payment intent for
order.total_amount. - Returns PSP client details (client secret or similar) and
payment_id.
3. Frontend Completes Payment
- Front end uses PSP widget or SDK with the client secret.
- User enters card details on PSP form.
- PSP processes payment.
- On success:
- PSP confirms on client side.
- Or triggers a redirect to success URL.
- Front end may call:
GET /orders/{order_id}to confirm status.
4. Webhook Confirms the Payment
- PSP sends
payment_succeededwebhook toPOST /webhooks/payments/{provider}. - Backend:
- Verifies webhook signature.
- Finds the payment by
provider_payment_id. - If not yet captured:
- Sets
payment.status = "CAPTURED". - Sets
order.payment_status = "PAID". - Reduces stock for products.
- Triggers order confirmation email.
5. User Sees Final Status
- Front end either:
- Polls the order endpoint until it sees
PAID, or - Relies on synchronous confirmation if available.
- User sees "Thank you for your order" page.
6. Refund Example
If customer cancels after payment:
- Admin requests refund via admin panel.
- Backend checks refundable amount using:
- $$L = A - R$$
- Backend calls PSP refund API.
- PSP sends
refund_succeededwebhook. - Backend updates payment and order statuses, and sends refund notification email.
Practical Tips When Implementing Payments
- Start with one provider and a very simple flow:
- Single currency, one-time payments only.
- Capture immediately on authorization.
- Abstract provider-specific details behind an interface, for example:
class PaymentProvider:
def create_payment(self, order, payment): ...
def handle_webhook(self, event): ...
def refund(self, payment, amount): ...- Store raw webhook payloads in a log table for debugging.
- Add clear audit logs for payment and refund events.
- Write tests for:
- Creating a payment.
- Handling a success webhook.
- Handling a failed payment.
- Idempotent webhook handling.
- Treat all external calls (PSP APIs) as potentially failing:
- Use retries with backoff.
- Log timeouts and errors clearly.
By following these design principles and patterns, you will have a robust and secure payment module for your e‑commerce backend that can be extended later to support multiple providers, subscriptions, and more advanced scenarios.
Views: 7
KAHIBARO