«ШопЮнион» начинал с прямой интеграции с одним PSP (Payment Service Provider). Это работало, пока не появились проблемы: PSP поднял комиссию, у него были даунтаймы в пиковые часы, а переключиться на альтернативу было невозможно — вся логика была завязана на конкретное API. Мы спроектировали абстрактный платёжный слой, который решает эти проблемы.
Платёжная система с нуля: PCI DSS, интеграции и подводные камни
Зачем своя платёжная система
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 транзакций в минуту без деградации
- Юридические документы: оферта, политика возвратов, согласие на обработку данных
Платёжная система — одна из самых ответственных частей любого продукта. Ошибка стоит реальных денег. Но при правильной архитектуре и соблюдении стандартов это решаемая задача. Если вам нужна платёжная интеграция — обращайтесь.
Оставить комментарий