License lifecycle: trial, grace & overflow
Policy model
All three features are policies. The app owner defines one per product for direct sales; each reseller may define its own for its channel, bounded by ceilings the app owner sets in reseller_products.max_*.
issue license for (product P, reseller R, client C)
policy = license_policies(P, R) ?? license_policies(P, NULL)
copy policy + plan values onto the license row
Changing a policy affects only licenses issued afterwards.
| Setting | Direct sale | Reseller channel | Ceiling |
|---|---|---|---|
| Trial days / seats / quota / activations / self-serve | App owner | Reseller | max_trial_days, max_trial_seats |
| Expiry grace days + mode | App owner | Reseller | max_expiry_grace_days |
| Overflow days / max seats | App owner | Reseller | max_overflow_days, max_overflow_seats |
License state machine
draft ──issue──► active ──expires_at passed──► grace ──grace_ends_at passed──► expired
│ ▲ │ │
│ └──────── renew ───────────┘◄────────── renew ──────────────┘
│
├──suspend──► suspended ──resume──► active
└──revoke───► revoked (terminal)
expiry_grace_days = 0skipsgrace.suspendedandrevokedare immediate; grace does not apply.- Effective status is computed on read (
v_license_effective_status). A scheduled job persists transitions and emits notifications. - Every transition bumps
token_versionand re-signs so the app never infers state.
Renewal
- Owner / reseller opens a
license_renewalsrow (status = open) — this is "renewal pending". - On completion: from
active, extend the existingexpires_at(early renewals keep their days); fromgraceorexpired, setstarts_at = now()and a freshexpires_at. - Status returns to
active; token re-signed.
Trial
A trial is a normal license with kind = trial, a short fixed expires_at, stricter snapshot limits, and a conversion path. The consuming app validates it through the same code as a paid license.
Request flow
tenant applies (product, optional reseller referral, email)
→ trial_requests row
→ abuse checks: trial_one_per_client, email_domain history, instance_fingerprint history
→ policy.trial_self_serve ? auto_approved : pending (owner / reseller approves)
→ license issued with trial snapshot; trial_requests.license_id set
trial_requests rows are retained permanently even if the org or license is deleted — domain and fingerprint history is what prevents repeat trials. So a deleted client organization that signs in again gets a new, empty client org, but not a second trial while trial_one_per_client is on.
When trial_one_per_client is on:
- Fingerprint: the tenant id, or
suite:<id>for a personal organization. It is always checked. - Email domain: checked only for a company domain. It's skipped for public mail providers (Gmail, Outlook.com, Yahoo…), where one user's trial would otherwise block everyone else on that provider. It's also skipped for personal organizations.
Conversion
Convert in place: same licenses.id, kind → paid, new plan_id, re-snapshot capacities, new expires_at, converted_at = now(), token_version++, re-sign. Seats and activations carry over untouched.
The admin portal's Convert trial asks for the paid plan, defaulting to the trial's own and showing each plan's price. The plan's price is what the license costs; a plan without one leaves the license out of income, and the dialog warns about that. Conversion also sets issued_at = now(), so a one-time plan's price counts in the month of the sale rather than the month the trial started. It is refused (409) while the trial has another renewal open, and the plan must be active.
Notifications
trial_request_pending (owner/reseller), trial_started, trial_ending (T-3d), trial_expired (client admin + reseller).
Expiry grace
When expires_at passes and expiry_grace_days > 0 the license enters grace until grace_ends_at. Behaviour during grace is the owner's / reseller's choice via grace_mode:
grace_mode | App behaviour |
|---|---|
full | Works normally, shows a renewal banner |
read_only | No writes, no new seats, no new activations, no API calls beyond existing quota |
reduced | App-defined; token carries status = grace and the app decides |
Server-side during grace regardless of mode: no new seat assignments, no new activations, no new overflow seats.
Notifications: grace_entered, grace_ending (T-2d), license_expired.
Overflow ("extended") seats
When every standard seat is taken and a new user is on-boarded, the user receives an overflow seat valid for overflow_days, if overflow_enabled and the overflow cap allows it.
Assignment
standard_used < seat_capacity
→ standard seat
else if overflow_enabled and status = active
and (overflow_max_seats = 0 or overflow_used < overflow_max_seats)
→ overflow seat, overflow_expires_at = now() + overflow_days
→ notify seats_exceeded (client admin + reseller / owner)
else
→ reject
Automatic promotion (oldest overflow first)
- A standard seat is released → promote one overflow seat.
seat_capacityincreases (more seats sold) → promote overflow seats until capacity is met.
Promotion sets kind = standard, clears overflow_expires_at, sets promoted_at, logs seat.promoted.
Expiry
Scheduled job releases overflow seats whose overflow_expires_at has passed with release_reason = overflow_expired; the user is blocked at next validation. Overflow seats share the license's API quota — they do not extend it.
Notifications: seats_exceeded, overflow_expiring (T-2d), overflow_expired.
Scheduled jobs
| Job | Cadence | Work |
|---|---|---|
| Status sweep | every few minutes | active → grace → expired transitions, re-sign, notify |
| Overflow sweep | hourly | Release expired overflow seats |
| Check-in sweep | hourly | Mark activations grace / blocked by grace_until |
| Quota window roll | at period boundary | Open next usage_counters row, snapshot quota |
| Reminders | daily | T-n day notifications |