Automated Trial Cleanup with Grace Period #583

Merged
tobru merged 8 commits from trial-cleanup-grace-period into main 2026-07-06 13:55:55 +00:00
Owner

Trial cleanup: scale-down at expiration, grace period, and automated deletion

TL;DR — Give trial organizations a proper, safe end-of-life. When a trial expires, the org goes read-only and its instances are scaled down (stopped, not deleted). After a configurable grace period the org is automatically decommissioned (instances, namespaces, and org archived). Trial timings are aligned to human-friendly weekday mornings (10:00) so a stopped service is noticed during support hours, and the Odoo billing window is kept far enough ahead of the actual teardown that a trial is never invoiced.

The lifecycle

Moment What happens
expiration_date (weekday 10:00) Org becomes read-only and every instance is scaled down to instances: 0 (reusing the existing pause() primitive)
during grace period Instances stay stopped, org read-only — the user notices and can contact support to extend
deletion_date (weekday 10:00) Full decommission: instances + namespaces deleted, org archived

Scale-down and deletion only apply when a grace period is configured; otherwise today's behavior (read-only, nothing deleted) is preserved.

Non-technical summary

Today, when a customer's free trial runs out, their organization is frozen (they can no longer change anything) and they receive reminder emails - but their services keep running indefinitely until someone on our team cleans them up by hand. Nothing is ever deleted automatically.

This plan gives trials a tidy, customer-friendly ending:

  • When the trial ends, services are stopped, not deleted. Everything is switched off but kept intact - like unplugging a machine rather than throwing it away.
  • A grace period follows. For a configurable number of days all data stays untouched. A customer who missed the reminder emails will notice their service has stopped and can contact support, who can extend the trial and switch everything back on. Nothing is lost during this window.
  • After the grace period, cleanup happens automatically. The services and the organization are permanently removed, and the customer receives a confirmation email.
  • Everything happens at a considerate moment. A trial never ends at 3 a.m. or on a weekend: stopping and deleting always take place on a weekday at 10:00 Swiss time, so support is reachable when someone notices their service is off. If anything, customers get slightly more trial time than promised, never less.
  • A trial never produces an invoice. The billing system's free window is always kept ahead of the actual cleanup, so even if deletion takes longer than planned, no charge can ever appear for a trial customer.
  • Customers stay informed. The existing reminders (7, 3 and 0 days before the trial ends) are reworded to explain what will happen, a final warning goes out one day before deletion, and a confirmation is sent once everything has been removed.

The grace period is optional per customer group: where none is configured, behavior stays exactly as today (frozen, but never deleted automatically).

What's new / changed

  • OrganizationLimitProfile.grace_period_days (nullable) gates the whole flow. Unset = no automated deletion. Derived Organization properties: deletion_date, days_until_deletion, is_pending_deletion.
  • Human-friendly, weekday-aligned times. expiration_date and deletion_date are rounded up to the next weekday at 10:00 (SERVALA_TRIAL_ACTION_HOUR / SERVALA_TRIAL_ACTION_TIMEZONE, default Europe/Zurich). Ceiling (never rounding earlier) guarantees a trial is never shortened and keeps the Odoo trial window forward-only for free.
  • cleanup_expired_organizations management command (hourly cron, --dry-run, @scopes_disabled, per-org row lock) owns the full teardown: pause instances for expired-but-in-grace orgs, decommission orgs past grace. Best-effort per-instance pausing; failed decommissions are logged and retried next run.
  • Odoo billing safety. trial_end_date = (deletion_date or expiration_date) + 2h margin, pushed forward to now + teardown_buffer + 2h right before decommission (SERVALA_TRIAL_TEARDOWN_BUFFER_DAYS, default 1). No analytic line can ever fall after the trial window, even across async K8s teardown lag. decommission_date stays informational only.
  • Signals re-push the trial window when either validity_days or grace_period_days changes.
  • Reminders (send_expiration_reminders): existing 7/3/0-day emails reworded to mention that services are stopped at expiration and permanently deleted on deletion_date, plus a deletion_1_day final-notice email and a post-deletion confirmation email.
  • Admin: grace_period_days surfaced on the limit-profile admin.

Restore path

While an org is in grace it's read-only, so members can't self-resume. Support extends the trial (which lifts read-only); the member or support then resumes the stopped instances. No auto-unpause.

Safety / defaults

  • Grace unset ⇒ no deletion ever (safe default; existing profiles migrate to NULL).
  • Rounding only moves dates later, so no historical trial day can become billable.
  • --dry-run touches nothing (no Odoo, K8s, DB, or email).
  • Fully tested with Odoo and Kubernetes mocked; migration verified on Postgres.
# Trial cleanup: scale-down at expiration, grace period, and automated deletion **TL;DR** — Give trial organizations a proper, safe end-of-life. When a trial expires, the org goes read-only and its instances are **scaled down** (stopped, not deleted). After a configurable **grace period** the org is **automatically decommissioned** (instances, namespaces, and org archived). Trial timings are aligned to **human-friendly weekday mornings (10:00)** so a stopped service is noticed during support hours, and the Odoo billing window is kept far enough ahead of the actual teardown that **a trial is never invoiced**. ## The lifecycle | Moment | What happens | |---|---| | `expiration_date` (weekday 10:00) | Org becomes read-only *and* every instance is scaled down to `instances: 0` (reusing the existing `pause()` primitive) | | during grace period | Instances stay stopped, org read-only — the user notices and can contact support to extend | | `deletion_date` (weekday 10:00) | Full decommission: instances + namespaces deleted, org archived | Scale-down and deletion only apply when a grace period is configured; otherwise today's behavior (read-only, nothing deleted) is preserved. ## Non-technical summary Today, when a customer's free trial runs out, their organization is frozen (they can no longer change anything) and they receive reminder emails - but their services keep running indefinitely until someone on our team cleans them up by hand. Nothing is ever deleted automatically. This plan gives trials a tidy, customer-friendly ending: - **When the trial ends, services are stopped, not deleted.** Everything is switched off but kept intact - like unplugging a machine rather than throwing it away. - **A grace period follows.** For a configurable number of days all data stays untouched. A customer who missed the reminder emails will notice their service has stopped and can contact support, who can extend the trial and switch everything back on. Nothing is lost during this window. - **After the grace period, cleanup happens automatically.** The services and the organization are permanently removed, and the customer receives a confirmation email. - **Everything happens at a considerate moment.** A trial never ends at 3 a.m. or on a weekend: stopping and deleting always take place on a weekday at 10:00 Swiss time, so support is reachable when someone notices their service is off. If anything, customers get slightly *more* trial time than promised, never less. - **A trial never produces an invoice.** The billing system's free window is always kept ahead of the actual cleanup, so even if deletion takes longer than planned, no charge can ever appear for a trial customer. - **Customers stay informed.** The existing reminders (7, 3 and 0 days before the trial ends) are reworded to explain what will happen, a final warning goes out one day before deletion, and a confirmation is sent once everything has been removed. The grace period is optional per customer group: where none is configured, behavior stays exactly as today (frozen, but never deleted automatically). ## What's new / changed - **`OrganizationLimitProfile.grace_period_days`** (nullable) gates the whole flow. Unset = no automated deletion. Derived `Organization` properties: `deletion_date`, `days_until_deletion`, `is_pending_deletion`. - **Human-friendly, weekday-aligned times.** `expiration_date` and `deletion_date` are rounded **up** to the next weekday at 10:00 (`SERVALA_TRIAL_ACTION_HOUR` / `SERVALA_TRIAL_ACTION_TIMEZONE`, default `Europe/Zurich`). Ceiling (never rounding earlier) guarantees a trial is never shortened and keeps the Odoo trial window forward-only for free. - **`cleanup_expired_organizations` management command** (hourly cron, `--dry-run`, `@scopes_disabled`, per-org row lock) owns the full teardown: pause instances for expired-but-in-grace orgs, decommission orgs past grace. Best-effort per-instance pausing; failed decommissions are logged and retried next run. - **Odoo billing safety.** `trial_end_date = (deletion_date or expiration_date) + 2h margin`, pushed forward to `now + teardown_buffer + 2h` right before decommission (`SERVALA_TRIAL_TEARDOWN_BUFFER_DAYS`, default 1). No analytic line can ever fall after the trial window, even across async K8s teardown lag. `decommission_date` stays informational only. - **Signals** re-push the trial window when either `validity_days` or `grace_period_days` changes. - **Reminders** (`send_expiration_reminders`): existing 7/3/0-day emails reworded to mention that services are stopped at expiration and permanently deleted on `deletion_date`, plus a `deletion_1_day` final-notice email and a post-deletion confirmation email. - **Admin**: `grace_period_days` surfaced on the limit-profile admin. ## Restore path While an org is in grace it's read-only, so members can't self-resume. Support extends the trial (which lifts read-only); the member or support then resumes the stopped instances. No auto-unpause. ## Safety / defaults - Grace unset ⇒ **no deletion ever** (safe default; existing profiles migrate to `NULL`). - Rounding only moves dates **later**, so no historical trial day can become billable. - `--dry-run` touches nothing (no Odoo, K8s, DB, or email). - Fully tested with Odoo and Kubernetes mocked; migration verified on Postgres.
tobru force-pushed trial-cleanup-grace-period from 6ccef3355f
All checks were successful
Tests / test (push) Successful in 1m13s
to f843fff26a
All checks were successful
Tests / test (push) Successful in 1m27s
2026-07-03 13:36:13 +00:00
Compare
tobru changed title from WIP: Automated Trial Cleanup with Grace Period to Automated Trial Cleanup with Grace Period 2026-07-06 09:30:59 +00:00
tobru force-pushed trial-cleanup-grace-period from c1f3b63873
All checks were successful
Tests / test (push) Successful in 1m36s
to 6ae0c91939
All checks were successful
Tests / test (push) Successful in 1m21s
2026-07-06 12:01:46 +00:00
Compare
Co-locate the background worker as a servala-worker container in the
portal Deployment instead of a standalone Deployment, so it shares the
web pod's netbird sidecar (SOCKS proxy to control planes and Odoo) and
its netbird peer license. The trial-cleanup task needs that reachability;
a separate worker Deployment would have needed its own netbird. Split it
back out if it ever needs to scale independently.

Adds terminationGracePeriodSeconds: 120 so in-flight jobs drain on
SIGTERM. Removes the worker-deployment.yaml base and the per-overlay
portal-worker patches; the worker's env moves onto the web-deployment
overlay patch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pause and decommission failures were swallowed, so the hourly task
reported success while doing nothing. Now hard failures propagate: the
command raises CommandError, which makes the procrastinate task retry
(exponential backoff) and shows up in monitoring. Failures are raised
after the per-org transaction commits, so partial progress (deleted
instances, paused resources) is preserved and a retry only redoes what
is left. One failing org no longer blocks the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
just fmt
All checks were successful
Tests / test (push) Successful in 1m24s
7b987ed28a
tobru merged commit f98fdf6bec into main 2026-07-06 13:55:55 +00:00
tobru deleted branch trial-cleanup-grace-period 2026-07-06 13:55:55 +00:00
Sign in to join this conversation.
No description provided.