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""" - <%= @title %> - + + <%= @title %> + + <%= render_quota(@usage) %> <%= if @limit, do: "/ #{render_quota(@limit)}" %> @@ -41,4 +46,149 @@ defmodule PlausibleWeb.Components.Billing do nil -> "" end end + + def monthly_quota_box(%{business_tier: true} = assigns) do + ~H""" +
+

Monthly quota

+
+ <%= PlausibleWeb.AuthView.subscription_quota(@subscription, format: :long) %> +
+ <.styled_link href={Routes.billing_path(@conn, :choose_plan)} class="text-sm font-medium"> + <%= upgrade_link_text(@subscription) %> + +
+ """ + end + + def monthly_quota_box(%{business_tier: false} = assigns) do + ~H""" +
+

Monthly quota

+ <%= if @subscription do %> +
+ <%= PlausibleWeb.AuthView.subscription_quota(@subscription) %> pageviews +
+ + <.styled_link + :if={@subscription.status == "active"} + href={Routes.billing_path(@conn, :change_plan_form)} + class="text-sm font-medium" + > + Change plan + + + + Change plan + + <% else %> +
Free trial
+ <.styled_link href={Routes.billing_path(@conn, :upgrade)} class="text-sm font-medium"> + Upgrade + + <% end %> +
+ """ + end + + def subscription_past_due_notice(%{subscription: %Subscription{status: "past_due"}} = assigns) do + ~H""" + + """ + end + + def subscription_past_due_notice(assigns), do: ~H"" + + def subscription_paused_notice(%{subscription: %Subscription{status: "paused"}} = assigns) do + ~H""" + + """ + end + + def subscription_paused_notice(assigns), do: ~H"" + + def format_price(%Money{} = money) do + money + |> Money.to_string!(format: :short, fractional_digits: 2) + |> String.replace(".00", "") + end + + defp upgrade_link_text(nil), do: "Upgrade" + defp upgrade_link_text(_subscription), do: "Change plan" end diff --git a/lib/plausible_web/controllers/billing_controller.ex b/lib/plausible_web/controllers/billing_controller.ex index abbc450a5e..42eefc2ad4 100644 --- a/lib/plausible_web/controllers/billing_controller.ex +++ b/lib/plausible_web/controllers/billing_controller.ex @@ -35,6 +35,21 @@ defmodule PlausibleWeb.BillingController do end end + def choose_plan(conn, _params) do + user = conn.assigns[:current_user] + + if FunWithFlags.enabled?(:business_tier, for: user) do + render(conn, "choose_plan.html", + skip_plausible_tracking: true, + user: user, + layout: {PlausibleWeb.LayoutView, "focus.html"}, + connect_live_socket: true + ) + else + render_error(conn, 404) + end + end + def upgrade_enterprise_plan(conn, %{"plan_id" => plan_id}) do user = conn.assigns[:current_user] subscription = user.subscription diff --git a/lib/plausible_web/live/choose_plan.ex b/lib/plausible_web/live/choose_plan.ex new file mode 100644 index 0000000000..d7c0ee1be6 --- /dev/null +++ b/lib/plausible_web/live/choose_plan.ex @@ -0,0 +1,648 @@ +defmodule PlausibleWeb.Live.ChoosePlan do + @moduledoc """ + LiveView for upgrading to a plan, or changing an existing plan. + """ + use Phoenix.LiveView + use Phoenix.HTML + alias Plausible.Users + alias Plausible.Billing.{Plans, Plan, Quota} + alias PlausibleWeb.Router.Helpers, as: Routes + + import PlausibleWeb.Components.Billing + + @contact_link "https://plausible.io/contact" + @billing_faq_link "https://plausible.io/docs/billing" + + def mount(_params, %{"user_id" => user_id}, socket) do + socket = + socket + |> assign_new(:user, fn -> + Users.with_subscription(user_id) + end) + |> assign_new(:usage, fn %{user: user} -> + Quota.monthly_pageview_usage(user) + end) + |> assign_new(:owned_plan, fn %{user: %{subscription: subscription}} -> + Plans.get_regular_plan(subscription, only_non_expired: true) + end) + |> assign_new(:current_interval, fn %{user: user} -> + current_user_subscription_interval(user.subscription) + end) + |> assign_new(:available_plans, fn %{user: user} -> + Plans.available_plans_with_prices(user) + end) + |> assign_new(:available_volumes, fn %{available_plans: available_plans} -> + get_available_volumes(available_plans) + end) + |> assign_new(:selected_volume, fn %{ + owned_plan: owned_plan, + usage: usage, + available_volumes: available_volumes + } -> + default_selected_volume(owned_plan, usage, available_volumes) + end) + |> assign_new(:selected_interval, fn %{current_interval: current_interval} -> + current_interval || :monthly + end) + |> assign_new(:selected_growth_plan, fn %{ + available_plans: available_plans, + selected_volume: selected_volume + } -> + get_plan_by_volume(available_plans.growth, selected_volume) + end) + |> assign_new(:selected_business_plan, fn %{ + available_plans: available_plans, + selected_volume: selected_volume + } -> + get_plan_by_volume(available_plans.business, selected_volume) + end) + + {:ok, socket} + end + + def render(assigns) do + ~H""" +
+
+ <.subscription_past_due_notice class="pb-2" subscription={@user.subscription} /> + <.subscription_paused_notice class="pb-2" subscription={@user.subscription} /> +
+

+ <%= if @owned_plan, + do: "Change subscription plan", + else: "Upgrade your account" %> +

+
+ <.interval_picker selected_interval={@selected_interval} /> + <.slider selected_volume={@selected_volume} available_volumes={@available_volumes} /> +
+ <.plan_box + kind={:growth} + owned={@owned_plan && Map.get(@owned_plan, :kind) == :growth} + plan_to_render={ + if @selected_growth_plan, + do: @selected_growth_plan, + else: List.last(@available_plans.growth) + } + available={!!@selected_growth_plan} + {assigns} + /> + <.plan_box + kind={:business} + owned={@owned_plan && Map.get(@owned_plan, :kind) == :business} + plan_to_render={ + if @selected_business_plan, + do: @selected_business_plan, + else: List.last(@available_plans.business) + } + available={!!@selected_business_plan} + {assigns} + /> + <.enterprise_plan_box /> +
+

+ <.usage usage={@usage} /> +

+ <.pageview_limit_notice :if={!@owned_plan} /> + <.help_links /> +
+
+ <.slider_styles /> + <.paddle_script /> + """ + end + + def handle_event("set_interval", %{"interval" => interval}, socket) do + new_interval = + case interval do + "yearly" -> :yearly + "monthly" -> :monthly + end + + {:noreply, assign(socket, selected_interval: new_interval)} + end + + def handle_event("slide", %{"slider" => index}, socket) do + index = String.to_integer(index) + %{available_plans: available_plans, available_volumes: available_volumes} = socket.assigns + + new_volume = + if index == length(available_volumes) do + :enterprise + else + Enum.at(available_volumes, index) + end + + {:noreply, + assign(socket, + selected_volume: new_volume, + selected_growth_plan: get_plan_by_volume(available_plans.growth, new_volume), + selected_business_plan: get_plan_by_volume(available_plans.business, new_volume) + )} + end + + defp default_selected_volume(%Plan{monthly_pageview_limit: limit}, _, _), do: limit + + defp default_selected_volume(_, usage, available_volumes) do + Enum.find(available_volumes, &(usage < &1)) || :enterprise + end + + defp current_user_subscription_interval(subscription) do + case Plans.subscription_interval(subscription) do + "yearly" -> :yearly + "monthly" -> :monthly + _ -> nil + end + end + + defp get_plan_by_volume(_, :enterprise), do: nil + + defp get_plan_by_volume(plans, volume) do + Enum.find(plans, &(&1.monthly_pageview_limit == volume)) + end + + defp interval_picker(assigns) do + ~H""" +
+
+ <.two_months_free active={@selected_interval == :yearly} /> +
+ + +
+
+
+ """ + end + + def two_months_free(assigns) do + ~H""" +
+
+ + 2 months free + +
+ """ + end + + defp slider(assigns) do + ~H""" +
+

+ Monthly pageviews: <%= slider_value(@selected_volume, @available_volumes) %> +

+ +
+ """ + end + + defp plan_box(assigns) do + ~H""" +
+
+

+ <%= String.capitalize(to_string(@kind)) %> +

+ <.current_label :if={@owned} /> +
+
+ <.render_price_info available={@available} {assigns} /> + <%= cond do %> + <% !@available -> %> + <.contact_button class="bg-indigo-600 hover:bg-indigo-500 text-white" /> + <% @owned_plan && @user.subscription && @user.subscription.status in ["active", "past_due", "paused"] -> %> + <.render_change_plan_link + paddle_product_id={get_paddle_product_id(@plan_to_render, @selected_interval)} + text={ + change_plan_link_text( + @owned_plan, + @plan_to_render, + @current_interval, + @selected_interval + ) + } + {assigns} + /> + <% true -> %> + <.paddle_button + paddle_product_id={get_paddle_product_id(@plan_to_render, @selected_interval)} + {assigns} + /> + <% end %> +
+ +
+ """ + end + + def render_price_info(%{available: false} = assigns) do + ~H""" +

+ + 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""" +
+

Enterprise

+

+ + Custom + +

+

+ <.contact_button class="" /> + +
+ """ + end + + defp current_label(assigns) do + ~H""" +
+

+ Current +

+
+ """ + end + + defp check_icon(assigns) do + ~H""" + + + + """ + end + + defp usage(assigns) do + ~H""" + You have used <%= PlausibleWeb.AuthView.delimit_integer(@usage) %> + billable pageviews in the last 30 days + """ + end + + defp pageview_limit_notice(assigns) do + ~H""" +
+
+

+ + What happens if I go over my page views limit? + +

+
+
+
+ You will never be charged extra for an occasional traffic spike. There are no surprise fees and your card will never be charged unexpectedly. If your page views exceed your plan for two consecutive months, we will contact you to upgrade to a higher plan for the following month. You will have two weeks to make a decision. You can decide to continue with a higher plan or to cancel your account at that point. +
+
+
+ """ + end + + defp help_links(assigns) do + ~H""" +
+ Questions? Contact us + or see billing FAQ +
+ """ + end + + defp price_tag(%{plan_to_render: %Plan{monthly_cost: nil}} = assigns) do + ~H""" + + N/A + + """ + end + + defp price_tag(%{selected_interval: :monthly} = assigns) do + ~H""" + + <%= @plan_to_render.monthly_cost |> format_price() %> + + + /month + + """ + end + + defp price_tag(%{selected_interval: :yearly} = assigns) do + ~H""" + + <%= @plan_to_render.monthly_cost |> Money.mult!(12) |> format_price() %> + + + <%= @plan_to_render.yearly_cost |> format_price() %> + + + /year + + """ + end + + defp paddle_script(assigns) do + ~H""" + + + + """ + end + + defp slider_styles(assigns) do + ~H""" + + """ + end + + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + defp change_plan_link_text( + %Plan{kind: from_kind, monthly_pageview_limit: from_volume}, + %Plan{kind: to_kind, monthly_pageview_limit: to_volume}, + from_interval, + to_interval + ) do + cond do + from_kind == :business && to_kind == :growth -> + "Downgrade to Growth" + + from_kind == :growth && to_kind == :business -> + "Upgrade to Business" + + from_volume == to_volume && from_interval == to_interval -> + "Currently on this plan" + + from_volume == to_volume -> + "Change billing interval" + + from_volume > to_volume -> + "Downgrade" + + true -> + "Upgrade" + end + end + + defp get_available_volumes(%{business: business_plans, growth: growth_plans}) do + growth_volumes = Enum.map(growth_plans, & &1.monthly_pageview_limit) + business_volumes = Enum.map(business_plans, & &1.monthly_pageview_limit) + + (growth_volumes ++ business_volumes) + |> Enum.uniq() + end + + defp get_paddle_product_id(%Plan{monthly_product_id: plan_id}, :monthly), do: plan_id + defp get_paddle_product_id(%Plan{yearly_product_id: plan_id}, :yearly), do: plan_id + + defp slider_value(:enterprise, available_volumes) do + List.last(available_volumes) + |> PlausibleWeb.StatsView.large_number_format() + |> Kernel.<>("+") + end + + defp slider_value(volume, _) when is_integer(volume) do + PlausibleWeb.StatsView.large_number_format(volume) + end + + defp contact_link(), do: @contact_link + + defp billing_faq_link(), do: @billing_faq_link +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index c32bf5104e..8016142dc6 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -200,6 +200,7 @@ defmodule PlausibleWeb.Router do get "/billing/change-plan/preview/:plan_id", BillingController, :change_plan_preview post "/billing/change-plan/:new_plan_id", BillingController, :change_plan get "/billing/upgrade", BillingController, :upgrade + get "/billing/choose-plan", BillingController, :choose_plan get "/billing/upgrade/:plan_id", BillingController, :upgrade_to_plan get "/billing/upgrade/enterprise/:plan_id", BillingController, :upgrade_enterprise_plan get "/billing/change-plan/enterprise/:plan_id", BillingController, :change_enterprise_plan diff --git a/lib/plausible_web/templates/auth/user_settings.html.heex b/lib/plausible_web/templates/auth/user_settings.html.heex index 7ac84ef522..7285259aa7 100644 --- a/lib/plausible_web/templates/auth/user_settings.html.heex +++ b/lib/plausible_web/templates/auth/user_settings.html.heex @@ -1,16 +1,26 @@ <%= if !Application.get_env(:plausible, :is_selfhost) do %> -
-
-

Subscription Plan

- - <%= present_subscription_status(@subscription.status) %> - +
+
+

Subscription Plan

+
+ + Business + + + <%= present_subscription_status(@subscription.status) %> + +
@@ -50,38 +60,11 @@
-
-

Monthly quota

- <%= if @subscription do %> -
- <%= subscription_quota(@subscription) %> pageviews -
- - <.styled_link - :if={@subscription.status == "active"} - href={Routes.billing_path(@conn, :change_plan_form)} - class="text-sm font-medium" - > - Change plan - - - - Change plan - - <% else %> -
Free trial
- <.styled_link class="text-sm font-medium" href={Routes.billing_path(@conn, :upgrade)}> - Upgrade - - <% end %> -
+
%>
<%= link("Upgrade", - to: "/billing/upgrade", + to: + if(FunWithFlags.enabled?(:business_tier, for: @user), + do: PlausibleWeb.Router.Helpers.billing_path(PlausibleWeb.Endpoint, :choose_plan), + else: "/billing/upgrade" + ), class: "inline-block px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150" ) %> diff --git a/lib/plausible_web/templates/billing/change_plan.html.eex b/lib/plausible_web/templates/billing/change_plan.html.eex index c5851b1105..99d90501ae 100644 --- a/lib/plausible_web/templates/billing/change_plan.html.eex +++ b/lib/plausible_web/templates/billing/change_plan.html.eex @@ -2,7 +2,7 @@