Skip to content

Two accounts, every creator, and a bonus that stops paying twice

A creator running two OnlyFans accounts is no longer a thing HRIS refuses. The routing lookup shows the whole roster on one page instead of the active third of it. And the chatting team's quarterly bonus now takes off what the team was already paid during the quarter.

A creator can run two OnlyFans accounts at once

Some creators do, and both accounts earn. HRIS used to allow exactly one and quietly answer questions about "the" account.

On a creator's record, Platform accounts now lists both. One carries a Primary star: its handle, display name and avatar are the ones the creator is known by everywhere else in HRIS.

  • Both accounts are fetched and locked on their own. The OF tab has an account picker above the cutoff selector, so one account can be locked for a cutoff while the other is still open — and the creator's income totals add the two together.
  • Add the second one the way you add any platform: pick OnlyFans in the Platform dropdown, then Choose the account.
  • Make primary moves the star. The dialog says what that does before it happens — it is a rename, it reaches every screen that shows her name, and no earnings move. The old handle is kept as an alias.
  • Detach only detaches the account you pressed it on. Detaching the primary promotes the oldest account that is left rather than leaving her unnamed.

Nothing changes for the great majority of creators with one account.

Guide: Platform accounts

The routing lookup shows every creator, and edits in one pass

Two changes to Info → Integrations → Routing lookup, both aimed at the only job the page has — finding gaps.

  • Paused and offboarded creators are on it now. They were filtered out, so you could not check a paused creator's routing from this page, or clear it when she left. Searching her name returned nothing. On the dev roster that was hiding 19 of 45.
  • A status column says which is which, so an offboarded row no longer reads identically to a live one. A status filter narrows the page when you want only one kind.
  • No more paging. The whole matching set comes back at once — a gap on page three was a gap nobody found.
  • One edit mode for the table, not one per row. Edit all opens every row at once; fix what you need across the page and save in one pass. A row that fails to save says so on the row, and the rest still save.

Guide: The lookup page

The chatting team's quarterly bonus stops paying twice on the same money

The quarterly bonus pool was 0.3% of a creator's whole quarter Net OF. But the chatting team is already paid out of that same revenue during the quarter — the daily bonus and the commissions both come from it — so a share of the full figure paid twice on one dollar.

Under Approvals → CT Quarterly Bonus, the per-model table now shows the working:

  • What was already paid, broken out — daily bonus, commission, cover and assist, and team-level support — then the Base the pool is taken from. The pool is 0.3% of that base, not of Net OF.
  • Most creators see only the commission come off. The daily bonus is tiered and reaches very few — a creator under $10,000 in a half-month earns none — so the typical base is about 3% below Net OF while the biggest earners are nearer 6%.
  • A creator whose payouts came to more than her revenue shows a base of 0.00 · floored and earns no pool. It is never negative.

Finance now accepts or refuses each member. The eligibility gates — sales volume and attendance — are decided by CT management outside HRIS, and this is where the outcome is recorded.

  • Accept or Refuse on each row, with a reason saved beside a refusal.
  • Approve stays disabled until every member has been ruled on, and names who is left. There is no later step that would decide for them.
  • A refused member is paid nothing and keeps their calculated figure on the record. That money is not shared out among the others — it stays with the agency.

Guide: The CT Quarterly Bonus

Also

  • Bulk lock and bulk refetch reach paused creators again, so a cutoff can still be sealed for someone who is no longer active.
  • The attach row on a creator's record says which job it is doing, instead of leaving you to guess whether it was attaching or replacing.
  • A session's sub-count detail keeps its shape for a creator with two accounts, listing them side by side rather than collapsing them.

Public API (for integrations)

The routing lookup reports and filters on a creator's status.

"local_status": "offboarded"
  • local_status is additive on every result, so an integration already reading the response needs no change.
  • Creators at every status resolve by default — active, inactive and offboarded alike. A creator who resolves to nothing is the silent failure this endpoint was built to replace, so narrowing is never the default.
  • local_status as a filter is opt-in and narrows the search when a miss is genuinely the answer you want. An unrecognised value is a 400, not an empty result: an automation cannot see a typo the way a person looking at a screen can, and would read the silence as "she is not on the roster".
  • A creator with two OnlyFans accounts is reachable by either address — the of_email filter is unchanged and matches any of her accounts.

The reference is in the app at Profile menu → HRIS API, which needs an HRIS account — a partner who only has a key cannot read it.

Guide: What an integration calls

Technical changes

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

Data model

  • ModelPlatformAccount.is_primary. uniq_model_account_per_platform is narrowed to exclude OnlyFans, and a new partial uniq_model_primary_account_per_platform enforces one primary per (model, platform) among undeleted rows. Every other platform is still one account per model — nothing downstream is built for a second, since their earnings are hand-entered against the platform.
  • Model.of_account is removed, not left returning the first active account: that became a silent wrong answer the moment there could be two. Replaced by of_accounts, sole_of_account and primary_of_account.
  • CtQuarterlyBonusDecision(run, employee, decision, note, decided_by, decided_at), unique on (run, employee). Its own table because CtQuarterlyBonus rows are only created at approval, and CtQuarterlyBonusRun.breakdown is rewritten wholesale by prepare_run. Absence of a row is "undecided", so a member appearing in a later recompute starts unruled rather than defaulting to paid.
  • CtQuarterlyBonus.decision, defaulting to accepted — every row that already existed was paid, so no RunPython backfill was needed.

Behaviour

  • The OF fetch unit is the account. (model, cutoff) → (account, cutoff) through the services, the Celery signatures, the global queue and the endpoints above them. The earnings tables were already account-keyed, so this is the layer above catching up.
  • Per-model endpoints take an optional account. Reads fall back to the primary; writes refuse with a 409 naming both accounts — a write that guesses fetches or seals half a creator's earnings and nothing downstream can tell afterwards.
  • POST /api/models/<pk>/accounts/<account_pk>/make-primary/ switches the primary, applies immediately and keeps the old handle as an alias.
  • The internal lookup returns of_emails (a list) and searches across all of them; LookupPagination is gone and the response keeps count and results so the one consumer reads the same two keys. A page_size still being sent is ignored rather than silently truncating.
  • /api/public/v1/lookup/model/ gains local_status in the response and as an optional exact filter; an unknown value is a 400.
  • finance/ct_quarterly_bonus/deductions.py answers what CT was already paid, per model and by type. Daily bonus is read from CtDailyBonus.breakdown (verified against Sum(CtDailyBonus.total)); regular commission is recomputed model-level as day_net × adapter.regular_pct before the roster split, since PayrollLineItem carries no model FK; cover/assist is attributed through the personal sales tracker; pooled moving support is apportioned by share of Net OF and reported separately as an allocation. Scoped to platforms.daily_bonus_platforms() — the same set _net_of_by_model reads revenue through — rather than a hardcoded ONLYFANS.
  • The quarterly base is clamped at zero and the row carries clamped. approve_run raises while any member with a non-zero total has no decision; refused members get a CtQuarterlyBonus row and no PayrollLineItem, and a refused member's finalized payslip no longer holds the run. The validation check is unchanged — it already held Σ calculated against Σ staffed pools, and refusal zeroes the payment rather than the calculation.
  • backend/schema.yaml is deleted: a 2,514-line April snapshot of 24 endpoints in an app now serving 268 routes, referenced by nothing. Both real schemas are generated at request time.

Deploy

  • Two migrations. agency_models.0043_multi_onlyfans_accounts adds is_primary, backfills every active OnlyFans account as primary, then adds the partial constraint — so the backfill runs before anything can violate it. finance.0135_ctquarterlybonus_decision_ctquarterlybonusdecision adds the decision table and the ledger column.
  • No new settings, env vars or permission surfaces.
  • Run pnpm generate: the models, finance and public schemas all moved.

Tests

  • The SextPanther paste tests created a CutoffPeriod that finance.0002_seed_payroll_periods already seeds, which only showed up under --create-db. Fixed with the hard-delete pattern agency_models already uses.