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:
hq1 2024-06-14 14:49:22 +02:00 committed by GitHub
parent 6a511ec8a6
commit 86d7031336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 200 additions and 45 deletions

View File

@ -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)

View File

@ -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 %{

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)}
) %>

View File

@ -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"),

View File

@ -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")

View File

@ -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

View File

@ -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 =