Every streaming service does this math. We publish ours, the running totals, the worked example, and the literal code the server runs.
All-time totals, straight from the database this page is served from.
Month-by-month detail, with cost assumptions and adjustments, is in the quarterly transparency reports.
Using the currently published assumptions. When Stripe reports your real fee, that number is used instead of the estimate.
Your $11.99 is recorded per real payment (per Stripe invoice), never assumed. A month with no payment pools nothing, the code refuses to allocate money that never arrived, even if a billing period overlaps the month.
Wherever possible we use the fee Stripe actually charged for YOUR payment. Only when no real fee was recorded do we fall back to the published assumption (currently 2.9% + $0.30).
A flat $0.30 per subscriber-month covers streaming, storage, and compute. It’s an estimate, published in advance, and trued against actual bills in the quarterly reports.
7.5% of gross ($0.90 on $11.99). This is the part that pays the humans who run the garden and keeps runway. The cap is published and governed by member vote.
$10.14 on a typical $11.99 month. It is divided among ONLY the artists you played (qualified plays, 30+ seconds), proportional to your listening time, using largest-remainder rounding so the cents always sum exactly, none lost, none invented.
This is src/lib/waterfall.ts, the file the server executes for every allocation, read from disk as you loaded this page. It is pure integer-cent arithmetic with no side effects, which is what lets us test it exhaustively and publish it whole.
// Pure money math for the user-centric payout model (spec §22).
// Everything is integer cents; no I/O so it can be exhaustively tested.
// This module stays free of server-only imports so the transparency
// simulator can run the SAME code in the browser.
// Standard US card estimate used when the real balance transaction fee is
// unavailable (re-exported by lib/stripe for the billing routes).
export function estimateProcessingFee(grossCents: number) {
return Math.round(grossCents * 0.029) + 30
}
export type WaterfallSubscription = {
grossSubscriptionAmount: number
paymentProcessorFeeAmount: number
netReceivedAmount: number
subscriptionSource: string
status: string
}
export type WaterfallConfig = {
infrastructureCostPerSubscriber: number
operatingReservePercent: number
appStorePlatformFeePercent: number
// Admin-configurable processing-fee assumptions; used only when no real
// fee was recorded for the subscription.
paymentProcessingPercent?: number
paymentProcessingFixedFee?: number
}
export type Waterfall = {
gross: number
processing: number
net: number
infra: number
reserve: number
appStoreFee: number
pool: number
}
export function computeWaterfall(
sub: WaterfallSubscription,
config: WaterfallConfig,
): Waterfall {
const gross = sub.grossSubscriptionAmount
// A trialing subscription with no recorded payment has produced no revenue
// yet; allocating from it would pay artists money that never arrived.
if (sub.status === 'trialing' && sub.netReceivedAmount <= 0) {
return { gross, processing: 0, net: 0, infra: 0, reserve: 0, appStoreFee: 0, pool: 0 }
}
const estimatedFee =
config.paymentProcessingPercent !== undefined && config.paymentProcessingFixedFee !== undefined
? Math.round((gross * config.paymentProcessingPercent) / 100) + config.paymentProcessingFixedFee
: estimateProcessingFee(gross)
const processing =
sub.paymentProcessorFeeAmount > 0
? sub.paymentProcessorFeeAmount
: sub.subscriptionSource === 'comped' || sub.status === 'comped'
? 0
: estimatedFee
const net = sub.netReceivedAmount > 0 ? sub.netReceivedAmount : gross - processing
const infra = config.infrastructureCostPerSubscriber
const reserve = Math.round((gross * config.operatingReservePercent) / 100)
const appStoreFee =
sub.subscriptionSource === 'apple_iap'
? Math.round((gross * config.appStorePlatformFeePercent) / 100)
: 0
const pool = Math.max(0, net - infra - reserve - appStoreFee)
return { gross, processing: gross - net, net, infra, reserve, appStoreFee, pool }
}
export type MonthPayment = { grossAmount: number; feeAmount: number; netAmount: number }
// Decide a subscriber's revenue basis for one calendar month, or null if they
// pool nothing that month. This is the guard against the churn over-allocation:
// - recorded per-invoice payment(s) for the month win (real revenue arrived;
// callers pass the SUM when a month holds several payments);
// - otherwise an INVOICE-BILLED subscription (identified by having a Stripe
// subscription id) with no payment this month contributes nothing — either
// a month its Stripe period merely spilled into (a period straddles two
// calendar months, so a churned subscriber's final period overlaps the next
// month too) or a first invoice that hasn't landed yet. Falling back to the
// cached amount would allocate money that never arrived; late payments flag
// the month for recompute instead;
// - a never-invoiced row (comp / manual / dev — no Stripe subscription id)
// keeps its cached amounts, exactly as before.
export function subscriberMonthRevenue<S extends WaterfallSubscription>(
sub: S,
payment: MonthPayment | undefined,
everInvoiced: boolean,
): S | null {
if (payment) {
return {
...sub,
grossSubscriptionAmount: payment.grossAmount,
paymentProcessorFeeAmount: payment.feeAmount,
netReceivedAmount: payment.netAmount,
}
}
if (everInvoiced) return null
return sub
}
export type ListeningShare = { artistId: string; seconds: number }
export type PoolAllocation = {
artistId: string
seconds: number
sharePercentage: number
amount: number
}
// Largest-remainder apportionment: allocations are proportional to qualified
// seconds and always sum exactly to the pool (no lost or invented cents).
export function splitPoolLargestRemainder(
pool: number,
listening: ListeningShare[],
): PoolAllocation[] {
const positive = listening.filter((l) => l.seconds > 0)
const totalSeconds = positive.reduce((s, l) => s + l.seconds, 0)
if (pool <= 0 || totalSeconds <= 0) return []
const shares = positive.map((l) => {
const exact = (pool * l.seconds) / totalSeconds
return { ...l, exact, floor: Math.floor(exact) }
})
let remainder = pool - shares.reduce((s, x) => s + x.floor, 0)
shares.sort((a, b) => b.exact - b.floor - (a.exact - a.floor))
const result: PoolAllocation[] = []
for (const share of shares) {
const bonus = remainder > 0 ? 1 : 0
remainder -= bonus
const amount = share.floor + bonus
if (amount <= 0) continue
result.push({
artistId: share.artistId,
seconds: share.seconds,
sharePercentage: (share.seconds / totalSeconds) * 100,
amount,
})
}
return result
}
export type SplitDefinition = {
/** Present for real splits; absent in previews and older callers. */
id?: string
artistId: string | null
labelId?: string | null
percent: number
}
/** One split's slice of a track's earnings. `splitId` is null for whatever the
* uploader keeps. */
export type SplitAllocation = { splitId: string | null; recipientKey: string; cents: number }
/** Remaining headroom on a capped split, and where the overflow goes. */
export type CapState = { remaining: number; revertsTo: string }
// The routing key for a split recipient. Labels are namespaced with a `label:`
// prefix so the payout engine can tell them apart from artists (artist keys
// stay raw cuids, keeping the existing artist path and its tests unchanged).
export const LABEL_RECIPIENT_PREFIX = 'label:'
function recipientKey(sp: SplitDefinition): string | null {
if (sp.labelId) return `${LABEL_RECIPIENT_PREFIX}${sp.labelId}`
return sp.artistId
}
// Divides one track's earnings among split recipients (integer cents,
// largest remainder). Splits without a linked platform artist/label route back
// to the uploader: no escrow, no waiting money. The uploader keeps whatever
// percentage is not assigned. Recipients are keyed by recipientKey(); a label
// recipient is `label:<id>`, an artist is its raw id.
export function allocateTrackSplits(
amount: number,
uploaderArtistId: string,
splits: SplitDefinition[],
): SplitAllocation[] {
const out: SplitAllocation[] = []
if (amount <= 0) return out
// A split routes away from the uploader if it targets a label, or a different
// artist. A split targeting the uploader's own artist id is a no-op remainder.
const linked = splits.filter((sp) => {
if (sp.percent <= 0) return false
if (sp.labelId) return true
return Boolean(sp.artistId) && sp.artistId !== uploaderArtistId
})
const linkedPercent = linked.reduce((sum, sp) => sum + sp.percent, 0)
if (linkedPercent <= 0) {
return [{ splitId: null, recipientKey: uploaderArtistId, cents: amount }]
}
// Defense-in-depth: percentages should never exceed 100 (the write path
// enforces it), but if bad data slips through, scale them down to sum to 100
// so recipients can never be routed MORE than the track earned (no invented
// cents). At exactly/over 100 the uploader keeps nothing.
const scale = linkedPercent > 100 ? 100 / linkedPercent : 1
const exact = linked.map((sp) => ({
splitId: sp.id ?? null,
artistId: recipientKey(sp) as string,
exact: (amount * sp.percent * scale) / 100,
}))
let allocated = 0
const floors = exact.map((e) => {
const f = Math.floor(e.exact)
allocated += f
return { ...e, amount: f }
})
let remainder = Math.round((amount * Math.min(linkedPercent, 100)) / 100) - allocated
floors.sort((a, b) => b.exact - b.amount - (a.exact - a.amount))
for (const f of floors) {
if (remainder <= 0) break
f.amount += 1
remainder -= 1
}
let toOthers = 0
for (const f of floors) {
if (f.amount <= 0) continue
out.push({ splitId: f.splitId, recipientKey: f.artistId, cents: f.amount })
toOthers += f.amount
}
const uploaderShare = amount - toOthers
if (uploaderShare > 0) {
out.push({ splitId: null, recipientKey: uploaderArtistId, cents: uploaderShare })
}
return out
}
// Clamps capped splits to what is left on them and sends the overflow onward,
// to the split's revertsTo recipient or the uploader. Pure: the caller owns the
// running headroom, because a month's earnings for one track arrive as many
// small shares and the cap applies across all of them, not to each one.
//
// Returns the adjusted allocations plus what each capped split actually took,
// so the caller can decrement its headroom and, once the month settles, add it
// to paidToDateCents.
export function applyCaps(
allocations: SplitAllocation[],
caps: Map<string, CapState>,
uploaderArtistId: string,
): { allocations: SplitAllocation[]; takenBySplit: Map<string, number> } {
const taken = new Map<string, number>()
if (caps.size === 0) return { allocations, takenBySplit: taken }
const adjusted: SplitAllocation[] = []
const overflow = new Map<string, number>()
// Several split rows may share ONE CapState (the same payee on a track).
// What earlier rows took in this call counts against later ones here,
// before the caller decrements the shared state afterwards.
const takenFromState = new Map<CapState, number>()
for (const a of allocations) {
const cap = a.splitId ? caps.get(a.splitId) : undefined
if (!cap) {
adjusted.push(a)
continue
}
const available = cap.remaining - (takenFromState.get(cap) ?? 0)
const pay = Math.max(0, Math.min(a.cents, available))
if (pay > 0) takenFromState.set(cap, (takenFromState.get(cap) ?? 0) + pay)
const spill = a.cents - pay
if (pay > 0) {
adjusted.push({ ...a, cents: pay })
taken.set(a.splitId!, (taken.get(a.splitId!) ?? 0) + pay)
}
if (spill > 0) {
const to = cap.revertsTo || uploaderArtistId
overflow.set(to, (overflow.get(to) ?? 0) + spill)
}
}
for (const [recipientKey, cents] of overflow) {
const existing = adjusted.find((a) => a.splitId === null && a.recipientKey === recipientKey)
if (existing) existing.cents += cents
else adjusted.push({ splitId: null, recipientKey, cents })
}
return { allocations: adjusted, takenBySplit: taken }
}
// Back-compatible view: the same division, aggregated by recipient.
export function applyTrackSplits(
amount: number,
uploaderArtistId: string,
splits: SplitDefinition[],
): Map<string, number> {
const result = new Map<string, number>()
for (const a of allocateTrackSplits(amount, uploaderArtistId, splits)) {
result.set(a.recipientKey, (result.get(a.recipientKey) ?? 0) + a.cents)
}
return result
}
// The identity of a split's PAYEE, independent of the split row's id: a label,
// a linked artist, an email, or (last resort) a name. Cap history is keyed by
// this, so a payee removed and re-added keeps their cap instead of restarting.
export function splitPayeeKey(sp: {
labelId?: string | null
artistId?: string | null
email?: string | null
name?: string | null
}): string {
const norm = (v?: string | null) => (v ?? '').trim().toLowerCase()
if (sp.labelId) return `label:${sp.labelId}`
if (sp.artistId) return `artist:${sp.artistId}`
if (norm(sp.email)) return `email:${norm(sp.email)}`
return `name:${norm(sp.name)}`
}
// Apportions a matrix of integer cents so that every row sums to its row
// total and every column to its column total (both must sum to the same
// grand total). Used to spread each split recipient's share of a track's
// month across the country buckets it was earned from: the recipient's total
// is the one figure (split once, as the estimate does) and the bucket totals
// are conserved for tax sourcing. Largest remainder first, then any deficit
// left by the two-way constraint is settled on pairs that still need cents.
export function apportionMatrix(rowTotals: number[], colTotals: number[]): number[][] {
const total = rowTotals.reduce((s, x) => s + x, 0)
if (total !== colTotals.reduce((s, x) => s + x, 0)) throw new Error('apportionMatrix: row and column totals differ')
const cells = rowTotals.map((r) => colTotals.map((c) => (total > 0 ? (r * c) / total : 0)))
const out = cells.map((row) => row.map((x) => Math.floor(x)))
const rowNeed = rowTotals.map((r, i) => r - out[i].reduce((s, x) => s + x, 0))
const colNeed = colTotals.map((c, j) => c - out.reduce((s, row) => s + row[j], 0))
const frac: { i: number; j: number; f: number }[] = []
for (let i = 0; i < rowTotals.length; i++) for (let j = 0; j < colTotals.length; j++) frac.push({ i, j, f: cells[i][j] - out[i][j] })
frac.sort((a, b) => b.f - a.f || a.i - b.i || a.j - b.j)
for (const { i, j } of frac) {
if (rowNeed[i] > 0 && colNeed[j] > 0) { out[i][j] += 1; rowNeed[i] -= 1; colNeed[j] -= 1 }
}
// Whatever the fractional pass could not place (a row and a column that
// both still need cents but whose cell had a small fraction) is settled
// pairwise; the two deficit sums are always equal, so this terminates.
for (let i = 0; i < rowTotals.length; i++) {
while (rowNeed[i] > 0) {
const j = colNeed.findIndex((n) => n > 0)
if (j < 0) throw new Error('apportionMatrix: unbalanced deficits')
out[i][j] += 1; rowNeed[i] -= 1; colNeed[j] -= 1
}
}
return out
}
Your monthly receipt has an “Export JSON” option with your own numbers in this exact shape, so you can re-run the math yourself.