Payment Systems Architecture - From Laravel Integrator to Staff-Level Architect
DEV Community

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_id uniqueness β†’ dispatch to a queued job β†’ job calls PaymentStateMachine::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.