Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
121 changes: 111 additions & 10 deletions lib/plausible/team_deletion_schedules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 178 additions & 0 deletions test/plausible/team_deletion_schedules_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading