ExpertКейс9 min

Платёжная система

Проектирование платёжной системы: обработка платежей, идемпотентность, reconciliation, PCI DSS

Проектирование платёжной системы -- один из самых ответственных кейсов. Ошибки стоят денег, нарушение безопасности -- доверия пользователей. Ключевые темы: идемпотентность, exactly-once семантика, reconciliation, PCI DSS.

Шаг 1: Требования

Функциональные требования

  1. Приём платежей (карты, электронные кошельки)
  2. Выплаты продавцам/поставщикам (payouts)
  3. Возвраты (refunds) -- полные и частичные
  4. История транзакций
  5. Мультивалютность
  6. Webhook-уведомления о статусе платежей
  7. Отчёты и reconciliation

Нефункциональные требования

  1. Идемпотентность: повторный запрос не приводит к двойному списанию
  2. Consistency: баланс всегда корректен
  3. PCI DSS compliance: безопасное обращение с карточными данными
  4. Latency < 2 секунды для обработки платежа
  5. Availability 99.999% (5 nines)
  6. Полный audit trail всех операций

Шаг 2: High-Level архитектура

┌──────────┐    ┌───────────────┐    ┌──────────────────────────────────┐
│  Client  │───>│  API Gateway  │───>│  Payment Service                 │
│          │    │  (PCI proxy)  │    │                                  │
└──────────┘    └───────────────┘    │  ┌────────────┐ ┌─────────────┐ │
                                     │  │ Payment    │ │ Ledger      │ │
                                     │  │ Processor  │ │ Service     │ │
                                     │  └─────┬──────┘ └──────┬──────┘ │
                                     │        │               │        │
                                     │  ┌─────▼──────┐ ┌──────▼──────┐│
                                     │  │ PSP Router │ │ Reconciler  ││
                                     │  │            │ │             ││
                                     │  └─────┬──────┘ └─────────────┘│
                                     └────────┼────────────────────────┘
                                              │
                        ┌─────────────────────┼─────────────────────┐
                        │                     │                     │
                 ┌──────▼──────┐    ┌─────────▼────────┐   ┌───────▼──────┐
                 │  Stripe     │    │  Adyen           │   │  PayPal      │
                 │  (PSP)      │    │  (PSP)           │   │  (PSP)       │
                 └─────────────┘    └──────────────────┘   └──────────────┘

Шаг 3: Схема данных

-- Payment orders (intent to pay)
CREATE TABLE payment_orders (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    merchant_id     UUID NOT NULL,
    buyer_id        UUID NOT NULL,
    amount          BIGINT NOT NULL,         -- Amount in cents (never float!)
    currency        VARCHAR(3) NOT NULL,     -- ISO 4217: USD, EUR
    status          VARCHAR(20) NOT NULL DEFAULT 'created',
    -- created, processing, completed, failed, refunded, partially_refunded
    idempotency_key VARCHAR(64) NOT NULL UNIQUE,
    description     TEXT,
    metadata        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Payment attempts (each try to process)
CREATE TABLE payment_executions (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    payment_order_id    UUID NOT NULL REFERENCES payment_orders(id),
    psp_name            VARCHAR(50) NOT NULL,      -- stripe, adyen
    psp_transaction_id  VARCHAR(255),               -- external ID
    status              VARCHAR(20) NOT NULL,
    amount              BIGINT NOT NULL,
    error_code          VARCHAR(50),
    error_message       TEXT,
    raw_response        JSONB,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_executions_order ON payment_executions (payment_order_id);

-- Double-entry ledger (accounting)
CREATE TABLE ledger_entries (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    payment_order_id UUID NOT NULL REFERENCES payment_orders(id),
    account_id      UUID NOT NULL,
    entry_type      VARCHAR(10) NOT NULL, -- DEBIT or CREDIT
    amount          BIGINT NOT NULL,       -- Always positive
    currency        VARCHAR(3) NOT NULL,
    description     TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Every transaction MUST have balanced debit and credit entries
CREATE INDEX idx_ledger_account ON ledger_entries (account_id, created_at);
CREATE INDEX idx_ledger_payment ON ledger_entries (payment_order_id);

-- Refunds
CREATE TABLE refunds (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    payment_order_id    UUID NOT NULL REFERENCES payment_orders(id),
    amount              BIGINT NOT NULL,
    reason              TEXT,
    status              VARCHAR(20) NOT NULL DEFAULT 'pending',
    psp_refund_id       VARCHAR(255),
    idempotency_key     VARCHAR(64) NOT NULL UNIQUE,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

Шаг 4: Детальный дизайн

4.1 Payment Processor (идемпотентный)

<?php

declare(strict_types=1);

final class PaymentProcessor
{
    public function __construct(
        private readonly \PDO $db,
        private readonly PspRouter $pspRouter,
        private readonly LedgerService $ledger,
        private readonly WebhookDispatcher $webhooks,
        private readonly \Redis $redis,
    ) {}

    /**
     * Process a payment with idempotency guarantee
     *
     * CRITICAL: This method must be idempotent.
     * Same idempotency_key ALWAYS returns same result.
     */
    public function processPayment(PaymentRequest $request): PaymentResult
    {
        // 1. Check idempotency (fast path)
        $existing = $this->findExistingPayment($request->idempotencyKey);
        if ($existing !== null) {
            return $existing; // Return cached result
        }

        // 2. Acquire distributed lock to prevent concurrent processing
        $lockKey = "payment_lock:{$request->idempotencyKey}";
        $lockValue = bin2hex(random_bytes(16));

        $acquired = $this->redis->set($lockKey, $lockValue, ['NX', 'EX' => 30]);
        if (!$acquired) {
            // Another request is processing this payment
            // Wait and return result
            return $this->waitForResult($request->idempotencyKey);
        }

        try {
            return $this->executePayment($request, $lockKey, $lockValue);
        } catch (\Throwable $e) {
            $this->releaseLock($lockKey, $lockValue);
            throw $e;
        }
    }

    private function executePayment(
        PaymentRequest $request,
        string $lockKey,
        string $lockValue,
    ): PaymentResult {
        // 3. Create payment order
        $orderId = $this->createPaymentOrder($request);

        // 4. Select PSP and attempt payment
        $psp = $this->pspRouter->selectPsp(
            amount: $request->amount,
            currency: $request->currency,
            paymentMethod: $request->paymentMethod,
        );

        $attempt = $psp->charge(
            amount: $request->amount,
            currency: $request->currency,
            paymentMethod: $request->paymentMethod,
            metadata: [
                'order_id' => $orderId,
                'merchant_id' => $request->merchantId,
            ],
        );

        // 5. Record execution attempt
        $this->recordExecution($orderId, $psp->getName(), $attempt);

        if ($attempt->success) {
            // 6. Update payment order status
            $this->updateOrderStatus($orderId, 'completed');

            // 7. Create ledger entries (double-entry bookkeeping)
            $this->ledger->recordPayment(
                paymentOrderId: $orderId,
                buyerAccountId: $request->buyerId,
                merchantAccountId: $request->merchantId,
                platformAccountId: 'platform',
                amount: $request->amount,
                currency: $request->currency,
                platformFee: $this->calculatePlatformFee($request->amount),
            );

            // 8. Dispatch webhook
            $this->webhooks->dispatch($request->merchantId, 'payment.completed', [
                'payment_id' => $orderId,
                'amount' => $request->amount,
                'currency' => $request->currency,
            ]);

            $result = new PaymentResult(
                paymentId: $orderId,
                status: 'completed',
                pspTransactionId: $attempt->transactionId,
            );
        } else {
            // Handle failure
            $this->updateOrderStatus($orderId, 'failed');

            // Try fallback PSP if available
            if ($this->shouldRetryWithFallback($attempt)) {
                $fallbackPsp = $this->pspRouter->getFallback($psp->getName());
                if ($fallbackPsp !== null) {
                    return $this->retryWithPsp($orderId, $fallbackPsp, $request);
                }
            }

            $result = new PaymentResult(
                paymentId: $orderId,
                status: 'failed',
                errorCode: $attempt->errorCode,
                errorMessage: $attempt->errorMessage,
            );
        }

        // 9. Cache result for idempotency
        $this->cacheResult($request->idempotencyKey, $result);

        // 10. Release lock
        $this->releaseLock($lockKey, $lockValue);

        return $result;
    }

    private function createPaymentOrder(PaymentRequest $request): string
    {
        $stmt = $this->db->prepare(
            'INSERT INTO payment_orders
             (merchant_id, buyer_id, amount, currency, idempotency_key, description, metadata)
             VALUES (:merchant_id, :buyer_id, :amount, :currency, :idem_key, :description, :metadata)
             ON CONFLICT (idempotency_key) DO NOTHING
             RETURNING id'
        );

        $stmt->execute([
            'merchant_id' => $request->merchantId,
            'buyer_id' => $request->buyerId,
            'amount' => $request->amount,
            'currency' => $request->currency,
            'idem_key' => $request->idempotencyKey,
            'description' => $request->description,
            'metadata' => json_encode($request->metadata),
        ]);

        return $stmt->fetchColumn();
    }

    private function cacheResult(string $idempotencyKey, PaymentResult $result): void
    {
        $this->redis->setex(
            "payment_result:{$idempotencyKey}",
            86400 * 7, // 7 days
            serialize($result),
        );
    }

    private function calculatePlatformFee(int $amountCents): int
    {
        // 2.9% + 30 cents
        return (int) round($amountCents * 0.029 + 30);
    }

    private function releaseLock(string $key, string $value): void
    {
        $script = <<<'LUA'
            if redis.call("get", KEYS[1]) == ARGV[1] then
                return redis.call("del", KEYS[1])
            else
                return 0
            end
        LUA;

        $this->redis->eval($script, [$key, $value], 1);
    }
}

4.2 Double-Entry Ledger

<?php

declare(strict_types=1);

final class LedgerService
{
    public function __construct(
        private readonly \PDO $db,
    ) {}

    /**
     * Record a payment in double-entry format
     *
     * Every money movement is recorded as:
     * - DEBIT from one account (money leaves)
     * - CREDIT to another account (money enters)
     *
     * Total DEBITs always equals total CREDITs
     */
    public function recordPayment(
        string $paymentOrderId,
        string $buyerAccountId,
        string $merchantAccountId,
        string $platformAccountId,
        int $amount,
        string $currency,
        int $platformFee,
    ): void {
        $merchantAmount = $amount - $platformFee;

        $this->db->beginTransaction();

        try {
            // Entry 1: Buyer pays total amount
            // DEBIT buyer (money leaves buyer)
            $this->createEntry($paymentOrderId, $buyerAccountId, 'DEBIT', $amount, $currency, 'Payment');

            // Entry 2: Merchant receives (amount - fee)
            // CREDIT merchant
            $this->createEntry($paymentOrderId, $merchantAccountId, 'CREDIT', $merchantAmount, $currency, 'Sale revenue');

            // Entry 3: Platform receives fee
            // CREDIT platform
            $this->createEntry($paymentOrderId, $platformAccountId, 'CREDIT', $platformFee, $currency, 'Platform fee');

            // Verify balance: total debits = total credits
            $this->verifyBalance($paymentOrderId);

            $this->db->commit();
        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }
    }

    /**
     * Record a refund
     */
    public function recordRefund(
        string $paymentOrderId,
        string $refundId,
        string $buyerAccountId,
        string $merchantAccountId,
        string $platformAccountId,
        int $refundAmount,
        string $currency,
        int $platformFeeRefund,
    ): void {
        $merchantRefund = $refundAmount - $platformFeeRefund;

        $this->db->beginTransaction();

        try {
            // Reverse the original entries
            // CREDIT buyer (money returns to buyer)
            $this->createEntry($paymentOrderId, $buyerAccountId, 'CREDIT', $refundAmount, $currency, "Refund #{$refundId}");

            // DEBIT merchant (money leaves merchant)
            $this->createEntry($paymentOrderId, $merchantAccountId, 'DEBIT', $merchantRefund, $currency, "Refund #{$refundId}");

            // DEBIT platform fee return
            $this->createEntry($paymentOrderId, $platformAccountId, 'DEBIT', $platformFeeRefund, $currency, "Fee refund #{$refundId}");

            $this->db->commit();
        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }
    }

    /**
     * Get account balance
     */
    public function getBalance(string $accountId, string $currency): int
    {
        $stmt = $this->db->prepare(
            'SELECT
                COALESCE(SUM(CASE WHEN entry_type = \'CREDIT\' THEN amount ELSE 0 END), 0)
                - COALESCE(SUM(CASE WHEN entry_type = \'DEBIT\' THEN amount ELSE 0 END), 0)
                AS balance
             FROM ledger_entries
             WHERE account_id = :account_id AND currency = :currency'
        );

        $stmt->execute([
            'account_id' => $accountId,
            'currency' => $currency,
        ]);

        return (int) $stmt->fetchColumn();
    }

    private function createEntry(
        string $paymentOrderId,
        string $accountId,
        string $entryType,
        int $amount,
        string $currency,
        string $description,
    ): void {
        $stmt = $this->db->prepare(
            'INSERT INTO ledger_entries (payment_order_id, account_id, entry_type, amount, currency, description)
             VALUES (:payment_order_id, :account_id, :entry_type, :amount, :currency, :description)'
        );

        $stmt->execute([
            'payment_order_id' => $paymentOrderId,
            'account_id' => $accountId,
            'entry_type' => $entryType,
            'amount' => $amount,
            'currency' => $currency,
            'description' => $description,
        ]);
    }

    private function verifyBalance(string $paymentOrderId): void
    {
        $stmt = $this->db->prepare(
            'SELECT
                SUM(CASE WHEN entry_type = \'DEBIT\' THEN amount ELSE 0 END) as total_debit,
                SUM(CASE WHEN entry_type = \'CREDIT\' THEN amount ELSE 0 END) as total_credit
             FROM ledger_entries
             WHERE payment_order_id = :id'
        );

        $stmt->execute(['id' => $paymentOrderId]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        if ((int) $row['total_debit'] !== (int) $row['total_credit']) {
            throw new LedgerImbalanceException(
                sprintf(
                    'Ledger imbalance for %s: debit=%d, credit=%d',
                    $paymentOrderId,
                    $row['total_debit'],
                    $row['total_credit'],
                )
            );
        }
    }
}

4.3 PSP Router (Failover)

<?php

declare(strict_types=1);

final class PspRouter
{
    /** @var array<string, PspClient> */
    private array $psps;

    /** @var array<string, float> routing weights */
    private array $weights;

    public function __construct(
        private readonly \Redis $redis,
        array $psps,
        array $weights,
    ) {
        $this->psps = $psps;
        $this->weights = $weights;
    }

    /**
     * Select PSP based on routing rules
     */
    public function selectPsp(int $amount, string $currency, string $paymentMethod): PspClient
    {
        // 1. Filter by supported payment method
        $available = array_filter(
            $this->psps,
            fn (PspClient $psp) => $psp->supports($paymentMethod) && $this->isHealthy($psp->getName()),
        );

        if (empty($available)) {
            throw new NoPspAvailableException('No payment processor available');
        }

        // 2. Weighted random selection (for load distribution)
        $totalWeight = 0;
        foreach ($available as $name => $psp) {
            $totalWeight += $this->weights[$name] ?? 1.0;
        }

        $random = mt_rand() / mt_getrandmax() * $totalWeight;
        $cumulative = 0;

        foreach ($available as $name => $psp) {
            $cumulative += $this->weights[$name] ?? 1.0;
            if ($random <= $cumulative) {
                return $psp;
            }
        }

        return reset($available);
    }

    public function getFallback(string $excludePsp): ?PspClient
    {
        foreach ($this->psps as $name => $psp) {
            if ($name !== $excludePsp && $this->isHealthy($name)) {
                return $psp;
            }
        }

        return null;
    }

    private function isHealthy(string $pspName): bool
    {
        $failCount = (int) $this->redis->get("psp:failures:{$pspName}");
        return $failCount < 5; // Circuit breaker: max 5 failures in window
    }
}

4.4 Reconciliation

<?php

declare(strict_types=1);

final class ReconciliationService
{
    public function __construct(
        private readonly PaymentOrderRepository $orders,
        private readonly PspReportClient $pspReports,
        private readonly AlertService $alerts,
    ) {}

    /**
     * Daily reconciliation: compare our records with PSP records
     */
    public function reconcileDaily(\DateTimeImmutable $date): ReconciliationReport
    {
        $report = new ReconciliationReport($date);

        // 1. Get our payment records for the day
        $ourPayments = $this->orders->getCompletedByDate($date);

        // 2. Get PSP settlement reports
        foreach (['stripe', 'adyen'] as $pspName) {
            $pspTransactions = $this->pspReports->getSettlement($pspName, $date);

            // 3. Match transactions
            foreach ($ourPayments as $payment) {
                $pspTx = $this->findMatchingTransaction(
                    $payment,
                    $pspTransactions,
                );

                if ($pspTx === null) {
                    $report->addMismatch(new ReconciliationMismatch(
                        type: 'missing_in_psp',
                        paymentId: $payment->id,
                        ourAmount: $payment->amount,
                        pspAmount: null,
                        description: "Payment {$payment->id} not found in PSP records",
                    ));
                    continue;
                }

                // 4. Compare amounts
                if ($payment->amount !== $pspTx->amount) {
                    $report->addMismatch(new ReconciliationMismatch(
                        type: 'amount_mismatch',
                        paymentId: $payment->id,
                        ourAmount: $payment->amount,
                        pspAmount: $pspTx->amount,
                        description: sprintf(
                            'Amount mismatch: ours=%d, psp=%d',
                            $payment->amount,
                            $pspTx->amount,
                        ),
                    ));
                } else {
                    $report->addMatched($payment->id);
                }
            }

            // 5. Check for PSP transactions we don't have
            foreach ($pspTransactions as $pspTx) {
                if (!$this->hasMatchingPayment($pspTx, $ourPayments)) {
                    $report->addMismatch(new ReconciliationMismatch(
                        type: 'missing_in_our_records',
                        paymentId: null,
                        ourAmount: null,
                        pspAmount: $pspTx->amount,
                        description: "PSP transaction {$pspTx->id} not in our records",
                    ));
                }
            }
        }

        // 6. Alert if mismatches found
        if ($report->hasMismatches()) {
            $this->alerts->critical(
                'Payment reconciliation mismatches found',
                ['date' => $date->format('Y-m-d'), 'count' => $report->getMismatchCount()],
            );
        }

        return $report;
    }
}

Шаг 5: PCI DSS ключевые требования

Требование Реализация
Не хранить CVV Никогда не сохранять, даже в логах
Шифрование PAN Tokenization через PSP
Audit trail Все операции логируются
Network segmentation PCI scope изолирован
Vulnerability scanning Регулярные сканы
Access control Role-based, MFA

Рекомендация: Использовать PSP tokenization (Stripe Elements, Adyen Drop-in). Карточные данные НИКОГДА не проходят через ваши серверы.

Шаг 6: Критические правила

НИКОГДА:
  ✗ Использовать float/double для денег (only integer cents!)
  ✗ Хранить CVV
  ✗ Логировать полные номера карт
  ✗ Обрабатывать платёж без idempotency key
  ✗ Использовать auto-increment ID для платежей (утечка информации)

ВСЕГДА:
  ✓ Суммы в центах (integer)
  ✓ Idempotency key на каждую операцию
  ✓ Double-entry ledger
  ✓ Ежедневная reconciliation
  ✓ Audit trail всех действий
  ✓ Timeout + retry с экспоненциальным backoff

Возможные вопросы интервьюера

  1. Почему integer cents, а не float?

    • Float: 0.1 + 0.2 = 0.30000000000000004
    • Integer: 10 + 20 = 30 (100% точность)
    • ISO: amount in minor units (cents, kopecks)
  2. Как гарантировать exactly-once payment?

    • Idempotency key + distributed lock
    • PSP тоже поддерживает idempotency
    • Double write: DB + cache result
  3. Что делать, если PSP не отвечает?

    • Timeout (10-30 секунд)
    • Failover to backup PSP
    • Manual reconciliation для "unknown" статусов
    • "Pending" состояние с periodic polling
  4. Как обрабатывать мультивалютность?

    • Хранить amount + currency
    • Конверсия только в момент settlement
    • Использовать ECB/Fixer rates
    • Каждая currency -- отдельная запись в ledger
  5. Как масштабировать?

    • Шардирование по merchant_id
    • Асинхронная обработка (queue для non-critical)
    • Read replicas для отчётов
    • Архивация старых транзакций