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=14hardcoded, 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 → OnTenantRegisteredListener → SubscriptionTrialService.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.)
billingExemptflag (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,seatLimitalready exist on Tenant.)
4. Build order
D1 — Trial on signup
auth.service.registerOwner: write an outbox eventTenantRegisteredinside 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=nullas active → allow. Harmless; outbox guarantees the row lands. TRIAL_DAYS = 14constant (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
billingExemptfirst → bypass. Trial-lapse computed on-the-fly (trialEndsAt ≤ now), independent of the sweep. - Throws
SUBSCRIPTION_REQUIRED→ HTTP 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 togglebillingExempt. 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 anisTrialing+trialEndsAtview 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 whenbillingExemptor 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.
billingExemptbypass, ADMIN bypass, GET bypass,/subscriptionbypass. - billingExempt toggle (API + audit).
- Sweep flip. Web banner states.
5. Open questions (resolve at implementation)
- D6 backfill: grandfather (a) / trial 14d (b) / expire (c)? — leaning (a) grandfather if real tenants exist, (b) for test cohort.
- Trial length config: hardcode
TRIAL_DAYS=14now, PlatformSetting later? (yes) - Block status code: 402 Payment Required (recommended) vs 403.
- 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.mdfor 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.