Skip to content

Models: the OnlyFans transaction summary, and an id integrations can join on

Models → Overview gains a third tab that reconciles OnlyFans transactions across every model in the range — on screen, as a CSV and as a branded PDF. For integrations, each OnlyFans account in the public roster now carries the platform's own id for it.

The quarterly OnlyFans reconciliation, without the spreadsheet

Building the quarterly transaction summary meant exporting transactions out of OF Manager, deduplicating them, pivoting them four ways and pasting the result into a document. Every figure in that document was already in HRIS.

Models → Overview → OnlyFans does the pivoting, over the cutoff range already picked at the top of the screen, across every model.

  • Headline tiles for transactions, gross, fee, net, VAT, tax and media tax, with the settled / pending / refunded split beside them.
  • By period, switching between cutoffs and months.
  • By model and by sales type, each with count, gross, net and share of gross.
  • Export → CSV puts all four blocks in one file. Export → PDF renders the branded report, in the same brand as a payslip.

It will not match the Net revenue figure on the Models tab, and the tab says so above the numbers. The two are counted from different places on purpose: transactions are the audit trail, and every other earnings figure in HRIS comes from the daily rollup. Refunds also count into gross here, because an OnlyFans refund carries a positive amount and is marked by its status rather than its sign — the status split is what separates them. Read this tab to reconcile against OnlyFans; read the Models tab for what the agency earned.

Guide: OnlyFans

Also

  • The per-model table on Models → Overview → Models sits on a white surface again. It had no background of its own, so the page showed through it and the table read as faintly grey.

Public API (for integrations)

Each account in the model roster now carries platform_user_id — the platform's own id for that account, which for OnlyFans is its numeric profile.id.

GET /api/public/v1/models/
  accounts[] → { platform, platform_name, handle, platform_user_id, is_active }

It is an integer. It is null for a platform with no ingest — everything but OnlyFans today — and for an OnlyFans account linked before the id was captured. The key is always present, so null never has to be told apart from a field that isn't there.

Why it matters. /income/ has always grouped by day (?group_by=day), but every row is keyed on the HRIS model id. Joining those rows to your own OnlyFans records meant matching on handle — the OnlyFans username — which changes when a model renames, and silently re-points the join at whoever took the name. platform_user_id doesn't change.

Reversed later the same day

This post originally said the account's other upstream id — the one our ingest vendor files it under — was "still not published and won't be". That is no longer true: it is published as aruna_of_api_id. See the follow-up post for which id to join on.

The reference is in the app, at Profile menu → HRIS API. It is generated from the API itself, so it already shows the new field. Reading it needs an HRIS account — a partner who holds only a key may not have one.

Guide: The API reference

Technical changes

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

Data model

  • No new tables, columns or constraints. platform_user_id reads the existing ModelPlatformAccount.external_user_id, populated by agency_models/services/of_link.py; the transaction summary aggregates ModelTransaction, which the nightly ingest already writes.

Behaviour

  • Two new reads under agency_models: GET /api/models/overview/of-transactions/ and GET /api/models/overview/of-transactions.pdf/ (?group=cutoff|month). Both resolve their range through the same _overview_range helper as /overview/, so the tabs cannot end up reporting different periods. Five SQL GROUP BYs, on an endpoint of their own rather than inside /overview/, so the default tab doesn't pay for them.
  • Months truncate in UTC, not TIME_ZONE. The column is created_at_utc, and Manila truncation would walk a 31st-at-23:00 row into the next month.
  • Percentages are served at four decimal places (max_digits=9, decimal_places=4) and rounded once, at the client. At two, a real share rounded to 0.00 and the renderer could no longer tell it from a model that earned nothing; those rows now read <0.1%.
  • PublicModelAccountSerializer gains platform_user_id — integer, nullable, source="external_user_id". The public schema marks it required and nullable. The app schema is untouched, because preprocess_exclude_non_api keeps /api/public/ out of it, so no client regeneration is needed.
  • core/pdf.py now holds the brand palette, logo and section band, moved out of finance/services/invoice_pdf.py. Mechanical — same values, no PDF_LAYOUT_VERSION bump, so cached invoices are unaffected.

Deploy

  • No migrations. No new environment variables, no new workers, no one-off commands.

Tests

  • agency_models/tests/test_of_transaction_summary.py — 28 tests over the aggregates, refund handling, both groupings and the rendered PDF.
  • integrations/tests/test_public_models.py pins the id split: the platform's own id is published, the vendor's row id is not.