diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index 76967e5c529c..c33eae9c862f 100644 --- a/assets/js/types/query-api.d.ts +++ b/assets/js/types/query-api.d.ts @@ -6,7 +6,6 @@ */ export type Metric = - | "time_on_page" | "visitors" | "visits" | "pageviews" @@ -16,7 +15,10 @@ export type Metric = | "events" | "percentage" | "conversion_rate" - | "group_conversion_rate"; + | "group_conversion_rate" + | "time_on_page" + | "total_revenue" + | "average_revenue"; export type DateRangeShorthand = "30m" | "realtime" | "all" | "day" | "7d" | "30d" | "month" | "6mo" | "12mo" | "year"; /** * @minItems 2 diff --git a/extra/lib/plausible/stats/goal/revenue.ex b/extra/lib/plausible/stats/goal/revenue.ex index a174a13d3b05..3a1948c0eba5 100644 --- a/extra/lib/plausible/stats/goal/revenue.ex +++ b/extra/lib/plausible/stats/goal/revenue.ex @@ -2,9 +2,6 @@ defmodule Plausible.Stats.Goal.Revenue do @moduledoc """ Revenue specific functions for the stats scope """ - import Ecto.Query - - alias Plausible.Stats.Filters @revenue_metrics [:average_revenue, :total_revenue] @@ -12,73 +9,65 @@ defmodule Plausible.Stats.Goal.Revenue do @revenue_metrics end - @spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) :: - {atom() | nil, [atom()]} @doc """ - Returns the common currency for the goal filters in a query. If there are no - goal filters, multiple currencies or the site owner does not have access to - revenue goals, `nil` is returned and revenue metrics are dropped. + Preloads revenue currencies for a query. - Aggregating revenue data works only for same currency goals. If the query is - filtered by goals with different currencies, for example, one USD and other - EUR, revenue metrics are dropped. - """ - def get_revenue_tracking_currency(site, query, metrics) do - goal_filters = - case Filters.get_toplevel_filter(query, "event:goal") do - [:is, "event:goal", list] -> list - _ -> [] - end + Assumptions and business logic: + 1. Goals are already filtered according to query filters and dimensions + 2. If there's a single currency involved, return map containing the default + 3. If there's a breakdown by event:goal we return all the relevant currencies as a map + 4. If filtering by multiple different currencies without event:goal breakdown empty map is returned + 5. If user has no access or preloading is not needed, empty map is returned - requested_revenue_metrics? = Enum.any?(metrics, &(&1 in @revenue_metrics)) - filtering_by_goal? = Enum.any?(goal_filters) - - revenue_goals_available? = fn -> - site = Plausible.Repo.preload(site, :owner) - Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok - end + The resulting data structure is attached to a `Query` and used below in `format_revenue_metric/3`. + """ + def preload_revenue_currencies(site, goals, metrics, dimensions) do + if requested?(metrics) and length(goals) > 0 and available?(site) do + goal_currency_map = + goals + |> Map.new(fn goal -> {Plausible.Goal.display_name(goal), goal.currency} end) + |> Map.reject(fn {_goal, currency} -> is_nil(currency) end) - if requested_revenue_metrics? && filtering_by_goal? && revenue_goals_available?.() do - revenue_goals_currencies = - Plausible.Repo.all( - from rg in Ecto.assoc(site, :revenue_goals), - where: rg.display_name in ^goal_filters, - select: rg.currency, - distinct: true - ) + currencies = goal_currency_map |> Map.values() |> Enum.uniq() + goal_dimension? = "event:goal" in dimensions - if length(revenue_goals_currencies) == 1, - do: {List.first(revenue_goals_currencies), metrics}, - else: {nil, metrics -- @revenue_metrics} + case {currencies, goal_dimension?} do + {[currency], false} -> %{default: currency} + {_, true} -> goal_currency_map + _ -> %{} + end else - {nil, metrics -- @revenue_metrics} + %{} end end - def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals) - when is_list(revenue_goals) do - for result <- results do - if matching_goal = Enum.find(revenue_goals, &(&1.display_name == result.goal)) do - cast_revenue_metrics_to_money(result, matching_goal.currency) - else - result - end + def format_revenue_metric(value, query, dimension_values) do + currency = + query.revenue_currencies[:default] || + get_goal_dimension_revenue_currency(query, dimension_values) + + if currency do + Money.new!(value || 0, currency) + else + value end end - def cast_revenue_metrics_to_money(results, currency) when is_map(results) do - for {metric, value} <- results, into: %{} do - {metric, maybe_cast_metric_to_money(value, metric, currency)} - end + def available?(site) do + site = Plausible.Repo.preload(site, :owner) + Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok end - def cast_revenue_metrics_to_money(results, _), do: results + # :NOTE: Legacy queries don't have metrics associated with them so work around the issue by assuming + # revenue metric was requested. + def requested?([]), do: true + def requested?(metrics), do: Enum.any?(metrics, &(&1 in @revenue_metrics)) - def maybe_cast_metric_to_money(value, metric, currency) do - if currency && metric in @revenue_metrics do - Money.new!(value || 0, currency) - else - value - end + defp get_goal_dimension_revenue_currency(query, dimension_values) do + Enum.zip(query.dimensions, dimension_values) + |> Enum.find_value(fn + {"event:goal", goal_label} -> Map.get(query.revenue_currencies, goal_label) + _ -> nil + end) end end diff --git a/lib/plausible/goals/filters.ex b/lib/plausible/goals/filters.ex index 6ad5b47e4f6b..142095e0488e 100644 --- a/lib/plausible/goals/filters.ex +++ b/lib/plausible/goals/filters.ex @@ -34,18 +34,39 @@ defmodule Plausible.Goals.Filters do end) end + def preload_needed_goals(site, filters) do + goals = Plausible.Goals.for_site(site) + + Enum.reduce(filters, goals, fn + [operation, "event:goal", clauses], goals -> + goals_matching_any_clause(goals, operation, clauses) + + _filter, goals -> + goals + end) + end + def filter_preloaded(preloaded_goals, operation, clause) when operation in [:is, :contains] do - Enum.filter(preloaded_goals, fn goal -> - case operation do - :is -> - Plausible.Goal.display_name(goal) == clause + Enum.filter(preloaded_goals, fn goal -> matches?(goal, operation, clause) end) + end - :contains -> - String.contains?(Plausible.Goal.display_name(goal), clause) - end + defp goals_matching_any_clause(goals, operation, clauses) do + goals + |> Enum.filter(fn goal -> + Enum.any?(clauses, fn clause -> matches?(goal, operation, clause) end) end) end + defp matches?(goal, operation, clause) do + case operation do + :is -> + Plausible.Goal.display_name(goal) == clause + + :contains -> + String.contains?(Plausible.Goal.display_name(goal), clause) + end + end + defp build_condition(filtered_goals, imported?) do Enum.reduce(filtered_goals, false, fn goal, dynamic_statement -> case goal do diff --git a/lib/plausible/stats/aggregate.ex b/lib/plausible/stats/aggregate.ex index 6b81774774ee..b7365f9e5502 100644 --- a/lib/plausible/stats/aggregate.ex +++ b/lib/plausible/stats/aggregate.ex @@ -6,20 +6,16 @@ defmodule Plausible.Stats.Aggregate do """ use Plausible.ClickhouseRepo - use Plausible - alias Plausible.Stats.{Query, QueryRunner} + alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer} def aggregate(site, query, metrics) do - {currency, metrics} = - on_ee do - Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics) - else - {nil, metrics} - end - Query.trace(query, metrics) - query = %Query{query | metrics: metrics} + query = + query + |> Query.set(metrics: metrics, remove_unavailable_revenue_metrics: true) + |> QueryOptimizer.optimize() + query_result = QueryRunner.run(site, query) [entry] = query_result.results @@ -29,7 +25,7 @@ defmodule Plausible.Stats.Aggregate do |> Enum.map(fn {metric, index} -> { metric, - metric_map(entry, index, metric, currency) + metric_map(entry, index, metric) } end) |> Enum.into(%{}) @@ -38,27 +34,25 @@ defmodule Plausible.Stats.Aggregate do def metric_map( %{metrics: metrics, comparison: %{metrics: comparison_metrics, change: change}}, index, - metric, - currency + metric ) do %{ - value: get_value(metrics, index, metric, currency), - comparison_value: get_value(comparison_metrics, index, metric, currency), + value: get_value(metrics, index, metric), + comparison_value: get_value(comparison_metrics, index, metric), change: Enum.at(change, index) } end - def metric_map(%{metrics: metrics}, index, metric, currency) do + def metric_map(%{metrics: metrics}, index, metric) do %{ - value: get_value(metrics, index, metric, currency) + value: get_value(metrics, index, metric) } end - def get_value(metric_list, index, metric, currency) do + def get_value(metric_list, index, metric) do metric_list |> Enum.at(index) |> maybe_round_value(metric) - |> maybe_cast_metric_to_money(metric, currency) end @metrics_to_round [:bounce_rate, :time_on_page, :visit_duration, :sample_percent] @@ -66,12 +60,4 @@ defmodule Plausible.Stats.Aggregate do defp maybe_round_value(nil, _metric), do: nil defp maybe_round_value(value, metric) when metric in @metrics_to_round, do: round(value) defp maybe_round_value(value, _metric), do: value - - on_ee do - defp maybe_cast_metric_to_money(value, metric, currency) do - Plausible.Stats.Goal.Revenue.maybe_cast_metric_to_money(value, metric, currency) - end - else - defp maybe_cast_metric_to_money(value, _metric, _currency), do: value - end end diff --git a/lib/plausible/stats/breakdown.ex b/lib/plausible/stats/breakdown.ex index c279406c3ac2..85e5d78f9bae 100644 --- a/lib/plausible/stats/breakdown.ex +++ b/lib/plausible/stats/breakdown.ex @@ -6,10 +6,9 @@ defmodule Plausible.Stats.Breakdown do """ use Plausible.ClickhouseRepo - use Plausible use Plausible.Stats.SQL.Fragments - alias Plausible.Stats.{Query, QueryOptimizer, QueryRunner} + alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer} def breakdown( site, @@ -22,8 +21,8 @@ defmodule Plausible.Stats.Breakdown do transformed_order_by = transform_order_by(order_by || [], dimension) query_with_metrics = - Query.set( - query, + query + |> Query.set( metrics: transformed_metrics, # Concat client requested order with default order, overriding only if client explicitly requests it order_by: @@ -34,13 +33,13 @@ defmodule Plausible.Stats.Breakdown do pagination: %{limit: limit, offset: (page - 1) * limit}, v2: true, # Allow pageview and event metrics to be queried off of sessions table - legacy_breakdown: true + legacy_breakdown: true, + remove_unavailable_revenue_metrics: true ) |> QueryOptimizer.optimize() QueryRunner.run(site, query_with_metrics) |> build_breakdown_result(query_with_metrics, metrics) - |> update_currency_metrics(site, query_with_metrics) end defp build_breakdown_result(query_result, query, metrics) do @@ -122,41 +121,4 @@ defmodule Plausible.Stats.Breakdown do end defp dimension_filters(_), do: [] - - on_ee do - defp update_currency_metrics(results, site, %Query{dimensions: ["event:goal"]}) do - site = Plausible.Repo.preload(site, :goals) - - {event_goals, _pageview_goals} = Enum.split_with(site.goals, & &1.event_name) - revenue_goals = Enum.filter(event_goals, &Plausible.Goal.Revenue.revenue?/1) - - if length(revenue_goals) > 0 and Plausible.Billing.Feature.RevenueGoals.enabled?(site) do - Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals) - else - remove_revenue_metrics(results) - end - end - - defp update_currency_metrics(results, site, query) do - {currency, _metrics} = - Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, query.metrics) - - if currency do - results - |> Enum.map(&Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(&1, currency)) - else - remove_revenue_metrics(results) - end - end - else - defp update_currency_metrics(results, _site, _query), do: remove_revenue_metrics(results) - end - - defp remove_revenue_metrics(results) do - Enum.map(results, fn map -> - map - |> Map.delete(:total_revenue) - |> Map.delete(:average_revenue) - end) - end end diff --git a/lib/plausible/stats/filters/query_parser.ex b/lib/plausible/stats/filters/query_parser.ex index 9e92b340c8d7..d00fe07c7050 100644 --- a/lib/plausible/stats/filters/query_parser.ex +++ b/lib/plausible/stats/filters/query_parser.ex @@ -1,6 +1,8 @@ defmodule Plausible.Stats.Filters.QueryParser do @moduledoc false + use Plausible + alias Plausible.Stats.{TableDecider, Filters, Metrics, DateTimeRange, JSONSchema} @default_include %{ @@ -36,7 +38,8 @@ defmodule Plausible.Stats.Filters.QueryParser do {:ok, order_by} <- parse_order_by(Map.get(params, "order_by")), {:ok, include} <- parse_include(site, Map.get(params, "include", %{})), {:ok, pagination} <- parse_pagination(Map.get(params, "pagination", %{})), - preloaded_goals <- preload_goals_if_needed(site, filters, dimensions), + {preloaded_goals, revenue_currencies} <- + preload_needed_goals(site, metrics, filters, dimensions), query = %{ metrics: metrics, filters: filters, @@ -44,15 +47,17 @@ defmodule Plausible.Stats.Filters.QueryParser do dimensions: dimensions, order_by: order_by, timezone: site.timezone, - preloaded_goals: preloaded_goals, include: include, - pagination: pagination + pagination: pagination, + preloaded_goals: preloaded_goals, + revenue_currencies: revenue_currencies }, :ok <- validate_order_by(query), :ok <- validate_custom_props_access(site, query), :ok <- validate_toplevel_only_filter_dimension(query), :ok <- validate_special_metrics_filters(query), :ok <- validate_filtered_goals_exist(query), + :ok <- validate_revenue_metrics_access(site, query), :ok <- validate_metrics(query), :ok <- validate_include(query) do {:ok, query} @@ -405,14 +410,19 @@ defmodule Plausible.Stats.Filters.QueryParser do end end - def preload_goals_if_needed(site, filters, dimensions) do + def preload_needed_goals(site, metrics, filters, dimensions) do goal_filters? = Enum.any?(filters, fn [_, filter_key | _rest] -> filter_key == "event:goal" end) if goal_filters? or Enum.member?(dimensions, "event:goal") do - Plausible.Goals.for_site(site) + goals = Plausible.Goals.Filters.preload_needed_goals(site, filters) + + { + goals, + preload_revenue_currencies(site, goals, metrics, dimensions) + } else - [] + {[], %{}} end end @@ -460,6 +470,25 @@ defmodule Plausible.Stats.Filters.QueryParser do end end + on_ee do + alias Plausible.Stats.Goal.Revenue + + defdelegate preload_revenue_currencies(site, preloaded_goals, metrics, dimensions), + to: Plausible.Stats.Goal.Revenue + + defp validate_revenue_metrics_access(site, query) do + if Revenue.requested?(query.metrics) and not Revenue.available?(site) do + {:error, "The owner of this site does not have access to the revenue metrics feature."} + else + :ok + end + end + else + defp preload_revenue_currencies(_site, _preloaded_goals, _metrics, _dimensions), do: %{} + + defp validate_revenue_metrics_access(_site, _query), do: :ok + end + defp validate_goal_filter(clause, configured_goals) do configured_goal_names = Enum.map(configured_goals, fn goal -> Plausible.Goal.display_name(goal) end) diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex index 065da1a3c80f..7b856fc9ec3f 100644 --- a/lib/plausible/stats/legacy/legacy_query_builder.ex +++ b/lib/plausible/stats/legacy/legacy_query_builder.ex @@ -32,14 +32,15 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do end defp put_preloaded_goals(query, site) do - goals = - Plausible.Stats.Filters.QueryParser.preload_goals_if_needed( + {preloaded_goals, revenue_currencies} = + Plausible.Stats.Filters.QueryParser.preload_needed_goals( site, + query.metrics, query.filters, query.dimensions ) - struct!(query, preloaded_goals: goals) + struct!(query, preloaded_goals: preloaded_goals, revenue_currencies: revenue_currencies) end defp put_period(%Query{now: now} = query, _site, %{"period" => period}) diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex index 1f04428144d2..1624e193e43b 100644 --- a/lib/plausible/stats/query.ex +++ b/lib/plausible/stats/query.ex @@ -17,7 +17,9 @@ defmodule Plausible.Stats.Query do timezone: nil, v2: false, legacy_breakdown: false, + remove_unavailable_revenue_metrics: false, preloaded_goals: [], + revenue_currencies: %{}, include: Plausible.Stats.Filters.QueryParser.default_include(), debug_metadata: %{}, pagination: nil diff --git a/lib/plausible/stats/query_optimizer.ex b/lib/plausible/stats/query_optimizer.ex index 5d30ace9e7fd..ce26ec06bb85 100644 --- a/lib/plausible/stats/query_optimizer.ex +++ b/lib/plausible/stats/query_optimizer.ex @@ -13,6 +13,8 @@ defmodule Plausible.Stats.QueryOptimizer do 1. Figures out what the right granularity to group by time is 2. Adds a missing order_by clause to a query 3. Updating "time" dimension in order_by to the right granularity + 4. Updates event:hostname filters to also apply on visit level for sane results. + 5. Removes revenue metrics from dashboard queries if not requested, present or unavailable for the site. """ def optimize(query) do @@ -47,7 +49,8 @@ defmodule Plausible.Stats.QueryOptimizer do &update_group_by_time/1, &add_missing_order_by/1, &update_time_in_order_by/1, - &extend_hostname_filters_to_visit/1 + &extend_hostname_filters_to_visit/1, + &remove_revenue_metrics_if_unavailable/1 ] end @@ -172,4 +175,16 @@ defmodule Plausible.Stats.QueryOptimizer do pagination: nil ) end + + on_ee do + defp remove_revenue_metrics_if_unavailable(query) do + if query.remove_unavailable_revenue_metrics and map_size(query.revenue_currencies) == 0 do + Query.set(query, metrics: query.metrics -- Plausible.Stats.Goal.Revenue.revenue_metrics()) + else + query + end + end + else + defp remove_revenue_metrics_if_unavailable(query), do: query + end end diff --git a/lib/plausible/stats/query_runner.ex b/lib/plausible/stats/query_runner.ex index 53142907d962..e297b8a3541b 100644 --- a/lib/plausible/stats/query_runner.ex +++ b/lib/plausible/stats/query_runner.ex @@ -9,6 +9,7 @@ defmodule Plausible.Stats.QueryRunner do 3. Passing total_rows from clickhouse to QueryResult meta """ + use Plausible use Plausible.ClickhouseRepo alias Plausible.Stats.{ @@ -141,7 +142,7 @@ defmodule Plausible.Stats.QueryRunner do %{ dimensions: dimensions, - metrics: Enum.map(query.metrics, &get_metric(entry, &1, dimensions, time_on_page)) + metrics: Enum.map(query.metrics, &get_metric(entry, &1, dimensions, query, time_on_page)) } end) end @@ -168,10 +169,19 @@ defmodule Plausible.Stats.QueryRunner do Map.get(entry, Util.shortname(query, dimension)) end - defp get_metric(_entry, :time_on_page, dimensions, time_on_page), + on_ee do + defp get_metric(entry, metric, dimensions, query, _time_on_page) + when metric in [:average_revenue, :total_revenue] do + value = Map.get(entry, metric) + + Plausible.Stats.Goal.Revenue.format_revenue_metric(value, query, dimensions) + end + end + + defp get_metric(_entry, :time_on_page, dimensions, _query, time_on_page), do: Map.get(time_on_page, dimensions) - defp get_metric(entry, metric, _dimensions, _time_on_page), do: Map.get(entry, metric) + defp get_metric(entry, metric, _dimensions, _query, _time_on_page), do: Map.get(entry, metric) # Special case: If comparison and single time dimension, add 0 rows - otherwise # comparisons would not be shown for timeseries with 0 values. diff --git a/lib/plausible/stats/timeseries.ex b/lib/plausible/stats/timeseries.ex index 2cf33eab51bb..e5d94f6f1ed9 100644 --- a/lib/plausible/stats/timeseries.ex +++ b/lib/plausible/stats/timeseries.ex @@ -5,9 +5,8 @@ defmodule Plausible.Stats.Timeseries do Avoid adding new logic here - update QueryBuilder etc instead. """ - use Plausible use Plausible.ClickhouseRepo - alias Plausible.Stats.{Comparisons, Query, QueryRunner, Time} + alias Plausible.Stats.{Comparisons, Query, QueryRunner, QueryOptimizer, Time} @time_dimension %{ "month" => "time:month", @@ -18,21 +17,16 @@ defmodule Plausible.Stats.Timeseries do } def timeseries(site, query, metrics) do - {currency, metrics} = - on_ee do - Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics) - else - {nil, metrics} - end - query = - Query.set( - query, + query + |> Query.set( metrics: transform_metrics(metrics, %{conversion_rate: :group_conversion_rate}), dimensions: [time_dimension(query)], order_by: [{time_dimension(query), :asc}], - v2: true + v2: true, + remove_unavailable_revenue_metrics: true ) + |> QueryOptimizer.optimize() comparison_query = if(query.include.comparisons, @@ -43,8 +37,8 @@ defmodule Plausible.Stats.Timeseries do query_result = QueryRunner.run(site, query) { - build_result(query_result, query, currency, fn entry -> entry end), - build_result(query_result, comparison_query, currency, fn entry -> entry.comparison end), + build_result(query_result, query, fn entry -> entry end), + build_result(query_result, comparison_query, fn entry -> entry.comparison end), query_result.meta } end @@ -53,7 +47,7 @@ defmodule Plausible.Stats.Timeseries do # Given a query result, build a legacy timeseries result # Format is %{ date => %{ date: date_string, [metric] => value } } with a bunch of special cases for the UI - defp build_result(query_result, %Query{} = query, currency, extract_entry) do + defp build_result(query_result, %Query{} = query, extract_entry) do query_result.results |> Enum.map(&extract_entry.(&1)) |> Enum.map(fn %{dimensions: [time_dimension_value], metrics: metrics} -> @@ -65,12 +59,12 @@ defmodule Plausible.Stats.Timeseries do } end) |> Map.new() - |> add_labels(query, currency) + |> add_labels(query) end - defp build_result(_, _, _, _), do: nil + defp build_result(_, _, _), do: nil - defp add_labels(results_map, query, currency) do + defp add_labels(results_map, query) do query |> Time.time_labels() |> Enum.map(fn key -> @@ -79,7 +73,6 @@ defmodule Plausible.Stats.Timeseries do key, empty_row(key, query.metrics) ) - |> cast_revenue_metrics_to_money(currency) end) |> transform_realtime_labels(query) |> transform_keys(%{group_conversion_rate: :conversion_rate}) @@ -122,12 +115,4 @@ defmodule Plausible.Stats.Timeseries do end defp transform_realtime_labels(results, _query), do: results - - on_ee do - defp cast_revenue_metrics_to_money(results, revenue_goals) do - Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals) - end - else - defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results - end end diff --git a/priv/json-schemas/query-api-schema.json b/priv/json-schemas/query-api-schema.json index 53c98b2d2349..69bcbce97b1a 100644 --- a/priv/json-schemas/query-api-schema.json +++ b/priv/json-schemas/query-api-schema.json @@ -218,10 +218,6 @@ }, "metric": { "oneOf": [ - { - "const": "time_on_page", - "$comment": "only :internal" - }, { "const": "visitors", "description": "Metric counting the number of unique visitors" @@ -261,6 +257,18 @@ { "const": "group_conversion_rate", "markdownDescription": "The percentage of visitors who completed the goal with the same dimension. Requires: dimension list passed, an `event:goal` filter or `event:goal` dimension" + }, + { + "const": "time_on_page", + "$comment": "only :internal" + }, + { + "const": "total_revenue", + "$comment": "only :internal" + }, + { + "const": "average_revenue", + "$comment": "only :internal" } ] }, diff --git a/test/plausible/stats/query_parser_test.exs b/test/plausible/stats/query_parser_test.exs index 64630a84f04a..a12395c3ffef 100644 --- a/test/plausible/stats/query_parser_test.exs +++ b/test/plausible/stats/query_parser_test.exs @@ -47,7 +47,12 @@ defmodule Plausible.Stats.Filters.QueryParserTest do def check_success(params, site, expected_result, schema_type \\ :public) do assert {:ok, result} = parse(site, schema_type, params, @now) + + return_value = Map.take(result, [:preloaded_goals, :revenue_currencies]) + result = Map.drop(result, [:preloaded_goals, :revenue_currencies]) assert result == expected_result + + return_value end def check_error(params, site, expected_error_message, schema_type \\ :public) do @@ -69,13 +74,22 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} } check_success(params, site, expected_parsed, schema_type) end + def check_goals(actual, opts) do + preloaded_goal_names = + actual[:preloaded_goals] + |> Enum.map(& &1.display_name) + |> Enum.sort() + + assert preloaded_goal_names == Keyword.get(opts, :preloaded_goals) + assert actual[:revenue_currencies] == Keyword.get(opts, :revenue_currencies) + end + test "parsing empty map fails", %{site: site} do %{} |> check_error(site, "#: Required properties site_id, metrics, date_range were not present.") @@ -92,8 +106,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -134,8 +147,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }, :internal ) @@ -201,8 +213,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }, :internal ) @@ -327,8 +338,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -353,8 +363,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end end @@ -380,8 +389,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end end @@ -447,8 +455,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) %{ @@ -467,8 +474,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -528,8 +534,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -576,8 +581,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -612,8 +616,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: true, time_labels: true, total_rows: true, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -676,8 +679,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do time_labels: false, total_rows: false }, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }, :internal ) @@ -707,8 +709,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do time_labels: false, total_rows: false }, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }, :internal ) @@ -741,8 +742,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do time_labels: false, total_rows: false }, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }, :internal ) @@ -799,8 +799,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 100, offset: 200}, - preloaded_goals: [] + pagination: %{limit: 100, offset: 200} }) end @@ -1125,8 +1124,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end end @@ -1147,8 +1145,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end end @@ -1168,8 +1165,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -1230,8 +1226,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: [{:events, :desc}, {:visitors, :asc}], timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -1251,8 +1246,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: [{"event:name", :desc}], timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -1344,45 +1338,51 @@ defmodule Plausible.Stats.Filters.QueryParserTest do ) end - # test "succeeds with event:goal filter", %{site: site} do - # insert(:goal, %{site: site, event_name: "Signup"}) - - # %{ - # "metrics" => ["conversion_rate"], - # "date_range" => "all", - # "filters" => [["is", "event:goal", ["Signup"]]] - # } - # |> check_success(site, %{ - # metrics: [:conversion_rate], - # utc_time_range: @date_range_day, - # filters: [[:is, "event:goal", [event: "Signup"]]], - # dimensions: [], - # order_by: nil, - # timezone: site.timezone, - # include: %{imports: false, time_labels: false}, - # preloaded_goals: [event: "Signup"] - # }) - # end - - # test "succeeds with event:goal dimension", %{site: site} do - # goal = insert(:goal, %{site: site, event_name: "Signup"}) - - # %{ - # "metrics" => ["conversion_rate"], - # "date_range" => "all", - # "dimensions" => ["event:goal"] - # } - # |> check_success(site, %{ - # metrics: [:conversion_rate], - # utc_time_range: @date_range_day, - # filters: [], - # dimensions: ["event:goal"], - # order_by: nil, - # timezone: site.timezone, - # include: %{imports: false, time_labels: false}, - # preloaded_goals: [goal] - # }) - # end + test "succeeds with event:goal filter", %{site: site} do + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) + + %{ + "site_id" => site.domain, + "metrics" => ["conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Signup"]]] + } + |> check_success(site, %{ + metrics: [:conversion_rate], + utc_time_range: @date_range_day, + filters: [[:is, "event:goal", ["Signup"]]], + dimensions: [], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }) + |> check_goals(preloaded_goals: ["Signup"], revenue_currencies: %{}) + end + + test "succeeds with event:goal dimension", %{site: site} do + insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + %{ + "site_id" => site.domain, + "metrics" => ["conversion_rate"], + "date_range" => "all", + "dimensions" => ["event:goal"] + } + |> check_success(site, %{ + metrics: [:conversion_rate], + utc_time_range: @date_range_day, + filters: [], + dimensions: ["event:goal"], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }) + |> check_goals(preloaded_goals: ["Purchase", "Signup"], revenue_currencies: %{}) + end test "custom properties filter with special metric", %{site: site} do %{ @@ -1402,8 +1402,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -1423,25 +1422,27 @@ defmodule Plausible.Stats.Filters.QueryParserTest do end describe "views_per_visit metric" do - # test "succeeds with normal filters", %{site: site} do - # insert(:goal, %{site: site, event_name: "Signup"}) - - # %{ - # "metrics" => ["views_per_visit"], - # "date_range" => "all", - # "filters" => [["is", "event:goal", ["Signup"]]] - # } - # |> check_success(site, %{ - # metrics: [:views_per_visit], - # utc_time_range: @date_range_day, - # filters: [[:is, "event:goal", [event: "Signup"]]], - # dimensions: [], - # order_by: nil, - # timezone: site.timezone, - # include: %{imports: false, time_labels: false}, - # preloaded_goals: [event: "Signup"] - # }) - # end + test "succeeds with normal filters", %{site: site} do + insert(:goal, %{site: site, event_name: "Signup"}) + + %{ + "site_id" => site.domain, + "metrics" => ["views_per_visit"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Signup"]]] + } + |> check_success(site, %{ + metrics: [:views_per_visit], + utc_time_range: @date_range_day, + filters: [[:is, "event:goal", ["Signup"]]], + dimensions: [], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }) + |> check_goals(preloaded_goals: ["Signup"], revenue_currencies: %{}) + end test "fails validation if event:page filter specified", %{site: site} do %{ @@ -1470,6 +1471,209 @@ defmodule Plausible.Stats.Filters.QueryParserTest do end end + describe "revenue metrics" do + @describetag :ee_only + + setup %{user: user} do + plan = + insert(:enterprise_plan, + features: [Plausible.Billing.Feature.RevenueGoals], + user_id: user.id + ) + + subscription = insert(:subscription, user: user, paddle_plan_id: plan.paddle_plan_id) + + {:ok, subscription: subscription} + end + + test "not valid in public schema", %{site: site} do + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all" + } + |> check_error( + site, + "#/metrics/0: Invalid metric \"total_revenue\"\n#/metrics/1: Invalid metric \"average_revenue\"" + ) + end + + test "can request in internal schema", %{site: site} do + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all" + } + |> check_success( + site, + %{ + metrics: [:total_revenue, :average_revenue], + utc_time_range: @date_range_day, + filters: [], + dimensions: [], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }, + :internal + ) + end + + test "no access", %{site: site, user: user, subscription: subscription} do + Repo.delete!(subscription) + + plan = + insert(:enterprise_plan, features: [Plausible.Billing.Feature.StatsAPI], user_id: user.id) + + insert(:subscription, user: user, paddle_plan_id: plan.paddle_plan_id) + + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all" + } + |> check_error( + site, + "The owner of this site does not have access to the revenue metrics feature.", + :internal + ) + end + + test "with event:goal filters with same currency", %{site: site} do + insert(:goal, + site: site, + event_name: "Purchase", + currency: "USD", + display_name: "PurchaseUSD" + ) + + insert(:goal, site: site, event_name: "Subscription", currency: "USD") + insert(:goal, site: site, event_name: "Signup") + insert(:goal, site: site, event_name: "Logout") + + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]] + } + |> check_success( + site, + %{ + metrics: [:total_revenue, :average_revenue], + utc_time_range: @date_range_day, + filters: [[:is, "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]], + dimensions: [], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }, + :internal + ) + |> check_goals( + preloaded_goals: ["PurchaseUSD", "Signup", "Subscription"], + revenue_currencies: %{default: :USD} + ) + end + + test "with event:goal filters with different currencies", %{site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Subscription", currency: "EUR") + insert(:goal, site: site, event_name: "Signup") + + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Purchase", "Signup", "Subscription"]]] + } + |> check_success( + site, + %{ + metrics: [:total_revenue, :average_revenue], + utc_time_range: @date_range_day, + filters: [[:is, "event:goal", ["Purchase", "Signup", "Subscription"]]], + dimensions: [], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }, + :internal + ) + |> check_goals( + preloaded_goals: ["Purchase", "Signup", "Subscription"], + revenue_currencies: %{} + ) + end + + test "with event:goal dimension, different currencies", %{site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Donation", currency: "EUR") + insert(:goal, site: site, event_name: "Signup") + + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all", + "dimensions" => ["event:goal"] + } + |> check_success( + site, + %{ + metrics: [:total_revenue, :average_revenue], + utc_time_range: @date_range_day, + filters: [], + dimensions: ["event:goal"], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }, + :internal + ) + |> check_goals( + preloaded_goals: ["Donation", "Purchase", "Signup"], + revenue_currencies: %{"Donation" => :EUR, "Purchase" => :USD} + ) + end + + test "with event:goal dimension and filters", %{site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Subscription", currency: "USD") + insert(:goal, site: site, event_name: "Signup") + insert(:goal, site: site, event_name: "Logout") + + %{ + "site_id" => site.domain, + "metrics" => ["total_revenue", "average_revenue"], + "date_range" => "all", + "dimensions" => ["event:goal"], + "filters" => [["is", "event:goal", ["Purchase", "Signup"]]] + } + |> check_success( + site, + %{ + metrics: [:total_revenue, :average_revenue], + utc_time_range: @date_range_day, + filters: [[:is, "event:goal", ["Purchase", "Signup"]]], + dimensions: ["event:goal"], + order_by: nil, + timezone: site.timezone, + include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, + pagination: %{limit: 10_000, offset: 0} + }, + :internal + ) + |> check_goals( + preloaded_goals: ["Purchase", "Signup"], + revenue_currencies: %{"Purchase" => :USD} + ) + end + end + describe "session metrics" do test "single session metric succeeds", %{site: site} do %{ @@ -1486,8 +1690,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -1519,8 +1722,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end @@ -1539,8 +1741,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do order_by: nil, timezone: site.timezone, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, - pagination: %{limit: 10_000, offset: 0}, - preloaded_goals: [] + pagination: %{limit: 10_000, offset: 0} }) end end diff --git a/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs b/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs index 5096b85fb386..3fc92087eee4 100644 --- a/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs @@ -1451,6 +1451,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do } in top_stats end + @tag :ee_only test "does not return average and total when filtering by many revenue goals with different currencies", %{conn: conn, site: site} do insert(:goal, site: site, event_name: "Payment", currency: "USD") @@ -1488,6 +1489,93 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do refute "Total revenue" in metrics end + @tag :ee_only + test "returns average and total revenue when filtering by many goals some which don't have currencies", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Signup"), + build(:event, name: "Signup") + ]) + + filters = Jason.encode!(%{goal: "Payment|Signup"}) + conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=all&filters=#{filters}") + assert %{"top_stats" => top_stats} = json_response(conn, 200) + + assert %{ + "name" => "Average revenue", + "value" => %{"long" => "$1,000.00", "short" => "$1.0K"}, + "graph_metric" => "average_revenue" + } in top_stats + + assert %{ + "name" => "Total revenue", + "value" => %{"long" => "$2,000.00", "short" => "$2.0K"}, + "graph_metric" => "total_revenue" + } in top_stats + end + + @tag :ee_only + test "returns average and total revenue when no conversions", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + filters = Jason.encode!(%{goal: "Payment|Signup"}) + conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=all&filters=#{filters}") + assert %{"top_stats" => top_stats} = json_response(conn, 200) + + assert %{ + "name" => "Average revenue", + "value" => %{"long" => "$0.00", "short" => "$0.0"}, + "graph_metric" => "average_revenue" + } in top_stats + + assert %{ + "name" => "Total revenue", + "value" => %{"long" => "$0.00", "short" => "$0.0"}, + "graph_metric" => "total_revenue" + } in top_stats + end + + @tag :ee_only + test "does not return average and total revenue when filtering non-currency goal", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", display_name: "PaymentWithoutCurrency") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ) + ]) + + filters = Jason.encode!(%{goal: "PaymentWithoutCurrency"}) + conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=all&filters=#{filters}") + assert %{"top_stats" => top_stats} = json_response(conn, 200) + + metrics = Enum.map(top_stats, & &1["name"]) + refute "Average revenue" in metrics + refute "Total revenue" in metrics + end + test "does not return average and total when site owner is on a growth plan", %{conn: conn, site: site, user: user} do insert(:growth_subscription, user: user)