Реализация Split-платежей (расщепление оплаты) на сайте
Split-платёж — это разбивка одной транзакции покупателя на несколько получателей одновременно. Типичные сценарии: маркетплейс, где часть суммы идёт продавцу, часть — платформе; booking-сервис с комиссией агрегатора; подписка с revenue share между партнёрами. Без правильной архитектуры это превращается в ручную бухгалтерию с регулярными ошибками и спорами.
Реализация занимает от 5 до 14 рабочих дней в зависимости от количества получателей, логики распределения и используемого платёжного провайдера.
Модели расщепления
Есть два принципиально разных подхода, и выбор между ними определяет всё остальное.
Charge + Transfer (Stripe) — деньги приходят на мастер-аккаунт платформы, затем вручную (через API) переводятся на connected accounts. Платформа несёт ответственность за KYC продавцов, комплаенс и возможные возвраты.
Direct Charge — клиент платит напрямую продавцу, платформа получает application fee. Продавец сам проходит KYC. Меньше ответственности, но меньше контроля.
Для большинства маркетплейсов на ранней стадии проще Charge + Transfer — меньше юридической сложности при онбординге продавцов.
Stripe: реализация Charge + Transfer
Stripe Connect — де-факто стандарт для split-платежей. Сначала создаём PaymentIntent на полную сумму:
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $order->total_cents,
'currency' => 'eur',
'payment_method_types' => ['card'],
'metadata' => [
'order_id' => $order->id,
'split_recipients' => json_encode($order->recipients),
],
]);
После успешного платежа — событие payment_intent.succeeded в webhook. В обработчике выполняем трансферы:
public function handlePaymentSucceeded(array $payload): void
{
$intent = $payload['data']['object'];
$recipients = json_decode($intent['metadata']['split_recipients'], true);
foreach ($recipients as $recipient) {
\Stripe\Transfer::create([
'amount' => $recipient['amount_cents'],
'currency' => $intent['currency'],
'destination' => $recipient['stripe_account_id'],
'transfer_group' => $intent['transfer_group'],
'source_transaction' => $intent['charges']['data'][0]['id'],
]);
}
}
transfer_group связывает все трансферы с исходным платежом — это критично для корректного рефанда. source_transaction гарантирует, что трансфер выполняется только из средств конкретного платежа, а не из общего баланса.
Хранение конфигурации расщепления
Правила split хранятся в БД, не в коде — иначе каждое изменение комиссии требует деплоя:
CREATE TABLE split_rules (
id bigserial PRIMARY KEY,
entity_type varchar(50) NOT NULL, -- 'seller', 'partner', 'platform'
entity_id bigint NOT NULL,
rule_type varchar(20) NOT NULL, -- 'percentage', 'fixed', 'remainder'
value numeric(10, 4) NOT NULL,
priority int NOT NULL DEFAULT 0,
currency char(3),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
Расчёт долей перед созданием трансферов:
class SplitCalculator
{
public function calculate(int $totalCents, array $rules): array
{
$allocated = 0;
$result = [];
// Сначала фиксированные суммы
foreach ($rules as $rule) {
if ($rule['rule_type'] === 'fixed') {
$result[] = ['recipient' => $rule['entity_id'], 'amount' => $rule['value']];
$allocated += $rule['value'];
}
}
// Затем процентные
foreach ($rules as $rule) {
if ($rule['rule_type'] === 'percentage') {
$amount = (int) round($totalCents * $rule['value'] / 100);
$result[] = ['recipient' => $rule['entity_id'], 'amount' => $amount];
$allocated += $amount;
}
}
// Остаток — платформе или last-in-line получателю
$remainder = $totalCents - $allocated;
foreach ($rules as $rule) {
if ($rule['rule_type'] === 'remainder') {
$result[] = ['recipient' => $rule['entity_id'], 'amount' => $remainder];
break;
}
}
return $result;
}
}
Правило remainder всегда должно быть ровно одно — это защита от округления и накопленных ошибок. Сумма долей обязана совпадать с total до копейки.
Возвраты при split
Возврат при расщеплённом платеже — самое болезненное место. Stripe автоматически не отзывает трансферы при рефанде на PaymentIntent — это нужно делать явно:
public function refund(Order $order, int $refundCents): void
{
// 1. Рефанд основного платежа
\Stripe\Refund::create([
'payment_intent' => $order->stripe_payment_intent_id,
'amount' => $refundCents,
'refund_application_fee' => true,
]);
// 2. Реверс трансферов пропорционально
$ratio = $refundCents / $order->total_cents;
foreach ($order->transfers as $transfer) {
$reverseAmount = (int) round($transfer->amount_cents * $ratio);
\Stripe\Transfer::createReversal($transfer->stripe_transfer_id, [
'amount' => $reverseAmount,
'refund_application_fee' => true,
]);
}
}
Если на аккаунте получателя недостаточно средств для реверса (например, он уже вывел деньги), Stripe вернёт ошибку. В этом случае нужна логика дебетования — отдельный сценарий с manual intervention.
Альтернативы Stripe
CloudPayments (для СНГ) поддерживает split через механизм Receipt с несколькими получателями, но API менее гибкий. YooKassa имеет встроенный split для маркетплейсов через Deal API — создаётся deal, к нему привязываются выплаты. Fondy и LiqPay предлагают split через партнёрские договоры, конфигурация на стороне провайдера, не через API.
Мониторинг и согласование
Каждый день запускается reconciliation job — сравнение сумм трансферов в БД с реальными трансферами в Stripe через API:
$stripeTransfers = \Stripe\Transfer::all([
'created' => ['gte' => $yesterday->timestamp, 'lt' => $today->timestamp],
'limit' => 100,
]);
$dbTransfers = Transfer::whereDate('created_at', $yesterday)->get()->keyBy('stripe_id');
foreach ($stripeTransfers->autoPagingIterator() as $transfer) {
if (!isset($dbTransfers[$transfer->id])) {
Log::critical('Untracked transfer', ['stripe_id' => $transfer->id, 'amount' => $transfer->amount]);
}
}
Расхождения уходят в алерт. Это не паранойя — webhook-и иногда теряются, особенно при деплоях в момент транзакции.
Налоговые и юридические аспекты
Расщепление платежа не освобождает платформу от фискальных обязательств — в большинстве юрисдикций платформа является налоговым агентом. В России это означает передачу данных о выплатах в ФНС, в ЕС — DAC7 reporting. Это нужно учитывать при проектировании схемы split ещё на старте — переделывать потом дороже.







