mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
0913576b69
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>
290 lines
18 KiB
Markdown
290 lines
18 KiB
Markdown
# 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](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 `getPaidAccess` → `getCapTiers` → `capMediaType` → `cappedTerms` →
|
||
`effectiveLicensingFee`. 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:
|
||
|
||
```ts
|
||
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
|
||
|
||
```ts
|
||
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 1–2 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].ts` — `hasSourceRule = !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).
|