# Subscription Management

This document explains how StratoFusion models Free, Pro, Unlimited, and trial access across Stripe, Clerk metadata, and the local Postgres projection.

## Canonical Rules

- Free tier remains the default for every new account.
- Pro and Unlimited trials are opt-in and only start from explicit `Start Free Trial` entry points.
- Trials last 7 days.
- A user only gets the trial if Stripe, Clerk metadata, and the Postgres projection do not already show prior managed StratoFusion subscription or trial history.
- The billing page uses the same eligibility evidence before showing `Start Free Trial`; returning Free users with trial history see direct subscribe actions instead.
- Existing paid subscribers do not see trial UI.
- If a trial ends without payment, the user must return to Free.

## State Sources

### Stripe

Stripe is the source of truth for:

- customer identity.
- active subscription status.
- trial start and end timestamps.
- conversion from trialing to active.
- cancellation after a trial with no payment method.

Stripe remains the billing source of truth, but it is not the source for
explicit internal testing entitlements. Internal entitlements never create or
modify Stripe customers, subscriptions, invoices, or payment state.

### Clerk `publicMetadata`

Clerk mirrors Stripe state for request-time access control and UI hydration:

- `subscriptionTier`.
- `subscriptionStatus`.
- `currentPeriodEnd`.
- `subscriptionExpiresAt`.
- `stripeCustomerId`.
- `stripeSubscriptionId`.
- `isTrial`.
- `trialStartDate`.
- `trialEndDate`.

### Postgres `user_subscriptions`

`src/lib/database/schema/subscriptions.ts` stores the billing projection used for reconciliation:

- `tier`.
- `status`.
- `currentPeriodEnd`.
- `isTrial`.
- `trialStartDate`.
- `trialEndDate`.
- `trialWarningSentAt`.

This projection is intentionally Stripe-shaped. It is not a second billing system. It exists so the app can reconcile trial state safely even when a user is offline or Clerk metadata is stale.

Administrative plan changes use the same Stripe-backed plan-change service as
self-service billing. The admin endpoint cannot write Clerk tier metadata or a
manual expiry directly: an existing managed Stripe subscription must be
changed first, immediate access changes flow through the Clerk/Postgres sync
boundary, and scheduled downgrades remain on the current entitlement until the
Stripe billing period ends.

### Internal entitlements

Approved devops and test accounts can receive a separate paid-tier entitlement
in Clerk `publicMetadata.internalSubscriptionGrant`. A valid grant contains a
paid `tier`, `grantedAt`, `grantedBy`, `reason`, and optional `expiresAt`.
Permanent grants use `expiresAt: null`.

- Internal grants take precedence when resolving feature and quota access.
- Stripe fields remain truthful; for example, a canceled historical
  subscription remains `Free / canceled` in the Stripe projection while an
  internal grant supplies effective Unlimited access.
- Stripe webhook and cron synchronization preserve the separate grant field.
- Billing page loads are read-only and cannot reconcile or downgrade metadata.
- Creating or revoking a grant requires an administrator session with verified
  MFA through `/api/admin/users/{userId}/internal-entitlement`.
- The normal admin tier endpoint remains Stripe-backed and cannot create an
  internal entitlement.

## Trial Lifecycle

### 1. Trial entry

- Pricing cards send the user to `/sign-up?startTrial=pro|unlimited`.
- After sign-up, the user lands on `/user/billing?startTrial=...`.
- `StartTrialLauncher` automatically creates the Checkout session for Free-tier users and switches its copy from trial activation to subscription checkout when the user is not trial eligible.

### 2. Stripe Checkout

For eligible first-time trial users, Checkout is created with:

- `payment_method_collection: "always"`.
- `subscription_data.trial_period_days: 7`.
- `subscription_data.trial_settings.end_behavior.missing_payment_method: "cancel"`.

### 3. Trial activation

- Stripe webhooks sync the subscription into Clerk metadata.
- The same webhook sync persists the projection to `user_subscriptions`.
- `SubscriptionContext` resolves trial-aware access immediately.
- UI surfaces.
  - compact header `TrialStatusLink`.
  - billing-page `TrialDetailsCard`.
  - `trialing` status badge.

### 4. Warning window

- Stripe emits `customer.subscription.trial_will_end`.
- `/api/cron/expire-trials` also scans the local projection for trials ending within 3 days.
- Warning state is persisted through `trialWarningSentAt`.

### 5. Trial end

Two outcomes are expected:

- Payment method exists and Stripe converts the subscription to `active`.
- Payment method is missing by trial end and Stripe cancels the subscription.

### 6. Reconciliation

`/api/cron/expire-trials` re-checks expired trial subscriptions against live Stripe and:

- keeps active converted subscriptions on their paid tier.
- downgrades canceled trials to Free.
- clears trial fields after conversion or expiration.

## Access Control

Trial-aware access is resolved in:

- `src/lib/subscription-state.ts`.
- `src/lib/subscription.ts`.
- `src/lib/subscription-server.ts`.

Important behavior:

- active trials inherit the target tier’s features and quotas.
- expired `trialing` metadata is treated as Free.
- paid `active` subscriptions are not downgraded just because old trial dates remain in metadata.

## Operational Notes

- Use Stripe test mode for all local validation.
- Do not mutate live Stripe configuration without explicit approval.
- `DATABASE_URL` must point to a safe non-production database before running migrations locally.
- `CRON_SECRET` protects the trial reconciliation route.

## Current Gap

The code now creates structured trigger points for trial warning and expiration emails, but the repo still does not contain a transactional email adapter. Until one is wired, the system logs the notification events instead of sending real email.
