Payment Split System — Low-Level Design
A payment split system divides a single transaction among multiple parties: splitting a restaurant bill, charging multiple payment methods, or distributing marketplace revenue between a platform and sellers. This design appears in interviews at Stripe, Venmo, Airbnb, and Lyft.
Core Data Model
PaymentIntent
id BIGSERIAL PK
initiator_user_id BIGINT NOT NULL
total_amount_cents BIGINT NOT NULL
currency TEXT NOT NULL -- 'usd', 'eur'
status TEXT DEFAULT 'pending' -- pending, completed, failed, cancelled
created_at TIMESTAMPTZ
SplitParticipant
id BIGSERIAL PK
payment_intent_id BIGINT FK NOT NULL
user_id BIGINT NOT NULL
amount_cents BIGINT NOT NULL -- their share
payment_method_id BIGINT -- their payment method
status TEXT DEFAULT 'pending' -- pending, paid, failed, declined
charged_at TIMESTAMPTZ
charge_id TEXT -- external payment processor charge ID
SplitTemplate
id BIGSERIAL PK
owner_user_id BIGINT NOT NULL
name TEXT NOT NULL -- 'Roommates', 'Dinner group'
participant_user_ids BIGINT[]
split_type TEXT -- 'equal', 'percentage', 'fixed'
created_at TIMESTAMPTZ
Calculating Split Amounts
def calculate_splits(total_cents, participants, split_type):
if split_type == 'equal':
base = total_cents // len(participants)
remainder = total_cents % len(participants)
amounts = [base] * len(participants)
# Assign remainder cents to first N participants
for i in range(remainder):
amounts[i] += 1
return amounts
if split_type == 'percentage':
# participants = [{'user_id': 1, 'pct': 60}, {'user_id': 2, 'pct': 40}]
amounts = [int(total_cents * p['pct'] / 100) for p in participants]
# Fix rounding: add remainder to largest share
diff = total_cents - sum(amounts)
max_idx = max(range(len(amounts)), key=lambda i: amounts[i])
amounts[max_idx] += diff
return amounts
if split_type == 'fixed':
# Validate: sum of fixed amounts must equal total
if sum(p['amount_cents'] for p in participants) != total_cents:
raise ValueError('Fixed amounts do not sum to total')
return [p['amount_cents'] for p in participants]
Critical: always compute splits in integer cents. Float arithmetic loses pennies. When rounding produces a remainder, assign it deterministically (e.g., to the initiator or largest share).
Charging Participants
def charge_participant(participant_id):
participant = db.get_for_update(SplitParticipant, participant_id)
if participant.status != 'pending':
return # Already processed (idempotency)
try:
charge = stripe.charge.create(
amount=participant.amount_cents,
currency=participant.currency,
payment_method=participant.payment_method_id,
idempotency_key=f'split-{participant.id}',
)
db.execute("""
UPDATE SplitParticipant
SET status='paid', charged_at=NOW(), charge_id=%(charge_id)s
WHERE id=%(id)s AND status='pending'
""", {'charge_id': charge.id, 'id': participant_id})
except stripe.CardError as e:
db.execute("""
UPDATE SplitParticipant SET status='failed'
WHERE id=%(id)s
""", {'id': participant_id})
notify_participant_payment_failed(participant_id, e.message)
# Check if all participants are done
check_and_finalize_payment_intent(participant.payment_intent_id)
Payment Intent Finalization
def check_and_finalize_payment_intent(intent_id):
participants = db.query("""
SELECT status, COUNT(*) as cnt
FROM SplitParticipant
WHERE payment_intent_id=%(id)s
GROUP BY status
""", {'id': intent_id})
counts = {row.status: row.cnt for row in participants}
total = sum(counts.values())
paid = counts.get('paid', 0)
failed = counts.get('failed', 0) + counts.get('declined', 0)
if paid + failed == total: # All settled
new_status = 'completed' if failed == 0 else 'partial'
db.execute("""
UPDATE PaymentIntent SET status=%(status)s
WHERE id=%(id)s AND status='pending'
""", {'status': new_status, 'id': intent_id})
notify_initiator_split_complete(intent_id, paid, failed)
Handling a Declined Share
When one participant’s card declines, the initiator has three options:
- Request re-payment: Notify the participant to add a new payment method and retry.
- Cover the shortfall: Charge the remaining amount to the initiator’s card.
- Cancel the split: Refund all participants who already paid.
def handle_declined_share(intent_id, declined_participant_id, initiator_choice):
if initiator_choice == 'cover':
intent = db.get(PaymentIntent, intent_id)
declined_amount = db.get(SplitParticipant, declined_participant_id).amount_cents
# Charge initiator for the shortfall
charge_user(intent.initiator_user_id, declined_amount,
idempotency_key=f'cover-{declined_participant_id}')
elif initiator_choice == 'cancel':
# Refund everyone who already paid
paid_participants = db.query("""
SELECT * FROM SplitParticipant
WHERE payment_intent_id=%(id)s AND status='paid'
""", {'id': intent_id})
for p in paid_participants:
stripe.refund.create(charge=p.charge_id,
idempotency_key=f'refund-split-{p.id}')
db.execute("UPDATE SplitParticipant SET status='refunded' WHERE id=%(id)s",
{'id': p.id})
db.execute("UPDATE PaymentIntent SET status='cancelled' WHERE id=%(id)s",
{'id': intent_id})
Key Interview Points
- Integer arithmetic only: Never use floats for money. Store amounts in the smallest currency unit (cents). Round remainders deterministically.
- Idempotency keys per participant charge: Use split-{participant_id} as the Stripe idempotency key so retries never double-charge.
- Async charging: Charge participants in parallel via a job queue — do not block the HTTP response waiting for all card charges to complete.
- Partial completion state: A split where some pay and one declines is a valid terminal state. Track it explicitly rather than treating partial completion as failure.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you split payment amounts without losing cents to rounding?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use integer arithmetic in the smallest currency unit (cents). For equal splits: divide total by participant count using integer division, then distribute the remainder one cent at a time to the first N participants. Example: $10.00 split 3 ways = 333 + 333 + 334 cents. For percentage splits: calculate each share as int(total * pct / 100), then add the total rounding difference to the participant with the largest share. Never use floating-point arithmetic for money — float rounding errors accumulate and violate the invariant that shares sum to the total.”}},{“@type”:”Question”,”name”:”How do you prevent double-charging a participant in a payment split?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use per-participant idempotency keys when calling the payment processor: key=split-{participant_id}. If the charge job fails and is retried, the payment processor (Stripe, Braintree) recognizes the key and returns the original charge result without a second charge. Additionally, check participant.status before charging: only charge if status=pending. Use SELECT FOR UPDATE on the participant row to prevent two workers from racing to charge the same participant simultaneously.”}},{“@type”:”Question”,”name”:”What happens when one participant in a payment split declines?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The payment intent moves to a partial completion state, not a hard failure. Notify the initiator of the declined share and offer three options: (1) request re-payment from the participant (send them a link to add a new payment method and retry), (2) cover the shortfall — charge the remaining amount to the initiator, (3) cancel the split — refund all participants who already paid. Each refund uses the original charge ID with the payment processor to reverse the transaction. Never automatically re-charge without explicit user action.”}},{“@type”:”Question”,”name”:”How do you handle a marketplace split between a platform and seller?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use Stripe Connect or equivalent: the charge goes to the platform account, then a transfer is created to the seller’s connected account minus the platform fee. Example: $100 order, 15% platform fee — charge $100 to platform, transfer $85 to seller. Record both the charge_id and transfer_id on the order. For refunds: reverse the transfer first (clawback from seller), then refund the customer. The seller’s payout is delayed until after the refund window closes to cover chargeback risk.”}},{“@type”:”Question”,”name”:”How do you support splitting across multiple payment methods?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Model each SplitParticipant with its own payment_method_id. The initiator’s share may come from a gift card (for $20) and a credit card (for the remainder). Treat each payment method as an independent charge with its own idempotency key. Process charges in parallel via a job queue. If one payment method fails, handle only that participant’s retry without affecting the others. Store the breakdown in participant records so support can see exactly which method was used for each portion.”}}]}
Payment split and multi-party charge design is discussed in Stripe system design interview questions.
Payment split and fare distribution design is covered in Uber system design interview preparation.
Payment split and marketplace payout design is discussed in Airbnb system design interview guide.