Implementation of Split Payments on Website
Split payment is dividing one customer transaction into multiple recipients simultaneously. Typical scenarios: marketplace where part goes to seller, part to platform; booking service with aggregator commission; subscription with revenue share between partners. Without proper architecture this becomes manual accounting with regular errors and disputes.
Implementation takes 5 to 14 business days depending on number of recipients, distribution logic and payment provider used.
Distribution Models
Two fundamentally different approaches, choice determines everything else.
Charge + Transfer (Stripe) — money arrives to platform master account, then manually (via API) transferred to connected accounts. Platform responsible for seller KYC, compliance and possible refunds.
Direct Charge — customer pays seller directly, platform gets application fee. Seller passes KYC themselves. Less responsibility, but less control.
For most early-stage marketplaces Charge + Transfer is simpler — less legal complexity when onboarding sellers.
Stripe: Charge + Transfer Implementation
Stripe Connect is de-facto standard for split payments. First create PaymentIntent for full amount:
$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),
],
]);
After successful payment — event payment_intent.succeeded in webhook. In handler execute transfers:
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 links all transfers to original payment — critical for correct refund. source_transaction ensures transfer executed only from specific payment funds, not general balance.
Storing Split Configuration
Split rules stored in DB, not code — otherwise every commission change requires deploy:
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()
);
Calculating shares before creating transfers:
class SplitCalculator
{
public function calculate(int $totalCents, array $rules): array
{
$allocated = 0;
$result = [];
// First fixed amounts
foreach ($rules as $rule) {
if ($rule['rule_type'] === 'fixed') {
$result[] = ['recipient' => $rule['entity_id'], 'amount' => $rule['value']];
$allocated += $rule['value'];
}
}
// Then percentage
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;
}
}
// Remainder to platform or last-in-line recipient
$remainder = $totalCents - $allocated;
foreach ($rules as $rule) {
if ($rule['rule_type'] === 'remainder') {
$result[] = ['recipient' => $rule['entity_id'], 'amount' => $remainder];
break;
}
}
return $result;
}
}
remainder rule must be exactly one — protects against rounding errors. Sum of shares must match total to the kopeck.
Refunds with Split
Refund with split payment — most painful place. Stripe doesn't automatically reverse transfers on PaymentIntent refund — must be done explicitly:
public function refund(Order $order, int $refundCents): void
{
// 1. Refund main payment
\Stripe\Refund::create([
'payment_intent' => $order->stripe_payment_intent_id,
'amount' => $refundCents,
'refund_application_fee' => true,
]);
// 2. Reverse transfers proportionally
$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,
]);
}
}
If recipient account lacks funds for reversal (e.g., already withdrew money), Stripe returns error. Requires debit logic — separate scenario with manual intervention.
Alternatives to Stripe
CloudPayments (for CIS) supports split via Receipt mechanism with multiple recipients, but API less flexible. YooKassa has built-in split for marketplaces via Deal API — create deal, bind payments to it. Fondy and LiqPay offer split via partner agreements, provider-side configuration, not API.
Monitoring and Reconciliation
Each day run reconciliation job — compare transfer sums in DB with real transfers in Stripe via 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]);
}
}
Discrepancies go to alert. Not paranoia — webhooks sometimes lost, especially on deploys during transaction.
Tax and Legal Aspects
Payment split doesn't exempt platform from tax obligations — in most jurisdictions platform is tax agent. In Russia means transferring payment data to Tax Service, in EU — DAC7 reporting. Must account when designing split scheme from start — redesign later costs more.







