Files
civitai__civitai/docs/monetization-cap-api-plan.md
T
briant 0913576b69 feat(monetization): replace tier price caps with a monthly pricing allowance
Membership stops governing how much a creator may charge and starts governing how
often they may put a new price on something — article 33749, plus the decisions
recorded in docs/licensing-fee-revamp-questions.md.

- Price caps deleted. The licensing fee ceiling is a flat 100/generation for every
  creator (500 on a video model); paid access has no ceiling at all. Every clamp
  goes with them, including the cap-tier Redis cache — getViewerMonetization, the
  purchase path and the orchestrator-facing mini endpoint no longer resolve a
  subscription tier at all.
- Membership instead grants a monthly allowance of NEW prices: free 3, bronze 10,
  silver 25, gold unlimited, counted in a new PricingSlot ledger keyed like
  PaidAccess. One slot per entity, spent on first pricing, never returned.
- A creator score of 10,000 is required to apply a price to something that has
  none. Moderators are not exempt from the floor or the allowance; they remain
  exempt from the fee ceiling.
- Both rules gate only the unpriced → priced transition. Editing, lowering or
  clearing a price that is already set is always free, at any score — which
  grandfathers everything priced before these rules existed, and is why the
  cutover needs no backfill.

Creator Studio enforces both rules itself: its fee and gate writes are direct SQL
that never reaches the service layer, so a rule not applied there is a rule the
spoke can bypass. The rules themselves live in @civitai/buzz so the two
implementations cannot drift on a threshold or a message.

Also reverts the unshipped lapse-grace work: it mitigated lapse-repricing, and a
lapse no longer changes any price.

MIGRATION — 20260821120000_pricing_slot is applied by hand, per environment, and
must land BEFORE this code deploys: until the table exists every first-time pricing
write throws on a missing relation, and the Creator Studio models page 500s
outright. Apply it under a short lock_timeout — the inline owner FK takes SHARE ROW
EXCLUSIVE on "User". See the migration header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:14:19 -06:00

18 KiB
Raw Blame History

Monetization caps — API cleanup plan

⚠️ Superseded 2026-08-21 by the monetization revamp. The per-tier caps this plan was cleaning up were deleted, taking most of the 44-symbol surface with them (getCapTiers, cappedTerms, effectiveLicensingFee, maxLicensingFee, maxPaidAccessPrice, feeImageOptionsForCap, tierCapRows). The plan's main recommendation — one write guard covering fee, price and count — shipped as assertMonetizationWrite. Every symbol count and consumer measurement below predates that and is stale. Kept as the design record for why the guard was unified; re-measure before reviving any remaining step. See features/monetization-rules.md.

Status: proposal, nothing implemented. Scope: licensing fees + paid access caps across @civitai/buzz, the main app, and the creator-studio spoke.

The problem, measured

getViewerMonetization internally calls getPaidAccessgetCapTierscapMediaTypecappedTermseffectiveLicensingFee. That composition is the function doing its job — one call in, one answer out. It is not the problem.

The problem is one layer up. @civitai/buzz exports 44 monetization symbols, and the primitives sit at the same level as the composed answers, so consumers reach past the facade and compose by hand:

Consumer Distinct monetization symbols composed
src/components/Resource/Forms/ModelVersionUpsertForm.tsx 11
apps/creator-studio/src/lib/monetization/fee.ts 9
src/server/services/paid-access.service.ts 8
apps/creator-studio/src/routes/(app)/models/+page.svelte 7
src/server/services/model-version.service.ts 6
4 more creator-studio files 4 each

The upsert form composes capMediaType, maxLicensingFee, maxLicensingFeeCeiling, maxFeeBuzzForRatio, feeImageOptionsForCap, maxPaidAccessPrice, suggestedFeePerImage, feeToRatio, ratioToFee, generationPrice, buildModelVersionTerms — to answer one question: what may this creator enter for this version?

This already cost us

The video-pricing review found 7 call sites that silently applied image caps to video models, two of them user-visible bugs (a form offering a max the server rejects; a generation gate displaying 500 then debiting 2,000). Every one had the same shape: an optional mediaType? parameter that defaults to image when omitted. The type system was happy. Nothing failed loudly.

raisesOverCap — one rule, "only a raise is capped" — is hand-implemented at 7 call sites across both apps. The rule exists because rejecting every over-cap submission caused a real outage (82f64846ba). Seven copies is seven chances to reintroduce it.

Root cause

Three specific design choices, each individually reasonable:

  1. Optional media/tier parameters that default to the strict value. maxLicensingFee(tier, modelType, mediaType?) is safe when forgotten but silently wrong. Safety made the bug invisible.
  2. Primitives exported alongside facades. Nothing signals that cappedTerms is plumbing for getViewerMonetization and not for direct use.
  3. The write guard is split by concern, not by call site. assertPaidAccessCaps covers access prices; licensing fees are guarded separately in every caller, so a new caller gets one and forgets the other — exactly what happened in model-version.controller.ts.

Proposed shape: five utilities

Two client, three server. Everything else becomes internal to @civitai/buzz.

Client (pure, no I/O)

1. monetizationLimits({ membership, modelType, baseModel, isModerator })MonetizationLimits

Everything an editor needs to bound its inputs, in one object:

type MonetizationLimits = {
  capTier: string; // resolved here, not by the caller
  mediaType: CapMediaType; // resolved, for display copy
  fee: {
    maxPerGeneration: number; // the tier cap
    denominators: number[]; // which "per N" options are expressible
    maxBuzzFor(images: number): number;
    suggested: FeeRatio; // seeded default for a NEW version
  };
  access: { maxPrice: number | null }; // null = unlimited
  permanent: { limit: number | null };
};

Replaces the 11-symbol compose in the upsert form and the 7 in +page.svelte. Takes baseModel, not mediaType — callers can't pass the wrong axis because they can't name it. Same for membership over a pre-resolved tier string (see Decision 1).

2. Display formatters — formatFeeRatio, feeToRatio, tierCapRows. Already correct; keep as-is.

Server

3. getViewerMonetization({ versions, viewer }) — the read path. Already exists, already correct. Keep. Consider folding toModelVersionPaidAccessDto into it: since rows arrive priced, that function is now a 4-line mapper whose only remaining job is dropping ownerId.

4. assertMonetizationCaps({ user, version, next })one write guard covering licensing fee and access price and the permanent count, applying the increase-only rule once. Replaces assertPaidAccessCaps plus the 7 hand-rolled raisesOverCap sites. A new write path gets all three checks or none — it cannot get one and forget the others.

5. getBillableMonetization(versionId) — what the orchestrator and the purchase path charge. Today mini/[id].ts hand-composes two recipients × their own tiers × their own base models, and earlyAccessPurchase composes its own clamp. Both want the same answer: what does this generation/purchase actually cost, and who receives it? Returns fee components per lineage version, never an aggregate — each carries its own recipient, version, amount, and settlement currency (Decision 3).

Tier resolution moves into the package

Both apps answer "which tier do the caps use?" and they answer it differently:

Shape Source
Spoke cappedTier(m) = m.isMember ? (m.tier ?? 'free') : 'free' — pure, sync session
Main app getCapTier(userId) — async, filters isBadState subs, returns null DB

They agree semantically but differ in return value ('free' vs null) and in when the "bad state" check happens. Per Decision 1 the spoke's shape wins: @civitai/buzz owns

type Membership = { tier: string | null; isMember: boolean };
resolveCapTier(m: Membership): CapTier   // always a real tier, never null

Each app keeps producing Membership its own way — the main app from the session or the cached getCapTier lookup, the spoke from the session plus its moderator test-cookie override, which stays spoke-only. What moves is the rule, not the fetching.

The rule that prevents recurrence

Make baseModel required wherever a cap is computed, and stop exporting the primitives. Every one of the 7 media-type bugs becomes a compile error rather than a silent image-cap. If a primitive must stay exported for tests, mark it @internal and lint against external use.

Migration plan

Per Decision 2, narrowing is not a trailing phase. Each phase ends by deleting or internalizing the primitives it just replaced, so there is never a window where both the facade and the cobbled-together path are reachable. Churn is not the constraint; a reachable wrong path is.

Phase 1 — monetizationLimits + resolveCapTier (client, no behavior change). Add both, migrate the upsert form and +page.svelte, then remove maxFeeBuzzForRatio, feeImageOptionsForCap, and suggestedFeePerImage from the package's public exports. Highest value-to-risk: pure, fully unit-testable, collapses the two worst offenders (11 symbols → 1, 7 → 1), and retires CreatorCaps.priceCap, which is page-level and structurally can't know a version's media type.

Phase 2 — assertMonetizationCaps (server, behavior-preserving by construction). Fold assertPaidAccessCaps + the fee guard into one, covering fee, access price, and permanent count. Migrate the tRPC handler, the REST endpoint, and both creator-studio actions, then remove raisesOverCap and the bare max* functions from the public surface. The increase-only rule gets asserted once, with 82f64846ba as the named regression test.

Phase 3 — make baseModel required. With Phases 12 done the remaining call sites are few, so this is mostly a signature change plus the compile errors it surfaces. It is the step that makes the media-type bug class unrepresentable rather than merely absent.

Phase 4 — getBillableMonetization (also the prerequisite for deeper lineage). Riskiest (money path + orchestrator contract), so last, behind the mini endpoint's shape tests — and behind an integration test that actually exercises a gated version, which we currently lack (see Risks).

Audit — cleanup available now

Main app

  • ModelVersionUpsertForm.tsx — 11 symbols → 1 (Phase 1).
  • model-version.controller.ts:447 — the licensing-fee guard is hand-rolled next to assertPaidAccessCaps, which is exactly why it was missing capMediaType until the review caught it. Phase 2 target.
  • model-version.schema.ts:366 re-exports MAX_LICENSING_FEE, giving the same constant two import paths. This bit us during the video work: the form imported the ceiling from the schema, which didn't export it. Delete the re-export; import from @civitai/buzz.
  • mini/[id].ts and earlyAccessPurchase hand-compose the same billing answer (Phase 4).
  • toModelVersionPaidAccessDto is now a near-trivial mapper (see above).

Creator Studio

  • lib/monetization/fee.ts is a 9-symbol pure re-export barrel. It exists so spoke imports point at $lib, which is fine — but after Phase 1 it should re-export ~3 things, not 9.
  • lib/server/monetization/licensing-fee.ts has 4 raisesOverCap sites; +page.server.ts has a 5th for access prices. All collapse into Phase 2.
  • The spoke re-implements the main app's write guard. +page.server.ts validates caps, then POSTs to the main app's REST endpoint which validates again. The main app is the real enforcement point; the spoke's copy is a UX pre-check that must be manually kept in sync — and currently isn't (it lacked the video multiplier until this week). Options: (a) accept the duplication and cover it with a shared test vector, (b) have the spoke call a thin /api/v1/monetization/limits endpoint, (c) share the assert via @civitai/buzz as a pure function over already-fetched state. (c) is the natural fit — the guard is pure once you have tier + stored values + submitted values, which is exactly Phase 2's signature.
  • NON_COMMERCIAL_BASE_MODELS = new Set(['Ideogram 4.0']) is hardcoded in the spoke. Now that basemodel.constants is shared, this should be data-driven off the license record.
  • CreatorCaps.priceCap is vestigial after PaidAccessEditor began deriving its own per-version cap. Remove it so nothing re-adopts the page-level value.

Package

  • licensing-fee.ts and paid-access.ts now import each other's concepts (CapMediaType, VIDEO_CAP_MULTIPLIER cross the boundary). The split is historical — fees vs gates — but caps are now one concept spanning both. Consider a caps.ts owning the tier tables, multiplier, and every max* function, leaving the other two files to terms-shape and ratio math.

Decisions

1. Tier resolution — use the spoke's model. (@dev) @civitai/buzz owns resolveCapTier(membership); each app keeps producing Membership its own way. See "Tier resolution moves into the package" above. Two things to settle during Phase 1: the main app's getCapTier returns null where the spoke returns 'free' — unify on 'free', since every consumer already funnels null through a ?? free fallback and the two values have never meant different things. And the spoke's moderator test-cookie override stays in the spoke; it simulates membership, not caps.

2. Phase 3 is not deferred cleanup. (@dev: "churn isn't the issue. Code that can be misinterpreted or poorly cobbled together is the issue.") Restructured accordingly: every phase now ends by removing the primitives it replaced, rather than leaving a trailing narrowing step. There is never an interval where both the facade and the hand-rolled path compile.

3. Lineage — per-version components, and it's cheap. (@dev: "we need to know the licensing fee for each lineage version, not an aggregate total")

@ai: Researched. Today lineage is structurally one hop, enforced at the write boundary — but see the N-hop section below, which is what actually drives the Phase 4 design. model-version.controller.ts:468 requires licensingSourceVersionId to point at a registered LicensingRoot for the same base model. A derivative therefore always points at a root, and a root is never itself a derivative, so chains can't form. mini/[id].ts already resolves the whole lineage in one LEFT JOIN — no recursion, no CTE.

And fees[] is already the per-version breakdown you want, not an aggregate: each entry carries role (baseModel | version), amount, type, settlementCurrency, recipientModelVersionId, and recipientUserId. So getBillableMonetization can own lineage outright at no extra query cost — it just returns that array instead of the endpoint hand-assembling it.

Lineage will get deeper — design for N hops now

@dev: lineage is one hop today but may need more levels later.

That changes the recommendation from "cheap, do it whenever" to getBillableMonetization should own lineage resolution in Phase 4 regardless, because one-hop is currently assumed in three separate places and each is a silent-truncation hazard the day depth increases:

  1. mini/[id].ts — a single LEFT JOIN to licensingSourceVersionId. At depth 2 it bills the parent and silently drops the grandparent. No error, just a recipient who stops getting paid.
  2. mini/[id].tshasSourceRule = !isLicensingRoot && .... The root/derivative split is binary; a mid-chain version is both.
  3. model-version.controller.ts:468 — the write guard requires the source to be a registered LicensingRoot, which is what makes depth 1 true. Relaxing it is what enables depth N.

Consolidating first means the depth change is one function's internals plus a wire-contract decision, rather than an archaeology exercise across the endpoint, the controller, and the purchase path.

What N-hop needs that 1-hop doesn't:

  • Recursive resolution with a bounded depth. A WITH RECURSIVE walk up licensingSourceVersionId, with an explicit MAX_LINEAGE_DEPTH so a pathological chain can't turn one generation into an unbounded walk. Decide whether exceeding the cap truncates (and under-pays) or errors (and blocks generation) — this is a product call, not a technical one.
  • Cycle protection. Depth 1 makes cycles impossible; depth N does not, and the FK allows them. Needs either a write-path check (walk before insert) or a WITH RECURSIVE visited-set at read time. Prefer the write path: cheaper, and it fails at authoring time rather than at generation time.
  • Per-hop cap resolution. Every ancestor is capped against its own recipient's tier and base model, which the current code already does correctly for its two components. getCapTiers already batches, so N recipients is one cache read — the existing shape scales without change.
  • A wire-contract decision. This is the sharp edge: deeper lineage means more entries in fees[], and the array is the orchestrator's contract. Adding entries is a coordinated change, not a unilateral one. Worth settling the shape before depth ships — e.g. a depth field on each component so consumers can reason about ancestry, added while the array is still length ≤ 2 and the change is free.
  • A settled root semantic. LicensingRoot is unique per (baseModel, modelType) and marks the top of a chain. At depth N, is a mid-chain version that is also registered as a root a terminator (stop walking) or a pass-through (keep walking)? Today the question can't arise. It decides who gets paid.

Still true regardless of depth: the return shape is a per-version array, never an aggregate — each entry carries its own recipient, version, amount, and settlement currency. Depth changes how many entries there are, not what an entry is.

One more caveat for today's code:

  • The one-hop guarantee is enforced in one controller, not in the schema. ModelVersion.licensingSourceVersionId is a plain nullable FK; nothing at the DB level stops a row pointing at a non-root. A second write path would make chains representable before the read side is ready for them — and the single JOIN would truncate silently. Until Phase 4, that guard is load-bearing; treat it as such.
  • Multi-parent / revenue splits are a different axis from depth and would change the shape, not just the query: N recipients per component with proportions. That's a schema change and the point to re-ask whether getBillableMonetization still owns it.

Risks and gaps

  • No integration coverage on the billing endpoint. The BigInt 500 fixed in 46512a8cf0 proves the gap: tsc typed freeTrialLimit as number, unit tests mock the query, and 1,144 gated versions were failing in production. Nothing in the suite exercises mini/[id] against a real gated version. Phase 4 should not land without one.
  • Sweep for sibling BigInt hazards. Any $queryRaw interpolating a JS number into COALESCE, GREATEST, LEAST, or a comparison against an int4 column has the same int8-promotion problem. Not yet swept.
  • Two enforcement points stay in sync by hand until Phase 2 — the spoke pre-validates, the main app's REST endpoint actually enforces. That drifted once already (the spoke lacked the video multiplier).