Monetization productization · implementation final report

Lissin Monetization — Implementation Final Report

What was built

The complete backend entitlement + quota foundation and its enforcement engine, the grandfathering system that protects existing users, the matching iOS client contract, an offer-stage rescue ladder, the Phase-4 client-side gates / trial / nudges / live balances, and the admin tooling to run it all — turning the PRD (lo/lissin-monetization-plan) into working code across six PRs / 59 commits. The backend chain runs end to end: a versioned, dashboard-editable plan contract → an immutable usage ledger → a quota service → an expanded /billing/access → trial projection → observe-only metering across every paid action → a flag-gated enforcement layer returning structured 402 denials, with cohort + hand-picked grandfather grants overlaid on top. Everything ships observe-only / additive / flag-gated (all enforcement flags off = observe-only, client gates fail open), so every commit is safe to merge and nothing blocks a user until you flip a flag. Every code-review comment (Greptile P1/P2 + admin security) is fixed & pushed, and the iOS #311/#312 re-reviews came back green.

Backend PR #514

dev · feat/billing-quota-foundation · 17 commits

✅ Contract → ledger → quota → access API → trial

✅ Observe-only metering on all paid actions

✅ Enforcement engine + 402 denials behind flags

✅ nest build clean · 3 migrations · review fixes in

Backend PR #515

dev · feat/billing-grandfather-cohort · 4 commits

✅ Cohort preview + idempotent bulk apply

✅ Hand-picked user-ID selection + campaign audit

✅ Overlay-safe (never demotes a payer), bounded

✅ 29/29 grandfather + billing tests

iOS PR #309

develop · feat/billing-plan-contract · 9 commits

✅ Decodes expanded /billing/access contract

✅ Fallback plan aligned to Phase-0 numbers

✅ Decodes 402 denial → paywall-placement mapping

✅ Backward-compatible optional fields

iOS PR #311

develop · feat/billing-offer-ladder · 6 commits stacks on #309

✅ OfferStage standard → save50 → save65 ladder

✅ Escalates a discount on each paywall dismiss

✅ Persists highest stage; resets on purchase

✅ 13 OfferLadderTests · re-review green

iOS PR #312

develop · feat/billing-phase4 · 12 commits stacks on #311

✅ Fail-open gates: transcript, speed, soundscapes, files

✅ Explore listening-time gate via meter balance

✅ Trial countdown + usage nudges on home

✅ Plan & Billing meters on live balances · re-review green

Admin PR #163

dev · feat/billing-grants-admin · 11 commits

✅ Grant/revoke/history + operator attribution

✅ Cohort grandfathering page (builder → preview → apply)

✅ Users multi-select → grandfather selected (N)

✅ Audit page · tsc + next build clean · security fixes in

The build, end to end

1 · Plan contract — one typed, credit-based Free/Plus/Pro + plus_trial definition (recurring/one-time credits, Explore minutes, files, episode caps, speed, transcript/soundscape/remix).
2 · plan_versions table — the contract seeded into the DB as the runtime source of truth, so numbers become admin/dashboard-editable without a deploy.
3 · Usage ledger — immutable CREDIT/RESERVE/RELEASE/CONSUME/EXPIRE/ADJUST entries + per-window usage_accounts, replacing placeholder-zero usage.
4 · Window math — UTC-anchored weekly / rolling-3-day / billing-month / trial windows, with rollover.
5 · Quota service — balances, reserve/consume/release, and a read-only peek; unlimited meters short-circuit.
6 · Access APIGET /billing/access expanded with phase, plan version, server time, per-meter balances, trial window (all additive).
7 · Trial projection — the RevenueCat webhook maps period_type=TRIAL to the trial phase + window and persists product/offer/environment.
8 · Observe-only metering — every paid action records real usage (generation, upload, Explore playback) and logs who would be gated.
9 · Enforcement — a pre-flight check that, behind per-domain flags, denies with a structured 402; the iOS client decodes it and maps to a paywall.
10 · Grandfathering — cohort (allow-listed filters) + hand-picked user-ID grants that overlay the plan, idempotently and overlay-safe (never demote a payer), with a per-campaign audit trail.
11 · Client gates & rescue ladder — iOS decodes the contract, gates paid features fail-open, drives trial countdowns / usage nudges / live per-meter balances, and escalates a discount ladder on each paywall dismiss.
12 · Admin control — a dashboard to grant/revoke tiers, run cohort + multi-select grandfathering, and read the campaign audit — all gated on billing.manage.

Backend — PR #514 (17 commits)

Branch feat/billing-quota-foundationdev. Exposes QuotaService (reserve/consume/release/observe/peekBalance); enforcement uses a fail-open peek. Flags BILLING_ENFORCE[_DOMAIN] default OFF.

CommitSummary
2c8591eVersioned credit-based plan contract (source of truth)
8edc016plan_versions table seeded from the contract (dashboard-editable)
f4adc3fUsage account + immutable usage ledger schema
8a6e08bUTC-anchored usage-window math
f546ffcQuota service — balances, reserve/finalize/release
17b250aAccess phase + trial fields on billing_access
c9caf70Expand GET /billing/access with phase, balances, trial
17fed30Project the trial phase from the RevenueCat webhook
fda8643Observe-only metering via QuotaService.observe
2de1f2eWire generation + document upload into observe-only metering
4804bf4Observe-only metering for Explore playback + SSE creates
9fe7dc2Enforcement engine behind per-domain flags (402 denials)
333c46bPre-flight enforcement on generation + document upload
4e1e491Document BILLING_ENFORCE[_DOMAIN] flags in .env.example

Plus 3 review-fix commits addressing Greptile P1/P2 feedback: an optimistic-lock reserve, rollover-aware limit checks, @IsDefined DTO filters, and rejecting a past expiresAt. Ships 3 migrations (plan_versions, usage_ledger + usage_accounts, billing_access phase/trial).

Backend — PR #515 — grandfathering (4 commits)

Branch feat/billing-grandfather-cohortdev, stacking on #514. A single GrandfatherService that mass-applies a plan tier to existing users so a pricing change never regresses them. It supports two selection modes over one shared grantToUsers core:

Safety properties: idempotent (keyed on campaign:userId, re-running an apply is a no-op), bounded (soft cap 1,000 / hard cap 5,000 per run), and overlay-safe — a grant only ever lifts a user and never demotes an existing payer. 29/29 grandfather + billing tests pass.

iOS — PR #309 (9 commits)

CommitSummary
772daf4eDecode the expanded /billing/access contract; extend PlanLimits
d8d93c06Align local fallback plan to the credit-based Phase-0 contract (+ plusTrial)
7dd7e34bTests: expanded access-contract decoding
a5a174e3React to the backend's structured 402 billing-denial (decode + placement mapping)

Branch feat/billing-plan-contractdevelop. Client robustness: phase and unknown denial codes decode leniently (never fail); new access fields are optional (old responses still decode); the 402 handler gates on error == "billing_denied" so non-billing 402s keep prior behavior. The typed error + placement mapping this PR exposes is what #311 (rescue ladder) and #312 (gates) build the presentation layer on, below.

iOS — PR #311 — offer-stage rescue ladder (6 commits)

Branch feat/billing-offer-ladderdevelop, stacking on #309. A discount downsell ladder that escalates when a user walks away from the paywall, turning a dismiss into a rescue offer rather than a lost conversion.

13 OfferLadderTests pass; the Greptile re-review came back green.

iOS — PR #312 — Phase-4 gates, trial & live balances (12 commits)

Branch feat/billing-phase4develop, stacking on #311. The client-side enforcement surface — every gate built on a fail-open EntitlementGate helper so a decode miss or missing balance never wrongly blocks a paying user.

GateHow it's enforced
TranscripttranscriptAccess capability
Playback-speed ceilingplaybackSpeedMax (Free 1.0× / Plus 1.5× / Pro 2.0×)
Premium soundscapesSoundscapeAccess
Explore listening-timevia the explorePlayback meter balance — fixes can(.explorePlayback), which had hard-returned true
File size + upload countfileMaxBytes + the fileUploads meter

On top of the gates: a trial countdown banner and usage nudges on the MainTabView home overlay, and the Plan & Billing screen now reads live per-meter balances (falling back to the legacy display). The pro_sources placement is intentionally not wired — there is no pro-sources feature to gate yet.

Full iOS stack compiles + 356 unit tests pass (Xcode 26.5); the Greptile re-review came back green.

Admin — PR #163 — grants, grandfathering & audit (11 commits)

Branch feat/billing-grants-admindev. The operator console for the whole system, with every mutation behind a billing.manage-gated server action in lib/actions.ts.

tsc + next build both clean; all admin security review comments fixed & pushed.

Enforcement model & flags

BillingService.enforce() is a single pre-flight run before each paid action. It checks, in order: capability (e.g. documentUpload, remix), file size (per-file byte cap), episode duration (plan cap), and the metered allowance (read-only peek). Behaviour is governed by flags:

FlagDomain coveredDefault
BILLING_ENFORCEMaster switch for all domainsoff
BILLING_ENFORCE_GENERATIONRecurring/one-time credits + episode-duration capsoff
BILLING_ENFORCE_FILESdocumentUpload capability + per-file byte cap + file creditsoff
BILLING_ENFORCE_PLAYBACKFree Explore playback minutes (reserved; URL gating is a follow-up)off
BILLING_ENFORCE_CAPABILITIESFeature capability gates (remix, transcript, pro sources, …)off

A per-domain flag overrides the master (including an explicit false to carve a domain out). With a domain off, enforce() only logs would-deny — never blocks.

The 402 denial contract

When a domain is enforced, the action returns HTTP 402 with a structured body that the iOS client decodes to route to the right paywall:

{
  "error": "billing_denied",
  "code": "CAPABILITY_REQUIRED" | "QUOTA_EXHAUSTED" | "DURATION_LIMIT"
        | "FILE_LIMIT" | "ACCESS_UNKNOWN",
  "message": "…",
  "requiredTier": "plus" | "pro" | null,
  "meter": "recurringGeneration | oneTimeGeneration | explorePlayback | fileUploads" | null,
  "remaining": Int | null,   "limit": Int | null,   "resetAt": ISO8601 | null,
  "maxMinutes": Int | null,  "maxBytes": Int | null,
  "placement": "generationAllowance | higherRecurringAllowance | documentAttach | remix | …"
}

iOS maps this to a paywall placement in priority order: backend placement hint → codemeter. SSE create endpoints surface the same denial as a stream error (not an HTTP status) — client-side SSE handling of that is a follow-up.

Live vs observe-only vs dark

CapabilityState today
Access snapshot, trial phase, balances, plan versionslive & additive
Usage metering (generation, upload, Explore playback, SSE creates)observe-only records + logs would-deny, never blocks
Enforcement / 402 denials (generation, files, capabilities)dark wired, flag-gated, off by default
iOS 402 decode + placement mappingdormant ready; activates when flags flip
Grandfather grants — cohort + hand-picked (#515)live & additive overlay-safe (never demotes a payer), idempotent, audited
iOS feature gates — transcript, speed, soundscapes, files, Explore time (#312)fail-open gated to plan; a decode miss never wrongly blocks
iOS offer-stage rescue ladder (#311)live client-side escalates a discount on paywall dismiss
Admin grants / grandfathering / audit (#163)live gated on billing.manage

Verification

How to turn enforcement on (when ready)

  1. Confirm real usage looks right by reading the ledger / would-deny logs while still in observe-only.
  2. Set one domain flag on dev, e.g. BILLING_ENFORCE_CAPABILITIES=true, and verify 402s + the iOS paywall route.
  3. Ramp the remaining domains (FILES, GENERATION) one at a time.
  4. Only enable in prod after the webhook secret is set and a real sandbox purchase projects a tier end to end.

Deferred follow-ups (deliberately separate)

Action items for you

ItemWhat's needed
Review & merge the PRsBackend #514 + #515 → dev, iOS #309 → #311 → #312 → develop, Admin #163 → dev. All are additive / observe-only / flag-gated and safe. Merge the iOS stack and the backend stack in order (#309→#311→#312; #514→#515).
Apply migrations on dev#514 authored 3 migrations (plan_versions, usage_ledger + usage_accounts, billing_access phase/trial); #515 needs none (grant rows are the audit). They apply on dev-deploy when #514 merges — or start Docker and run prisma migrate deploy + npm run seed:plan-versions now.
Configure RevenueCat offeringsThe rescue ladder (#311) expects RC offerings default / save50 / save65; it degrades gracefully if they're missing, but the discount downsell only works once they exist.
Product sign-offsPrices, 50/65 discount duration, and a few rollover caps still use PRD defaults (flagged in plan-contract.ts). Needed before store config, not before merge.

All 59 commits are pushed across six open PRs, awaiting review. Every code-review comment is fixed & pushed and the iOS #311/#312 re-reviews are green — but nothing is merged yet.