diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index ec0e1f83b..66a8374a5 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -19,13 +19,13 @@ export default class TopStats extends React.Component { const formattedComparison = numberFormatter(Math.abs(comparison)) const defaultClassName = classNames({ - "text-xs dark:text-gray-100": !forceDarkBg, - "text-xs text-gray-100": forceDarkBg + "pl-2 text-xs dark:text-gray-100": !forceDarkBg, + "pl-2 text-xs text-gray-100": forceDarkBg }) const noChangeClassName = classNames({ - "text-xs text-gray-700 dark:text-gray-300": !forceDarkBg, - "text-xs text-gray-300": forceDarkBg + "pl-2 text-xs text-gray-700 dark:text-gray-300": !forceDarkBg, + "pl-2 text-xs text-gray-300": forceDarkBg }) if (comparison > 0) { @@ -46,6 +46,8 @@ export default class TopStats extends React.Component { return durationFormatter(value) } else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) { return value + '%' + } else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) { + return value?.short } else { return numberFormatter(value) } @@ -56,6 +58,8 @@ export default class TopStats extends React.Component { return durationFormatter(value) } else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) { return value + '%' + } else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) { + return value?.long } else { return (value || 0).toLocaleString() } diff --git a/lib/plausible/stats/aggregate.ex b/lib/plausible/stats/aggregate.ex index d628d7612..90d14e944 100644 --- a/lib/plausible/stats/aggregate.ex +++ b/lib/plausible/stats/aggregate.ex @@ -2,11 +2,26 @@ defmodule Plausible.Stats.Aggregate do alias Plausible.Stats.Query use Plausible.ClickhouseRepo import Plausible.Stats.{Base, Imported, Util} + import Ecto.Query - @event_metrics [:visitors, :pageviews, :events, :sample_percent] + @event_metrics [ + :visitors, + :pageviews, + :events, + :sample_percent, + :average_revenue, + :total_revenue + ] @session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit, :sample_percent] + @revenue_metrics [:average_revenue, :total_revenue] def aggregate(site, query, metrics) do + # 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. + currency = get_revenue_tracking_currency(site, query, metrics) + metrics = if currency, do: metrics, else: metrics -- @revenue_metrics + event_metrics = Enum.filter(metrics, &(&1 in @event_metrics)) event_task = fn -> aggregate_events(site, query, event_metrics) end session_metrics = Enum.filter(metrics, &(&1 in @session_metrics)) @@ -22,6 +37,7 @@ defmodule Plausible.Stats.Aggregate do Plausible.ClickhouseRepo.parallel_tasks([session_task, event_task, time_on_page_task]) |> Enum.reduce(%{}, fn aggregate, task_result -> Map.merge(aggregate, task_result) end) |> Enum.map(&maybe_round_value/1) + |> Enum.map(&cast_revenue_metric_to_money(&1, currency)) |> Enum.map(fn {metric, value} -> {metric, %{value: value}} end) |> Enum.into(%{}) end @@ -134,4 +150,37 @@ defmodule Plausible.Stats.Aggregate do end defp maybe_round_value(entry), do: entry + + defp get_revenue_tracking_currency(site, query, metrics) do + goal_filters = + case query.filters do + %{"event:goal" => {:is, {_, goal_name}}} -> [goal_name] + %{"event:goal" => {:member, list}} -> Enum.map(list, fn {_, goal_name} -> goal_name end) + _any -> [] + end + + if Enum.any?(metrics, &(&1 in @revenue_metrics)) && Enum.any?(goal_filters) do + revenue_goals_currencies = + Plausible.Repo.all( + from rg in assoc(site, :revenue_goals), + where: rg.event_name in ^goal_filters, + select: rg.currency, + distinct: true + ) + + if length(revenue_goals_currencies) == 1, + do: List.first(revenue_goals_currencies), + else: nil + else + nil + end + end + + defp cast_revenue_metric_to_money({metric, value}, currency) do + if metric in @revenue_metrics and is_atom(currency) do + {metric, Money.new!(value, currency)} + else + {metric, value} + end + end end diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 4e8c11772..bdc306fb1 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -329,54 +329,61 @@ defmodule PlausibleWeb.Api.StatsController do {stats, 100} end - defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query, comparison_query) do - totals_query = Query.remove_event_filters(query, [:goal, :props]) + defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do + query_without_filters = Query.remove_event_filters(query, [:goal, :props]) + metrics = [:visitors, :events, :average_revenue, :total_revenue] - %{ - visitors: %{value: unique_visitors} - } = Stats.aggregate(site, totals_query, [:visitors]) + results_without_filters = + site + |> Stats.aggregate(query_without_filters, [:visitors]) + |> transform_keys(%{visitors: :unique_visitors}) - %{ - visitors: %{value: converted_visitors}, - events: %{value: completions} - } = Stats.aggregate(site, query, [:visitors, :events]) + results = + site + |> Stats.aggregate(query, metrics) + |> transform_keys(%{visitors: :converted_visitors, events: :completions}) + |> Map.merge(results_without_filters) - {prev_unique_visitors, prev_converted_visitors, prev_completions} = + comparison = if comparison_query do - totals_comparison_query = Query.remove_event_filters(comparison_query, [:goal, :props]) + comparison_query_without_filters = + Query.remove_event_filters(comparison_query, [:goal, :props]) - %{ - visitors: %{value: prev_unique_visitors} - } = Stats.aggregate(site, totals_comparison_query, [:visitors]) + comparison_without_filters = + site + |> Stats.aggregate(comparison_query_without_filters, [:visitors]) + |> transform_keys(%{visitors: :unique_visitors}) - %{ - visitors: %{value: prev_converted_visitors}, - events: %{value: prev_completions} - } = Stats.aggregate(site, comparison_query, [:visitors, :events]) - - {prev_unique_visitors, prev_converted_visitors, prev_completions} - else - {nil, nil, nil} + site + |> Stats.aggregate(comparison_query, metrics) + |> transform_keys(%{visitors: :converted_visitors, events: :completions}) + |> Map.merge(comparison_without_filters) end - conversion_rate = calculate_cr(unique_visitors, converted_visitors) - prev_conversion_rate = calculate_cr(prev_unique_visitors, prev_converted_visitors) + conversion_rate = %{ + cr: %{value: calculate_cr(results.unique_visitors.value, results.converted_visitors.value)} + } - build_item = fn name, value, comparison_value -> - if comparison_value do - change = percent_change(comparison_value, value) - %{name: name, value: value, comparison_value: comparison_value, change: change} + comparison_conversion_rate = + if comparison do + value = + calculate_cr(comparison.unique_visitors.value, comparison.converted_visitors.value) + + %{cr: %{value: value}} else - %{name: name, value: value} + nil end - end - {[ - build_item.("Unique visitors", unique_visitors, prev_unique_visitors), - build_item.("Unique conversions", converted_visitors, prev_converted_visitors), - build_item.("Total conversions", completions, prev_completions), - build_item.("Conversion rate", conversion_rate, prev_conversion_rate) - ], 100} + [ + top_stats_entry(results, comparison, "Unique visitors", :unique_visitors), + top_stats_entry(results, comparison, "Unique conversions", :converted_visitors), + top_stats_entry(results, comparison, "Total conversions", :completions), + top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1), + top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1), + top_stats_entry(conversion_rate, comparison_conversion_rate, "Conversion rate", :cr) + ] + |> Enum.reject(&is_nil/1) + |> then(&{&1, 100}) end defp fetch_top_stats(site, query, comparison_query) do @@ -420,16 +427,22 @@ defmodule PlausibleWeb.Api.StatsController do {stats, current_results[:sample_percent][:value]} end - defp top_stats_entry(current_results, prev_results, name, key) do + defp top_stats_entry(current_results, prev_results, name, key, formatter \\ & &1) do if current_results[key] do value = get_in(current_results, [key, :value]) if prev_results do prev_value = get_in(prev_results, [key, :value]) change = calculate_change(key, prev_value, value) - %{name: name, value: value, comparison_value: prev_value, change: change} + + %{ + name: name, + value: formatter.(value), + comparison_value: formatter.(prev_value), + change: change + } else - %{name: name, value: value} + %{name: name, value: formatter.(value)} end end end @@ -444,6 +457,12 @@ defmodule PlausibleWeb.Api.StatsController do defp percent_change(nil, _new_count), do: nil + defp percent_change(%Money{} = old_count, %Money{} = new_count) do + old_count = old_count |> Money.to_decimal() |> Decimal.to_float() + new_count = new_count |> Money.to_decimal() |> Decimal.to_float() + percent_change(old_count, new_count) + end + defp percent_change(old_count, new_count) do cond do old_count == 0 and new_count > 0 -> @@ -1062,7 +1081,8 @@ defmodule PlausibleWeb.Api.StatsController do goal |> Map.put(:prop_names, CustomProps.props_for_goal(site, query)) |> Map.put(:conversion_rate, calculate_cr(total_visitors, goal[:unique_conversions])) - |> format_revenue_metrics() + |> Enum.map(&format_revenue_metric/1) + |> Map.new() end) if params["csv"] do @@ -1072,21 +1092,27 @@ defmodule PlausibleWeb.Api.StatsController do end end - defp format_revenue_metrics(%{average_revenue: %Money{}, total_revenue: %Money{}} = results) do - %{ - results - | average_revenue: %{ - short: Money.to_string!(results.average_revenue, format: :short, fractional_digits: 1), - long: Money.to_string!(results.average_revenue) - }, - total_revenue: %{ - short: Money.to_string!(results.total_revenue, format: :short, fractional_digits: 1), - long: Money.to_string!(results.total_revenue) - } - } + @revenue_metrics [:average_revenue, :total_revenue] + defp format_revenue_metric({metric, value}) do + if metric in @revenue_metrics do + {metric, format_money(value)} + else + {metric, value} + end end - defp format_revenue_metrics(results), do: results + defp format_money(value) do + case value do + %Money{} -> + %{ + short: Money.to_string!(value, format: :short, fractional_digits: 1), + long: Money.to_string!(value) + } + + _any -> + value + end + end def prop_breakdown(conn, params) do site = conn.assigns[:site] @@ -1162,13 +1188,12 @@ defmodule PlausibleWeb.Api.StatsController do ) end - defp transform_keys(results, keys_to_replace) do - Enum.map(results, fn map -> - Enum.map(map, fn {key, val} -> - {Map.get(keys_to_replace, key, key), val} - end) - |> Enum.into(%{}) - end) + defp transform_keys(result, keys_to_replace) when is_map(result) do + for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{} + end + + defp transform_keys(results, keys_to_replace) when is_list(results) do + Enum.map(results, &transform_keys(&1, keys_to_replace)) end defp parse_pagination(params) do diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 79ae8b786..162bf657a 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -36,6 +36,7 @@ site = {:ok, goal1} = Plausible.Goals.create(site, %{"page_path" => "/"}) {:ok, goal2} = Plausible.Goals.create(site, %{"page_path" => "/register"}) {:ok, goal3} = Plausible.Goals.create(site, %{"page_path" => "/login"}) +{:ok, goal4} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"}) {:ok, _funnel} = Plausible.Funnels.create(site, "From homepage to login", [ @@ -130,6 +131,35 @@ native_stats_range end) |> Plausible.TestUtils.populate_stats() +native_stats_range +|> Enum.with_index() +|> Enum.flat_map(fn {date, index} -> + Enum.map(0..Enum.random(1..50), fn _ -> + geolocation = Enum.random(geolocations) + + [ + name: goal4.event_name, + site_id: site.id, + hostname: site.domain, + timestamp: put_random_time.(date, index), + referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]), + browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]), + browser_version: to_string(Enum.random(0..50)), + screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]), + operating_system: Enum.random(["Windows", "macOS", "Linux"]), + operating_system_version: to_string(Enum.random(0..15)), + pathname: + Enum.random(["/", "/login", "/settings", "/register", "/docs", "/docs/1", "/docs/2"]), + user_id: Enum.random(1..1200), + revenue_reporting_amount: Decimal.new(Enum.random(100..10000)), + revenue_reporting_currency: "USD" + ] + |> Keyword.merge(geolocation) + |> then(&Plausible.Factory.build(:event, &1)) + end) +end) +|> Plausible.TestUtils.populate_stats() + site = site |> Plausible.Site.start_import( 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 5ba3a5dbc..0ccd276bf 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 @@ -690,6 +690,141 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"] end + + test "returns average and total when filtering by a revenue goal", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(10_31), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(20_00), + revenue_reporting_currency: "EUR" + ) + ]) + + filters = Jason.encode!(%{goal: "Payment"}) + 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,659.50", "short" => "$1.7K"} + } in top_stats + + assert %{ + "name" => "Total revenue", + "value" => %{"long" => "$3,319.00", "short" => "$3.3K"} + } in top_stats + end + + test "returns average and total when filtering by many revenue goals with same currency", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Payment2", currency: "USD") + insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment2", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment2", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(10_31), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(20_00), + revenue_reporting_currency: "EUR" + ) + ]) + + filters = Jason.encode!(%{goal: "Payment|Payment2"}) + 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,659.50", "short" => "$1.7K"} + } in top_stats + + assert %{ + "name" => "Total revenue", + "value" => %{"long" => "$6,638.00", "short" => "$6.6K"} + } in top_stats + end + + 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") + insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(10_31), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(20_00), + revenue_reporting_currency: "EUR" + ) + ]) + + filters = Jason.encode!(%{goal: "Payment|AddToCart"}) + 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 end describe "GET /api/stats/top-stats - with comparisons" do