The 100 problems, solved / Commerce, booking and payments / 048 · Design the Play Store revenue split and developer payout flow

Design the Play Store revenue split and developer payout flow

Aggregate billions of purchases into monthly developer payouts, with refunds and tax, correct to the cent.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements

Functional

  • Every app purchase, subscription renewal and refund is recorded with its currency and tax.
  • Each month, compute every developer's payout: gross sales minus refunds, taxes and the platform's share, per country and currency.
  • Developers see a running estimate during the month and a final statement after close.
  • Corrections after close (late refunds, chargebacks) carry into the next statement.
  • Out of scope: the purchase flow itself, bank transfer rails, developer onboarding.

Non-functional

  • 2 billion transactions a month; 3 million developers; 150 countries and 50 currencies.
  • Monthly close completes within 48 hours; running estimates lag by at most an hour.
  • Correct to the cent: totals must reconcile exactly with the transaction ledger; nothing double counted or dropped.
  • Reproducible: rerunning a month gives the same statement; every number traceable to transactions and the rates used.
  • Auditable for 10 years.
Back of the envelope

2 × 109 transactions a month ÷ (30 × 86,400) ≈ 770 per second average, a few thousand at peak; writes are modest.

Transaction rows at 300 B: 600 GB a month, 7 TB a year, 70 TB for the retention period; cheap in columnar storage.

The monthly batch reads 2 B rows and groups by developer, country, currency and tax category: a few thousand cores for under an hour, well inside 48 hours even with reruns.

Running estimates: aggregate incrementally per developer in hourly windows, about 3 M counters updated every hour.

2. Core entities

  • Transaction: txn_id, type (purchase, renewal, refund, chargeback), app_id, developer_id, country, currency, gross, tax, occurred_at, recorded_at, version.
  • Rate snapshot: period, currency pair, exchange rate; tax rules by country and category; platform share rules.
  • Statement line: period, developer_id, country, currency, gross, refunds, tax, platform share, net, transaction count.
  • Statement: period, developer_id, state (estimate, closed, paid), total by payout currency, version.
  • Adjustment: a transaction recorded in a later period that refers to an earlier one.

3. API

  • POST /transactions: from the purchase system, with txn_id as the idempotency key.
  • GET /developers/{id}/statements/{period}: lines and totals, with state.
  • GET /developers/{id}/estimate: the running month.
  • POST /periods/{period}/close: run the close; POST /periods/{period}/rerun: recompute with the same snapshots.
  • GET /audit/statements/{period}/{developer}/lines/{line}/transactions: the transactions behind a line.

4. High-level design

SourcesIngestComputeDataappendnightlyestimatesread monthwrite linesPurchase systemDeveloper consoleTransaction ingest(idempotent)Statements APIHourly estimator(stream)Monthly close(batch)Transaction ledger(append-only)Columnar archiveRate + rule snapshotsStatements DB

From a purchase to a payout line:

  1. The purchase system posts the transaction; ingest writes it once (txn_id is the key) to the append-only ledger with both occurred_at and recorded_at.
  2. The hourly estimator aggregates new rows per developer and updates the estimate; developers see the month building up.
  3. On the first of the month the close job freezes the inputs: it takes a snapshot of exchange rates, tax rules and share rules for the period, and defines the period as transactions with recorded_at in the month.
  4. The batch groups the month's transactions by developer, country, currency and tax category, computes each line, converts to the payout currency with the snapshot rates, and writes statement lines and totals in state closed.
  5. Late refunds for last month arrive with recorded_at in this month; they become adjustment lines on this month's statement, referring back to the original period.
  6. The payout rail consumes closed statements and marks them paid; a rerun of a closed period is allowed only for audit and must reproduce the same lines.

5. Deep dives

Why recorded_at defines the period, not occurred_at?

Transactions arrive late: an offline refund, a chargeback weeks after the sale. If the period is defined by when the event happened, a closed statement changes whenever something late arrives and is never final. Defining the period by when the ledger recorded it makes every statement final at close, and late events flow into the next period as adjustments with a reference to the original. Occurred_at is still stored for the developer's analytics; the two dates serve different purposes and both are needed.

How is correctness to the cent guaranteed with 50 currencies and rounding?

ChoiceWhy
Integers in minor units (cents), never floating pointFloating point drifts; integer arithmetic is exact
Round once, at the line level, with a stated rule (half up), after summing in source currencyRounding per transaction accumulates error; summing first keeps totals reconcilable
Snapshot rates per period, stored with the statementA rerun uses the same rates; the statement explains itself
Reconciliation: sum of lines equals sum of transactions per developer, per currency, before conversionA mismatch fails the close before anything is paid

The close job asserts the reconciliation and refuses to mark a period closed if a single developer's lines do not sum to their transactions, which turns silent errors into loud ones.

How do we make the close reproducible and idempotent?

The batch reads immutable inputs (the ledger for the period, the snapshots) and writes lines keyed by (period, developer, line key) with upsert semantics, so a crashed job rerun produces identical rows and no duplicates; idempotency here is a property of deterministic inputs plus natural keys. Nothing in the close depends on wall-clock time or on data outside the period. A rerun that would change a closed statement is a bug, and the job compares its output with the stored lines and reports differences rather than overwriting.

What does the running estimate cost, and why not just run the batch hourly?

The full batch over 2 B rows every hour is wasteful and would produce numbers that still change at close (rates, late rows). The estimator is a streaming aggregation over new ledger rows only, keyed by developer, using today's rates and labelled as an estimate; its purpose is a trend, not a statement. It is also a cheap early warning: if the hourly totals diverge from the purchase system's own counts, something is being dropped at ingest and can be fixed before close.

How does the audit trail answer "where did this number come from" ten years later?

Each statement line stores the rule and rate snapshot ids and the count and sum of its transactions; the archive keeps the transactions in columnar files partitioned by period and developer, so the transactions behind a line are one partition scan. Rule and rate snapshots are immutable rows, never edited, and the ledger itself is append-only, with corrections as new rows. The audit question is answered by data that cannot have changed since the close.

6. What a strong answer sounds like

  • Open with the two defining decisions: periods by recorded_at with adjustments, and frozen snapshots of rates and rules; everything reproducible follows from them.
  • Say integers in minor units and one rounding point, and that the close reconciles before it closes.
  • Separate the hourly estimate (a stream, approximate) from the monthly close (a batch, exact).
  • Expect follow-ups on chargebacks months later, on a rate correction after close (a new adjustment, never a rewrite), and on scaling the batch.
  • A common mistake is to treat this as a reporting dashboard; it is an accounting system, and the interviewer listens for finality, reproducibility and reconciliation.

02Where this question was reported