Why this exists

Swappr’s economics only make sense once there’s a critical mass of CurrentHome 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 in packages/shared/src/paywall.ts and is shared between the API process and the worker so no second copy can drift:
export interface PaywallStateInput {
  currentListings: number;
  threshold: number;
  previouslyFlipped: boolean;
}

export const paywallActiveFor = ({
  currentListings,
  threshold,
  previouslyFlipped,
}: PaywallStateInput): boolean => {
  if (previouslyFlipped) return true;
  return currentListings >= threshold;
};

export const PAYWALL_THRESHOLD_DEFAULT = 50;
Two inputs, one output:
InputSource
currentListingsCurrentHome.countDocuments({ status: 'LIVE', ownerTenancyApproved: true }) via ListingsCountPort.countActiveLiveListings().
previouslyFlippedA boolean flag persisted in Redis (paywall:flipped:v1). Once set, it is never unset within the deployment.
thresholdCompile-time default 50; can be overridden via BillingServiceDeps.threshold for tests.
The function is pure — no IO, no 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 under paywall: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.
Do NOT delete paywall:flipped:v1 from a live Redis. It is a one-way ratchet by design. If you genuinely need to re-launch under new terms, bump the key suffix in code and ship a new deployment.

The 60-second Redis cache

Each call to GET /api/v1/billing/state would otherwise trigger:
  1. A countDocuments on CurrentHome (the listing count).
  2. A findOne on subscriptions (the user’s status).
  3. A read of the Redis flip flag.
The count is the expensive one. Caching it solves the request-time cost AND smooths the timing of flips across multiple concurrent reads. When the listing count climbs and the flip happens: After this point, even if a CurrentHome is deleted and the count drops to 49 in the next minute, the next cache miss reads 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:
GET /api/v1/billing/state
Authorization: Bearer <accessToken>
Response is a flat envelope:
{
  "paywallActive": false,
  "reason": "launch_phase",
  "currentListings": 12,
  "threshold": 50,
  "subscriptionStatus": "NONE"
}
The client decides what to show using paywallActive AND subscriptionStatus together:
paywallActivesubscriptionStatusClient behaviour
falseanyFree phase — no purchase prompt, no gating.
trueNONE, EXPIRED, CANCELLED (after period end)Show paywall — /start-trial or /subscribe CTA.
trueTRIALING, ACTIVE, CANCELLED (still in-period)Premium UI unlocked.
truePAST_DUEShow 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.
The full endpoint contract is in Get billing state.

Server-side enforcement

Each paywalled feature (Phase 7 — search, advanced filters, multi-match, etc.) sits behind a requireActiveSubscription middleware. The middleware reads the user’s denormalised subscriptionStatus mirror (set by the RevenueCat webhook handler) and the global paywall flag:
// pseudo
const requireActiveSubscription: RequestHandler = async (req, res, next) => {
  const paywall = await billingService.computePaywallState();
  if (!paywall.paywallActive) return next();   // free phase — no gate
  const status = req.user.subscriptionStatus;
  if (status === 'ACTIVE' || status === 'TRIALING' || status === 'PAST_DUE') return next();
  if (status === 'CANCELLED' && !subscriptionPeriodEnded(req.user)) return next();
  throw new AppError('SUBSCRIPTION_REQUIRED');
};
The Phase 5 deliverable is the flip predicate + billing state read. The 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 local subscriptions.status is a projection of the RevenueCat event stream. Every accepted RC webhook event transitions through this state machine: Notes:
  • The CANCELLED → EXPIRED transition is what actually revokes access — CANCELLED users keep their premium UI until currentPeriodEnd because they’ve paid for that period.
  • PAST_DUE users 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, an EXPIRATION event arrives and the status drops to EXPIRED.
  • TRANSFER, SUBSCRIBER_ALIAS, and TEST events are accepted, persisted to the audit log, but trigger no state mutation.
The transition table is implemented in 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 with period_type: TRIAL:
  1. RC fires INITIAL_PURCHASE with expiration_at_ms ≈ 7 days out.
  2. Webhook handler upserts the subscriptions row with status: TRIALING and trialEndsAt set.
  3. User’s mirror subscriptionStatus is set to TRIALING.
  4. Client now sees subscriptionStatus: 'TRIALING' from GET /billing/state. Premium UI is unlocked.
After 7 days, one of:
  • Trial converts → RC fires RENEWAL. Status flips to ACTIVE. The user is now paying.
  • User cancels → RC fires CANCELLATION. Status flips to CANCELLED. Access continues until trialEndsAt, then RC fires EXPIRATION and status flips to EXPIRED.
  • Payment method fails → RC fires BILLING_ISSUE. Status flips to PAST_DUE while 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
The server stores 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. The subscriptions 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:
  • requireActiveSubscription middleware 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_DUE status 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_PURCHASE flow) but the UX impact is not yet designed.
  • Refund handling. RC fires CANCELLATION + EXPIRATION on refunds; we treat them like normal cancellations. There’s no separate “refund issued” UI.

See also