diff --git a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex index 3fd1866cec52..54823df50170 100644 --- a/extra/lib/plausible_web/live/customer_support/team/components/overview.ex +++ b/extra/lib/plausible_web/live/customer_support/team/components/overview.ex @@ -48,6 +48,8 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Overview do def handle_event("save-team", %{"team" => params}, socket) do changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params) + # TODO: if this prolongs trial_expiry_date (or otherwise makes the team + # eligible again) cancely any Plausible.TeamDeletionSchedule case Plausible.Repo.update(changeset) do {:ok, team} -> success("Team saved") diff --git a/lib/plausible/team_deletion_schedules.ex b/lib/plausible/team_deletion_schedules.ex index 6d33e9cf8181..bed3ad876ae1 100644 --- a/lib/plausible/team_deletion_schedules.ex +++ b/lib/plausible/team_deletion_schedules.ex @@ -69,21 +69,122 @@ defmodule Plausible.TeamDeletionSchedules do team = Teams.with_subscription(team) if Subscriptions.active?(team.subscription) do - {count, _} = - Repo.update_all( - from(sch in TeamDeletionSchedule, - where: sch.team_id == ^team.id, - where: sch.status in ^TeamDeletionSchedule.active_statuses() - ), - set: [status: :cancelled, updated_at: NaiveDateTime.utc_now(:second)] - ) - - count + cancel_active_schedule(team.id) else 0 end end + @type transition_result :: + {:ok, TeamDeletionSchedule.t()} | {:error, {:invalid_transition, atom(), atom()}} + + @transitions %{ + scheduled: [:first_notice_sent, :cancelled, :snoozed], + first_notice_sent: [:reminder_sent, :cancelled, :snoozed], + reminder_sent: [:completed, :cancelled, :snoozed], + snoozed: [:cancelled, :scheduled], + completed: [], + cancelled: [] + } + + @spec transitions() :: %{atom() => [atom()]} + def transitions, do: @transitions + + @spec mark_first_notice_sent(TeamDeletionSchedule.t(), NaiveDateTime.t()) :: transition_result + def mark_first_notice_sent(schedule, now \\ NaiveDateTime.utc_now(:second)) + + def mark_first_notice_sent(%TeamDeletionSchedule{is_backlog: true} = schedule, now) do + transition(schedule, :first_notice_sent, %{ + first_notice_sent_at: now, + deletion_date: DeletionSchedule.backlog_deletion_date(now) + }) + end + + def mark_first_notice_sent(%TeamDeletionSchedule{is_backlog: false} = schedule, now) do + transition(schedule, :first_notice_sent, %{first_notice_sent_at: now}) + end + + @spec mark_reminder_sent(TeamDeletionSchedule.t(), NaiveDateTime.t()) :: transition_result + def mark_reminder_sent(schedule, now \\ NaiveDateTime.utc_now(:second)) do + transition(schedule, :reminder_sent, %{reminder_sent_at: now}) + end + + @spec mark_completed(TeamDeletionSchedule.t()) :: transition_result + def mark_completed(schedule) do + transition(schedule, :completed) + end + + @spec cancel(TeamDeletionSchedule.t()) :: transition_result + def cancel(schedule) do + transition(schedule, :cancelled) + end + + @spec snooze(TeamDeletionSchedule.t(), Date.t(), String.t() | nil) :: transition_result + def snooze(schedule, until_date, note \\ nil) do + transition(schedule, :snoozed, %{snoozed_until: until_date, snooze_note: note}) + end + + @doc """ + Unsnoozes a schedule once its snooze has lapsed without the team + resubscribing. Restarts the notice cycle from scratch + """ + @spec unsnooze(TeamDeletionSchedule.t(), Date.t()) :: transition_result + def unsnooze(schedule, today \\ Date.utc_today()) do + transition(schedule, :scheduled, %{ + is_backlog: true, + first_notice_due_date: today, + first_notice_sent_at: nil, + reminder_sent_at: nil, + snoozed_until: nil, + snooze_note: nil + }) + end + + @valid_transitions for {from, tos} <- @transitions, to <- tos, do: {from, to} + + defp transition(schedule, to, extra_changes \\ %{}) + + defp transition(%TeamDeletionSchedule{status: from} = schedule, to, extra_changes) + when {from, to} in @valid_transitions do + updated = + schedule + |> Ecto.Changeset.change(Map.put(extra_changes, :status, to)) + |> Repo.update!() + + {:ok, updated} + end + + defp transition(%TeamDeletionSchedule{status: from}, to, _extra_changes) do + {:error, {:invalid_transition, from, to}} + end + + defp cancel_active_schedule(team_id) do + {:ok, count} = + Repo.transact(fn -> + case active_schedule_for(team_id) do + nil -> {:ok, 0} + schedule -> {:ok, cancel_count(schedule)} + end + end) + + count + end + + defp cancel_count(schedule) do + case cancel(schedule) do + {:ok, _} -> 1 + {:error, _} -> 0 + end + end + + defp active_schedule_for(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 dynamic(^expired_trial?(today) or ^churned_subscription?(today)) end diff --git a/test/plausible/team_deletion_schedules_test.exs b/test/plausible/team_deletion_schedules_test.exs index 36ae80938813..526d4fa65818 100644 --- a/test/plausible/team_deletion_schedules_test.exs +++ b/test/plausible/team_deletion_schedules_test.exs @@ -295,4 +295,182 @@ defmodule Plausible.TeamDeletionSchedulesTest do assert TeamDeletionSchedules.cancel_for_team(team) == 0 end end + + describe "transitions/0" do + test "matches the schema's known statuses" do + transitions = TeamDeletionSchedules.transitions() + + assert Map.keys(transitions) |> Enum.sort() == Enum.sort(TeamDeletionSchedule.statuses()) + + for {_from, targets} <- transitions, target <- targets do + assert target in TeamDeletionSchedule.statuses() + 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] + ) + + now = ~N[2026-08-20 10:00:00] + + assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, now) + assert updated.status == :first_notice_sent + assert updated.first_notice_sent_at == now + assert updated.deletion_date == ~D[2026-10-19] + end + + test "sends the first notice for a backlog schedule, anchoring deletion_date to now" do + schedule = + insert(:team_deletion_schedule, + status: :scheduled, + is_backlog: true, + deletion_date: ~D[2026-01-01] + ) + + now = ~N[2026-08-20 10:00:00] + + assert {:ok, updated} = TeamDeletionSchedules.mark_first_notice_sent(schedule, 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 + + test "rejects any status other than :scheduled" do + for status <- TeamDeletionSchedule.statuses() -- [:scheduled] do + schedule = insert(:team_deletion_schedule, status: status) + + assert TeamDeletionSchedules.mark_first_notice_sent(schedule, ~N[2026-08-20 10:00:00]) == + {:error, {:invalid_transition, status, :first_notice_sent}} + 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] + + assert {:ok, updated} = TeamDeletionSchedules.mark_reminder_sent(schedule, 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, ~N[2026-08-20 10:00:00]) == + {:error, {:invalid_transition, status, :reminder_sent}} + 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) + + assert {:ok, updated} = TeamDeletionSchedules.mark_completed(schedule) + assert updated.status == :completed + end + + test "rejects any status other than :reminder_sent" do + for status <- TeamDeletionSchedule.statuses() -- [:reminder_sent] do + schedule = insert(:team_deletion_schedule, status: status) + + assert TeamDeletionSchedules.mark_completed(schedule) == + {:error, {:invalid_transition, status, :completed}} + 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) + + assert {:ok, updated} = TeamDeletionSchedules.cancel(schedule) + assert updated.status == :cancelled + end + end + + test "rejects an already-terminal status" do + for status <- [:completed, :cancelled] do + schedule = insert(:team_deletion_schedule, status: status) + + assert TeamDeletionSchedules.cancel(schedule) == + {:error, {:invalid_transition, status, :cancelled}} + 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) + + assert {:ok, updated} = + TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20], "customer asked for time") + + assert updated.status == :snoozed + assert updated.snoozed_until == ~D[2026-09-20] + assert updated.snooze_note == "customer asked for time" + end + end + + test "defaults the note to nil" do + schedule = insert(:team_deletion_schedule, status: :scheduled) + + assert {:ok, updated} = TeamDeletionSchedules.snooze(schedule, ~D[2026-09-20]) + assert updated.snooze_note == nil + end + + 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 + + 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) + 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 "rejects any status other than :snoozed" do + for status <- TeamDeletionSchedule.statuses() -- [:snoozed] do + schedule = insert(:team_deletion_schedule, status: status) + + assert TeamDeletionSchedules.unsnooze(schedule, ~D[2026-08-20]) == + {:error, {:invalid_transition, status, :scheduled}} + end + end + end end