Skip to content

Models: revenue over a range of cutoffs, and a record of who typed each figure

Models opens on a new Overview that totals revenue between any two cutoffs, the roster says which platforms each model is on, and every earnings figure now carries who entered it and when. Finance gains the CT Quarterly Bonus under Approvals, and CT gets a Probationary bucket per platform.

Revenue between any two cutoffs

Every earnings screen used to answer "this model, this cutoff". A quarter meant opening each period and adding the numbers up by hand.

Opening Models now lands on Overview. Pick a Year, then a From and a To cutoff — the range is continuous, so it includes every cutoff between the two.

  • Models totals the range per platform and per model, sortable, with Export CSV.
  • Compare charts revenue per cutoff, each platform's share, and the models over the range.
  • Four presets — This month, Last month, This quarter, Last 3 months. A preset with no complete cutoff inside it is greyed out rather than doing nothing.
  • The range lives in the address bar, so a view is a link you can send someone.

The roster moved to its own Roster tab alongside it. There is no combined gross figure: only OnlyFans reports one, so gross is published per platform and the headline total is net.

Guide: Overview — revenue across a range of cutoffs

The CT Quarterly Bonus, under Approvals

Finance → Approvals has a CT Quarterly Bonus tab. Each model earns 0.3% of its own quarter Net OF, divided over only the days that model had somebody on it, and each day's rate is shared between the people who worked it.

  • Prepare the quarter first: it computes and freezes the figures onto a run, so what you approve is what gets paid however many cutoffs later that happens.
  • Three tiles tell the story — Pools, Payable, and Unallocated for a model that earned but was never staffed, listed by name rather than quietly shared out.
  • The decision stays available on every open cutoff. Approving pays it on the earliest open cutoff from the quarter's own last cutoff onward, and the screen says which one — including when an earlier cutoff was skipped because its payslips are already final.
  • Somebody who has since left still gets paid, on a payout-only row that carries the bonus and no salary.

Guide: The CT Quarterly Bonus

Who typed this number, and when

A model's revenue is entered three ways, and none of them kept a history. A SextPanther day held one name that the next person's save overwrote; a manual income row kept its original author's name through everyone else's edits; a cleared day took its figures and its author with it.

Every earnings tab on a model now has an Audit trail. Each entry is one line — who did what, and when — and opens for the day it covers and the old and new figures.

  • SextPanther records one entry per day you actually changed. Untouched days stay silent, so a single correction reads as a single entry.
  • Income records adding, editing and removing a row, and a removed row keeps the figures it held.
  • OnlyFans records the fetch instead, because nobody types those figures: each run, plus your name when you press Refetch now, Lock or Unlock. On that tab it shares an Activity section with the fetch log.
  • Each tab also states what its figures are for: OnlyFans and SextPanther are platform-level earnings that CT commission is paid on, everything else is revenue recorded for reporting. SextPanther feeds commission but not the CT daily or quarterly bonus pools, which surprises people.

Figures that existed before today show as recorded before the trail started — their author was never stored anywhere, so it cannot be recovered.

Guide: Who entered each figure

Who is on which platform

The roster's Platforms column badges every platform a model currently has an active account on, so the answer is visible without opening anybody. A dash means none.

The dropdown beside the search box narrows the roster to one platform, and lists every platform rather than only the ones in use — "nobody is on BongaCams" is an answer worth being able to get.

Guide: Who is on which platform

A Probationary bucket per platform (CT managers)

A probationary chatter on SextPanther had nowhere to go: there was one Probationary team per cutoff and it was implicitly OnlyFans.

There is now one per platform — Probationary for OnlyFans and SP Probationary for SextPanther — named the way model teams are. Put a chatter on the bucket for the platform they are actually learning on. Neither earns commission or takes a share of the CT Daily Bonus pool, exactly as before.

On a cutoff set up before this, press Sync teams once and the SextPanther bucket appears; the OnlyFans one and its rows are untouched.

Guide: Probationary

Also

  • Day-off shifts can be added from "+ range" on someone already on a team. Until now only the team-level Add row form could set one, so a day-off range added from the row booked as a regular row and took a CT Daily Bonus slot it shouldn't have — which moved everyone else's share on that model. An existing range can also be flipped between regular and day-off instead of being removed and re-added. Guide: Day-off shifts
  • One calendar for picking a range on the Model Mapping board, in both the Add row form and "+ range", with days outside the cutoff greyed out. A one-day shift is a single click.

Public API (for integrations)

The weekly performance figures now need the income scope as well. On /weeks/ and /models/{id}/weeks/, the revenue and model-cut fields — cb_revenue_usd, of_revenue_usd, total_revenue_usd, model_cut_usd, customs_total_usd, avg_custom_price_usd, rate_per_cb_hour — are returned only to a key that holds Model performance and Model income. The request still succeeds either way, and every activity metric (hours, viewers, followers, tokens, subs, customs count, the content KPIs) is unchanged. The keys are absent rather than null, so a withheld figure can't be mistaken for a zero one.

No existing key loses a figure it reads today. Every key already holding Model performance is granted Model income as part of this release. That also widens those keys to the /income/ endpoints, which they could not reach before — narrow any you'd rather keep tight from Info → API keys.

The reference lives inside HRIS, at Profile menu → HRIS API, and needs an HRIS login — a partner who holds a key may not have one.

Technical changes

For developers and whoever deploys. None of this needs a guide.

Data model

  • agency_models.ModelEarningsAudit — new table. Snapshots its subject (model, platform slug, account, cutoff, subject_kind + subject_id, period, action, actor + actor_kind, changes, metadata) so an entry outlives the row it describes. A SextPanther day is hard-deleted and an income row is soft-deleted with no deleted_by; a JSON column on either would die with it.
  • finance.CtQuarterlyBonusRun / CtQuarterlyBonus / CtQuarterlyBonusConfig — new. The run carries natural_cutoff (the quarter's last cutoff, the floor under targeting) and target_cutoff (where it actually paid). PayrollLineItem.source_ct_quarterly_bonus FK, and a new line-item category.
  • finance.EmployeePayroll.payout_only — a row that carries a bonus and no salary, for a leaver. The resync's hard-delete of offboarded drafts skips it.
  • management.CoverageTeam.platform — registry slug, SYSTEM teams only. Unique key (cutoff, system_key) → (cutoff, system_key, platform). Model teams keep deriving platform from account.platform.
  • agency_models platform registry serializer gains derived commission, commission_pct and bonus_pool, read from the adapters.

Behaviour

  • The earnings audit writer swallows its own exceptions: a failed audit must not fail the save that triggered it.
  • PublicWeekSerializer.to_representation drops its money keys for a token without income, and fails closed — anything that isn't a live ApiToken reads as no. The legacy /api/public/model-performance/v1/ base narrows identically.
  • freeze_without_fetch now records its lock; the queued path already did.
  • setup_teams_for_cutoff creates one Probationary per platforms.ct_capable_platforms(). Moving Support stays single, stamped onlyfans.

Deploy

Six migrations, all reversible except where noted:

  • agency_models.0038_modelearningsaudit — the new table.
  • agency_models.0039_backfill_earnings_audit — one before_audit entry per existing SextPanther day, income row and (account, cutoff) with day stats, dated from the row rather than from migration time. Data volume scales with existing rows.
  • finance.0133_employeepayroll_payout_only_and_more — the quarterly bonus tables, payout_only, the line-item category and FK.
  • finance.0134_seed_ct_quarterly_shared_roles — seeds the config's shared roles.
  • management.0039_coverageteam_platform — AddField, a RunPython stamping every existing SYSTEM team onlyfans, then the constraint swap. Its reverse refuses rather than deletes: re-adding the old unique key dies on the duplicate inside the transaction, leaving the migration applied and every row intact. Without the stamp the next sync would create a second Probationary and strand its roster.
  • integrations.0003_income_with_model_performance — grants income to every ApiToken already holding model_performance, revoked and soft-deleted rows included (scopes is the record of what a credential was allowed to read). Reverse is a no-op on data, because un-granting would strip income from the keys that hold it on purpose. Worth reading the token list after this runs: the keys it changed are exactly the integrations that were reading week revenue without the income scope.

No env vars, no new workers, no one-off commands.

Tests

  • New: agency_models/tests/test_earnings_audit.py (17), integrations/tests/test_public_week_money_scope.py (14), the CT quarterly bonus suite, plus 17 on the Models Overview range and 10 on the roster filter.
  • integrations/tests 293 passing; agency_models/ + integrations/ 781 on a fresh DB at the time of the roster merge.