Lissin Monetization, User Tiers, Paywalls, Trials, and Grandfathering Report
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:
- The legacy database and API profile expose
FREE / PREMIUM / PRO. - The newer backend billing branch implements
FREE / PLUS / PRO, RevenueCat webhook projection, effective-access snapshots, and expiring complimentary grants. - 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:
- StoreKit configuration and local StoreKit provider.
- RevenueCat provider and RevenueCat paywall renderer, with
default/save50/save65offerings and graceful fallback. - Backend billing-access API client decoding the expanded contract (phase, plan version, server time, trial, and per-meter balances).
FREE / PLUS / PROaccess models.- Purchase, restore, reconciliation, foreground sync, and delayed-activation states.
- Nine paywall placements, with
402responses mapped to the matching placement. - Contextual gates for transcript, playback speed, soundscape, Explore time, and file limits, plus generation allowance, cadence, documents, remix, second show, and locked episodes (
pro_sourcesintentionally not wired — no such feature yet). - An offer-stage rescue ladder (standard→50%→65%) that persists, resumes, resets on purchase, and commits a stage only after its offering loads.
- A trial countdown banner and usage nudges.
- An onboarding plan-offer screen.
- A Profile Plan & Billing screen showing live per-meter balances.
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:
FREEPREMIUMPRO
It is used in two places:
users.subscription_tier, defaulting toFREE.subscriptions.tier, alongside subscription status, provider, provider subscription ID, billing dates, cancellation date, and metadata.
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:
FREEPLUSPRO
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:
- Tier.
- Revision number.
- Access source.
- Renewal state.
- Expiration time.
- Management platform.
- Last provider event timestamp.
- Active-grant marker.
billing_grants
Expiring complimentary Plus or Pro access issued by an operator:
- User.
- Tier.
- Start and expiration.
- Revocation.
- Reason.
- Campaign.
- Granting operator.
- Per-user idempotency key.
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:
| Store | Values | Current role |
|---|---|---|
users.subscription_tier | Free, Premium, Pro | Legacy profile/admin field |
subscriptions | Free, Premium, Pro | Legacy subscription record |
billing_access | Free, Plus, Pro | New provider projection |
billing_grants | Plus, Pro | New 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:
GET /billing/access: authenticated effective access snapshot.POST /billing/reconciliations: records purchase, restore, transaction-update, foreground, or offer-code reconciliation attempts.POST /billing/webhooks/revenuecat: authenticated RevenueCat webhook ingestion.- Admin endpoints to create/revoke grants and inspect a user's access and grant history.
The access snapshot includes:
- Revision.
- Environment.
- Effective tier.
- Access source.
- Capabilities.
- Limits.
- Usage.
- Renewal status.
- Expiry.
- Management platform.
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/limit | Free | Plus | Pro |
|---|---|---|---|
| Recurring (scheduled) shows / week | 1 | 2 | 5 |
| One-time episodes / week | 2 | 10 | 50 |
| Weekly rollover | 2× base | 2× base | 2× base |
| Documents | 0 | 3 | 25 |
| Max document size | 0 | 20 MiB | 50 MiB |
| Max episode length | 15 min | 20 min | 45 min |
| Cadence | Weekly | Weekly, weekdays, daily | Weekly, weekdays, daily |
| Explore playback | 45 min / week | Yes | Yes |
| Starter personal show | Yes | Yes | Yes |
| Personal generation | Yes | Yes | Yes |
| Additional shows | No | Yes | Yes |
| Personal episode playback | No | Yes | Yes |
| Document upload | No | Yes | Yes |
| Remix | No | Yes | Yes |
| Source monitoring | No | No | Yes |
| Advanced transcript | No | No | Yes |
| Creator export | No | No | Yes |
| Long-form generation | No | No | Yes |
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:
- Usage is metered against the plan contract via a usage ledger (observe-only).
- The
402enforcement paths exist and are exercised by tests, but are gated off in production. - Fail-open means a metering or quota error never blocks a legitimate request.
- Because the flag is off, a modified client or a direct API call is metered but not yet blocked.
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:
- Free and Premium only.
- Subscribe/unsubscribe merely toggles in-memory state.
- No persistence.
- No StoreKit transaction.
- Restore, Terms, and Privacy actions are nonfunctional.
- One-episode-per-day unlock logic exists only in the old
ShowDetailView. - The active Home, Explore, My Shows, and Search stacks navigate to
ContentDetailView, notShowDetailView.
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:
BillingServiceas the client access coordinator.- A provider protocol with mock, StoreKit-local, RevenueCat, and unconfigured implementations.
- Fail-closed Release configuration.
- StoreKit transaction observation.
- RevenueCat customer/access projection.
- Backend access reconciliation and revision handling.
- Restore behavior and "nothing to restore" feedback.
- RevenueCat-console paywalls behind a renderer toggle.
- Local custom paywalls as an alternative renderer.
Paywall placement registry
The feature branch defines nine placements:
| Placement | Default tier | Presentation | Intended trigger |
|---|---|---|---|
| Onboarding | Plus | Full | Final onboarding choice |
| Locked episode | Plus | Full | Opening a paid personal episode |
| Generation allowance | Plus | Action sheet | Usage exhausted |
| Cadence change | Plus | Medium | Choosing weekdays/daily |
| Document attach | Plus | Medium | Attaching a document |
| Remix | Plus | Action sheet | Remixing without capability |
| Second show | Plus | Full | Creating beyond the free starter show |
| Pro sources | Pro | Medium | Opening Pro source features |
| Plan and billing | Plus | Full | Profile 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:
- Generation allowance.
- Restricted cadence.
- Document attachment.
- Remix.
- Second show.
- Locked episode.
- Pro source access.
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:
- Plus weekly: $3.99, then four weekly periods at $1.99 (approximately 50% introductory pricing).
- Plus monthly: $9.99 with a one-week free trial.
- Plus yearly: $69.99 with a one-week free trial.
- Pro weekly: $6.99, then four weekly periods at $3.49 (approximately 50% introductory pricing).
- Pro monthly: $19.99 with one introductory month at $9.99 (approximately 50%).
- Pro yearly: $199.99 with one introductory year at $149.99 (25%).
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:
- Grandfather cohorts plus hand-picked user IDs, so a campaign can target a whole legacy cohort or an explicit list of individual users.
- A campaign audit recording which policy/campaign was applied, backed by an admin audit page.
- Idempotent, bounded, and overlay-safe grant issuance (a grant never downgrades paid access).
- Admin grants + cohort grandfathering UI + a Users-page multi-select bulk grandfather action (a standalone redesigned admin PR off
devalso carries these). - The pre-existing building blocks: expiring Plus/Pro grants, campaign and reason fields, and per-user/campaign idempotency keys.
What remains (mostly product/policy decisions rather than code):
- Signing off the exact legacy-user cutoff timestamp and the lifetime-versus-expiring access decision.
- If access is to be lifetime, a durable non-expiring grant representation;
billing_grants.expires_atis currently required. - End-user communication and success analytics.
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.
Recommended offer strategy
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:
- New eligible user: show the standard onboarding/contextual paywall with a clearly disclosed introductory trial or standard introductory price.
- Engaged free user: repeat the normal paywall only at high-intent gates, with frequency caps. Do not automatically invent a deeper price.
- Qualified non-converter: expose a real 50% promotional/offer-code campaign configured in App Store Connect and represented in RevenueCat.
- Lapsed subscriber: use an Apple win-back offer. A 65% offer is best reserved for this cohort or another explicitly approved, store-eligible segment.
- 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:
- Introductory offers for acquiring eligible new subscribers.
- Promotional offers for eligible current or previous subscribers; these require server-signed offer support when implemented directly.
- Offer codes for acquire/retain/win-back campaigns.
- Win-back offers for eligible churned subscribers.
Recommended trial design
Pick one acquisition trial for the subscription group after measuring activation time:
- 3 days: only appropriate if users consistently experience the core value in the first session or first day.
- 7 days: matches the current feature-branch implementation and gives users time to receive a scheduled weekly show.
- 4 weeks: materially increases exposure and should be treated as a deliberate acquisition experiment, not an additional trial granted after a shorter trial.
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:
- Canonical tiers: Free, Plus, Pro.
- Exact capabilities and limits.
- Product IDs and regional prices.
- Trial duration and eligible products.
- 50% and 65% audience/eligibility rules.
- Grandfather cutoff, duration, and exclusions.
- Paywall placements, cooldowns, and maximum impressions.
- Trial and offer notification schedule.
- Analytics events and experiment assignment.
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.
- Merge/rebase backend billing and iOS subscription branches onto active development branches.
- Make
billing_access + billing_grantsthe canonical access read. - Remove or derive legacy
users.subscription_tierand legacysubscriptionsvalues. - Add an opaque commerce-customer mapping instead of depending solely on UUID-shaped RevenueCat aliases.
- Verify migrations and RevenueCat secrets in development, staging, and production.
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.
- Add a shared entitlement/limit guard to paid endpoints.
- Implement immutable usage ledgers/buckets.
- Reserve generation allowance transactionally before queue submission.
- Finalize actual minutes on completion and release reservation on terminal failure.
- Enforce active shows, cadence, documents, document bytes, episode length, remix, source monitoring, transcript, and export server-side.
- Return structured
403 capability_requiredand429 limit_reachedresponses with the current access snapshot.
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.
- Fetch billing access after authentication, foregrounding, purchase, restore, and webhook/reconciliation delay.
- Use backend snapshots as the final authority while allowing StoreKit/RevenueCat optimistic activation only during bounded reconciliation.
- Attach gates to the live
ContentDetailView, Create, Schedule, document, remix, source, and Profile routes. - Add Plan & Billing to Profile/Settings.
- Ensure every placement sends impression, CTA, dismiss, purchase, restore, and activation events.
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.
- Run a read-only cohort count first.
- Generate one idempotent Plus grant per eligible legacy user using a campaign such as
legacy-plus-2026-v1. - If access is lifetime, change the schema to permit non-expiring grants or introduce an explicit permanent grant type; do not use an arbitrary far-future date silently.
- Produce an audit export and verify sample accounts before enabling new gates.
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.
- Configure the approved intro offer in App Store Connect and RevenueCat.
- Configure a genuine 50% promotional or offer-code campaign for its approved cohort.
- Configure a 65% win-back/promotional offer only for eligible users.
- Add backend events and audience rules for paywall dismissal, allowance thresholds, trial start/end, cancellation, and lapse.
- Add global and per-placement frequency caps, quiet periods after dismissal, and mutual exclusion so users never receive overlapping paywall sheets, in-app nudges, and pushes.
- Schedule trial reminders only after notification permission and user opt-in; cancel them on purchase cancellation, refund, or changed expiry.
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.
- Internal StoreKit testing.
- RevenueCat sandbox and webhook replay tests.
- TestFlight with staff/test cohorts.
- Grandfather dry-run and then controlled backfill.
- Small percentage rollout with server-side kill switches.
- Expand only after entitlement mismatch, purchase activation latency, restore success, refund handling, and conversion metrics are healthy.
Final readiness assessment
| Area | Status |
|---|---|
| Database representation for Plus/Pro | Implemented as versioned plan_versions contract (#514); PR open, not merged |
| RevenueCat webhook projection | Implemented and unit-tested, incl. trial projection (#514); not merged |
| Complimentary grants | Implemented per user (#514/#515) |
| Backend feature enforcement | Built as flag-gated / dark (#514, default OFF, fail-open); flip + QA + ramp remain |
| iOS StoreKit/RevenueCat architecture | Implemented (#309/#311/#312); compiles, 356 tests |
| iOS contextual gates | Implemented on live routes and mapped to backend 402 (#312); pro_sources not wired |
| Reachable paywall on checked-out iOS branch | Lives in the #309→#311→#312 stack (not yet merged) |
| 50% introductory pricing | Implemented as the save50 rescue stage (#311); App Store catalog config remains |
| 65% offer | Implemented as the save65 rescue stage (#311); App Store catalog config remains |
| 3-day trial | Implemented in plan contract + iOS countdown banner (#514/#312); intro-offer config remains |
| 4-week free trial | Not implemented (rescue ladder is standard→50%→65%, not a long trial) |
| One-week Plus trial | Represented in local StoreKit config; superseded by the 3-day plan-contract trial |
| Trial reminder notification | In-app countdown banner shipped (#312); scheduled push reminders still not wired |
| Automatic grandfathering | Implemented — cohort + hand-picked bulk grants with audit (#515/#163); cutoff/lifetime policy sign-off remains |
| Monetization/paywall lifecycle documentation | Placement registry + rescue-ladder/trial/nudge behavior now in code; no separate approved ADR |