DEV Community

Cover image for Payment Systems Architecture — From Laravel Integrator to Staff-Level Architect
ali ehab algmass
ali ehab algmass

Posted on

Payment Systems Architecture — From Laravel Integrator to Staff-Level Architect

This guide takes you from "I integrate HyperPay/Paymob/Stripe webhooks" to "I can design the payment infrastructure of a fintech company." It's written assuming PHP 8.x + Laravel as the implementation stack, since that's your strongest base.


1. 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.


2. 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)]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


3. 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
Enter fullscreen mode Exit fullscreen mode

4. 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 --> [*]
Enter fullscreen mode Exit fullscreen mode

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,
            ]);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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.


6. 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',
        ]);
    });
}
Enter fullscreen mode Exit fullscreen mode

The UNIQUE constraint on idempotency_key is your real safety net — even under concurrent requests, only one INSERT succeeds; catch the UniqueConstraintViolationException and re-fetch.

Race condition prevention

try {
    $payment = Payment::create([...]);
} catch (UniqueConstraintViolationException $e) {
    $payment = Payment::where('idempotency_key', $key)->firstOrFail();
}
Enter fullscreen mode Exit fullscreen mode

This pattern (insert-then-catch) is more robust under high concurrency than select-then-insert, because the database, not your app, arbitrates the race.


7. Webhook Architecture

Signature verification

Always verify before touching the payload. Stripe: HMAC-SHA256 over timestamp.payload using the webhook secret; reject if timestamp is older than ~5 minutes (replay protection).

public function verifyWebhookSignature(string $payload, string $sigHeader): bool
{
    [$timestamp, $signature] = $this->parseStripeSigHeader($sigHeader);

    if (abs(time() - (int) $timestamp) > 300) {
        return false; // reject stale/replayed events
    }

    $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $this->webhookSecret);
    return hash_equals($expected, $signature);
}
Enter fullscreen mode Exit fullscreen mode

Retry mechanisms

Gateways retry webhooks on non-2xx for hours/days with exponential backoff. Your endpoint must be fast and idempotent — respond 200 immediately after persisting the raw event, process asynchronously.

Duplicate events

UNIQUE(gateway, event_id) on webhooks table. On duplicate insert, catch and return 200 without reprocessing — the gateway just needs an ack.

Event ordering problems

Webhooks for the same payment can arrive out of order (e.g., charge.failed before a delayed retry's charge.succeeded, or capture before authorize due to network jitter). Never assume sequence. Use the state machine's canTransition() as the ordering guard — if an out-of-order event would cause an invalid transition, log it to a dead-letter table for manual review instead of silently applying or silently dropping it.

Event replay protection

Combine: (1) timestamp freshness check, (2) event_id uniqueness, (3) state machine validity check. All three layers — any one alone is insufficient.

flowchart LR
    A[Webhook received] --> B{Signature valid?}
    B -- No --> X[Reject 400]
    B -- Yes --> C{event_id seen before?}
    C -- Yes --> D[Return 200, no-op]
    C -- No --> E[Persist raw event]
    E --> F[Dispatch ProcessWebhookJob]
    F --> G[Return 200]
    G -.async.-> H{Valid state transition?}
    H -- No --> I[Dead-letter + alert]
    H -- Yes --> J[Apply transition + emit domain event]
Enter fullscreen mode Exit fullscreen mode

8. Accounting Design

Why payments need double-entry accounting

A status = 'captured' flag tells you what happened but not where the money is. Finance needs to know: how much is in the payment gateway's pending balance, how much is in your bank account, how much is owed to merchants, how much is fee expense. That requires a real ledger.

Chart of accounts (minimal example)

1000 Assets
  1010 Cash - Bank Account
  1020 Gateway Receivable (Stripe)
  1030 Gateway Receivable (HyperPay)
2000 Liabilities
  2010 Merchant Payables
  2020 Customer Wallet Liability
  2030 Refunds Payable
4000 Revenue
  4010 Platform Fee Revenue
5000 Expenses
  5010 Payment Processing Fees
  5020 Chargeback Losses
Enter fullscreen mode Exit fullscreen mode

Journal entry structure

Column Type
id PK
reference_type payment/refund/chargeback
reference_id FK
description string
posted_at timestamp

ledger_lines (the actual debits/credits)

Column Type
journal_entry_id FK
account_code string
debit bigint
credit bigint

Invariant enforced at the application layer (and ideally a DB trigger/check): SUM(debit) = SUM(credit) per journal entry. This is non-negotiable — it's what "double entry" means.

Example: successful $100 payment, $3 gateway fee

DR  1020 Gateway Receivable     100.00
CR  4010 Platform Fee Revenue ... (if you're the merchant of record, separate later)
Enter fullscreen mode Exit fullscreen mode

More precisely, two entries:

Entry 1 (capture):
  DR 1020 Gateway Receivable    100.00
  CR 2010 Merchant Payables     100.00

Entry 2 (gateway settles, fee deducted):
  DR 1010 Cash - Bank            97.00
  DR 5010 Payment Processing Fee  3.00
  CR 1020 Gateway Receivable    100.00
Enter fullscreen mode Exit fullscreen mode

Example: $20 partial refund

DR 2010 Merchant Payables       20.00
CR 1010 Cash - Bank Account     20.00
Enter fullscreen mode Exit fullscreen mode

Example: chargeback ($100, lost dispute)

DR 5020 Chargeback Losses      100.00
CR 1010 Cash - Bank Account    100.00
Enter fullscreen mode Exit fullscreen mode

Common mistake: updating account balances directly instead of deriving them from SUM(ledger_lines). Balances should always be a materialized view/cache of the ledger, never the source of truth — otherwise you lose the audit trail and reconciliation becomes impossible.


9. Reconciliation System

Why it exists

Your database can say "captured" while the gateway never actually settled the funds (gateway-side failure, currency conversion issue, a webhook you never received because your endpoint was down). Reconciliation is the safety net that catches drift between your internal ledger and external reality.

Process

  1. Daily, pull the gateway's settlement report (CSV/API) into settlements.
  2. For each settlement line, attempt to match it to a payment_transactions row by gateway_transaction_id and amount.
  3. Write the outcome to reconciliation_records with a match_status.
  4. Alert on missing_internally (gateway has it, you don't — means a webhook was lost) and amount_mismatch (currency conversion or fee miscalculation).
flowchart TD
    A[Pull gateway settlement file] --> B[For each line]
    B --> C{Matching internal transaction?}
    C -- Yes, amount matches --> D[matched]
    C -- Yes, amount differs --> E[amount_mismatch -> alert]
    C -- No --> F[missing_internally -> alert + replay webhook fetch]
    G[Scan internal CAPTURED payments with no settlement after N days] --> H[missing_externally -> investigate]
Enter fullscreen mode Exit fullscreen mode

Common mistake: treating reconciliation as a nice-to-have batch report instead of a system that can trigger corrective action — e.g., automatically re-fetching the payment status from the gateway's API when a webhook seems to have been missed, rather than only producing a spreadsheet for humans.


10. Concurrency and Race Conditions

Scenario Mitigation
User double-clicks pay Idempotency key on the create-payment request (frontend disables button + sends same key)
Webhook arrives before the synchronous callback/API response State machine + row lock; whichever arrives first wins, the second is validated against current state, not blindly applied
Callback arrives before webhook Callback never finalizes state by itself — it triggers a status check call to the gateway, doesn't trust local assumptions
Two queue workers process the same webhook event SELECT ... FOR UPDATE on the payment row inside the job, or a unique processing lock (webhooks.status transitioned from received to processing via an atomic UPDATE ... WHERE status = 'received', checking affected rows)
$updated = DB::table('webhooks')
    ->where('id', $webhookId)
    ->where('status', 'received')
    ->update(['status' => 'processing']);

if ($updated === 0) {
    return; // another worker already claimed it
}
Enter fullscreen mode Exit fullscreen mode

This "claim via conditional UPDATE" pattern avoids needing a separate distributed lock (Redis) for the common case, though Redis locks (Cache::lock()) are useful when the critical section spans multiple services.


11. Event Driven Architecture

Domain events vs integration events

  • Domain event: internal to Payment Service's bounded context (e.g., PaymentTransitioned), used for internal side effects.
  • Integration event: published to other services via the event bus (e.g., PaymentCaptured, RefundIssued), consumed by Order, Accounting, Notification, Wallet services.

Outbox pattern

Problem: you write payment.status = CAPTURED to the DB and then publish PaymentCaptured to your message broker — but if the process crashes between those two steps, the event is lost, and other services never find out. The fix is to write the event to an outbox table in the same DB transaction as the state change, then have a separate relay process publish from the outbox.

DB::transaction(function () use ($payment) {
    $payment->update(['status' => 'CAPTURED']);
    Outbox::create([
        'event_type' => 'PaymentCaptured',
        'payload' => json_encode(['payment_id' => $payment->id, 'amount' => $payment->amount]),
        'published' => false,
    ]);
});
// separate relay (cron/queue worker) polls outbox WHERE published = false, publishes, marks published = true
Enter fullscreen mode Exit fullscreen mode

This guarantees at-least-once delivery with transactional consistency between state and event — the single most important pattern for reliable event-driven payment systems.


12. Security

  • PCI DSS basics: if you ever touch raw card numbers (PAN), you fall under PCI DSS Level 1-4 scope, which is extremely costly to maintain. Avoid this entirely by using gateway-hosted fields / tokenization (Stripe Elements, HyperPay's COPYandPAY, Paymob's iframe) — card data never touches your servers, so your PCI scope shrinks to SAQ-A (the lightest tier).
  • Tokenization: store only the gateway's token/reference for a saved card, never PAN/CVV. payment_methods.gateway_token should additionally be encrypted at rest (Laravel's encrypted cast) even though it's already a token, as defense in depth.
  • Encryption: TLS in transit everywhere; encrypt sensitive columns at rest using Laravel's built-in Crypt facade / model casts; rotate encryption keys periodically.
  • Sensitive data handling: scrub PAN/CVV from logs at the HTTP middleware level (never log raw webhook payloads from card-present flows without redaction); restrict DB access to payment tables via least-privilege roles.
  • Fraud prevention: velocity checks (X attempts per card/IP/user per hour), device fingerprinting, 3DS2 enforcement for high-risk transactions, manual review queue for amount/geography anomalies.

13. Scalability

Scale Key changes
10K/day (~0.1 TPS avg) Single Postgres/MySQL instance, sync processing fine, basic queue for webhooks is enough
100K/day (~1-2 TPS avg, bursts higher) Move webhook processing fully async (queue workers scale horizontally), read replicas for reporting/reconciliation queries, Redis cache for payment-method lookups, partition payment_transactions by month
1M/day (~12 TPS avg, much higher at peak) Shard payments by merchant_id or date range, dedicated queue clusters per event type, event bus (Kafka/RabbitMQ) replacing simple DB queues for the outbox relay, separate OLTP (payments) from OLAP (reconciliation/reporting) databases entirely, idempotency key lookups served from Redis with DB as fallback

Common mistake: trying to scale by adding more app servers when the actual bottleneck is row-lock contention on a hot payments row (e.g., a popular subscription renewal day). Diagnose lock contention via SHOW ENGINE INNODB STATUS / pg_locks before scaling horizontally.


14. Failure Scenarios

Failure Handling
Gateway timeout on charge creation Don't assume failure — the charge may have succeeded gateway-side. Poll the gateway's status endpoint using your idempotency key before retrying, never blindly retry a charge creation
Webhook endpoint down during gateway delivery Gateway retries with backoff (Stripe retries for ~3 days); reconciliation catches anything that still slips through
Network failure mid-transaction Outbox pattern + DB transaction ensures partial writes never leave inconsistent state
Duplicate payment Idempotency key + unique constraint
Settlement mismatch Reconciliation service flags amount_mismatch, routed to finance for manual review

15. Laravel Implementation

Folder structure

app/
  Domain/
    Payment/
      Models/Payment.php
      Models/PaymentTransaction.php
      Models/Refund.php
      StateMachine/PaymentStateMachine.php
      DTOs/ChargeRequest.php
      DTOs/GatewayChargeResponse.php
      Events/PaymentCaptured.php
      Events/PaymentFailed.php
      Exceptions/InvalidPaymentTransitionException.php
    Accounting/
      Models/JournalEntry.php
      Models/LedgerLine.php
      Services/LedgerService.php
    Reconciliation/
      Services/ReconciliationService.php
  Services/
    Payment/
      PaymentService.php
      Gateways/Contracts/PaymentGatewayContract.php
      Gateways/StripeGateway.php
      Gateways/HyperPayGateway.php
      Gateways/PaymobGateway.php
      Gateways/PaymentGatewayFactory.php
  Repositories/
    PaymentRepository.php
  Http/
    Controllers/Api/PaymentController.php
    Controllers/Webhooks/StripeWebhookController.php
    Requests/CreatePaymentRequest.php
  Jobs/
    ProcessWebhookJob.php
    RelayOutboxEventsJob.php
  Listeners/
    UpdateOrderOnPaymentCaptured.php
    PostLedgerEntryOnPaymentCaptured.php
    SendReceiptOnPaymentCaptured.php
Enter fullscreen mode Exit fullscreen mode

Service layer + repository + DTO sketch

final class PaymentService
{
    public function __construct(
        private PaymentRepository $repository,
        private PaymentGatewayFactory $gatewayFactory,
        private PaymentStateMachine $stateMachine,
    ) {}

    public function charge(ChargeRequest $request): Payment
    {
        $payment = $this->repository->createOrGetByIdempotencyKey($request);
        $gateway = $this->gatewayFactory->for($payment->gateway);

        $response = $gateway->createCharge($request);

        $this->stateMachine->transition($payment, $response->isAuthorized() ? 'AUTHORIZED' : 'PROCESSING');

        return $payment->fresh();
    }
}
Enter fullscreen mode Exit fullscreen mode

Use Database Transactions around every state-affecting write, Jobs/Queues for all webhook processing and outbox relay, and Events/Listeners for cross-cutting side effects (notifications, ledger posting) so PaymentService stays focused on payment state only.


16. Interview Preparation

Senior Backend Engineer — payment systems

  1. How would you prevent double-charging a customer who clicks "pay" twice rapidly?
  2. Walk me through what happens, end-to-end, when a webhook for a payment you already marked as failed arrives saying it succeeded.
  3. Why store amounts as integers (minor units) instead of floats/decimals naively?
  4. How do you guarantee a webhook handler is safe to run twice?
  5. Design the refunds table. Why isn't a single refunded boolean on payments sufficient?

Staff Engineer — payment architecture

  1. How would you redesign this system to support a marketplace with split payments to multiple sellers per order?
  2. Walk through the outbox pattern and why "just publish after commit" isn't reliable.
  3. How do you scale payment processing to 1M transactions/day while keeping financial consistency? Where do you shard, and what stays single-writer?
  4. How would you design a reconciliation system that not only detects mismatches but auto-heals the common cases?
  5. What's your strategy for supporting a new payment gateway without touching Order Service or Accounting Service code?

System Design interview format

  1. Design a payment system like Stripe Checkout for a mid-size e-commerce platform. (Drive the candidate toward: requirements clarification → high-level architecture → data model → idempotency → webhook reliability → failure modes → scaling.)
  2. Design a wallet system supporting peer-to-peer transfers with strong consistency guarantees.
  3. Design a subscription billing system handling retries, dunning, and proration.

Common Mistakes Recap (the senior-vs-mid-level tells)

  • Using a single status column with no immutable transaction/audit log
  • Trusting browser redirect/callback params as final truth instead of webhooks
  • Synchronous, heavy logic inside webhook HTTP handlers
  • No idempotency key enforced at the database level (app-level checks only)
  • Storing money as float/decimal instead of integer minor units
  • No double-entry ledger — balances tracked as mutable counters instead of derived sums
  • No reconciliation process — assuming webhooks are 100% reliable
  • No explicit state machine — if/else transition logic scattered across the codebase

Top comments (0)