Why this exists
Swappr’s economics only make sense once there’s a critical mass ofCurrentHome listings to swap against. Charging users on day 1, when the live count is in the low double digits, is hostile. The “launch phase” rule encodes the trade-off:
The paywall is OFF while the platform has < 50 active, tenancy-approved listings. The moment the count crosses 50, the paywall flips ON for everyone — including users who joined during the free phase — and stays ON for the rest of the deployment’s life.The 50-listing threshold and the one-way semantics are locked in QUESTIONS.md §7.3. This page is the engineer-facing reference for what that means in code, in Redis, in the request lifecycle, and in the client.
The rule, in one function
The flip predicate lives inpackages/shared/src/paywall.ts and is shared between the API process and the worker so no second copy can drift:
| Input | Source |
|---|---|
currentListings | CurrentHome.countDocuments({ status: 'LIVE', ownerTenancyApproved: true }) via ListingsCountPort.countActiveLiveListings(). |
previouslyFlipped | A boolean flag persisted in Redis (paywall:flipped:v1). Once set, it is never unset within the deployment. |
threshold | Compile-time default 50; can be overridden via BillingServiceDeps.threshold for tests. |
Date.now(), no Mongo. The caching, the Redis key, the listings count: all of those are in the service layer (billing.service.ts) calling into ports. This lets the same flip predicate be reused in the worker without dragging cache adapters along.
One-way flip
The flip is one-way by design. Suppose the platform crosses 50 listings, a few users delete their CurrentHome, and the count drops back to 48. The rule says: paywall stays ON. Why? Because the alternative — paywall flickers on/off as the count crosses 50 in either direction — creates a perverse incentive for cohorts of users to coordinate listing deletes to dodge the wall, and it makes the UX deeply confusing (“did I just get charged? am I being charged now? was I charged yesterday?”). The one-way flip says: once the platform has crossed the launch threshold, the launch phase is over. The persisted flag lives in Redis underpaywall:flipped:v1. The :v1 suffix is intentional — if business needs ever require a hard reset (e.g. a re-launch under different rules), bumping to :v2 is the lever, NOT deleting the key. Deleting paywall:flipped:v1 in production would silently re-enter launch phase for everyone with active subscriptions, which would be the most expensive mistake possible.
The 60-second Redis cache
Each call toGET /api/v1/billing/state would otherwise trigger:
- A
countDocumentsonCurrentHome(the listing count). - A
findOneonsubscriptions(the user’s status). - A read of the Redis flip flag.
previouslyFlipped: true from Redis and returns paywallActive: true regardless of count.
The 60s TTL means clients observe a flip within at most 60 seconds of the underlying count crossing the threshold. This is intentional — the alternative (instantaneous flips) requires either pub/sub cache invalidation or no cache at all, both of which trade complexity for ~1 minute of latency that nobody notices.
What the client sees
The client makes one call:paywallActive AND subscriptionStatus together:
paywallActive | subscriptionStatus | Client behaviour |
|---|---|---|
false | any | Free phase — no purchase prompt, no gating. |
true | NONE, EXPIRED, CANCELLED (after period end) | Show paywall — /start-trial or /subscribe CTA. |
true | TRIALING, ACTIVE, CANCELLED (still in-period) | Premium UI unlocked. |
true | PAST_DUE | Show a soft “payment failed, update your card” banner BUT keep premium UI unlocked while RC dunning runs. RC will deliver EXPIRATION if the dunning ultimately fails. |
Server-side enforcement
Each paywalled feature (Phase 7 — search, advanced filters, multi-match, etc.) sits behind arequireActiveSubscription middleware. The middleware reads the user’s denormalised subscriptionStatus mirror (set by the RevenueCat webhook handler) and the global paywall flag:
requireActiveSubscription middleware itself is not wired onto any production route yet — it lands with the first paywalled feature in Phase 7.
Subscription state machine
The localsubscriptions.status is a projection of the RevenueCat event stream. Every accepted RC webhook event transitions through this state machine:
Notes:
- The
CANCELLED → EXPIREDtransition is what actually revokes access —CANCELLEDusers keep their premium UI untilcurrentPeriodEndbecause they’ve paid for that period. PAST_DUEusers are treated as still-paid for the duration of RC’s dunning retries. RC’s dunning is configured to last up to 16 days for monthly plans; if it ultimately fails, anEXPIRATIONevent arrives and the status drops toEXPIRED.TRANSFER,SUBSCRIBER_ALIAS, andTESTevents are accepted, persisted to the audit log, but trigger no state mutation.
apps/api/src/modules/billing/billing.service.ts (the rcEventToStatus function). The webhook controller persists every event to webhook_events for audit before the service runs — even unhandled events (TRANSFER etc.) leave a row, so forensic queries always work.
The 7-day trial
When a user starts a subscription withperiod_type: TRIAL:
- RC fires
INITIAL_PURCHASEwithexpiration_at_ms≈ 7 days out. - Webhook handler upserts the
subscriptionsrow withstatus: TRIALINGandtrialEndsAtset. - User’s mirror
subscriptionStatusis set toTRIALING. - Client now sees
subscriptionStatus: 'TRIALING'fromGET /billing/state. Premium UI is unlocked.
- Trial converts → RC fires
RENEWAL. Status flips toACTIVE. The user is now paying. - User cancels → RC fires
CANCELLATION. Status flips toCANCELLED. Access continues untiltrialEndsAt, then RC firesEXPIRATIONand status flips toEXPIRED. - Payment method fails → RC fires
BILLING_ISSUE. Status flips toPAST_DUEwhile RC retries.
A “trial ending in N days” push notification is deferred — it requires either a periodic scan job or RC’s pre-renewal events, both of which are out of scope for Phase 5. The data is all there (
subscriptions.trialEndsAt); the worker job to act on it is Phase 7. See Push notification fan-out — Deferred items.Pricing
Per QUESTIONS.md §7.2, pricing lives in the RevenueCat dashboard, NOT in code:- £5.99 / month —
swappr.sub.monthly.v1 - £59.99 / year —
swappr.sub.annual.v1
productId on each subscriptions row but does not store prices. The client fetches RC’s Offerings object to render the purchase UI; the server never quotes a price. This means:
- Price changes do NOT require a deploy. RC dashboard → save → next purchase uses the new price.
- A/B pricing experiments live entirely on the client + RC dashboard.
- The server cannot “validate” a purchase price — RC’s receipt validation is the source of truth.
Why RevenueCat is the source of truth
Subscription state across Apple + Google + (future) Stripe is non-trivial: refunds, family-sharing, ask-to-buy, store-side cancellations, plan upgrades mid-period — each store has different webhook semantics. RevenueCat normalises all of this into one event stream with stable identifiers. We do NOT call any store API directly. Thesubscriptions collection is a read-side cache of RC’s state. If our cache ever disagrees with RC’s, RC wins on the next event delivery — and the unique index on (provider, notificationId) makes RC’s “redeliver until we ack” pattern safe.
This trade-off is the single biggest reason the inbound webhook is built around idempotency rather than around correctness. Correctness is RC’s problem; our problem is “don’t double-apply events”.
Deferred / out-of-scope
Phase 5 ships the flip predicate, the cache, the webhook ingestion, and the read endpoint. It does NOT yet ship:requireActiveSubscriptionmiddleware on any production route. The middleware is written but no route uses it — Phase 7 wires it onto paywalled features (search, advanced filters, etc.).- Trial-ending push notifications. Requires a periodic scan job. Phase 7.
- In-app receipt validation. All purchases flow through RC’s SDK; we don’t accept raw App Store receipts.
- Grace-period UI. The
PAST_DUEstatus is exposed but the client doesn’t yet render a special banner for it. Phase 7 may. - Family-sharing semantics. RC’s family-shared subscriptions are accepted by the webhook (same
INITIAL_PURCHASEflow) but the UX impact is not yet designed. - Refund handling. RC fires
CANCELLATION+EXPIRATIONon refunds; we treat them like normal cancellations. There’s no separate “refund issued” UI.
See also
- Get billing state — the read endpoint.
- RevenueCat webhook — the ingestion endpoint + full RC → local status table.
- Push notification fan-out — covers the trial-ending push deferment.
- Idempotency — how
(provider, notificationId)works across the webhook surface.