diff --git a/fixture/paddle_prices_success_response.json b/fixture/paddle_prices_success_response.json new file mode 100644 index 0000000000..b32dfb8a91 --- /dev/null +++ b/fixture/paddle_prices_success_response.json @@ -0,0 +1,132 @@ +{ + "response": { + "customer_country": "ES", + "products": [ + { + "currency": "EUR", + "list_price": { + "gross": 7.26, + "net": 6.0, + "tax": 1.26 + }, + "price": { + "gross": 7.26, + "net": 6.0, + "tax": 1.26 + }, + "product_id": 19878, + "product_title": "kymme tuhat", + "subscription": { + "frequency": 1, + "interval": "month", + "list_price": { + "gross": 7.26, + "net": 6.0, + "tax": 1.26 + }, + "price": { + "gross": 7.26, + "net": 6.0, + "tax": 1.26 + }, + "trial_days": 0 + }, + "vendor_set_prices_included_tax": false + }, + { + "currency": "EUR", + "list_price": { + "gross": 72.6, + "net": 60.0, + "tax": 12.6 + }, + "price": { + "gross": 72.6, + "net": 60.0, + "tax": 12.6 + }, + "product_id": 20127, + "product_title": "kymme tuhat yearly", + "subscription": { + "frequency": 1, + "interval": "year", + "list_price": { + "gross": 72.6, + "net": 60.0, + "tax": 12.6 + }, + "price": { + "gross": 72.6, + "net": 60.0, + "tax": 12.6 + }, + "trial_days": 0 + }, + "vendor_set_prices_included_tax": false + }, + { + "currency": "EUR", + "list_price": { + "gross": 14.93, + "net": 12.34, + "tax": 2.59 + }, + "price": { + "gross": 14.93, + "net": 12.34, + "tax": 2.59 + }, + "product_id": 20657, + "product_title": "sadat tuhat", + "subscription": { + "frequency": 1, + "interval": "month", + "list_price": { + "gross": 14.93, + "net": 12.34, + "tax": 2.59 + }, + "price": { + "gross": 14.93, + "net": 12.34, + "tax": 2.59 + }, + "trial_days": 0 + }, + "vendor_set_prices_included_tax": false + }, + { + "currency": "EUR", + "list_price": { + "gross": 145.61, + "net": 120.34, + "tax": 25.27 + }, + "price": { + "gross": 145.61, + "net": 120.34, + "tax": 25.27 + }, + "product_id": 20658, + "product_title": "sada tuhat yearly", + "subscription": { + "frequency": 1, + "interval": "year", + "list_price": { + "gross": 145.61, + "net": 120.34, + "tax": 25.27 + }, + "price": { + "gross": 145.61, + "net": 120.34, + "tax": 25.27 + }, + "trial_days": 0 + }, + "vendor_set_prices_included_tax": false + } + ] + }, + "success": true +} \ No newline at end of file diff --git a/lib/mix/tasks/cancel_subscription.ex b/lib/mix/tasks/cancel_subscription.ex new file mode 100644 index 0000000000..e6390e2738 --- /dev/null +++ b/lib/mix/tasks/cancel_subscription.ex @@ -0,0 +1,23 @@ +defmodule Mix.Tasks.CancelSubscription do + @moduledoc """ + This task is meant to replicate the behavior of cancelling + a subscription. On production, this action is initiated by + a Paddle webhook. Currently, only the subscription status + is changed with that action. + """ + + use Mix.Task + use Plausible.Repo + alias Plausible.{Repo, Billing.Subscription} + require Logger + + def run([paddle_subscription_id]) do + Mix.Task.run("app.start") + + Repo.get_by!(Subscription, paddle_subscription_id: paddle_subscription_id) + |> Subscription.changeset(%{status: "deleted"}) + |> Repo.update!() + + Logger.info("Successfully set the subscription status to 'deleted'.") + end +end diff --git a/lib/plausible/billing/paddle_api.ex b/lib/plausible/billing/paddle_api.ex index 555dd917e6..b50699a675 100644 --- a/lib/plausible/billing/paddle_api.ex +++ b/lib/plausible/billing/paddle_api.ex @@ -120,6 +120,33 @@ 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 + {:ok, %{body: %{"success" => true, "response" => %{"products" => products}}}} -> + products = + Enum.into(products, %{}, fn %{ + "currency" => currency, + "price" => %{"net" => net_price}, + "product_id" => product_id + } -> + {Integer.to_string(product_id), Money.from_float!(currency, net_price)} + end) + + {:ok, products} + + {:ok, %{body: body}} -> + Sentry.capture_message("Paddle API: Unexpected response when fetching prices", + extra: %{api_response: body, product_ids: product_ids} + ) + + {:error, :api_error} + + {:error, %{reason: reason}} -> + Sentry.capture_message("Paddle API: Error when fetching prices", extra: %{reason: reason}) + {:error, :api_error} + end + end + def checkout_domain() do case Application.get_env(:plausible, :environment) do "dev" -> "https://sandbox-checkout.paddle.com" @@ -153,4 +180,8 @@ defmodule Plausible.Billing.PaddleApi do defp get_subscription_url() do Path.join(vendors_domain(), "/api/2.0/subscription/users") end + + defp prices_url() do + Path.join(checkout_domain(), "/api/2.0/prices") + end end diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex index 704a75ca16..d179d92e20 100644 --- a/lib/plausible/billing/plans.ex +++ b/lib/plausible/billing/plans.ex @@ -2,8 +2,9 @@ defmodule Plausible.Billing.Plan do @moduledoc false @derive Jason.Encoder - @enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a - defstruct @enforce_keys + + @enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit volume monthly_product_id yearly_product_id)a + defstruct @enforce_keys ++ [:monthly_cost, :yearly_cost] @type t() :: %__MODULE__{ @@ -12,28 +13,36 @@ defmodule Plausible.Billing.Plan do site_limit: non_neg_integer(), team_member_limit: non_neg_integer() | :unlimited, volume: String.t(), - monthly_cost: String.t() | nil, - yearly_cost: String.t() | nil, + monthly_cost: Money.t() | nil, + yearly_cost: Money.t() | nil, monthly_product_id: String.t() | nil, yearly_product_id: String.t() | nil } | :enterprise + + def new(params) when is_map(params) do + struct!(__MODULE__, params) + end end defmodule Plausible.Billing.Plans do + alias Plausible.Billing.Subscriptions use Plausible.Repo + alias Plausible.Billing.{Subscription, Plan, EnterprisePlan} + alias Plausible.Auth.User for f <- [ :plans_v1, :plans_v2, :plans_v3, + :plans_v4, :unlisted_plans_v1, :unlisted_plans_v2, :sandbox_plans ] do path = Application.app_dir(:plausible, ["priv", "#{f}.json"]) - contents = + plans_list = path |> File.read!() |> Jason.decode!(keys: :atoms!) @@ -51,34 +60,50 @@ defmodule Plausible.Billing.Plans do |> Map.put(:volume, volume) |> Map.put(:kind, String.to_atom(raw.kind)) |> Map.put(:team_member_limit, team_member_limit) - |> then(&struct!(Plausible.Billing.Plan, &1)) + |> Plan.new() end) - Module.put_attribute(__MODULE__, f, contents) + Module.put_attribute(__MODULE__, f, plans_list) + + # https://hexdocs.pm/elixir/1.15/Module.html#module-external_resource Module.put_attribute(__MODULE__, :external_resource, path) end - @spec for_user(Plausible.Auth.User.t()) :: [Plausible.Billing.Plan.t()] + @spec growth_plans_for(User.t()) :: [Plan.t()] @doc """ - Returns a list of plans available for the user to choose. + Returns a list of growth plans available for the user to choose. As new versions of plans are introduced, users who were on old plans can still choose from old plans. """ - def for_user(%Plausible.Auth.User{} = user) do + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def growth_plans_for(%User{} = user) do user = Plausible.Users.with_subscription(user) + v4_available = FunWithFlags.enabled?(:business_tier, for: user) + owned_plan_id = user.subscription && user.subscription.paddle_plan_id cond do - find(user.subscription, @plans_v1) -> @plans_v1 - find(user.subscription, @plans_v2) -> @plans_v2 - find(user.subscription, @plans_v3) -> @plans_v3 - find(user.subscription, plans_sandbox()) -> plans_sandbox() + find(owned_plan_id, @plans_v1) -> @plans_v1 + find(owned_plan_id, @plans_v2) -> @plans_v2 + find(owned_plan_id, @plans_v3) -> @plans_v3 + find(owned_plan_id, plans_sandbox()) -> plans_sandbox() Application.get_env(:plausible, :environment) == "dev" -> plans_sandbox() Timex.before?(user.inserted_at, ~D[2022-01-01]) -> @plans_v2 + v4_available -> Enum.filter(@plans_v4, &(&1.kind == :growth)) true -> @plans_v3 end end + def business_plans() do + Enum.filter(@plans_v4, &(&1.kind == :business)) + end + + def available_plans_with_prices(%User{} = user) do + (growth_plans_for(user) ++ business_plans()) + |> with_prices() + |> Enum.group_by(& &1.kind) + end + @spec yearly_product_ids() :: [String.t()] @doc """ List yearly plans product IDs. @@ -89,44 +114,32 @@ defmodule Plausible.Billing.Plans do do: yearly_product_id end - @spec find(String.t() | Plausible.Billing.Subscription.t(), [Plausible.Billing.Plan.t()]) :: - Plausible.Billing.Plan.t() | nil - @spec find(nil, any()) :: nil - @doc """ - Finds a plan by product ID. + defp find(product_id, scope \\ all()) - Returns nil when plan can't be found. - """ - def find(product_id_or_subscription, scope \\ all()) + defp find(nil, _scope), do: nil - def find(nil, _scope) do - nil - end - - def find(%Plausible.Billing.Subscription{} = subscription, scope) do - find(subscription.paddle_plan_id, scope) - end - - def find(product_id, scope) do + defp find(product_id, scope) do Enum.find(scope, fn plan -> product_id in [plan.monthly_product_id, plan.yearly_product_id] end) end + def get_subscription_plan(nil), do: nil + def get_subscription_plan(subscription) do - if subscription && subscription.paddle_plan_id == "free_10k" do + if subscription.paddle_plan_id == "free_10k" do :free_10k else - find(subscription) || get_enterprise_plan(subscription) + get_regular_plan(subscription) || get_enterprise_plan(subscription) end end def subscription_interval(subscription) do case get_subscription_plan(subscription) do - %Plausible.Billing.EnterprisePlan{billing_interval: interval} -> + %EnterprisePlan{billing_interval: interval} -> interval - %Plausible.Billing.Plan{} = plan -> + %Plan{} = plan -> if plan.monthly_product_id == subscription.paddle_plan_id do "monthly" else @@ -138,17 +151,58 @@ defmodule Plausible.Billing.Plans do end end - defp get_enterprise_plan(nil), do: nil + @doc """ + This function takes a list of plans as an argument, gathers all product + IDs in a single list, and makes an API call to Paddle. After a successful + 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 + product_ids = Enum.flat_map(plans, &[&1.monthly_product_id, &1.yearly_product_id]) - defp get_enterprise_plan(%Plausible.Billing.Subscription{} = subscription) do - Repo.get_by(Plausible.Billing.EnterprisePlan, + case Plausible.Billing.paddle_api().fetch_prices(product_ids) do + {:ok, prices} -> + Enum.map(plans, fn plan -> + plan + |> Map.put(:monthly_cost, prices[plan.monthly_product_id]) + |> Map.put(:yearly_cost, prices[plan.yearly_product_id]) + end) + + {:error, :api_error} -> + plans + end + end + + def get_regular_plan(subscription, opts \\ []) + + def get_regular_plan(nil, _opts), do: nil + + def get_regular_plan(%Subscription{} = subscription, opts) do + if Keyword.get(opts, :only_non_expired) && Subscriptions.expired?(subscription) do + nil + else + find(subscription.paddle_plan_id) + end + end + + defp get_enterprise_plan(%Subscription{} = subscription) do + Repo.get_by(EnterprisePlan, user_id: subscription.user_id, paddle_plan_id: subscription.paddle_plan_id ) end + def business_tier?(nil), do: false + + def business_tier?(%Subscription{} = subscription) do + case get_subscription_plan(subscription) do + %Plan{kind: :business} -> true + _ -> false + end + end + @enterprise_level_usage 10_000_000 - @spec suggest(Plausible.Auth.User.t(), non_neg_integer()) :: Plausible.Billing.Plan.t() + @spec suggest(User.t(), non_neg_integer()) :: Plan.t() @doc """ Returns the most appropriate plan for a user based on their usage during a given cycle. @@ -164,13 +218,25 @@ defmodule Plausible.Billing.Plans do cond do usage_during_cycle > @enterprise_level_usage -> :enterprise Plausible.Auth.enterprise?(user) -> :enterprise - true -> Enum.find(for_user(user), &(usage_during_cycle < &1.monthly_pageview_limit)) + true -> suggest_by_usage(user, usage_during_cycle) end end + defp suggest_by_usage(user, usage_during_cycle) do + user = Plausible.Users.with_subscription(user) + + available_plans = + if business_tier?(user.subscription), + do: business_plans(), + else: growth_plans_for(user) + + Enum.find(available_plans, &(usage_during_cycle < &1.monthly_pageview_limit)) + end + defp all() do @plans_v1 ++ - @unlisted_plans_v1 ++ @plans_v2 ++ @unlisted_plans_v2 ++ @plans_v3 ++ plans_sandbox() + @unlisted_plans_v1 ++ + @plans_v2 ++ @unlisted_plans_v2 ++ @plans_v3 ++ @plans_v4 ++ plans_sandbox() end defp plans_sandbox() do diff --git a/lib/plausible/billing/quota.ex b/lib/plausible/billing/quota.ex index 481140157f..eda48353f9 100644 --- a/lib/plausible/billing/quota.ex +++ b/lib/plausible/billing/quota.ex @@ -75,7 +75,8 @@ defmodule Plausible.Billing.Quota do @spec monthly_pageview_usage(Plausible.Auth.User.t()) :: non_neg_integer() @doc """ - Returns the amount of pageviews sent by the sites the user owns in last 30 days. + Returns the amount of pageviews and custom events + sent by the sites the user owns in last 30 days. """ def monthly_pageview_usage(user) do user diff --git a/lib/plausible/billing/subscription.ex b/lib/plausible/billing/subscription.ex index 148e00daa4..d5f4927ad4 100644 --- a/lib/plausible/billing/subscription.ex +++ b/lib/plausible/billing/subscription.ex @@ -1,8 +1,39 @@ defmodule Plausible.Billing.Subscription do - @moduledoc false use Ecto.Schema import Ecto.Changeset + @moduledoc """ + The subscription statuses are stored in Paddle. They can only be changed + through Paddle webhooks, which always send the current subscription status + via the payload. + + * `active` - All good with the payments. Can access stats. + + * `past_due` - The payment has failed, but we're trying to charge the customer + again. Access to stats is still granted. There will be three retries - after + 3, 5, and 7 days have passed from the first failure. After a failure on the + final retry, the subscription status will change to `paused`. As soon as the + customer updates their billing details, Paddle will charge them again, and + after a successful payment, the subscription will become `active` again. + + * `paused` - we've tried to charge the customer but all the retries have failed. + Stats access restricted. As soon as the customer updates their billing details, + Paddle will charge them again, and after a successful payment, the subscription + will become `active` again. + + * `deleted` - The customer has triggered the cancel subscription action. Access + to stats should be granted for the time the customer has already paid for. If + they want to upgrade again, new billing details have to be provided. + + Paddle documentation links for reference: + + * Subscription statuses - + https://developer.paddle.com/classic/reference/zg9joji1mzu0mdi2-subscription-status-reference + + * Payment failures - + https://developer.paddle.com/classic/guides/zg9joji1mzu0mduy-payment-failures + """ + @type t() :: %__MODULE__{} @required_fields [ @@ -18,6 +49,7 @@ defmodule Plausible.Billing.Subscription do ] @optional_fields [:last_bill_date] + @valid_statuses ["active", "past_due", "deleted", "paused"] schema "subscriptions" do diff --git a/lib/plausible/billing/subscriptions.ex b/lib/plausible/billing/subscriptions.ex new file mode 100644 index 0000000000..f98b635b33 --- /dev/null +++ b/lib/plausible/billing/subscriptions.ex @@ -0,0 +1,22 @@ +defmodule Plausible.Billing.Subscriptions do + @moduledoc false + + alias Plausible.Billing.{Subscription} + + @spec expired?(Subscription.t()) :: boolean() + @doc """ + Returns whether the given subscription is expired. That means that the + subscription status is `deleted` and the date until which the customer + has paid for (i.e. `next_bill_date`) has passed. + """ + def expired?(subscription) + + def expired?(%Subscription{paddle_plan_id: "free_10k"}), do: false + + def expired?(%Subscription{status: status, next_bill_date: next_bill_date}) do + cancelled? = status == "deleted" + expired? = Timex.compare(next_bill_date, Timex.today()) < 0 + + cancelled? && expired? + end +end diff --git a/lib/plausible/http_client.ex b/lib/plausible/http_client.ex index d3ab8a71c4..f79bb5106f 100644 --- a/lib/plausible/http_client.ex +++ b/lib/plausible/http_client.ex @@ -19,6 +19,7 @@ defmodule Plausible.HTTPClient.Interface do {:ok, Finch.Response.t()} | {:error, Mint.Types.error() | Finch.Error.t() | Plausible.HTTPClient.Non200Error.t()} + @callback get(url(), headers(), params()) :: response() @callback get(url(), headers()) :: response() @callback get(url()) :: response() @callback post(url(), headers(), params()) :: response() @@ -53,8 +54,8 @@ defmodule Plausible.HTTPClient do Make a GET request """ @impl Plausible.HTTPClient.Interface - def get(url, headers \\ []) do - call(:get, url, headers, nil) + def get(url, headers \\ [], params \\ nil) do + call(:get, url, headers, params) end # TODO: Is it possible to tell the type checker that we're returning a module that conforms to the diff --git a/lib/plausible_web/components/billing.ex b/lib/plausible_web/components/billing.ex index b97a9d4a0a..6e4f55bfef 100644 --- a/lib/plausible_web/components/billing.ex +++ b/lib/plausible_web/components/billing.ex @@ -2,6 +2,9 @@ defmodule PlausibleWeb.Components.Billing do @moduledoc false use Phoenix.Component + import PlausibleWeb.Components.Generic + alias PlausibleWeb.Router.Helpers, as: Routes + alias Plausible.Billing.Subscription slot(:inner_block, required: true) attr(:rest, :global) @@ -25,8 +28,10 @@ defmodule PlausibleWeb.Components.Billing do def usage_and_limits_row(assigns) do ~H"""
+ <%= if @owned_plan, + do: "Change subscription plan", + else: "Upgrade your account" %> +
++ <.usage usage={@usage} /> +
+ <.pageview_limit_notice :if={!@owned_plan} /> + <.help_links /> ++ + Custom + +
+ + """ + end + + def render_price_info(assigns) do + ~H""" ++ <.price_tag + kind={@kind} + selected_interval={@selected_interval} + plan_to_render={@plan_to_render} + /> +
++ VAT if applicable
+ """ + end + + defp render_change_plan_link(assigns) do + ~H""" + <.change_plan_link + plan_already_owned={@text == "Currently on this plan"} + billing_details_expired={ + @user.subscription && @user.subscription.status in ["past_due", "paused"] + } + {assigns} + /> + """ + end + + defp change_plan_link(assigns) do + ~H""" + <.link + id={"#{@kind}-checkout"} + href={"/billing/change-plan/preview/" <> @paddle_product_id} + class={[ + "w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white", + !(@plan_already_owned || @billing_details_expired) && "bg-indigo-600 hover:bg-indigo-500", + (@plan_already_owned || @billing_details_expired) && + "pointer-events-none bg-gray-400 dark:bg-gray-600" + ]} + > + <%= @text %> + ++ Please update your billing details first +
+ """ + end + + defp paddle_button(assigns) do + ~H""" + + """ + end + + defp contact_button(assigns) do + ~H""" + <.link + href={contact_link()} + class={[ + "mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 bg-gray-800 hover:bg-gray-700 text-white dark:bg-indigo-600 dark:hover:bg-indigo-500", + @class + ]} + > + Contact us + + """ + end + + defp enterprise_plan_box(assigns) do + ~H""" ++ + Custom + +
+ + <.contact_button class="" /> ++ Current +
++ + What happens if I go over my page views limit? + +
+