Revenue tracking: Add currency field to goal creation (#2948)

* Add revenue goal option to goal creation

This commit adds a currency field to the goals form. Goals that have a
currency set are now revenue goals, and are cached with sites to later
be used during ingestion.

Co-authored-by: Robert Joonas <robertjoonas16@gmail.com>

* Enable feature flag in tests

---------

Co-authored-by: Robert Joonas <robertjoonas16@gmail.com>
This commit is contained in:
Vini Brasil 2023-05-23 12:08:09 +02:00 committed by GitHub
parent 3a07487c05
commit 10d9e3b083
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 197 additions and 28 deletions

View File

@ -54,4 +54,8 @@ config :plausible,
config :plausible, Plausible.Ingestion.Counters, enabled: true
config :ex_cldr,
default_locale: "en",
default_backend: Plausible.Cldr
import_config "#{config_env()}.exs"

View File

@ -320,6 +320,9 @@ config :plausible, Plausible.ImportDeletionRepo,
transport_opts: ch_transport_opts,
pool_size: 1
config :ex_money,
open_exchange_rates_app_id: get_var_from_path_or_env(config_dir, "OPEN_EXCHANGE_RATES_APP_ID")
case mailer_adapter do
"Bamboo.PostmarkAdapter" ->
config :plausible, Plausible.Mailer,

View File

@ -29,4 +29,6 @@ config :plausible,
http_impl: Plausible.HTTPClient.Mock,
sites_by_domain_cache_enabled: false
config :ex_money, api_module: Plausible.ExchangeRateMock
config :plausible, Plausible.Ingestion.Counters, enabled: false

5
lib/plausible/cldr.ex Normal file
View File

@ -0,0 +1,5 @@
defmodule Plausible.Cldr do
@moduledoc false
use Cldr, locales: ["en"], providers: [Cldr.Number]
end

View File

@ -24,20 +24,40 @@ defmodule Plausible.Goal do
field :event_name, :string
field :page_path, :string
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
belongs_to :site, Plausible.Site
timestamps()
end
def revenue?(%__MODULE__{currency: currency}) do
!!currency
end
def valid_currencies do
Ecto.Enum.dump_values(__MODULE__, :currency)
end
def currency_options do
options =
for code <- valid_currencies() do
{"#{code} - #{Cldr.Currency.display_name!(code)}", code}
end
[{"", nil}] ++ options
end
def changeset(goal, attrs \\ %{}) do
goal
|> cast(attrs, [:site_id, :event_name, :page_path])
|> cast(attrs, [:site_id, :event_name, :page_path, :currency])
|> validate_required([:site_id])
|> cast_assoc(:site)
|> validate_event_name_and_page_path()
|> update_change(:event_name, &String.trim/1)
|> update_change(:page_path, &String.trim/1)
|> validate_length(:event_name, max: 120)
|> maybe_drop_currency()
end
defp validate_event_name_and_page_path(changeset) do
@ -59,4 +79,12 @@ defmodule Plausible.Goal do
value = get_field(changeset, :event_name)
value && String.match?(value, ~r/^.+/)
end
defp maybe_drop_currency(changeset) do
if get_field(changeset, :page_path) do
delete_change(changeset, :currency)
else
changeset
end
end
end

View File

@ -31,6 +31,7 @@ defmodule Plausible.Site do
many_to_many :members, User, join_through: Plausible.Site.Membership
has_many :memberships, Plausible.Site.Membership
has_many :invitations, Plausible.Auth.Invitation
has_many :revenue_goals, Plausible.Goal, where: [currency: {:not, nil}]
has_one :google_auth, GoogleAuth
has_one :weekly_report, Plausible.Site.WeeklyReport
has_one :monthly_report, Plausible.Site.MonthlyReport

View File

@ -51,11 +51,11 @@ defmodule Plausible.Site.Cache do
@modes [:all, :updated_recently]
@cached_schema_fields ~w(
id
domain
domain_changed_from
ingest_rate_limit_scale_seconds
ingest_rate_limit_threshold
id
domain
domain_changed_from
ingest_rate_limit_scale_seconds
ingest_rate_limit_threshold
)a
@type t() :: Site.t()
@ -92,17 +92,9 @@ defmodule Plausible.Site.Cache do
@spec refresh_all(Keyword.t()) :: :ok
def refresh_all(opts \\ []) do
sites_by_domain_query =
from s in Site,
select: {
s.domain,
s.domain_changed_from,
%{struct(s, ^@cached_schema_fields) | from_cache?: true}
}
refresh(
:all,
sites_by_domain_query,
sites_by_domain_query(),
Keyword.put(opts, :delete_stale_items?, true)
)
end
@ -110,14 +102,9 @@ defmodule Plausible.Site.Cache do
@spec refresh_updated_recently(Keyword.t()) :: :ok
def refresh_updated_recently(opts \\ []) do
recently_updated_sites_query =
from s in Site,
from [s, mg] in sites_by_domain_query(),
order_by: [asc: s.updated_at],
where: s.updated_at > ago(^15, "minute"),
select: {
s.domain,
s.domain_changed_from,
%{struct(s, ^@cached_schema_fields) | from_cache?: true}
}
where: s.updated_at > ago(^15, "minute") or mg.updated_at > ago(^15, "minute")
refresh(
:updated_recently,
@ -126,6 +113,17 @@ defmodule Plausible.Site.Cache do
)
end
defp sites_by_domain_query do
from s in Site,
left_join: mg in assoc(s, :revenue_goals),
select: {
s.domain,
s.domain_changed_from,
%{struct(s, ^@cached_schema_fields) | from_cache?: true}
},
preload: [revenue_goals: mg]
end
@spec merge(new_items :: [Site.t()], opts :: Keyword.t()) :: :ok
def merge(new_items, opts \\ [])
def merge([], _), do: :ok
@ -182,7 +180,16 @@ defmodule Plausible.Site.Cache do
nil
end
else
Plausible.Sites.get_by_domain(domain)
get_from_source(domain)
end
end
defp get_from_source(domain) do
query = from s in sites_by_domain_query(), where: s.domain == ^domain
case Plausible.Repo.one(query) do
{_, _, site} -> %Site{site | from_cache?: false}
_any -> nil
end
end

View File

@ -7,9 +7,19 @@
</div>
<div class="my-6">
<div id="event-fields">
<%= label f, :event_name, class: "block text-sm font-bold dark:text-gray-100" %>
<%= text_input f, :event_name, class: "transition mt-3 bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500", placeholder: "Signup" %>
<%= error_tag f, :event_name %>
<div>
<%= label f, :event_name, class: "block text-sm font-bold dark:text-gray-100" %>
<%= text_input f, :event_name, class: "transition mt-3 bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500", placeholder: "Signup" %>
<%= error_tag f, :event_name %>
</div>
<%= if FunWithFlags.enabled?(:revenue_goals, for: @current_user) do %>
<div class="mt-3">
<%= label f, :currency, "Reporting currency for revenue tracking (optional)", class: "block text-sm font-bold dark:text-gray-100" %>
<%= select f, :currency, Plausible.Goal.currency_options(), class: "transition mt-3 bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500" %>
<%= error_tag f, :currency %>
</div>
<% end %>
</div>
<div id="pageview-fields" class="hidden">
<%= label f, :page_path, class: "block text-sm font-bold dark:text-gray-100" %>

View File

@ -116,7 +116,8 @@ defmodule Plausible.MixProject do
{:siphash, "~> 3.2"},
{:timex, "~> 3.7"},
{:ua_inspector, "~> 3.0"},
{:ex_doc, "~> 0.28", only: :dev, runtime: false}
{:ex_doc, "~> 0.28", only: :dev, runtime: false},
{:ex_money, "~> 5.12"}
]
end

View File

@ -13,6 +13,7 @@
"ch": {:git, "https://github.com/plausible/ch.git", "f411daa07c6310d3308f81397905df65330aeb64", []},
"chatterbox": {:hex, :ts_chatterbox, "0.13.0", "6f059d97bcaa758b8ea6fffe2b3b81362bd06b639d3ea2bb088335511d691ebf", [:rebar3], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "b93d19104d86af0b3f2566c4cba2a57d2e06d103728246ba1ac6c3c0ff010aa7"},
"chto": {:git, "https://github.com/plausible/chto.git", "6afe76281e617c19df9b7a705c8401f604fe091f", []},
"cldr_utils": {:hex, :cldr_utils, "2.22.0", "5df60df2603dfeeffe26e40ab1ee565df34da288a53bb2db0d0dbd243fd646ef", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "ea14e8a6aa89ffd59a5d49baebe7ebf852cc024ac50dc2b3dabcd3786eeed657"},
"combination": {:hex, :combination, "0.0.3", "746aedca63d833293ec6e835aa1f34974868829b1486b1e1cb0685f0b2ae1f41", [:mix], [], "hexpm", "72b099f463df42ef7dc6371d250c7070b57b6c5902853f69deb894f79eda18ca"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
@ -27,6 +28,7 @@
"db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"},
"decimal": {:hex, :decimal, "2.1.0", "4a607736e18d9a77ea44fa18388bf43bb7f7df70ff2e0d23d4238f9d18a3ad7d", [:mix], [], "hexpm", "6396853b6b4bf8b1ff2c2217121f1fad4a1aff999014a10fba60e6009194f91b"},
"dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"},
"digital_token": {:hex, :digital_token, "0.4.0", "2ad6894d4a40be8b2890aad286ecd5745fa473fa5699d80361a8c94428edcd1f", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a178edf61d1fee5bb3c34e14b0f4ee21809ee87cade8738f87337e59e5e66e26"},
"double": {:hex, :double, "0.8.2", "8e1cfcccdaef76c18846bc08e555555a2a699b806fa207b6468572a60513cc6a", [:mix], [], "hexpm", "90287642b2ec86125e0457aaba2ab0e80f7d7050cc80a0cef733e59bd70aa67c"},
"earmark_parser": {:hex, :earmark_parser, "1.4.31", "a93921cdc6b9b869f519213d5bc79d9e218ba768d7270d46fdcf1c01bacff9e2", [:mix], [], "hexpm", "317d367ee0335ef037a87e46c91a2269fef6306413f731e8ec11fc45a7efd059"},
"ecto": {:hex, :ecto, "3.9.5", "9f0aa7ae44a1577b651c98791c6988cd1b69b21bc724e3fd67090b97f7604263", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d4f3115d8cbacdc0bfa4b742865459fb1371d0715515842a1fb17fe31920b74c"},
@ -35,8 +37,12 @@
"envy": {:hex, :envy, "1.1.1", "0bc9bd654dec24fcdf203f7c5aa1b8f30620f12cfb28c589d5e9c38fe1b07475", [:mix], [], "hexpm", "7061eb1a47415fd757145d8dec10dc0b1e48344960265cb108f194c4252c3a89"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"},
"ex_cldr": {:hex, :ex_cldr, "2.36.0", "ccd7c61c4126aa42d3cd9d93097642930f1b8a10c9c8351fc58bb2c627bad839", [:mix], [{:cldr_utils, "~> 2.21", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "5a56c66cd61ebde42277baa828cd1587959b781ac7e2aee135328a78a4de3fe9"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.15.0", "aadd34e91cfac7ef6b03fe8f47f8c6fa8c5daf3f89b5d9fee64ec545ded839cf", [:mix], [{:ex_cldr, "~> 2.34", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "0521316396c66877a2d636219767560bb2397c583341fcb154ecf9f3000e6ff8"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.30.1", "9acd7adb30079057ba606d73ffdaccb86020b07b734f98a229b5674032181668", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.35", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, ">= 2.14.2", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8a81b63d595a589e72c8b629653755c8b88edd6405a657eba236477f7a85939a"},
"ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"},
"ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"},
"ex_money": {:hex, :ex_money, "5.12.4", "2c9778ccac56eb16796dac4cebc02eb7d093639aed08c904444a8d9fa7e69e29", [:mix], [{:cldr_utils, "~> 2.21", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.27", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2b7977fb58af4f79bb7861741314b26b954b8e4d771471490ad68942fcd11619"},
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"},
"excoveralls": {:hex, :excoveralls, "0.14.4", "295498f1ae47bdc6dce59af9a585c381e1aefc63298d48172efaaa90c3d251db", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e3ab02f2df4c1c7a519728a6f0a747e71d7d6e846020aae338173619217931c1"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"},

View File

@ -1,7 +1,7 @@
defmodule Plausible.Site.CacheTest do
use Plausible.DataCase, async: true
alias Plausible.Site
alias Plausible.{Site, Goal}
alias Plausible.Site.Cache
import ExUnit.CaptureLog
@ -53,6 +53,31 @@ defmodule Plausible.Site.CacheTest do
refute Cache.get("site3.example.com", cache_name: test, force?: true)
end
test "cache caches revenue goals", %{test: test} do
{:ok, _} =
Supervisor.start_link([{Cache, [cache_name: test, child_id: :test_cache_caches_id]}],
strategy: :one_for_one,
name: Test.Supervisor.Cache
)
%{id: site_id} = site = insert(:site, domain: "site1.example.com")
insert(:goal, site: site, event_name: "Purchase", currency: :BRL)
insert(:goal, site: site, event_name: "Add to Cart", currency: :USD)
insert(:goal, site: site, event_name: "Click", currency: nil)
:ok = Cache.refresh_all(cache_name: test)
{:ok, _} = Plausible.Repo.delete(site)
assert %Site{from_cache?: true, id: ^site_id, revenue_goals: cached_goals} =
Cache.get("site1.example.com", force?: true, cache_name: test)
assert [
%Goal{event_name: "Add to Cart", currency: :USD},
%Goal{event_name: "Purchase", currency: :BRL}
] = Enum.sort_by(cached_goals, & &1.event_name)
end
test "cache is ready when no sites exist in the db", %{test: test} do
{:ok, _} = start_test_cache(test)
assert Cache.ready?(test)

View File

@ -658,6 +658,62 @@ defmodule PlausibleWeb.SiteControllerTest do
assert goal.page_path == nil
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
test "creates a custom event goal with a revenue value", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "",
event_name: "Purchase",
currency: "EUR"
}
})
goal = Repo.get_by(Plausible.Goal, site_id: site.id)
assert goal.event_name == "Purchase"
assert goal.page_path == nil
assert goal.currency == :EUR
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
test "fails to create a custom event goal with a non-existant currency", %{
conn: conn,
site: site
} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "",
event_name: "Purchase",
currency: "EEEE"
}
})
refute Repo.get_by(Plausible.Goal, site_id: site.id)
assert html_response(conn, 200) =~ "is invalid"
end
test "Cleans currency for pageview goal creation", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "/purchase",
event_name: "",
currency: "EUR"
}
})
goal = Repo.get_by(Plausible.Goal, site_id: site.id)
assert goal.event_name == nil
assert goal.page_path == "/purchase"
assert goal.currency == nil
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
end
describe "DELETE /:website/goals/:id" do

View File

@ -0,0 +1,20 @@
defmodule Plausible.ExchangeRateMock do
@moduledoc false
@behaviour Money.ExchangeRates
def init(config) do
config
end
def decode_rates(rates) do
Money.ExchangeRates.OpenExchangeRates.decode_rates(rates)
end
def get_latest_rates(_config) do
{:ok, %{BRL: Decimal.new("0.7"), EUR: Decimal.new("1.2"), USD: Decimal.new(1)}}
end
def get_historic_rates(_date, _config) do
{:ok, %{BRL: Decimal.new("0.8"), EUR: Decimal.new("1.3"), USD: Decimal.new(2)}}
end
end

View File

@ -1,5 +1,6 @@
{:ok, _} = Application.ensure_all_started(:ex_machina)
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
Application.ensure_all_started(:double)
FunWithFlags.enable(:revenue_goals)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
ExUnit.configure(exclude: [:slow])