Money & Revenue
The rules every dollar in Club obeys — net cents, what counts as a purchase, money-kind groups, the fail-closed money mask, and the two revenue sources (OnlyFans/Fansly transactions and Tribute).
Every revenue number in Club — the dashboard, a fan's card, a payout, a forecast — is built on a small set of shared rules. They live in a handful of core modules that the rest of the product imports rather than re-implements, so a subscription counted on the dashboard is the same subscription counted on a fan's card.
Net cents, and one function that turns money into them
All money is stored and summed as integer net cents — never floats, never gross-only. The one
place that turns a vendor's raw amount into cents is to_cents_exact: it takes whatever OnlyFans
sends (a number, a string, None), rounds half-up to the cent, and never raises — garbage in
becomes 0, not a crash.
Whether a transaction counts as revenue or as a reversal is also centralized: is_revenue and
is_reversal read a transaction's vendor status against two canonical status sets. They are not
opposites of each other — a status can be neither (unrecognized), so a naive not is_revenue
would wrongly count an unknown status as a reversal.
Don't write a second rounding implementation
The canon module explicitly refuses to duplicate this logic — a second $ → cents conversion
written elsewhere WILL eventually round differently and put two different totals in two
reports for the same window. Every money read in Club goes through the one canon module.
What counts as a "paid purchase"
A transaction row is a paid purchase only when net_cents > 0 — never by counting rows, and
never by trusting a kind that merely looks like a sale. A $0 free-trial subscribe or an
engagement row is a real event, but it is not a purchase until it produces positive net money.
is_paid_purchase optionally also checks the row's kind against a fixed set of paid kinds
(message, chat_message, post, subscription, subscription_prolong, tips) when a kind is
supplied. Note that referral_share — money paid out for a referral — is not in that set even
though it can carry a positive net_cents; a caller that passes kind will not count a referral
payout as a "purchase," even though the plain net_cents > 0 check (no kind) would.
Money-kind groups
core/money/canon.py rolls every of_transactions.tx_kind value up into one of six named
groups:
tx_kind | Group |
|---|---|
subscription | subscription |
subscription_prolong | rebill |
message, chat_message, post | ppv |
tips | tips |
referral_share | referral |
| anything else | other |
This is not the same grouping the Money dashboard uses
The dashboard's by-type breakdown is computed by compute_source_leaderboard, which groups
kinds through a separate dict maintained in core/source_leaderboard/shared.py — only five
buckets (subscription/rebill/ppv/tips/other), with no dedicated referral bucket at all.
referral_share money falls into other there, not into a referral line. If you're
explaining what a reader sees on the dashboard screen, use the leaderboard's five buckets —
see Read the money dashboard — not this
table.
Refund is a status, not a kind
OnlyFans represents a reversed payment as the same transaction with status='undo' — there is
no separate refund kind. The chargebacks
and money-breakdown "refund" filter both
read the status set, not a kind.
The money mask — fail-closed by design
Team Access can grant a member every tab except money (show_money=0). Enforcement is not a UI
toggle: every response under /api/admin/* passes through an ASGI middleware that, for a
money-hidden principal, rewrites the body.
- JSON responses are parsed and passed through
mask_money_payload; the masked JSON replaces the original body. - CSV and event-stream (SSE) responses cannot be masked line-by-line, so they are denied
outright — the whole export comes back as a
403. - Any other body (images, thumbnails) passes through unmasked — it carries no structured money.
Fail-closed means an error, never a leak
If masking itself throws for any reason, the middleware does not fall back to sending the
original body — it replaces the WHOLE response with a generic 500 mask_error. A masking bug
degrades to "the screen doesn't load," never to "the screen shows the real numbers."
Two revenue sources: platform transactions and Tribute
Most of Club's money comes from of_transactions — the ledger of OnlyFans/Fansly payments the
engine ingests. Tribute (shown in the panel as Private) is a second, independent, self-serve money source: Telegram
subscriptions, donations, and digital-product sales, connected per-owner with the creator's own
Tribute API key.
Tribute's truth is a signed webhook, not a poll. The REST client's list_subscribers and
list_revenue are deliberate NotImplementedError stubs — Tribute publishes no stable schema to
poll them against, and the module refuses to guess one. Every subscriber count and revenue figure
you see for Tribute comes from TributeEvent rows recorded off the webhook.
Each creator's webhook is per-owner multi-tenant by token: POST /api/tg/tribute/webhook/{token} resolves the token to one TributeConnection, verifies the
trbt-signature HMAC with that owner's own API key, and records the event under that owner's
owner_id — so two creators' Tribute events can never cross accounts. A legacy, token-less route
still exists for the platform owner's own original setup.
Traffer payouts: flat or percent, one per source
A traffer_payout row is the owner's arrangement with the contractor who supplies one traffic
source's clicks: either a flat cost (fixed_cents) or a percent of that source's revenue
(percent_bps, basis points). Two rules keep the arrangement unambiguous:
- The
sourcemust be a real registered domain — the sentinels__organic__and__pending__are rejected, because you cannot pay a contractor for traffic that carries no attribution. - One active arrangement per source. A create call for a source that already has one returns
409; edit the existing row instead.
See Set up a traffer payout for the full flow.
The revenue → traffer-cut → net split math itself (traffer_econ) is a dependency owned by the
Link product — this area documents the payout agreement, not that computation.
Revenue forecast: per-stream, clamped, plus MRR
The 30-day forecast never extrapolates the whole account from one blended trend. It splits the
last 30 days vs. the prior 30 days into five streams (subscription, rebill, ppv, tips,
other), computes each stream's own trend, and clamps every stream's trend to ±50% before
projecting — a single volatile stream can't blow up the account-wide number.
Recurring-subscription health (MRR) is computed separately, from live OnlyFans-native subscription
state (OfFan), not from the transaction trend:
- MRR — active subscriptions with auto-renew on.
- At-risk MRR — subscriptions that cancelled auto-renew but haven't expired yet.
- Expiring-30d MRR — subscriptions (any renewal state) expiring within 30 days.
- Trial potential — free-trial subscribers with auto-renew on are tracked as a separate potential-MRR line, deliberately not added to MRR — they haven't paid yet.
See Check the revenue forecast for the full payload and how "unknown" subscriptions (not yet confirmed by a feed sweep) are kept apart from both "active" and "expired."
Reconciliation invariants — money always adds up
Two different parts of Club independently prove that their money splits sum to the whole, each
with its own reconciliation.balanced flag:
- The source leaderboard (dashboard, attribution) proves
Σ(sources) + organic + pendingequals the account total for the window. - The static-link funnel (
segment_rev) provesΣ(segments) + unattributedequals the total for its own segment/arm/country breakdown.
Neither hides a shortfall — if a window's numbers don't balance, balanced: false says so plainly
rather than silently rounding or dropping a bucket.
Chargebacks are keyed by when we saw them, not when they happened
A refund enters the recent-chargebacks list on the moment Club's poller first observed a
payment flip to status='undo' — not the payment's original date, and not whenever OnlyFans
actually reversed it (OnlyFans doesn't report that time). A chargeback that happened before this
tracking existed is deliberately excluded; see
the troubleshooting page
for what this looks like in practice.
Fansly's net formula is different from OnlyFans
Fansly's own export data is inconsistent about whether its 20% platform fee has already been deducted from a given figure, so Club's Fansly net-revenue formula is a mix, not a single subtraction:
fansly_net = round(0.8 × gross_with_platform_fee) + net_without_feegross_with_platform_fee is the portion of the window's revenue Fansly reports before its cut;
net_without_fee is the portion Fansly already reports net. The 80% creator share
(FANSLY_CREATOR_SHARE = 0.8) is applied only to the first term.
Two PPV price suggesters — do not conflate them
Club has two unrelated functions named suggest_ppv_price, in different modules, used by
different screens:
core.pricing.suggest_ppv_price | core.ppv.pricing.suggest_ppv_price | |
|---|---|---|
| Input | fan's lifetime net + their own avg PPV | fan's lifetime spend + country |
| Tiers | new / small / medium / large / whale (default bands: under $20 / under $100 / under $500 / whale) | new / low / mid / vip / whale (default bands: under $50 / under $500 / under $2000 / whale) |
| Geography | none | country purchasing-power (PPP) multiplier |
| Used by | GET /api/admin/chatters/fan-pricing — see Get a suggested PPV price | the inbox composer's own advisory-price read (a different, uninvestigated-here endpoint) |
Both clamp to OnlyFans' own PPV bounds ($3–$200). This page and its how-to only document the
fan-tier suggester (core.pricing) — treat the other as a separate mechanism, not a variant of
this one.
What's next
- See where today's revenue is coming from — the Money dashboard built on top of these rules.
- Set up a traffer payout
- Connect Tribute for Telegram revenue
Campaigns & promotions concepts
The mental model behind broadcasts, automation, smart links, the free pool, native OF promos, and referrals — and the traps between look-alike names.
Source attribution
How Club decides where a paying fan came from — the priority ladder, the four money buckets, and what the reconciliation numbers actually prove.