Реалізація 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 ще на старті — переробка пізніше коштує дорожче.







