Lissin Monetization — Implementation Final Report
2026-07-18 · updated 2026-07-20 · base branch = dev everywhere · 59 commits across six PRs · build + tests green
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
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 API —
GET /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-foundation → dev. Exposes QuotaService (reserve/consume/release/observe/peekBalance); enforcement uses a fail-open peek. Flags BILLING_ENFORCE[_DOMAIN] default OFF.
| Commit | Summary |
|---|---|
2c8591e | Versioned credit-based plan contract (source of truth) |
8edc016 | plan_versions table seeded from the contract (dashboard-editable) |
f4adc3f | Usage account + immutable usage ledger schema |
8a6e08b | UTC-anchored usage-window math |
f546ffc | Quota service — balances, reserve/finalize/release |
17b250a | Access phase + trial fields on billing_access |
c9caf70 | Expand GET /billing/access with phase, balances, trial |
17fed30 | Project the trial phase from the RevenueCat webhook |
fda8643 | Observe-only metering via QuotaService.observe |
2de1f2e | Wire generation + document upload into observe-only metering |
4804bf4 | Observe-only metering for Explore playback + SSE creates |
9fe7dc2 | Enforcement engine behind per-domain flags (402 denials) |
333c46b | Pre-flight enforcement on generation + document upload |
4e1e491 | Document 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-cohort → dev, 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:
- Cohort selection —
preview+ idempotent bulkapplyover an allow-listed filter set: signup-date and last-active windows, with excludes for banned / deleted / specific email domains. No free-form query surface. - Hand-picked selection —
preview-users/apply-userstake an explicit list of user IDs for surgical grants. - Campaign audit — list campaigns + drill into per-campaign grants. The grant rows are the audit, so there is no migration.
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)
| Commit | Summary |
|---|---|
772daf4e | Decode the expanded /billing/access contract; extend PlanLimits |
d8d93c06 | Align local fallback plan to the credit-based Phase-0 contract (+ plusTrial) |
7dd7e34b | Tests: expanded access-contract decoding |
a5a174e3 | React to the backend's structured 402 billing-denial (decode + placement mapping) |
Branch feat/billing-plan-contract → develop. 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-ladder → develop, 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.
- Stages —
OfferStagerunsstandard → save50 → save65, driven by anOfferLadderCoordinatorthat persists the highest stage reached inUserDefaults, resumes it on reopen, and resets it on a successful purchase. - Escalation — dismissing the paywall (custom
PaywallView+RevenueCatPaywallHost) escalates to the next discount. A stage is committed only after its offering actually loads (peek → load → commit), and dismissal is re-entrancy-guarded so a fast double-dismiss can't skip a rung. - RevenueCat wiring —
loadPackages(offeringIdentifier:)via the provider, backed by RC offeringsdefault/save50/save65, degrading gracefully to the prior offering if one is missing.
13 OfferLadderTests pass; the Greptile re-review came back green.
iOS — PR #312 — Phase-4 gates, trial & live balances (12 commits)
Branch feat/billing-phase4 → develop, 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.
| Gate | How it's enforced |
|---|---|
| Transcript | transcriptAccess capability |
| Playback-speed ceiling | playbackSpeedMax (Free 1.0× / Plus 1.5× / Pro 2.0×) |
| Premium soundscapes | SoundscapeAccess |
| Explore listening-time | via the explorePlayback meter balance — fixes can(.explorePlayback), which had hard-returned true |
| File size + upload count | fileMaxBytes + 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-admin → dev. The operator console for the whole system, with every mutation behind a billing.manage-gated server action in lib/actions.ts.
- Per-user grants — grant / revoke / history with operator attribution, plus the expanded
/billing/accesscontract rendered inline. - Cohort grandfathering page — a builder → preview → two-click apply flow over the backend cohort endpoints.
- Users-page multi-select — select rows → "Grandfather selected (N)" dialog (preview → confirm → apply) for hand-picked grants.
- Audit page —
/billing/campaignswith per-campaign grant drill-down, wired into nav.
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:
| Flag | Domain covered | Default |
|---|---|---|
BILLING_ENFORCE | Master switch for all domains | off |
BILLING_ENFORCE_GENERATION | Recurring/one-time credits + episode-duration caps | off |
BILLING_ENFORCE_FILES | documentUpload capability + per-file byte cap + file credits | off |
BILLING_ENFORCE_PLAYBACK | Free Explore playback minutes (reserved; URL gating is a follow-up) | off |
BILLING_ENFORCE_CAPABILITIES | Feature 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 → code → meter. 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
| Capability | State today |
|---|---|
| Access snapshot, trial phase, balances, plan versions | live & 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 mapping | dormant 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
- Backend (#514 + #515):
nest buildclean; 29/29 billing + grandfather tests pass. - iOS (#309 + #311 + #312): the full stack compiles and 356 unit tests pass (Xcode 26.5), including the 13
OfferLadderTestsand the expanded access-contract / gate suites. - Admin (#163):
tsc+next buildboth clean. - Reviews: every code-review comment (Greptile P1/P2 + admin security) is fixed & pushed; the iOS #311 and #312 re-reviews came back green.
- Safety: everything is observe-only / additive / flag-gated — metering is fire-and-forget and non-fatal; enforcement is off by default; client gates fail open; grandfather grants only ever lift a user; all new access/response fields are optional and backward-compatible.
How to turn enforcement on (when ready)
- Confirm real usage looks right by reading the ledger / would-deny logs while still in observe-only.
- Set one domain flag on dev, e.g.
BILLING_ENFORCE_CAPABILITIES=true, and verify 402s + the iOS paywall route. - Ramp the remaining domains (
FILES,GENERATION) one at a time. - 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)
- Explore-playback signed-URL denial — the backend
PLAYBACKflag is reserved; denying playback URLs mid-session is UX-sensitive and deserves its own change (metering is observing it, and #312 already gates client-side listening-time via the meter balance). - Scheduler per-episode recurring metering — user-initiated recurring creates are metered; the scheduler's automated episode generation is a separate hook.
- Atomic reserve/consume lifecycle — enforcement uses a fail-open
peek(small concurrency window); #514 now ships an optimistic-lockreserve/consume/release, ready to wire the atomic reserve-before-enqueue path for strict "at most one credit" guarantees. - SSE-stream denials on iOS — HTTP 402s decode + present a paywall (#309/#311/#312); the create endpoints surface the same denial as a stream error, and client-side handling of that is next.
pro_sourcespaywall placement — intentionally not wired; there is no pro-sources feature to gate yet.- Admin editor for plan_versions — per-user grants, cohort + multi-select grandfathering, and the campaign audit all shipped in #163; the dashboard UI to edit the
plan_versionsnumbers themselves is a later admin task (the DB is already the runtime source of truth).
Action items for you
| Item | What's needed |
|---|---|
| Review & merge the PRs | Backend #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 offerings | The 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-offs | Prices, 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.