Платёжная система с нуля: PCI DSS, интеграции и подводные камни

Зачем своя платёжная система

«ШопЮнион» начинал с прямой интеграции с одним PSP (Payment Service Provider). Это работало, пока не появились проблемы: PSP поднял комиссию, у него были даунтаймы в пиковые часы, а переключиться на альтернативу было невозможно — вся логика была завязана на конкретное API. Мы спроектировали абстрактный платёжный слой, который решает эти проблемы.

PCI DSS: что нужно знать

PCI DSS (Payment Card Industry Data Security Standard) — набор требований для всех, кто обрабатывает, хранит или передаёт данные банковских карт. Полная сертификация (Level 1) — это 12 разделов требований, ежегодный аудит и пентест. Стоимость — от 5 млн рублей в год.

Мы выбрали стратегию минимизации scope: данные карт никогда не касаются наших серверов. Вся работа с картами происходит через iframe или JavaScript SDK платёжного провайдера. Это позволяет заполнить упрощённую анкету SAQ A вместо полного аудита.

<!-- Платёжная форма: iframe от PSP, наши серверы не видят карту -->
<div id="payment-form"></div>
<script>
  const checkout = new PSPCheckout({
    publicKey: 'pk_live_xxxx',
    container: '#payment-form',
    amount: orderTotal,
    currency: 'RUB',
    onToken: async (token) => {
      // Токен безопасно отправляется на наш backend
      const response = await fetch('/api/payments/charge', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': generateIdempotencyKey(),
        },
        body: JSON.stringify({
          order_id: orderId,
          payment_token: token, // токен, а не данные карты
        }),
      });
      handlePaymentResult(await response.json());
    },
    onError: (error) => showPaymentError(error),
  });
</script>

Архитектура токенизации

Токенизация — замена чувствительных данных карты на токен, который бесполезен без ключей PSP. Наша архитектура:

┌─────────────┐    token    ┌─────────────┐    token    ┌─────────────┐
│   Browser   │ ──────────→ │  Our API    │ ──────────→ │    PSP      │
│  (iframe)   │             │  (backend)  │             │  (gateway)  │
└─────────────┘             └─────────────┘             └─────────────┘
       │                           │                           │
  Card data                  Never sees                  Decrypts token,
  entered in                 card data                   processes payment
  PSP iframe

Для сохранённых карт PSP возвращает recurring_token, который мы храним в зашифрованном виде. Этот токен позволяет списывать средства без повторного ввода карты, но бесполезен без нашего merchant ID и API-ключа.

Payment Flow: Authorize-Capture

Для маркетплейса критически важен двухэтапный платёж. При оформлении заказа мы замораживаем (authorize) средства на карте, а списываем (capture) только когда продавец подтвердил наличие товара и отправку.

# payment_service.py
from enum import Enum
from decimal import Decimal
from datetime import datetime, timedelta

class PaymentStatus(Enum):
    PENDING = "pending"
    AUTHORIZED = "authorized"
    CAPTURED = "captured"
    VOIDED = "voided"
    REFUNDED = "refunded"
    FAILED = "failed"

class PaymentService:
    def __init__(self, gateway: PaymentGateway, repo: PaymentRepository):
        self.gateway = gateway
        self.repo = repo

    async def authorize(self, order_id: str, amount: Decimal,
                        currency: str, token: str,
                        idempotency_key: str) -> Payment:
        """Шаг 1: Авторизация — заморозка средств на карте"""
        # Проверка идемпотентности
        existing = await self.repo.find_by_idempotency_key(idempotency_key)
        if existing:
            return existing

        payment = Payment(
            order_id=order_id,
            amount=amount,
            currency=currency,
            status=PaymentStatus.PENDING,
            idempotency_key=idempotency_key,
            authorized_at=None,
            capture_deadline=None,
        )
        await self.repo.save(payment)

        try:
            result = await self.gateway.authorize(
                amount=amount,
                currency=currency,
                token=token,
                merchant_reference=payment.id,
            )
            payment.gateway_id = result.transaction_id
            payment.status = PaymentStatus.AUTHORIZED
            payment.authorized_at = datetime.utcnow()
            # Авторизация живёт 7 дней — после этого банк отменит hold
            payment.capture_deadline = datetime.utcnow() + timedelta(days=7)
        except GatewayError as e:
            payment.status = PaymentStatus.FAILED
            payment.failure_reason = str(e)

        await self.repo.save(payment)
        await self.event_bus.publish('payment.authorized', payment)
        return payment

    async def capture(self, payment_id: str, amount: Decimal = None) -> Payment:
        """Шаг 2: Списание — выполняется после подтверждения продавцом"""
        payment = await self.repo.get(payment_id)
        if payment.status != PaymentStatus.AUTHORIZED:
            raise InvalidStateError(f"Cannot capture {payment.status}")
        if datetime.utcnow() > payment.capture_deadline:
            raise CaptureExpiredError("Authorization expired")

        capture_amount = amount or payment.amount  # Частичный capture возможен
        result = await self.gateway.capture(
            transaction_id=payment.gateway_id,
            amount=capture_amount,
        )
        payment.status = PaymentStatus.CAPTURED
        payment.captured_amount = capture_amount
        await self.repo.save(payment)
        await self.event_bus.publish('payment.captured', payment)
        return payment

Мульти-PSP: абстрактный gateway

Зависимость от одного PSP — бизнес-риск. Мы спроектировали абстрактный интерфейс, под который подключаются разные провайдеры:

# gateways/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal

@dataclass
class GatewayResult:
    transaction_id: str
    status: str
    raw_response: dict

class PaymentGateway(ABC):
    @abstractmethod
    async def authorize(self, amount: Decimal, currency: str,
                        token: str, merchant_reference: str) -> GatewayResult: ...

    @abstractmethod
    async def capture(self, transaction_id: str,
                      amount: Decimal) -> GatewayResult: ...

    @abstractmethod
    async def void(self, transaction_id: str) -> GatewayResult: ...

    @abstractmethod
    async def refund(self, transaction_id: str,
                     amount: Decimal) -> GatewayResult: ...

# gateways/alfabank.py
class AlfaBankGateway(PaymentGateway):
    async def authorize(self, amount, currency, token, merchant_reference):
        response = await self.client.post('/payment/rest/register.do', data={
            'amount': int(amount * 100),  # копейки
            'currency': self.CURRENCY_CODES[currency],
            'orderNumber': merchant_reference,
            'token': token,
        })
        return GatewayResult(
            transaction_id=response['orderId'],
            status='authorized',
            raw_response=response,
        )

# gateways/tinkoff.py
class TinkoffGateway(PaymentGateway):
    async def authorize(self, amount, currency, token, merchant_reference):
        payload = {
            'Amount': int(amount * 100),
            'OrderId': merchant_reference,
            'PayType': 'T',  # двухстадийная оплата
            'Token': token,
        }
        payload['Token'] = self._sign(payload)
        response = await self.client.post('/v2/Init', json=payload)
        return GatewayResult(
            transaction_id=response['PaymentId'],
            status='authorized',
            raw_response=response,
        )

Маршрутизация между PSP настраивается правилами: основной провайдер — «Альфа», если он недоступен — fallback на «Тинькофф». Для сумм выше 100 000 руб. — всегда «Альфа» (ниже комиссия для крупных транзакций).

Идемпотентность: не списать дважды

Сеть ненадёжна. Клиент может отправить запрос, не получить ответ из-за таймаута и повторить. Без идемпотентности это приведёт к двойному списанию.

# middleware/idempotency.py
class IdempotencyMiddleware:
    def __init__(self, redis_client):
        self.redis = redis_client

    async def process(self, request, handler):
        key = request.headers.get('Idempotency-Key')
        if not key:
            return await handler(request)

        # Проверяем, был ли уже такой запрос
        cached = await self.redis.get(f"idempotency:{key}")
        if cached:
            return json.loads(cached)  # Возвращаем сохранённый ответ

        # Ставим блокировку на 60 секунд (защита от параллельных запросов)
        acquired = await self.redis.set(
            f"idempotency:{key}:lock", "1",
            nx=True, ex=60
        )
        if not acquired:
            raise ConflictError("Request is being processed")

        response = await handler(request)

        # Сохраняем ответ на 24 часа
        await self.redis.set(
            f"idempotency:{key}",
            json.dumps(response),
            ex=86400
        )
        return response

Вебхуки и сверка (reconciliation)

PSP уведомляет нас о статусе платежа через вебхуки. Но вебхуки могут потеряться, прийти с задержкой или дублироваться. Мы построили надёжную систему обработки:

# webhooks/handler.py
class WebhookHandler:
    async def handle(self, payload: dict, signature: str):
        # 1. Проверяем подпись
        if not self.verify_signature(payload, signature):
            raise SecurityError("Invalid webhook signature")

        # 2. Идемпотентная обработка
        webhook_id = payload['webhook_id']
        if await self.repo.webhook_processed(webhook_id):
            return {'status': 'already_processed'}

        # 3. Обработка в транзакции
        async with self.db.transaction():
            await self.repo.mark_webhook_processed(webhook_id)
            await self.process_event(payload)

        return {'status': 'ok'}

# Сверка — ежедневная задача
class ReconciliationJob:
    async def run(self, date: str):
        """Сравниваем наши записи с отчётом PSP"""
        our_payments = await self.repo.get_payments_for_date(date)
        psp_report = await self.gateway.get_settlement_report(date)

        discrepancies = []
        for our_payment in our_payments:
            psp_record = psp_report.get(our_payment.gateway_id)
            if not psp_record:
                discrepancies.append(('missing_in_psp', our_payment))
            elif our_payment.amount != psp_record.amount:
                discrepancies.append(('amount_mismatch', our_payment, psp_record))

        if discrepancies:
            await self.alert_finance_team(discrepancies)

Возвраты и чарджбэки

Маркетплейс добавляет сложности: возврат может быть частичным (один товар из заказа), а деньги нужно вернуть с разных счетов продавцов.

# refund_service.py
class RefundService:
    async def process_refund(self, order_id: str, items: list[RefundItem]):
        order = await self.order_repo.get(order_id)
        payment = await self.payment_repo.get_by_order(order_id)

        total_refund = Decimal('0')
        seller_refunds = {}  # seller_id -> amount

        for item in items:
            total_refund += item.amount
            seller_id = item.seller_id
            seller_refunds[seller_id] = (
                seller_refunds.get(seller_id, Decimal('0')) + item.amount
            )

        # Выполняем refund через PSP
        result = await self.gateway.refund(
            transaction_id=payment.gateway_id,
            amount=total_refund,
        )

        # Обновляем балансы продавцов
        for seller_id, amount in seller_refunds.items():
            await self.wallet_service.debit(
                seller_id=seller_id,
                amount=amount,
                reason=f"Refund for order {order_id}",
            )

        # Аудит-лог
        await self.audit_log.record(
            action='refund',
            order_id=order_id,
            amount=total_refund,
            items=items,
            gateway_result=result,
        )

Чарджбэки (когда покупатель оспаривает транзакцию через банк) обрабатываются через вебхуки PSP. Мы автоматически блокируем соответствующую сумму на счёте продавца и создаём тикет для команды поддержки.

Конвертация валют

«ШопЮнион» работает с продавцами из нескольких стран. При покупке за рубли у продавца с ценой в тенге нужна конвертация:

# currency_service.py
class CurrencyService:
    def __init__(self, rate_provider):
        self.rate_provider = rate_provider
        self.cache = {}

    async def convert(self, amount: Decimal, from_currency: str,
                      to_currency: str) -> ConversionResult:
        if from_currency == to_currency:
            return ConversionResult(amount=amount, rate=Decimal('1'))

        rate = await self.get_rate(from_currency, to_currency)

        # Округляем в пользу платформы (банковское округление)
        converted = (amount * rate).quantize(
            Decimal('0.01'), rounding=ROUND_HALF_UP
        )

        return ConversionResult(
            amount=converted,
            rate=rate,
            rate_timestamp=datetime.utcnow(),
        )

    async def get_rate(self, from_curr: str, to_curr: str) -> Decimal:
        """Курс кешируется на 15 минут"""
        cache_key = f"{from_curr}_{to_curr}"
        cached = self.cache.get(cache_key)
        if cached and cached.expires_at > datetime.utcnow():
            return cached.rate
        rate = await self.rate_provider.fetch_rate(from_curr, to_curr)
        self.cache[cache_key] = CachedRate(rate=rate,
            expires_at=datetime.utcnow() + timedelta(minutes=15))
        return rate

Важно: курс фиксируется в момент авторизации и сохраняется в записи платежа. Если между авторизацией и capture курс изменился, мы используем зафиксированный курс.

Фрод-детекция

Базовая защита от фрода состоит из velocity checks и скоринга:

# fraud/detector.py
class FraudDetector:
    async def check(self, payment_request: PaymentRequest) -> FraudResult:
        score = 0
        reasons = []

        # Velocity checks
        recent_count = await self.redis.get(
            f"velocity:card:{payment_request.card_hash}:1h"
        )
        if recent_count and int(recent_count) > 5:
            score += 40
            reasons.append("5+ транзакций с одной карты за час")

        ip_count = await self.redis.get(
            f"velocity:ip:{payment_request.ip}:1h"
        )
        if ip_count and int(ip_count) > 10:
            score += 30
            reasons.append("10+ транзакций с одного IP за час")

        # Проверка суммы
        if payment_request.amount > Decimal('50000'):
            score += 15
            reasons.append("Сумма выше 50 000 руб.")

        # Геолокация: IP из другой страны, чем карта
        if payment_request.ip_country != payment_request.card_country:
            score += 25
            reasons.append("IP и карта из разных стран")

        # ML-модель (дообученная на наших данных)
        ml_score = await self.ml_model.predict(payment_request.features())
        score += int(ml_score * 50)

        # Решение
        if score >= 80:
            return FraudResult(action='block', score=score, reasons=reasons)
        elif score >= 50:
            return FraudResult(action='3ds_required', score=score, reasons=reasons)
        else:
            return FraudResult(action='allow', score=score, reasons=reasons)

Retry с экспоненциальным backoff

Запросы к PSP могут падать из-за таймаутов. Но не все запросы можно ретраить:

# utils/retry.py
import asyncio
import random

async def retry_with_backoff(func, max_retries=3, base_delay=1.0,
                              max_delay=30.0, retryable_errors=(TimeoutError,)):
    for attempt in range(max_retries + 1):
        try:
            return await func()
        except retryable_errors as e:
            if attempt == max_retries:
                raise
            delay = min(base_delay * (2 ** attempt), max_delay)
            jitter = random.uniform(0, delay * 0.1)
            await asyncio.sleep(delay + jitter)

# ВАЖНО: authorize можно ретраить (идемпотентный через Idempotency-Key)
# capture можно ретраить (идемпотентный по transaction_id)
# НО: запрос на создание платежа БЕЗ idempotency key — НЕЛЬЗЯ ретраить!

Аудит-логирование

Для финтеха аудит-лог — не опция, а требование регулятора. Каждое действие с платежом логируется в immutable-хранилище:

# audit/logger.py
class AuditLogger:
    async def record(self, **kwargs):
        entry = AuditEntry(
            timestamp=datetime.utcnow(),
            action=kwargs['action'],
            actor=kwargs.get('actor', 'system'),
            entity_type='payment',
            entity_id=kwargs.get('payment_id'),
            changes=kwargs.get('changes', {}),
            ip_address=kwargs.get('ip'),
            metadata=kwargs.get('metadata', {}),
        )
        # Append-only таблица, DELETE/UPDATE запрещены на уровне БД
        await self.repo.append(entry)
        # Дублируем в S3 для долгосрочного хранения (5 лет по требованиям)
        await self.s3_archiver.archive(entry)

Финансовая отчётность

Каждый день система генерирует отчёты: оборот, комиссии, возвраты, балансы продавцов. Данные сверяются с выписками PSP. Расхождения больше 1 рубля — алерт финансовому отделу.

Go-live чеклист

Перед запуском в продакшен мы прошли по чеклисту из 30+ пунктов. Вот ключевые:

  • SAQ A заполнена и подписана
  • Все API-ключи PSP в production — отдельные от staging
  • Idempotency-Key обязателен для всех мутирующих эндпоинтов
  • Webhook endpoint отвечает за 200 мс (иначе PSP считает его мёртвым)
  • Reconciliation-задача настроена и протестирована
  • Фрод-лимиты настроены (жёсткие на старте, ослабляем по мере сбора данных)
  • Аудит-лог пишется и проверен на полноту
  • Fallback PSP протестирован в staging
  • Мониторинг: алерты на ошибки, на рост refund rate, на аномалии в суммах
  • Нагрузочное тестирование: 1000 транзакций в минуту без деградации
  • Юридические документы: оферта, политика возвратов, согласие на обработку данных

Платёжная система — одна из самых ответственных частей любого продукта. Ошибка стоит реальных денег. Но при правильной архитектуре и соблюдении стандартов это решаемая задача. Если вам нужна платёжная интеграция — обращайтесь.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#authorizecapture#backoff#flow#gateway#golive#payment#reconciliation#retry
Комментарии 0

Оставить комментарий

загрузка...