Payment Systems Architecture - From Laravel Integrator to Staff-Level Architect
Business Requirements
What problem does a Payment System actually solve? A naive view: "take money from customer, give to merchant." The real problem is reconciling three asynchronous, unreliable sources of truth - your application state, the payment gateway's state, and the bank/card network's state - while guaranteeing money is never lost, never duplicated, and every movement is auditable for accounting and legal purposes.
Everything else (idempotency, state machines, webhooks, reconciliation) exists because of one fact: payment confirmation is asynchronous and can fail silently, arrive late, arrive twice, or arrive out of order.
Payment flows you must support
| Flow | Core challenge |
|---|---|
| One-time payment | Simple authβcapture, but still needs idempotency and webhook handling |
| Subscription | Recurring billing, dunning (retry failed renewals), proration, plan changes |
| Wallet payments | Internal ledger balance, must be ACID - no gateway involved for wallet-to-wallet, but funding/withdrawal does involve a gateway |
| Split payments | One payment, multiple payees (marketplace model) - needs a sub-ledger per payee, and gateways like Stripe Connect or manual split-then-payout |
| Refunds (full) | Reverse a captured payment; must reverse accounting entries too |
| Partial refunds | Same payment can be refunded multiple times up to captured amount; needs a refunds table, not a single flag |
| Chargebacks | Bank-initiated reversal, outside your control, arrives days/weeks later, needs dispute evidence submission flow |
| Payment disputes | Formal process with deadlines, evidence, and a final win/loss outcome that must hit accounting |
Common mistake: modeling refunds as payments.status = 'refunded'. This breaks partial refunds and loses history. Refunds are separate first-class entities linked to a payment.
System Architecture
Components
- API Gateway - auth, rate limiting, request routing, never contains payment logic itself
- Order Service - owns the business order/cart; payment is a consequence of an order, not vice versa
- Payment Service - owns gateway integration, payment state machine, idempotency
- Wallet Service - owns internal balances, wallet ledger, transfers
- Accounting Service - owns double-entry ledger, journal entries, chart of accounts
- Webhook Service - receives, verifies, deduplicates, and queues gateway events (often a thin ingestion layer in front of Payment Service)
- Notification Service - emails/SMS/push on payment events, fully decoupled, consumes events
- Reconciliation Service - periodically compares gateway settlement reports against internal records
Design principle: Payment Service is the only service allowed to talk to gateways. Order Service never calls Stripe directly. This keeps gateway-specific logic in one place and lets you swap/add gateways without touching order logic.
graph TD
Client --> APIGateway
APIGateway --> OrderService
APIGateway --> WalletService
OrderService -- "1. request payment" --> PaymentService
PaymentService -- "2. charge" --> Gateway[Stripe/HyperPay/Paymob]
Gateway -- "3. webhook" --> WebhookService
WebhookService -- "4. verified event" --> PaymentService
PaymentService -- "5. PaymentCaptured event" --> EventBus
EventBus --> OrderService
EventBus --> AccountingService
EventBus --> NotificationService
EventBus --> WalletService
ReconciliationService -- "settlement files" --> Gateway
ReconciliationService -- "compare" --> PaymentService
AccountingService --> Ledger[(Double-Entry Ledger)]
Event flow for a one-time payment (happy path)
sequenceDiagram
participant U as User
participant O as Order Service
participant P as Payment Service
participant G as Gateway
participant W as Webhook Service
participant A as Accounting
U->>O: Checkout
O->>P: CreatePayment(order_id, amount, idempotency_key)
P->>P: Insert payment(status=PENDING)
P->>G: Create charge
G-->>P: charge_id (PROCESSING)
P-->>O: payment_id, redirect_url
U->>G: Completes 3DS
G->>W: webhook: charge.succeeded
W->>W: verify signature, dedupe
W->>P: handle event
P->>P: transition PROCESSING -> CAPTURED
P->>A: emit PaymentCaptured
A->>A: write journal entry
P->>O: emit OrderPaid
The key insight: the redirect/callback the user sees is never trusted as the source of truth. Only the webhook (server-to-server, signed) finalizes state. The callback is UX only.
Database Design
payments
| Column | Type | Notes |
|---|---|---|
| id | bigint | PK |
| uuid | uuid | unique, external-facing ID, never expose id |
| order_id | bigint, FK, indexed | |
| merchant_id | bigint, indexed | for multi-tenant/marketplace |
| amount | bigint | store in minor units (cents/piastres), never float |
| currency | char(3) | ISO 4217 |
| status | enum/string, indexed | state machine value |
| gateway | string | stripe, hyperpay, paymob |
| gateway_payment_id | string, indexed, nullable | |
| idempotency_key | string, unique, indexed | |
| captured_amount | bigint, default 0 | for partial captures |
| refunded_amount | bigint, default 0 | denormalized, kept in sync via refunds |
| metadata | json | |
| created_at, updated_at | timestamp |
Constraints: UNIQUE(idempotency_key), CHECK(refunded_amount <= captured_amount), index on (order_id), (status, created_at) for queue scanning.
payment_attempts
One payment can have multiple attempts (card declined, retry with another method). This is what most beginners conflate with payments itself.
| Column | Type |
|---|---|
| id | bigint PK |
| payment_id | FK, indexed |
| payment_method_id | FK, nullable |
| attempt_number | int |
| status | succeeded/failed/declined |
| gateway_response_code | string |
| failure_reason | string, nullable |
| created_at | timestamp |
payment_transactions
Immutable, append-only audit log of every state-affecting event (the event sourcing layer for payments). Never update or delete rows here.
| Column | Type |
|---|---|
| id | bigint PK |
| payment_id | FK, indexed |
| type | authorize/capture/refund/void/chargeback |
| amount | bigint |
| gateway_transaction_id | string, indexed |
| raw_payload | json |
| created_at | timestamp |
payment_methods
| Column | Type |
|---|---|
| id | bigint PK |
| user_id | FK, indexed |
| gateway | string |
| gateway_token | string, encrypted at rest |
| type | card/wallet/bank |
| last4 | string, nullable |
| brand | string, nullable |
| is_default | boolean |
| expires_at | date, nullable |
Never store PAN, CVV. Only gateway tokens. (See Security section.)
webhooks
| Column | Type |
|---|---|
| id | bigint PK |
| gateway | string |
| event_id | string, unique, indexed (this is your dedupe key) |
| event_type | string |
| payload | json |
| signature_verified | boolean |
| status | received/processing/processed/failed |
| processed_at | timestamp, nullable |
| received_at | timestamp |
UNIQUE(gateway, event_id) is the single most important constraint in this whole schema - it's your idempotency guard against duplicate webhook delivery.
refunds
| Column | Type |
|---|---|
| id | bigint PK |
| payment_id | FK, indexed |
| amount | bigint |
| reason | string |
| status | pending/succeeded/failed |
| gateway_refund_id | string, indexed |
| initiated_by | user_id/system |
| created_at | timestamp |
disputes
| Column | Type |
|---|---|
| id | bigint PK |
| payment_id | FK, indexed |
| gateway_dispute_id | string, indexed |
| reason | string |
| amount | bigint |
| status | needs_response/under_review/won/lost |
| evidence_due_by | timestamp, nullable |
| outcome_at | timestamp, nullable |
settlements and reconciliation_records
settlements: one row per gateway payout batch (gateway_settlement_id, amount, currency, settled_at, fee_amount).
reconciliation_records: links payment_transactions to settlements, with a match_status (matched/missing_internally/missing_externally/amount_mismatch). This table is the output of the Reconciliation Service, not an input.
erDiagram
payments ||--o{ payment_attempts : has
payments ||--o{ payment_transactions : logs
payments ||--o{ refunds : has
payments ||--o{ disputes : has
payments }o--|| payment_methods : uses
webhooks ||--o{ payment_transactions : triggers
settlements ||--o{ reconciliation_records : produces
payment_transactions ||--o{ reconciliation_records : matched_against
Payment State Machine
stateDiagram-v2
[*] --> PENDING
PENDING --> PROCESSING
PROCESSING --> AUTHORIZED
PROCESSING --> FAILED
AUTHORIZED --> CAPTURED
AUTHORIZED --> CANCELLED
CAPTURED --> PARTIALLY_REFUNDED
CAPTURED --> REFUNDED
CAPTURED --> CHARGEBACK
PARTIALLY_REFUNDED --> REFUNDED
PARTIALLY_REFUNDED --> CHARGEBACK
FAILED --> [*]
CANCELLED --> [*]
REFUNDED --> [*]
CHARGEBACK --> [*]
Invalid transitions to explicitly guard against: FAILED β CAPTURED (a late, out-of-order webhook trying to "fix" a failed payment - reject it, log it, alert), REFUNDED β CAPTURED (impossible reversal), PENDING β REFUNDED (you can't refund what was never captured).
Implementation pattern: explicit transition table, not scattered ifs:
final class PaymentStateMachine
{
private const TRANSITIONS = [
'PENDING' => ['PROCESSING', 'FAILED'],
'PROCESSING' => ['AUTHORIZED', 'FAILED'],
'AUTHORIZED' => ['CAPTURED', 'CANCELLED'],
'CAPTURED' => ['PARTIALLY_REFUNDED', 'REFUNDED', 'CHARGEBACK'],
'PARTIALLY_REFUNDED' => ['REFUNDED', 'CHARGEBACK'],
'FAILED' => [],
'CANCELLED' => [],
'REFUNDED' => [],
'CHARGEBACK' => [],
];
public function canTransition(string $from, string $to): bool
{
return in_array($to, self::TRANSITIONS[$from] ?? [], true);
}
public function transition(Payment $payment, string $to): void
{
if (!$this->canTransition($payment->status, $to)) {
throw new InvalidPaymentTransitionException($payment->status, $to);
}
DB::transaction(function () use ($payment, $to) {
$payment->lockForUpdate();
// re-fetch with row lock inside transaction
if (!$this->canTransition($payment->status, $to)) {
throw new InvalidPaymentTransitionException($payment->status, $to);
}
$payment->update(['status' => $to]);
PaymentTransaction::create([
'payment_id' => $payment->id,
'type' => strtolower($to),
'amount' => $payment->amount,
]);
});
}
}
Common mistake: checking status, then updating, without a row lock - classic TOCTOU race when a webhook and a callback hit simultaneously. Always re-check status inside the locked transaction.
Payment Gateway Integration
Abstraction layer
Define a PaymentGatewayInterface so Payment Service logic doesn't leak gateway specifics:
interface PaymentGatewayContract
{
public function createCharge(ChargeRequest $request): GatewayChargeResponse;
public function capture(string $gatewayPaymentId, int $amount): GatewayChargeResponse;
public function refund(string $gatewayPaymentId, int $amount): GatewayRefundResponse;
public function verifyWebhookSignature(string $payload, string $signature): bool;
public function parseWebhookEvent(string $payload): WebhookEvent;
}
Each gateway (StripeGateway, HyperPayGateway, PaymobGateway) implements this. Payment Service depends only on the interface, resolved via a factory keyed by payment.gateway.
Stripe - auth + capture
$intent = $this->stripe->paymentIntents->create([
'amount' => $amountInCents,
'currency' => 'usd',
'capture_method' => 'manual', // separate authorize/capture
'metadata' => [
'payment_uuid' => $payment->uuid,
],
], [
'idempotency_key' => $payment->idempotency_key,
]);
Note Stripe's native idempotency key parameter - pass your own key, don't rely only on your DB constraint.
HyperPay - checkout id flow
HyperPay requires a two-step flow: create a checkoutId, redirect the user to its hosted widget, then call GET /payments/{id} server-side after redirect to confirm status. Never trust the redirect query params alone - always re-verify with a server-to-server status call, because redirect params can be tampered with client-side.
Paymob - auth token β order β payment key β iframe
Paymob's flow is the most stateful: (1) get auth token, (2) register order, (3) request payment key with order_id + amount, (4) render iframe with payment key. Each step is a separate API call; cache the auth token (it's short-lived, ~1hr) rather than fetching it per request.
Callback vs Webhook handling
- Callback (browser redirect): UX only. Show "processing" page. Trigger a status check call, don't trust query params for final state.
- Webhook (server-to-server): source of truth. Verify signature β check
webhooks.event_iduniqueness β dispatch to a queued job β job callsPaymentStateMachine::transition().
Common mistake: marking an order as paid directly inside the webhook HTTP controller, synchronously, with heavy logic. Webhook endpoints must respond 200 in milliseconds - verify signature, persist raw event, dispatch a job, return. All processing happens in the queued job.
Idempotency
Why duplicate charges happen
Network timeouts are the root cause: client calls "charge customer," request reaches the gateway and succeeds, but the response is lost (timeout, connection drop). Client retries. Without idempotency, that's two charges for one user action.
How Stripe solves it
Stripe lets you pass an Idempotency-Key header; if the same key is reused within 24h, Stripe returns the original response without re-executing the operation. You should mirror this pattern internally for every your-side operation too, not just rely on the gateway's.
Database design
payments.idempotency_key UNIQUE - generated client-side per checkout attempt (e.g., UUID stored in the frontend, sent with the create-payment request) or server-side as hash(order_id + user_id + amount).
Laravel implementation
public function createPayment(CreatePaymentRequest $request): Payment
{
$key = $request->header('Idempotency-Key') ?? throw new MissingIdempotencyKeyException();
return DB::transaction(function () use ($request, $key) {
$existing = Payment::where('idempotency_key', $key)
->lockForUpdate()
->first();
if ($existing) {
return $existing; // return the original result, don't re-charge
}
return Payment::create([
'idempotency_key' => $key,
'order_id' => $request->order_id,
'amount' => $request->amount,
'status' => 'PENDING',
]);
});
}
The UNIQUE constraint on idempotency_key is your real safety net - even under concurrent requests, only one insert succeeds.
Comments
No comments yet. Start the discussion.