Implementation & integration status · backend · iOS · admin

Lissin Billing — Implementation & Integration Status

One-paragraph summary

The subscription system is code-complete across all three repos as 6 open PRs (59 commits, none merged) — all observe-only, additive, and flag-gated. The shared GET /billing/access contract now flows end-to-end: the backend serves an expanded snapshot (phase, plan version, server time, trial window, per-meter balances), iOS decodes it and drives the offer ladder plus Phase-4 gates from it, and admin can grandfather cohorts and hand-picked users against it with an audit trail. iOS is server-authoritative: the moment GET /billing/access returns 200 it trusts the server over its local StoreKit projection. The server only grants paid tiers when a RevenueCat webhook reaches the backend — that webhook is the single load-bearing dependency, and its trial-phase projection is now implemented. What is not done: nothing is merged; enforcement stays observe-only / dark until the flags are flipped; and the RevenueCat / App Store catalog (the default/save50/save65 offerings, trial intro offer, prod webhook) is the user's remaining part — the offer ladder falls back gracefully until the save50/save65 offerings exist.

Where we are — at a glance

Backend code-complete

#514 + #515dev

✅ Quota foundation + grandfathering (21 commits)

✅ nest build clean · 29/29 billing+grandfather tests

✅ Metering observe-only; 402 enforcement flag-gated OFF

Not merged — merge one at a time

iOS code-complete

#309#311#312 · develop

✅ Contract + offer ladder + Phase-4 gates (27 commits)

✅ Compiles + 356 tests (Xcode 26.5)

✅ #311 & #312 re-reviews COMPLETED/SUCCESS

Not merged — order #309→#311→#312

Admin code-complete

#163dev

✅ Grants + cohort & multi-select grandfathering + audit (11 commits)

✅ tsc + next build clean · billing.manage-gated

✅ Grant / revoke / access-snapshot / bulk grandfather

Not merged (admin base = dev env)

6 PRs across the three repos, 59 commits total, none merged — every one is observe-only, additive, and flag-gated (enforcement defaults OFF, fail-open). Every code-review comment has been fixed and pushed; the iOS #311 and #312 re-reviews came back COMPLETED/SUCCESS. Backend #514/#515 target dev, iOS #309/#311/#312 target develop, admin #163 targets dev.

How the integration works

Three components, one server-authoritative contract. RevenueCat sits between the App Store and the backend; the backend is the single source of truth for what tier a user has; iOS and admin both read that truth.

App Store  ──purchase──▶  RevenueCat  ──webhook (Authorization: <secret>)──▶  Backend
                              ▲                                              │
   iOS app ──logIn(userUUID)──┘                                   projects tier onto
      │                                                            billing_access row
      │  GET /billing/access (Firebase auth)                              │
      └──────────────────────────────────────────────────────────◀──────┘
          returns expanded AccessSnapshot {tier, phase, planVersion, capabilities, balances, trial, serverTime}

   Admin ──X-Admin-Secret──▶ /admin/billing/grants  ── writes billing_grants (overlay)

The load-bearing contract

Store purchases only grant access if the RevenueCat webhook reaches the backend.

iOS discards its local StoreKit/RevenueCat projection as soon as GET /billing/access returns 200 (server always wins in conflict resolution). But the backend's reconcile() endpoint — what iOS calls right after a purchase — only records the attempt and bumps a revision; receipt validation is an explicit TODO(revenuecat) (billing.service.ts:164-166). The webhook is therefore the only code path that sets a paid tier. If REVENUECAT_WEBHOOK_SECRET is unset, the webhook returns 501, nothing projects, and a real purchase resolves to tier:"free"reconciliationFailed. The one server-side path that works without RevenueCat is an admin complimentary grant.

A half-deployed billing system is worse than none.

Because the client hard-tags server responses as authoritative, a live /billing/access returning 200 + tier:free while the webhook is dead would actively downgrade paying users with no on-device self-heal. The safe "off" state is 404/5xx — the client maps those to nil and falls back to its local RevenueCat projection, so payers keep access (BillingAccessAPI.isUnavailable). Note the asymmetry: 401/400 do NOT fall back — an auth failure must never silently grant locally-projected paid access.

Purchase happy path (signed-in Beta build)

1
User signs in → iOS calls Purchases.logIn(authenticatedUser.id), so RevenueCat's app_user_id equals the Lissin user UUID. Purchases refuse while RC is anonymous.
2
User hits a gate or the paywall → buys a product (e.g. app.lissin.audio.plus.monthly) via RevenueCat against the App Store sandbox.
3
RevenueCat fires an INITIAL_PURCHASE webhook to POST /billing/webhooks/revenuecat with entitlement_ids:["plus"] and the Authorization header = the shared secret.
4
Backend verifies the secret (exact-string compare), records the event in billing_events (idempotent), derives PLUS from the entitlement, and writes the billing_access row.
5
iOS calls POST /billing/reconciliations then GET /billing/access → gets {tier: plus, capabilities:[…]} with origin:.server, unlocks the feature, and resumes the pending action (play the episode / send the generation / attach the doc).

Backend implementation

NestJS module at src/modules/billing/, global prefix /api/v1. Controllers, a service, a Prisma-backed store, and — as of #514 — a versioned plan contract (plan_versions), a usage ledger, and a QuotaService feeding the expanded snapshot. #515 layers grandfathering (cohorts + hand-picked users + campaign audit) on top.

Endpoints

Method + pathGuardWhat it does
GET /billing/accessFirebaseReturns the expanded AccessSnapshot for the current user — now including phase, planVersion, serverTime, a trial{startsAt,endsAt}|null window, and per-meter balances (recurring generation, one-time generation, explore playback in seconds, file uploads) alongside tier, capabilities, revision, and source. Each meter is a MeterBalance{limit,remaining,used,reserved,rolloverBalance,resetAt,window}. All fields are additive / backward-compatible.
POST /billing/reconciliationsFirebaseIdempotently records a client purchase-attempt and returns a fresh snapshot. Does not validate receipts (TODO(revenuecat)) — only bumps revision.
POST /billing/webhooks/revenuecatsecret publicThe only writer of paid tiers. Authenticated by the Authorization header, not Firebase.
POST /admin/billing/grantsAdminCreate an expiring complimentary grant (plus/pro only).
GET /admin/billing/users/:id/accessAdminEffective snapshot for any user.
GET /admin/billing/users/:id/grantsAdminAll grants incl. expired/revoked.
POST /admin/billing/grants/:id/revokeAdminRevoke a grant (idempotent).

AdminGuard checks header x-admin-secret == ADMIN_SECRET. Fine-grained billing.manage vs users.view permissions exist only in the admin app's RBAC layer — the backend route is a single admin gate.

RevenueCat webhook

Authentication

billing.service.ts:176-182: loads REVENUECAT_WEBHOOK_SECRET; if blank → 501; then an exact-string compare of the entire Authorization header (authorization !== secret → 401). No Bearer parsing, no HMAC — whatever string sits in RevenueCat's Authorization field must equal the env var byte-for-byte.

Idempotency & ordering

Every event is inserted into billing_events keyed on the globally-unique provider_event_id (createMany + skipDuplicates); a duplicate returns {duplicate:true, projected:false}. A stale-event guard compares the event's intrinsic moment (event_timestamp_ms ?? expiration_at_ms) against the stored last_event_at_ms; older events are recorded but not applied, and an EXPIRATION/CANCELLATION with no intrinsic timestamp is refused projection (so a delayed replay can't beat a newer renewal).

Handled events & user resolution

Projected types: INITIAL_PURCHASE, RENEWAL, CANCELLATION, UNCANCELLATION, EXPIRATION, PRODUCT_CHANGE, NON_RENEWING_PURCHASE, PROMOTIONAL, TRANSFER. User is resolved by matching app_user_id / original_app_user_id / aliases[] — filtered to valid UUIDs (anonymous $RCAnonymousID: ids are skipped) — against users.id. No match → recorded, not projected (grants nothing silently).

Trial-phase projection (#514, implemented). The webhook now projects the subscription phase and the trial{startsAt,endsAt} window onto billing_access, so the expanded snapshot can surface trial state to iOS. This is the load-bearing piece and is wired end-to-end; what remains on the RevenueCat side (the trial intro offer in the App Store / RC catalog, plus the default/save50/save65 offerings) is the user's part.

EventEffect on billing_access
INITIAL_PURCHASE / RENEWALtier from entitlement (pro>plus), accessSource=storePurchase, willRenew=true.
PROMOTIONALaccessSource=complimentaryGrant, willRenew=false.
CANCELLATIONKeeps tier + period-end access; only flips willRenew=false.
EXPIRATIONHard reset to FREE / accessSource=none.
TRANSFERStands the sender down (reset to FREE); recipient's tier arrives as a separate ordinary event. (commit 1d69f3a)

Tier / plan model

PlanTier = free | plus | pro. Tier is derived purely from RevenueCat entitlement identifiers by lowercase match ('pro'/'plus') — there are no App Store product IDs hard-coded on the backend. Capabilities and limits per tier are now served from a versioned plan contract (plan_versions, surfaced as planVersion in the snapshot), and the meter model is split into recurring generation, one-time generation, explore playback, and file uploads. The baseline limits (below, pending final product sign-off) are:

FreePlusPro
Active shows1520
Generated minutes15 / rolling 7d180 / month600 / month
Documents03 (20 MiB)25 (50 MiB)
Episode max15 min20 min45 min
Cadencesweekly+ weekdays, daily+ weekdays, daily

Access projection & admin grants

getAccessSnapshot runs in a repeatable-read transaction (retried once on serialization failure) so the access row, grants, and usage never straddle a concurrent commit. The snapshot resolves three recent fixes:

Admin grants live in a separate billing_grants table and never touch the store-projected tier — when a grant expires or is revoked, the underlying store tier cleanly re-appears. Grants are plus/pro only, per-user idempotent, and attributed to the operator (x-admin-actor header → grantedBy → shared admin user).

Grandfathering (#515). On top of individual grants, the backend now projects grandfather cohorts plus a list of hand-picked user IDs, each attributed to a campaign with an audit trail. The projection is idempotent, bounded, and overlay-safe — it applies the same rank rule as grants, so it never demotes a higher paid tier. Covered by 29/29 billing + grandfather tests.

Usage is now really metered — but enforcement is dark.

The old hard-coded 0 usage is gone. Backend #514 adds a usage ledger and a QuotaService, and GET /billing/access now returns real per-meter balances — recurring generation, one-time generation, explore playback (seconds), and file uploads — each a MeterBalance with limit, remaining, used, reserved, rolloverBalance, resetAt, and window. Metering runs observe-only; the 402 enforcement path is flag-gated and defaults OFF (fail-open), so nothing is actually blocked until the flags are flipped and the path is QA'd.

Database schema

Migration 20260714120000_add_billing_contract. Enum BillingTier {FREE, PLUS, PRO} plus three tables:

iOS implementation

Lives under Services/Billing/, Models/Billing/, Features/Subscription/. The manager is BillingService (not "SubscriptionManager"). The current iOS work is three stacked PRs on develop#309 (plan contract, 9 commits), #311 (offer ladder, 6 commits, stacks on #309), #312 (Phase 4, 12 commits, stacks on #311) — 27 commits total, building on the earlier paywall work (PR #307). The full stack compiles and passes 356 tests under Xcode 26.5; the #311 and #312 re-reviews came back COMPLETED/SUCCESS.

RevenueCat wiring

6 products: app.lissin.audio.{plus,pro}.{weekly,monthly,yearly} — Plus $3.99/$9.99/$69.99, Pro $6.99/$19.99/$199.99, each with an intro price.

Server-authoritative sync

GET /billing/access is fetched at launch, on foreground, and after purchase / restore. PR #309 decodes the expanded contract (phase, plan version, server time, trial window, per-meter balances) with a fallback plan for older/absent fields, and maps a server 402 to the right paywall placement. Server snapshots are stamped origin=.server (the field is excluded from the wire CodingKeys so it can't be spoofed); in any conflict, server wins over local. On a thrown error the access state stays .unknown and gates fail closed. 404/network/5xx/501/503 → fall back to the local RC projection; 401/400 → no fallback.

Paywall & gate trigger map

Every surface funnels through one component, PaywallHost(placement:onOutcome:), which renders either the RevenueCat-hosted paywall (default) or the custom PaywallView via a runtime toggle. The three "gate" sheets are explainer/upsell sheets that present a nested PaywallHost when the user taps the CTA. This is the real answer to "where and when is the paywall shown":

Full paywall — direct triggers

PlacementWhere / when it firesGate condition
.onboardingFinal onboarding step — full-screen, unconditional. Has a "continue free" exit; any outcome completes onboarding.None (always shown once)
.lockedEpisodeTapping a locked episode row on a user-created show (episode #>1). On purchase it plays the episode.episode #>1 && !can(.personalEpisodePlayback)
.lockedEpisodeSubscriptionBanner "Upgrade" button inside a personal show's detail view.isUserCreated && !can(.personalEpisodePlayback) && episodes>1
.remixTapping Remix (or its upsell row) in the remix sheet.tier==free && !can(.remix) (unresolved → gate)
.planBillingSettings → Plan & Billing → "Explore/Compare plans". Informational, no hard gate.User-initiated
anyDEBUG only launch-arg harness in MainTabView. Not a production surface.

Contextual gate sheets — feature-limit triggers

SheetTriggered when…Free-tier limit that trips it
GenerationAllowanceSheetUser taps Send in Chat Create and the generation gate trips.!can(.personalGeneration) OR minutes exhausted OR activeShows >= 1
DocumentAttachSheetUser taps the attach-document button in the chat input bar.tier==free && !can(.documentUpload) (free docs = 0)
CadenceComparisonSheetUser selects daily/weekdays cadence — in the create schedule picker or when saving Show Settings.tier==free && !can(.dailyCadence) (free = weekly only)

Each gate sheet resumes the user's original intent after purchase (play the episode, send the generation, open the file picker, save the cadence) via a pending-action mechanism. Unresolved access fails closed everywhere (shows the gate rather than leaking the feature).

Design archive vs. what's built.

The paywall design archive frames the funnel as four surfaces (main wall, exit-intent rescue, locked-episode sheet, out-of-minutes sheet). The shipped funnel differs: the "out of free minutes" sheet is instead generation-allowance / document-attach / cadence-comparison gates, and the rescue is now delivered as an offer-stage ladder (#311) rather than a separate exit-intent surface. The built gates are onboarding wall, locked-episode, remix, generation-allowance, document-attach, and cadence-comparison, wrapped by the ladder below. Worth aligning the archive before the offer copy is finalized.

Offer ladder (rescue)

PR #311 adds an offer-stage rescue ladder: when a user declines, the next presentation escalates the offer standard → 50% off → 65% off, backed by the default / save50 / save65 RevenueCat offerings.

Entitlement gating & free-tier limits

A single decision method, BillingService.can(capability), gates all UI (.explorePlayback is always free). Capabilities: explorePlayback, starterPersonalShow, personalGeneration, additionalPersonalShows, personalEpisodePlayback, dailyCadence, documentUpload, remix, sourceMonitoring, advancedTranscript, creatorExport, longFormGeneration. Client free-tier defaults (used for gate copy and when there's no server snapshot; server overrides): 1 show, 15 gen-min/7d, 0 docs, weekly-only, 15-min episodes.

Enforcement exists on the server now — but it's dark.

Backend #514 adds server-side metering (usage ledger + QuotaService) and a flag-gated 402 enforcement path, and iOS #309 maps those 402s to paywall placements. But enforcement defaults OFF (fail-open), so today the gates remain conversion controls, not security — a patched client is still not blocked from the underlying capability until the flags are flipped and the path is QA'd. The iOS Phase-4 gates likewise use a fail-open EntitlementGate.

Phase-4 gates, trial & live balances

PR #312 wires the expanded contract into the product surfaces:

Note: pro_sources is not wired — there is no such feature to gate. Fail-open means an unresolved snapshot never blocks the capability.

Beta / dev config

Admin implementation

PR #163 (11 commits) covers a per-user billing panel plus cohort and hand-picked grandfathering. Operators can:

Calls go through backendFetch (injects X-Admin-Secret) to GET/POST /admin/billing/…, exactly matching the backend controller. Create sends the operator identity as X-Admin-Actor — the fix (6485987) that stopped every grant collapsing to the shared admin@lissin.internal identity. The whole surface is billing.manage-gated in the admin app's RBAC; the backend enforces only the single admin secret. The admin base branch is the dev environment, so merging #163 deploys it to admin-dev. tsc + next build clean.

Known gaps & risks

GapImpactSeverity
Webhook secret is the single point of failureIf unset in prod, every store purchase resolves to free. The prod deploy workflow already has a step to inject it from Secrets Manager, but it must be populated before go-live.blocker
402 enforcement ships dark (flag OFF)Server-side metering now exists (usage ledger + QuotaService, live per-meter balances), but the 402 enforcement path defaults OFF / fail-open. Until the flags flip and the path is QA'd, plan limits are effectively still client-side merchandising — a patched client bypasses the gates.by design
Offer ladder needs save50/save65 offeringsThe rescue ladder (standard→50%→65%) only shows discounted stages once the save50/save65 RevenueCat offerings exist; until then it falls back gracefully to the standard offering. Catalog work is the user's part.watch
Beta & Release share bundle id + versionA dev-pointed Beta build is indistinguishable in App Store Connect; promoting one to the App Store would ship a build that talks to the dev backend.medium
accessEnvironment hard-coded productionSandbox events report as production. Informational only — no iOS consumer gates on it.low
Backend recognises only entitlements literally named plus/proA RevenueCat dashboard naming mismatch fails every purchase identically, with no build-time signal.watch
Design funnel ≠ built funnelThe archive's rescue is now delivered as the offer-stage ladder (#311), and the out-of-minutes sheet is the generation/document/cadence gates; built gates are onboarding/locked-episode/remix/generation/document/cadence. Align the archive before finalizing offer copy.align

Release status & remaining steps

Enforcement stays observe-only / dark until the flags are flipped. All 6 PRs are code-complete, comment-clean, and unmerged.

Backend

iOS

Admin

Remaining (user / non-code)

What to verify next

  1. Merge in the right order — backend #514/#515 one at a time to dev; iOS #309 → #311 → #312 to develop; admin #163 to dev. Each backend merge runs prisma migrate deploy.
  2. Stand up the RevenueCat / App Store catalog — entitlements named exactly plus/pro; the default, save50, and save65 offerings; the trial intro offer; placements; all products attached; and the webhook Authorization value equal to the secret byte-for-byte, pointed at the target backend's /billing/webhooks/revenuecat.
  3. Run one real sandbox purchase through the offer ladder while signed in, then read that user's billing_access row and per-meter balances — true end-to-end proof that purchase → webhook → tier + trial-phase projection works.
  4. When ready, flip enforcement on and confirm the 402 path maps to the right paywall placements before ramping.

Prepared from a fresh read of all three repositories on 2026-07-18, updated 2026-07-20 to the 6-PR state. Line-level citations are available in the working notes if you want them drilled into any specific claim.