# Payment Split System Low-Level Design

Source: https://www.techinterview.org/post/3233467853/lld-payment-split/
Updated: 2026-04-17 · techinterview.org

## 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.

Payment split and multi-party charge design is discussed in [Stripe system design interview questions](https://www.techinterview.org/post/3233460268/stripe-interview-guide-2026-process-bug-bash-round-and-payment-systems/).

Payment split and fare distribution design is covered in [Uber system design interview preparation](https://www.techinterview.org/post/3233460273/uber-interview-guide-2026-dispatch-systems-geospatial-algorithms-and-marketplace-engineering/).

Payment split and marketplace payout design is discussed in [Airbnb system design interview guide](https://www.techinterview.org/post/3233460279/airbnb-interview-guide-2026-search-systems-trust-and-safety-and-full-stack-engineering/).
