Проектирование платёжной системы -- один из самых ответственных кейсов. Ошибки стоят денег, нарушение безопасности -- доверия пользователей. Ключевые темы: идемпотентность, exactly-once семантика, reconciliation, PCI DSS.
Шаг 1: Требования
Функциональные требования
- Приём платежей (карты, электронные кошельки)
- Выплаты продавцам/поставщикам (payouts)
- Возвраты (refunds) -- полные и частичные
- История транзакций
- Мультивалютность
- Webhook-уведомления о статусе платежей
- Отчёты и reconciliation
Нефункциональные требования
- Идемпотентность: повторный запрос не приводит к двойному списанию
- Consistency: баланс всегда корректен
- PCI DSS compliance: безопасное обращение с карточными данными
- Latency < 2 секунды для обработки платежа
- Availability 99.999% (5 nines)
- Полный 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
Возможные вопросы интервьюера
-
Почему integer cents, а не float?
- Float: 0.1 + 0.2 = 0.30000000000000004
- Integer: 10 + 20 = 30 (100% точность)
- ISO: amount in minor units (cents, kopecks)
-
Как гарантировать exactly-once payment?
- Idempotency key + distributed lock
- PSP тоже поддерживает idempotency
- Double write: DB + cache result
-
Что делать, если PSP не отвечает?
- Timeout (10-30 секунд)
- Failover to backup PSP
- Manual reconciliation для "unknown" статусов
- "Pending" состояние с periodic polling
-
Как обрабатывать мультивалютность?
- Хранить amount + currency
- Конверсия только в момент settlement
- Использовать ECB/Fixer rates
- Каждая currency -- отдельная запись в ledger
-
Как масштабировать?
- Шардирование по merchant_id
- Асинхронная обработка (queue для non-critical)
- Read replicas для отчётов
- Архивация старых транзакций