mirror of
https://github.com/plausible/analytics.git
synced 2024-08-16 13:20:59 +03:00
Unify default pricing currency selection (#4221)
* Start cache meant to store customer currencies * Expose caching fetch_or_store interface * Improve IP picking strategy - skip empty header values * Use customer IP in determining pricing currency * Expose /api/paddle/currency API * Remove cache-control header * Tidy up
This commit is contained in:
parent
6a511ec8a6
commit
86d7031336
@ -24,6 +24,10 @@ defmodule Plausible.Application do
|
||||
Supervisor.child_spec(Plausible.Event.WriteBuffer, id: Plausible.Event.WriteBuffer),
|
||||
Supervisor.child_spec(Plausible.Session.WriteBuffer, id: Plausible.Session.WriteBuffer),
|
||||
ReferrerBlocklist,
|
||||
Plausible.Cache.Adapter.child_spec(:customer_currency, :cache_customer_currency,
|
||||
ttl_check_interval: :timer.minutes(5),
|
||||
global_ttl: :timer.minutes(60)
|
||||
),
|
||||
Plausible.Cache.Adapter.child_spec(:user_agents, :cache_user_agents,
|
||||
ttl_check_interval: :timer.seconds(5),
|
||||
global_ttl: :timer.minutes(60)
|
||||
|
@ -119,8 +119,10 @@ defmodule Plausible.Billing.PaddleApi do
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_prices([_ | _] = product_ids) do
|
||||
case HTTPClient.impl().get(prices_url(), @headers, %{product_ids: Enum.join(product_ids, ",")}) do
|
||||
def fetch_prices([_ | _] = product_ids, customer_ip) do
|
||||
params = %{product_ids: Enum.join(product_ids, ","), customer_ip: customer_ip}
|
||||
|
||||
case HTTPClient.impl().get(prices_url(), @headers, params) do
|
||||
{:ok, %{body: %{"success" => true, "response" => %{"products" => products}}}} ->
|
||||
products =
|
||||
Enum.into(products, %{}, fn %{
|
||||
|
@ -71,7 +71,8 @@ defmodule Plausible.Billing.Plans do
|
||||
|
||||
plans =
|
||||
if Keyword.get(opts, :with_prices) do
|
||||
with_prices(plans)
|
||||
customer_ip = Keyword.fetch!(opts, :customer_ip)
|
||||
with_prices(plans, customer_ip)
|
||||
else
|
||||
plans
|
||||
end
|
||||
@ -107,7 +108,7 @@ defmodule Plausible.Billing.Plans do
|
||||
end
|
||||
end
|
||||
|
||||
def latest_enterprise_plan_with_price(user) do
|
||||
def latest_enterprise_plan_with_price(user, customer_ip) do
|
||||
enterprise_plan =
|
||||
Repo.one!(
|
||||
from(e in EnterprisePlan,
|
||||
@ -117,7 +118,7 @@ defmodule Plausible.Billing.Plans do
|
||||
)
|
||||
)
|
||||
|
||||
{enterprise_plan, get_price_for(enterprise_plan)}
|
||||
{enterprise_plan, get_price_for(enterprise_plan, customer_ip)}
|
||||
end
|
||||
|
||||
def subscription_interval(subscription) do
|
||||
@ -143,10 +144,10 @@ defmodule Plausible.Billing.Plans do
|
||||
response, fills in the `monthly_cost` and `yearly_cost` fields for each
|
||||
given plan and returns the new list of plans with completed information.
|
||||
"""
|
||||
def with_prices([_ | _] = plans) do
|
||||
def with_prices([_ | _] = plans, customer_ip) do
|
||||
product_ids = Enum.flat_map(plans, &[&1.monthly_product_id, &1.yearly_product_id])
|
||||
|
||||
case Plausible.Billing.paddle_api().fetch_prices(product_ids) do
|
||||
case Plausible.Billing.paddle_api().fetch_prices(product_ids, customer_ip) do
|
||||
{:ok, prices} ->
|
||||
Enum.map(plans, fn plan ->
|
||||
plan
|
||||
@ -171,8 +172,8 @@ defmodule Plausible.Billing.Plans do
|
||||
end
|
||||
end
|
||||
|
||||
def get_price_for(%EnterprisePlan{paddle_plan_id: product_id}) do
|
||||
case Plausible.Billing.paddle_api().fetch_prices([product_id]) do
|
||||
def get_price_for(%EnterprisePlan{paddle_plan_id: product_id}, customer_ip) do
|
||||
case Plausible.Billing.paddle_api().fetch_prices([product_id], customer_ip) do
|
||||
{:ok, prices} -> Map.fetch!(prices, product_id)
|
||||
{:error, :api_error} -> nil
|
||||
end
|
||||
|
9
lib/plausible/cache/adapter.ex
vendored
9
lib/plausible/cache/adapter.ex
vendored
@ -52,6 +52,15 @@ defmodule Plausible.Cache.Adapter do
|
||||
nil
|
||||
end
|
||||
|
||||
@spec fetch(atom(), any(), (-> any())) :: any()
|
||||
def fetch(cache_name, key, fallback_fn) do
|
||||
ConCache.fetch_or_store(cache_name, key, fallback_fn)
|
||||
catch
|
||||
:exit, _ ->
|
||||
Logger.error("Error fetching key from '#{inspect(cache_name)}'")
|
||||
nil
|
||||
end
|
||||
|
||||
@spec put(atom(), any(), any()) :: any()
|
||||
def put(cache_name, key, value) do
|
||||
:ok = ConCache.put(cache_name, key, value)
|
||||
|
@ -85,7 +85,7 @@ defmodule Plausible.Release do
|
||||
|
||||
plans =
|
||||
Plausible.Billing.Plans.all()
|
||||
|> Plausible.Billing.Plans.with_prices()
|
||||
|> Plausible.Billing.Plans.with_prices("127.0.0.1")
|
||||
|> Enum.map(fn plan ->
|
||||
plan = Map.from_struct(plan)
|
||||
|
||||
|
@ -3,7 +3,7 @@ defmodule PlausibleWeb.Api.PaddleController do
|
||||
use Plausible.Repo
|
||||
require Logger
|
||||
|
||||
plug :verify_signature
|
||||
plug :verify_signature when action in [:webhook]
|
||||
|
||||
def webhook(conn, %{"alert_name" => "subscription_created"} = params) do
|
||||
Plausible.Billing.subscription_created(params)
|
||||
@ -29,6 +29,42 @@ defmodule PlausibleWeb.Api.PaddleController do
|
||||
send_resp(conn, 404, "") |> halt
|
||||
end
|
||||
|
||||
@default_currency_fallback :EUR
|
||||
|
||||
def currency(conn, _params) do
|
||||
plan_id = get_currency_reference_plan_id()
|
||||
customer_ip = PlausibleWeb.RemoteIP.get(conn)
|
||||
|
||||
result =
|
||||
Plausible.Cache.Adapter.fetch(:customer_currency, {plan_id, customer_ip}, fn ->
|
||||
case Plausible.Billing.PaddleApi.fetch_prices([plan_id], customer_ip) do
|
||||
{:ok, %{^plan_id => money}} ->
|
||||
{:ok, money.currency}
|
||||
|
||||
error ->
|
||||
Sentry.capture_message("Failed to fetch currency reference plan",
|
||||
extra: %{error: inspect(error)}
|
||||
)
|
||||
|
||||
{:error, :fetch_prices_failed}
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{:ok, currency} ->
|
||||
conn
|
||||
|> put_status(200)
|
||||
|> json(%{currency: Cldr.Currency.currency_for_code!(currency).narrow_symbol})
|
||||
|
||||
{:error, :fetch_prices_failed} ->
|
||||
conn
|
||||
|> put_status(200)
|
||||
|> json(%{
|
||||
currency: Cldr.Currency.currency_for_code!(@default_currency_fallback).narrow_symbol
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def verify_signature(conn, _opts) do
|
||||
signature = Base.decode64!(conn.params["p_signature"])
|
||||
|
||||
@ -49,18 +85,14 @@ defmodule PlausibleWeb.Api.PaddleController do
|
||||
end
|
||||
end
|
||||
|
||||
def verified_signature?(params) do
|
||||
signature = Base.decode64!(params["p_signature"])
|
||||
|
||||
msg =
|
||||
Map.delete(params, "p_signature")
|
||||
|> Enum.map(fn {key, val} -> {key, "#{val}"} end)
|
||||
|> List.keysort(0)
|
||||
|> PhpSerializer.serialize()
|
||||
|
||||
[key_entry] = :public_key.pem_decode(get_paddle_key())
|
||||
public_key = :public_key.pem_entry_decode(key_entry)
|
||||
:public_key.verify(msg, :sha, signature, public_key)
|
||||
@paddle_currency_reference_plan_id "857097"
|
||||
@paddle_sandbox_currency_reference_plan_id "63842"
|
||||
defp get_currency_reference_plan_id() do
|
||||
if Application.get_env(:plausible, :environment) in ["dev", "staging"] do
|
||||
@paddle_sandbox_currency_reference_plan_id
|
||||
else
|
||||
@paddle_currency_reference_plan_id
|
||||
end
|
||||
end
|
||||
|
||||
@paddle_prod_key File.read!("priv/paddle.pem")
|
||||
|
@ -31,7 +31,8 @@ defmodule PlausibleWeb.BillingController do
|
||||
def upgrade_to_enterprise_plan(conn, _params) do
|
||||
user = Plausible.Users.with_subscription(conn.assigns.current_user)
|
||||
|
||||
{latest_enterprise_plan, price} = Plans.latest_enterprise_plan_with_price(user)
|
||||
{latest_enterprise_plan, price} =
|
||||
Plans.latest_enterprise_plan_with_price(user, PlausibleWeb.RemoteIP.get(conn))
|
||||
|
||||
subscription_resumable? = Plausible.Billing.Subscriptions.resumable?(user.subscription)
|
||||
|
||||
|
@ -15,7 +15,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
@contact_link "https://plausible.io/contact"
|
||||
@billing_faq_link "https://plausible.io/docs/billing"
|
||||
|
||||
def mount(_params, %{"current_user_id" => user_id}, socket) do
|
||||
def mount(_params, %{"current_user_id" => user_id, "remote_ip" => remote_ip}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign_new(:user, fn ->
|
||||
@ -57,7 +57,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
current_user_subscription_interval(user.subscription)
|
||||
end)
|
||||
|> assign_new(:available_plans, fn %{user: user} ->
|
||||
Plans.available_plans_for(user, with_prices: true)
|
||||
Plans.available_plans_for(user, with_prices: true, customer_ip: remote_ip)
|
||||
end)
|
||||
|> assign_new(:available_volumes, fn %{available_plans: available_plans} ->
|
||||
get_available_volumes(available_plans)
|
||||
|
@ -4,26 +4,26 @@ defmodule PlausibleWeb.RemoteIP do
|
||||
"""
|
||||
|
||||
def get(conn) do
|
||||
x_plausible_ip = List.first(Plug.Conn.get_req_header(conn, "x-plausible-ip"))
|
||||
cf_connecting_ip = List.first(Plug.Conn.get_req_header(conn, "cf-connecting-ip"))
|
||||
x_forwarded_for = List.first(Plug.Conn.get_req_header(conn, "x-forwarded-for"))
|
||||
b_forwarded_for = List.first(Plug.Conn.get_req_header(conn, "b-forwarded-for"))
|
||||
forwarded = List.first(Plug.Conn.get_req_header(conn, "forwarded"))
|
||||
x_plausible_ip = List.first(Plug.Conn.get_req_header(conn, "x-plausible-ip")) || ""
|
||||
cf_connecting_ip = List.first(Plug.Conn.get_req_header(conn, "cf-connecting-ip")) || ""
|
||||
x_forwarded_for = List.first(Plug.Conn.get_req_header(conn, "x-forwarded-for")) || ""
|
||||
b_forwarded_for = List.first(Plug.Conn.get_req_header(conn, "b-forwarded-for")) || ""
|
||||
forwarded = List.first(Plug.Conn.get_req_header(conn, "forwarded")) || ""
|
||||
|
||||
cond do
|
||||
x_plausible_ip ->
|
||||
byte_size(x_plausible_ip) > 0 ->
|
||||
clean_ip(x_plausible_ip)
|
||||
|
||||
cf_connecting_ip ->
|
||||
byte_size(cf_connecting_ip) > 0 ->
|
||||
clean_ip(cf_connecting_ip)
|
||||
|
||||
b_forwarded_for ->
|
||||
byte_size(b_forwarded_for) > 0 ->
|
||||
parse_forwarded_for(b_forwarded_for)
|
||||
|
||||
x_forwarded_for ->
|
||||
byte_size(x_forwarded_for) > 0 ->
|
||||
parse_forwarded_for(x_forwarded_for)
|
||||
|
||||
forwarded ->
|
||||
byte_size(forwarded) > 0 ->
|
||||
Regex.named_captures(~r/for=(?<for>[^;,]+).*$/, forwarded)
|
||||
|> Map.get("for")
|
||||
# IPv6 addresses are enclosed in quote marks and square brackets: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
|
||||
|
@ -197,6 +197,7 @@ defmodule PlausibleWeb.Router do
|
||||
get "/system", Api.ExternalController, :info
|
||||
|
||||
post "/paddle/webhook", Api.PaddleController, :webhook
|
||||
get "/paddle/currency", Api.PaddleController, :currency
|
||||
|
||||
get "/:domain/status", Api.InternalController, :domain_status
|
||||
put "/:domain/disable-feature", Api.InternalController, :disable_feature
|
||||
|
@ -1,4 +1,4 @@
|
||||
<%= live_render(@conn, PlausibleWeb.Live.ChoosePlan,
|
||||
id: "choose-plan",
|
||||
session: %{"current_user_id" => @user.id}
|
||||
session: %{"current_user_id" => @user.id, "remote_ip" => PlausibleWeb.RemoteIP.get(@conn)}
|
||||
) %>
|
||||
|
@ -23,7 +23,10 @@ defmodule Plausible.Billing.PaddleApiTest do
|
||||
end
|
||||
)
|
||||
|
||||
assert Plausible.Billing.PaddleApi.fetch_prices(["19878", "20127", "20657", "20658"]) ==
|
||||
assert Plausible.Billing.PaddleApi.fetch_prices(
|
||||
["19878", "20127", "20657", "20658"],
|
||||
"127.0.0.1"
|
||||
) ==
|
||||
{:ok,
|
||||
%{
|
||||
"19878" => Money.new(:EUR, "6.0"),
|
||||
|
@ -123,7 +123,7 @@ defmodule Plausible.Billing.PlansTest do
|
||||
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
|
||||
|
||||
%{growth: growth_plans, business: business_plans} =
|
||||
Plans.available_plans_for(user, with_prices: true)
|
||||
Plans.available_plans_for(user, with_prices: true, customer_ip: "127.0.0.1")
|
||||
|
||||
assert Enum.find(growth_plans, fn plan ->
|
||||
(%Money{} = plan.monthly_cost) && plan.monthly_product_id == @v2_plan_id
|
||||
@ -156,7 +156,7 @@ defmodule Plausible.Billing.PlansTest do
|
||||
inserted_at: Timex.shift(Timex.now(), minutes: -2)
|
||||
)
|
||||
|
||||
{enterprise_plan, price} = Plans.latest_enterprise_plan_with_price(user)
|
||||
{enterprise_plan, price} = Plans.latest_enterprise_plan_with_price(user, "127.0.0.1")
|
||||
|
||||
assert enterprise_plan.paddle_plan_id == "123"
|
||||
assert price == Money.new(:EUR, "10.0")
|
||||
|
@ -2,7 +2,10 @@ defmodule PlausibleWeb.Api.PaddleControllerTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
use Plausible.Repo
|
||||
|
||||
@body %{
|
||||
import Mox
|
||||
setup :verify_on_exit!
|
||||
|
||||
@webhook_body %{
|
||||
"alert_id" => "16173800",
|
||||
"alert_name" => "subscription_created",
|
||||
"cancel_url" =>
|
||||
@ -29,15 +32,114 @@ defmodule PlausibleWeb.Api.PaddleControllerTest do
|
||||
describe "webhook verification" do
|
||||
test "is verified when signature is correct", %{conn: conn} do
|
||||
insert(:user, id: 235)
|
||||
conn = post(conn, "/api/paddle/webhook", @body)
|
||||
conn = post(conn, Routes.paddle_path(conn, :webhook), @webhook_body)
|
||||
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
test "not verified when signature is corrupted", %{conn: conn} do
|
||||
corrupted = Map.put(@body, "p_signature", Base.encode64("123 fake signature"))
|
||||
conn = post(conn, "/api/paddle/webhook", corrupted)
|
||||
corrupted = Map.put(@webhook_body, "p_signature", Base.encode64("123 fake signature"))
|
||||
conn = post(conn, Routes.paddle_path(conn, :webhook), corrupted)
|
||||
assert conn.status == 400
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetching currency" do
|
||||
test "retrieves successfully", %{conn: conn} do
|
||||
expect_get_prices_response(get_prices_body("USD"))
|
||||
|
||||
conn = get(conn, Routes.paddle_path(conn, :currency))
|
||||
assert_receive :paddle_queried
|
||||
assert json_response(conn, 200) == %{"currency" => "$"}
|
||||
end
|
||||
|
||||
test "caches per ip", %{conn: initial_conn} do
|
||||
expect_get_prices_response(get_prices_body("USD"))
|
||||
|
||||
conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency))
|
||||
assert json_response(conn, 200) == %{"currency" => "$"}
|
||||
assert_receive :paddle_queried
|
||||
|
||||
expect_get_prices_response(get_prices_body("GBP"))
|
||||
|
||||
conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency))
|
||||
assert json_response(conn, 200) == %{"currency" => "$"}
|
||||
refute_receive :paddle_queried
|
||||
|
||||
new_ip =
|
||||
Plug.Conn.put_req_header(initial_conn, "x-forwarded-for", Plausible.TestUtils.random_ip())
|
||||
|
||||
conn = get(new_ip, Routes.paddle_path(initial_conn, :currency))
|
||||
assert json_response(conn, 200) == %{"currency" => "£"}
|
||||
assert_receive :paddle_queried
|
||||
end
|
||||
|
||||
test "falls back to EUR when paddle fails to respond", %{conn: conn} do
|
||||
expect_get_prices_response(%{"response" => %{}})
|
||||
|
||||
conn = get(conn, Routes.paddle_path(conn, :currency))
|
||||
assert_receive :paddle_queried
|
||||
assert json_response(conn, 200) == %{"currency" => "€"}
|
||||
end
|
||||
|
||||
test "does not cache failed fetches", %{conn: initial_conn} do
|
||||
expect_get_prices_response(%{"response" => %{}})
|
||||
|
||||
conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency))
|
||||
assert json_response(conn, 200) == %{"currency" => "€"}
|
||||
|
||||
expect_get_prices_response(get_prices_body("USD"))
|
||||
|
||||
conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency))
|
||||
assert json_response(conn, 200) == %{"currency" => "$"}
|
||||
end
|
||||
|
||||
defp expect_get_prices_response(body) do
|
||||
test = self()
|
||||
|
||||
expect(
|
||||
Plausible.HTTPClient.Mock,
|
||||
:get,
|
||||
fn "https://checkout.paddle.com/api/2.0/prices",
|
||||
_,
|
||||
%{customer_ip: _customer_ip, product_ids: "857097"} ->
|
||||
send(test, :paddle_queried)
|
||||
|
||||
{:ok,
|
||||
%Finch.Response{
|
||||
status: 200,
|
||||
headers: [{"content-type", "application/json"}],
|
||||
body: body
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp get_prices_body(currency) do
|
||||
%{
|
||||
"response" => %{
|
||||
"customer_country" => "PL",
|
||||
"products" => [
|
||||
%{
|
||||
"applied_coupon" => [],
|
||||
"currency" => currency,
|
||||
"list_price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0},
|
||||
"price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0},
|
||||
"product_id" => 857_097,
|
||||
"product_title" => "random",
|
||||
"subscription" => %{
|
||||
"frequency" => 1,
|
||||
"interval" => "month",
|
||||
"list_price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0},
|
||||
"price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0},
|
||||
"trial_days" => 0
|
||||
},
|
||||
"vendor_set_prices_included_tax" => false
|
||||
}
|
||||
]
|
||||
},
|
||||
"success" => true
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -75,7 +75,7 @@ defmodule Plausible.PaddleApi.Mock do
|
||||
# to give a reasonable testing structure for monthly and yearly plan
|
||||
# prices, this function returns prices with the following logic:
|
||||
# 10, 100, 20, 200, 30, 300, ...and so on.
|
||||
def fetch_prices([_ | _] = product_ids) do
|
||||
def fetch_prices([_ | _] = product_ids, _customer_ip) do
|
||||
{prices, _index} =
|
||||
Enum.reduce(product_ids, {%{}, 1}, fn p, {acc, i} ->
|
||||
price =
|
||||
|
Loading…
Reference in New Issue
Block a user