Low Level Design: Marketplace Payment Service

Overview

A marketplace payment service handles money flows in a multi-sided marketplace where a buyer pays a platform, the platform takes a fee, and the remainder is distributed to one or more sellers. Think Airbnb, Etsy, Uber, or Amazon Marketplace. The complexity comes from splitting payments, managing seller payout schedules, handling refunds that propagate across multiple parties, and supporting multiple currencies.

Core Requirements

Functional

  • Accept a payment from a buyer for an order that may involve multiple sellers
  • Calculate platform fee and net seller payout per line item
  • Split payment: route platform fee to platform account, remainder to each seller
  • Schedule and execute seller payouts (instant, daily, weekly)
  • Handle full and partial refunds; propagate refund to affected sellers and reclaim platform fee
  • Support multi-currency: buyer pays in one currency, seller may receive in another
  • Idempotent payment processing

Non-Functional

  • Payment capture must be atomic relative to split calculation
  • Refund propagation must be eventually consistent within a defined SLA
  • All money movements auditable and reconcilable
  • Support 10,000+ transactions per second at peak

Data Model

CREATE TABLE orders (
  id              UUID PRIMARY KEY,
  buyer_id        UUID NOT NULL,
  currency        CHAR(3) NOT NULL,
  gross_amount    NUMERIC(20, 6) NOT NULL,
  status          VARCHAR(24) NOT NULL,   -- PENDING, PAID, REFUNDED, PARTIALLY_REFUNDED
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE order_line_items (
  id              UUID PRIMARY KEY,
  order_id        UUID NOT NULL REFERENCES orders(id),
  seller_id       UUID NOT NULL,
  gross_amount    NUMERIC(20, 6) NOT NULL,
  platform_fee    NUMERIC(20, 6) NOT NULL,
  seller_net      NUMERIC(20, 6) NOT NULL,  -- gross_amount - platform_fee
  currency        CHAR(3) NOT NULL,
  status          VARCHAR(24) NOT NULL   -- PENDING, PAID, REFUNDED
);

CREATE TABLE payment_transactions (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  order_id          UUID NOT NULL REFERENCES orders(id),
  transaction_type  VARCHAR(24) NOT NULL, -- CHARGE, REFUND, PAYOUT, FEE_TRANSFER
  amount            NUMERIC(20, 6) NOT NULL,
  currency          CHAR(3) NOT NULL,
  from_account_id   UUID,
  to_account_id     UUID,
  status            VARCHAR(16) NOT NULL, -- PENDING, COMPLETED, FAILED
  idempotency_key   VARCHAR(128) UNIQUE,
  external_ref      VARCHAR(128),         -- payment processor transaction ID
  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE seller_payouts (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  seller_id       UUID NOT NULL,
  amount          NUMERIC(20, 6) NOT NULL,
  currency        CHAR(3) NOT NULL,
  payout_method   VARCHAR(16) NOT NULL,  -- BANK_TRANSFER, WALLET, STRIPE_CONNECT
  status          VARCHAR(16) NOT NULL,  -- SCHEDULED, PROCESSING, COMPLETED, FAILED
  scheduled_at    TIMESTAMPTZ NOT NULL,
  executed_at     TIMESTAMPTZ,
  external_ref    VARCHAR(128)
);

CREATE TABLE platform_fee_ledger (
  id              BIGSERIAL PRIMARY KEY,
  order_id        UUID NOT NULL,
  line_item_id    UUID NOT NULL,
  fee_amount      NUMERIC(20, 6) NOT NULL,
  currency        CHAR(3) NOT NULL,
  event_type      VARCHAR(24) NOT NULL,  -- FEE_COLLECTED, FEE_REFUNDED
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Platform Fee Calculation

Fee structures vary by marketplace. Common models:

  • Flat percentage: fee = gross * rate (e.g., 5%)
  • Tiered: rate decreases as seller GMV increases (volume discounts)
  • Category-based: different rates per product category
  • Blended: base fee + payment processing passthrough (e.g., Stripe’s 2.9% + $0.30)
interface FeeCalculator {
    fun calculate(lineItem: OrderLineItem, seller: Seller): FeeBreakdown
}

data class FeeBreakdown(
    val grossAmount: Money,
    val platformFee: Money,
    val processingFee: Money,
    val sellerNet: Money
)

// Example: 10% platform fee + 3% payment processing on gross
fun calculate(lineItem: OrderLineItem, seller: Seller): FeeBreakdown {
    val platformFeeRate = lookupRate(seller.tier, lineItem.category)
    val platformFee = lineItem.grossAmount * platformFeeRate
    val processingFee = lineItem.grossAmount * PROCESSING_RATE + PROCESSING_FIXED
    val sellerNet = lineItem.grossAmount - platformFee - processingFee
    return FeeBreakdown(lineItem.grossAmount, platformFee, processingFee, sellerNet)
}

Fee calculation happens before payment capture. The breakdown is stored in order_line_items at the time of order creation so that historical records reflect the rates that were in effect, not current rates (which may change).

Payment Splitting Flow

1. Buyer submits payment for order (multi-seller cart)
2. PaymentService.charge(orderId, paymentMethod, idempotencyKey)
   a. Calculate fees for each line item
   b. Capture full gross_amount from buyer via payment processor
   c. Insert payment_transaction (CHARGE)
   d. For each line item:
      - Insert seller_net into seller's pending payout balance
      - Insert platform_fee into platform_fee_ledger
   e. Update order status to PAID
3. Payout scheduler runs (per seller payout schedule):
   a. Aggregate pending payout balance for seller
   b. Initiate bank transfer / wallet credit
   c. Insert payment_transaction (PAYOUT)
   d. Update seller_payouts status to COMPLETED

Refund Propagation

Refunds in a marketplace affect multiple parties. A full refund must:

  1. Refund the buyer (via payment processor reversal or new credit)
  2. Claw back platform fee from platform account
  3. Claw back seller net from seller’s pending payout balance (or issue a debit if already paid out)
fun processRefund(orderId: UUID, lineItemIds: List<UUID>, reason: String, idempotencyKey: String) {
    val lineItems = fetchLineItems(lineItemIds)
    val refundAmount = lineItems.sumOf { it.grossAmount }

    // Step 1: Refund buyer via processor
    val processorRefund = paymentProcessor.refund(order.externalChargeId, refundAmount, idempotencyKey)

    // Step 2: For each affected line item
    lineItems.forEach { item ->
        // Claw back from seller pending balance
        val sellerPendingBalance = getSellerPendingBalance(item.sellerId, item.currency)
        if (sellerPendingBalance >= item.sellerNet) {
            // Reduce pending payout — money was not yet sent
            deductFromPendingPayout(item.sellerId, item.sellerNet)
        } else {
            // Seller already paid out — issue debit or invoice
            scheduleSellerDebit(item.sellerId, item.sellerNet - sellerPendingBalance)
        }
        // Reverse platform fee
        insertPlatformFeeLedger(item, FEE_REFUNDED)
        // Update line item status
        updateLineItemStatus(item.id, REFUNDED)
    }

    // Step 3: Update order status
    val allRefunded = fetchLineItems(orderId).all { it.status == REFUNDED }
    updateOrderStatus(orderId, if (allRefunded) REFUNDED else PARTIALLY_REFUNDED)
}

Seller Payout Scheduling

Sellers configure payout frequency in their account settings. The system maintains a seller_payout_balance (pending earnings) and drains it on the schedule.

CREATE TABLE seller_payout_settings (
  seller_id       UUID PRIMARY KEY,
  payout_schedule VARCHAR(16) NOT NULL DEFAULT 'WEEKLY', -- INSTANT, DAILY, WEEKLY, MONTHLY
  payout_method   VARCHAR(16) NOT NULL,
  bank_account_id UUID,
  minimum_payout  NUMERIC(20, 6) NOT NULL DEFAULT 1.00,
  currency        CHAR(3) NOT NULL
);

A scheduled job runs daily, queries sellers whose next_payout_at <= NOW(), aggregates their pending balance, and initiates payout if above minimum_payout. For INSTANT payouts, the payout is triggered at the end of the payment flow itself.

Multi-Currency Settlement

When buyer currency differs from seller currency:

  1. Buyer is charged in their currency (e.g., EUR)
  2. A currency conversion is applied using a locked FX rate at the time of charge
  3. Seller receives payout in their preferred currency (e.g., GBP)
  4. FX rate and converted amounts are stored in order_line_items at order time

Never convert at payout time using a current FX rate — the seller net must be deterministic from the moment of sale. Lock the rate at charge time and store it.

Idempotency

Every payment operation accepts an idempotency_key. The key is stored in payment_transactions.idempotency_key with a UNIQUE constraint. If a duplicate key is received, the existing transaction is returned. This handles:

  • Network retries from the buyer’s client
  • Webhook replays from the payment processor
  • Internal retry loops in the payment service

High-Level Architecture

Buyer Client
  |
API Gateway
  |
Payment Service
  |
  +-- Payment Processor (Stripe, Adyen, Braintree) -- external charge/refund
  +-- PostgreSQL -- orders, line items, transactions, payout balances
  +-- Fee Calculator -- rule engine for platform fee rates
  +-- Payout Scheduler -- cron-driven, reads pending balances, initiates payouts
  +-- FX Service -- locked exchange rates at charge time
  +-- Kafka -- emit PaymentCompleted, RefundInitiated, PayoutCompleted events
  +-- Notification Service -- consumes events, emails buyers/sellers

Reconciliation

Daily reconciliation job:

  1. Pull all charges and refunds from payment processor API for the previous day
  2. Compare against payment_transactions in the database
  3. Flag any transactions present in processor but missing in DB (or vice versa)
  4. Verify: SUM(gross_amount) = SUM(platform_fee) + SUM(seller_net) + SUM(processing_fee) for all completed orders
  5. Alert on any discrepancy; escalate to finance team

Failure Scenarios and Mitigations

Scenario Mitigation
Payment captured but split not recorded (crash) Idempotency key + background reconciler detects unpropagated splits and retries
Seller paid out before refund clawback Seller debit scheduled; if unrecoverable, platform absorbs and flags for manual review
FX rate unavailable at charge time Payment is held PENDING until rate is available; timeout triggers payment failure
Payout processor rejects (invalid bank account) Payout marked FAILED; seller notified; retry after seller updates bank details
Partial refund on multi-seller order Refund only affects line items included in request; others remain PAID

Interview Tips

  • Start by asking: single seller or multi-seller per order? The answer determines whether you need payment splitting at all.
  • Emphasize storing fee rates and FX rates at order creation time, not lookup time at payout — rates change.
  • Refund propagation is the hardest part: walk through what happens if a seller has already been paid out.
  • Payout scheduling is a standard cron + job queue problem; use SKIP LOCKED for safe parallel processing.
  • Mention reconciliation proactively — it shows you understand that distributed systems drift and you need a safety net.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a marketplace payment service and how does payment splitting work?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A marketplace payment service processes transactions between buyers and sellers on a platform, automatically splitting the gross payment into a seller payout and a platform fee. Payment splitting is typically implemented via the payment processor’s connect or destination charge API (e.g., Stripe Connect): the platform charges the buyer, deducts its fee, and routes the remainder to the seller’s connected account—all in a single API call, reducing reconciliation complexity.”
}
},
{
“@type”: “Question”,
“name”: “How is platform fee calculated and deducted in a marketplace payment?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The platform fee is computed as a percentage of the subtotal (e.g., 15%) plus optional fixed amounts, and may vary by seller tier, category, or promotional agreement. The fee is stored as an integer in the smallest currency unit to avoid floating-point errors. A fee schedule table allows per-seller overrides. The fee is deducted from the gross charge before the net amount is credited to the seller’s ledger, and both gross and fee amounts are recorded on the order for audit purposes.”
}
},
{
“@type”: “Question”,
“name”: “How does refund propagation work in a marketplace when the platform fee must also be refunded?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A full refund reverses both the seller payout and the platform fee: the payment processor issues a refund to the buyer’s original payment method, pulls funds back from the seller’s connected account, and credits the platform fee back to the platform account. For partial refunds, the split is recalculated proportionally. The refund saga writes a REFUND_INITIATED event, calls the payment processor, and on success writes REFUND_COMPLETED—with a compensating REFUND_FAILED event and manual queue entry if the processor call fails.”
}
},
{
“@type”: “Question”,
“name”: “How does multi-currency settlement work in a marketplace?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Buyers are charged in their local currency; sellers are settled in their preferred currency. The marketplace uses the payment processor’s currency conversion at the time of charge to lock an exchange rate. The platform fee is calculated on the presentment currency amount before conversion. Sellers’ ledger balances are maintained in their settlement currency, and payouts are batched (daily or weekly) to minimize FX conversion costs. Exchange rate records are stored with each transaction for accurate financial reporting and tax compliance.”
}
}
]
}

See also: Airbnb Interview Guide 2026: Search Systems, Trust and Safety, and Full-Stack Engineering

See also: Shopify Interview Guide

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

Scroll to Top