From 3a1249408be46603c7cc7a818413b192f9ecddc7 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 1 Sep 2026 07:18:14 +0200 Subject: [PATCH 1/8] Expose snooze/unsnooze via CRM --- .../team/components/overview.ex | 121 +++++++++++++++++- lib/plausible/team_deletion_schedules.ex | 19 ++- .../team_deletion_schedules_test.exs | 31 +++++ .../live/customer_support/teams_test.exs | 70 ++++++++++ 4 files changed, 238 insertions(+), 3 deletions(-) diff --git a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex index 54823df50170..bd9e19f1d78d 100644 --- a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex +++ b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex @@ -5,16 +5,21 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do use PlausibleWeb, :live_component import PlausibleWeb.CustomerSupport.Live + alias Plausible.TeamDeletionSchedules + def update(%{team: team}, socket) do changeset = Plausible.Teams.Team.crm_changeset(team, %{}) form = to_form(changeset) + schedule = TeamDeletionSchedules.active_schedule_for_team(team) - {:ok, assign(socket, team: team, form: form)} + {:ok, assign(socket, team: team, form: form, schedule: schedule)} end def render(assigns) do ~H"""
+ <.deletion_schedule :if={@schedule} schedule={@schedule} myself={@myself} /> + <.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}> <.input field={f[:trial_expiry_date]} type="date" label="Trial Expiry Date" /> <.input field={f[:accept_traffic_until]} type="date" label="Accept traffic Until" /> @@ -45,6 +50,83 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do """ end + attr :schedule, :any, required: true + attr :myself, :any, required: true + + defp deletion_schedule(assigns) do + ~H""" +
+ <.notice theme={notice_theme(@schedule.status)} title="Deletion scheduled"> + <%= deletion_sentence(@schedule) %> + +
+

+ Snoozed until {@schedule.snoozed_until} — "{@schedule.snooze_note}". +

+ + <.button + class="mt-2" + phx-click="unsnooze-schedule" + phx-target={@myself} + data-confirm="Resume the deletion schedule now? This restarts the notice cycle." + > + Unsnooze + +
+ +
+ <.input type="date" name="until" value="" label="Snooze until" /> + <.input type="text" name="note" value="" label="Note (optional)" /> + <.button type="submit">Snooze +
+ + +
+ """ + end + + defp notice_theme(:snoozed), do: :gray + defp notice_theme(_), do: :yellow + + defp deletion_sentence(schedule) do + category = + case schedule.category do + :expired_trial -> "expired trial" + :churned_subscription -> "churned subscription" + end + + status_detail = + case schedule.status do + :scheduled -> + "Pending. First notice due #{schedule.first_notice_due_date}." + + :first_notice_sent -> + "First notice sent #{format_dt(schedule.first_notice_sent_at)}." + + :reminder_sent -> + "Reminder sent #{format_dt(schedule.reminder_sent_at)}." + + :snoozed -> + "Snoozed." + + :cancelled -> + "Cancelled." + + :completed -> + "Completed." + end + + "#{String.capitalize(category)}. Stats deletion on #{schedule.deletion_date}. #{status_detail}" + end + + defp format_dt(nil), do: "N/A" + defp format_dt(%NaiveDateTime{} = dt), do: NaiveDateTime.to_date(dt) |> Date.to_string() + def handle_event("save-team", %{"team" => params}, socket) do changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params) @@ -73,4 +155,41 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do {:noreply, socket} end end + + def handle_event("snooze-schedule", %{"until" => until_str} = params, socket) do + case Date.from_iso8601(until_str) do + {:ok, until_date} -> + note = + case params |> Map.get("note", "") |> String.trim() do + "" -> nil + note -> note + end + + case TeamDeletionSchedules.snooze(socket.assigns.schedule, until_date, note: note) do + {:ok, schedule} -> + success("Deletion snoozed until #{until_date}") + {:noreply, assign(socket, schedule: schedule)} + + {:error, {:invalid_transition, _, _}} -> + failure("Could not snooze - schedule is no longer in a snoozable state") + {:noreply, socket} + end + + {:error, _} -> + failure("Invalid date") + {:noreply, socket} + end + end + + def handle_event("unsnooze-schedule", _params, socket) do + case TeamDeletionSchedules.unsnooze(socket.assigns.schedule) do + {:ok, schedule} -> + success("Deletion schedule resumed") + {:noreply, assign(socket, schedule: schedule)} + + {:error, {:invalid_transition, _, _}} -> + failure("Could not resume - schedule is not currently snoozed") + {:noreply, socket} + end + end end diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index 9a1c180fcff9..c0c06c8ee712 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -125,6 +125,16 @@ defmodule Plausible.TeamDeletionSchedules do ) end + @doc """ + Get the team's current active (non-terminal) deletion schedule, if any + """ + @spec active_schedule_for_team(Teams.Team.t()) :: TeamDeletionSchedule.t() | nil + def active_schedule_for_team(team) do + team.id + |> active_schedule_query() + |> Repo.one() + end + @doc """ Pending, non-backlog expired trial schedules for the given team ids, keyed by `team_id` @@ -267,11 +277,16 @@ defmodule Plausible.TeamDeletionSchedules do end defp active_schedule_for(team_id) do + team_id + |> active_schedule_query() + |> lock("FOR UPDATE") + |> Repo.one() + end + + defp active_schedule_query(team_id) do TeamDeletionSchedule |> where([sch], sch.team_id == ^team_id) |> where([sch], sch.status in ^TeamDeletionSchedule.active_statuses()) - |> lock("FOR UPDATE") - |> Repo.one() end defp eligible_category?(today) do diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index 9dde10804797..0cbd7b532a59 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -296,6 +296,37 @@ defmodule Plausible.TeamDeletionSchedulesTest do end end + describe "active_schedule_for_team/1" do + test "returns the team's active schedule" do + team = insert(:team) + schedule = insert(:team_deletion_schedule, team: team, status: :reminder_sent) + + assert result = TeamDeletionSchedules.active_schedule_for_team(team) + assert result.id == schedule.id + end + + test "returns nil for a team with no schedule at all" do + team = insert(:team) + + assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + end + + test "returns nil when the team's only schedule is terminal" do + team = insert(:team) + insert(:team_deletion_schedule, team: team, status: :cancelled) + + assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + end + + test "does not return another team's schedule" do + team = insert(:team) + other_team = insert(:team) + insert(:team_deletion_schedule, team: other_team, status: :scheduled) + + assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + end + end + describe "transitions/0" do test "matches the schema's known statuses" do transitions = TeamDeletionSchedules.transitions() diff --git a/test/plausible_web/live/customer_support/teams_test.exs b/test/plausible_web/live/customer_support/teams_test.exs index 214be799ff04..e2dfd5efd656 100644 --- a/test/plausible_web/live/customer_support/teams_test.exs +++ b/test/plausible_web/live/customer_support/teams_test.exs @@ -159,6 +159,76 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do {:ok, _lv, _html} = live(conn, open_team(9999)) end end + + test "does not show a deletion schedule section when there is none", %{ + conn: conn, + user: user + } do + team = team_of(user) + + {:ok, _lv, html} = live(conn, open_team(team.id)) + + refute text(html) =~ "Deletion scheduled" + end + + test "shows an active deletion schedule and allows snoozing it", %{ + conn: conn, + user: user + } do + team = team_of(user) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :reminder_sent, + deletion_date: ~D[2026-10-19] + ) + + {:ok, lv, html} = live(conn, open_team(team.id)) + + assert text(html) =~ "Deletion scheduled" + assert element_exists?(html, ~s|form[phx-submit="snooze-schedule"]|) + + lv + |> element(~s|form[phx-submit="snooze-schedule"]|) + |> render_submit(%{"until" => "2026-09-20", "note" => "give them time"}) + + html = render(lv) + assert text(html) =~ "Snoozed until 2026-09-20" + assert text(html) =~ "give them time" + + updated = Plausible.Repo.reload!(schedule) + assert updated.status == :snoozed + assert updated.snoozed_until == ~D[2026-09-20] + assert updated.snooze_note == "give them time" + end + + test "allows unsnoozing a snoozed schedule", %{conn: conn, user: user} do + team = team_of(user) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :snoozed, + snoozed_until: ~D[2026-09-20], + snooze_note: "customer asked for time", + deletion_date: ~D[2026-10-19] + ) + + {:ok, lv, html} = live(conn, open_team(team.id)) + + assert text(html) =~ "Snoozed until" + assert element_exists?(html, ~s|button[phx-click="unsnooze-schedule"]|) + + lv |> element(~s|button[phx-click="unsnooze-schedule"]|) |> render_click() + + html = render(lv) + assert element_exists?(html, ~s|form[phx-submit="snooze-schedule"]|) + + updated = Plausible.Repo.reload!(schedule) + assert updated.status == :scheduled + assert updated.is_backlog + end end describe "sites" do From 309e2a5268c5adb2266c364264de7f0f84dd0b15 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 1 Sep 2026 08:59:09 +0200 Subject: [PATCH 2/8] Cancel any deletions if trial date gets prolonged --- .../team/components/overview.ex | 59 ++++++++++--------- lib/plausible/team_deletion_schedules.ex | 18 +++++- .../team_deletion_schedules_test.exs | 28 ++++++++- .../live/customer_support/teams_test.exs | 28 +++++++++ test/workers/execute_team_deletions_test.exs | 12 +++- .../send_deletion_notifications_test.exs | 10 +++- 6 files changed, 118 insertions(+), 37 deletions(-) diff --git a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex index bd9e19f1d78d..e9f81530b4ac 100644 --- a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex +++ b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex @@ -57,35 +57,35 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do ~H"""
<.notice theme={notice_theme(@schedule.status)} title="Deletion scheduled"> - <%= deletion_sentence(@schedule) %> + {deletion_sentence(@schedule)} -
-

- Snoozed until {@schedule.snoozed_until} — "{@schedule.snooze_note}". -

+
+

+ Snoozed until + {@schedule.snoozed_until} — "{@schedule.snooze_note}". +

- <.button - class="mt-2" - phx-click="unsnooze-schedule" + <.button + class="mt-2" + phx-click="unsnooze-schedule" + phx-target={@myself} + data-confirm="Resume the deletion schedule now? This restarts the notice cycle." + > + Unsnooze + +
+ +
- Unsnooze - -
- - - <.input type="date" name="until" value="" label="Snooze until" /> - <.input type="text" name="note" value="" label="Note (optional)" /> - <.button type="submit">Snooze - - - + <.input type="date" name="until" value="" label="Snooze until" /> + <.input type="text" name="note" value="" label="Note (optional)" /> + <.button type="submit">Snooze + +
""" end @@ -130,12 +130,15 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do def handle_event("save-team", %{"team" => params}, socket) do changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params) - # TODO: if this prolongs trial_expiry_date (or otherwise makes the team - # eligible again) cancely any Plausible.TeamDeletionSchedule case Plausible.Repo.update(changeset) do {:ok, team} -> + # Prolonging trial_expiry_date (or otherwise making the team + # eligible again) cancels any pending deletion schedule. + TeamDeletionSchedules.cancel_for_team(team) + schedule = TeamDeletionSchedules.active_schedule_for_team(team) + success("Team saved") - {:noreply, assign(socket, team: team, form: to_form(changeset))} + {:noreply, assign(socket, team: team, form: to_form(changeset), schedule: schedule)} {:error, changeset} -> failure("Error saving team: #{inspect(changeset.errors)}") diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index c0c06c8ee712..e158b8340687 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -62,19 +62,33 @@ defmodule Plausible.TeamDeletionSchedules do end @doc """ - Cancels any pending deletion schedule for a team + Cancels any pending deletion schedule for a team that's no longer + eligible for it - either its subscription became active, or (for a + schedule based on an expired trial) its trial_expiry_date got prolonged + past today, e.g. by staff via the CRM. """ @spec cancel_for_team(Teams.Team.t()) :: non_neg_integer() def cancel_for_team(team) do team = Teams.with_subscription(team) - if Subscriptions.active?(team.subscription) do + if should_cancel?(team) do cancel_active_schedule(team.id) else 0 end end + defp should_cancel?(%{subscription: nil} = team) do + # Teams.on_trial?/1 treats a cleared trial_expiry_date as "not on + # trial", but here a cleared date means there's no trial-based justification + # for the schedule at all, so it should cancel too. + Teams.on_trial?(team) or is_nil(team.trial_expiry_date) + end + + defp should_cancel?(team) do + Subscriptions.active?(team.subscription) + end + @doc """ Schedules due for their first notice - still scheduled and past their first_notice_due_date. diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index 0cbd7b532a59..e8669a8f47d7 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -242,14 +242,38 @@ defmodule Plausible.TeamDeletionSchedulesTest do assert Repo.reload(schedule).status == :cancelled end - test "does not cancel when the team has no subscription at all" do - team = insert(:team) + test "does not cancel when the team has no subscription and its trial is still expired" do + team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: -1)) schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) assert TeamDeletionSchedules.cancel_for_team(team) == 0 assert Repo.reload(schedule).status == :scheduled end + test "cancels when the team has no subscription but its trial is no longer expired" do + team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: 30)) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + + assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert Repo.reload(schedule).status == :cancelled + end + + test "cancels when the team's trial_expiry_date is today (no longer counts as expired)" do + team = insert(:team, trial_expiry_date: Date.utc_today()) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + + assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert Repo.reload(schedule).status == :cancelled + end + + test "cancels when the team has no subscription and no trial_expiry_date at all" do + team = insert(:team, trial_expiry_date: nil) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + + assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert Repo.reload(schedule).status == :cancelled + end + test "does not cancel for a paused subscription" do team = insert(:team) schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) diff --git a/test/plausible_web/live/customer_support/teams_test.exs b/test/plausible_web/live/customer_support/teams_test.exs index e2dfd5efd656..9b44f21848b8 100644 --- a/test/plausible_web/live/customer_support/teams_test.exs +++ b/test/plausible_web/live/customer_support/teams_test.exs @@ -154,6 +154,34 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do assert text_of_attr(html, "#team_accept_traffic_until", "value") == "2029-01-15" end + test "prolonging trial_expiry_date cancels a pending expired-trial deletion schedule", %{ + conn: conn, + user: user + } do + team = team_of(user) + + schedule = + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :scheduled, + deletion_date: ~D[2026-10-19] + ) + + {:ok, lv, html} = live(conn, open_team(team.id)) + + assert text(html) =~ "Deletion scheduled" + + lv + |> element(~s|form[phx-submit="save-team"]|) + |> render_submit(%{"team" => %{"trial_expiry_date" => "2029-01-01"}}) + + html = render(lv) + refute text(html) =~ "Deletion scheduled" + + assert Plausible.Repo.reload!(schedule).status == :cancelled + end + test "404", %{conn: conn} do assert_raise Ecto.NoResultsError, fn -> {:ok, _lv, _html} = live(conn, open_team(9999)) diff --git a/test/workers/execute_team_deletions_test.exs b/test/workers/execute_team_deletions_test.exs index c220f25ca58d..ac6ab1d5fd18 100644 --- a/test/workers/execute_team_deletions_test.exs +++ b/test/workers/execute_team_deletions_test.exs @@ -12,7 +12,7 @@ defmodule Plausible.Workers.ExecuteTeamDeletionsTest do test "deletes the team's site and marks the schedule completed, keeping the team intact" do owner = new_user() site = new_site(owner: owner) - team = team_of(owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() schedule = insert(:team_deletion_schedule, @@ -31,7 +31,7 @@ defmodule Plausible.Workers.ExecuteTeamDeletionsTest do test "deletes every site owned by the team" do owner = new_user() site_a = new_site(owner: owner) - team = team_of(owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() site_b = new_site(team: team) insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today) @@ -68,7 +68,7 @@ defmodule Plausible.Workers.ExecuteTeamDeletionsTest do test "passes the schedule's category through as the pending stats deletion reason (expired_trial)" do owner = new_user() site = new_site(owner: owner) - team = team_of(owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() insert(:team_deletion_schedule, team: team, @@ -87,6 +87,12 @@ defmodule Plausible.Workers.ExecuteTeamDeletionsTest do site = new_site(owner: owner) team = team_of(owner) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -400) + ) + insert(:team_deletion_schedule, team: team, category: :churned_subscription, diff --git a/test/workers/send_deletion_notifications_test.exs b/test/workers/send_deletion_notifications_test.exs index 3851ed672a88..e5e95cbf3458 100644 --- a/test/workers/send_deletion_notifications_test.exs +++ b/test/workers/send_deletion_notifications_test.exs @@ -17,6 +17,12 @@ defmodule Plausible.Workers.SendDeletionNotificationsTest do insert(:team_membership, team: team, user: build(:user), role: :billing) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -400) + ) + schedule = insert(:team_deletion_schedule, team: team, @@ -47,7 +53,7 @@ defmodule Plausible.Workers.SendDeletionNotificationsTest do test "finalizes a backlog row's deletion_date anchored to when the notice actually sends" do owner = new_user() new_site(owner: owner) - team = team_of(owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() schedule = insert(:team_deletion_schedule, @@ -115,7 +121,7 @@ defmodule Plausible.Workers.SendDeletionNotificationsTest do test "sends the reminder email and advances status" do owner = new_user() new_site(owner: owner) - team = team_of(owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() schedule = insert(:team_deletion_schedule, From b200038e591641150e01c90bef1eb6bb4129934e Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 1 Sep 2026 10:13:09 +0200 Subject: [PATCH 3/8] Put a badge next to teams with pending deletions --- .../customer_support/resource/team.ex | 6 ++--- .../components/search_result.ex | 7 +++++ lib/plausible/teams/team.ex | 3 +++ .../live/customer_support_test.exs | 27 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/extra/lib/plausible/customer_support/resource/team.ex b/extra/lib/plausible/customer_support/resource/team.ex index d8129323770c..9e94162618bb 100644 --- a/extra/lib/plausible/customer_support/resource/team.ex +++ b/extra/lib/plausible/customer_support/resource/team.ex @@ -24,7 +24,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do left_lateral_join: s in subquery(Teams.last_subscription_join_query()), on: true, order_by: [desc: :id], - preload: [owners: o, subscription: s] + preload: [:team_deletion_schedule, owners: o, subscription: s] ) Plausible.Repo.all(q) @@ -39,7 +39,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do as: :team, inner_join: o in assoc(t, :owners), where: t.identifier == ^input, - preload: [owners: o] + preload: [:team_deletion_schedule, owners: o] ) else from(t in Plausible.Teams.Team, @@ -56,7 +56,7 @@ defmodule Plausible.CustomerSupport.Resource.Team do desc: fragment("?.email = ?", o, ^input), asc: t.name ], - preload: [owners: o] + preload: [:team_deletion_schedule, owners: o] ) end diff --git a/extra/lib/plausible_web/live/customer_support/components/search_result.ex b/extra/lib/plausible_web/live/customer_support/components/search_result.ex index 5cb2c00a63bd..bdc0ca66a115 100644 --- a/extra/lib/plausible_web/live/customer_support/components/search_result.ex +++ b/extra/lib/plausible_web/live/customer_support/components/search_result.ex @@ -33,6 +33,13 @@ defmodule PlausibleWeb.CustomerSupport.Components.SearchResult do > $ + + 🧨 +

diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex index bd4872c3df9e..2f184f4198d4 100644 --- a/lib/plausible/teams/team.ex +++ b/lib/plausible/teams/team.ex @@ -66,6 +66,9 @@ defmodule Plausible.Teams.Team do has_one :subscription, Plausible.Billing.Subscription has_one :enterprise_plan, Plausible.Billing.EnterprisePlan + has_one :team_deletion_schedule, Plausible.TeamDeletionSchedule, + where: [status: {:in, Plausible.TeamDeletionSchedule.active_statuses()}] + on_ee do has_one :sso_integration, Plausible.Auth.SSO.Integration end diff --git a/test/plausible_web/live/customer_support_test.exs b/test/plausible_web/live/customer_support_test.exs index ff65980336ef..05f8c77676a4 100644 --- a/test/plausible_web/live/customer_support_test.exs +++ b/test/plausible_web/live/customer_support_test.exs @@ -42,6 +42,33 @@ defmodule PlausibleWeb.Live.CustomerSupportTest do refute_search_result(resp, "site", consolidated_site.id) end + test "shows a pending-deletion badge for a team with an active schedule", %{ + conn: conn, + user: user + } do + team = team_of(user) + insert(:team_deletion_schedule, team: team, status: :scheduled) + + conn = get(conn, @cs_index) + resp = html_response(conn, 200) + + assert text_of_element(resp, ~s|a[data-test-type="team"][data-test-id="#{team.id}"]|) =~ + "🧨" + end + + test "does not show a pending-deletion badge for a team without one", %{ + conn: conn, + user: user + } do + team = team_of(user) + + conn = get(conn, @cs_index) + resp = html_response(conn, 200) + + refute text_of_element(resp, ~s|a[data-test-type="team"][data-test-id="#{team.id}"]|) =~ + "🧨" + end + test "filters as you type", %{conn: conn, site: site, user: user} do site2 = new_site(owner: user, domain: "hello.example.com") {:ok, lv, _html} = live(conn, @cs_index) From 00f78a1fbdcaaeb1791bfcc9834f1442aba7ca75 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 1 Sep 2026 10:13:31 +0200 Subject: [PATCH 4/8] Validate staff submitted snooze --- .../team/components/overview.ex | 62 +++++++++------- lib/plausible/team_deletion_schedule.ex | 21 ++++++ .../plausible/team_deletion_schedule_test.exs | 74 +++++++++++++++++++ .../live/customer_support/teams_test.exs | 53 ++++++++++++- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex index e9f81530b4ac..b840703db1da 100644 --- a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex +++ b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex @@ -5,20 +5,27 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do use PlausibleWeb, :live_component import PlausibleWeb.CustomerSupport.Live + alias Plausible.TeamDeletionSchedule alias Plausible.TeamDeletionSchedules def update(%{team: team}, socket) do changeset = Plausible.Teams.Team.crm_changeset(team, %{}) form = to_form(changeset) schedule = TeamDeletionSchedules.active_schedule_for_team(team) + snooze_form = schedule && to_form(TeamDeletionSchedule.crm_changeset(schedule, %{})) - {:ok, assign(socket, team: team, form: form, schedule: schedule)} + {:ok, assign(socket, team: team, form: form, schedule: schedule, snooze_form: snooze_form)} end def render(assigns) do ~H"""
- <.deletion_schedule :if={@schedule} schedule={@schedule} myself={@myself} /> + <.deletion_schedule + :if={@schedule} + schedule={@schedule} + snooze_form={@snooze_form} + myself={@myself} + /> <.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}> <.input field={f[:trial_expiry_date]} type="date" label="Trial Expiry Date" /> @@ -51,6 +58,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do end attr :schedule, :any, required: true + attr :snooze_form, :any, required: true attr :myself, :any, required: true defp deletion_schedule(assigns) do @@ -75,16 +83,18 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do
-
- <.input type="date" name="until" value="" label="Snooze until" /> - <.input type="text" name="note" value="" label="Note (optional)" /> + <.input field={f[:snoozed_until]} type="date" label="Snooze until" /> + <.input field={f[:snooze_note]} type="text" label="Note (optional)" /> <.button type="submit">Snooze -
+ """ @@ -159,28 +169,24 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do end end - def handle_event("snooze-schedule", %{"until" => until_str} = params, socket) do - case Date.from_iso8601(until_str) do - {:ok, until_date} -> - note = - case params |> Map.get("note", "") |> String.trim() do - "" -> nil - note -> note - end - - case TeamDeletionSchedules.snooze(socket.assigns.schedule, until_date, note: note) do - {:ok, schedule} -> - success("Deletion snoozed until #{until_date}") - {:noreply, assign(socket, schedule: schedule)} - - {:error, {:invalid_transition, _, _}} -> - failure("Could not snooze - schedule is no longer in a snoozable state") - {:noreply, socket} - end - - {:error, _} -> - failure("Invalid date") - {:noreply, socket} + def handle_event("snooze-schedule", %{"team_deletion_schedule" => params}, socket) do + changeset = TeamDeletionSchedule.crm_changeset(socket.assigns.schedule, params) + + if changeset.valid? do + until_date = Ecto.Changeset.get_change(changeset, :snoozed_until) + note = Ecto.Changeset.get_change(changeset, :snooze_note) + + case TeamDeletionSchedules.snooze(socket.assigns.schedule, until_date, note: note) do + {:ok, schedule} -> + success("Deletion snoozed until #{until_date}") + {:noreply, assign(socket, schedule: schedule)} + + {:error, {:invalid_transition, _, _}} -> + failure("Could not snooze - schedule is no longer in a snoozable state") + {:noreply, socket} + end + else + {:noreply, assign(socket, snooze_form: to_form(%{changeset | action: :validate}))} end end diff --git a/lib/plausible/team_deletion_schedule.ex b/lib/plausible/team_deletion_schedule.ex index 639781d064ca..2051e8872712 100644 --- a/lib/plausible/team_deletion_schedule.ex +++ b/lib/plausible/team_deletion_schedule.ex @@ -6,6 +6,8 @@ defmodule Plausible.TeamDeletionSchedule do use Ecto.Schema + import Ecto.Changeset + @categories [:expired_trial, :churned_subscription] @statuses [:scheduled, :first_notice_sent, :reminder_sent, :completed, :cancelled, :snoozed] @@ -42,4 +44,23 @@ defmodule Plausible.TeamDeletionSchedule do @spec active_statuses() :: [atom()] def active_statuses, do: @statuses -- @terminal_statuses + + @doc """ + Validates staff submitted snooze input from the CRM - snoozed_until is + required and must be in the future. Doesn't touch status, the actual + transition happens via Plausible.TeamDeletionSchedules.snooze/3. + """ + @spec crm_changeset(t(), map()) :: Ecto.Changeset.t() + def crm_changeset(schedule, params) do + schedule + |> cast(params, [:snoozed_until, :snooze_note]) + |> validate_required([:snoozed_until]) + |> validate_change(:snoozed_until, fn field, date -> + if Date.after?(date, Date.utc_today()) do + [] + else + [{field, "must be in the future"}] + end + end) + end end diff --git a/test/plausible/team_deletion_schedule_test.exs b/test/plausible/team_deletion_schedule_test.exs index 1f61f1a3d53b..1cb2aafaf2c3 100644 --- a/test/plausible/team_deletion_schedule_test.exs +++ b/test/plausible/team_deletion_schedule_test.exs @@ -102,6 +102,80 @@ defmodule Plausible.TeamDeletionScheduleTest do end end + describe "crm_changeset/2" do + test "is valid for a snoozed_until date in the future" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + until_date = Date.shift(Date.utc_today(), day: 1) + + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(until_date), + "snooze_note" => "customer asked for time" + }) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :snoozed_until) == until_date + assert Ecto.Changeset.get_change(changeset, :snooze_note) == "customer asked for time" + end + + test "does not require a note" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + until_date = Date.shift(Date.utc_today(), day: 1) + + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(until_date) + }) + + assert changeset.valid? + end + + test "requires snoozed_until" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + + changeset = TeamDeletionSchedule.crm_changeset(schedule, %{}) + + refute changeset.valid? + assert {"can't be blank", _} = changeset.errors[:snoozed_until] + end + + test "rejects a snoozed_until of today" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(Date.utc_today()) + }) + + refute changeset.valid? + assert {"must be in the future", _} = changeset.errors[:snoozed_until] + end + + test "rejects a snoozed_until in the past" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(Date.shift(Date.utc_today(), day: -1)) + }) + + refute changeset.valid? + assert {"must be in the future", _} = changeset.errors[:snoozed_until] + end + + test "does not touch status" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + until_date = Date.shift(Date.utc_today(), day: 1) + + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(until_date) + }) + + refute Ecto.Changeset.get_change(changeset, :status) + end + end + describe "team_id foreign key" do test "deleting the team cascades to the schedule" do schedule = insert(:team_deletion_schedule) diff --git a/test/plausible_web/live/customer_support/teams_test.exs b/test/plausible_web/live/customer_support/teams_test.exs index 9b44f21848b8..fdf4fcd13d35 100644 --- a/test/plausible_web/live/customer_support/teams_test.exs +++ b/test/plausible_web/live/customer_support/teams_test.exs @@ -219,7 +219,12 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do lv |> element(~s|form[phx-submit="snooze-schedule"]|) - |> render_submit(%{"until" => "2026-09-20", "note" => "give them time"}) + |> render_submit(%{ + "team_deletion_schedule" => %{ + "snoozed_until" => "2026-09-20", + "snooze_note" => "give them time" + } + }) html = render(lv) assert text(html) =~ "Snoozed until 2026-09-20" @@ -231,6 +236,52 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do assert updated.snooze_note == "give them time" end + test "rejects a snooze date that isn't in the future", %{conn: conn, user: user} do + team = team_of(user) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :reminder_sent, + deletion_date: ~D[2026-10-19] + ) + + {:ok, lv, _html} = live(conn, open_team(team.id)) + + today_iso = Date.to_iso8601(Date.utc_today()) + + html = + lv + |> element(~s|form[phx-submit="snooze-schedule"]|) + |> render_submit(%{"team_deletion_schedule" => %{"snoozed_until" => today_iso}}) + + assert text(html) =~ "must be in the future" + assert element_exists?(html, ~s|form[phx-submit="snooze-schedule"]|) + + assert Plausible.Repo.reload!(schedule).status == :reminder_sent + end + + test "rejects a blank snooze date", %{conn: conn, user: user} do + team = team_of(user) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :reminder_sent, + deletion_date: ~D[2026-10-19] + ) + + {:ok, lv, _html} = live(conn, open_team(team.id)) + + html = + lv + |> element(~s|form[phx-submit="snooze-schedule"]|) + |> render_submit(%{"team_deletion_schedule" => %{"snoozed_until" => ""}}) + + assert text(html) =~ "can't be blank" + assert Plausible.Repo.reload!(schedule).status == :reminder_sent + end + test "allows unsnoozing a snoozed schedule", %{conn: conn, user: user} do team = team_of(user) From 1962a4b787d22e104e799e6803681359dbbfb4cd Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 1 Sep 2026 10:19:54 +0200 Subject: [PATCH 5/8] Implement unsnooze background sweep --- config/runtime.exs | 5 ++ lib/plausible/team_deletion_schedules.ex | 14 ++++ lib/workers/unsnooze_team_deletions.ex | 19 ++++++ .../team_deletion_schedules_test.exs | 33 ++++++++++ test/workers/unsnooze_team_deletions_test.exs | 64 +++++++++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 lib/workers/unsnooze_team_deletions.ex create mode 100644 test/workers/unsnooze_team_deletions_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index b014c0b0b838..5a85deb255e9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -846,6 +846,10 @@ cloud_cron = [ {"0 15 * * *", Plausible.Workers.NotifyAnnualRenewal}, # Every midnight {"0 0 * * *", Plausible.Workers.LockSites}, + # Daily at 6, ahead of ScanInactiveTeams - restarts the notice cycle for + # any lapsed snoozes so they're immediately eligible again same-day + # TODO: enable + # {"0 6 * * *", Plausible.Workers.UnsnoozeTeamDeletions}, # Daily at 7, ahead of AcceptTrafficUntil/SendTrialNotifications # TODO: enable # {"0 7 * * *", Plausible.Workers.ScanInactiveTeams}, @@ -891,6 +895,7 @@ cloud_queues = [ notify_annual_renewal: 1, lock_sites: 1, scan_inactive_teams: 1, + unsnooze_team_deletions: 1, deletion_notification_emails: 1, execute_team_deletions: 1, legacy_time_on_page_cutoff: 1, diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index e158b8340687..7b76709ee6a0 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -139,6 +139,20 @@ defmodule Plausible.TeamDeletionSchedules do ) end + @doc """ + Schedules whose snooze has lapsed: still snoozed, past their + snoozed_until date. + """ + @spec due_for_unsnooze(Date.t()) :: [TeamDeletionSchedule.t()] + def due_for_unsnooze(today \\ Date.utc_today()) do + Repo.all( + from(sch in TeamDeletionSchedule, + where: sch.status == :snoozed, + where: sch.snoozed_until <= ^today + ) + ) + end + @doc """ Get the team's current active (non-terminal) deletion schedule, if any """ diff --git a/lib/workers/unsnooze_team_deletions.ex b/lib/workers/unsnooze_team_deletions.ex new file mode 100644 index 000000000000..2d521412619b --- /dev/null +++ b/lib/workers/unsnooze_team_deletions.ex @@ -0,0 +1,19 @@ +defmodule Plausible.Workers.UnsnoozeTeamDeletions do + @moduledoc """ + Restarts the notice cycle for schedules whose snooze has lapsed: still + `:snoozed`, past their `snoozed_until` date. + """ + + use Oban.Worker, queue: :unsnooze_team_deletions, max_attempts: 1 + + alias Plausible.TeamDeletionSchedules + + @impl Oban.Worker + def perform(_job, today \\ Date.utc_today()) do + for schedule <- TeamDeletionSchedules.due_for_unsnooze(today) do + TeamDeletionSchedules.unsnooze(schedule, today: today, report_if_invalid?: true) + end + + :ok + end +end diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index e8669a8f47d7..abac9f56f456 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -713,6 +713,39 @@ defmodule Plausible.TeamDeletionSchedulesTest do end end + describe "due_for_unsnooze/1" do + test "returns a snoozed row whose snoozed_until has arrived" do + schedule = + insert(:team_deletion_schedule, status: :snoozed, snoozed_until: @today) + + assert [%{id: id}] = TeamDeletionSchedules.due_for_unsnooze(@today) + assert id == schedule.id + end + + test "returns a row whose snoozed_until is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + snoozed_until: Date.shift(@today, day: -3) + ) + + assert [%{id: id}] = TeamDeletionSchedules.due_for_unsnooze(@today) + assert id == schedule.id + end + + test "does not return a row whose snoozed_until is still in the future" do + insert(:team_deletion_schedule, status: :snoozed, snoozed_until: Date.shift(@today, day: 1)) + + assert TeamDeletionSchedules.due_for_unsnooze(@today) == [] + end + + test "does not return a row that isn't snoozed" do + insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) + + assert TeamDeletionSchedules.due_for_unsnooze(@today) == [] + end + end + describe "pending_steady_state_trials_by_team_id/1" do test "returns a scheduled, non-backlog expired_trial schedule keyed by team_id" do team = insert(:team) diff --git a/test/workers/unsnooze_team_deletions_test.exs b/test/workers/unsnooze_team_deletions_test.exs new file mode 100644 index 000000000000..3a59bab38c83 --- /dev/null +++ b/test/workers/unsnooze_team_deletions_test.exs @@ -0,0 +1,64 @@ +defmodule Plausible.Workers.UnsnoozeTeamDeletionsTest do + use Plausible.DataCase, async: true + + alias Plausible.Workers.UnsnoozeTeamDeletions + + @today ~D[2026-08-20] + + test "restarts the notice cycle for a schedule whose snooze has lapsed" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + is_backlog: false, + snoozed_until: @today, + snooze_note: "customer asked for time", + first_notice_sent_at: ~N[2026-07-01 10:00:00], + reminder_sent_at: ~N[2026-07-20 10:00:00] + ) + + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + + updated = Repo.reload!(schedule) + assert updated.status == :scheduled + assert updated.is_backlog + assert updated.first_notice_due_date == @today + assert updated.first_notice_sent_at == nil + assert updated.reminder_sent_at == nil + assert updated.snoozed_until == nil + assert updated.snooze_note == nil + end + + test "restarts a schedule whose snooze is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + snoozed_until: Date.shift(@today, day: -3) + ) + + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + + assert Repo.reload!(schedule).status == :scheduled + end + + test "does not touch a row whose snooze hasn't lapsed yet" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + snoozed_until: Date.shift(@today, day: 1) + ) + + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + + updated = Repo.reload!(schedule) + assert updated.status == :snoozed + assert updated.snoozed_until == Date.shift(@today, day: 1) + end + + test "does not touch a row that isn't snoozed" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) + + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + + assert Repo.reload!(schedule).status == :reminder_sent + end +end From d9798fe4cda59ab90f99a3155a630ac5d6f3988d Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 3 Sep 2026 15:22:12 +0200 Subject: [PATCH 6/8] Change cancel_for_team/1 return type --- lib/plausible/team_deletion_schedules.ex | 18 +++++++-------- lib/workers/execute_team_deletions.ex | 2 +- lib/workers/send_deletion_notifications.ex | 4 ++-- .../team_deletion_schedules_test.exs | 22 +++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index 7b76709ee6a0..0116c14eaa0c 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -67,14 +67,14 @@ defmodule Plausible.TeamDeletionSchedules do schedule based on an expired trial) its trial_expiry_date got prolonged past today, e.g. by staff via the CRM. """ - @spec cancel_for_team(Teams.Team.t()) :: non_neg_integer() + @spec cancel_for_team(Teams.Team.t()) :: :no_schedule | :ok def cancel_for_team(team) do team = Teams.with_subscription(team) if should_cancel?(team) do cancel_active_schedule(team.id) else - 0 + :no_schedule end end @@ -286,21 +286,21 @@ defmodule Plausible.TeamDeletionSchedules do end defp cancel_active_schedule(team_id) do - {:ok, count} = + {:ok, result} = Repo.transact(fn -> case active_schedule_for(team_id) do - nil -> {:ok, 0} - schedule -> {:ok, cancel_count(schedule)} + nil -> {:ok, :no_schedule} + schedule -> {:ok, cancel_result(schedule)} end end) - count + result end - defp cancel_count(schedule) do + defp cancel_result(schedule) do case cancel(schedule) do - {:ok, _} -> 1 - {:error, _} -> 0 + {:ok, _} -> :ok + {:error, _} -> :no_schedule end end diff --git a/lib/workers/execute_team_deletions.ex b/lib/workers/execute_team_deletions.ex index dbd64582e444..65ea4d4d271d 100644 --- a/lib/workers/execute_team_deletions.ex +++ b/lib/workers/execute_team_deletions.ex @@ -21,7 +21,7 @@ defmodule Plausible.Workers.ExecuteTeamDeletions do for schedule <- TeamDeletionSchedules.due_for_deletion(today) do team = schedule.team - if TeamDeletionSchedules.cancel_for_team(team) == 0 do + if TeamDeletionSchedules.cancel_for_team(team) == :no_schedule do execute(schedule, team) end end diff --git a/lib/workers/send_deletion_notifications.ex b/lib/workers/send_deletion_notifications.ex index 574c2e0813aa..8dea14df1b72 100644 --- a/lib/workers/send_deletion_notifications.ex +++ b/lib/workers/send_deletion_notifications.ex @@ -29,7 +29,7 @@ defmodule Plausible.Workers.SendDeletionNotifications do for schedule <- TeamDeletionSchedules.due_for_first_notice(today) do team = schedule.team - if TeamDeletionSchedules.cancel_for_team(team) == 0 do + if TeamDeletionSchedules.cancel_for_team(team) == :no_schedule do summary = sites_summary(team) for recipient <- team.owners ++ team.billing_members do @@ -47,7 +47,7 @@ defmodule Plausible.Workers.SendDeletionNotifications do for schedule <- TeamDeletionSchedules.due_for_reminder(today) do team = schedule.team - if TeamDeletionSchedules.cancel_for_team(team) == 0 do + if TeamDeletionSchedules.cancel_for_team(team) == :no_schedule do summary = sites_summary(team) for recipient <- team.owners ++ team.billing_members do diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index abac9f56f456..3be75db83c95 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -224,7 +224,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) insert(:subscription, team: team, status: Subscription.Status.active()) - assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert TeamDeletionSchedules.cancel_for_team(team) == :ok assert Repo.reload(schedule).status == :cancelled end @@ -238,7 +238,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do next_bill_date: Date.shift(Date.utc_today(), day: 10) ) - assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert TeamDeletionSchedules.cancel_for_team(team) == :ok assert Repo.reload(schedule).status == :cancelled end @@ -246,7 +246,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: -1)) schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == 0 + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule assert Repo.reload(schedule).status == :scheduled end @@ -254,7 +254,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: 30)) schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert TeamDeletionSchedules.cancel_for_team(team) == :ok assert Repo.reload(schedule).status == :cancelled end @@ -262,7 +262,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do team = insert(:team, trial_expiry_date: Date.utc_today()) schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert TeamDeletionSchedules.cancel_for_team(team) == :ok assert Repo.reload(schedule).status == :cancelled end @@ -270,7 +270,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do team = insert(:team, trial_expiry_date: nil) schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == 1 + assert TeamDeletionSchedules.cancel_for_team(team) == :ok assert Repo.reload(schedule).status == :cancelled end @@ -279,7 +279,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) insert(:subscription, team: team, status: Subscription.Status.paused()) - assert TeamDeletionSchedules.cancel_for_team(team) == 0 + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule assert Repo.reload(schedule).status == :scheduled end @@ -293,7 +293,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do next_bill_date: Date.shift(Date.utc_today(), day: -1) ) - assert TeamDeletionSchedules.cancel_for_team(team) == 0 + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule assert Repo.reload(schedule).status == :scheduled end @@ -306,8 +306,8 @@ defmodule Plausible.TeamDeletionSchedulesTest do insert(:subscription, team: team1, status: Subscription.Status.active()) insert(:subscription, team: team2, status: Subscription.Status.active()) - assert TeamDeletionSchedules.cancel_for_team(team1) == 0 - assert TeamDeletionSchedules.cancel_for_team(team2) == 0 + assert TeamDeletionSchedules.cancel_for_team(team1) == :no_schedule + assert TeamDeletionSchedules.cancel_for_team(team2) == :no_schedule assert Repo.reload(cancelled).status == :cancelled assert Repo.reload(completed).status == :completed end @@ -316,7 +316,7 @@ defmodule Plausible.TeamDeletionSchedulesTest do team = insert(:team) insert(:subscription, team: team, status: Subscription.Status.active()) - assert TeamDeletionSchedules.cancel_for_team(team) == 0 + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule end end From 396ebc25fd2ce525eb2e261ad4154f66c48d37e6 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 3 Sep 2026 15:53:51 +0200 Subject: [PATCH 7/8] ee --- .../send_deletion_notifications_test.exs | 315 +++++++++--------- 1 file changed, 159 insertions(+), 156 deletions(-) diff --git a/test/workers/send_deletion_notifications_test.exs b/test/workers/send_deletion_notifications_test.exs index e5e95cbf3458..45a929811ed5 100644 --- a/test/workers/send_deletion_notifications_test.exs +++ b/test/workers/send_deletion_notifications_test.exs @@ -1,216 +1,219 @@ defmodule Plausible.Workers.SendDeletionNotificationsTest do use Plausible.DataCase, async: true - use Bamboo.Test - require Plausible.Billing.Subscription.Status + on_ee do + use Bamboo.Test - alias Plausible.Billing.Subscription - alias Plausible.Workers.SendDeletionNotifications + require Plausible.Billing.Subscription.Status - @today ~D[2026-08-20] + alias Plausible.Billing.Subscription + alias Plausible.Workers.SendDeletionNotifications - describe "first notices" do - test "sends the full notice email to owners and billing members" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) + @today ~D[2026-08-20] - insert(:team_membership, team: team, user: build(:user), role: :billing) + describe "first notices" do + test "sends the full notice email to owners and billing members" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(@today, day: -400) - ) + insert(:team_membership, team: team, user: build(:user), role: :billing) - schedule = - insert(:team_deletion_schedule, + insert(:subscription, team: team, - category: :churned_subscription, - status: :scheduled, - first_notice_due_date: @today, - deletion_date: ~D[2026-10-19] + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -400) ) - SendDeletionNotifications.perform(nil, @today) + schedule = + insert(:team_deletion_schedule, + team: team, + category: :churned_subscription, + status: :scheduled, + first_notice_due_date: @today, + deletion_date: ~D[2026-10-19] + ) - team = Repo.preload(team, [:owners, :billing_members]) - recipients = team.owners ++ team.billing_members + SendDeletionNotifications.perform(nil, @today) - assert length(recipients) == 2 + team = Repo.preload(team, [:owners, :billing_members]) + recipients = team.owners ++ team.billing_members - for recipient <- recipients do - assert_email_delivered_with( - to: [{recipient.name, recipient.email}], - subject: "Your Plausible dashboards and stats will be deleted in 30 days" - ) - end + assert length(recipients) == 2 - assert Repo.reload!(schedule).status == :first_notice_sent - assert Repo.reload!(schedule).first_notice_sent_at - end + for recipient <- recipients do + assert_email_delivered_with( + to: [{recipient.name, recipient.email}], + subject: "Your Plausible dashboards and stats will be deleted in 30 days" + ) + end - test "finalizes a backlog row's deletion_date anchored to when the notice actually sends" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() + assert Repo.reload!(schedule).status == :first_notice_sent + assert Repo.reload!(schedule).first_notice_sent_at + end - schedule = - insert(:team_deletion_schedule, - team: team, - category: :expired_trial, - status: :scheduled, - is_backlog: true, - first_notice_due_date: @today, - deletion_date: ~D[2024-01-01] - ) + test "finalizes a backlog row's deletion_date anchored to when the notice actually sends" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() + + schedule = + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :scheduled, + is_backlog: true, + first_notice_due_date: @today, + deletion_date: ~D[2024-01-01] + ) + + SendDeletionNotifications.perform(nil, @today) + + updated = Repo.reload!(schedule) + assert updated.status == :first_notice_sent + assert updated.deletion_date == Date.shift(@today, day: 30) + end - SendDeletionNotifications.perform(nil, @today) + test "does not touch a row whose first_notice_due_date hasn't arrived" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) - updated = Repo.reload!(schedule) - assert updated.status == :first_notice_sent - assert updated.deletion_date == Date.shift(@today, day: 30) - end + schedule = + insert(:team_deletion_schedule, + team: team, + status: :scheduled, + first_notice_due_date: Date.shift(@today, day: 1) + ) - test "does not touch a row whose first_notice_due_date hasn't arrived" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) + SendDeletionNotifications.perform(nil, @today) - schedule = - insert(:team_deletion_schedule, - team: team, - status: :scheduled, - first_notice_due_date: Date.shift(@today, day: 1) + refute_email_delivered_with( + subject: "Your Plausible dashboards and stats will be deleted in 30 days" ) - SendDeletionNotifications.perform(nil, @today) + assert Repo.reload!(schedule).status == :scheduled + end - refute_email_delivered_with( - subject: "Your Plausible dashboards and stats will be deleted in 30 days" - ) + test "cancels instead of sending when the team has reactivated since the last scan" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) - assert Repo.reload!(schedule).status == :scheduled - end + schedule = + insert(:team_deletion_schedule, + team: team, + status: :scheduled, + first_notice_due_date: @today + ) - test "cancels instead of sending when the team has reactivated since the last scan" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) + insert(:subscription, team: team, status: Subscription.Status.active()) - schedule = - insert(:team_deletion_schedule, - team: team, - status: :scheduled, - first_notice_due_date: @today + SendDeletionNotifications.perform(nil, @today) + + refute_email_delivered_with( + subject: "Your Plausible dashboards and stats will be deleted in 30 days" ) - insert(:subscription, team: team, status: Subscription.Status.active()) + assert Repo.reload!(schedule).status == :cancelled + end + end - SendDeletionNotifications.perform(nil, @today) + describe "reminders" do + test "sends the reminder email and advances status" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() - refute_email_delivered_with( - subject: "Your Plausible dashboards and stats will be deleted in 30 days" - ) + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 3) + ) - assert Repo.reload!(schedule).status == :cancelled - end - end - - describe "reminders" do - test "sends the reminder email and advances status" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() + SendDeletionNotifications.perform(nil, @today) - schedule = - insert(:team_deletion_schedule, - team: team, - status: :first_notice_sent, - deletion_date: Date.shift(@today, day: 3) + assert_email_delivered_with( + to: [{owner.name, owner.email}], + subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" ) - SendDeletionNotifications.perform(nil, @today) + updated = Repo.reload!(schedule) + assert updated.status == :reminder_sent + assert updated.reminder_sent_at + end - assert_email_delivered_with( - to: [{owner.name, owner.email}], - subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" - ) + test "does not touch a row whose deletion_date is still more than 5 days out" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) - updated = Repo.reload!(schedule) - assert updated.status == :reminder_sent - assert updated.reminder_sent_at - end + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 6) + ) - test "does not touch a row whose deletion_date is still more than 5 days out" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) + SendDeletionNotifications.perform(nil, @today) - schedule = - insert(:team_deletion_schedule, - team: team, - status: :first_notice_sent, - deletion_date: Date.shift(@today, day: 6) + refute_email_delivered_with( + subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" ) - SendDeletionNotifications.perform(nil, @today) - - refute_email_delivered_with( - subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" - ) - - assert Repo.reload!(schedule).status == :first_notice_sent - end + assert Repo.reload!(schedule).status == :first_notice_sent + end - test "cancels instead of sending when the team has reactivated since the last scan" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) + test "cancels instead of sending when the team has reactivated since the last scan" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) - schedule = - insert(:team_deletion_schedule, - team: team, - status: :first_notice_sent, - deletion_date: Date.shift(@today, day: 3) - ) + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 3) + ) - insert(:subscription, team: team, status: Subscription.Status.active()) + insert(:subscription, team: team, status: Subscription.Status.active()) - SendDeletionNotifications.perform(nil, @today) + SendDeletionNotifications.perform(nil, @today) - refute_email_delivered_with( - subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" - ) + refute_email_delivered_with( + subject: "Final notice: your Plausible dashboards and stats will be deleted in 5 days" + ) - assert Repo.reload!(schedule).status == :cancelled + assert Repo.reload!(schedule).status == :cancelled + end end - end - describe "sites_summary/1" do - test "returns every domain uncapped when the team has few sites" do - owner = new_user() - new_site(owner: owner) - new_site(owner: owner) - team = team_of(owner) + describe "sites_summary/1" do + test "returns every domain uncapped when the team has few sites" do + owner = new_user() + new_site(owner: owner) + new_site(owner: owner) + team = team_of(owner) - summary = SendDeletionNotifications.sites_summary(team) + summary = SendDeletionNotifications.sites_summary(team) - assert length(summary.domains) == 2 - assert summary.more_count == 0 - end + assert length(summary.domains) == 2 + assert summary.more_count == 0 + end - test "caps the domain list and reports how many more sites exist" do - owner = new_user() + test "caps the domain list and reports how many more sites exist" do + owner = new_user() - for _ <- 1..13, do: new_site(owner: owner) + for _ <- 1..13, do: new_site(owner: owner) - team = team_of(owner) + team = team_of(owner) - summary = SendDeletionNotifications.sites_summary(team) + summary = SendDeletionNotifications.sites_summary(team) - assert length(summary.domains) == 3 - assert summary.more_count == 10 + assert length(summary.domains) == 3 + assert summary.more_count == 10 + end end end end From 38f809dd4653ee9e3068b9cfa5ed53d9397d3b15 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 3 Sep 2026 16:09:48 +0200 Subject: [PATCH 8/8] ee --- .../plausible/team_deletion_schedule_test.exs | 444 ++++--- .../team_deletion_schedules_test.exs | 1183 +++++++++-------- .../teams/deletion_schedule_test.exs | 86 +- test/workers/execute_team_deletions_test.exs | 256 ++-- test/workers/unsnooze_team_deletions_test.exs | 94 +- 5 files changed, 1040 insertions(+), 1023 deletions(-) diff --git a/test/plausible/team_deletion_schedule_test.exs b/test/plausible/team_deletion_schedule_test.exs index 1cb2aafaf2c3..8903e2281ca5 100644 --- a/test/plausible/team_deletion_schedule_test.exs +++ b/test/plausible/team_deletion_schedule_test.exs @@ -1,277 +1,279 @@ defmodule Plausible.TeamDeletionScheduleTest do use Plausible.DataCase, async: true - alias Plausible.TeamDeletionSchedule - - describe "schema" do - test "inserts a valid schedule with expected defaults" do - team = insert(:team) - today = Date.utc_today() - deletion_date = Date.shift(today, day: 60) - - assert {:ok, schedule} = - %TeamDeletionSchedule{ - team_id: team.id, - category: :expired_trial, - expiry_date: today, - deletion_date: deletion_date, - first_notice_due_date: Date.shift(deletion_date, day: -30) - } - |> Repo.insert() - - assert schedule.status == :scheduled - assert schedule.is_backlog == false - assert is_nil(schedule.first_notice_sent_at) - assert is_nil(schedule.reminder_sent_at) - assert is_nil(schedule.snoozed_until) - end + on_ee do + alias Plausible.TeamDeletionSchedule - test "accepts both categories" do - team1 = insert(:team) - team2 = insert(:team) - today = Date.utc_today() - - assert {:ok, %{category: :expired_trial}} = - Repo.insert(%TeamDeletionSchedule{ - team_id: team1.id, - category: :expired_trial, - expiry_date: today, - deletion_date: Date.shift(today, day: 60), - first_notice_due_date: Date.shift(today, day: 30) - }) - - assert {:ok, %{category: :churned_subscription}} = - Repo.insert(%TeamDeletionSchedule{ - team_id: team2.id, - category: :churned_subscription, - expiry_date: today, - deletion_date: Date.shift(today, day: 180), - first_notice_due_date: Date.shift(today, day: 150) - }) - end + describe "schema" do + test "inserts a valid schedule with expected defaults" do + team = insert(:team) + today = Date.utc_today() + deletion_date = Date.shift(today, day: 60) - test "rejects an invalid category" do - team = insert(:team) - today = Date.utc_today() + assert {:ok, schedule} = + %TeamDeletionSchedule{ + team_id: team.id, + category: :expired_trial, + expiry_date: today, + deletion_date: deletion_date, + first_notice_due_date: Date.shift(deletion_date, day: -30) + } + |> Repo.insert() + + assert schedule.status == :scheduled + assert schedule.is_backlog == false + assert is_nil(schedule.first_notice_sent_at) + assert is_nil(schedule.reminder_sent_at) + assert is_nil(schedule.snoozed_until) + end - assert_raise Ecto.ChangeError, fn -> - Repo.insert(%TeamDeletionSchedule{ - team_id: team.id, - category: :not_a_real_category, - expiry_date: today, - deletion_date: today, - first_notice_due_date: today - }) + test "accepts both categories" do + team1 = insert(:team) + team2 = insert(:team) + today = Date.utc_today() + + assert {:ok, %{category: :expired_trial}} = + Repo.insert(%TeamDeletionSchedule{ + team_id: team1.id, + category: :expired_trial, + expiry_date: today, + deletion_date: Date.shift(today, day: 60), + first_notice_due_date: Date.shift(today, day: 30) + }) + + assert {:ok, %{category: :churned_subscription}} = + Repo.insert(%TeamDeletionSchedule{ + team_id: team2.id, + category: :churned_subscription, + expiry_date: today, + deletion_date: Date.shift(today, day: 180), + first_notice_due_date: Date.shift(today, day: 150) + }) end - end - test "rejects an invalid status" do - team = insert(:team) - today = Date.utc_today() + test "rejects an invalid category" do + team = insert(:team) + today = Date.utc_today() + + assert_raise Ecto.ChangeError, fn -> + Repo.insert(%TeamDeletionSchedule{ + team_id: team.id, + category: :not_a_real_category, + expiry_date: today, + deletion_date: today, + first_notice_due_date: today + }) + end + end - assert_raise Ecto.ChangeError, fn -> - Repo.insert(%TeamDeletionSchedule{ - team_id: team.id, - category: :expired_trial, - status: :not_a_real_status, - expiry_date: today, - deletion_date: today, - first_notice_due_date: today - }) + test "rejects an invalid status" do + team = insert(:team) + today = Date.utc_today() + + assert_raise Ecto.ChangeError, fn -> + Repo.insert(%TeamDeletionSchedule{ + team_id: team.id, + category: :expired_trial, + status: :not_a_real_status, + expiry_date: today, + deletion_date: today, + first_notice_due_date: today + }) + end end - end - test "categories/0 and statuses/0 expose the valid enum values" do - assert TeamDeletionSchedule.categories() == [:expired_trial, :churned_subscription] - - assert TeamDeletionSchedule.statuses() == [ - :scheduled, - :first_notice_sent, - :reminder_sent, - :completed, - :cancelled, - :snoozed - ] - end + test "categories/0 and statuses/0 expose the valid enum values" do + assert TeamDeletionSchedule.categories() == [:expired_trial, :churned_subscription] + + assert TeamDeletionSchedule.statuses() == [ + :scheduled, + :first_notice_sent, + :reminder_sent, + :completed, + :cancelled, + :snoozed + ] + end - test "terminal_statuses/0 and active_statuses/0 partition statuses/0" do - terminal = TeamDeletionSchedule.terminal_statuses() - active = TeamDeletionSchedule.active_statuses() + test "terminal_statuses/0 and active_statuses/0 partition statuses/0" do + terminal = TeamDeletionSchedule.terminal_statuses() + active = TeamDeletionSchedule.active_statuses() - assert Enum.sort(terminal ++ active) == Enum.sort(TeamDeletionSchedule.statuses()) + assert Enum.sort(terminal ++ active) == Enum.sort(TeamDeletionSchedule.statuses()) + end end - end - describe "crm_changeset/2" do - test "is valid for a snoozed_until date in the future" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) - until_date = Date.shift(Date.utc_today(), day: 1) + describe "crm_changeset/2" do + test "is valid for a snoozed_until date in the future" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + until_date = Date.shift(Date.utc_today(), day: 1) - changeset = - TeamDeletionSchedule.crm_changeset(schedule, %{ - "snoozed_until" => Date.to_iso8601(until_date), - "snooze_note" => "customer asked for time" - }) + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(until_date), + "snooze_note" => "customer asked for time" + }) - assert changeset.valid? - assert Ecto.Changeset.get_change(changeset, :snoozed_until) == until_date - assert Ecto.Changeset.get_change(changeset, :snooze_note) == "customer asked for time" - end + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :snoozed_until) == until_date + assert Ecto.Changeset.get_change(changeset, :snooze_note) == "customer asked for time" + end - test "does not require a note" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) - until_date = Date.shift(Date.utc_today(), day: 1) + test "does not require a note" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + until_date = Date.shift(Date.utc_today(), day: 1) - changeset = - TeamDeletionSchedule.crm_changeset(schedule, %{ - "snoozed_until" => Date.to_iso8601(until_date) - }) + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(until_date) + }) - assert changeset.valid? - end + assert changeset.valid? + end - test "requires snoozed_until" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) + test "requires snoozed_until" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) - changeset = TeamDeletionSchedule.crm_changeset(schedule, %{}) + changeset = TeamDeletionSchedule.crm_changeset(schedule, %{}) - refute changeset.valid? - assert {"can't be blank", _} = changeset.errors[:snoozed_until] - end + refute changeset.valid? + assert {"can't be blank", _} = changeset.errors[:snoozed_until] + end - test "rejects a snoozed_until of today" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) + test "rejects a snoozed_until of today" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) - changeset = - TeamDeletionSchedule.crm_changeset(schedule, %{ - "snoozed_until" => Date.to_iso8601(Date.utc_today()) - }) + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(Date.utc_today()) + }) - refute changeset.valid? - assert {"must be in the future", _} = changeset.errors[:snoozed_until] - end + refute changeset.valid? + assert {"must be in the future", _} = changeset.errors[:snoozed_until] + end - test "rejects a snoozed_until in the past" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) + test "rejects a snoozed_until in the past" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) - changeset = - TeamDeletionSchedule.crm_changeset(schedule, %{ - "snoozed_until" => Date.to_iso8601(Date.shift(Date.utc_today(), day: -1)) - }) + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(Date.shift(Date.utc_today(), day: -1)) + }) - refute changeset.valid? - assert {"must be in the future", _} = changeset.errors[:snoozed_until] - end + refute changeset.valid? + assert {"must be in the future", _} = changeset.errors[:snoozed_until] + end - test "does not touch status" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) - until_date = Date.shift(Date.utc_today(), day: 1) + test "does not touch status" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) + until_date = Date.shift(Date.utc_today(), day: 1) - changeset = - TeamDeletionSchedule.crm_changeset(schedule, %{ - "snoozed_until" => Date.to_iso8601(until_date) - }) + changeset = + TeamDeletionSchedule.crm_changeset(schedule, %{ + "snoozed_until" => Date.to_iso8601(until_date) + }) - refute Ecto.Changeset.get_change(changeset, :status) + refute Ecto.Changeset.get_change(changeset, :status) + end end - end - describe "team_id foreign key" do - test "deleting the team cascades to the schedule" do - schedule = insert(:team_deletion_schedule) + describe "team_id foreign key" do + test "deleting the team cascades to the schedule" do + schedule = insert(:team_deletion_schedule) - Repo.delete!(schedule.team) + Repo.delete!(schedule.team) - refute Repo.get(TeamDeletionSchedule, schedule.id) - end - - test "rejects a schedule for a non-existent team" do - today = Date.utc_today() + refute Repo.get(TeamDeletionSchedule, schedule.id) + end - assert_raise Ecto.ConstraintError, fn -> - Repo.insert!(%TeamDeletionSchedule{ - team_id: -1, - category: :expired_trial, - expiry_date: today, - deletion_date: today, - first_notice_due_date: today - }) + test "rejects a schedule for a non-existent team" do + today = Date.utc_today() + + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%TeamDeletionSchedule{ + team_id: -1, + category: :expired_trial, + expiry_date: today, + deletion_date: today, + first_notice_due_date: today + }) + end end end - end - describe "one_active_schedule_per_team index" do - test "rejects a second active schedule for the same team" do - team = insert(:team) - today = Date.utc_today() + describe "one_active_schedule_per_team index" do + test "rejects a second active schedule for the same team" do + team = insert(:team) + today = Date.utc_today() - base = %{ - team_id: team.id, - category: :expired_trial, - expiry_date: today, - deletion_date: Date.shift(today, day: 60), - first_notice_due_date: Date.shift(today, day: 30) - } + base = %{ + team_id: team.id, + category: :expired_trial, + expiry_date: today, + deletion_date: Date.shift(today, day: 60), + first_notice_due_date: Date.shift(today, day: 30) + } - assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base)) + assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base)) - assert_raise Ecto.ConstraintError, fn -> - Repo.insert!(struct(TeamDeletionSchedule, Map.put(base, :status, :first_notice_sent))) + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(struct(TeamDeletionSchedule, Map.put(base, :status, :first_notice_sent))) + end end - end - - test "allows a new schedule once the previous one is cancelled" do - team = insert(:team) - today = Date.utc_today() - base = %{ - team_id: team.id, - category: :expired_trial, - expiry_date: today, - deletion_date: Date.shift(today, day: 60), - first_notice_due_date: Date.shift(today, day: 30) - } + test "allows a new schedule once the previous one is cancelled" do + team = insert(:team) + today = Date.utc_today() - assert {:ok, _} = - struct(TeamDeletionSchedule, Map.put(base, :status, :cancelled)) - |> Repo.insert() + base = %{ + team_id: team.id, + category: :expired_trial, + expiry_date: today, + deletion_date: Date.shift(today, day: 60), + first_notice_due_date: Date.shift(today, day: 30) + } - assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base)) - end + assert {:ok, _} = + struct(TeamDeletionSchedule, Map.put(base, :status, :cancelled)) + |> Repo.insert() - test "allows a new schedule once the previous one is completed" do - team = insert(:team) - today = Date.utc_today() + assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base)) + end - base = %{ - team_id: team.id, - category: :churned_subscription, - expiry_date: today, - deletion_date: Date.shift(today, day: 180), - first_notice_due_date: Date.shift(today, day: 150) - } + test "allows a new schedule once the previous one is completed" do + team = insert(:team) + today = Date.utc_today() - assert {:ok, _} = - struct(TeamDeletionSchedule, Map.put(base, :status, :completed)) - |> Repo.insert() + base = %{ + team_id: team.id, + category: :churned_subscription, + expiry_date: today, + deletion_date: Date.shift(today, day: 180), + first_notice_due_date: Date.shift(today, day: 150) + } - assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base)) - end + assert {:ok, _} = + struct(TeamDeletionSchedule, Map.put(base, :status, :completed)) + |> Repo.insert() - test "allows active schedules for different teams" do - team1 = insert(:team) - team2 = insert(:team) - today = Date.utc_today() + assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base)) + end - for team <- [team1, team2] do - assert {:ok, _} = - Repo.insert(%TeamDeletionSchedule{ - team_id: team.id, - category: :expired_trial, - expiry_date: today, - deletion_date: Date.shift(today, day: 60), - first_notice_due_date: Date.shift(today, day: 30) - }) + test "allows active schedules for different teams" do + team1 = insert(:team) + team2 = insert(:team) + today = Date.utc_today() + + for team <- [team1, team2] do + assert {:ok, _} = + Repo.insert(%TeamDeletionSchedule{ + team_id: team.id, + category: :expired_trial, + expiry_date: today, + deletion_date: Date.shift(today, day: 60), + first_notice_due_date: Date.shift(today, day: 30) + }) + end end end end diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index 3be75db83c95..c60553fb015c 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -1,812 +1,819 @@ defmodule Plausible.TeamDeletionSchedulesTest do use Plausible.DataCase, async: true - require Plausible.Billing.Subscription.Status + on_ee do + require Plausible.Billing.Subscription.Status - alias Plausible.Billing.Subscription - alias Plausible.TeamDeletionSchedule - alias Plausible.TeamDeletionSchedules + alias Plausible.Billing.Subscription + alias Plausible.TeamDeletionSchedule + alias Plausible.TeamDeletionSchedules - @today ~D[2026-08-20] + @today ~D[2026-08-20] - describe "sync_eligible/1 - expired trials" do - test "schedules a team whose trial expired with no subscription" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - new_site(team: team) + describe "sync_eligible/1 - expired trials" do + test "schedules a team whose trial expired with no subscription" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + new_site(team: team) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 - schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) - assert schedule.category == :expired_trial - assert schedule.expiry_date == team.trial_expiry_date - assert schedule.deletion_date == Date.shift(team.trial_expiry_date, day: 60) - assert schedule.status == :scheduled - end + schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) + assert schedule.category == :expired_trial + assert schedule.expiry_date == team.trial_expiry_date + assert schedule.deletion_date == Date.shift(team.trial_expiry_date, day: 60) + assert schedule.status == :scheduled + end - test "does not schedule a team whose trial has not expired yet" do - insert(:team, trial_expiry_date: Date.shift(@today, day: 1)) + test "does not schedule a team whose trial has not expired yet" do + insert(:team, trial_expiry_date: Date.shift(@today, day: 1)) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - assert Repo.aggregate(TeamDeletionSchedule, :count) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + assert Repo.aggregate(TeamDeletionSchedule, :count) == 0 + end - test "does not schedule a team whose trial expires today" do - insert(:team, trial_expiry_date: @today) + test "does not schedule a team whose trial expires today" do + insert(:team, trial_expiry_date: @today) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "marks a long-expired trial as backlog, due today" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -400)) - new_site(team: team) + test "marks a long-expired trial as backlog, due today" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -400)) + new_site(team: team) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 - schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) - assert schedule.is_backlog - assert schedule.first_notice_due_date == @today - end + schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) + assert schedule.is_backlog + assert schedule.first_notice_due_date == @today + end - test "does not mark a recently-expired trial as backlog" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - new_site(team: team) + test "does not mark a recently-expired trial as backlog" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + new_site(team: team) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 - schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) - refute schedule.is_backlog - assert schedule.first_notice_due_date == Date.shift(team.trial_expiry_date, day: 30) - end + schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) + refute schedule.is_backlog + assert schedule.first_notice_due_date == Date.shift(team.trial_expiry_date, day: 30) + end - test "does not schedule a team with an expired trial but no sites" do - insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + test "does not schedule a team with an expired trial but no sites" do + insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end end - end - describe "sync_eligible/1 - churned subscriptions" do - test "schedules a team with a deleted subscription past its paid period" do - team = insert(:team) - new_site(team: team) + describe "sync_eligible/1 - churned subscriptions" do + test "schedules a team with a deleted subscription past its paid period" do + team = insert(:team) + new_site(team: team) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(@today, day: -1) - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -1) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 - schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) - assert schedule.category == :churned_subscription - assert schedule.deletion_date == Date.shift(schedule.expiry_date, day: 180) - end + schedule = Repo.get_by!(TeamDeletionSchedule, team_id: team.id) + assert schedule.category == :churned_subscription + assert schedule.deletion_date == Date.shift(schedule.expiry_date, day: 180) + end - test "schedules a team with a paused subscription past its paid period" do - team = insert(:team) - new_site(team: team) + test "schedules a team with a paused subscription past its paid period" do + team = insert(:team) + new_site(team: team) - insert(:subscription, - team: team, - status: Subscription.Status.paused(), - next_bill_date: Date.shift(@today, day: -1) - ) + insert(:subscription, + team: team, + status: Subscription.Status.paused(), + next_bill_date: Date.shift(@today, day: -1) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 - assert Repo.get_by(TeamDeletionSchedule, - team_id: team.id, - category: :churned_subscription - ) - end + assert Repo.get_by(TeamDeletionSchedule, + team_id: team.id, + category: :churned_subscription + ) + end - test "does not schedule a churned subscription team with no sites" do - team = insert(:team) + test "does not schedule a churned subscription team with no sites" do + team = insert(:team) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(@today, day: -1) - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -1) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "does not schedule a subscription whose paid period ends today" do - team = insert(:team) + test "does not schedule a subscription whose paid period ends today" do + team = insert(:team) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: @today - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: @today + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "does not schedule a team whose deleted subscription hasn't lapsed yet" do - team = insert(:team) + test "does not schedule a team whose deleted subscription hasn't lapsed yet" do + team = insert(:team) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(@today, day: 1) - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: 1) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "does not schedule a team with an active subscription" do - team = insert(:team) + test "does not schedule a team with an active subscription" do + team = insert(:team) - insert(:subscription, - team: team, - status: Subscription.Status.active(), - next_bill_date: Date.shift(@today, day: 30) - ) + insert(:subscription, + team: team, + status: Subscription.Status.active(), + next_bill_date: Date.shift(@today, day: 30) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "does not schedule a team with a past_due subscription" do - team = insert(:team) + test "does not schedule a team with a past_due subscription" do + team = insert(:team) - insert(:subscription, - team: team, - status: Subscription.Status.past_due(), - next_bill_date: Date.shift(@today, day: -10) - ) + insert(:subscription, + team: team, + status: Subscription.Status.past_due(), + next_bill_date: Date.shift(@today, day: -10) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "never schedules a free_10k plan even if marked deleted" do - team = insert(:team) + test "never schedules a free_10k plan even if marked deleted" do + team = insert(:team) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - paddle_plan_id: "free_10k", - next_bill_date: Date.shift(@today, day: -400) - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + paddle_plan_id: "free_10k", + next_bill_date: Date.shift(@today, day: -400) + ) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end end - end - describe "sync_eligible/1 - permanent exclusions" do - test "excludes enterprise teams even with an expired trial" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - insert(:enterprise_plan, team: team) + describe "sync_eligible/1 - permanent exclusions" do + test "excludes enterprise teams even with an expired trial" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + insert(:enterprise_plan, team: team) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end end - end - describe "sync_eligible/1 - idempotency" do - test "does not create a duplicate schedule on a second run" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - new_site(team: team) + describe "sync_eligible/1 - idempotency" do + test "does not create a duplicate schedule on a second run" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + new_site(team: team) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 - assert TeamDeletionSchedules.sync_eligible(@today) == 0 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 0 - assert Repo.aggregate(TeamDeletionSchedule, :count) == 1 - end + assert Repo.aggregate(TeamDeletionSchedule, :count) == 1 + end - test "does not schedule a team that already has an active schedule" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - insert(:team_deletion_schedule, team: team, status: :first_notice_sent) + test "does not schedule a team that already has an active schedule" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + insert(:team_deletion_schedule, team: team, status: :first_notice_sent) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "does not re-schedule a completed team with no sites left to delete" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - insert(:team_deletion_schedule, team: team, status: :completed) + test "does not re-schedule a completed team with no sites left to delete" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + insert(:team_deletion_schedule, team: team, status: :completed) - assert TeamDeletionSchedules.sync_eligible(@today) == 0 - end + assert TeamDeletionSchedules.sync_eligible(@today) == 0 + end - test "re-schedules a completed team if it still has sites (e.g. a partial deletion retry)" do - team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) - new_site(team: team) - insert(:team_deletion_schedule, team: team, status: :completed) + test "re-schedules a completed team if it still has sites (e.g. a partial deletion retry)" do + team = insert(:team, trial_expiry_date: Date.shift(@today, day: -1)) + new_site(team: team) + insert(:team_deletion_schedule, team: team, status: :completed) - assert TeamDeletionSchedules.sync_eligible(@today) == 1 + assert TeamDeletionSchedules.sync_eligible(@today) == 1 + end end - end - describe "cancel_for_team/1" do - test "cancels an active schedule when the team has an active subscription" do - team = insert(:team) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - insert(:subscription, team: team, status: Subscription.Status.active()) + describe "cancel_for_team/1" do + test "cancels an active schedule when the team has an active subscription" do + team = insert(:team) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + insert(:subscription, team: team, status: Subscription.Status.active()) - assert TeamDeletionSchedules.cancel_for_team(team) == :ok - assert Repo.reload(schedule).status == :cancelled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :ok + assert Repo.reload(schedule).status == :cancelled + end - test "cancels an active schedule for a deleted subscription still within its paid period" do - team = insert(:team) - schedule = insert(:team_deletion_schedule, team: team, status: :first_notice_sent) + test "cancels an active schedule for a deleted subscription still within its paid period" do + team = insert(:team) + schedule = insert(:team_deletion_schedule, team: team, status: :first_notice_sent) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(Date.utc_today(), day: 10) - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(Date.utc_today(), day: 10) + ) - assert TeamDeletionSchedules.cancel_for_team(team) == :ok - assert Repo.reload(schedule).status == :cancelled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :ok + assert Repo.reload(schedule).status == :cancelled + end - test "does not cancel when the team has no subscription and its trial is still expired" do - team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: -1)) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + test "does not cancel when the team has no subscription and its trial is still expired" do + team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: -1)) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule - assert Repo.reload(schedule).status == :scheduled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule + assert Repo.reload(schedule).status == :scheduled + end - test "cancels when the team has no subscription but its trial is no longer expired" do - team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: 30)) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + test "cancels when the team has no subscription but its trial is no longer expired" do + team = insert(:team, trial_expiry_date: Date.shift(Date.utc_today(), day: 30)) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == :ok - assert Repo.reload(schedule).status == :cancelled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :ok + assert Repo.reload(schedule).status == :cancelled + end - test "cancels when the team's trial_expiry_date is today (no longer counts as expired)" do - team = insert(:team, trial_expiry_date: Date.utc_today()) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + test "cancels when the team's trial_expiry_date is today (no longer counts as expired)" do + team = insert(:team, trial_expiry_date: Date.utc_today()) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == :ok - assert Repo.reload(schedule).status == :cancelled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :ok + assert Repo.reload(schedule).status == :cancelled + end - test "cancels when the team has no subscription and no trial_expiry_date at all" do - team = insert(:team, trial_expiry_date: nil) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + test "cancels when the team has no subscription and no trial_expiry_date at all" do + team = insert(:team, trial_expiry_date: nil) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - assert TeamDeletionSchedules.cancel_for_team(team) == :ok - assert Repo.reload(schedule).status == :cancelled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :ok + assert Repo.reload(schedule).status == :cancelled + end - test "does not cancel for a paused subscription" do - team = insert(:team) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - insert(:subscription, team: team, status: Subscription.Status.paused()) + test "does not cancel for a paused subscription" do + team = insert(:team) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + insert(:subscription, team: team, status: Subscription.Status.paused()) - assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule - assert Repo.reload(schedule).status == :scheduled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule + assert Repo.reload(schedule).status == :scheduled + end - test "does not cancel for a deleted subscription past its paid period" do - team = insert(:team) - schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) + test "does not cancel for a deleted subscription past its paid period" do + team = insert(:team) + schedule = insert(:team_deletion_schedule, team: team, status: :scheduled) - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(Date.utc_today(), day: -1) - ) + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(Date.utc_today(), day: -1) + ) - assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule - assert Repo.reload(schedule).status == :scheduled - end + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule + assert Repo.reload(schedule).status == :scheduled + end - test "leaves an already-cancelled or already-completed schedule untouched" do - team1 = insert(:team) - team2 = insert(:team) - cancelled = insert(:team_deletion_schedule, team: team1, status: :cancelled) - completed = insert(:team_deletion_schedule, team: team2, status: :completed) + test "leaves an already-cancelled or already-completed schedule untouched" do + team1 = insert(:team) + team2 = insert(:team) + cancelled = insert(:team_deletion_schedule, team: team1, status: :cancelled) + completed = insert(:team_deletion_schedule, team: team2, status: :completed) - insert(:subscription, team: team1, status: Subscription.Status.active()) - insert(:subscription, team: team2, status: Subscription.Status.active()) + insert(:subscription, team: team1, status: Subscription.Status.active()) + insert(:subscription, team: team2, status: Subscription.Status.active()) - assert TeamDeletionSchedules.cancel_for_team(team1) == :no_schedule - assert TeamDeletionSchedules.cancel_for_team(team2) == :no_schedule - assert Repo.reload(cancelled).status == :cancelled - assert Repo.reload(completed).status == :completed - end + assert TeamDeletionSchedules.cancel_for_team(team1) == :no_schedule + assert TeamDeletionSchedules.cancel_for_team(team2) == :no_schedule + assert Repo.reload(cancelled).status == :cancelled + assert Repo.reload(completed).status == :completed + end - test "is a no-op for a team with no schedule at all" do - team = insert(:team) - insert(:subscription, team: team, status: Subscription.Status.active()) + test "is a no-op for a team with no schedule at all" do + team = insert(:team) + insert(:subscription, team: team, status: Subscription.Status.active()) - assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule + assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule + end end - end - describe "active_schedule_for_team/1" do - test "returns the team's active schedule" do - team = insert(:team) - schedule = insert(:team_deletion_schedule, team: team, status: :reminder_sent) + describe "active_schedule_for_team/1" do + test "returns the team's active schedule" do + team = insert(:team) + schedule = insert(:team_deletion_schedule, team: team, status: :reminder_sent) - assert result = TeamDeletionSchedules.active_schedule_for_team(team) - assert result.id == schedule.id - end + assert result = TeamDeletionSchedules.active_schedule_for_team(team) + assert result.id == schedule.id + end - test "returns nil for a team with no schedule at all" do - team = insert(:team) + test "returns nil for a team with no schedule at all" do + team = insert(:team) - assert TeamDeletionSchedules.active_schedule_for_team(team) == nil - end + assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + end - test "returns nil when the team's only schedule is terminal" do - team = insert(:team) - insert(:team_deletion_schedule, team: team, status: :cancelled) + test "returns nil when the team's only schedule is terminal" do + team = insert(:team) + insert(:team_deletion_schedule, team: team, status: :cancelled) - assert TeamDeletionSchedules.active_schedule_for_team(team) == nil - end + assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + end - test "does not return another team's schedule" do - team = insert(:team) - other_team = insert(:team) - insert(:team_deletion_schedule, team: other_team, status: :scheduled) + test "does not return another team's schedule" do + team = insert(:team) + other_team = insert(:team) + insert(:team_deletion_schedule, team: other_team, status: :scheduled) - assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + assert TeamDeletionSchedules.active_schedule_for_team(team) == nil + end end - end - describe "transitions/0" do - test "matches the schema's known statuses" do - transitions = TeamDeletionSchedules.transitions() + describe "transitions/0" do + test "matches the schema's known statuses" do + transitions = TeamDeletionSchedules.transitions() - assert Map.keys(transitions) |> Enum.sort() == Enum.sort(TeamDeletionSchedule.statuses()) + assert Map.keys(transitions) |> Enum.sort() == Enum.sort(TeamDeletionSchedule.statuses()) - for {_from, targets} <- transitions, target <- targets do - assert target in TeamDeletionSchedule.statuses() + for {_from, targets} <- transitions, target <- targets do + assert target in TeamDeletionSchedule.statuses() + end end end - end - describe "mark_first_notice_sent/2" do - test "sends the first notice for a steady-state schedule, keeping its deletion_date" do - schedule = - insert(:team_deletion_schedule, - status: :scheduled, - is_backlog: false, - deletion_date: ~D[2026-10-19] - ) + describe "mark_first_notice_sent/2" do + test "sends the first notice for a steady-state schedule, keeping its deletion_date" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + is_backlog: false, + deletion_date: ~D[2026-10-19] + ) - now = ~N[2026-08-20 10:00:00] + now = ~N[2026-08-20 10:00:00] - assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now) - assert updated.status == :first_notice_sent - assert updated.first_notice_sent_at == now - assert updated.deletion_date == ~D[2026-10-19] - end + assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now) + assert updated.status == :first_notice_sent + assert updated.first_notice_sent_at == now + assert updated.deletion_date == ~D[2026-10-19] + end - test "sends the first notice for a backlog schedule, anchoring deletion_date to now" do - schedule = - insert(:team_deletion_schedule, - status: :scheduled, - is_backlog: true, - deletion_date: ~D[2026-01-01] - ) + test "sends the first notice for a backlog schedule, anchoring deletion_date to now" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + is_backlog: true, + deletion_date: ~D[2026-01-01] + ) - now = ~N[2026-08-20 10:00:00] + now = ~N[2026-08-20 10:00:00] - assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now) - assert updated.status == :first_notice_sent - assert updated.first_notice_sent_at == now - assert updated.deletion_date == Plausible.Teams.DeletionSchedule.backlog_deletion_date(now) - end + assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now: now) + assert updated.status == :first_notice_sent + assert updated.first_notice_sent_at == now - test "rejects any status other than :scheduled" do - for status <- TeamDeletionSchedule.statuses() -- [:scheduled] do - schedule = insert(:team_deletion_schedule, status: status) + assert updated.deletion_date == + Plausible.Teams.DeletionSchedule.backlog_deletion_date(now) + end + + test "rejects any status other than :scheduled" do + for status <- TeamDeletionSchedule.statuses() -- [:scheduled] do + schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.mark_first_notice_sent(schedule, - now: ~N[2026-08-20 10:00:00] - ) == - {:error, {:invalid_transition, status, :first_notice_sent}} + assert TeamDeletionSchedules.mark_first_notice_sent(schedule, + now: ~N[2026-08-20 10:00:00] + ) == + {:error, {:invalid_transition, status, :first_notice_sent}} + end end end - end - describe "mark_reminder_sent/2" do - test "sends the reminder for a schedule that already sent its first notice" do - schedule = insert(:team_deletion_schedule, status: :first_notice_sent) - now = ~N[2026-08-20 10:00:00] + describe "mark_reminder_sent/2" do + test "sends the reminder for a schedule that already sent its first notice" do + schedule = insert(:team_deletion_schedule, status: :first_notice_sent) + now = ~N[2026-08-20 10:00:00] - assert {:ok, updated} = TeamDeletionSchedules.mark_reminder_sent(schedule, now: now) - assert updated.status == :reminder_sent - assert updated.reminder_sent_at == now - end + assert {:ok, updated} = TeamDeletionSchedules.mark_reminder_sent(schedule, now: now) + assert updated.status == :reminder_sent + assert updated.reminder_sent_at == now + end - test "rejects any status other than :first_notice_sent" do - for status <- TeamDeletionSchedule.statuses() -- [:first_notice_sent] do - schedule = insert(:team_deletion_schedule, status: status) + test "rejects any status other than :first_notice_sent" do + for status <- TeamDeletionSchedule.statuses() -- [:first_notice_sent] do + schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.mark_reminder_sent(schedule, now: ~N[2026-08-20 10:00:00]) == - {:error, {:invalid_transition, status, :reminder_sent}} + assert TeamDeletionSchedules.mark_reminder_sent(schedule, now: ~N[2026-08-20 10:00:00]) == + {:error, {:invalid_transition, status, :reminder_sent}} + end end end - end - describe "mark_completed/1" do - test "completes a schedule that already sent its reminder" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent) + describe "mark_completed/1" do + test "completes a schedule that already sent its reminder" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent) - assert {:ok, updated} = TeamDeletionSchedules.mark_completed(schedule) - assert updated.status == :completed - end + assert {:ok, updated} = TeamDeletionSchedules.mark_completed(schedule) + assert updated.status == :completed + end - test "rejects any status other than :reminder_sent" do - for status <- TeamDeletionSchedule.statuses() -- [:reminder_sent] do - schedule = insert(:team_deletion_schedule, status: status) + test "rejects any status other than :reminder_sent" do + for status <- TeamDeletionSchedule.statuses() -- [:reminder_sent] do + schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.mark_completed(schedule) == - {:error, {:invalid_transition, status, :completed}} + assert TeamDeletionSchedules.mark_completed(schedule) == + {:error, {:invalid_transition, status, :completed}} + end end end - end - describe "cancel/1" do - test "cancels a schedule from any active status" do - for status <- [:scheduled, :first_notice_sent, :reminder_sent, :snoozed] do - schedule = insert(:team_deletion_schedule, status: status) + describe "cancel/1" do + test "cancels a schedule from any active status" do + for status <- [:scheduled, :first_notice_sent, :reminder_sent, :snoozed] do + schedule = insert(:team_deletion_schedule, status: status) - assert {:ok, updated} = TeamDeletionSchedules.cancel(schedule) - assert updated.status == :cancelled + assert {:ok, updated} = TeamDeletionSchedules.cancel(schedule) + assert updated.status == :cancelled + end end - end - test "rejects an already-terminal status" do - for status <- [:completed, :cancelled] do - schedule = insert(:team_deletion_schedule, status: status) + test "rejects an already-terminal status" do + for status <- [:completed, :cancelled] do + schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.cancel(schedule) == - {:error, {:invalid_transition, status, :cancelled}} + assert TeamDeletionSchedules.cancel(schedule) == + {:error, {:invalid_transition, status, :cancelled}} + end end end - end - describe "snooze/3" do - test "snoozes a schedule from any active, not-yet-snoozed status" do - for status <- [:scheduled, :first_notice_sent, :reminder_sent] do - schedule = insert(:team_deletion_schedule, status: status) + describe "snooze/3" do + test "snoozes a schedule from any active, not-yet-snoozed status" do + for status <- [:scheduled, :first_notice_sent, :reminder_sent] do + schedule = insert(:team_deletion_schedule, status: status) - assert {:ok, updated} = - TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20], - note: "customer asked for time" - ) + assert {:ok, updated} = + TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20], + note: "customer asked for time" + ) - assert updated.status == :snoozed - assert updated.snoozed_until == ~D[2026-09-20] - assert updated.snooze_note == "customer asked for time" + assert updated.status == :snoozed + assert updated.snoozed_until == ~D[2026-09-20] + assert updated.snooze_note == "customer asked for time" + end end - end - test "defaults the note to nil" do - schedule = insert(:team_deletion_schedule, status: :scheduled) + test "defaults the note to nil" do + schedule = insert(:team_deletion_schedule, status: :scheduled) - assert {:ok, updated} = TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20]) - assert updated.snooze_note == nil - end + assert {:ok, updated} = TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20]) + assert updated.snooze_note == nil + end - test "rejects an already-snoozed or terminal status" do - for status <- [:snoozed, :completed, :cancelled] do - schedule = insert(:team_deletion_schedule, status: status) + test "rejects an already-snoozed or terminal status" do + for status <- [:snoozed, :completed, :cancelled] do + schedule = insert(:team_deletion_schedule, status: status) - assert TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20]) == - {:error, {:invalid_transition, status, :snoozed}} + assert TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20]) == + {:error, {:invalid_transition, status, :snoozed}} + end end end - end - describe "unsnooze/2" do - test "restarts a snoozed schedule as a fresh backlog row due today" do - schedule = - insert(:team_deletion_schedule, - status: :snoozed, - is_backlog: false, - snoozed_until: ~D[2026-09-20], - snooze_note: "customer asked for time", - first_notice_sent_at: ~N[2026-07-01 10:00:00], - reminder_sent_at: ~N[2026-07-20 10:00:00] - ) + describe "unsnooze/2" do + test "restarts a snoozed schedule as a fresh backlog row due today" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + is_backlog: false, + snoozed_until: ~D[2026-09-20], + snooze_note: "customer asked for time", + first_notice_sent_at: ~N[2026-07-01 10:00:00], + reminder_sent_at: ~N[2026-07-20 10:00:00] + ) + + today = ~D[2026-08-20] + + assert {:ok, updated} = TeamDeletionSchedules.unsnooze(schedule, today: today) + assert updated.status == :scheduled + assert updated.is_backlog + assert updated.first_notice_due_date == today + assert updated.first_notice_sent_at == nil + assert updated.reminder_sent_at == nil + assert updated.snoozed_until == nil + assert updated.snooze_note == nil + end - today = ~D[2026-08-20] + test "rejects any status other than :snoozed" do + for status <- TeamDeletionSchedule.statuses() -- [:snoozed] do + schedule = insert(:team_deletion_schedule, status: status) - assert {:ok, updated} = TeamDeletionSchedules.unsnooze(schedule, today: today) - assert updated.status == :scheduled - assert updated.is_backlog - assert updated.first_notice_due_date == today - assert updated.first_notice_sent_at == nil - assert updated.reminder_sent_at == nil - assert updated.snoozed_until == nil - assert updated.snooze_note == nil + assert TeamDeletionSchedules.unsnooze(schedule, today: ~D[2026-08-20]) == + {:error, {:invalid_transition, status, :scheduled}} + end + end end - test "rejects any status other than :snoozed" do - for status <- TeamDeletionSchedule.statuses() -- [:snoozed] do - schedule = insert(:team_deletion_schedule, status: status) + describe "report_if_invalid? option" do + setup do + Plausible.Test.Support.Sentry.setup(self()) + :ok + end + + test "does not report to Sentry by default" do + schedule = insert(:team_deletion_schedule, status: :completed) - assert TeamDeletionSchedules.unsnooze(schedule, today: ~D[2026-08-20]) == - {:error, {:invalid_transition, status, :scheduled}} + assert TeamDeletionSchedules.cancel(schedule) == + {:error, {:invalid_transition, :completed, :cancelled}} + + assert [] = Sentry.Test.pop_sentry_reports() end - end - end - describe "report_if_invalid? option" do - setup do - Plausible.Test.Support.Sentry.setup(self()) - :ok - end + test "reports an invalid transition to Sentry when set" do + schedule = insert(:team_deletion_schedule, status: :completed) + + assert TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) == + {:error, {:invalid_transition, :completed, :cancelled}} + + assert [report] = Sentry.Test.pop_sentry_reports() + assert report.message.formatted == "Invalid team deletion schedule transition" + assert report.extra.from == :completed + assert report.extra.to == :cancelled + assert report.extra.team_id == schedule.team_id + end - test "does not report to Sentry by default" do - schedule = insert(:team_deletion_schedule, status: :completed) + test "does not report a successful transition even when set" do + schedule = insert(:team_deletion_schedule, status: :scheduled) - assert TeamDeletionSchedules.cancel(schedule) == - {:error, {:invalid_transition, :completed, :cancelled}} + assert {:ok, _} = TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) - assert [] = Sentry.Test.pop_sentry_reports() + assert [] = Sentry.Test.pop_sentry_reports() + end end - test "reports an invalid transition to Sentry when set" do - schedule = insert(:team_deletion_schedule, status: :completed) + describe "due_for_first_notice/1" do + test "returns a scheduled row whose first_notice_due_date has arrived" do + owner = new_user() + new_site(owner: owner) - assert TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) == - {:error, {:invalid_transition, :completed, :cancelled}} + schedule = + insert(:team_deletion_schedule, + team: team_of(owner), + status: :scheduled, + first_notice_due_date: @today + ) - assert [report] = Sentry.Test.pop_sentry_reports() - assert report.message.formatted == "Invalid team deletion schedule transition" - assert report.extra.from == :completed - assert report.extra.to == :cancelled - assert report.extra.team_id == schedule.team_id - end + [due] = TeamDeletionSchedules.due_for_first_notice(@today) + assert due.id == schedule.id + assert [%Plausible.Auth.User{}] = due.team.owners + end - test "does not report a successful transition even when set" do - schedule = insert(:team_deletion_schedule, status: :scheduled) + test "returns a row whose first_notice_due_date is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + first_notice_due_date: Date.shift(@today, day: -3) + ) - assert {:ok, _} = TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) + assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today) + assert id == schedule.id + end - assert [] = Sentry.Test.pop_sentry_reports() - end - end + test "does not return a row whose first_notice_due_date is still in the future" do + insert(:team_deletion_schedule, status: :scheduled, first_notice_due_date: @today) - describe "due_for_first_notice/1" do - test "returns a scheduled row whose first_notice_due_date has arrived" do - owner = new_user() - new_site(owner: owner) + assert TeamDeletionSchedules.due_for_first_notice(Date.shift(@today, day: -1)) == [] + end - schedule = + test "does not return a row that already had its first notice sent" do insert(:team_deletion_schedule, - team: team_of(owner), - status: :scheduled, + status: :first_notice_sent, first_notice_due_date: @today ) - [due] = TeamDeletionSchedules.due_for_first_notice(@today) - assert due.id == schedule.id - assert [%Plausible.Auth.User{}] = due.team.owners - end + assert TeamDeletionSchedules.due_for_first_notice(@today) == [] + end - test "returns a row whose first_notice_due_date is overdue (missed run catch-up)" do - schedule = + test "does not return a currently-snoozed row" do insert(:team_deletion_schedule, - status: :scheduled, - first_notice_due_date: Date.shift(@today, day: -3) + status: :snoozed, + first_notice_due_date: @today, + snoozed_until: Date.shift(@today, day: 1) ) - assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today) - assert id == schedule.id - end - - test "does not return a row whose first_notice_due_date is still in the future" do - insert(:team_deletion_schedule, status: :scheduled, first_notice_due_date: @today) - - assert TeamDeletionSchedules.due_for_first_notice(Date.shift(@today, day: -1)) == [] - end + assert TeamDeletionSchedules.due_for_first_notice(@today) == [] + end - test "does not return a row that already had its first notice sent" do - insert(:team_deletion_schedule, - status: :first_notice_sent, - first_notice_due_date: @today - ) + test "returns a row whose snooze has already lapsed" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + first_notice_due_date: @today, + snoozed_until: Date.shift(@today, day: -1) + ) - assert TeamDeletionSchedules.due_for_first_notice(@today) == [] + assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today) + assert id == schedule.id + end end - test "does not return a currently-snoozed row" do - insert(:team_deletion_schedule, - status: :snoozed, - first_notice_due_date: @today, - snoozed_until: Date.shift(@today, day: 1) - ) + describe "due_for_reminder/1" do + test "returns a first_notice_sent row within 5 days of its deletion_date" do + schedule = + insert(:team_deletion_schedule, + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 5) + ) - assert TeamDeletionSchedules.due_for_first_notice(@today) == [] - end + assert [%{id: id}] = TeamDeletionSchedules.due_for_reminder(@today) + assert id == schedule.id + end - test "returns a row whose snooze has already lapsed" do - schedule = + test "does not return a row whose deletion_date is more than 5 days out" do insert(:team_deletion_schedule, - status: :scheduled, - first_notice_due_date: @today, - snoozed_until: Date.shift(@today, day: -1) + status: :first_notice_sent, + deletion_date: Date.shift(@today, day: 6) ) - assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today) - assert id == schedule.id - end - end + assert TeamDeletionSchedules.due_for_reminder(@today) == [] + end - describe "due_for_reminder/1" do - test "returns a first_notice_sent row within 5 days of its deletion_date" do - schedule = + test "does not return a scheduled (not yet first-notified) row" do insert(:team_deletion_schedule, - status: :first_notice_sent, + status: :scheduled, deletion_date: Date.shift(@today, day: 5) ) - assert [%{id: id}] = TeamDeletionSchedules.due_for_reminder(@today) - assert id == schedule.id - end - - test "does not return a row whose deletion_date is more than 5 days out" do - insert(:team_deletion_schedule, - status: :first_notice_sent, - deletion_date: Date.shift(@today, day: 6) - ) - - assert TeamDeletionSchedules.due_for_reminder(@today) == [] - end + assert TeamDeletionSchedules.due_for_reminder(@today) == [] + end - test "does not return a scheduled (not yet first-notified) row" do - insert(:team_deletion_schedule, - status: :scheduled, - deletion_date: Date.shift(@today, day: 5) - ) + test "does not return a currently-snoozed row" do + insert(:team_deletion_schedule, + status: :snoozed, + deletion_date: Date.shift(@today, day: 5), + snoozed_until: Date.shift(@today, day: 1) + ) - assert TeamDeletionSchedules.due_for_reminder(@today) == [] + assert TeamDeletionSchedules.due_for_reminder(@today) == [] + end end - test "does not return a currently-snoozed row" do - insert(:team_deletion_schedule, - status: :snoozed, - deletion_date: Date.shift(@today, day: 5), - snoozed_until: Date.shift(@today, day: 1) - ) + describe "due_for_deletion/1" do + test "returns a reminder_sent row whose deletion_date has arrived" do + schedule = + insert(:team_deletion_schedule, + status: :reminder_sent, + deletion_date: @today + ) - assert TeamDeletionSchedules.due_for_reminder(@today) == [] - end - end + assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today) + assert id == schedule.id + end - describe "due_for_deletion/1" do - test "returns a reminder_sent row whose deletion_date has arrived" do - schedule = - insert(:team_deletion_schedule, - status: :reminder_sent, - deletion_date: @today - ) + test "returns a row whose deletion_date is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :reminder_sent, + deletion_date: Date.shift(@today, day: -3) + ) - assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today) - assert id == schedule.id - end + assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today) + assert id == schedule.id + end - test "returns a row whose deletion_date is overdue (missed run catch-up)" do - schedule = - insert(:team_deletion_schedule, - status: :reminder_sent, - deletion_date: Date.shift(@today, day: -3) - ) + test "does not return a row whose deletion_date is still in the future" do + insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) - assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today) - assert id == schedule.id - end + assert TeamDeletionSchedules.due_for_deletion(Date.shift(@today, day: -1)) == [] + end - test "does not return a row whose deletion_date is still in the future" do - insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) + test "does not return a row that hasn't had its reminder sent yet" do + insert(:team_deletion_schedule, status: :first_notice_sent, deletion_date: @today) - assert TeamDeletionSchedules.due_for_deletion(Date.shift(@today, day: -1)) == [] + assert TeamDeletionSchedules.due_for_deletion(@today) == [] + end end - test "does not return a row that hasn't had its reminder sent yet" do - insert(:team_deletion_schedule, status: :first_notice_sent, deletion_date: @today) + describe "due_for_unsnooze/1" do + test "returns a snoozed row whose snoozed_until has arrived" do + schedule = + insert(:team_deletion_schedule, status: :snoozed, snoozed_until: @today) - assert TeamDeletionSchedules.due_for_deletion(@today) == [] - end - end + assert [%{id: id}] = TeamDeletionSchedules.due_for_unsnooze(@today) + assert id == schedule.id + end - describe "due_for_unsnooze/1" do - test "returns a snoozed row whose snoozed_until has arrived" do - schedule = - insert(:team_deletion_schedule, status: :snoozed, snoozed_until: @today) + test "returns a row whose snoozed_until is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + snoozed_until: Date.shift(@today, day: -3) + ) - assert [%{id: id}] = TeamDeletionSchedules.due_for_unsnooze(@today) - assert id == schedule.id - end + assert [%{id: id}] = TeamDeletionSchedules.due_for_unsnooze(@today) + assert id == schedule.id + end - test "returns a row whose snoozed_until is overdue (missed run catch-up)" do - schedule = + test "does not return a row whose snoozed_until is still in the future" do insert(:team_deletion_schedule, status: :snoozed, - snoozed_until: Date.shift(@today, day: -3) + snoozed_until: Date.shift(@today, day: 1) ) - assert [%{id: id}] = TeamDeletionSchedules.due_for_unsnooze(@today) - assert id == schedule.id - end + assert TeamDeletionSchedules.due_for_unsnooze(@today) == [] + end - test "does not return a row whose snoozed_until is still in the future" do - insert(:team_deletion_schedule, status: :snoozed, snoozed_until: Date.shift(@today, day: 1)) + test "does not return a row that isn't snoozed" do + insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) - assert TeamDeletionSchedules.due_for_unsnooze(@today) == [] + assert TeamDeletionSchedules.due_for_unsnooze(@today) == [] + end end - test "does not return a row that isn't snoozed" do - insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) + describe "pending_steady_state_trials_by_team_id/1" do + test "returns a scheduled, non-backlog expired_trial schedule keyed by team_id" do + team = insert(:team) - assert TeamDeletionSchedules.due_for_unsnooze(@today) == [] - end - end + schedule = + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :scheduled, + is_backlog: false + ) + + team_id = team.id + + assert %{^team_id => result} = + TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) - describe "pending_steady_state_trials_by_team_id/1" do - test "returns a scheduled, non-backlog expired_trial schedule keyed by team_id" do - team = insert(:team) + assert result.id == schedule.id + end + + test "excludes a backlog trial schedule" do + team = insert(:team) - schedule = insert(:team_deletion_schedule, team: team, category: :expired_trial, status: :scheduled, - is_backlog: false + is_backlog: true ) - team_id = team.id - - assert %{^team_id => result} = - TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) - - assert result.id == schedule.id - end - - test "excludes a backlog trial schedule" do - team = insert(:team) - - insert(:team_deletion_schedule, - team: team, - category: :expired_trial, - status: :scheduled, - is_backlog: true - ) - - assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} - end + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} + end - test "excludes a churned_subscription schedule" do - team = insert(:team) + test "excludes a churned_subscription schedule" do + team = insert(:team) - insert(:team_deletion_schedule, - team: team, - category: :churned_subscription, - status: :scheduled, - is_backlog: false - ) + insert(:team_deletion_schedule, + team: team, + category: :churned_subscription, + status: :scheduled, + is_backlog: false + ) - assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} - end + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} + end - test "excludes a schedule whose first notice was already sent" do - team = insert(:team) + test "excludes a schedule whose first notice was already sent" do + team = insert(:team) - insert(:team_deletion_schedule, - team: team, - category: :expired_trial, - status: :first_notice_sent, - is_backlog: false - ) + insert(:team_deletion_schedule, + team: team, + category: :expired_trial, + status: :first_notice_sent, + is_backlog: false + ) - assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} - end + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([team.id]) == %{} + end - test "returns an empty map without querying for an empty list of team ids" do - assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([]) == %{} + test "returns an empty map without querying for an empty list of team ids" do + assert TeamDeletionSchedules.pending_steady_state_trials_by_team_id([]) == %{} + end end end end diff --git a/test/plausible/teams/deletion_schedule_test.exs b/test/plausible/teams/deletion_schedule_test.exs index aa69abbce449..f3beb1d72262 100644 --- a/test/plausible/teams/deletion_schedule_test.exs +++ b/test/plausible/teams/deletion_schedule_test.exs @@ -1,64 +1,68 @@ defmodule Plausible.Teams.DeletionScheduleTest do use ExUnit.Case, async: true - alias Plausible.Teams.DeletionSchedule + use Plausible - describe "deletion_offset_days/1" do - test "returns the trial offset for :expired_trial" do - assert DeletionSchedule.deletion_offset_days(:expired_trial) == 60 - end + on_ee do + alias Plausible.Teams.DeletionSchedule - test "returns the subscription offset for :churned_subscription" do - assert DeletionSchedule.deletion_offset_days(:churned_subscription) == 180 - end - end + describe "deletion_offset_days/1" do + test "returns the trial offset for :expired_trial" do + assert DeletionSchedule.deletion_offset_days(:expired_trial) == 60 + end - describe "deletion_date/2" do - test "adds the trial offset to the expiry date" do - assert DeletionSchedule.deletion_date(:expired_trial, ~D[2026-01-01]) == ~D[2026-03-02] + test "returns the subscription offset for :churned_subscription" do + assert DeletionSchedule.deletion_offset_days(:churned_subscription) == 180 + end end - test "adds the subscription offset to the expiry date" do - assert DeletionSchedule.deletion_date(:churned_subscription, ~D[2026-01-01]) == - ~D[2026-06-30] - end - end + describe "deletion_date/2" do + test "adds the trial offset to the expiry date" do + assert DeletionSchedule.deletion_date(:expired_trial, ~D[2026-01-01]) == ~D[2026-03-02] + end - describe "first_notice_due_date/1" do - test "is 30 days before the deletion date" do - assert DeletionSchedule.first_notice_due_date(~D[2026-03-02]) == ~D[2026-01-31] + test "adds the subscription offset to the expiry date" do + assert DeletionSchedule.deletion_date(:churned_subscription, ~D[2026-01-01]) == + ~D[2026-06-30] + end end - end - describe "reminder_due_date/1" do - test "is 5 days before the deletion date" do - assert DeletionSchedule.reminder_due_date(~D[2026-03-02]) == ~D[2026-02-25] + describe "first_notice_due_date/1" do + test "is 30 days before the deletion date" do + assert DeletionSchedule.first_notice_due_date(~D[2026-03-02]) == ~D[2026-01-31] + end end - end - describe "backlog_deletion_date/1" do - test "is anchored to when the first notice was actually sent, not a planned date" do - assert DeletionSchedule.backlog_deletion_date(~N[2026-08-20 10:00:00]) == ~D[2026-09-19] + describe "reminder_due_date/1" do + test "is 5 days before the deletion date" do + assert DeletionSchedule.reminder_due_date(~D[2026-03-02]) == ~D[2026-02-25] + end end - test "slides forward if the notice went out later than originally planned" do - on_time = DeletionSchedule.backlog_deletion_date(~N[2026-08-20 10:00:00]) - delayed = DeletionSchedule.backlog_deletion_date(~N[2026-08-25 10:00:00]) + describe "backlog_deletion_date/1" do + test "is anchored to when the first notice was actually sent, not a planned date" do + assert DeletionSchedule.backlog_deletion_date(~N[2026-08-20 10:00:00]) == ~D[2026-09-19] + end - assert Date.diff(delayed, on_time) == 5 - end + test "slides forward if the notice went out later than originally planned" do + on_time = DeletionSchedule.backlog_deletion_date(~N[2026-08-20 10:00:00]) + delayed = DeletionSchedule.backlog_deletion_date(~N[2026-08-25 10:00:00]) - test "reminder_due_date/1 applies uniformly on top of it (no separate backlog variant needed)" do - first_notice_sent_at = ~N[2026-08-20 10:00:00] - deletion_date = DeletionSchedule.backlog_deletion_date(first_notice_sent_at) + assert Date.diff(delayed, on_time) == 5 + end - assert DeletionSchedule.reminder_due_date(deletion_date) == ~D[2026-09-14] + test "reminder_due_date/1 applies uniformly on top of it (no separate backlog variant needed)" do + first_notice_sent_at = ~N[2026-08-20 10:00:00] + deletion_date = DeletionSchedule.backlog_deletion_date(first_notice_sent_at) + + assert DeletionSchedule.reminder_due_date(deletion_date) == ~D[2026-09-14] + end end - end - describe "notification_site_list_limit/0" do - test "returns the configured cap" do - assert DeletionSchedule.notification_site_list_limit() == 3 + describe "notification_site_list_limit/0" do + test "returns the configured cap" do + assert DeletionSchedule.notification_site_list_limit() == 3 + end end end end diff --git a/test/workers/execute_team_deletions_test.exs b/test/workers/execute_team_deletions_test.exs index ac6ab1d5fd18..fb4b22358cf2 100644 --- a/test/workers/execute_team_deletions_test.exs +++ b/test/workers/execute_team_deletions_test.exs @@ -1,166 +1,168 @@ defmodule Plausible.Workers.ExecuteTeamDeletionsTest do use Plausible.DataCase, async: true - require Plausible.Billing.Subscription.Status + on_ee do + require Plausible.Billing.Subscription.Status - alias Plausible.Billing.Subscription - alias Plausible.PendingStatsDeletion - alias Plausible.Workers.ExecuteTeamDeletions + alias Plausible.Billing.Subscription + alias Plausible.PendingStatsDeletion + alias Plausible.Workers.ExecuteTeamDeletions - @today ~D[2026-08-20] + @today ~D[2026-08-20] - test "deletes the team's site and marks the schedule completed, keeping the team intact" do - owner = new_user() - site = new_site(owner: owner) - team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() + test "deletes the team's site and marks the schedule completed, keeping the team intact" do + owner = new_user() + site = new_site(owner: owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() - schedule = - insert(:team_deletion_schedule, - team: team, - status: :reminder_sent, - deletion_date: @today - ) + schedule = + insert(:team_deletion_schedule, + team: team, + status: :reminder_sent, + deletion_date: @today + ) - assert :ok = ExecuteTeamDeletions.perform(nil, @today) + assert :ok = ExecuteTeamDeletions.perform(nil, @today) - refute Repo.reload(site) - assert Repo.reload(team) - assert Repo.reload!(schedule).status == :completed - end + refute Repo.reload(site) + assert Repo.reload(team) + assert Repo.reload!(schedule).status == :completed + end - test "deletes every site owned by the team" do - owner = new_user() - site_a = new_site(owner: owner) - team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() - site_b = new_site(team: team) + test "deletes every site owned by the team" do + owner = new_user() + site_a = new_site(owner: owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() + site_b = new_site(team: team) - insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today) + insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today) - assert :ok = ExecuteTeamDeletions.perform(nil, @today) + assert :ok = ExecuteTeamDeletions.perform(nil, @today) - refute Repo.reload(site_a) - refute Repo.reload(site_b) - assert Repo.reload(team) - end + refute Repo.reload(site_a) + refute Repo.reload(site_b) + assert Repo.reload(team) + end - test "does not touch the team's settings or subscription history" do - owner = new_user() - new_site(owner: owner) - team = team_of(owner) + test "does not touch the team's settings or subscription history" do + owner = new_user() + new_site(owner: owner) + team = team_of(owner) - subscription = - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(@today, day: -400) - ) - - insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today) + subscription = + insert(:subscription, + team: team, + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -400) + ) - assert :ok = ExecuteTeamDeletions.perform(nil, @today) - - reloaded_team = Repo.reload(team) - assert reloaded_team - assert reloaded_team.name == team.name - assert Repo.reload(subscription) - end + insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today) - test "passes the schedule's category through as the pending stats deletion reason (expired_trial)" do - owner = new_user() - site = new_site(owner: owner) - team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() - - insert(:team_deletion_schedule, - team: team, - category: :expired_trial, - status: :reminder_sent, - deletion_date: @today - ) - - assert :ok = ExecuteTeamDeletions.perform(nil, @today) - - assert Repo.get_by(PendingStatsDeletion, site_id: site.id).reason == :expired_trial - end + assert :ok = ExecuteTeamDeletions.perform(nil, @today) - test "passes the schedule's category through as the pending stats deletion reason (churned_subscription)" do - owner = new_user() - site = new_site(owner: owner) - team = team_of(owner) + reloaded_team = Repo.reload(team) + assert reloaded_team + assert reloaded_team.name == team.name + assert Repo.reload(subscription) + end - insert(:subscription, - team: team, - status: Subscription.Status.deleted(), - next_bill_date: Date.shift(@today, day: -400) - ) + test "passes the schedule's category through as the pending stats deletion reason (expired_trial)" do + owner = new_user() + site = new_site(owner: owner) + team = team_of(owner) |> Plausible.Teams.Team.end_trial() |> Repo.update!() - insert(:team_deletion_schedule, - team: team, - category: :churned_subscription, - status: :reminder_sent, - deletion_date: @today - ) - - assert :ok = ExecuteTeamDeletions.perform(nil, @today) - - assert Repo.get_by(PendingStatsDeletion, site_id: site.id).reason == :churned_subscription - end - - test "does not touch a row whose deletion_date hasn't arrived" do - owner = new_user() - site = new_site(owner: owner) - team = team_of(owner) - - schedule = insert(:team_deletion_schedule, team: team, + category: :expired_trial, status: :reminder_sent, - deletion_date: Date.shift(@today, day: 1) + deletion_date: @today ) - assert :ok = ExecuteTeamDeletions.perform(nil, @today) + assert :ok = ExecuteTeamDeletions.perform(nil, @today) - assert Repo.reload(team) - assert Repo.reload(site) - assert Repo.reload!(schedule).status == :reminder_sent - end + assert Repo.get_by(PendingStatsDeletion, site_id: site.id).reason == :expired_trial + end - test "does not touch a row that hasn't had its reminder sent yet" do - owner = new_user() - site = new_site(owner: owner) - team = team_of(owner) + test "passes the schedule's category through as the pending stats deletion reason (churned_subscription)" do + owner = new_user() + site = new_site(owner: owner) + team = team_of(owner) - schedule = - insert(:team_deletion_schedule, + insert(:subscription, team: team, - status: :first_notice_sent, - deletion_date: @today + status: Subscription.Status.deleted(), + next_bill_date: Date.shift(@today, day: -400) ) - assert :ok = ExecuteTeamDeletions.perform(nil, @today) - - assert Repo.reload(team) - assert Repo.reload(site) - assert Repo.reload!(schedule).status == :first_notice_sent - end - - test "cancels instead of deleting when the team has reactivated since the last scan" do - owner = new_user() - site = new_site(owner: owner) - team = team_of(owner) - - schedule = insert(:team_deletion_schedule, team: team, + category: :churned_subscription, status: :reminder_sent, deletion_date: @today ) - insert(:subscription, team: team, status: Subscription.Status.active()) - - assert :ok = ExecuteTeamDeletions.perform(nil, @today) - - assert Repo.reload(team) - assert Repo.reload(site) - assert Repo.reload!(schedule).status == :cancelled + assert :ok = ExecuteTeamDeletions.perform(nil, @today) + + assert Repo.get_by(PendingStatsDeletion, site_id: site.id).reason == :churned_subscription + end + + test "does not touch a row whose deletion_date hasn't arrived" do + owner = new_user() + site = new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :reminder_sent, + deletion_date: Date.shift(@today, day: 1) + ) + + assert :ok = ExecuteTeamDeletions.perform(nil, @today) + + assert Repo.reload(team) + assert Repo.reload(site) + assert Repo.reload!(schedule).status == :reminder_sent + end + + test "does not touch a row that hasn't had its reminder sent yet" do + owner = new_user() + site = new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :first_notice_sent, + deletion_date: @today + ) + + assert :ok = ExecuteTeamDeletions.perform(nil, @today) + + assert Repo.reload(team) + assert Repo.reload(site) + assert Repo.reload!(schedule).status == :first_notice_sent + end + + test "cancels instead of deleting when the team has reactivated since the last scan" do + owner = new_user() + site = new_site(owner: owner) + team = team_of(owner) + + schedule = + insert(:team_deletion_schedule, + team: team, + status: :reminder_sent, + deletion_date: @today + ) + + insert(:subscription, team: team, status: Subscription.Status.active()) + + assert :ok = ExecuteTeamDeletions.perform(nil, @today) + + assert Repo.reload(team) + assert Repo.reload(site) + assert Repo.reload!(schedule).status == :cancelled + end end end diff --git a/test/workers/unsnooze_team_deletions_test.exs b/test/workers/unsnooze_team_deletions_test.exs index 3a59bab38c83..845814794fcb 100644 --- a/test/workers/unsnooze_team_deletions_test.exs +++ b/test/workers/unsnooze_team_deletions_test.exs @@ -1,64 +1,66 @@ defmodule Plausible.Workers.UnsnoozeTeamDeletionsTest do use Plausible.DataCase, async: true - alias Plausible.Workers.UnsnoozeTeamDeletions + on_ee do + alias Plausible.Workers.UnsnoozeTeamDeletions - @today ~D[2026-08-20] + @today ~D[2026-08-20] - test "restarts the notice cycle for a schedule whose snooze has lapsed" do - schedule = - insert(:team_deletion_schedule, - status: :snoozed, - is_backlog: false, - snoozed_until: @today, - snooze_note: "customer asked for time", - first_notice_sent_at: ~N[2026-07-01 10:00:00], - reminder_sent_at: ~N[2026-07-20 10:00:00] - ) + test "restarts the notice cycle for a schedule whose snooze has lapsed" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + is_backlog: false, + snoozed_until: @today, + snooze_note: "customer asked for time", + first_notice_sent_at: ~N[2026-07-01 10:00:00], + reminder_sent_at: ~N[2026-07-20 10:00:00] + ) - assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) - updated = Repo.reload!(schedule) - assert updated.status == :scheduled - assert updated.is_backlog - assert updated.first_notice_due_date == @today - assert updated.first_notice_sent_at == nil - assert updated.reminder_sent_at == nil - assert updated.snoozed_until == nil - assert updated.snooze_note == nil - end + updated = Repo.reload!(schedule) + assert updated.status == :scheduled + assert updated.is_backlog + assert updated.first_notice_due_date == @today + assert updated.first_notice_sent_at == nil + assert updated.reminder_sent_at == nil + assert updated.snoozed_until == nil + assert updated.snooze_note == nil + end - test "restarts a schedule whose snooze is overdue (missed run catch-up)" do - schedule = - insert(:team_deletion_schedule, - status: :snoozed, - snoozed_until: Date.shift(@today, day: -3) - ) + test "restarts a schedule whose snooze is overdue (missed run catch-up)" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + snoozed_until: Date.shift(@today, day: -3) + ) - assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) - assert Repo.reload!(schedule).status == :scheduled - end + assert Repo.reload!(schedule).status == :scheduled + end - test "does not touch a row whose snooze hasn't lapsed yet" do - schedule = - insert(:team_deletion_schedule, - status: :snoozed, - snoozed_until: Date.shift(@today, day: 1) - ) + test "does not touch a row whose snooze hasn't lapsed yet" do + schedule = + insert(:team_deletion_schedule, + status: :snoozed, + snoozed_until: Date.shift(@today, day: 1) + ) - assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) - updated = Repo.reload!(schedule) - assert updated.status == :snoozed - assert updated.snoozed_until == Date.shift(@today, day: 1) - end + updated = Repo.reload!(schedule) + assert updated.status == :snoozed + assert updated.snoozed_until == Date.shift(@today, day: 1) + end - test "does not touch a row that isn't snoozed" do - schedule = insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) + test "does not touch a row that isn't snoozed" do + schedule = insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today) - assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) + assert :ok = UnsnoozeTeamDeletions.perform(nil, @today) - assert Repo.reload!(schedule).status == :reminder_sent + assert Repo.reload!(schedule).status == :reminder_sent + end end end