Use Phoenix LiveView for the upgrade page (#3382)
* add a new upgrade page liveview behind a FF * Create plans_v4.json file * Add the upgrade page UI template and some basic functionalities * different content based on subscription plan existing or not * pageview slider * monthly/yearly switch * fix tests * split into 2 separate functions * rename variables * implement volume slider + read default interval/volume from plan * organize choose-plan.ex better * remove unused vars from tests * make monthly_cost and yearly_cost nil by default The actual prices for all plans are stored in Paddle. We don't need to keep the duplicates in the JSON files. * add fetch_prices/1 to PaddleApi * make v4 business ID's differ from growth ones * render actual price information from plans ...and make the prices in both growth and business plan boxes change dynamically when the pageview slider or interval is changed. * highlight current subscription plan box * add test describe block for business tier subscription * connect to live socket only on the specific LV page using focus.html * only wrap the input slider inside the form * little readability improvement * add v4 team_member_limits (after rebase with master) * extract monthly_quota_box function in user_settings When the business_tier FF is enabled, this section is different and links to the new upgrade page. * document subscription statuses * change _notice.html.eex to .heex * extract subscription status notice components * add failed payment notices to upgrade page * create class_of_element/2 convenience function for testing * add cancel_subscription mix task * implement checkout buttons * mix format * get all available plans with prices through plans.ex * use more suitable function for fetching usage * avoid double db lookups on mount * rename variable * separate functions for getting plan by product_id vs subscription * separate subscription status docs into context module * consider cancelled subscriptions * default volume by usage if no subscription plan * add enterprise-level volume option to slider * optimize for darkmode * UI improvements * display 2 months free notice for yearly billing * VAT excluded notice * note about having a business subscription in user settings * make the page pop and fit plans on screen on first render * optimize for mobile and remove background containers * change default price tag to simply 'N/A' * fix tests * Change Paddle.js integration to use JavaScript directly * rename many variables * allow users on v1 and v2 plan subscribe to 20M and 50M tiers * add a test for two months free label * make it work with a free_10k subscription * small test improvement and formatting * change other upgrade link in user settings if FF enabled * dialyzer * fix typo * add test for free_10k user * silence credo * mix format * credo - add moduledoc * credo - another moduledoc * handle calls to sentry on the api level * refactor getting regular subscription plan for LiveView * post review code style tweaks * remove unused aliases * credo - add @moduledoc false to Subscriptions * crash in cancel_subscription task when Repo update fails * readability improvements (review suggestions) * add comment about 'external_resource' module attr --------- Co-authored-by: Vinicius Brasil <vini@hey.com>
This commit is contained in:
parent
16ce0f1ea8
commit
8bc86d165f
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<tr {@rest}>
|
||||
<td class={["py-4 text-sm whitespace-nowrap text-left", @pad && "pl-6"]}><%= @title %></td>
|
||||
<td class="py-4 text-sm whitespace-nowrap text-right">
|
||||
<td class={["py-4 pr-1 text-sm sm:whitespace-nowrap text-left", @pad && "pl-6"]}>
|
||||
<%= @title %>
|
||||
</td>
|
||||
<td class="py-4 text-sm sm:whitespace-nowrap text-right">
|
||||
<%= render_quota(@usage) %>
|
||||
<%= if @limit, do: "/ #{render_quota(@limit)}" %>
|
||||
</td>
|
||||
|
|
@ -41,4 +46,149 @@ defmodule PlausibleWeb.Components.Billing do
|
|||
nil -> ""
|
||||
end
|
||||
end
|
||||
|
||||
def monthly_quota_box(%{business_tier: true} = assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
|
||||
style="width: 11.75rem;"
|
||||
>
|
||||
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
|
||||
<div class="py-2 text-xl font-medium dark:text-gray-100">
|
||||
<%= PlausibleWeb.AuthView.subscription_quota(@subscription, format: :long) %>
|
||||
</div>
|
||||
<.styled_link href={Routes.billing_path(@conn, :choose_plan)} class="text-sm font-medium">
|
||||
<%= upgrade_link_text(@subscription) %>
|
||||
</.styled_link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def monthly_quota_box(%{business_tier: false} = assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
|
||||
style="width: 11.75rem;"
|
||||
>
|
||||
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
|
||||
<%= if @subscription do %>
|
||||
<div class="py-2 text-xl font-medium dark:text-gray-100">
|
||||
<%= PlausibleWeb.AuthView.subscription_quota(@subscription) %> pageviews
|
||||
</div>
|
||||
|
||||
<.styled_link
|
||||
:if={@subscription.status == "active"}
|
||||
href={Routes.billing_path(@conn, :change_plan_form)}
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
Change plan
|
||||
</.styled_link>
|
||||
|
||||
<span
|
||||
:if={@subscription.status == "past_due"}
|
||||
class="text-sm text-gray-600 dark:text-gray-400 font-medium"
|
||||
tooltip="Please update your billing details before changing plans"
|
||||
>
|
||||
Change plan
|
||||
</span>
|
||||
<% else %>
|
||||
<div class="py-2 text-xl font-medium dark:text-gray-100">Free trial</div>
|
||||
<.styled_link href={Routes.billing_path(@conn, :upgrade)} class="text-sm font-medium">
|
||||
Upgrade
|
||||
</.styled_link>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_past_due_notice(%{subscription: %Subscription{status: "past_due"}} = assigns) do
|
||||
~H"""
|
||||
<aside class={@class}>
|
||||
<div class="shadow-md dark:shadow-none rounded-lg bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-5 h-5 mt-0.5 text-yellow-800"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p class="text-yellow-700">
|
||||
There was a problem with your latest payment. Please update your payment information to keep using Plausible.
|
||||
</p>
|
||||
<.link
|
||||
href={@subscription.update_url}
|
||||
class="whitespace-nowrap font-medium text-yellow-700 hover:text-yellow-600"
|
||||
>
|
||||
Update billing info <span aria-hidden="true"> →</span>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_past_due_notice(assigns), do: ~H""
|
||||
|
||||
def subscription_paused_notice(%{subscription: %Subscription{status: "paused"}} = assigns) do
|
||||
~H"""
|
||||
<aside class={@class}>
|
||||
<div class="shadow-md dark:shadow-none rounded-lg bg-red-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-5 h-5 mt-0.5 text-yellow-800"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p class="text-red-700">
|
||||
Your subscription is paused due to failed payments. Please provide valid payment details to keep using Plausible.
|
||||
</p>
|
||||
<.link
|
||||
href={@subscription.update_url}
|
||||
class="whitespace-nowrap font-medium text-red-700 hover:text-red-600"
|
||||
>
|
||||
Update billing info <span aria-hidden="true"> →</span>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<div class="bg-gray-100 dark:bg-gray-900 pt-1 pb-12 sm:pb-16 text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<.subscription_past_due_notice class="pb-2" subscription={@user.subscription} />
|
||||
<.subscription_paused_notice class="pb-2" subscription={@user.subscription} />
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<p class="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
<%= if @owned_plan,
|
||||
do: "Change subscription plan",
|
||||
else: "Upgrade your account" %>
|
||||
</p>
|
||||
</div>
|
||||
<.interval_picker selected_interval={@selected_interval} />
|
||||
<.slider selected_volume={@selected_volume} available_volumes={@available_volumes} />
|
||||
<div class="mt-6 isolate mx-auto grid max-w-md grid-cols-1 gap-8 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
<.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 />
|
||||
</div>
|
||||
<p class="mx-auto mt-2 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-gray-400">
|
||||
<.usage usage={@usage} />
|
||||
</p>
|
||||
<.pageview_limit_notice :if={!@owned_plan} />
|
||||
<.help_links />
|
||||
</div>
|
||||
</div>
|
||||
<.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"""
|
||||
<div class="mt-6 flex justify-center">
|
||||
<div class="flex flex-col">
|
||||
<.two_months_free active={@selected_interval == :yearly} />
|
||||
<fieldset class="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold leading-5 ring-1 ring-inset ring-gray-300 dark:ring-gray-600">
|
||||
<label
|
||||
class={"cursor-pointer rounded-full px-2.5 py-1 #{if @selected_interval == :monthly, do: "bg-indigo-600 text-white"}"}
|
||||
phx-click="set_interval"
|
||||
phx-value-interval="monthly"
|
||||
>
|
||||
<input type="radio" name="frequency" value="monthly" class="sr-only" />
|
||||
<span>Monthly</span>
|
||||
</label>
|
||||
<label
|
||||
class={"cursor-pointer rounded-full px-2.5 py-1 #{if @selected_interval == :yearly, do: "bg-indigo-600 text-white"}"}
|
||||
phx-click="set_interval"
|
||||
phx-value-interval="yearly"
|
||||
>
|
||||
<input type="radio" name="frequency" value="yearly" class="sr-only" />
|
||||
<span>Yearly</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def two_months_free(assigns) do
|
||||
~H"""
|
||||
<div class="grid grid-cols-2 gap-x-1">
|
||||
<div></div>
|
||||
<span
|
||||
id="two-months-free"
|
||||
class={[
|
||||
"mb-1 block whitespace-no-wrap w-max px-2.5 py-0.5 rounded-full text-xs font-medium leading-4 ring-1",
|
||||
@active &&
|
||||
"bg-yellow-100 ring-yellow-700 text-yellow-700 dark:text-yellow-200 dark:bg-inherit dark:ring-1 dark:ring-yellow-200",
|
||||
!@active && "text-gray-500 ring-gray-300 dark:text-gray-400 dark:ring-gray-600"
|
||||
]}
|
||||
>
|
||||
2 months free
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp slider(assigns) do
|
||||
~H"""
|
||||
<form class="mt-4 max-w-2xl mx-auto">
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 text-center">
|
||||
Monthly pageviews: <b><%= slider_value(@selected_volume, @available_volumes) %></b>
|
||||
</p>
|
||||
<input
|
||||
phx-change="slide"
|
||||
name="slider"
|
||||
class="shadow-md border border-gray-200 dark:bg-gray-600 dark:border-none"
|
||||
type="range"
|
||||
min="0"
|
||||
max={length(@available_volumes)}
|
||||
step="1"
|
||||
value={
|
||||
Enum.find_index(@available_volumes, &(&1 == @selected_volume)) || length(@available_volumes)
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
"""
|
||||
end
|
||||
|
||||
defp plan_box(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"#{@kind}-plan-box"}
|
||||
class={[
|
||||
"rounded-3xl px-6 sm:px-8 py-4 sm:py-6 dark:bg-gray-800",
|
||||
!@owned && "ring-1 ring-gray-300 dark:ring-gray-600",
|
||||
@owned && "ring-2 ring-indigo-600"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-x-4">
|
||||
<h3 class={[
|
||||
"text-lg font-semibold leading-8",
|
||||
!@owned && "text-gray-900 dark:text-gray-100",
|
||||
@owned && "text-indigo-600"
|
||||
]}>
|
||||
<%= String.capitalize(to_string(@kind)) %>
|
||||
</h3>
|
||||
<.current_label :if={@owned} />
|
||||
</div>
|
||||
<div>
|
||||
<.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 %>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
class="mt-8 space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-100 xl:mt-10"
|
||||
>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-indigo-600 dark:text-green-600" /> 5 products
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-indigo-600 dark:text-green-600" /> Up to 1,000 subscribers
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-indigo-600 dark:text-green-600" /> Basic analytics
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-indigo-600 dark:text-green-600" /> 48-hour support response time
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_price_info(%{available: false} = assigns) do
|
||||
~H"""
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<span class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Custom
|
||||
</span>
|
||||
</p>
|
||||
<p class="h-4 mt-1"></p>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_price_info(assigns) do
|
||||
~H"""
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<.price_tag
|
||||
kind={@kind}
|
||||
selected_interval={@selected_interval}
|
||||
plan_to_render={@plan_to_render}
|
||||
/>
|
||||
</p>
|
||||
<p class="mt-1 text-xs">+ VAT if applicable</p>
|
||||
"""
|
||||
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 %>
|
||||
</.link>
|
||||
<p
|
||||
:if={@billing_details_expired && !@plan_already_owned}
|
||||
class="text-center text-sm text-red-700 dark:text-red-500"
|
||||
>
|
||||
Please update your billing details first
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp paddle_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
id={"#{@kind}-checkout"}
|
||||
onclick={"Paddle.Checkout.open(#{Jason.encode!(%{product: @paddle_product_id, email: @user.email, disableLogout: true, passthrough: @user.id, success: Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), theme: "none"})})"}
|
||||
class="w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white bg-indigo-600 hover:bg-indigo-500"
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
"""
|
||||
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
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
defp enterprise_plan_box(assigns) do
|
||||
~H"""
|
||||
<div class="rounded-3xl px-6 sm:px-8 py-4 sm:py-6 ring-1 bg-gray-900 ring-gray-900 dark:bg-gray-800 dark:ring-gray-600">
|
||||
<h3 class="text-lg font-semibold leading-8 text-white dark:text-gray-100">Enterprise</h3>
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<span class="text-4xl font-bold tracking-tight text-white dark:text-gray-100">
|
||||
Custom
|
||||
</span>
|
||||
</p>
|
||||
<p class="h-4 mt-1"></p>
|
||||
<.contact_button class="" />
|
||||
<ul role="list" class="mt-8 space-y-3 text-sm leading-6 xl:mt-10 text-gray-300">
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-white dark:text-green-600" /> Unlimited products
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-white dark:text-green-600" /> Unlimited subscribers
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-white dark:text-green-600" /> Advanced analytics
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-white dark:text-green-600" />
|
||||
1-hour, dedicated support response time
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-white dark:text-green-600" /> Marketing automations
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class="text-white dark:text-green-600" /> Custom reporting tools
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp current_label(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center justify-between gap-x-4">
|
||||
<p
|
||||
id="current-label"
|
||||
class="rounded-full bg-indigo-600/10 px-2.5 py-1 text-xs font-semibold leading-5 text-indigo-600 dark:ring-1 dark:ring-indigo-600/40"
|
||||
>
|
||||
Current
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp check_icon(assigns) do
|
||||
~H"""
|
||||
<svg {%{class: "h-6 w-5 flex-none #{@class}", viewBox: "0 0 20 20",fill: "currentColor","aria-hidden": "true"}}>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp usage(assigns) do
|
||||
~H"""
|
||||
You have used <b><%= PlausibleWeb.AuthView.delimit_integer(@usage) %></b>
|
||||
billable pageviews in the last 30 days
|
||||
"""
|
||||
end
|
||||
|
||||
defp pageview_limit_notice(assigns) do
|
||||
~H"""
|
||||
<div class="mt-12 mx-auto mt-6 max-w-2xl">
|
||||
<dt>
|
||||
<p class="w-full text-center text-gray-900 dark:text-gray-100">
|
||||
<span class="text-center font-semibold leading-7">
|
||||
What happens if I go over my page views limit?
|
||||
</span>
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="mt-3">
|
||||
<div class="text-justify leading-7 block text-gray-600 dark:text-gray-100">
|
||||
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.
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp help_links(assigns) do
|
||||
~H"""
|
||||
<div class="mt-8 text-center">
|
||||
Questions? <a class="text-indigo-600" href={contact_link()}>Contact us</a>
|
||||
or see <a class="text-indigo-600" href={billing_faq_link()}>billing FAQ</a>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{plan_to_render: %Plan{monthly_cost: nil}} = assigns) do
|
||||
~H"""
|
||||
<span class="text-4xl font-bold tracking-tight text-gray-900">
|
||||
N/A
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{selected_interval: :monthly} = assigns) do
|
||||
~H"""
|
||||
<span
|
||||
id={"#{@kind}-price-tag-amount"}
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<%= @plan_to_render.monthly_cost |> format_price() %>
|
||||
</span>
|
||||
<span
|
||||
id={"#{@kind}-price-tag-interval"}
|
||||
class="text-sm font-semibold leading-6 text-gray-600 dark:text-gray-500"
|
||||
>
|
||||
/month
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{selected_interval: :yearly} = assigns) do
|
||||
~H"""
|
||||
<span class="text-2xl font-bold w-max tracking-tight line-through text-gray-500 dark:text-gray-600 mr-1">
|
||||
<%= @plan_to_render.monthly_cost |> Money.mult!(12) |> format_price() %>
|
||||
</span>
|
||||
<span
|
||||
id={"#{@kind}-price-tag-amount"}
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<%= @plan_to_render.yearly_cost |> format_price() %>
|
||||
</span>
|
||||
<span id={"#{@kind}-price-tag-interval"} class="text-sm font-semibold leading-6 text-gray-600">
|
||||
/year
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp paddle_script(assigns) do
|
||||
~H"""
|
||||
<script type="text/javascript" src="https://cdn.paddle.com/paddle/paddle.js">
|
||||
</script>
|
||||
<script :if={Application.get_env(:plausible, :environment) == "dev"}>
|
||||
Paddle.Environment.set('sandbox')
|
||||
</script>
|
||||
<script>
|
||||
Paddle.Setup({vendor: <%= Application.get_env(:plausible, :paddle) |> Keyword.fetch!(:vendor_id) %> })
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
|
||||
defp slider_styles(assigns) do
|
||||
~H"""
|
||||
<style>
|
||||
input[type="range"] {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
input[type="range"]::-ms-thumb {
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
<%= if !Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
|
||||
<div class="flex justify-between">
|
||||
<h2 class="text-xl font-black dark:text-gray-100">Subscription Plan</h2>
|
||||
<div class="max-w-2xl px-8 pt-4 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
|
||||
<div class="flex flex-wrap justify-between">
|
||||
<h2 class="text-xl font-black dark:text-gray-100 w-max mr-4 mt-2">Subscription Plan</h2>
|
||||
<div class="gap-x-2 mt-2 inline-flex">
|
||||
<span
|
||||
:if={@subscription && Plausible.Billing.Plans.business_tier?(@subscription)}
|
||||
class={[
|
||||
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5 text-indigo-600 bg-blue-100 dark:text-yellow-200 dark:border dark:bg-inherit dark:border-yellow-200"
|
||||
]}
|
||||
>
|
||||
Business
|
||||
</span>
|
||||
<span
|
||||
:if={@subscription}
|
||||
class={[
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
|
||||
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
|
||||
subscription_colors(@subscription.status)
|
||||
]}
|
||||
>
|
||||
<%= present_subscription_status(@subscription.status) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 border-b border-gray-400"></div>
|
||||
|
||||
|
|
@ -50,38 +60,11 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
|
||||
<div
|
||||
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
|
||||
style="width: 11.75rem;"
|
||||
>
|
||||
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
|
||||
<%= if @subscription do %>
|
||||
<div class="py-2 text-xl font-medium dark:text-gray-100">
|
||||
<%= subscription_quota(@subscription) %> pageviews
|
||||
</div>
|
||||
|
||||
<.styled_link
|
||||
:if={@subscription.status == "active"}
|
||||
href={Routes.billing_path(@conn, :change_plan_form)}
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
Change plan
|
||||
</.styled_link>
|
||||
|
||||
<span
|
||||
:if={@subscription.status == "past_due"}
|
||||
class="text-sm text-gray-600 dark:text-gray-400 font-medium"
|
||||
tooltip="Please update your billing details before changing plans"
|
||||
>
|
||||
Change plan
|
||||
</span>
|
||||
<% else %>
|
||||
<div class="py-2 text-xl font-medium dark:text-gray-100">Free trial</div>
|
||||
<.styled_link class="text-sm font-medium" href={Routes.billing_path(@conn, :upgrade)}>
|
||||
Upgrade
|
||||
</.styled_link>
|
||||
<% end %>
|
||||
</div>
|
||||
<PlausibleWeb.Components.Billing.monthly_quota_box
|
||||
subscription={@subscription}
|
||||
conn={@conn}
|
||||
business_tier={FunWithFlags.enabled?(:business_tier, for: @user)}
|
||||
/>
|
||||
<div
|
||||
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
|
||||
style="width: 11.75rem;"
|
||||
|
|
@ -168,7 +151,11 @@
|
|||
<% true -> %>
|
||||
<div class="mt-8">
|
||||
<%= 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"
|
||||
) %>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<script>
|
||||
plans = function() {
|
||||
return {
|
||||
rawPlans: <%= raw Jason.encode!(Plausible.Billing.Plans.for_user(@conn.assigns[:current_user])) %>,
|
||||
rawPlans: <%= raw Jason.encode!(Plausible.Billing.Plans.growth_plans_for(@conn.assigns[:current_user])) %>,
|
||||
localizedPlans: null,
|
||||
volume: '10k',
|
||||
billingCycle: 'monthly',
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
<%= for plan <- Plausible.Billing.Plans.for_user(@conn.assigns[:current_user]) do %>
|
||||
<%= for plan <- Plausible.Billing.Plans.growth_plans_for(@conn.assigns[:current_user]) do %>
|
||||
<%= render("_plan_option.html", plan: plan) %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<%= live_render(@conn, PlausibleWeb.Live.ChoosePlan,
|
||||
id: "choose-plan",
|
||||
session: %{"user_id" => @user.id}
|
||||
) %>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<script>
|
||||
plans = function() {
|
||||
return {
|
||||
rawPlans: <%= raw Jason.encode!(Plausible.Billing.Plans.for_user(@user)) %>,
|
||||
rawPlans: <%= raw Jason.encode!(Plausible.Billing.Plans.growth_plans_for(@user)) %>,
|
||||
localizedPlans: null,
|
||||
volume: '10k',
|
||||
billingCycle: 'monthly',
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
<%= for plan <- Plausible.Billing.Plans.for_user(@user) do %>
|
||||
<%= for plan <- Plausible.Billing.Plans.growth_plans_for(@user) do %>
|
||||
<%= render("_plan_option.html", plan: plan) %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
<%= if assigns[:flash] do %>
|
||||
<%= render("_flash.html", assigns) %>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= if Plausible.Auth.GracePeriod.active?(@conn.assigns[:current_user]) do %>
|
||||
<div class="container">
|
||||
<div class="rounded-md bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<%= if Plausible.Auth.enterprise?(@conn.assigns[:current_user]) do %>
|
||||
You have outgrown your Plausible subscription tier
|
||||
<% else %>
|
||||
Please upgrade your account
|
||||
<% end %>
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
<%= if Plausible.Auth.enterprise?(@conn.assigns[:current_user]) do %>
|
||||
In order to keep your stats running, we require you to upgrade
|
||||
your account to accommodate your new usage levels. Please contact
|
||||
us to discuss a new custom enterprise plan.
|
||||
<%= link("Contact us →", to: "mailto:enterprise@plausible.io", class: "text-sm font-medium text-yellow-800") %>
|
||||
<% else %>
|
||||
In order to keep your stats running, we require you to upgrade
|
||||
your account. If you do not upgrade your account
|
||||
<%= grace_period_end(@conn.assigns[:current_user]) %>, we will
|
||||
lock your stats and they won't be accessible.
|
||||
<%= link("Upgrade now →", to: "/settings", class: "text-sm font-medium text-yellow-800") %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Plausible.Auth.GracePeriod.expired?(@conn.assigns[:current_user]) do %>
|
||||
<div class="container">
|
||||
<div class="rounded-md bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Dashboard locked
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
As you have outgrown your subscription tier, we kindly ask you to
|
||||
upgrade your subscription to accommodate your new traffic levels.
|
||||
<%= link("Upgrade now →", to: "/settings", class: "text-sm font-medium text-yellow-800") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @conn.assigns[:current_user] && @conn.assigns[:current_user].subscription && @conn.assigns[:current_user].subscription.status == "past_due" do %>
|
||||
<aside class="container">
|
||||
<div class="rounded-lg bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 mt-0.5 text-yellow-800" viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p class="text-yellow-700">There was a problem with your latest payment. Please update your payment information to keep using Plausible.</p>
|
||||
<%= link to: @conn.assigns.current_user.subscription.update_url, class: "whitespace-nowrap font-medium text-yellow-700 hover:text-yellow-600" do %>
|
||||
Update billing info
|
||||
<span aria-hidden="true"> →</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<% end %>
|
||||
|
||||
<%= if @conn.assigns[:current_user] && @conn.assigns[:current_user].subscription && @conn.assigns[:current_user].subscription.status == "paused" do %>
|
||||
<aside class="container">
|
||||
<div class="rounded-lg bg-red-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 mt-0.5 text-red-800" viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p class="text-red-700">Your subscription is paused due to failed payments. Please provide valid payment details to keep using Plausible.</p>
|
||||
<%= link to: @conn.assigns.current_user.subscription.update_url, class: "whitespace-nowrap font-medium text-red-700 hover:text-red-600" do %>
|
||||
Update billing info
|
||||
<span aria-hidden="true"> →</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<% end %>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<%= if assigns[:flash] do %>
|
||||
<%= render("_flash.html", assigns) %>
|
||||
<% end %>
|
||||
|
||||
<%= if Plausible.Auth.GracePeriod.active?(@conn.assigns[:current_user]) do %>
|
||||
<div class="container">
|
||||
<div class="rounded-md bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-yellow-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
<%= if Plausible.Auth.enterprise?(@conn.assigns[:current_user]) do %>
|
||||
You have outgrown your Plausible subscription tier
|
||||
<% else %>
|
||||
Please upgrade your account
|
||||
<% end %>
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
<%= if Plausible.Auth.enterprise?(@conn.assigns[:current_user]) do %>
|
||||
In order to keep your stats running, we require you to upgrade
|
||||
your account to accommodate your new usage levels. Please contact
|
||||
us to discuss a new custom enterprise plan. <%= link("Contact us →",
|
||||
to: "mailto:enterprise@plausible.io",
|
||||
class: "text-sm font-medium text-yellow-800"
|
||||
) %>
|
||||
<% else %>
|
||||
In order to keep your stats running, we require you to upgrade
|
||||
your account. If you do not upgrade your account <%= grace_period_end(
|
||||
@conn.assigns[:current_user]
|
||||
) %>, we will
|
||||
lock your stats and they won't be accessible. <%= link("Upgrade now →",
|
||||
to: "/settings",
|
||||
class: "text-sm font-medium text-yellow-800"
|
||||
) %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Plausible.Auth.GracePeriod.expired?(@conn.assigns[:current_user]) do %>
|
||||
<div class="container">
|
||||
<div class="rounded-md bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-yellow-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Dashboard locked
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
As you have outgrown your subscription tier, we kindly ask you to
|
||||
upgrade your subscription to accommodate your new traffic levels. <%= link(
|
||||
"Upgrade now →",
|
||||
to: "/settings",
|
||||
class: "text-sm font-medium text-yellow-800"
|
||||
) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<.subscription_past_due_notice
|
||||
subscription={@conn.assigns[:current_user] && @conn.assigns[:current_user].subscription}
|
||||
class="container"
|
||||
/>
|
||||
|
||||
<.subscription_paused_notice
|
||||
subscription={@conn.assigns[:current_user] && @conn.assigns[:current_user].subscription}
|
||||
class="container"
|
||||
/>
|
||||
|
|
@ -5,11 +5,11 @@
|
|||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta name="description" content="A lightweight, non-intrusive alternative to Google Analytics."/>
|
||||
<meta name="robots" content="<%= @conn.private.robots %>" />
|
||||
<%= if @conn.assigns[:connect_live_socket] do %>
|
||||
<meta name="csrf-token" content="<%= Plug.CSRFProtection.get_csrf_token() %>" />
|
||||
<meta name="websocket-url" content="<%= websocket_url() %>" />
|
||||
<% end %>
|
||||
<meta name="robots" content="<%= @conn.private.robots %>" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="<%= PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_favicon.png") %>">
|
||||
<title><%= assigns[:title] || "Plausible · Web analytics" %></title>
|
||||
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,19 @@ defmodule PlausibleWeb.AuthView do
|
|||
PlausibleWeb.Endpoint.url()
|
||||
end
|
||||
|
||||
def subscription_quota(subscription) do
|
||||
def subscription_quota(subscription, options \\ [])
|
||||
|
||||
def subscription_quota(nil, _options), do: "Free trial"
|
||||
|
||||
def subscription_quota(subscription, options) do
|
||||
subscription
|
||||
|> Plausible.Billing.Quota.monthly_pageview_limit()
|
||||
|> PlausibleWeb.StatsView.large_number_format()
|
||||
|> then(fn quota ->
|
||||
if Keyword.get(options, :format) == :long,
|
||||
do: "#{quota} pageviews",
|
||||
else: quota
|
||||
end)
|
||||
end
|
||||
|
||||
def subscription_interval(subscription) do
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule PlausibleWeb.LayoutView do
|
||||
use PlausibleWeb, :view
|
||||
import PlausibleWeb.Components.Billing
|
||||
|
||||
def base_domain do
|
||||
PlausibleWeb.Endpoint.host()
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_cost":"$6",
|
||||
"monthly_product_id":"558018",
|
||||
"yearly_cost":"$48",
|
||||
"yearly_product_id":"572810",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -12,9 +10,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_cost":"$12",
|
||||
"monthly_product_id":"558745",
|
||||
"yearly_cost":"$96",
|
||||
"yearly_product_id":"590752",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -22,9 +18,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_cost":"$18",
|
||||
"monthly_product_id":"597485",
|
||||
"yearly_cost":"$144",
|
||||
"yearly_product_id":"597486",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -32,9 +26,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_cost":"$27",
|
||||
"monthly_product_id":"597487",
|
||||
"yearly_cost":"$216",
|
||||
"yearly_product_id":"597488",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -42,9 +34,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_cost":"$48",
|
||||
"monthly_product_id":"597642",
|
||||
"yearly_cost":"$384",
|
||||
"yearly_product_id":"597643",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -52,9 +42,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_cost":"$69",
|
||||
"monthly_product_id":"597309",
|
||||
"yearly_cost":"$552",
|
||||
"yearly_product_id":"597310",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -62,9 +50,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_cost":"$99",
|
||||
"monthly_product_id":"597311",
|
||||
"yearly_cost":"$792",
|
||||
"yearly_product_id":"597312",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -72,9 +58,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_cost":"$150",
|
||||
"monthly_product_id":"642352",
|
||||
"yearly_cost":"$1200",
|
||||
"yearly_product_id":"642354",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -82,9 +66,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":20000000,
|
||||
"monthly_cost":"$225",
|
||||
"monthly_product_id":"642355",
|
||||
"yearly_cost":"$1800",
|
||||
"yearly_product_id":"642356",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -92,9 +74,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":50000000,
|
||||
"monthly_cost":"$330",
|
||||
"monthly_product_id":"650652",
|
||||
"yearly_cost":"$2640",
|
||||
"yearly_product_id":"650653",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_cost":"$6",
|
||||
"monthly_product_id":"654177",
|
||||
"yearly_cost":"$60",
|
||||
"yearly_product_id":"653232",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -12,9 +10,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_cost":"$12",
|
||||
"monthly_product_id":"654178",
|
||||
"yearly_cost":"$120",
|
||||
"yearly_product_id":"653234",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -22,9 +18,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_cost":"$20",
|
||||
"monthly_product_id":"653237",
|
||||
"yearly_cost":"$200",
|
||||
"yearly_product_id":"653236",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -32,9 +26,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_cost":"$30",
|
||||
"monthly_product_id":"653238",
|
||||
"yearly_cost":"$300",
|
||||
"yearly_product_id":"653239",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -42,9 +34,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_cost":"$50",
|
||||
"monthly_product_id":"653240",
|
||||
"yearly_cost":"$500",
|
||||
"yearly_product_id":"653242",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -52,9 +42,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_cost":"$70",
|
||||
"monthly_product_id":"653253",
|
||||
"yearly_cost":"$700",
|
||||
"yearly_product_id":"653254",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -62,9 +50,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_cost":"$100",
|
||||
"monthly_product_id":"653255",
|
||||
"yearly_cost":"$1000",
|
||||
"yearly_product_id":"653256",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -72,9 +58,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_cost":"$150",
|
||||
"monthly_product_id":"654181",
|
||||
"yearly_cost":"$1500",
|
||||
"yearly_product_id":"653257",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -82,9 +66,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":20000000,
|
||||
"monthly_cost":"$225",
|
||||
"monthly_product_id":"654182",
|
||||
"yearly_cost":"$2250",
|
||||
"yearly_product_id":"653258",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -92,9 +74,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":50000000,
|
||||
"monthly_cost":"$330",
|
||||
"monthly_product_id":"654183",
|
||||
"yearly_cost":"$3300",
|
||||
"yearly_product_id":"653259",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_cost":"$9",
|
||||
"monthly_product_id":"749342",
|
||||
"yearly_cost":"$90",
|
||||
"yearly_product_id":"749343",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -12,9 +10,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_cost":"$19",
|
||||
"monthly_product_id":"749344",
|
||||
"yearly_cost":"$190",
|
||||
"yearly_product_id":"749345",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -22,9 +18,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_cost":"$29",
|
||||
"monthly_product_id":"749346",
|
||||
"yearly_cost":"$290",
|
||||
"yearly_product_id":"749347",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -32,9 +26,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_cost":"$49",
|
||||
"monthly_product_id":"749348",
|
||||
"yearly_cost":"$490",
|
||||
"yearly_product_id":"749349",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -42,9 +34,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_cost":"$69",
|
||||
"monthly_product_id":"749350",
|
||||
"yearly_cost":"$690",
|
||||
"yearly_product_id":"749352",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -52,9 +42,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_cost":"$89",
|
||||
"monthly_product_id":"749353",
|
||||
"yearly_cost":"$890",
|
||||
"yearly_product_id":"749355",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -62,9 +50,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_cost":"$129",
|
||||
"monthly_product_id":"749356",
|
||||
"yearly_cost":"$1290",
|
||||
"yearly_product_id":"749357",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
@ -72,9 +58,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_cost":"$169",
|
||||
"monthly_product_id":"749358",
|
||||
"yearly_cost":"$1690",
|
||||
"yearly_product_id":"749359",
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
[
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_product_id":"change-me-749342",
|
||||
"yearly_product_id":"change-me-749343",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_product_id":"change-me-749344",
|
||||
"yearly_product_id":"change-me-749345",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_product_id":"change-me-749346",
|
||||
"yearly_product_id":"change-me-749347",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_product_id":"change-me-749348",
|
||||
"yearly_product_id":"change-me-749349",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_product_id":"change-me-749350",
|
||||
"yearly_product_id":"change-me-749352",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_product_id":"change-me-749353",
|
||||
"yearly_product_id":"change-me-749355",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_product_id":"change-me-749356",
|
||||
"yearly_product_id":"change-me-749357",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_product_id":"change-me-749358",
|
||||
"yearly_product_id":"change-me-749359",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_product_id":"change-me-b749342",
|
||||
"yearly_product_id":"change-me-b749343",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_product_id":"change-me-b749344",
|
||||
"yearly_product_id":"change-me-b749345",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_product_id":"change-me-b749346",
|
||||
"yearly_product_id":"change-me-b749347",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_product_id":"change-me-b749348",
|
||||
"yearly_product_id":"change-me-b749349",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_product_id":"change-me-b749350",
|
||||
"yearly_product_id":"change-me-b749352",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_product_id":"change-me-b749353",
|
||||
"yearly_product_id":"change-me-b749355",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_product_id":"change-me-b749356",
|
||||
"yearly_product_id":"change-me-b749357",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
},
|
||||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_product_id":"change-me-b749358",
|
||||
"yearly_product_id":"change-me-b749359",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
}
|
||||
]
|
||||
|
|
@ -2,9 +2,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_cost":"$9",
|
||||
"monthly_product_id":"63842",
|
||||
"yearly_cost":"$90",
|
||||
"yearly_product_id":"63859",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -12,9 +10,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_cost":"$19",
|
||||
"monthly_product_id":"63843",
|
||||
"yearly_cost":"$190",
|
||||
"yearly_product_id":"63860",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -22,9 +18,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_cost":"$29",
|
||||
"monthly_product_id":"63844",
|
||||
"yearly_cost":"$290",
|
||||
"yearly_product_id":"63861",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -32,9 +26,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_cost":"$49",
|
||||
"monthly_product_id":"63845",
|
||||
"yearly_cost":"$490",
|
||||
"yearly_product_id":"63862",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -42,9 +34,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_cost":"$69",
|
||||
"monthly_product_id":"63846",
|
||||
"yearly_cost":"$690",
|
||||
"yearly_product_id":"63863",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -52,9 +42,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_cost":"$89",
|
||||
"monthly_product_id":"63847",
|
||||
"yearly_cost":"$890",
|
||||
"yearly_product_id":"63864",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -62,9 +50,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_cost":"$129",
|
||||
"monthly_product_id":"63848",
|
||||
"yearly_cost":"$1290",
|
||||
"yearly_product_id":"63865",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -72,9 +58,7 @@
|
|||
{
|
||||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_cost":"$169",
|
||||
"monthly_product_id":"63849",
|
||||
"yearly_cost":"$1690",
|
||||
"yearly_product_id":"63866",
|
||||
"site_limit":10,
|
||||
"team_member_limit":5
|
||||
|
|
@ -82,9 +66,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":10000,
|
||||
"monthly_cost":"$19",
|
||||
"monthly_product_id":"63850",
|
||||
"yearly_cost":"$100",
|
||||
"yearly_product_id":"63867",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -92,9 +74,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":100000,
|
||||
"monthly_cost":"$29",
|
||||
"monthly_product_id":"63851",
|
||||
"yearly_cost":"$200",
|
||||
"yearly_product_id":"63868",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -102,9 +82,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":200000,
|
||||
"monthly_cost":"$39",
|
||||
"monthly_product_id":"63852",
|
||||
"yearly_cost":"$300",
|
||||
"yearly_product_id":"63869",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -112,9 +90,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":500000,
|
||||
"monthly_cost":"$59",
|
||||
"monthly_product_id":"63853",
|
||||
"yearly_cost":"$500",
|
||||
"yearly_product_id":"63870",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -122,9 +98,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":1000000,
|
||||
"monthly_cost":"$79",
|
||||
"monthly_product_id":"63854",
|
||||
"yearly_cost":"$700",
|
||||
"yearly_product_id":"63871",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -132,9 +106,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":2000000,
|
||||
"monthly_cost":"$99",
|
||||
"monthly_product_id":"63855",
|
||||
"yearly_cost":"$900",
|
||||
"yearly_product_id":"63872",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -142,9 +114,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":5000000,
|
||||
"monthly_cost":"$139",
|
||||
"monthly_product_id":"63856",
|
||||
"yearly_cost":"$1300",
|
||||
"yearly_product_id":"63873",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
@ -152,9 +122,7 @@
|
|||
{
|
||||
"kind":"business",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_cost":"$179",
|
||||
"monthly_product_id":"63857",
|
||||
"yearly_cost":"$1700",
|
||||
"yearly_product_id":"63874",
|
||||
"site_limit":50,
|
||||
"team_member_limit":50
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@
|
|||
"kind":"growth",
|
||||
"monthly_pageview_limit":150000000,
|
||||
"yearly_product_id":"648089",
|
||||
"yearly_cost":"$4800",
|
||||
"monthly_product_id":null,
|
||||
"monthly_cost":null,
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@
|
|||
"kind":"growth",
|
||||
"monthly_pageview_limit":10000000,
|
||||
"monthly_product_id":"655350",
|
||||
"monthly_cost":"$250",
|
||||
"yearly_product_id":null,
|
||||
"yearly_cost":null,
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
defmodule Plausible.Billing.PaddleApiTest do
|
||||
use Plausible.DataCase
|
||||
|
||||
import Mox
|
||||
setup :verify_on_exit!
|
||||
|
||||
@success "fixture/paddle_prices_success_response.json" |> File.read!() |> Jason.decode!()
|
||||
|
||||
describe "fetch_prices/1" do
|
||||
test "returns %Money{} structs per product_id when given a list of product_ids" do
|
||||
expect(
|
||||
Plausible.HTTPClient.Mock,
|
||||
:get,
|
||||
fn "https://checkout.paddle.com/api/2.0/prices",
|
||||
[{"content-type", "application/json"}, {"accept", "application/json"}],
|
||||
%{product_ids: "19878,20127,20657,20658"} ->
|
||||
{:ok,
|
||||
%Finch.Response{
|
||||
status: 200,
|
||||
headers: [{"content-type", "application/json"}],
|
||||
body: @success
|
||||
}}
|
||||
end
|
||||
)
|
||||
|
||||
assert Plausible.Billing.PaddleApi.fetch_prices(["19878", "20127", "20657", "20658"]) ==
|
||||
{:ok,
|
||||
%{
|
||||
"19878" => Money.new(:EUR, "6.0"),
|
||||
"20127" => Money.new(:EUR, "60.0"),
|
||||
"20657" => Money.new(:EUR, "12.34"),
|
||||
"20658" => Money.new(:EUR, "120.34")
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,31 +4,62 @@ defmodule Plausible.Billing.PlansTest do
|
|||
|
||||
@v1_plan_id "558018"
|
||||
@v2_plan_id "654177"
|
||||
@v3_plan_id "749342"
|
||||
@v4_plan_id "change-me-749342"
|
||||
@v4_business_plan_id "change-me-b749342"
|
||||
|
||||
describe "for_user" do
|
||||
test "shows v1 pricing for users who are already on v1 pricing" do
|
||||
describe "getting subscription plans for user" do
|
||||
test "growth_plans_for/1 shows v1 pricing for users who are already on v1 pricing" do
|
||||
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
|
||||
|
||||
assert List.first(Plans.for_user(user)).monthly_product_id == @v1_plan_id
|
||||
assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v1_plan_id
|
||||
end
|
||||
|
||||
test "shows v2 pricing for users who are already on v2 pricing" do
|
||||
test "growth_plans_for/1 shows v2 pricing for users who are already on v2 pricing" do
|
||||
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
|
||||
|
||||
assert List.first(Plans.for_user(user)).monthly_product_id == @v2_plan_id
|
||||
assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v2_plan_id
|
||||
end
|
||||
|
||||
test "shows v2 pricing for users who signed up in 2021" do
|
||||
test "growth_plans_for/1 shows v2 pricing for users who signed up in 2021" do
|
||||
user = insert(:user, inserted_at: ~N[2021-12-31 00:00:00])
|
||||
|
||||
assert List.first(Plans.for_user(user)).monthly_product_id == @v2_plan_id
|
||||
assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v2_plan_id
|
||||
end
|
||||
|
||||
test "shows v3 pricing for everyone else" do
|
||||
test "growth_plans_for/1 shows v4 pricing for everyone else" do
|
||||
user = insert(:user)
|
||||
|
||||
assert List.first(Plans.for_user(user)).monthly_product_id == @v3_plan_id
|
||||
assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v4_plan_id
|
||||
end
|
||||
|
||||
test "growth_plans_for/1 does not return business plans" do
|
||||
user = insert(:user)
|
||||
|
||||
Plans.growth_plans_for(user)
|
||||
|> Enum.each(fn plan ->
|
||||
assert plan.kind != :business
|
||||
end)
|
||||
end
|
||||
|
||||
test "business_plans/0 returns only v4 business plans" do
|
||||
Plans.business_plans()
|
||||
|> Enum.each(fn plan ->
|
||||
assert plan.kind == :business
|
||||
end)
|
||||
end
|
||||
|
||||
test "available_plans_with_prices/1" do
|
||||
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
|
||||
|
||||
%{growth: growth_plans, business: business_plans} = Plans.available_plans_with_prices(user)
|
||||
|
||||
assert Enum.find(growth_plans, fn plan ->
|
||||
(%Money{} = plan.monthly_cost) && plan.monthly_product_id == @v2_plan_id
|
||||
end)
|
||||
|
||||
assert Enum.find(business_plans, fn plan ->
|
||||
(%Money{} = plan.monthly_cost) && plan.monthly_product_id == @v4_business_plan_id
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -63,19 +94,19 @@ defmodule Plausible.Billing.PlansTest do
|
|||
|
||||
assert %Plausible.Billing.Plan{
|
||||
monthly_pageview_limit: 100_000,
|
||||
monthly_cost: "$12",
|
||||
monthly_cost: nil,
|
||||
monthly_product_id: "558745",
|
||||
volume: "100k",
|
||||
yearly_cost: "$96",
|
||||
yearly_cost: nil,
|
||||
yearly_product_id: "590752"
|
||||
} = Plans.suggest(user, 10_000)
|
||||
|
||||
assert %Plausible.Billing.Plan{
|
||||
monthly_pageview_limit: 200_000,
|
||||
monthly_cost: "$18",
|
||||
monthly_cost: nil,
|
||||
monthly_product_id: "597485",
|
||||
volume: "200k",
|
||||
yearly_cost: "$144",
|
||||
yearly_cost: nil,
|
||||
yearly_product_id: "597486"
|
||||
} = Plans.suggest(user, 100_000)
|
||||
end
|
||||
|
|
@ -123,7 +154,23 @@ defmodule Plausible.Billing.PlansTest do
|
|||
"749352",
|
||||
"749355",
|
||||
"749357",
|
||||
"749359"
|
||||
"749359",
|
||||
"change-me-749343",
|
||||
"change-me-749345",
|
||||
"change-me-749347",
|
||||
"change-me-749349",
|
||||
"change-me-749352",
|
||||
"change-me-749355",
|
||||
"change-me-749357",
|
||||
"change-me-749359",
|
||||
"change-me-b749343",
|
||||
"change-me-b749345",
|
||||
"change-me-b749347",
|
||||
"change-me-b749349",
|
||||
"change-me-b749352",
|
||||
"change-me-b749355",
|
||||
"change-me-b749357",
|
||||
"change-me-b749359"
|
||||
] == Plans.yearly_product_ids()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule Plausible.Billing.QuotaTest do
|
|||
@v1_plan_id "558018"
|
||||
@v2_plan_id "654177"
|
||||
@v3_plan_id "749342"
|
||||
@v4_growth_plan_id "change-me-749342"
|
||||
@v4_business_plan_id "change-me-b749342"
|
||||
|
||||
describe "site_limit/1" do
|
||||
test "returns 50 when user is on an old plan" do
|
||||
|
|
@ -349,5 +351,16 @@ defmodule Plausible.Billing.QuotaTest do
|
|||
|
||||
assert :unlimited == Quota.team_member_limit(user)
|
||||
end
|
||||
|
||||
test "reads from json file when the user is on a v4 plan" do
|
||||
user_on_growth =
|
||||
insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_growth_plan_id))
|
||||
|
||||
user_on_business =
|
||||
insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_business_plan_id))
|
||||
|
||||
assert 5 == Quota.team_member_limit(user_on_growth)
|
||||
assert 50 == Quota.team_member_limit(user_on_business)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,516 @@
|
|||
defmodule PlausibleWeb.Live.ChoosePlanTest do
|
||||
alias Plausible.{Repo, Billing.Subscription}
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
@v1_10k_yearly_plan_id "572810"
|
||||
@v4_growth_200k_yearly_plan_id "change-me-749347"
|
||||
@v4_business_5m_monthly_plan_id "change-me-b749356"
|
||||
|
||||
@monthly_interval_button ~s/label[phx-click="set_interval"][phx-value-interval="monthly"]/
|
||||
@yearly_interval_button ~s/label[phx-click="set_interval"][phx-value-interval="yearly"]/
|
||||
@interval_button_active_class "bg-indigo-600 text-white"
|
||||
@slider_input ~s/input[name="slider"]/
|
||||
@two_months_free "#two-months-free"
|
||||
|
||||
@growth_plan_box "#growth-plan-box"
|
||||
@growth_price_tag_amount "#growth-price-tag-amount"
|
||||
@growth_price_tag_interval "#growth-price-tag-interval"
|
||||
@growth_current_label "#{@growth_plan_box} #current-label"
|
||||
@growth_checkout_button "#growth-checkout"
|
||||
|
||||
@business_plan_box "#business-plan-box"
|
||||
@business_price_tag_amount "#business-price-tag-amount"
|
||||
@business_price_tag_interval "#business-price-tag-interval"
|
||||
@business_current_label "#{@business_plan_box} #current-label"
|
||||
@business_checkout_button "#business-checkout"
|
||||
|
||||
describe "for a user with no subscription" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
test "displays basic page content", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
|
||||
assert doc =~ "Upgrade your account"
|
||||
assert doc =~ "You have used <b>0</b>\nbillable pageviews in the last 30 days"
|
||||
assert doc =~ "Questions?"
|
||||
assert doc =~ "What happens if I go over my page views limit?"
|
||||
assert doc =~ "Enterprise"
|
||||
assert doc =~ "+ VAT if applicable"
|
||||
end
|
||||
|
||||
test "changing billing interval changes two months free colour", %{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
|
||||
assert class_of_element(doc, @two_months_free) =~ "text-gray-500"
|
||||
refute class_of_element(doc, @two_months_free) =~ "text-yellow-700"
|
||||
|
||||
doc = element(lv, @yearly_interval_button) |> render_click()
|
||||
|
||||
refute class_of_element(doc, @two_months_free) =~ "text-gray-500"
|
||||
assert class_of_element(doc, @two_months_free) =~ "text-yellow-700"
|
||||
end
|
||||
|
||||
test "default billing interval is monthly, and can switch to yearly", %{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
|
||||
assert class_of_element(doc, @monthly_interval_button) =~ @interval_button_active_class
|
||||
refute class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class
|
||||
|
||||
doc = element(lv, @yearly_interval_button) |> render_click()
|
||||
|
||||
refute class_of_element(doc, @monthly_interval_button) =~ @interval_button_active_class
|
||||
assert class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class
|
||||
end
|
||||
|
||||
test "default pageview limit is 10k", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert doc =~ "Monthly pageviews: <b>10k</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€10"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€90"
|
||||
end
|
||||
|
||||
test "pageview slider changes selected volume and prices shown", %{conn: conn} do
|
||||
{:ok, lv, _doc} = get_liveview(conn)
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 1})
|
||||
assert doc =~ "Monthly pageviews: <b>100k</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€20"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€100"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 2})
|
||||
assert doc =~ "Monthly pageviews: <b>200k</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€30"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€110"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 3})
|
||||
assert doc =~ "Monthly pageviews: <b>500k</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€40"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€120"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 4})
|
||||
assert doc =~ "Monthly pageviews: <b>1M</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€50"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€130"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 5})
|
||||
assert doc =~ "Monthly pageviews: <b>2M</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€60"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€140"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 6})
|
||||
assert doc =~ "Monthly pageviews: <b>5M</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€70"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€150"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 7})
|
||||
assert doc =~ "Monthly pageviews: <b>10M</b"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€80"
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€160"
|
||||
end
|
||||
|
||||
test "renders contact links for business and growth tiers when enterprise-level volume selected",
|
||||
%{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, lv, _doc} = get_liveview(conn)
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 8})
|
||||
|
||||
assert text_of_element(doc, @growth_plan_box) =~ "Custom"
|
||||
assert text_of_element(doc, @growth_plan_box) =~ "Contact us"
|
||||
assert text_of_element(doc, @business_plan_box) =~ "Custom"
|
||||
assert text_of_element(doc, @business_plan_box) =~ "Contact us"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 7})
|
||||
|
||||
refute text_of_element(doc, @growth_plan_box) =~ "Custom"
|
||||
refute text_of_element(doc, @growth_plan_box) =~ "Contact us"
|
||||
refute text_of_element(doc, @business_plan_box) =~ "Custom"
|
||||
refute text_of_element(doc, @business_plan_box) =~ "Contact us"
|
||||
end
|
||||
|
||||
test "switching billing interval changes business and growth prices", %{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€10"
|
||||
assert text_of_element(doc, @growth_price_tag_interval) == "/month"
|
||||
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€90"
|
||||
assert text_of_element(doc, @business_price_tag_interval) == "/month"
|
||||
|
||||
doc = element(lv, @yearly_interval_button) |> render_click()
|
||||
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€100"
|
||||
assert text_of_element(doc, @growth_price_tag_interval) == "/year"
|
||||
|
||||
assert text_of_element(doc, @business_price_tag_amount) == "€900"
|
||||
assert text_of_element(doc, @business_price_tag_interval) == "/year"
|
||||
end
|
||||
|
||||
test "checkout buttons are 'paddle buttons' with dynamic onclick attribute", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
{:ok, lv, _doc} = get_liveview(conn)
|
||||
|
||||
element(lv, @slider_input) |> render_change(%{slider: 2})
|
||||
doc = element(lv, @yearly_interval_button) |> render_click()
|
||||
|
||||
assert %{
|
||||
"disableLogout" => true,
|
||||
"email" => user.email,
|
||||
"passthrough" => user.id,
|
||||
"product" => @v4_growth_200k_yearly_plan_id,
|
||||
"success" => Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success),
|
||||
"theme" => "none"
|
||||
} == get_paddle_checkout_params(find(doc, @growth_checkout_button))
|
||||
|
||||
element(lv, @slider_input) |> render_change(%{slider: 6})
|
||||
doc = element(lv, @monthly_interval_button) |> render_click()
|
||||
|
||||
assert get_paddle_checkout_params(find(doc, @business_checkout_button))["product"] ==
|
||||
@v4_business_5m_monthly_plan_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a user with a v4 growth subscription plan" do
|
||||
setup [:create_user, :log_in, :subscribe_v4_growth]
|
||||
|
||||
test "displays basic page content", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
|
||||
assert doc =~ "Change subscription plan"
|
||||
assert doc =~ "Questions?"
|
||||
refute doc =~ "What happens if I go over my page views limit?"
|
||||
end
|
||||
|
||||
test "displays usage", %{conn: conn, user: user} do
|
||||
site = insert(:site, members: [user])
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview),
|
||||
build(:pageview)
|
||||
])
|
||||
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert doc =~ "You have used <b>2</b>\nbillable pageviews in the last 30 days"
|
||||
end
|
||||
|
||||
test "gets default selected interval from current subscription plan", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class
|
||||
end
|
||||
|
||||
test "gets default pageview limit from current subscription plan", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert doc =~ "Monthly pageviews: <b>200k</b"
|
||||
end
|
||||
|
||||
test "pageview slider changes selected volume", %{conn: conn} do
|
||||
{:ok, lv, _doc} = get_liveview(conn)
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 1})
|
||||
assert doc =~ "Monthly pageviews: <b>100k</b"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 0})
|
||||
assert doc =~ "Monthly pageviews: <b>10k</b"
|
||||
end
|
||||
|
||||
test "makes it clear that the user is currently on a growth tier", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
|
||||
class = class_of_element(doc, @growth_plan_box)
|
||||
|
||||
assert class =~ "ring-2"
|
||||
assert class =~ "ring-indigo-600"
|
||||
assert text_of_element(doc, @growth_current_label) == "Current"
|
||||
end
|
||||
|
||||
test "checkout button text and click-disabling CSS classes are dynamic", %{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Currently on this plan"
|
||||
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business"
|
||||
|
||||
doc = element(lv, @monthly_interval_button) |> render_click()
|
||||
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Change billing interval"
|
||||
assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 4})
|
||||
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Upgrade"
|
||||
assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 1})
|
||||
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Downgrade"
|
||||
assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business"
|
||||
end
|
||||
|
||||
test "checkout buttons are dynamic links to /billing/change-plan/preview/<plan_id>", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
|
||||
growth_checkout_button = find(doc, @growth_checkout_button)
|
||||
|
||||
assert text_of_attr(growth_checkout_button, "href") =~
|
||||
"/billing/change-plan/preview/#{@v4_growth_200k_yearly_plan_id}"
|
||||
|
||||
element(lv, @slider_input) |> render_change(%{slider: 6})
|
||||
doc = element(lv, @monthly_interval_button) |> render_click()
|
||||
|
||||
business_checkout_button = find(doc, @business_checkout_button)
|
||||
|
||||
assert text_of_attr(business_checkout_button, "href") =~
|
||||
"/billing/change-plan/preview/#{@v4_business_5m_monthly_plan_id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a user with a v4 business subscription plan" do
|
||||
setup [:create_user, :log_in, :subscribe_v4_business]
|
||||
|
||||
test "gets default pageview limit from current subscription plan", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert doc =~ "Monthly pageviews: <b>5M</b"
|
||||
end
|
||||
|
||||
test "makes it clear that the user is currently on a business tier", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
|
||||
class = class_of_element(doc, @business_plan_box)
|
||||
|
||||
assert class =~ "ring-2"
|
||||
assert class =~ "ring-indigo-600"
|
||||
assert text_of_element(doc, @business_current_label) == "Current"
|
||||
end
|
||||
|
||||
test "checkout button text and click-disabling CSS classes are dynamic", %{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
|
||||
assert text_of_element(doc, @business_checkout_button) == "Currently on this plan"
|
||||
assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth"
|
||||
|
||||
doc = element(lv, @yearly_interval_button) |> render_click()
|
||||
|
||||
assert text_of_element(doc, @business_checkout_button) == "Change billing interval"
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 7})
|
||||
|
||||
assert text_of_element(doc, @business_checkout_button) == "Upgrade"
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 1})
|
||||
|
||||
assert text_of_element(doc, @business_checkout_button) == "Downgrade"
|
||||
assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth"
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a user with a past_due subscription" do
|
||||
setup [:create_user, :log_in, :create_past_due_subscription]
|
||||
|
||||
test "renders failed payment notice and link to update billing details", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert doc =~ "There was a problem with your latest payment"
|
||||
assert doc =~ "https://update.billing.details"
|
||||
end
|
||||
|
||||
test "checkout buttons are disabled + notice about billing details (unless plan owned already)",
|
||||
%{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
assert text_of_element(doc, @growth_checkout_button) =~ "Currently on this plan"
|
||||
refute element_exists?(doc, "#{@growth_checkout_button} + p")
|
||||
|
||||
assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
|
||||
assert text_of_element(doc, "#{@business_checkout_button} + p") =~
|
||||
"Please update your billing details first"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 4})
|
||||
|
||||
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
|
||||
assert text_of_element(doc, "#{@growth_checkout_button} + p") =~
|
||||
"Please update your billing details first"
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a user with a paused subscription" do
|
||||
setup [:create_user, :log_in, :create_paused_subscription]
|
||||
|
||||
test "renders subscription paused notice and link to update billing details", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert doc =~ "Your subscription is paused due to failed payments"
|
||||
assert doc =~ "https://update.billing.details"
|
||||
end
|
||||
|
||||
test "checkout buttons are disabled + notice about billing details when plan not owned already",
|
||||
%{conn: conn} do
|
||||
{:ok, lv, doc} = get_liveview(conn)
|
||||
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
assert text_of_element(doc, @growth_checkout_button) =~ "Currently on this plan"
|
||||
refute element_exists?(doc, "#{@growth_checkout_button} + p")
|
||||
|
||||
assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
|
||||
assert text_of_element(doc, "#{@business_checkout_button} + p") =~
|
||||
"Please update your billing details first"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 4})
|
||||
|
||||
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400"
|
||||
|
||||
assert text_of_element(doc, "#{@growth_checkout_button} + p") =~
|
||||
"Please update your billing details first"
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a user with a cancelled subscription" do
|
||||
setup [:create_user, :log_in, :create_cancelled_subscription]
|
||||
|
||||
test "checkout buttons are paddle buttons", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~ "Paddle.Checkout.open"
|
||||
|
||||
assert text_of_attr(find(doc, @business_checkout_button), "onclick") =~
|
||||
"Paddle.Checkout.open"
|
||||
end
|
||||
|
||||
test "currently owned tier is highlighted if stats are still unlocked", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
assert text_of_element(doc, @growth_current_label) == "Current"
|
||||
end
|
||||
|
||||
test "currently owned tier is not highlighted if stats are locked", %{conn: conn, user: user} do
|
||||
user.subscription
|
||||
|> Subscription.changeset(%{next_bill_date: Timex.shift(Timex.now(), months: -2)})
|
||||
|> Repo.update()
|
||||
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
refute element_exists?(doc, @growth_current_label)
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a grandfathered user" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
test "on a v1 plan, Growth tiers are available at 20M, 50M, 50M+, but Business tiers are not",
|
||||
%{conn: conn, user: user} do
|
||||
create_subscription_for(user, paddle_plan_id: @v1_10k_yearly_plan_id)
|
||||
|
||||
{:ok, lv, _doc} = get_liveview(conn)
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 8})
|
||||
assert doc =~ "Monthly pageviews: <b>20M</b"
|
||||
assert text_of_element(doc, @business_plan_box) =~ "Contact us"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€900"
|
||||
assert text_of_element(doc, @growth_price_tag_interval) == "/year"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 9})
|
||||
assert doc =~ "Monthly pageviews: <b>50M</b"
|
||||
assert text_of_element(doc, @business_plan_box) =~ "Contact us"
|
||||
assert text_of_element(doc, @growth_price_tag_amount) == "€1K"
|
||||
assert text_of_element(doc, @growth_price_tag_interval) == "/year"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 10})
|
||||
assert doc =~ "Monthly pageviews: <b>50M+</b"
|
||||
assert text_of_element(doc, @business_plan_box) =~ "Contact us"
|
||||
assert text_of_element(doc, @growth_plan_box) =~ "Contact us"
|
||||
|
||||
doc = lv |> element(@slider_input) |> render_change(%{slider: 7})
|
||||
assert doc =~ "Monthly pageviews: <b>10M</b"
|
||||
refute text_of_element(doc, @business_plan_box) =~ "Contact us"
|
||||
refute text_of_element(doc, @growth_plan_box) =~ "Contact us"
|
||||
end
|
||||
end
|
||||
|
||||
describe "for a free_10k subscription" do
|
||||
setup [:create_user, :log_in, :subscribe_free_10k]
|
||||
|
||||
test "does not highlight any tier", %{conn: conn} do
|
||||
{:ok, _lv, doc} = get_liveview(conn)
|
||||
refute element_exists?(doc, @growth_current_label)
|
||||
refute element_exists?(doc, @business_current_label)
|
||||
end
|
||||
|
||||
test "renders Paddle upgrade buttons", %{conn: conn, user: user} do
|
||||
{:ok, lv, _doc} = get_liveview(conn)
|
||||
|
||||
element(lv, @slider_input) |> render_change(%{slider: 2})
|
||||
doc = element(lv, @yearly_interval_button) |> render_click()
|
||||
|
||||
assert %{
|
||||
"disableLogout" => true,
|
||||
"email" => user.email,
|
||||
"passthrough" => user.id,
|
||||
"product" => @v4_growth_200k_yearly_plan_id,
|
||||
"success" => Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success),
|
||||
"theme" => "none"
|
||||
} == get_paddle_checkout_params(find(doc, @growth_checkout_button))
|
||||
end
|
||||
end
|
||||
|
||||
defp subscribe_v4_growth(%{user: user}) do
|
||||
create_subscription_for(user, paddle_plan_id: @v4_growth_200k_yearly_plan_id)
|
||||
end
|
||||
|
||||
defp subscribe_v4_business(%{user: user}) do
|
||||
create_subscription_for(user, paddle_plan_id: @v4_business_5m_monthly_plan_id)
|
||||
end
|
||||
|
||||
defp create_past_due_subscription(%{user: user}) do
|
||||
create_subscription_for(user,
|
||||
paddle_plan_id: @v4_growth_200k_yearly_plan_id,
|
||||
status: "past_due",
|
||||
update_url: "https://update.billing.details"
|
||||
)
|
||||
end
|
||||
|
||||
defp create_paused_subscription(%{user: user}) do
|
||||
create_subscription_for(user,
|
||||
paddle_plan_id: @v4_growth_200k_yearly_plan_id,
|
||||
status: "paused",
|
||||
update_url: "https://update.billing.details"
|
||||
)
|
||||
end
|
||||
|
||||
defp create_cancelled_subscription(%{user: user}) do
|
||||
create_subscription_for(user,
|
||||
paddle_plan_id: @v4_growth_200k_yearly_plan_id,
|
||||
status: "deleted"
|
||||
)
|
||||
end
|
||||
|
||||
defp create_subscription_for(user, subscription_options) do
|
||||
insert(:subscription, Keyword.put(subscription_options, :user, user))
|
||||
{:ok, user: Plausible.Users.with_subscription(user)}
|
||||
end
|
||||
|
||||
defp subscribe_free_10k(%{user: user}) do
|
||||
Plausible.Billing.Subscription.free(%{user_id: user.id})
|
||||
|> Repo.insert!()
|
||||
|
||||
{:ok, user: user}
|
||||
end
|
||||
|
||||
defp get_liveview(conn) do
|
||||
conn = assign(conn, :live_module, PlausibleWeb.Live.ChoosePlan)
|
||||
{:ok, _lv, _doc} = live(conn, "/billing/choose-plan")
|
||||
end
|
||||
|
||||
defp get_paddle_checkout_params(element) do
|
||||
with onclick <- text_of_attr(element, "onclick"),
|
||||
[[_, checkout_params_str]] <- Regex.scan(~r/Paddle\.Checkout\.open\((.*?)\)/, onclick),
|
||||
{:ok, checkout_params} <- Jason.decode(checkout_params_str) do
|
||||
checkout_params
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -37,6 +37,12 @@ defmodule Plausible.Test.Support.HTML do
|
|||
|> String.trim()
|
||||
end
|
||||
|
||||
def class_of_element(html, element) do
|
||||
html
|
||||
|> find(element)
|
||||
|> text_of_attr("class")
|
||||
end
|
||||
|
||||
def text_of_attr(element, attr) do
|
||||
element
|
||||
|> Floki.attribute(attr)
|
||||
|
|
|
|||
|
|
@ -52,4 +52,21 @@ defmodule Plausible.PaddleApi.Mock do
|
|||
]}
|
||||
end
|
||||
end
|
||||
|
||||
# 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
|
||||
{prices, _index} =
|
||||
Enum.reduce(product_ids, {%{}, 1}, fn p, {acc, i} ->
|
||||
price =
|
||||
if rem(i, 2) == 1,
|
||||
do: ceil(i / 2.0) * 10.0,
|
||||
else: ceil(i / 2.0) * 100.0
|
||||
|
||||
{Map.put(acc, p, Money.from_float!(:EUR, price)), i + 1}
|
||||
end)
|
||||
|
||||
{:ok, prices}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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(:business_tier)
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
|
||||
ExUnit.configure(exclude: [:slow])
|
||||
|
|
|
|||
Loading…
Reference in New Issue