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/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
>
$
+
+ 🧨
+
+ <.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" />
<.input field={f[:accept_traffic_until]} type="date" label="Accept traffic Until" />
@@ -45,15 +57,98 @@ 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
+ ~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
+
+
+
+ <.form
+ :let={f}
+ :if={@schedule.status != :snoozed}
+ for={@snooze_form}
+ phx-submit="snooze-schedule"
+ phx-target={@myself}
+ class="mt-3 flex items-end gap-x-4"
+ >
+ <.input field={f[:snoozed_until]} type="date" label="Snooze until" />
+ <.input field={f[:snooze_note]} type="text" 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)
- # 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)}")
@@ -73,4 +168,37 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do
{:noreply, socket}
end
end
+
+ 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
+
+ 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_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/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex
index 9a1c180fcff9..0116c14eaa0c 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()
+ @spec cancel_for_team(Teams.Team.t()) :: :no_schedule | :ok
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
+ :no_schedule
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.
@@ -125,6 +139,30 @@ 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
+ """
+ @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`
@@ -248,30 +286,35 @@ 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
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/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/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/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_schedule_test.exs b/test/plausible/team_deletion_schedule_test.exs
index 1f61f1a3d53b..8903e2281ca5 100644
--- a/test/plausible/team_deletion_schedule_test.exs
+++ b/test/plausible/team_deletion_schedule_test.exs
@@ -1,203 +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)
+
+ 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
- test "rejects an invalid category" do
- team = insert(:team)
- today = Date.utc_today()
+ test "accepts both categories" do
+ team1 = insert(:team)
+ team2 = 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
- })
+ 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 "team_id foreign key" do
- test "deleting the team cascades to the schedule" do
- schedule = insert(:team_deletion_schedule)
+ 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)
- Repo.delete!(schedule.team)
+ changeset =
+ TeamDeletionSchedule.crm_changeset(schedule, %{
+ "snoozed_until" => Date.to_iso8601(until_date),
+ "snooze_note" => "customer asked for time"
+ })
- refute Repo.get(TeamDeletionSchedule, schedule.id)
- 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 "rejects a schedule for a non-existent team" do
- today = Date.utc_today()
+ test "does not require a note" do
+ schedule = insert(:team_deletion_schedule, status: :reminder_sent)
+ until_date = Date.shift(Date.utc_today(), day: 1)
- 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
- })
+ changeset =
+ TeamDeletionSchedule.crm_changeset(schedule, %{
+ "snoozed_until" => Date.to_iso8601(until_date)
+ })
+
+ assert changeset.valid?
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()
+ 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
- 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 "rejects a snoozed_until of today" do
+ schedule = insert(:team_deletion_schedule, status: :reminder_sent)
- assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base))
+ changeset =
+ TeamDeletionSchedule.crm_changeset(schedule, %{
+ "snoozed_until" => Date.to_iso8601(Date.utc_today())
+ })
- assert_raise Ecto.ConstraintError, fn ->
- Repo.insert!(struct(TeamDeletionSchedule, Map.put(base, :status, :first_notice_sent)))
+ refute changeset.valid?
+ assert {"must be in the future", _} = changeset.errors[:snoozed_until]
end
- end
- test "allows a new schedule once the previous one is cancelled" do
- team = insert(:team)
- today = Date.utc_today()
+ 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
- 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 "does not touch status" do
+ schedule = insert(:team_deletion_schedule, status: :reminder_sent)
+ until_date = Date.shift(Date.utc_today(), day: 1)
- assert {:ok, _} =
- struct(TeamDeletionSchedule, Map.put(base, :status, :cancelled))
- |> Repo.insert()
+ changeset =
+ TeamDeletionSchedule.crm_changeset(schedule, %{
+ "snoozed_until" => Date.to_iso8601(until_date)
+ })
- assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base))
+ refute Ecto.Changeset.get_change(changeset, :status)
+ end
end
- test "allows a new schedule once the previous one is completed" do
- team = insert(:team)
- today = Date.utc_today()
+ describe "team_id foreign key" do
+ test "deleting the team cascades to the schedule" do
+ schedule = insert(:team_deletion_schedule)
- 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)
- }
+ Repo.delete!(schedule.team)
- assert {:ok, _} =
- struct(TeamDeletionSchedule, Map.put(base, :status, :completed))
- |> Repo.insert()
+ refute Repo.get(TeamDeletionSchedule, schedule.id)
+ end
- assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base))
+ 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
- test "allows active schedules for different teams" do
- team1 = insert(:team)
- team2 = 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)
+ }
+
+ assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base))
+
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(struct(TeamDeletionSchedule, Map.put(base, :status, :first_notice_sent)))
+ 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)
+ }
+
+ assert {:ok, _} =
+ struct(TeamDeletionSchedule, Map.put(base, :status, :cancelled))
+ |> Repo.insert()
+
+ assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base))
+ end
+
+ test "allows a new schedule once the previous one is completed" do
+ team = insert(:team)
+ today = Date.utc_today()
+
+ 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)
+ }
- 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)
- })
+ struct(TeamDeletionSchedule, Map.put(base, :status, :completed))
+ |> Repo.insert()
+
+ assert {:ok, _} = Repo.insert(struct(TeamDeletionSchedule, base))
+ end
+
+ 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 9dde10804797..c60553fb015c 100644
--- a/test/plausible/team_deletion_schedules_test.exs
+++ b/test/plausible/team_deletion_schedules_test.exs
@@ -1,724 +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) == 1
- 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) == 1
- 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 at all" do
- team = insert(:team)
- 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) == 0
- 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 paused subscription" do
- team = insert(:team)
- schedule = insert(:team_deletion_schedule, team: team, status: :scheduled)
- insert(:subscription, team: team, status: Subscription.Status.paused())
+ 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) == 0
- assert Repo.reload(schedule).status == :scheduled
- end
+ assert TeamDeletionSchedules.cancel_for_team(team) == :ok
+ assert Repo.reload(schedule).status == :cancelled
+ 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 "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)
- insert(:subscription,
- team: team,
- status: Subscription.Status.deleted(),
- next_bill_date: Date.shift(Date.utc_today(), day: -1)
- )
+ assert TeamDeletionSchedules.cancel_for_team(team) == :ok
+ assert Repo.reload(schedule).status == :cancelled
+ end
- assert TeamDeletionSchedules.cancel_for_team(team) == 0
- assert Repo.reload(schedule).status == :scheduled
- 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 "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)
+ assert TeamDeletionSchedules.cancel_for_team(team) == :ok
+ assert Repo.reload(schedule).status == :cancelled
+ end
- insert(:subscription, team: team1, status: Subscription.Status.active())
- insert(:subscription, team: team2, status: Subscription.Status.active())
+ 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(team1) == 0
- assert TeamDeletionSchedules.cancel_for_team(team2) == 0
- assert Repo.reload(cancelled).status == :cancelled
- assert Repo.reload(completed).status == :completed
- end
+ assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule
+ assert Repo.reload(schedule).status == :scheduled
+ 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 "does not cancel for a deleted subscription past its paid period" do
+ team = insert(:team)
+ schedule = insert(:team_deletion_schedule, team: team, status: :scheduled)
- assert TeamDeletionSchedules.cancel_for_team(team) == 0
- end
- end
+ insert(:subscription,
+ team: team,
+ status: Subscription.Status.deleted(),
+ next_bill_date: Date.shift(Date.utc_today(), day: -1)
+ )
- describe "transitions/0" do
- test "matches the schema's known statuses" do
- transitions = TeamDeletionSchedules.transitions()
+ 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)
- assert Map.keys(transitions) |> Enum.sort() == Enum.sort(TeamDeletionSchedule.statuses())
+ insert(:subscription, team: team1, status: Subscription.Status.active())
+ insert(:subscription, team: team2, status: Subscription.Status.active())
- for {_from, targets} <- transitions, target <- targets do
- assert target in TeamDeletionSchedule.statuses()
+ 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())
+
+ assert TeamDeletionSchedules.cancel_for_team(team) == :no_schedule
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 "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)
- now = ~N[2026-08-20 10:00:00]
+ assert result = TeamDeletionSchedules.active_schedule_for_team(team)
+ assert result.id == schedule.id
+ 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 "returns nil for a team with no schedule at all" do
+ team = insert(:team)
- 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]
- )
+ assert TeamDeletionSchedules.active_schedule_for_team(team) == nil
+ end
- now = ~N[2026-08-20 10:00:00]
+ test "returns nil when the team's only schedule is terminal" do
+ team = insert(:team)
+ insert(:team_deletion_schedule, team: team, status: :cancelled)
- 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 TeamDeletionSchedules.active_schedule_for_team(team) == nil
+ end
- test "rejects any status other than :scheduled" do
- for status <- TeamDeletionSchedule.statuses() -- [:scheduled] do
- schedule = insert(:team_deletion_schedule, status: status)
+ 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.mark_first_notice_sent(schedule,
- now: ~N[2026-08-20 10:00:00]
- ) ==
- {:error, {:invalid_transition, status, :first_notice_sent}}
+ assert TeamDeletionSchedules.active_schedule_for_team(team) == nil
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 "transitions/0" do
+ test "matches the schema's known statuses" do
+ transitions = TeamDeletionSchedules.transitions()
- assert {:ok, updated} = TeamDeletionSchedules.mark_reminder_sent(schedule, now: now)
- assert updated.status == :reminder_sent
- assert updated.reminder_sent_at == now
+ assert Map.keys(transitions) |> Enum.sort() == Enum.sort(TeamDeletionSchedule.statuses())
+
+ for {_from, targets} <- transitions, target <- targets do
+ assert target in TeamDeletionSchedule.statuses()
+ end
+ end
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)
+ 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]
+ )
- assert TeamDeletionSchedules.mark_reminder_sent(schedule, now: ~N[2026-08-20 10:00:00]) ==
- {:error, {:invalid_transition, status, :reminder_sent}}
+ 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
- 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)
+ 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]
+ )
- assert {:ok, updated} = TeamDeletionSchedules.mark_completed(schedule)
- assert updated.status == :completed
- end
+ now = ~N[2026-08-20 10:00:00]
- test "rejects any status other than :reminder_sent" do
- for status <- TeamDeletionSchedule.statuses() -- [:reminder_sent] do
- schedule = insert(:team_deletion_schedule, status: status)
+ 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 TeamDeletionSchedules.mark_completed(schedule) ==
- {:error, {:invalid_transition, status, :completed}}
+ assert updated.deletion_date ==
+ Plausible.Teams.DeletionSchedule.backlog_deletion_date(now)
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)
+ test "rejects any status other than :scheduled" do
+ for status <- TeamDeletionSchedule.statuses() -- [:scheduled] do
+ schedule = insert(:team_deletion_schedule, status: status)
- assert {:ok, updated} = TeamDeletionSchedules.cancel(schedule)
- assert updated.status == :cancelled
+ 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
- test "rejects an already-terminal status" do
- for status <- [:completed, :cancelled] do
- schedule = insert(:team_deletion_schedule, status: status)
+ 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 TeamDeletionSchedules.cancel(schedule) ==
- {:error, {:invalid_transition, status, :cancelled}}
+ 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)
+
+ 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 "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 "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.snooze(schedule, ~D[2026-09-20],
- note: "customer asked for time"
- )
+ test "rejects any status other than :reminder_sent" do
+ for status <- TeamDeletionSchedule.statuses() -- [:reminder_sent] do
+ schedule = insert(:team_deletion_schedule, status: status)
- assert updated.status == :snoozed
- assert updated.snoozed_until == ~D[2026-09-20]
- assert updated.snooze_note == "customer asked for time"
+ assert TeamDeletionSchedules.mark_completed(schedule) ==
+ {:error, {:invalid_transition, status, :completed}}
+ end
end
end
- test "defaults the note to nil" do
- schedule = insert(:team_deletion_schedule, status: :scheduled)
+ 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.snooze(schedule, ~D[2026-09-20])
- assert updated.snooze_note == nil
- end
+ assert {:ok, updated} = TeamDeletionSchedules.cancel(schedule)
+ assert updated.status == :cancelled
+ end
+ 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-terminal status" do
+ for status <- [: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.cancel(schedule) ==
+ {:error, {:invalid_transition, status, :cancelled}}
+ 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 "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)
- today = ~D[2026-08-20]
+ assert {:ok, updated} =
+ TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20],
+ note: "customer asked for time"
+ )
- 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
+ assert updated.status == :snoozed
+ assert updated.snoozed_until == ~D[2026-09-20]
+ assert updated.snooze_note == "customer asked for time"
+ end
+ end
- test "rejects any status other than :snoozed" do
- for status <- TeamDeletionSchedule.statuses() -- [:snoozed] do
- schedule = insert(:team_deletion_schedule, status: status)
+ test "defaults the note to nil" do
+ schedule = insert(:team_deletion_schedule, status: :scheduled)
- assert TeamDeletionSchedules.unsnooze(schedule, today: ~D[2026-08-20]) ==
- {:error, {:invalid_transition, status, :scheduled}}
+ assert {:ok, updated} = TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20])
+ assert updated.snooze_note == nil
end
- end
- end
- describe "report_if_invalid? option" do
- setup do
- Plausible.Test.Support.Sentry.setup(self())
- :ok
+ 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}}
+ end
+ end
end
- test "does not report to Sentry by default" do
- schedule = insert(:team_deletion_schedule, status: :completed)
+ 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
- assert TeamDeletionSchedules.cancel(schedule) ==
- {:error, {:invalid_transition, :completed, :cancelled}}
+ test "rejects any status other than :snoozed" do
+ for status <- TeamDeletionSchedule.statuses() -- [:snoozed] do
+ schedule = insert(:team_deletion_schedule, status: status)
- assert [] = Sentry.Test.pop_sentry_reports()
+ assert TeamDeletionSchedules.unsnooze(schedule, today: ~D[2026-08-20]) ==
+ {:error, {:invalid_transition, status, :scheduled}}
+ end
+ end
end
- test "reports an invalid transition to Sentry when set" do
- schedule = insert(:team_deletion_schedule, status: :completed)
+ describe "report_if_invalid? option" do
+ setup do
+ Plausible.Test.Support.Sentry.setup(self())
+ :ok
+ end
- assert TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true) ==
- {:error, {:invalid_transition, :completed, :cancelled}}
+ test "does not report to Sentry by default" do
+ schedule = insert(:team_deletion_schedule, status: :completed)
- 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
+ assert TeamDeletionSchedules.cancel(schedule) ==
+ {:error, {:invalid_transition, :completed, :cancelled}}
- test "does not report a successful transition even when set" do
- schedule = insert(:team_deletion_schedule, status: :scheduled)
+ assert [] = Sentry.Test.pop_sentry_reports()
+ end
+
+ test "reports an invalid transition to Sentry when set" do
+ schedule = insert(:team_deletion_schedule, status: :completed)
- assert {:ok, _} = TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true)
+ 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
- assert [] = Sentry.Test.pop_sentry_reports()
+ test "does not report a successful transition even when set" do
+ schedule = insert(:team_deletion_schedule, status: :scheduled)
+
+ assert {:ok, _} = TeamDeletionSchedules.cancel(schedule, report_if_invalid?: true)
+
+ assert [] = Sentry.Test.pop_sentry_reports()
+ end
end
- end
- 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)
+ 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)
+
+ schedule =
+ insert(:team_deletion_schedule,
+ team: team_of(owner),
+ status: :scheduled,
+ 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
+
+ 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 [%{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
- 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
+ assert TeamDeletionSchedules.due_for_first_notice(@today) == []
+ 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)
+ 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(Date.shift(@today, day: -1)) == []
+ assert [%{id: id}] = TeamDeletionSchedules.due_for_first_notice(@today)
+ assert id == schedule.id
+ end
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
- )
+ 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 "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)
- )
+ 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_first_notice(@today) == []
- end
+ assert TeamDeletionSchedules.due_for_reminder(@today) == []
+ end
- test "returns a row whose snooze has already lapsed" do
- schedule =
+ test "does not return a scheduled (not yet first-notified) row" do
insert(:team_deletion_schedule,
status: :scheduled,
- first_notice_due_date: @today,
- snoozed_until: Date.shift(@today, day: -1)
+ deletion_date: Date.shift(@today, day: 5)
)
- 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 currently-snoozed row" do
insert(:team_deletion_schedule,
- status: :first_notice_sent,
- deletion_date: Date.shift(@today, day: 5)
+ status: :snoozed,
+ deletion_date: Date.shift(@today, day: 5),
+ snoozed_until: Date.shift(@today, day: 1)
)
- assert [%{id: id}] = TeamDeletionSchedules.due_for_reminder(@today)
- assert id == schedule.id
+ assert TeamDeletionSchedules.due_for_reminder(@today) == []
+ end
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)
- )
+ 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
+ assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today)
+ assert id == schedule.id
+ 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 "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 TeamDeletionSchedules.due_for_reminder(@today) == []
- end
+ assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today)
+ assert id == schedule.id
+ 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)
- )
+ 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 TeamDeletionSchedules.due_for_reminder(@today) == []
- end
- end
+ assert TeamDeletionSchedules.due_for_deletion(Date.shift(@today, day: -1)) == []
+ 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 "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 [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today)
- assert id == schedule.id
+ assert TeamDeletionSchedules.due_for_deletion(@today) == []
+ end
end
- test "returns a row whose deletion_date is overdue (missed run catch-up)" do
- schedule =
+ 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: :reminder_sent,
- deletion_date: Date.shift(@today, day: -3)
+ status: :snoozed,
+ snoozed_until: Date.shift(@today, day: 1)
)
- assert [%{id: id}] = TeamDeletionSchedules.due_for_deletion(@today)
- assert id == schedule.id
- end
+ assert TeamDeletionSchedules.due_for_unsnooze(@today) == []
+ 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 isn't snoozed" do
+ insert(:team_deletion_schedule, status: :reminder_sent, deletion_date: @today)
- assert TeamDeletionSchedules.due_for_deletion(Date.shift(@today, day: -1)) == []
+ assert TeamDeletionSchedules.due_for_unsnooze(@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 "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_deletion(@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/plausible_web/live/customer_support/teams_test.exs b/test/plausible_web/live/customer_support/teams_test.exs
index 214be799ff04..fdf4fcd13d35 100644
--- a/test/plausible_web/live/customer_support/teams_test.exs
+++ b/test/plausible_web/live/customer_support/teams_test.exs
@@ -154,11 +154,160 @@ 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))
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(%{
+ "team_deletion_schedule" => %{
+ "snoozed_until" => "2026-09-20",
+ "snooze_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 "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)
+
+ 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
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)
diff --git a/test/workers/execute_team_deletions_test.exs b/test/workers/execute_team_deletions_test.exs
index c220f25ca58d..fb4b22358cf2 100644
--- a/test/workers/execute_team_deletions_test.exs
+++ b/test/workers/execute_team_deletions_test.exs
@@ -1,160 +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)
+ 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
- )
-
- assert :ok = ExecuteTeamDeletions.perform(nil, @today)
-
- 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)
- site_b = new_site(team: team)
-
- insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today)
-
- assert :ok = ExecuteTeamDeletions.perform(nil, @today)
-
- 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)
-
- subscription =
- insert(:subscription,
- team: team,
- status: Subscription.Status.deleted(),
- next_bill_date: Date.shift(@today, day: -400)
- )
+ schedule =
+ 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)
+ assert Repo.reload(team)
+ assert Repo.reload!(schedule).status == :completed
+ end
- reloaded_team = Repo.reload(team)
- assert reloaded_team
- assert reloaded_team.name == team.name
- assert Repo.reload(subscription)
- 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 "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)
+ insert(:team_deletion_schedule, team: team, status: :reminder_sent, deletion_date: @today)
- insert(:team_deletion_schedule,
- team: team,
- category: :expired_trial,
- 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
- assert Repo.get_by(PendingStatsDeletion, site_id: site.id).reason == :expired_trial
- 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 "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)
+ subscription =
+ 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,
- 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)
- assert Repo.get_by(PendingStatsDeletion, site_id: site.id).reason == :churned_subscription
- end
+ reloaded_team = Repo.reload(team)
+ assert reloaded_team
+ assert reloaded_team.name == team.name
+ assert Repo.reload(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)
+ 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!()
- 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/send_deletion_notifications_test.exs b/test/workers/send_deletion_notifications_test.exs
index 3851ed672a88..45a929811ed5 100644
--- a/test/workers/send_deletion_notifications_test.exs
+++ b/test/workers/send_deletion_notifications_test.exs
@@ -1,210 +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)
- schedule =
- insert(:team_deletion_schedule,
+ insert(:team_membership, team: team, user: build(:user), role: :billing)
+
+ 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)
+ 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)
- insert(:subscription, team: team, status: Subscription.Status.active())
+ 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 == :cancelled
+ end
+ end
- refute_email_delivered_with(
- subject: "Your Plausible dashboards and stats will be deleted in 30 days"
- )
+ 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!()
- assert Repo.reload!(schedule).status == :cancelled
- end
- end
+ schedule =
+ insert(:team_deletion_schedule,
+ team: team,
+ status: :first_notice_sent,
+ deletion_date: Date.shift(@today, day: 3)
+ )
- describe "reminders" do
- test "sends the reminder email and advances status" 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: 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
diff --git a/test/workers/unsnooze_team_deletions_test.exs b/test/workers/unsnooze_team_deletions_test.exs
new file mode 100644
index 000000000000..845814794fcb
--- /dev/null
+++ b/test/workers/unsnooze_team_deletions_test.exs
@@ -0,0 +1,66 @@
+defmodule Plausible.Workers.UnsnoozeTeamDeletionsTest do
+ use Plausible.DataCase, async: true
+
+ on_ee do
+ 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
+end