TEASEDocs
ProductsClubConcepts

Fan profile & signals

How Club scores a fan's RFM/lifecycle, resolves who sent them, and layers a second self-reported "discovery" signal on top of it — the one place every fan screen gets these labels from.

Every fan-facing screen — the fan card, the chat context panel, the Fans grid, the source leaderboard's drills — renders the same small set of computed labels. This page is the mental model for where those labels come from. The how-to pages under How-to cover the screens that show them.

RFM, lifecycle, and value tier

core/fan/profile.py's compute_fan_profile is a pure function: no DB, no network, no clock of its own — a caller hands it a fan's OnlyFans-native subscription state plus their transaction rows, and it returns the same dict every time for the same inputs. It computes, all at once:

FieldWhat it is
lifecycleOne of New, Active, Regular, Returned, Leaving, Lapsed — see below.
value_tier / value_labelfree / small / medium / large / whale, plus a real dollar label (e.g. 🐳 $610).
tenure_bucket / tenure_daysDays since first subscribe, bucketed freshog.
rfm_r / rfm_f / rfm_m / rfm_segmentRecency/Frequency/Monetary, each 1–5, folded into one of 7 named segments.
recency_tokenThe one short chip a card shows — "3d" for a New fan's tenure, "dormant 8mo" for a Returned fan's lapse, "−5d" counting down to a Leaving fan's expiry.

Lifecycle is a strict priority order, so a fan is never in two states at once: Lapsed (sub expired) beats Leaving beats Returned (came back after a gap) beats Regular (≥90 days tenure, ≥2 paid cycles) beats New (single cycle, under 30 days) — everything else is Active. Leaving itself fires on either of two conditions, not just one: the sub ends within 7 days with auto-renew off, or the fan has already set a cancellation date at all — even a fan who cancels on day one of a 30-day cycle shows as Leaving immediately, not just in the final week.

RFM segments resolve in this order, best fan wins first:

SegmentRule
Championrecency ≥4 and money ≥4
Big spendermoney ≥4
Loyalfrequency ≥4 and recency ≥3
Promisingrecency ≥4 and frequency ≤2
At riskrecency ≤2 and money ≥3
Dormantrecency ≤2, money ≤2, has paid at least once
Losteverything else

Thresholds are owner-configurable, not hardcoded

The dollar bands behind value_tier and the RFM money axis come from owner_config (value_tier_cents, rfm_m_cents), resolved once per request and passed in — the classifier itself never reads settings. Two accounts can see a "whale" at different dollar amounts.

Dormancy (return_recency, lapse_days) is only computed for a Returned fan, and it degrades honestly: a real gap between transactions wins; with no transaction before the return, tenure is used as a flagged estimate (lapse_estimated: true); with neither, the field is left blank rather than invented.

build_fan_profiles is the DB-aware wrapper — one pass over of_transactions + of_fans, optionally restricted to a fan-id set — that most callers actually use.

Where a fan's source comes from

core/fan/classify.py's resolve_fan_source walks a fixed precedence and stops at the first match, mirroring the leaderboard's own resolution:

  1. cid first-touch — a proven click through one of your domains.
  2. stored link (fan_source.kind == 'link') — a SourceLink rule already matched.
  3. campaign — the OF tracking link the fan came through resolved to a known campaign; folds in an additional flag-gated D3b code lookup when OF_D3B_FORWARD=1.
  4. stored llm (fan_source.kind == 'llm') — a DM-scan guess, gap-filled below every OF-native signal.
  5. stored pending — traffic is known, the source isn't yet.
  6. organic — the honest remainder.

Two implementations, not one

classify.py's resolve_fan_source is its own pure implementation, built to "mirror" the money leaderboard's ladder — its own docstring says so. The fan card itself (GET /fan/{fan_id}) and the money leaderboard resolve through a different function, source_leaderboard.resolve_fan_attribution. Both are meant to agree; this page describes what classify.py computes, not a proof that the two never diverge. For the leaderboard's own, longer ladder (it adds a checking state ahead of organic), see Source attribution.

A resolved fan's klass is organic or ours — a pending fan is klass: 'ours' with the label Pending attribution, not folded into organic.

Exact vs. approximate origin

core/fan/origin.py renders a resolved source into the card's {kind, label, source, domain, country, precision, link_ref} contract. Its one hard rule: precision: 'exact' only for kind: 'cid' — a proven per-click domain, source, and country. Every other kind (link, campaign, llm, pending, organic) is 'approx', and country is left blank for all of them except cid — a link or a DM guess carries no proven per-fan geography. Never render a calculation as if it were a click.

The origin stamp: frozen once, not re-guessed

core/fan/origin_stamp.py freezes a fan's origin into FanOriginStamp the first time it resolves to a hard kind (cid, link, or campaign — never llm, pending, or organic, which would lock in a weak signal and block a later real determination). Once frozen:

  • A soft resolution (llm/pending/organic) never touches an existing stamp.
  • An exact stamp (cid) is never overwritten by an approx calculation.
  • A later hard determination can still refresh a diverged stamp — but only when the source identity actually moved, or when it upgrades approx → exact; a bare kind/link_ref flip from resolver churn is not a refresh.
  • Country is carried forward independently: an empty incoming country never erases a country the stamp already had.

GET /fan/{fan_id} prefers the frozen stamp when one exists, and only falls back to a live resolution for an unstamped fan.

The discovery axis: what the fan said about themselves

core/fan/discovery.py is a deliberately second, independent signal — not a replacement for fan_source. A fan can subscribe through your Instagram link and mention Reddit in chat; both facts are true, and the card shows both. Discovery is built straight from FanAttribution (status == 'scanned'), and is only surfaced on the card once its confidence clears 0.8 — a weaker self-report stays in the data but off the card, because a low-confidence signal about what a person said is a strong claim to attach a name to.

Two different confidence thresholds — don't conflate them

Discovery's display threshold (0.8) is unrelated to the DM-scan's own gap-fill threshold (0.6, below) — one gates whether a chat-derived hint is shown at all, the other gates whether that same scan is allowed to write the fan's canonical source.

The DM-scan attribution engine

core/fan/attribution.py is the product half of enriching an organic fan: an LLM reads a fan's already-captured DM history in two focused turns (source/country, then a 13-field dossier) and writes FanAttribution. The precedence discipline is the important part:

  • The scan always writes FanAttribution (tags and evidence land regardless of confidence).
  • It only gap-fills the canonical FanSource when confidence is ≥ 0.6 and the fan has no hard signal yet (no row, or kind in pending/organic).
  • A cid or link fan is never touched — hard signals always win, first-seen-wins.

The scan makes zero OnlyFans/OFAPI/engine network calls of its own — it only reads a locally captured feed file and runs one BYO-key LLM call over already-captured text. The operational side (starting a sweep, reading progress, what a stuck job means) is Run a DM-history scan.

Segments, audience, and churn risk

A dynamic fan segment (core/fan/segments.py) is a saved filters_json rule recomputed at read time — members are never materialized. Its filter set covers subscription state, spend windows, purchase recency, chargeback count, and source/country — money fields import the same _REVENUE_STATUS allow-list the fan card uses, so a segment and a fan's own card can never disagree about what counts as spend.

The audience resolver (core/audience_service.AudienceSpec / resolve_audience) is the one engine behind both broadcast targeting and churn risk (core/fan/insights.churn_risk — high lifetime spenders inactive ≥N days, including fans who never wrote in at all, ranked by spend). A single recipient cap applies to both selection modes, and it's measured on what would actually be sent, not on how many fans matched before the cap.

Churn risk has no dashboard screen today

churn_risk is only reachable as an AI-assistant (MCP) tool right now — no dashboard screen in the fans area calls it. If you need it, read it through the MCP tool rather than expecting a panel for it in the product yet.

Fan economics

core/fan/metrics.py computes ARPPU, APC, APV, and ARPNU from money rows plus a cohort first-seen map — the same functions behind revenue.fans / revenue.fans_account on the attribution leaderboard. A $0 trial subscriber counts as a new fan in the cohort denominator from the moment they subscribe, before they've paid a cent. The full formulas and response shape are documented once, in Source attribution — this page only notes that the fans area reads the same numbers, not a second copy of them.

Full lifetime value by source

core/full_ltv.py attributes a fan's entire lifetime net — not just new-traffic revenue — to their acquisition source, with the same invariant as the leaderboard: attributed + organic always equals the total, and a fan with no acquisition row is organic, never retro-guessed. It's a pure function consumed by the source leaderboard, not by any screen in this area directly.

What's next

On this page