From e7867985ff3f9d81b72773b657f93ff96e933ac6 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Mon, 29 Jun 2026 15:46:27 +0300 Subject: [PATCH 1/2] Implement trial prospects worker --- config/runtime.exs | 7 +- .../customer_support/trial_prospect.ex | 54 +++ .../customer_support/trial_prospects.ex | 131 +++++++ .../workers/score_trial_prospects.ex | 91 +++++ lib/plausible/stats/clickhouse.ex | 72 +++- .../20260617120000_create_trial_prospects.exs | 24 ++ priv/trial_prospect_pricing.json | 12 + test/workers/score_trial_prospects_test.exs | 340 ++++++++++++++++++ 8 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 extra/lib/plausible/customer_support/trial_prospect.ex create mode 100644 extra/lib/plausible/customer_support/trial_prospects.ex create mode 100644 extra/lib/plausible/workers/score_trial_prospects.ex create mode 100644 priv/repo/migrations/20260617120000_create_trial_prospects.exs create mode 100644 priv/trial_prospect_pricing.json create mode 100644 test/workers/score_trial_prospects_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index 3d3194df891d..e87edf3518f9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -846,7 +846,9 @@ cloud_cron = [ # First sunday of the month, 4:00 UTC {"0 4 1-7 * SUN", Plausible.Workers.ClickhouseCleanSites}, # Daily at 4:00 UTC - {"0 4 * * *", Plausible.Workers.SetLegacyTimeOnPageCutoff} + {"0 4 * * *", Plausible.Workers.SetLegacyTimeOnPageCutoff}, + # Daily at 2:00 UTC + {"0 2 * * *", Plausible.Workers.ScoreTrialProspects} ] crontab = if(is_selfhost, do: base_cron, else: base_cron ++ cloud_cron) @@ -876,7 +878,8 @@ cloud_queues = [ lock_sites: 1, legacy_time_on_page_cutoff: 1, purge_cdn_cache: 1, - sso_domain_ownership_verification: 32 + sso_domain_ownership_verification: 32, + score_trial_prospects: 1 ] queues = if(is_selfhost, do: base_queues, else: base_queues ++ cloud_queues) diff --git a/extra/lib/plausible/customer_support/trial_prospect.ex b/extra/lib/plausible/customer_support/trial_prospect.ex new file mode 100644 index 000000000000..aba54ee8b1f5 --- /dev/null +++ b/extra/lib/plausible/customer_support/trial_prospect.ex @@ -0,0 +1,54 @@ +defmodule Plausible.CustomerSupport.TrialProspect do + @moduledoc """ + Cached revenue-potential scoring for a team on (or just off) a trial. + Rows are (re)computed daily by `Plausible.Workers.ScoreTrialProspects` and + read by the customer support UI. + """ + use Ecto.Schema + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @fields [ + :team_id, + :estimated_monthly, + :observed_days, + :first_data_day, + :kind, + :forced_by, + :pageview_limit, + :over_top_tier, + :estimated_mrr, + :computed_at + ] + + schema "trial_prospects" do + belongs_to :team, Plausible.Teams.Team + + field :estimated_monthly, :integer + field :observed_days, :integer + field :first_data_day, :date + field :kind, Ecto.Enum, values: [:starter, :growth, :business] + field :forced_by, {:array, :string}, default: [] + field :pageview_limit, :integer + field :over_top_tier, :boolean, default: false + field :estimated_mrr, :integer + field :computed_at, :utc_datetime + + timestamps() + end + + def changeset(prospect, attrs) do + prospect + |> cast(attrs, @fields) + |> validate_required([ + :team_id, + :estimated_monthly, + :observed_days, + :first_data_day, + :kind, + :computed_at + ]) + |> unique_constraint(:team_id) + end +end diff --git a/extra/lib/plausible/customer_support/trial_prospects.ex b/extra/lib/plausible/customer_support/trial_prospects.ex new file mode 100644 index 000000000000..d5e079079a59 --- /dev/null +++ b/extra/lib/plausible/customer_support/trial_prospects.ex @@ -0,0 +1,131 @@ +defmodule Plausible.CustomerSupport.TrialProspects do + @moduledoc """ + Pure scoring logic for ranking trial teams by revenue potential. Turns a + team's partial traffic sample and premium-feature usage into an estimated MRR. + """ + + # Static reference pricing (EUR/mo) + pageview ladder. Hand-maintained; keep in sync when public pricing changes. + @pricing_path Application.app_dir(:plausible, ["priv", "trial_prospect_pricing.json"]) + @external_resource @pricing_path + @tiers @pricing_path + |> File.read!() + |> Jason.decode!() + |> Map.fetch!("tiers") + |> Enum.map(fn tier -> + %{ + limit: tier["monthly_pageview_limit"], + starter: tier["starter"], + growth: tier["growth"], + business: tier["business"] + } + end) + |> Enum.sort_by(& &1.limit) + @ladder Enum.map(@tiers, & &1.limit) + + @business_features [ + :props, + :funnels, + :revenue_goals, + :stats_api + ] + @growth_features [:shared_links, :site_segments] + + @starter_site_limit 1 + @growth_site_limit 3 + @starter_member_limit 0 + @growth_member_limit 3 + + @kind_rank %{starter: 0, growth: 1, business: 2} + + @doc """ + Combines feature usage, site/member counts + a monthly estimate into the + persisted scoring fields: `kind`, `forced_by`, `pageview_limit`, + `over_top_tier`, `estimated_mrr`. + """ + @spec score(non_neg_integer(), [module()], non_neg_integer(), non_neg_integer()) :: %{ + kind: :starter | :growth | :business, + forced_by: [String.t()], + pageview_limit: pos_integer() | nil, + over_top_tier: boolean(), + estimated_mrr: non_neg_integer() | nil + } + def score(estimated_monthly, feature_modules, site_count, member_count) do + {kind, forced_by} = plan_kind(feature_modules, site_count, member_count) + {limit, over_top_tier} = pageview_rung(estimated_monthly) + + %{ + kind: kind, + forced_by: forced_by, + pageview_limit: limit, + over_top_tier: over_top_tier, + estimated_mrr: estimated_mrr(kind, limit, over_top_tier) + } + end + + @spec plan_kind([module()], non_neg_integer(), non_neg_integer()) :: + {:starter | :growth | :business, [String.t()]} + defp plan_kind(feature_modules, site_count, member_count) do + used = Enum.map(feature_modules, & &1.name()) + + feature_k = feature_kind(used) + site_k = site_count_kind(site_count) + member_k = member_count_kind(member_count) + + kind = feature_k |> higher_kind(site_k) |> higher_kind(member_k) + + {kind, forced_by(used, kind, site_k, member_k)} + end + + @spec pageview_rung(non_neg_integer()) :: {pos_integer() | nil, boolean()} + defp pageview_rung(estimated_monthly) do + case Enum.find(@ladder, &(&1 >= estimated_monthly)) do + nil -> {nil, true} + limit -> {limit, false} + end + end + + @spec estimated_mrr(:starter | :growth | :business, pos_integer() | nil, boolean()) :: + non_neg_integer() | nil + defp estimated_mrr(_kind, _limit, true), do: nil + + defp estimated_mrr(kind, limit, false) do + tier = Enum.find(@tiers, &(&1.limit == limit)) + tier && Map.fetch!(tier, kind) + end + + defp feature_kind(used) do + cond do + forcing(used, @business_features) != [] -> :business + forcing(used, @growth_features) != [] -> :growth + true -> :starter + end + end + + defp site_count_kind(n) when n <= @starter_site_limit, do: :starter + defp site_count_kind(n) when n <= @growth_site_limit, do: :growth + defp site_count_kind(_), do: :business + + defp member_count_kind(n) when n <= @starter_member_limit, do: :starter + defp member_count_kind(n) when n <= @growth_member_limit, do: :growth + defp member_count_kind(_), do: :business + + defp higher_kind(a, b), do: if(@kind_rank[a] >= @kind_rank[b], do: a, else: b) + + defp forced_by(_used, :starter, _site_k, _member_k), do: [] + + defp forced_by(used, kind, site_k, member_k) do + forcing_for(used, kind) ++ + List.wrap(if(site_k == kind, do: "site_limit")) ++ + List.wrap(if(member_k == kind, do: "team_member_limit")) + end + + defp forcing_for(used, :business), do: forcing(used, @business_features) + defp forcing_for(used, :growth), do: forcing(used, @growth_features) + + defp forcing(used, tier_features) do + used + |> Enum.filter(&(&1 in tier_features)) + |> Enum.map(&Atom.to_string/1) + |> Enum.sort() + end +end diff --git a/extra/lib/plausible/workers/score_trial_prospects.ex b/extra/lib/plausible/workers/score_trial_prospects.ex new file mode 100644 index 000000000000..b016aa917e04 --- /dev/null +++ b/extra/lib/plausible/workers/score_trial_prospects.ex @@ -0,0 +1,91 @@ +defmodule Plausible.Workers.ScoreTrialProspects do + @moduledoc """ + Daily worker that scores the revenue potential of every team on (or just off) + a trial and caches the result in the `trial_prospects` table. + """ + use Plausible.Repo + + use Oban.Worker, + queue: :score_trial_prospects, + max_attempts: 1 + + alias Plausible.Teams + alias Plausible.CustomerSupport.{TrialProspect, TrialProspects} + + @max_expired_days 30 + + @impl Oban.Worker + def perform(_job) do + rows = + Date.utc_today() + |> trial_population() + |> Enum.flat_map(&score_team/1) + + persist(rows) + :ok + end + + defp trial_population(today) do + cutoff = Date.add(today, -@max_expired_days) + + Repo.all( + from t in Teams.Team, + left_join: s in assoc(t, :subscription), + where: not is_nil(t.trial_expiry_date), + where: is_nil(s.id), + where: t.trial_expiry_date >= ^cutoff + ) + end + + defp score_team(team) do + site_ids = Teams.owned_sites_ids(team) + + traffic = Plausible.Stats.Clickhouse.trial_traffic(site_ids) + + case traffic do + %{events_in_window: 0} -> [] + traffic -> [build_row(team, traffic, site_ids)] + end + end + + defp build_row(team, traffic, site_ids) do + score = + TrialProspects.score( + traffic.estimated_monthly, + Teams.Billing.features_usage(team, site_ids), + length(site_ids), + Teams.Billing.team_member_usage(team) + ) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + naive_now = DateTime.to_naive(now) + + %{ + team_id: team.id, + estimated_monthly: traffic.estimated_monthly, + observed_days: traffic.observed_days, + first_data_day: traffic.first_data_day, + kind: score.kind, + forced_by: score.forced_by, + pageview_limit: score.pageview_limit, + over_top_tier: score.over_top_tier, + estimated_mrr: score.estimated_mrr, + computed_at: now, + inserted_at: naive_now, + updated_at: naive_now + } + end + + defp persist(rows) do + scored_team_ids = Enum.map(rows, & &1.team_id) + + Repo.transaction(fn -> + Repo.insert_all(TrialProspect, rows, + on_conflict: {:replace_all_except, [:id, :team_id, :inserted_at]}, + conflict_target: :team_id + ) + + Repo.delete_all(from p in TrialProspect, where: p.team_id not in ^scored_team_ids) + end) + end +end diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex index 66f31cdcb052..06b54f968344 100644 --- a/lib/plausible/stats/clickhouse.ex +++ b/lib/plausible/stats/clickhouse.ex @@ -4,7 +4,7 @@ defmodule Plausible.Stats.Clickhouse do use Plausible.ClickhouseRepo use Plausible.Stats.SQL.Fragments - import Ecto.Query, only: [from: 2] + import Ecto.Query, only: [from: 2, subquery: 1] alias Plausible.Timezones alias Plausible.Stats @@ -76,6 +76,76 @@ defmodule Plausible.Stats.Clickhouse do def usage_breakdown([], _date_range), do: {0, 0} + @doc """ + Estimates a team's monthly traffic from a partial sample, computed entirely in + ClickHouse with one light query scoped to the team's own sites. Used by + `Plausible.Workers.ScoreTrialProspects`. + + Billable events = pageviews + custom events (everything except engagement). + The window is the trailing `@window_days` complete days (up to yesterday). + Within it, `min(toDate(timestamp))` is the team's `first_data_day`, so the + elapsed complete days since then give `observed_days` (clamped to + `[1, @window_days]`), and the monthly figure is + `round(events_in_window / observed_days * @days_in_month)` — all in the DB. + + Returns `%{first_data_day, events_in_window, observed_days, estimated_monthly}`. + `events_in_window == 0` means the team had no complete day of traffic. + """ + # trailing complete days sampled to estimate the monthly run rate + @window_days 30 + @days_in_month 30 + @spec trial_traffic([pos_integer()]) :: %{ + first_data_day: Date.t() | nil, + events_in_window: non_neg_integer(), + observed_days: non_neg_integer(), + estimated_monthly: non_neg_integer() + } + def trial_traffic([]) do + %{first_data_day: nil, events_in_window: 0, observed_days: 0, estimated_monthly: 0} + end + + def trial_traffic(site_ids) do + last_complete_day = Date.add(Date.utc_today(), -1) + first_day = Date.add(last_complete_day, -(@window_days - 1)) + + aggregates = + from(e in "events_v2", + where: e.site_id in ^site_ids, + where: e.name != "engagement", + where: fragment("toDate(?)", e.timestamp) >= ^first_day, + where: fragment("toDate(?)", e.timestamp) <= ^last_complete_day, + select: %{ + first_data_day: fragment("min(toDate(?))", e.timestamp), + events_in_window: fragment("count(*)"), + # elapsed complete days since first traffic, clamped to [1, @window_days] + observed_days: + fragment( + "least(greatest(dateDiff('day', min(toDate(?)), ?) + 1, 1), ?)", + e.timestamp, + ^last_complete_day, + ^@window_days + ) + } + ) + + ClickhouseRepo.one( + from(t in subquery(aggregates), + select: %{ + first_data_day: t.first_data_day, + events_in_window: t.events_in_window, + observed_days: t.observed_days, + estimated_monthly: + fragment( + "toUInt64(round(? / ? * ?))", + t.events_in_window, + t.observed_days, + ^@days_in_month + ) + } + ) + ) + end + def per_site_usage_breakdown( site_ids, date_range, diff --git a/priv/repo/migrations/20260617120000_create_trial_prospects.exs b/priv/repo/migrations/20260617120000_create_trial_prospects.exs new file mode 100644 index 000000000000..cd0873d4776b --- /dev/null +++ b/priv/repo/migrations/20260617120000_create_trial_prospects.exs @@ -0,0 +1,24 @@ +defmodule Plausible.Repo.Migrations.CreateTrialProspects do + use Ecto.Migration + + def change do + create table(:trial_prospects) do + add :team_id, references(:teams, on_delete: :delete_all), null: false + add :estimated_monthly, :integer, null: false + add :observed_days, :integer, null: false + add :first_data_day, :date, null: false + add :kind, :string, null: false + + add :forced_by, {:array, :string}, null: false, default: [] + add :pageview_limit, :integer + add :over_top_tier, :boolean, null: false, default: false + add :estimated_mrr, :integer + add :computed_at, :utc_datetime, null: false + + timestamps() + end + + create unique_index(:trial_prospects, [:team_id]) + create index(:trial_prospects, [:estimated_mrr]) + end +end diff --git a/priv/trial_prospect_pricing.json b/priv/trial_prospect_pricing.json new file mode 100644 index 000000000000..2fc8a9a64e51 --- /dev/null +++ b/priv/trial_prospect_pricing.json @@ -0,0 +1,12 @@ +{ + "tiers": [ + { "monthly_pageview_limit": 10000, "starter": 9, "growth": 14, "business": 19 }, + { "monthly_pageview_limit": 100000, "starter": 19, "growth": 29, "business": 39 }, + { "monthly_pageview_limit": 200000, "starter": 29, "growth": 44, "business": 59 }, + { "monthly_pageview_limit": 500000, "starter": 49, "growth": 74, "business": 99 }, + { "monthly_pageview_limit": 1000000, "starter": 69, "growth": 104, "business": 139 }, + { "monthly_pageview_limit": 2000000, "starter": 89, "growth": 134, "business": 179 }, + { "monthly_pageview_limit": 5000000, "starter": 129, "growth": 194, "business": 259 }, + { "monthly_pageview_limit": 10000000, "starter": 169, "growth": 254, "business": 339 } + ] +} diff --git a/test/workers/score_trial_prospects_test.exs b/test/workers/score_trial_prospects_test.exs new file mode 100644 index 000000000000..0c1636b45789 --- /dev/null +++ b/test/workers/score_trial_prospects_test.exs @@ -0,0 +1,340 @@ +defmodule Plausible.Workers.ScoreTrialProspectsTest do + use Plausible.DataCase + @moduletag :ee_only + + on_ee do + use Oban.Testing, repo: Plausible.Repo + + alias Plausible.Workers.ScoreTrialProspects + alias Plausible.CustomerSupport.{TrialProspect, TrialProspects} + + describe "TrialProspects scoring (pure)" do + # No feature/site/member gate (`[], 1, 0`) keeps kind :starter so these + # assertions isolate the pageview ladder in `score`. + test "score maps the monthly estimate to the smallest pageview rung >= estimate" do + assert %{pageview_limit: 10_000, over_top_tier: false} = + TrialProspects.score(6_000, [], 1, 0) + + assert %{pageview_limit: 100_000, over_top_tier: false} = + TrialProspects.score(80_000, [], 1, 0) + + assert %{pageview_limit: 500_000, over_top_tier: false} = + TrialProspects.score(350_000, [], 1, 0) + + assert %{pageview_limit: 10_000_000, over_top_tier: false} = + TrialProspects.score(10_000_000, [], 1, 0) + + assert %{pageview_limit: nil, over_top_tier: true} = + TrialProspects.score(14_000_000, [], 1, 0) + end + + test "score escalates plan kind to the highest tier a used feature forces" do + assert %{kind: :starter, forced_by: []} = TrialProspects.score(6_000, [], 1, 0) + + assert %{kind: :starter, forced_by: []} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.Goals], 1, 0) + + assert %{kind: :growth, forced_by: ["shared_links"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.SharedLinks], 1, 0) + + assert %{kind: :business, forced_by: ["funnels", "props"]} = + TrialProspects.score( + 6_000, + [ + Plausible.Billing.Feature.Funnels, + Plausible.Billing.Feature.Props, + Plausible.Billing.Feature.SharedLinks + ], + 1, + 0 + ) + end + + test "score escalates plan kind on site count (plans_v5 site_limit)" do + # starter allows 1 site, growth 3, business 10 + assert %{kind: :starter, forced_by: []} = TrialProspects.score(6_000, [], 1, 0) + assert %{kind: :growth, forced_by: ["site_limit"]} = TrialProspects.score(6_000, [], 2, 0) + assert %{kind: :growth, forced_by: ["site_limit"]} = TrialProspects.score(6_000, [], 3, 0) + + assert %{kind: :business, forced_by: ["site_limit"]} = + TrialProspects.score(6_000, [], 4, 0) + + assert %{kind: :business, forced_by: ["site_limit"]} = + TrialProspects.score(6_000, [], 25, 0) + end + + test "score escalates plan kind on team member count (plans_v5 team_member_limit)" do + # starter is solo (0 extra members), growth allows 3, business 10 + assert %{kind: :starter, forced_by: []} = TrialProspects.score(6_000, [], 1, 0) + + assert %{kind: :growth, forced_by: ["team_member_limit"]} = + TrialProspects.score(6_000, [], 1, 1) + + assert %{kind: :growth, forced_by: ["team_member_limit"]} = + TrialProspects.score(6_000, [], 1, 3) + + assert %{kind: :business, forced_by: ["team_member_limit"]} = + TrialProspects.score(6_000, [], 1, 4) + end + + test "score takes the highest tier across features, sites and members" do + # a growth feature but a business-sized site count -> business; no business + # feature forced it, so only the site limit is listed + assert %{kind: :business, forced_by: ["site_limit"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.SharedLinks], 5, 0) + + # a business feature with tiny site/member counts still wins on features + assert %{kind: :business, forced_by: ["funnels"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.Funnels], 1, 0) + + # both a business feature and the site count force business -> both listed + assert %{kind: :business, forced_by: ["funnels", "site_limit"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.Funnels], 4, 0) + end + + test "score prices the estimate against the plan kind, nil over the top tier" do + # starter @ 10k rung + assert %{kind: :starter, estimated_mrr: 9} = TrialProspects.score(6_000, [], 1, 0) + + # growth @ 2M rung (estimate lands between the 1M and 2M rungs) + assert %{kind: :growth, estimated_mrr: 134} = + TrialProspects.score(1_500_000, [Plausible.Billing.Feature.SharedLinks], 1, 0) + + # business @ 10M rung + assert %{kind: :business, estimated_mrr: 339} = + TrialProspects.score(8_000_000, [Plausible.Billing.Feature.Funnels], 1, 0) + + # over the top tier -> no price + assert %{over_top_tier: true, estimated_mrr: nil} = + TrialProspects.score(14_000_000, [Plausible.Billing.Feature.Funnels], 1, 0) + end + + test "score combines feature usage and estimate" do + assert %{ + kind: :business, + forced_by: ["funnels"], + pageview_limit: 500_000, + over_top_tier: false, + estimated_mrr: 99 + } = TrialProspects.score(350_000, [Plausible.Billing.Feature.Funnels], 1, 0) + end + end + + describe "worker" do + test "scores a trial team with traffic" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.observed_days == 10 + assert prospect.estimated_monthly == 30 + assert prospect.first_data_day == Date.add(Date.utc_today(), -10) + assert prospect.kind == :starter + assert prospect.forced_by == [] + assert prospect.pageview_limit == 10_000 + assert prospect.over_top_tier == false + assert prospect.estimated_mrr == 9 + end + + test "estimate spans from earliest traffic day across multiple days" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # earliest traffic 10 days ago, more 3 days ago -> observed over 10 days + populate_stats( + site, + pageviews_on(Date.add(Date.utc_today(), -10), 4) ++ + pageviews_on(Date.add(Date.utc_today(), -3), 6) + ) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.first_data_day == Date.add(Date.utc_today(), -10) + assert prospect.observed_days == 10 + # 10 events / 10 days * 30.4 + assert prospect.estimated_monthly == 30 + end + + test "premium feature usage forces a higher plan kind and price" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user, allowed_event_props: ["author"]) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.kind == :business + assert prospect.forced_by == ["props"] + assert prospect.estimated_mrr == 19 + end + + test "site count alone forces a higher plan kind" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + # 4 owned sites, no premium features -> over growth's 3-site limit -> business + site = new_site(owner: user) + for _ <- 1..3, do: new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.kind == :business + assert prospect.forced_by == ["site_limit"] + assert prospect.estimated_mrr == 19 + end + + test "team member count alone forces a higher plan kind" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # one extra member -> over starter's solo (0-member) limit -> growth + add_member(team_of(user), role: :viewer) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.kind == :growth + assert prospect.forced_by == ["team_member_limit"] + assert prospect.estimated_mrr == 14 + end + + test "does not score a team with no complete day of traffic" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # only today (partial, excluded) + populate_stats(site, pageviews_on(Date.utc_today(), 5)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert Repo.get_by(TrialProspect, team_id: team_of(user).id) == nil + end + + test "does not score teams with a subscription" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + subscribe_to_growth_plan(user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert Repo.get_by(TrialProspect, team_id: team_of(user).id) == nil + end + + test "removes stale rows for teams no longer in the population" do + # eligible team + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + # stale: a trial that expired beyond the 60-day window, with a leftover row + old_user = new_user(trial_expiry_date: Date.add(Date.utc_today(), -90)) + old_team = team_of(old_user) + + %TrialProspect{} + |> TrialProspect.changeset(%{ + team_id: old_team.id, + estimated_monthly: 100, + observed_days: 5, + first_data_day: Date.utc_today(), + kind: :starter, + computed_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert Repo.get_by(TrialProspect, team_id: old_team.id) == nil + assert Repo.get_by(TrialProspect, team_id: team_of(user).id) + end + + test "upserts on re-run rather than duplicating" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert [_only_one] = + Repo.all(from p in TrialProspect, where: p.team_id == ^team_of(user).id) + end + + test "scores a recently expired trial that still has in-window traffic" do + # trial ended 2 days ago -> within the 60-day population window (spec §2) + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), -2)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -5), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.observed_days == 5 + # 10 events / 5 days * 30 -> 60 + assert prospect.estimated_monthly == 60 + assert prospect.kind == :starter + assert prospect.estimated_mrr == 9 + end + + test "counts pageviews and custom events but excludes engagement" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # single complete day -> observed_days = 1 + t = NaiveDateTime.new!(Date.add(Date.utc_today(), -1), ~T[12:00:00]) + + # engagement events need a preceding pageview (shared user_id) for a session + events = + [ + build(:pageview, user_id: 1, timestamp: t), + build(:pageview, user_id: 2, timestamp: t), + build(:pageview, user_id: 3, timestamp: t), + build(:pageview, user_id: 4, timestamp: t), + build(:engagement, user_id: 1, timestamp: t, engagement_time: 5000), + build(:engagement, user_id: 2, timestamp: t, engagement_time: 5000) + ] ++ for(_ <- 1..6, do: build(:event, name: "Signup", user_id: 5, timestamp: t)) + + populate_stats(site, events) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.observed_days == 1 + # billable total = 4 pageviews + 6 custom events; the engagements are + # excluded. 10 events * 30 -> 300 (would be higher if engagement counted). + assert prospect.estimated_monthly == 300 + end + + test "ignores traffic older than the sampling window and caps observed_days at 30" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + + populate_stats( + site, + # 40 days ago: before the 30-day window -> must be ignored entirely + # window floor (30 complete days ago): sets first_data_day, observed_days = 30 + pageviews_on(Date.add(Date.utc_today(), -40), 1000) ++ + pageviews_on(Date.add(Date.utc_today(), -30), 1) ++ + pageviews_on(Date.add(Date.utc_today(), -1), 59) + ) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + # earliest day *within* the window, not the 40-day-old batch + assert prospect.first_data_day == Date.add(Date.utc_today(), -30) + # clamped to the window length + assert prospect.observed_days == 30 + # only the 60 in-window events count (1 + 59); 60 / 30 * 30 -> 60. + # Had the 40-day-old batch leaked in it would be ~1074. + assert prospect.estimated_monthly == 60 + end + end + + defp pageviews_on(date, count) do + timestamp = NaiveDateTime.new!(date, ~T[12:00:00]) + for _ <- 1..count, do: build(:pageview, timestamp: timestamp) + end + end +end From 7f1c8102f19fae58688f15dc55134065f67f164e Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Thu, 9 Jul 2026 16:44:49 +0300 Subject: [PATCH 2/2] Add moduledoc to stats/clickhouse.ex --- lib/plausible/stats/clickhouse.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex index 06b54f968344..375c9e9f47fc 100644 --- a/lib/plausible/stats/clickhouse.ex +++ b/lib/plausible/stats/clickhouse.ex @@ -1,4 +1,8 @@ defmodule Plausible.Stats.Clickhouse do + @moduledoc """ + Clickhouse utility functions + """ + use Plausible use Plausible.Repo use Plausible.ClickhouseRepo