TEASEDocs
ProductsClubConcepts

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_kindGroup
subscriptionsubscription
subscription_prolongrebill
message, chat_message, postppv
tipstips
referral_sharereferral
anything elseother

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 source must 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 + pending equals the account total for the window.
  • The static-link funnel (segment_rev) proves Σ(segments) + unattributed equals 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_fee

gross_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_pricecore.ppv.pricing.suggest_ppv_price
Inputfan's lifetime net + their own avg PPVfan's lifetime spend + country
Tiersnew / 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)
Geographynonecountry purchasing-power (PPP) multiplier
Used byGET /api/admin/chatters/fan-pricing — see Get a suggested PPV pricethe 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

On this page