Architecture & rollout audit

Lissin Monetization, User Tiers, Paywalls, Trials, and Grandfathering Report

Updated 2026-07-20. The architecture and design below still hold and are unchanged. The status and rollout sections have been refreshed to current reality: the build now stands at 6 PRs / 59 commits (all observe-only, additive, and flag-gated; none merged). The offer-stage rescue ladder (#311), iOS Phase-4 gates + trial banner + usage nudges + live Plan & Billing balances (#312), backend flag-gated enforcement (#514, dark), hand-picked + cohort grandfathering with audit (#515), and admin multi-select bulk grandfather (#163) are all implemented. See the Master report · hub.

Date: 2026-07-18 · updated 2026-07-20

Executive conclusion

Lissin has three different generations of subscription work, and they are not yet one production system:

  1. The legacy database and API profile expose FREE / PREMIUM / PRO.
  2. The newer backend billing branch implements FREE / PLUS / PRO, RevenueCat webhook projection, effective-access snapshots, and expiring complimentary grants.
  3. The newer iOS subscription branch implements FREE / PLUS / PRO, StoreKit/RevenueCat providers, plan UI, contextual paywall placements, and several client-side gates.

This work is now consolidated into six stacked PRs — iOS #309→#311→#312 (onto develop), backend #514 + #515 (onto dev), and admin #163 (onto dev) — totalling 59 commits, all observe-only, additive, and flag-gated, and none merged yet. Every code-review comment has been fixed and pushed; the iOS #311 and #312 re-reviews completed with SUCCESS. The full iOS stack compiles and passes 356 tests (Xcode 26.5); the backend passes 29/29 billing + grandfather tests; the admin builds clean (tsc + next build). The backend now implements a versioned plan contract, a usage ledger + QuotaService, and flag-gated 402 enforcement across paid endpoints — currently default OFF and fail-open (a dark launch), so it meters and observes without blocking until the flag is flipped. iOS decodes the expanded access contract and maps 402s to paywall placements, so its gates are now backed by server entitlements rather than being client-only merchandising.

The 50%→65% rescue-offer ladder (#311), the 3-day restricted Plus trial with an in-app countdown banner (#312), and hand-picked + cohort grandfathering with a campaign audit (#515 backend / #163 admin) are all now implemented. What remains is largely non-code: merging the six PRs, building out the RevenueCat / App Store Connect catalog (default + save50 + save65 offerings, the trial intro offer, placements, and the production webhook), product price/discount sign-offs, and flipping enforcement on behind QA and a percentage ramp. There is still no separate 4-week free trial — the ladder is standard→50%→65%, not an additional long trial — and there is no dedicated approved monetization/lifecycle ADR beyond the in-code placement registry.

Repository and branch status

iOS

The iOS monetization work now lands as three stacked PRs onto develop: #309 (decode the expanded billing-access contract + 402→placement mapping), #311 (offer-stage rescue ladder, stacks on #309), and #312 (Phase-4 gates, trial banner, usage nudges, live Plan & Billing balances, stacks on #311). The full stack compiles and passes 356 tests (Xcode 26.5); the #311 and #312 re-reviews completed with SUCCESS and all review comments were fixed and pushed. None of the three is merged yet. Together they contain:

Backend

The backend monetization work now lands as two PRs onto dev: #514 (plan contract → plan_versions table → usage ledger → QuotaService → expanded /billing/access → webhook trial projection → observe-only metering → flag-gated 402 enforcement, default OFF and fail-open) and #515 (grandfather cohorts + hand-picked user IDs + campaign audit; idempotent, bounded, and overlay-safe). Billing + grandfather tests pass 29/29.

Neither PR is merged, so backend main/dev do not yet contain this contract. Deployment and migration state must therefore be verified independently; source presence on a PR branch does not prove that production has the tables or endpoints.

User-tier database implementation

Legacy tier system

prisma/schema.prisma has a legacy SubscriptionTier enum:

It is used in two places:

This older tier field is returned in user/profile data and displayed in admin user data, but it is not used for feature enforcement in the backend. It also uses PREMIUM, whereas the new system uses PLUS.

New billing-access system

Migration 20260714120000_add_billing_contract introduces a new BillingTier:

It creates:

billing_events

An immutable, idempotent inbox for RevenueCat and client reconciliation events. provider_event_id is globally unique, preventing duplicate provider retries from being applied twice.

billing_access

The current provider-projected paid access for each user:

billing_grants

Expiring complimentary Plus or Pro access issued by an operator:

An active grant overlays paid access, but never downgrades it. For example, a complimentary Plus grant does not reduce a paying Pro user to Plus.

Source of truth conflict

The repository currently has two tier stores:

StoreValuesCurrent role
users.subscription_tierFree, Premium, ProLegacy profile/admin field
subscriptionsFree, Premium, ProLegacy subscription record
billing_accessFree, Plus, ProNew provider projection
billing_grantsPlus, ProNew complimentary overlay

No code synchronizes the legacy fields with billing_access. The new access snapshot should become canonical, and the legacy fields should either be removed or explicitly maintained as derived compatibility fields. Leaving both writable will cause users to appear as different tiers in different surfaces.

Backend billing API

The new backend branch exposes:

The access snapshot includes:

RevenueCat events currently projected include initial purchase, renewal, cancellation, uncancellation, expiration, product change, non-renewing purchase, promotional access, and transfer.

Important limitation: client reconciliation does not validate a receipt or RevenueCat customer state. The code explicitly leaves this as a TODO. Actual entitlement changes currently depend on RevenueCat webhooks.

Planned plan limits currently encoded in the backend

These limits are now encoded as a versioned plan contract (plan_versions, #514) rather than inline hypotheses, though product price/discount sign-off is still pending. The model is weekly, with a 2× base rollover and a reduced-allowance 3-day restricted Plus trial (1 recurring show/week and 4 one-time episodes/week during the trial).

Capability/limitFreePlusPro
Recurring (scheduled) shows / week125
One-time episodes / week21050
Weekly rollover2× base2× base2× base
Documents0325
Max document size020 MiB50 MiB
Max episode length15 min20 min45 min
CadenceWeeklyWeekly, weekdays, dailyWeekly, weekdays, daily
Explore playback45 min / weekYesYes
Starter personal showYesYesYes
Personal generationYesYesYes
Additional showsNoYesYes
Personal episode playbackNoYesYes
Document uploadNoYesYes
RemixNoYesYes
Source monitoringNoNoYes
Advanced transcriptNoNoYes
Creator exportNoNoYes
Long-form generationNoNoYes

Usage is now tracked by a usage ledger and QuotaService (#514), which meter recurring shows, one-time episodes, and Explore time against the plan contract. Metering currently runs in observe-only mode, and the expanded /billing/access response returns real per-meter balances alongside server time, plan version, phase, and trial state. Document-count and document-byte meters are represented but are not the primary launch meters.

Backend enforcement status

The backend now has a QuotaService and flag-gated 402 enforcement wired across paid endpoints (#514). Enforcement is default OFF and fail-open — a dark launch — so the server meters usage and can return 402 limit responses, but until the flag is flipped it observes rather than blocks. In this state:

The backend billing contract is now both a strong entitlement-read foundation and a dark-launched enforcement system. Making it authoritative requires flipping the enforcement flag, validating under QA, and ramping by percentage.

iOS status on the currently checked-out branch

The pre-PR baseline branch — the state before the #309→#311→#312 stack is applied — contains a mock two-tier system:

Consequently, that baseline has no normally reachable production paywall or effective subscription gate. The production paywall, gates, and subscription logic now live in the #309→#311→#312 stack described below (not yet merged).

iOS implementation on feat/subscription-paywall

Purchase architecture

The feature branch adds:

Paywall placement registry

The feature branch defines nine placements:

PlacementDefault tierPresentationIntended trigger
OnboardingPlusFullFinal onboarding choice
Locked episodePlusFullOpening a paid personal episode
Generation allowancePlusAction sheetUsage exhausted
Cadence changePlusMediumChoosing weekdays/daily
Document attachPlusMediumAttaching a document
RemixPlusAction sheetRemixing without capability
Second showPlusFullCreating beyond the free starter show
Pro sourcesProMediumOpening Pro source features
Plan and billingPlusFullProfile plan-management entry

This placement registry is the closest thing in the repository to paywall-placement documentation, but it is code, not a reviewed lifecycle/monetization specification.

Contextual gate status

The branch contains client implementations for:

These gates are now rebased onto the live navigation and paired with backend entitlements in the #309→#311→#312 stack: iOS decodes the expanded access contract and maps 402 responses to the matching placement (#309), and #312 adds the Phase-4 gates — transcript, playback speed, soundscape, Explore time, and file limits — alongside the trial countdown banner, usage nudges, and live Plan & Billing balances. Locked-episode gating is applied on the live ContentDetailView route. Pro-source access is intentionally not wired, because no such feature exists yet.

Trials and discounts already configured

The feature branch's local StoreKit file defines six products:

The iOS side now implements a 50%→65% rescue-offer ladder (#311): after the standard offer, successive dismissals step down to a 50%-off stage (save50) and then a 65%-off stage (save65). The ladder persists across sessions, resumes where it left off, resets on purchase, and commits a stage only after its RevenueCat offering loads (with graceful fallback if it does not). A 3-day restricted Plus trial is now part of the plan contract and is surfaced in-app with a countdown banner (#312). Still not implemented: a separate 4-week free trial — the ladder is standard→50%→65%, not a long trial — and the RevenueCat / App Store Connect catalog that must back all of this. The default/save50/save65 offerings, the trial intro offer, the placements, and the production webhook still need to be configured and signed off.

The custom paywall has a one-week trial timeline (today, reminder around day five, charge on day seven). The reminder toggle is UI-only and has a TODO; it does not schedule a local notification.

Offer-code redemption code exists, but the production UI flag is disabled until App Store configuration and support flows are ready.

Grandfathering status

Grandfathering is now implemented (#515 backend / #163 admin).

What exists:

What remains (mostly product/policy decisions rather than code):

The notification system has NEW_ONLY / LEGACY_ONLY / ALL cohorts, but that cohort facility is for messaging and does not grant billing access.

Notification and in-app nudge infrastructure

The backend has a documented notification-campaign system supporting time-based and event-based campaigns, audience queries, send windows, throttling, delivery history, admin management, and new/legacy cohorts.

It is suitable infrastructure for lifecycle messages, but monetization messages and triggers such as paywall.dismissed, trial.started, trial.ending, allowance.80_percent, or offer.eligible are not currently defined or wired.

The iOS app now has in-app usage nudges and a trial countdown banner (#312) on top of its notification soft-ask and general sheet/coachmark infrastructure, but there is still no central monetization-nudge scheduler, frequency cap, mutual-exclusion policy, or cross-device exposure history coordinating them.

Do not implement 50% and 65% as hardcoded visual claims shown to everyone after repeated dismissals. The displayed price must correspond to an Apple-configured offer for which the account is eligible.

Use this lifecycle instead:

  1. New eligible user: show the standard onboarding/contextual paywall with a clearly disclosed introductory trial or standard introductory price.
  2. Engaged free user: repeat the normal paywall only at high-intent gates, with frequency caps. Do not automatically invent a deeper price.
  3. Qualified non-converter: expose a real 50% promotional/offer-code campaign configured in App Store Connect and represented in RevenueCat.
  4. Lapsed subscriber: use an Apple win-back offer. A 65% offer is best reserved for this cohort or another explicitly approved, store-eligible segment.
  5. Grandfathered legacy user: grant Plus independently of App Store purchase state and do not show acquisition discounts while the grant remains active.

Apple's supported mechanisms are:

Pick one acquisition trial for the subscription group after measuring activation time:

Apple introductory-offer eligibility is managed at the subscription-group level. The product should not promise that the same user will receive a 3-day trial followed by another 4-week introductory trial. A four-week follow-up would need a separately eligible promotional/offer-code campaign or a complimentary backend grant, with clear renewal and access behavior.

Required implementation plan

Phase 0: approve the product contract

Status: partially done. Tiers, capabilities, and limits are now encoded as a versioned plan_versions contract (#514); product price/discount and trial sign-offs are still pending.

Create a monetization ADR defining:

Phase 1: unify data and branches

Status: built, not merged. The unified contract exists across the six PRs; merging iOS #309→#311→#312, backend #514 + #515, and admin #163 (one at a time) is the remaining step.

Phase 2: authoritative backend enforcement

Status: built as a dark launch (#514). QuotaService + usage ledger + flag-gated 402 enforcement are wired across paid endpoints, currently default OFF and fail-open. Flipping the flag, QA, and a percentage ramp remain.

Phase 3: complete iOS integration

Status: built (#309/#311/#312). iOS decodes the expanded contract and maps 402s to placements (#309), adds the rescue ladder (#311), and adds Phase-4 gates, the trial banner, usage nudges, and live Plan & Billing balances on live routes (#312). pro_sources is intentionally not wired.

Phase 4: grandfathering

Status: built (#515 / #163). Cohort + hand-picked bulk grandfathering with a campaign audit is implemented on the backend (#515), with the grants/cohort UI, Users-page multi-select, and audit page on admin (#163 and a standalone redesigned admin PR). The read-only cohort count, idempotent per-user grants, and audit export are covered; the cutoff/lifetime policy sign-off and end-user comms remain.

Phase 5: offers and nudges

Status: in-app side built; catalog remains. The 50%→65% rescue ladder (#311) and the trial/usage nudges and countdown banner (#312) are implemented client-side. Remaining: configure the RevenueCat / App Store Connect catalog (the default/save50/save65 offerings, the trial intro offer, placements, and the production webhook) and wire the backend lifecycle events/audience rules below.

Phase 6: rollout

Status: remaining. With the six PRs built but unmerged, rollout is the outstanding work: merge the PRs, then proceed through the steps below.

Final readiness assessment

AreaStatus
Database representation for Plus/ProImplemented as versioned plan_versions contract (#514); PR open, not merged
RevenueCat webhook projectionImplemented and unit-tested, incl. trial projection (#514); not merged
Complimentary grantsImplemented per user (#514/#515)
Backend feature enforcementBuilt as flag-gated / dark (#514, default OFF, fail-open); flip + QA + ramp remain
iOS StoreKit/RevenueCat architectureImplemented (#309/#311/#312); compiles, 356 tests
iOS contextual gatesImplemented on live routes and mapped to backend 402 (#312); pro_sources not wired
Reachable paywall on checked-out iOS branchLives in the #309→#311→#312 stack (not yet merged)
50% introductory pricingImplemented as the save50 rescue stage (#311); App Store catalog config remains
65% offerImplemented as the save65 rescue stage (#311); App Store catalog config remains
3-day trialImplemented in plan contract + iOS countdown banner (#514/#312); intro-offer config remains
4-week free trialNot implemented (rescue ladder is standard→50%→65%, not a long trial)
One-week Plus trialRepresented in local StoreKit config; superseded by the 3-day plan-contract trial
Trial reminder notificationIn-app countdown banner shipped (#312); scheduled push reminders still not wired
Automatic grandfatheringImplemented — cohort + hand-picked bulk grants with audit (#515/#163); cutoff/lifetime policy sign-off remains
Monetization/paywall lifecycle documentationPlacement registry + rescue-ladder/trial/nudge behavior now in code; no separate approved ADR