Lissin Billing — Implementation & Integration Status
2026-07-18 · updated 2026-07-20 · reconstructed directly from the code across the 6 open PRs — backend #514/#515, iOS #309/#311/#312, admin #163 — plus verified live dev-box state.
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 + #515 → dev
✅ 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
#163 → dev
✅ 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
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.
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)
Purchases.logIn(authenticatedUser.id), so RevenueCat's app_user_id equals the Lissin user UUID. Purchases refuse while RC is anonymous.app.lissin.audio.plus.monthly) via RevenueCat against the App Store sandbox.INITIAL_PURCHASE webhook to POST /billing/webhooks/revenuecat with entitlement_ids:["plus"] and the Authorization header = the shared secret.billing_events (idempotent), derives PLUS from the entitlement, and writes the billing_access row.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 + path | Guard | What it does |
|---|---|---|
GET /billing/access | Firebase | Returns 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/reconciliations | Firebase | Idempotently records a client purchase-attempt and returns a fresh snapshot. Does not validate receipts (TODO(revenuecat)) — only bumps revision. |
POST /billing/webhooks/revenuecat | secret public | The only writer of paid tiers. Authenticated by the Authorization header, not Firebase. |
POST /admin/billing/grants | Admin | Create an expiring complimentary grant (plus/pro only). |
GET /admin/billing/users/:id/access | Admin | Effective snapshot for any user. |
GET /admin/billing/users/:id/grants | Admin | All grants incl. expired/revoked. |
POST /admin/billing/grants/:id/revoke | Admin | Revoke 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.
| Event | Effect on billing_access |
|---|---|
INITIAL_PURCHASE / RENEWAL | tier from entitlement (pro>plus), accessSource=storePurchase, willRenew=true. |
PROMOTIONAL | accessSource=complimentaryGrant, willRenew=false. |
CANCELLATION | Keeps tier + period-end access; only flips willRenew=false. |
EXPIRATION | Hard reset to FREE / accessSource=none. |
TRANSFER | Stands 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:
| Free | Plus | Pro | |
|---|---|---|---|
| Active shows | 1 | 5 | 20 |
| Generated minutes | 15 / rolling 7d | 180 / month | 600 / month |
| Documents | 0 | 3 (20 MiB) | 25 (50 MiB) |
| Episode max | 15 min | 20 min | 45 min |
| Cadences | weekly | + 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:
- Store-expiry honoured at read time — since the webhook is the sole writer and no sweeper runs, an expired
expiresAtis treated asfreeon read, so a single lostEXPIRATIONcan't strand paid access forever. - Grant is an overlay, not a replacement — a complimentary grant only applies if its rank ≥ the store tier (
TIER_RANK[grant] >= TIER_RANK[store]), so a Plus grant never demotes a paying Pro subscriber. - TRANSFER stands the sender down — closes the "access nobody is paying for that nothing would revoke" gap.
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.
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:
billing_events— immutable idempotency inbox; uniqueprovider_event_id, jsonbpayload. No FK to users (records unmatched events too).billing_grants— operator grants; FK→users, unique(user_id, idempotency_key), fieldstier / starts_at / expires_at / revoked_at / reason / campaign / granted_by.billing_access— one row per user:revision,tier,access_source,will_renew,expires_at,management_platform,last_event_at_ms(ordering guard),grant_active, plus the trial-phase columns behind the expanded snapshot.- + #514 adds 3 migrations — a
plan_versionstable (the versioned plan contract), a usage-ledger table (per-meter consumption events feedingQuotaServiceand the live balances), and the schema behindphase/trialonbilling_access.
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
- Lazy configure —
Purchases.configureis called on first purchase-surface use, not at launch. - Public key
appl_XpLTTWDJZdTEnqTAYxrGYTXUBCkships as a build setting on Release and Beta; only anappl_-prefixed key is accepted at runtime (atest_/sk_key is rejected). DEBUG uses the RC Test Store key. - Entitlements
"pro"then"plus"; the offer ladder (#311) reads three offerings —default,save50,save65— and falls back gracefully to the console current offering untilsave50/save65exist in the RevenueCat catalog. - Identity —
logIn(authenticatedUser.id)ties RC'sapp_user_idto the Lissin UUID; money-moving paths throw a retryable error if RC is anonymous.
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
| Placement | Where / when it fires | Gate condition |
|---|---|---|
.onboarding | Final onboarding step — full-screen, unconditional. Has a "continue free" exit; any outcome completes onboarding. | None (always shown once) |
.lockedEpisode | Tapping a locked episode row on a user-created show (episode #>1). On purchase it plays the episode. | episode #>1 && !can(.personalEpisodePlayback) |
.lockedEpisode | SubscriptionBanner "Upgrade" button inside a personal show's detail view. | isUserCreated && !can(.personalEpisodePlayback) && episodes>1 |
.remix | Tapping Remix (or its upsell row) in the remix sheet. | tier==free && !can(.remix) (unresolved → gate) |
.planBilling | Settings → Plan & Billing → "Explore/Compare plans". Informational, no hard gate. | User-initiated |
any | DEBUG only launch-arg harness in MainTabView. Not a production surface. | — |
Contextual gate sheets — feature-limit triggers
| Sheet | Triggered when… | Free-tier limit that trips it |
|---|---|---|
| GenerationAllowanceSheet | User taps Send in Chat Create and the generation gate trips. | !can(.personalGeneration) OR minutes exhausted OR activeShows >= 1 |
| DocumentAttachSheet | User taps the attach-document button in the chat input bar. | tier==free && !can(.documentUpload) (free docs = 0) |
| CadenceComparisonSheet | User 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).
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.
- The stage persists and resumes across sessions, and resets on purchase.
- It commits a stage only after the offering actually loads, so a missing offering never strands the user on a discount that can't render.
- It is re-entrancy-guarded and falls back gracefully to the standard offering until
save50/save65exist in the catalog (the user's remaining RevenueCat work). Covered by 13 tests.
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.
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:
- Phase-4 gates via a fail-open
EntitlementGate— transcript, playback speed, soundscape, explore-time, and file size + count. - Trial banner + usage nudges driven off the snapshot's
trialwindow and per-meter balances. - Plan & Billing now renders live per-meter balances rather than static copy.
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
- Base URL —
#if DEBUG || BETA→https://api.dev.lissin.com/api/v1; Release → prod. The widget mirrors this. - Beta is a dedicated Xcode configuration + scheme (
SWIFT_ACTIVE_COMPILATION_CONDITIONS … BETA). It uses real App Store sandbox purchases against the real RC products with the publicappl_key; DEBUG instead uses a local StoreKit config + test key. - Scheme decides Archive —
LissinApparchives Release (prod);LissinApp-Betaarchives Beta (dev). Beta & Release share bundle id and version, so a dev-pointed build is indistinguishable in App Store Connect — never promote a Beta TestFlight build to the App Store. - Offer-code redemption UI is currently disabled.
Admin implementation
PR #163 (11 commits) covers a per-user billing panel plus cohort and hand-picked grandfathering. Operators can:
- See current access — tier, source (
complimentaryGrant/storePurchase/…), revision, ends date, will-renew, environment, and the live access-snapshot meters (now real per-meter balances). - Grant / revoke complimentary access — tier (Plus/Pro only), end date with 30/60/90-day quick-picks, required reason + campaign, an editable idempotency "request key", and computed grant status (Active/Scheduled/Expired/Revoked) with the Granted by operator.
- Grandfather a cohort — a dedicated cohort-grandfathering UI, plus a Users-page multi-select → bulk "Grandfather selected (N)" for hand-picked users.
- Audit grandfathering — a dedicated grandfathering audit page showing what was applied, to whom, and under which campaign.
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
| Gap | Impact | Severity |
|---|---|---|
| Webhook secret is the single point of failure | If 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 offerings | The 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 + version | A 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 production | Sandbox events report as production. Informational only — no iOS consumer gates on it. | low |
Backend recognises only entitlements literally named plus/pro | A RevenueCat dashboard naming mismatch fails every purchase identically, with no build-time signal. | watch |
| Design funnel ≠ built funnel | The 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
- done #514 (17 commits,
feat/billing-quota-foundation) + #515 (4 commits,feat/billing-grandfather-cohort) →dev; nest build clean; 29/29 billing + grandfather tests pass. - done Metering observe-only; the RevenueCat webhook trial-phase projection is implemented; the
402enforcement path is flag-gated OFF (fail-open). 3 migrations. - to merge Merge the two PRs one at a time (no concurrency guard; each runs
prisma migrate deploy). Populate the prod webhook secret before go-live.
iOS
- done #309 (9) → #311 (6) → #312 (12) on
develop, 27 commits; full stack compiles + 356 tests; #311 & #312 re-reviews COMPLETED/SUCCESS. - toolchain iOS builds require Xcode 26.5 — the default 16.4 toolchain can't compile the stack.
- to merge Merge in order #309 → #311 → #312.
Admin
- done #163 (11 commits,
feat/billing-grants-admin) →dev;tsc+next buildclean; grants + cohort & multi-select grandfathering + audit,billing.manage-gated. - to merge Merge to
devto deploy to admin-dev (lets you grandfather a tier without a real purchase — the one server path that works without RevenueCat).
Remaining (user / non-code)
- Merge the 6 PRs (order + one-at-a-time as above).
- RevenueCat / App Store catalog — the
default+save50+save65offerings, the trial intro offer, placements, and the prod webhook. The offer ladder falls back gracefully untilsave50/save65exist. - Product sign-offs on plan limits and copy.
- Flip enforcement on → QA the 402 path → ramp.
What to verify next
- Merge in the right order — backend #514/#515 one at a time to
dev; iOS #309 → #311 → #312 todevelop; admin #163 todev. Each backend merge runsprisma migrate deploy. - Stand up the RevenueCat / App Store catalog — entitlements named exactly
plus/pro; thedefault,save50, andsave65offerings; the trial intro offer; placements; all products attached; and the webhookAuthorizationvalue equal to the secret byte-for-byte, pointed at the target backend's/billing/webhooks/revenuecat. - Run one real sandbox purchase through the offer ladder while signed in, then read that user's
billing_accessrow and per-meter balances — true end-to-end proof that purchase → webhook → tier + trial-phase projection works. - When ready, flip enforcement on and confirm the
402path 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.