## What Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`). **OFF serves the legacy single-column page byte-identically.** See "Rollout" below. ## Panes | Pane | Change | |---|---| | Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile | | Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped | | Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features | | Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened | | Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers | | Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config | | Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading | | Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last | ## Decisions worth a reviewer's attention - **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field. - **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead. - **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL. - **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count. - **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read). - **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork. - **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported. - **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`. ## Verification Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79). `SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it. Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program. ## Not in this PR - `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately. ## Rollout `accountSettingsV2` → Flipt key `account-settings-v2`. `availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
23 KiB
Monetization rules: paid access, licensing fees, donation goals
How the four monetization concepts on a model version interact. Written 2026-08-05 from the code, with the surprising claims verified against the prod replica; R3 rewritten 2026-08-21, 2026-08-24 and 2026-08-25 for the monetization revamp. Where a rule is enforced matters as much as what it is — several are enforced in only one of two write paths, and those are called out.
Scope: ModelVersion only. ComicChapter has its own gate and is not covered here.
The four things
| Concept | Stored | What it is |
|---|---|---|
| Usage control | ModelVersion.usageControl |
Whether buyers get files, on-site generation, or both. The axis the others price against. |
| Paid access | PaidAccess row |
A gate. Two kinds, below. |
| Licensing fee | ModelVersion.licensingFee |
Buzz the creator earns per generation by others. Independent of any gate. |
| Donation goal | DonationGoal row |
A community target that, when met, ends a timed gate early. |
Usage control
Two creator-settable values (CREATOR_USAGE_CONTROLS): Download (download + on-site generation) and
Generation (on-site only). Two further values exist for moderators/API and are not editable in the
studio — the editor shows a banner instead of a picker so it can't silently downgrade them.
A gate always prices the surviving tier: a Generation version charges via the generation price, a
Download version via the download price.
When that conflicts — a gen-only version carrying a download tier — the price migrates to the surviving
tier rather than the write being refused. Both apps go through migrateTermsForUsageControl, so this is a
global rule, not a Creator Studio behaviour.
Paid access — two kinds, one table
Distinguished by timeframeDays:
- Timed ("Early Access") —
timeframeDaysset. Ends on its own; the version becomes free. - Permanent ("Paid Access") —
timeframeDays IS NULL. Never ends.
endsAt discriminates nothing on its own. A NULL endsAt means either of two unrelated things:
- a permanent gate — it has no end date by definition, or
- a timed gate that hasn't started —
endsAtis materialized at publish (materializePaidAccessEndsAt), so a pending window carries NULL until then.
It never means "no gate" — that's the absence of the row. This is why isPermanentGate keys off
timeframeDays and why "currently gated" needs both columns:
| Want | Test |
|---|---|
| Is it permanent? | timeframeDays IS NULL |
| Is it a timed window that hasn't elapsed? | timeframeDays IS NOT NULL AND (endsAt IS NULL OR endsAt > now()) |
| Is it gated at all right now? | row exists AND (endsAt IS NULL OR endsAt > now()) — this is isPaidAccessActive |
The third row needs no timeframeDays clause because a permanent gate always has a NULL endsAt.
Expiry never deletes the row (process-ending-early-access says so explicitly), so an expired gate is a
tombstone — present, timeframeDays set, endsAt in the past — and timeframeDays != null alone does
not mean "has a window". Three other paths do delete it: clearing the gate, deleting the version, and
merging versions. Clearing also resets availability from 'EarlyAccess' to 'Public'; without that
reconciliation non-buyers stay locked out permanently.
Rules
R1. A timed window can only be started on a version that has never been published
The window is meant to precede release. After publish it would gate what the audience already has, and on
expiry process-ending-early-access bumps publishedAt — resurfacing an old model as "New".
The test is initialPublishedAt <= now() OR status = 'Published'.
- Not
publishedAt— the expiry job rewrites it on republish. tens of thousands of versions already have the two diverged. <= now()matters — theset_initial_published_attrigger copies future timestamps, so a Scheduled version carries an anchor for a release that hasn't happened. Without the comparison, the pre-release case the feature exists for is refused.statusalone is wrong in the other direction — a large number ofUnpublishedandDraftversions have been published before.
Carve-out: a version with an active timed window may be re-priced. That's an edit, not a start. Tombstones don't qualify.
Enforced: assertUserEarlyAccessLimits (src/server/services/model-version.service.ts), which both
the REST endpoint and tRPC modelVersion.upsert call. Moderators are exempt.
R2. Permanent access is legal on a published version
Paywalling an already-released model is an intentional product capability. Only the timed kind is publish-restricted.
R3. Membership limits how OFTEN you may price, not how much
| Limit | Source | Applies to |
|---|---|---|
| Eligibility (score ≥ 10k) | Creator score (scores.total) |
A new fee or a new gate |
| New prices per month | Membership tier | A new fee or a permanent gate |
| Licensing fee ceiling | Flat 100/generation × media type | Fees |
| Paid access price ceiling | — none | — |
| Window length (days) | Creator score | Timed |
| Concurrent windows | Creator score | Timed |
Every "creator score" in this table is User.meta.scores.total — the figure the account Profile pane
displays under that name. Monetization and the early-access ladder both compared against the
per-category scores.models until 2026-09-04; 45,216 accounts were above the displayed floor and
below the enforced one, and had no way to see why. Any gate that says "creator score" to the user
reads it through creatorScoreFromMeta (src/shared/utils/creator-score.ts) rather than reaching
into meta.scores directly. (The rate-limit checks in src/server/schema/{comment,reaction,post}.schema.ts
still inline scores.total; they gate posting frequency, not money, and are not covered by this rule.)
Every creator gets the same fee ceiling — maxLicensingFeeCeiling, 100 per generation and 500 on a
video model. Paid access has no ceiling at all. What a tier buys is allowance:
monthlyPricingAllowance — free 3, bronze 10, silver 25, gold unlimited.
A lapse cannot change any price, but it lowers the allowance immediately. getCapTier reads live
subscription state at write time, and incomplete/past_due/unpaid all count as lapsed, so a
membership ending mid-month drops that month's allowance to free's from that moment — in the main app.
Creator Studio resolves the tier from the session instead (4h TTL, busted by
invalidateSubscriptionCaches), so its own writes can honour the old allowance for up to that long.
Slots already spent stand: a creator who spent 8 as bronze and lapses on the 8th sets nothing new until
the month turns, or until clearing prices releases enough slots (R3b) to get back under 3. An unknown or
absent tier resolves to the FREE allowance rather than zero, so losing a membership never takes away the
ability to price at all. No path that reads a price resolves a subscription tier any more —
getViewerMonetization reads the gate rows and returns the stored numbers. The one read that still
resolves a tier is modelVersion.getPricingAllowance, which reports the allowance and names no price.
Considered and declined (2026-08-25): granting the lapsed tier's allowance for the whole calendar month it lapsed in, which would fix the involuntary case (a failed card is the most common mid-month drop). Gold is unlimited, so "held it at any point this month" would mean unlimited pricing for that month off one payment. Left as-is deliberately; revisit with that cost in view.
Membership tier does not unlock early access. The ladder reads the creator score
(scores.total), so simulating a tier will never reach it — the studio has a separate
moderator-only score simulator for this reason. Its entry rung is 10,000, matching the pricing
floor. The one non-score unlock is the granted thirtyDayEarlyAccess feature flag, which by
itself confers the top rung (30 days, 30 concurrent) at any score.
The fee ceiling blocks raises only (raisesOverCap): a stored price above it stays chargeable, so a
max must never clamp below the stored value or an unrelated edit silently cuts a grandfathered price.
One exception: a write that moves the version onto a stricter media axis. 500 is legal on a video
model and 5x the ceiling on an image one, so that write faces the ceiling outright even though the
number did not rise. The test is the fee the write leaves behind, not the one it names — an upsert
that omits licensingFee keeps the stored one, and the base model can still move under it.
R3a. Both rules turn on ONE question: is this version newly priced?
A "price" is a licensing fee or a permanent gate. A timed early-access window is neither — it prices itself out when the window closes, and is already gated on a far higher creator score of its own.
Applying a price to a version that has none requires the score floor and spends a slot. Editing, lowering, or clearing a price that is already set spends nothing and needs no score — and clearing the last one may hand the slot back (R3b). That exemption is what grandfathers everything priced before these rules existed, and it is why the cutover needed no backfill.
The two rules read that question from different places, deliberately:
- Eligibility reads current state — does this version carry a fee or a
PaidAccessrow right now. Grandfathering attaches to a price that is still set, so no history is needed. - The allowance reads the
PricingSlotledger — what has been spent this calendar month. That needs a record, because a gate created and deleted inside the month usually still spent its slot — the row survives unless the release conditions in R3b are met.
⚠️ A creator below the floor who clears a price cannot re-apply it: current state no longer shows one, so re-application is a new price. That follows from "no new fee below the floor" read literally.
R3b. One slot per entity, returned only if the last price comes off untouched
PricingSlot is keyed (entityType, entityId) — the same key as PaidAccess, so it already covers
ComicChapter. The primary key IS the idempotency while a price stands: a fee added beside an existing
gate, or any edit to a price already set, finds the row there and costs nothing.
Clearing the LAST price off a version deletes its slot, if nothing has transacted against it: no
EntityAccess row held by anyone but the owner, and no licensing fee charged since the slot was
created. Deleting the row is what "returned" has to mean for an allowance that counts rows created this
calendar month. Removing one of two prices returns nothing — the version is still priced. So a price
cleared and re-applied inside one month spends a second slot when the release succeeded, and nothing
when it did not.
Clearing a price set in an earlier month deletes the row but returns nothing — the count is of rows created this month, and that one was not. Nothing is lost either: this month's allowance was never reduced by it. Refunding across the boundary is exactly what would let allowance carry forward.
The transaction test reads orchestration.resourceCompensations in ClickHouse, bounded on the slot's
own createdAt and raced against a 3s timeout, falling back to the daily
ModelVersionMetric.earnedAmount mirror when ClickHouse cannot answer. Both fail closed — an
unanswerable question leaves the slot spent. A version that was never published skips the fee test
entirely: no buyer could reach it and no generation could charge for it.
There is deliberately no foreign key to the entity. The key is polymorphic, so there is nothing to point at, and the consequence is wanted — deleting a version does not refund its slot. Rows that outlive their entity are inert, because the count is scoped to the current month.
A model transfer deletes the slots on its versions rather than moving them, and it is the one case
where a stranded row would not go inert — the entity outlives it. Moving ownerId would charge the
recipient for a pricing they never made (what #4309 rejected for PaidAccess in the other direction);
leaving it makes the row unreleasable (release refuses on an owner mismatch) and un-insertable (the
key is the entity alone), which would let the recipient re-price that version forever without it ever
counting against their allowance.
Moderators are not exempt from either the floor or the allowance. That is the one creator-score gate in the codebase they do not bypass: the floor is a statement about who may sell, not a permission level. They remain exempt from the fee ceiling.
R3c. What the creator is told, and what they can look at
The counter and the allowance refusal say priced, not "monetized", and name what is counted:
PRICING_SLOT_EXPLAINER and EARLY_ACCESS_NOT_COUNTED in @civitai/buzz are the one wording, shared by
both apps, and the refusal names the tier it refuses on (capTierLabel). "Monetized" was read as covering
Early Access — the two meters sit side by side, so creators added them together and asked why 25 + 20 wasn't
45, then hit the cap on an Early Access publish that had spent no slot at all (CU 868m1baec). The count was
already shown in both editors; what arrived only on save was the explanation, so both apps now carry the
explainer where the price is set. pricingFloorMessage deliberately keeps "monetize" — the floor is about
who may sell at all, not about what the counter counts, and a test pins that split.
listPricingSlots (both apps) answers what spent them: the owner's slots newest first, split on the
calendar month the allowance counts, with the model version and its prices. Read behind
modelVersion.getPricingSlots in the main app and loaded with the models page in Creator Studio.
⚠️ The amounts are the version's price NOW, not the price at spend time. PricingSlot stores
{ entityType, entityId, ownerId, createdAt } and nothing else, one row per entity — current state, not an
audit log. A true history (priced, released, re-priced, and the amount each time) needs an append-only
ledger or amount + releasedAt columns; neither exists.
Enforced: assertMonetizationWrite (src/server/services/paid-access.service.ts), called by tRPC
modelVersion.upsert and the REST early-access endpoint, and mirrored for Creator Studio's direct-SQL
writes in apps/creator-studio/src/lib/server/monetization/pricing-slot.ts. Three write surfaces, so a
rule enforced in only one of them is not enforced.
R4. Licensing fees are independent of gates
A fee is charged per generation by other people; a gate is charged once to the buyer. A version can have both, either, or neither.
The one coupling is payout: a version charging a licensing fee is opted out of tips + creator compensation for that generation — it earns through the fee channel instead. A lineage fee inherited from a source rule settles to a different creator and does not opt this version out.
R5. Rights affirmation gates starting monetization, never stopping it
Required the first time a version charges anything, recorded per version with the wording stored verbatim. Never required to clear a fee, remove a gate, or set a duration of 0.
An affirmation expires two ways: bumping MONETIZATION_RIGHTS_AFFIRMATION_VERSION invalidates every
stored record, and the check is scoped to ownerId — so an affirmation does not survive an ownership
transfer.
In bulk, it's demanded whenever any selected version lacks one; versions already affirmed are skipped server-side. Ids outside the current page are conservatively treated as needing one.
R6. Donation goals
- Attached per version, addressed by
(entityType, entityId), with a legacymodelVersionIdcolumn still dual-written — any query must check both or it misses pre-re-key rows. - Create-once. The endpoint never updates or removes one, and
active: falseis written in exactly one place: goal completion. There is no cancel path, for creators or moderators. - Early-access purchases count toward the goal. Every purchase writes a
Donationrow for the full amount and then trips the completion check. A goal can therefore complete on sales alone. In practice the overwhelming majority of what sits against these goals came from purchases, not donations. - On completion, the goal closes and a timed gate ends immediately. A permanent gate is exempt —
by design (
isTimedGateActive), so a funded goal cannot wipe a permanent paywall.
Interactions
Usage control × an existing gate
Changing usage control moves the price to the surviving tier — it does not refuse, and it does not
change the gate kind. Both the bulk path and the single-version editor go through the same
bulkSetUsageControl, so they cannot diverge.
- →
Generation: the generation price survives; if unset, the download price becomes it. - →
Download: the download price survives; if unset, the generation price becomes it. Those versions then sell downloads at a price the creator never chose — the studio surfaces this after the write. - Switching a version that gives generation away free to
Generationremoves the free grant — it can't both charge via generation and give it away. Disclosed with the affected list before applying.
Every live gate carries at least one price, so a migration always has something to move.
Timed ↔ permanent
- Timed → permanent is allowed. It spends a pricing slot if the version is not already priced (R3b), and there is no price ceiling to satisfy.
- Permanent → timed is refused on a published version by R1, since a permanent gate is not an active timed window. This refuses a strictly less restrictive change; deliberate but worth revisiting.
- When it does go through,
endsAtis derived from the version's existingpublishedAt, not from now — so on a long-published version it lands in the past and the gate is born a tombstone, silently ending paid access. R1 blocks that for creators; moderators are exempt and can do it without an error.
Gate × donation goal
A goal only means anything on a timed gate. Switching to permanent does not deactivate the goal — the
write path treats a null goal as a no-op — so it stays active and keeps accruing from sales toward an
unlock that can never happen. It does so invisibly: the public read filters goals on
isTimedGateActive, so no viewer sees it; only the owner's edit form does. 754 permanent-gated versions
are in this state today.
Nothing stops a goal being created on a permanent gate either — writeModelVersionGateAndGoal never
checks permanent. Only the studio suppresses it.
Standing product decision (Justin, 2026-08-05): leave donation goals exactly as they are. A rule blocking the switch was considered and rejected pending a creator survey. Do not build one.
Gate × licensing fee
No interaction on the write path. Both can be set on the same version; the payout consequence in R4 is the only coupling.
Where each rule is enforced
Two write paths reach a gate: the REST endpoint /api/v1/model-versions/early-access (what Creator
Studio calls) and tRPC modelVersion.upsert (what the main app's form calls). A rule enforced in only
one of them is not enforced.
Licensing fees and usage control are a third path. Creator Studio writes both directly to Postgres
via kysely — they never reach the main app, so any rule about them has to be implemented there too. The
affirmation check on that path is owner-scoped, matching resolveRightsAffirmation.
| Rule | Lives in | Covers every path |
|---|---|---|
| R1 publish restriction | assertUserEarlyAccessLimits |
✅ |
| Window length + concurrent caps | assertUserEarlyAccessLimits |
✅ |
| Fee ceiling | assertMonetizationWrite + the spoke's licensing-fee.ts |
✅ (see below) |
| Eligibility floor + allowance | assertPricingAllowed |
✅ (see below) |
| Rights affirmation | resolveRightsAffirmation |
✅ |
Four rules have two implementations each, because Creator Studio's fee and gate writes are direct
SQL that never reaches the service layer: the fee ceiling (assertMonetizationWrite vs the spoke's
licensing-fee.ts), the eligibility floor and the allowance (pricing-slot.service.ts vs the spoke's
pricing-slot.ts), and slot release — versionHasTransacted/releasePricingSlot against
releasableVersionIds/releasePricingSlots, each with its own ClickHouse query over
orchestration.resourceCompensations. Keep each pair in step — a divergence makes the spoke a way
around whichever rule it drops, which is the same failure the POI guard has already had here.
Known gaps
- Active donation goals accumulate on versions with no gate at all — goals are never deactivated when a window ends naturally.
- Some currently-gated versions have no rights-affirmation record, predating the requirement. Any write path that re-saves their gate without passing an affirmation will 400.
paidAccessToConfigstill returnsdonationGoalEnabled: false— the models-page loader patches it from aDonationGoalsubquery, so the sidebar's lock engages. The CSV export doesn't use that flag; it reads the goal columns from its own join.
The full findings list and its status are kept privately — ask a maintainer.