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:
RobertJoonas 2023-10-03 13:36:22 +03:00 committed by GitHub
parent 16ce0f1ea8
commit 8bc86d165f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2111 additions and 319 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"> &rarr;</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"> &rarr;</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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,26 @@
<%= 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>
<span
:if={@subscription}
class={[
"inline-flex items-center 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 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={[
"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"
) %>

View File

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

View File

@ -0,0 +1,4 @@
<%= live_render(@conn, PlausibleWeb.Live.ChoosePlan,
id: "choose-plan",
session: %{"user_id" => @user.id}
) %>

View File

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

View File

@ -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"> &rarr;</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"> &rarr;</span>
<% end %>
</div>
</div>
</div>
</aside>
<% end %>

View File

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

View File

@ -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."/>
<%= 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 %>" />
<%= 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 %>
<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") %>"/>

View File

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

View File

@ -1,5 +1,6 @@
defmodule PlausibleWeb.LayoutView do
use PlausibleWeb, :view
import PlausibleWeb.Components.Billing
def base_domain do
PlausibleWeb.Endpoint.host()

View File

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

View File

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

View File

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

130
priv/plans_v4.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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