diff --git a/config/runtime.exs b/config/runtime.exs index f31c2bc41b047ccb882e1304713d623910512246..103537cd509eddb12ac8edd5179479db7f5375ae 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -377,6 +377,14 @@ config :kaffy, resources: [ site: [schema: Plausible.Site, admin: Plausible.SiteAdmin] ] + ], + billing: [ + resources: [ + enterprise_plan: [ + schema: Plausible.Billing.EnterprisePlan, + admin: Plausible.Billing.EnterprisePlanAdmin + ] + ] ] ] diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index a367672b0ebe7cd57e06fa521c002b08be2488a4..5c0fe34cc37cd3c54eacbc287fb949c19c58eef5 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -25,6 +25,7 @@ defmodule Plausible.Auth.User do has_many :api_keys, Plausible.Auth.ApiKey has_one :google_auth, Plausible.Site.GoogleAuth has_one :subscription, Plausible.Billing.Subscription + has_one :enterprise_plan, Plausible.Billing.EnterprisePlan timestamps() end diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex new file mode 100644 index 0000000000000000000000000000000000000000..65141098323636619f0af1119bb7b296a48296e1 --- /dev/null +++ b/lib/plausible/billing/enterprise_plan.ex @@ -0,0 +1,30 @@ +defmodule Plausible.Billing.EnterprisePlan do + use Ecto.Schema + import Ecto.Changeset + + @required_fields [ + :user_id, + :paddle_plan_id, + :billing_interval, + :monthly_pageview_limit, + :hourly_api_request_limit + ] + + schema "enterprise_plans" do + field :paddle_plan_id, :string + field :billing_interval, Ecto.Enum, values: [:monthly, :yearly] + field :monthly_pageview_limit, :integer + field :hourly_api_request_limit, :integer + + belongs_to :user, Plausible.Auth.User + + timestamps() + end + + def changeset(model, attrs \\ %{}) do + model + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + |> unique_constraint(:user_id) + end +end diff --git a/lib/plausible/billing/enterprise_plan_admin.ex b/lib/plausible/billing/enterprise_plan_admin.ex new file mode 100644 index 0000000000000000000000000000000000000000..616eea5b66a055d6ea2d30eaf7e5bc21f1d4b227 --- /dev/null +++ b/lib/plausible/billing/enterprise_plan_admin.ex @@ -0,0 +1,37 @@ +defmodule Plausible.Billing.EnterprisePlanAdmin do + use Plausible.Repo + + def search_fields(_schema) do + [ + :paddle_plan_id, + user: [:name, :email] + ] + end + + def form_fields(_) do + [ + user_id: nil, + paddle_plan_id: nil, + billing_interval: %{choices: [{"Yearly", "yearly"}, {"Monthly", "monthly"}]}, + monthly_pageview_limit: nil, + hourly_api_request_limit: nil + ] + end + + def custom_index_query(_conn, _schema, query) do + from(r in query, preload: :user) + end + + def index(_) do + [ + id: nil, + user_email: %{value: &get_user_email/1}, + paddle_plan_id: nil, + billing_interval: nil, + monthly_pageview_limit: nil, + hourly_api_request_limit: nil + ] + end + + defp get_user_email(plan), do: plan.user.email +end diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex index 7cc0895a41a38fdaaf85c93352630334d92b6913..9f7d7062a6cd3c586eab483a0064c763dbe53cfc 100644 --- a/lib/plausible/billing/plans.ex +++ b/lib/plausible/billing/plans.ex @@ -1,4 +1,6 @@ defmodule Plausible.Billing.Plans do + use Plausible.Repo + @unlisted_plans_v1 [ %{limit: 150_000_000, yearly_product_id: "648089", yearly_cost: "$4800"} ] @@ -30,21 +32,15 @@ defmodule Plausible.Billing.Plans do end) end - def subscription_quota("free_10k"), do: "10k" - - def subscription_quota(product_id) do - case for_product_id(product_id) do - nil -> raise "Unknown quota for subscription #{product_id}" - product -> number_format(product[:limit]) - end - end - def subscription_interval("free_10k"), do: "N/A" def subscription_interval(product_id) do case for_product_id(product_id) do nil -> - raise "Unknown interval for subscription #{product_id}" + enterprise_plan = + Repo.get_by(Plausible.Billing.EnterprisePlan, paddle_plan_id: product_id) + + enterprise_plan && enterprise_plan.billing_interval plan -> if product_id == plan[:monthly_product_id] do @@ -62,6 +58,11 @@ defmodule Plausible.Billing.Plans do if found do Map.fetch!(found, :limit) + else + enterprise_plan = + Repo.get_by(Plausible.Billing.EnterprisePlan, paddle_plan_id: subscription.paddle_plan_id) + + enterprise_plan && enterprise_plan.monthly_pageview_limit end end diff --git a/lib/plausible/mailer.ex b/lib/plausible/mailer.ex index 83fd6ba80e3ca49ee5f412a5975f3b549b335ffb..8768e542a3a7c39b08cde31142766a39ac6fbe54 100644 --- a/lib/plausible/mailer.ex +++ b/lib/plausible/mailer.ex @@ -14,4 +14,16 @@ defmodule Plausible.Mailer do reraise error, __STACKTRACE__ end end + + def send_email_safe(email) do + try do + Plausible.Mailer.deliver_now!(email) + rescue + error -> + Sentry.capture_exception(error, + stacktrace: __STACKTRACE__, + extra: %{extra: "Error while sending email"} + ) + end + end end diff --git a/lib/plausible_web/controllers/billing_controller.ex b/lib/plausible_web/controllers/billing_controller.ex index 5de38f183a8fdb03f23075bc1865b62111a70627..7b81f276fa186dfc1094b4cda5b7eeddfeadb580 100644 --- a/lib/plausible_web/controllers/billing_controller.ex +++ b/lib/plausible_web/controllers/billing_controller.ex @@ -6,20 +6,122 @@ defmodule PlausibleWeb.BillingController do plug PlausibleWeb.RequireAccountPlug - def admin_email do - Application.get_env(:plausible, :admin_email) + def upgrade(conn, _params) do + user = + conn.assigns[:current_user] + |> Repo.preload(:enterprise_plan) + + cond do + user.subscription && user.subscription.status == "active" -> + redirect(conn, to: Routes.billing_path(conn, :change_plan_form)) + + user.enterprise_plan -> + redirect(conn, + to: Routes.billing_path(conn, :upgrade_enterprise_plan, user.enterprise_plan.id) + ) + + true -> + render(conn, "upgrade.html", + usage: Plausible.Billing.usage(user), + user: user, + layout: {PlausibleWeb.LayoutView, "focus.html"} + ) + end end - def change_plan_form(conn, _params) do - subscription = Billing.active_subscription_for(conn.assigns[:current_user].id) + def upgrade_enterprise_plan(conn, %{"plan_id" => plan_id}) do + user = + conn.assigns[:current_user] + |> Repo.preload(:enterprise_plan) - if subscription do - render(conn, "change_plan.html", - subscription: subscription, + if user.enterprise_plan && user.enterprise_plan.id == String.to_integer(plan_id) do + usage = Plausible.Billing.usage(conn.assigns[:current_user]) + + render(conn, "upgrade_to_plan.html", + usage: usage, + user: user, layout: {PlausibleWeb.LayoutView, "focus.html"} ) else - redirect(conn, to: "/billing/upgrade") + render_error(conn, 404) + end + end + + def upgrade_to_plan(conn, %{"plan_id" => plan_id}) do + plan = Plausible.Billing.Plans.for_product_id(plan_id) + + if plan do + cycle = if plan[:monthly_product_id] == plan_id, do: "monthly", else: "yearly" + plan = Map.merge(plan, %{cycle: cycle, product_id: plan_id}) + usage = Plausible.Billing.usage(conn.assigns[:current_user]) + + render(conn, "upgrade_to_plan.html", + usage: usage, + plan: plan, + user: conn.assigns[:current_user], + layout: {PlausibleWeb.LayoutView, "focus.html"} + ) + else + render_error(conn, 404) + end + end + + def upgrade_success(conn, _params) do + render(conn, "upgrade_success.html", layout: {PlausibleWeb.LayoutView, "focus.html"}) + end + + def change_plan_form(conn, _params) do + user = + conn.assigns[:current_user] + |> Repo.preload(:enterprise_plan) + + subscription = Billing.active_subscription_for(user.id) + + cond do + subscription && user.enterprise_plan -> + redirect(conn, + to: Routes.billing_path(conn, :change_enterprise_plan, user.enterprise_plan.id) + ) + + subscription -> + render(conn, "change_plan.html", + subscription: subscription, + layout: {PlausibleWeb.LayoutView, "focus.html"} + ) + + true -> + redirect(conn, to: Routes.billing_path(conn, :upgrade)) + end + end + + def change_enterprise_plan(conn, %{"plan_id" => plan_id}) do + user = + conn.assigns[:current_user] + |> Repo.preload(:enterprise_plan) + + cond do + is_nil(user.subscription) -> + redirect(conn, to: "/billing/upgrade") + + is_nil(user.enterprise_plan) -> + render_error(conn, 404) + + user.enterprise_plan.id !== String.to_integer(plan_id) -> + render_error(conn, 404) + + user.enterprise_plan.paddle_plan_id == user.subscription.paddle_plan_id -> + render(conn, "change_enterprise_plan_contact_us.html", + user: user, + plan: user.enterprise_plan, + layout: {PlausibleWeb.LayoutView, "focus.html"} + ) + + true -> + render(conn, "change_enterprise_plan.html", + user: user, + plan: user.enterprise_plan, + layout: {PlausibleWeb.LayoutView, "focus.html"} + ) end end @@ -77,39 +179,4 @@ defmodule PlausibleWeb.BillingController do |> redirect(to: "/settings") end end - - def upgrade(conn, _params) do - usage = Plausible.Billing.usage(conn.assigns[:current_user]) - today = Timex.today() - - render(conn, "upgrade.html", - usage: usage, - today: today, - user: conn.assigns[:current_user], - layout: {PlausibleWeb.LayoutView, "focus.html"} - ) - end - - def upgrade_to_plan(conn, %{"plan_id" => plan_id}) do - plan = Plausible.Billing.Plans.for_product_id(plan_id) - - if plan do - cycle = if plan[:monthly_product_id] == plan_id, do: "monthly", else: "yearly" - plan = Map.merge(plan, %{cycle: cycle, product_id: plan_id}) - usage = Plausible.Billing.usage(conn.assigns[:current_user]) - - render(conn, "upgrade_to_plan.html", - usage: usage, - plan: plan, - user: conn.assigns[:current_user], - layout: {PlausibleWeb.LayoutView, "focus.html"} - ) - else - render_error(conn, 404) - end - end - - def upgrade_success(conn, _params) do - render(conn, "upgrade_success.html", layout: {PlausibleWeb.LayoutView, "focus.html"}) - end end diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index d21b1008c05f8b2e71c8775b64ac736926a61457..d2f6fee7bb9f2b269d2ecfdb43aea00449d93647 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -119,7 +119,7 @@ defmodule PlausibleWeb.Email do base_email() |> to(user) |> tag("over-limit") - |> subject("You have outgrown your Plausible subscription tier ") + |> subject("You have outgrown your Plausible subscription tier") |> render("over_limit.html", %{ user: user, usage: usage, @@ -128,6 +128,18 @@ defmodule PlausibleWeb.Email do }) end + def enterprise_over_limit_email(user, usage, last_cycle) do + base_email() + |> to("enterprise@plausible.io") + |> tag("enterprise-over-limit") + |> subject("#{user.email} has outgrown their enterprise plan") + |> render("enterprise_over_limit.html", %{ + user: user, + usage: usage, + last_cycle: last_cycle + }) + end + def yearly_renewal_notification(user) do date = Timex.format!(user.subscription.next_bill_date, "{Mfull} {D}, {YYYY}") diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 158da85013efa4d74173612d4fadbd581b5d89ab..233a814ce7dd7f8a8de215f23937b7efebcf4aef 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -147,6 +147,8 @@ defmodule PlausibleWeb.Router do post "/billing/change-plan/:new_plan_id", BillingController, :change_plan get "/billing/upgrade", BillingController, :upgrade 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 get "/billing/upgrade-success", BillingController, :upgrade_success get "/sites", SiteController, :index diff --git a/lib/plausible_web/templates/billing/change_enterprise_plan.html.eex b/lib/plausible_web/templates/billing/change_enterprise_plan.html.eex new file mode 100644 index 0000000000000000000000000000000000000000..81318c05b4c9bdd038edefb392a5049b951f0100 --- /dev/null +++ b/lib/plausible_web/templates/billing/change_enterprise_plan.html.eex @@ -0,0 +1,68 @@ +<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/fetch-jsonp/1.1.3/fetch-jsonp.min.js"></script> +<div class="mx-auto mt-6 text-center"> + <h1 class="text-3xl font-black dark:text-gray-100">Change subscription plan</h1> +</div> + +<script> + plan = function() { + return { + localizedPlan: null, + price() { + var currency = { + 'USD': '$', + 'EUR': '€', + 'GBP': '£' + }[this.localizedPlan.currency] + + return currency + this.localizedPlan.price.net + }, + fetchPlan() { + fetchJsonp('https://checkout.paddle.com/api/2.0/prices?product_ids=<%= @plan.paddle_plan_id %>') + .then(res => res.json()) + .then((data) => { + this.localizedPlan = data.response.products[0] + }) + } + } + } +</script> + +<div class="w-full max-w-lg px-4 mx-auto mt-4"> + <div x-init="fetchPlan()" x-data="window.plan()" class="flex-1 p-8 mt-8 bg-white rounded shadow-md dark:bg-gray-800"> + <div x-show="!localizedPlan" class="mx-auto my-40 loading sm"><div></div></div> + <template x-if="localizedPlan"> + <div> + <div class="w-full pb-4 dark:text-gray-100"> + <span>We've prepared your account for an upgrade to custom limits outside the listed plans:</span> + </div> + + <ul class="w-full py-4 dark:text-gray-100"> + <li>Up to <b><%= PlausibleWeb.StatsView.large_number_format(@plan.monthly_pageview_limit) %></b> monthly pageviews</li> + <li>Up to <b><%= PlausibleWeb.StatsView.large_number_format(@plan.hourly_api_request_limit) %></b> hourly api requests</li> + </ul> + + <ul class="w-full py-4 dark:text-gray-100"> + <span>The plan is priced at</span> + <template x-if="localizedPlan"><b x-text="price()"></b> </template> + <span>per <%= if @plan.billing_interval == :yearly, do: "year", else: "month" %>. On the next page, our payment provider will calculate the prorated amount that your card will be charged if you decide to upgrade now.</span> + </ul> + + <div class="mt-6 text-left"> + <span class="inline-flex w-full rounded-md shadow-sm"> + <%= link(to: Routes.billing_path(@conn, :change_plan_preview, @plan.paddle_plan_id), class: "inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent leading-5 rounded-md hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150 ") do %> + <svg fill="currentColor" viewBox="0 0 20 20" class="inline w-4 h-4 mr-2"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path></svg> + Preview changes + <% end %> + </span> + </div> + </div> + </template> + </div> +</div> + +<div class="mt-8 text-center dark:text-gray-100"> + Questions? Contact <%= link("support@plausible.io", to: "mailto: support@plausible.io", class: "text-indigo-500") %> +</div> + +<script type="text/javascript" src="https://cdn.paddle.com/paddle/paddle.js"></script> +<script>Paddle.Setup({vendor: 49430})</script> diff --git a/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex b/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex new file mode 100644 index 0000000000000000000000000000000000000000..67a85d883de2dc927f6daa6c3b7c22971f20c153 --- /dev/null +++ b/lib/plausible_web/templates/billing/change_enterprise_plan_contact_us.html.eex @@ -0,0 +1,16 @@ +<div class="mx-auto mt-6 text-center"> + <h1 class="text-3xl font-black dark:text-gray-100">Change subscription plan</h1> +</div> + +<div class="w-full max-w-lg px-4 mx-auto mt-4"> + <div class="flex-1 p-8 mt-8 bg-white rounded shadow-md dark:bg-gray-800"> + <div class="w-full pb-4 dark:text-gray-100"> + <span>Need to change your limits?</span> + </div> + + <ul class="w-full py-4 dark:text-gray-100"> + <span>Your account is on an enterprise plan. If you want to increase or decrease the limits on your account, please contact us at enterprise@plausible.io</span> + </ul> + + </div> +</div> diff --git a/lib/plausible_web/templates/billing/change_plan_preview.html.eex b/lib/plausible_web/templates/billing/change_plan_preview.html.eex index 8622e19aa281bafcd766dd254ac9d726682f9307..98cf7e215c6da616030955a10e0c2a9c951cf6c6 100644 --- a/lib/plausible_web/templates/billing/change_plan_preview.html.eex +++ b/lib/plausible_web/templates/billing/change_plan_preview.html.eex @@ -36,7 +36,7 @@ <div class="pt-6"></div> - <div class="py-2 text-lg font-bold">Next payment</div> + <div class="py-4 dark:text-gray-100 text-lg font-bold">Next payment</div> <div class="flex flex-col"> diff --git a/lib/plausible_web/templates/billing/upgrade.html.eex b/lib/plausible_web/templates/billing/upgrade.html.eex index 569e383ba6edb92227ffc1c857590aef08450555..c2d9c4e8273916de9a55179bf9a40a55c243999a 100644 --- a/lib/plausible_web/templates/billing/upgrade.html.eex +++ b/lib/plausible_web/templates/billing/upgrade.html.eex @@ -25,7 +25,6 @@ 'GBP': '£' }[plan.currency] - console.log(plan) return currency + plan.price.net }, fetchPlans() { diff --git a/lib/plausible_web/templates/billing/upgrade_enterprise_plan.html.eex b/lib/plausible_web/templates/billing/upgrade_enterprise_plan.html.eex new file mode 100644 index 0000000000000000000000000000000000000000..7167f01a17e2041b45f01890b01868be09942d08 --- /dev/null +++ b/lib/plausible_web/templates/billing/upgrade_enterprise_plan.html.eex @@ -0,0 +1,44 @@ +<div class="mx-auto mt-6 text-center"> + <h1 class="text-3xl font-black dark:text-gray-100">Upgrade your free trial</h1> +</div> + +<div> + <div class="flex flex-col w-full max-w-4xl px-4 mx-auto mt-4 md:flex-row"> + <div class="flex-1 px-8 py-4 mt-8 mb-4 bg-white rounded shadow-md dark:bg-gray-800"> + <div class="w-full py-4 dark:text-gray-100"> + <span>You've used <b><%= PlausibleWeb.AuthView.delimit_integer(@usage) %></b> billable pageviews in the last 30 days</span> + </div> + + <div class="w-full py-4 dark:text-gray-100"> + <span>With this link you can upgrade to an enterprise plan with <b><%= PlausibleWeb.StatsView.large_number_format(@plan[:limit]) %> monthly pageviews</b></span>, billed on a <%= @plan[:cycle] %> basis. + </div> + + <div class="mt-6 text-left"> + <span class="inline-flex w-full rounded-md shadow-sm"> + <button type="button" data-theme="none" data-product="<%= @plan[:product_id] %>" data-email="<%= @conn.assigns[:current_user].email %>" data-disable-logout="true" data-passthrough="<%= @conn.assigns[:current_user].id %>" data-success="/billing/upgrade-success" class="items-center button paddle_button"> + <svg fill="currentColor" viewBox="0 0 20 20" class="inline w-4 h-4 mr-2"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path></svg> + Pay securely via Paddle + </button> + </span> + </div> + </div> + + <div class="flex-1 pl-8 pt-14"> + <h3 class="text-lg font-medium text-gray-900 leading-6 dark:text-gray-100"> + What happens if I go over my page views limit? + </h3> + <div class="mt-2 text-base text-gray-500 leading-6 dark:text-gray-200"> + You will never be charged extra for an occasional traffic spike. There are no surprise fees and your card will never be charged unexpectedly.<br /><br /> + 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> + + </div> + </div> +</div> + +<div class="mt-8 text-center dark:text-gray-100"> + Questions? Contact <%= link("support@plausible.io", to: "mailto: support@plausible.io", class: "text-indigo-500") %> +</div> + +<script type="text/javascript" src="https://cdn.paddle.com/paddle/paddle.js"></script> +<script>Paddle.Setup({vendor: 49430})</script> diff --git a/lib/plausible_web/templates/billing/upgrade_to_plan.html.eex b/lib/plausible_web/templates/billing/upgrade_to_plan.html.eex index 3adcd89023f50cb403a834df4dedc032344185fe..f0340668ba2d897cda6c14aebb92dc45f3f9950e 100644 --- a/lib/plausible_web/templates/billing/upgrade_to_plan.html.eex +++ b/lib/plausible_web/templates/billing/upgrade_to_plan.html.eex @@ -10,12 +10,12 @@ </div> <div class="w-full py-4 dark:text-gray-100"> - <span>With this link you can upgrade to a plan with <b><%= PlausibleWeb.StatsView.large_number_format(@plan[:limit]) %> monthly pageviews</b></span>, billed on a <%= @plan[:cycle] %> basis. + <span>With this link you can upgrade to an enterprise plan with <b><%= PlausibleWeb.StatsView.large_number_format(@user.enterprise_plan.monthly_pageview_limit) %> monthly pageviews</b></span>, billed on a <%= @user.enterprise_plan.billing_interval %> basis. </div> <div class="mt-6 text-left"> <span class="inline-flex w-full rounded-md shadow-sm"> - <button type="button" data-theme="none" data-product="<%= @plan[:product_id] %>" data-email="<%= @conn.assigns[:current_user].email %>" data-disable-logout="true" data-passthrough="<%= @conn.assigns[:current_user].id %>" data-success="/billing/upgrade-success" class="items-center button paddle_button"> + <button type="button" data-theme="none" data-product="<%= @user.enterprise_plan.paddle_plan_id %>" data-email="<%= @conn.assigns[:current_user].email %>" data-disable-logout="true" data-passthrough="<%= @conn.assigns[:current_user].id %>" data-success="/billing/upgrade-success" class="items-center button paddle_button"> <svg fill="currentColor" viewBox="0 0 20 20" class="inline w-4 h-4 mr-2"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path></svg> Pay securely via Paddle </button> diff --git a/lib/plausible_web/templates/email/enterprise_over_limit.html.eex b/lib/plausible_web/templates/email/enterprise_over_limit.html.eex new file mode 100644 index 0000000000000000000000000000000000000000..6fea4741b28a6e79d8c7aeedfb6f3f96e7341543 --- /dev/null +++ b/lib/plausible_web/templates/email/enterprise_over_limit.html.eex @@ -0,0 +1,8 @@ +Automated notice about an account that has gone over their enteprise plan limit. + +Customer email: <% @user.email %> +Last billing cycle: <%= date_format(@last_cycle.first) %> to <%= date_format(@last_cycle.last) %> +Usage: <%= PlausibleWeb.StatsView.large_number_format(@usage) %> billable pageviews + +--<br /> +<%= plausible_url() %><br /> diff --git a/lib/workers/check_usage.ex b/lib/workers/check_usage.ex index d6703c8115f0d16c632e658c11a6438e74865db1..a596b86dd321d9311d6ad32f2f81155ff3493a56 100644 --- a/lib/workers/check_usage.ex +++ b/lib/workers/check_usage.ex @@ -38,31 +38,47 @@ defmodule Plausible.Workers.CheckUsage do from u in Plausible.Auth.User, join: s in Plausible.Billing.Subscription, on: s.user_id == u.id, + left_join: ep in Plausible.Billing.EnterprisePlan, + on: ep.user_id == u.id, where: s.status == "active", where: not is_nil(s.last_bill_date), # Accounts for situations like last_bill_date==2021-01-31 AND today==2021-03-01. Since February never reaches the 31st day, the account is checked on 2021-03-01. where: least(day_of_month(s.last_bill_date), day_of_month(last_day_of_month(^yesterday))) == day_of_month(^yesterday), - preload: [subscription: s] + preload: [subscription: s, enterprise_plan: ep] ) for subscriber <- active_subscribers do allowance = Plausible.Billing.Plans.allowance(subscriber.subscription) {last_last_month, last_month} = billing_mod.last_two_billing_months_usage(subscriber) + is_over_limit = last_last_month > allowance && last_month > allowance - if last_last_month > allowance && last_month > allowance do - {_, last_cycle} = billing_mod.last_two_billing_cycles(subscriber) - suggested_plan = Plausible.Billing.Plans.suggested_plan(subscriber, last_month) + cond do + is_over_limit && subscriber.enterprise_plan -> + {_, last_cycle} = billing_mod.last_two_billing_cycles(subscriber) - template = - PlausibleWeb.Email.over_limit_email(subscriber, last_month, last_cycle, suggested_plan) + template = + PlausibleWeb.Email.enterprise_over_limit_email(subscriber, last_month, last_cycle) - try do - Plausible.Mailer.send_email(template) - rescue - _ -> nil - end + Plausible.Mailer.send_email_safe(template) + + is_over_limit -> + {_, last_cycle} = billing_mod.last_two_billing_cycles(subscriber) + suggested_plan = Plausible.Billing.Plans.suggested_plan(subscriber, last_month) + + template = + PlausibleWeb.Email.over_limit_email( + subscriber, + last_month, + last_cycle, + suggested_plan + ) + + Plausible.Mailer.send_email_safe(template) + + true -> + nil end end diff --git a/priv/repo/migrations/20211020093238_add_enterprise_plans.exs b/priv/repo/migrations/20211020093238_add_enterprise_plans.exs new file mode 100644 index 0000000000000000000000000000000000000000..f6b9e23b401113f79f4a8b58502956c432988393 --- /dev/null +++ b/priv/repo/migrations/20211020093238_add_enterprise_plans.exs @@ -0,0 +1,19 @@ +defmodule Plausible.Repo.Migrations.AddEnterprisePlans do + use Ecto.Migration + + def change do + create_query = "CREATE TYPE billing_interval AS ENUM ('monthly', 'yearly')" + drop_query = "DROP TYPE billing_interval" + execute(create_query, drop_query) + + create table(:enterprise_plans) do + add :user_id, references(:users), null: false, unique: true + add :paddle_plan_id, :string, null: false + add :billing_interval, :billing_interval, null: false + add :monthly_pageview_limit, :integer, null: false + add :hourly_api_request_limit, :integer, null: false + + timestamps() + end + end +end diff --git a/test/plausible_web/controllers/billing_controller_test.exs b/test/plausible_web/controllers/billing_controller_test.exs index 61e02d81c6361e29fc0c56ce2c024b26d6e838df..8c0b408c03d78ff57ea3d99dacae32cd97e01689 100644 --- a/test/plausible_web/controllers/billing_controller_test.exs +++ b/test/plausible_web/controllers/billing_controller_test.exs @@ -2,6 +2,46 @@ defmodule PlausibleWeb.BillingControllerTest do use PlausibleWeb.ConnCase import Plausible.TestUtils + describe "GET /upgrade" do + setup [:create_user, :log_in] + + test "shows upgrade page when user does not have a subcription already", %{conn: conn} do + conn = get(conn, "/billing/upgrade") + + assert html_response(conn, 200) =~ "Upgrade your free trial" + end + + test "redirects user to change plan if they already have a plan", %{conn: conn, user: user} do + insert(:subscription, user: user) + conn = get(conn, "/billing/upgrade") + + assert redirected_to(conn) == "/billing/change-plan" + end + + test "redirects user to enteprise plan page if they are configured with one", %{ + conn: conn, + user: user + } do + plan = insert(:enterprise_plan, user: user) + conn = get(conn, "/billing/upgrade") + + assert redirected_to(conn) == "/billing/upgrade/enterprise/#{plan.id}" + end + end + + describe "GET /upgrade/enterprise/:plan_id" do + setup [:create_user, :log_in] + + test "renders enteprise plan upgrade page", %{conn: conn, user: user} do + plan = insert(:enterprise_plan, user: user) + + conn = get(conn, "/billing/upgrade/enterprise/#{plan.id}") + + assert html_response(conn, 200) =~ "Upgrade your free trial" + assert html_response(conn, 200) =~ "enterprise plan" + end + end + describe "GET /change-plan" do setup [:create_user, :log_in] @@ -17,6 +57,37 @@ defmodule PlausibleWeb.BillingControllerTest do assert redirected_to(conn) == "/billing/upgrade" end + + test "redirects to enterprise change plan page if user has enterprise plan and existing subscription", + %{conn: conn, user: user} do + insert(:subscription, user: user) + plan = insert(:enterprise_plan, user: user) + conn = get(conn, "/billing/change-plan") + + assert redirected_to(conn) == "/billing/change-plan/enterprise/#{plan.id}" + end + end + + describe "GET /change-plan/enterprise/:plan_id" do + setup [:create_user, :log_in] + + test "shows change plan page if user has subsription and enterprise plan", %{ + conn: conn, + user: user + } do + insert(:subscription, user: user) + plan = insert(:enterprise_plan, user: user) + conn = get(conn, "/billing/change-plan/enterprise/#{plan.id}") + + assert html_response(conn, 200) =~ "Change subscription plan" + end + + test "renders 404 is user does not have enterprise plan", %{conn: conn, user: user} do + insert(:subscription, user: user) + conn = get(conn, "/billing/change-plan/enterprise/123") + + assert conn.status == 404 + end end describe "POST /change-plan" do diff --git a/test/support/factory.ex b/test/support/factory.ex index 3e6e11dae27410c9584fc8b22905b0bdbc300a95..02727def53c14ca89d67580101832f7b3f989864 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -116,6 +116,15 @@ defmodule Plausible.Factory do } end + def enterprise_plan_factory do + %Plausible.Billing.EnterprisePlan{ + paddle_plan_id: sequence(:paddle_plan_id, &"plan-#{&1}"), + billing_interval: :monthly, + monthly_pageview_limit: 1_000_000, + hourly_api_request_limit: 3000 + } + end + def google_auth_factory do %Plausible.Site.GoogleAuth{ email: sequence(:google_auth_email, &"email-#{&1}@email.com"), diff --git a/test/workers/check_usage_test.exs b/test/workers/check_usage_test.exs index 4df0daac05fc9d4a5b9622241cecdca6a9150ecc..7441f64e8a77a3cef3fbd5f1e9e1cd3bef8ded4d 100644 --- a/test/workers/check_usage_test.exs +++ b/test/workers/check_usage_test.exs @@ -64,7 +64,34 @@ defmodule Plausible.Workers.CheckUsageTest do assert_email_delivered_with( to: [user], - subject: "You have outgrown your Plausible subscription tier " + subject: "You have outgrown your Plausible subscription tier" + ) + end + + test "checks usage for enterprise customer, sends usage information to enterprise@plausible.io", + %{ + user: user + } do + billing_stub = + Plausible.Billing + |> stub(:last_two_billing_months_usage, fn _user -> {1_100_000, 1_100_000} end) + |> stub(:last_two_billing_cycles, fn _user -> + {Date.range(Timex.today(), Timex.today()), Date.range(Timex.today(), Timex.today())} + end) + + enterprise_plan = insert(:enterprise_plan, user: user, monthly_pageview_limit: 1_000_000) + + insert(:subscription, + user: user, + paddle_plan_id: enterprise_plan.paddle_plan_id, + last_bill_date: Timex.shift(Timex.today(), days: -1) + ) + + CheckUsage.perform(nil, billing_stub) + + assert_email_delivered_with( + to: [{nil, "enterprise@plausible.io"}], + subject: "#{user.email} has outgrown their enterprise plan" ) end @@ -89,7 +116,7 @@ defmodule Plausible.Workers.CheckUsageTest do assert_email_delivered_with( to: [user], - subject: "You have outgrown your Plausible subscription tier " + subject: "You have outgrown your Plausible subscription tier" ) end @@ -135,7 +162,7 @@ defmodule Plausible.Workers.CheckUsageTest do assert_email_delivered_with( to: [user], - subject: "You have outgrown your Plausible subscription tier " + subject: "You have outgrown your Plausible subscription tier" ) end end