Skip to main content

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.

SettingDirect saleReseller channelCeiling
Trial days / seats / quota / activations / self-serveApp ownerResellermax_trial_days, max_trial_seats
Expiry grace days + modeApp ownerResellermax_expiry_grace_days
Overflow days / max seatsApp ownerResellermax_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 = 0 skips grace.
  • suspended and revoked are 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_version and re-signs so the app never infers state.

Renewal​

  1. Owner / reseller opens a license_renewals row (status = open) — this is "renewal pending".
  2. On completion: from active, extend the existing expires_at (early renewals keep their days); from grace or expired, set starts_at = now() and a fresh expires_at.
  3. 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_modeApp behaviour
fullWorks normally, shows a renewal banner
read_onlyNo writes, no new seats, no new activations, no API calls beyond existing quota
reducedApp-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_capacity increases (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​

JobCadenceWork
Status sweepevery few minutesactive → grace → expired transitions, re-sign, notify
Overflow sweephourlyRelease expired overflow seats
Check-in sweephourlyMark activations grace / blocked by grace_until
Quota window rollat period boundaryOpen next usage_counters row, snapshot quota
RemindersdailyT-n day notifications