architecture/subscription-enforcement-plan.md

Subscription Trial & Enforcement Plan (Phase D)

Status: IMPLEMENTED — 2026-06-10 (uncommitted). Built on top of the Polar integration (Phase A checkout + Phase B status/portal/cancel). D1–D5 + D7 shipped; D6 resolved as grandfather (no migration). Decisions locked at implementation: block status 402, TRIAL_DAYS=14 hardcoded, cron sweep built (hourly).

Related: polar-integration-plan.md · subscription-architecture.md · ../operations/billing-providers/polar-setup.md

Implementation map (where it landed)

Part Code
D1 trial-on-signup auth.service.ts writes tenant.registered to outbox in the register tx → OnTenantRegisteredListenerSubscriptionTrialService.startTrial (provider = active PlatformBillingConfig; planKey = DEFAULT_TRIAL_PLAN_KEY; TRIAL_DAYS=14). Event contract: auth/events/tenant-registered.event.ts.
D2 BillingGuard core/subscription/interface/guards/billing.guard.ts, global APP_GUARD (after JwtAuthGuard in app.module). Opt-out: @AllowWithoutSubscription() (shared/billing/), applied to SubscriptionCheckoutController + AuthController. Throws SubscriptionRequiredError → 402.
D3 billingExempt Tenant.billingExempt + migration 20260610000000_add_tenant_billing_exempt. PATCH /superadmin/tenants/:id/billing-exempt (ADMIN, audit-logged). Web: Gift-icon toggle + ConfirmDialog on SuperadminTenantsContent.
D4 status + UX SubscriptionStatusView.isTrialing. Web SubscriptionBanner (admin layout) + errors.SUBSCRIPTION_REQUIRED toast i18n.
D5 sweep ExpireTrialsService + BullMQ repeatable subscription-expiry queue (hourly). findLapsedTrials repo method; aggregate expireTrial(now).
D6 backfill Grandfather — existing tenants stay ACTIVE + trialEndsAt=null → guard treats as paid. No migration.

1. Agreed model (locked 2026-06-10)

  • System-managed 14-day trial on signup (no card). Every new tenant gets a trial automatically.
  • After trial, the tenant must subscribe to keep write access. Gating is 100% our system — Polar is only the payment rail.
  • Polar product trial = OFF. Enabling it would double-trial (14d app + 14d Polar) and delay the first charge. Turn it off on the Polar product (850a5748… sandbox; 6feccf1c… prod).
  • Subscribe mid-trial charges immediately (confirmed): the paid period starts at subscribe time; remaining trial days are forfeited. Standard SaaS "subscribe = start paying"; UI nudges owners to subscribe near trial end via a "N days left" countdown. (No date-aligned Polar trial — too complex.)
  • billingExempt flag (NEW): super-admin can mark a tenant as exempt → bypasses ALL gating (comp / partner / internal salons). Highest-priority allow in the guard.

Consequence: P4 simplified

With Polar trial OFF, subscription.created arrives as status: active with no trial fields, so polar.event-mapper.ts hardcoding trialEndsAt: null is correct — the trial lives on the row created at signup, never from Polar. No event-mapper trial fix needed.

2. Gate decision matrix (BillingGuard)

Reads the Tenant read-model (subscriptionStatus, trialEndsAt, billingExempt) — no join, hot-path friendly.

Condition Writes (POST/PUT/PATCH/DELETE) Banner
billingExempt = true ✅ Allow (bypass everything)
ACTIVE + trialEndsAt > now (trialing) ✅ Allow "N days left in trial"
ACTIVE + trialEndsAt = null (paid) ✅ Allow
ACTIVE + trialEndsAt ≤ now (trial lapsed, unpaid) ⛔ Block "Trial ended — Subscribe to continue"
PAST_DUE (soft) ✅ Allow "Payment failed, update card"
CANCELED (cancelAtPeriodEnd, still in period) ✅ Allow "Ends on …"
EXPIRED ⛔ Block "Subscribe to continue"

Always allowed (never blocked): all GET (read-only), /subscription/* (so they can subscribe to unblock!), /auth/*, onboarding, /webhooks/*, and ADMIN role (super-admin exempt).

Hard-PAST_DUE block (>7d / ≥4 attempts) → deferred (needs attempt counter; MVP blocks only EXPIRED + lapsed-trial).

3. Schema changes

  • Tenant.billingExempt Boolean @default(false) @map("billing_exempt") — new column. Migration nullable→default false (safe).
  • (Read-model fields subscriptionStatus, trialEndsAt, currentPlanKey, seatLimit already exist on Tenant.)

4. Build order

D1 — Trial on signup

  • auth.service.registerOwner: write an outbox event TenantRegistered inside the tenant-creation transaction (avoids the auth→subscription circular dep — subscription already imports AuthModule).
  • Subscription listener → SubscriptionTrialService.startTrial(tenantId): Subscription.startTrial() (exists) → status ACTIVE, trialEndsAt = +14d, planKey = premium_1_month, providerSubId = null → repo.save → sync Tenant read-model.
  • Brief window (tenant created ↔ trial row created): guard fallback treats ACTIVE + trialEndsAt=null as active → allow. Harmless; outbox guarantees the row lands.
  • TRIAL_DAYS = 14 constant (config-via-PlatformSetting deferred).

D2 — BillingGuard

  • Global APP_GUARD, secure-by-default: blocks write methods for OWNER/STAFF when the matrix says block.
  • @AllowWithoutSubscription() opt-out decorator marks exempt routes (subscription / auth / onboarding / webhooks). Default-deny writes → new endpoints are safe automatically.
  • Checks billingExempt first → bypass. Trial-lapse computed on-the-fly (trialEndsAt ≤ now), independent of the sweep.
  • Throws SUBSCRIPTION_REQUIREDHTTP 402 (Payment Required) → domain-error filter maps it → web shows banner / blocks UX.

D3 — billingExempt super-admin control

  • API: extend tenant update (or a dedicated PATCH /superadmin/tenants/:id/billing-exempt) to toggle billingExempt. ADMIN-only. Audit-log the change.
  • Web: toggle on the super-admin tenant detail page (/admin/superadmin/tenants), with a clear "Free access (no subscription)" label + confirm.

D4 — Status endpoint + Web UX

  • GET /subscription (Phase B): add an isTrialing + trialEndsAt view so the page shows "Trial — N days left" when on trial (no paid row yet).
  • Global billing banner in the admin layout: reads useSubscriptionStatus, shows trial-ending / expired / past-due states + Subscribe CTA. Hidden when billingExempt or healthy-paid.
  • Subscription page: "Trialing — N days left" / "Expired" states + CTA.
  • Blocked mutation → toast "Subscribe to continue" + banner link.

D5 — Trial-expiry sweep (cron)

  • Daily ExpireTrialsService: ACTIVE + trialEndsAt ≤ now + providerSubId=null → flip EXPIRED (keeps status badge accurate; guard already blocks on-the-fly). Can be deferred if badge lag is acceptable.

D6 — Existing-tenant backfill

  • Tenants created before Phase D: ACTIVE + trialEndsAt=null → guard treats as paid → grandfathered free.
  • Decision pending (see open questions): grandfather / give 14d trial / expire.

D7 — Tests

  • Trial-on-register (outbox → listener → row + read-model).
  • BillingGuard: every matrix branch incl. billingExempt bypass, ADMIN bypass, GET bypass, /subscription bypass.
  • billingExempt toggle (API + audit).
  • Sweep flip. Web banner states.

5. Open questions (resolve at implementation)

  1. D6 backfill: grandfather (a) / trial 14d (b) / expire (c)? — leaning (a) grandfather if real tenants exist, (b) for test cohort.
  2. Trial length config: hardcode TRIAL_DAYS=14 now, PlatformSetting later? (yes)
  3. Block status code: 402 Payment Required (recommended) vs 403.
  4. Sweep (D5): build now or defer (guard on-the-fly already enforces)?

6. Reminders for the implementing session

  • Turn OFF Polar product trial before testing (owner had enabled it).
  • Phase A + Phase B are shipped but uncommitted — see polar-integration-plan.md for the full uncommitted inventory; commit those alongside / before Phase D.
  • P4 (real sandbox checkout) can run independently to verify the webhook → Subscription → read-model path; with Polar trial OFF the event-mapper needs no trial fix.