Track billing cycles (#697)

* Add dummy check usage worker

* Add and persist last_bill_date

* Remove name clash

* Correct timing and suggestions for over limit emails

* Fix tests for trial upgrade notifications
This commit is contained in:
Uku Taht 2021-02-12 10:17:53 +02:00 committed by GitHub
parent 77628d04c9
commit a8cb187e8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 363 additions and 99 deletions

View File

@ -65,7 +65,8 @@ defmodule Plausible.Billing do
changeset = changeset =
Subscription.changeset(subscription, %{ Subscription.changeset(subscription, %{
next_bill_amount: amount, next_bill_amount: amount,
next_bill_date: api_subscription["next_payment"]["date"] next_bill_date: api_subscription["next_payment"]["date"],
last_bill_date: params["event_time"]
}) })
Repo.update(changeset) Repo.update(changeset)
@ -133,6 +134,10 @@ defmodule Plausible.Billing do
pageviews + custom_events pageviews + custom_events
end end
def last_two_billing_months_usage(_user) do
{1, 2}
end
def usage_breakdown(user) do def usage_breakdown(user) do
user = Repo.preload(user, :sites) user = Repo.preload(user, :sites)

View File

@ -1,44 +1,67 @@
defmodule Plausible.Billing.Plans do defmodule Plausible.Billing.Plans do
@plans %{ @monthly_plans [
monthly: %{ %{product_id: "558018", cost: "$6", limit: 10_000, cycle: "monthly"},
"10k": %{product_id: "558018", due_now: "$6"}, %{product_id: "558745", cost: "$12", limit: 100_000, cycle: "monthly"},
"100k": %{product_id: "558745", due_now: "$12"}, %{product_id: "597485", cost: "$18", limit: 200_000, cycle: "monthly"},
"200k": %{product_id: "597485", due_now: "$18"}, %{product_id: "597487", cost: "$27", limit: 500_000, cycle: "monthly"},
"500k": %{product_id: "597487", due_now: "$27"}, %{product_id: "597642", cost: "$48", limit: 1_000_000, cycle: "monthly"},
"1m": %{product_id: "597642", due_now: "$48"}, %{product_id: "597309", cost: "$69", limit: 2_000_000, cycle: "monthly"},
"2m": %{product_id: "597309", due_now: "$69"}, %{product_id: "597311", cost: "$99", limit: 5_000_000, cycle: "monthly"},
"5m": %{product_id: "597311", due_now: "$99"}, %{product_id: "642352", cost: "$150", limit: 10_000_000, cycle: "monthly"},
"10m": %{product_id: "642352", due_now: "$150"}, %{product_id: "642355", cost: "$225", limit: 20_000_000, cycle: "monthly"}
"20m": %{product_id: "642355", due_now: "$225"} ]
},
yearly: %{ @yearly_plans [
"10k": %{product_id: "572810", due_now: "$48"}, %{product_id: "572810", cost: "$48", limit: 10_000, cycle: "yearly"},
"100k": %{product_id: "590752", due_now: "$96"}, %{product_id: "590752", cost: "$96", limit: 100_000, cycle: "yearly"},
"200k": %{product_id: "597486", due_now: "$144"}, %{product_id: "597486", cost: "$144", limit: 200_000, cycle: "yearly"},
"500k": %{product_id: "597488", due_now: "$216"}, %{product_id: "597488", cost: "$216", limit: 500_000, cycle: "yearly"},
"1m": %{product_id: "597643", due_now: "$384"}, %{product_id: "597643", cost: "$384", limit: 1_000_000, cycle: "yearly"},
"2m": %{product_id: "597310", due_now: "$552"}, %{product_id: "597310", cost: "$552", limit: 2_000_000, cycle: "yearly"},
"5m": %{product_id: "597312", due_now: "$792"}, %{product_id: "597312", cost: "$792", limit: 5_000_000, cycle: "yearly"},
"10m": %{product_id: "642354", due_now: "$1200"}, %{product_id: "642354", cost: "$1200", limit: 10_000_000, cycle: "yearly"},
"20m": %{product_id: "642356", due_now: "$1800"} %{product_id: "642356", cost: "$1800", limit: 20_000_000, cycle: "yearly"}
} ]
}
@all_plans @monthly_plans ++ @yearly_plans
def plans do def plans do
@plans monthly =
@monthly_plans
|> Enum.map(fn plan -> {String.to_atom(number_format(plan[:limit])), plan} end)
|> Enum.into(%{})
yearly =
@yearly_plans
|> Enum.map(fn plan -> {String.to_atom(number_format(plan[:limit])), plan} end)
|> Enum.into(%{})
%{
monthly: monthly,
yearly: yearly
}
end
def suggested_plan_name(usage) do
plan = suggested_plan(usage)
number_format(plan[:limit]) <> "/mo"
end
def suggested_plan_cost(usage) do
plan = suggested_plan(usage)
plan[:cost] <> "/mo"
end
defp suggested_plan(usage) do
Enum.find(@monthly_plans, fn plan -> usage < plan[:limit] end)
end end
def allowance(subscription) do def allowance(subscription) do
allowed_volume = %{ Enum.find(@all_plans, fn plan -> plan[:product_id] == subscription.paddle_plan_id end)
"free_10k" => 10_000, |> Map.fetch!(:limit)
"558018" => 10_000, end
"572810" => 10_000,
"558745" => 100_000,
"590752" => 100_000,
"558746" => 1_000_000,
"590753" => 1_000_000
}
allowed_volume[subscription.paddle_plan_id] defp number_format(num) do
PlausibleWeb.StatsView.large_number_format(num)
end end
end end

View File

@ -12,6 +12,8 @@ defmodule Plausible.Billing.Subscription do
:next_bill_date, :next_bill_date,
:user_id :user_id
] ]
@optional_fields [:last_bill_date]
@valid_statuses ["active", "past_due", "deleted", "paused"] @valid_statuses ["active", "past_due", "deleted", "paused"]
schema "subscriptions" do schema "subscriptions" do
@ -22,6 +24,7 @@ defmodule Plausible.Billing.Subscription do
field :status, :string field :status, :string
field :next_bill_amount, :string field :next_bill_amount, :string
field :next_bill_date, :date field :next_bill_date, :date
field :last_bill_date, :date
belongs_to :user, Plausible.Auth.User belongs_to :user, Plausible.Auth.User
@ -30,7 +33,7 @@ defmodule Plausible.Billing.Subscription do
def changeset(model, attrs \\ %{}) do def changeset(model, attrs \\ %{}) do
model model
|> cast(attrs, @required_fields) |> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields) |> validate_required(@required_fields)
|> validate_inclusion(:status, @valid_statuses) |> validate_inclusion(:status, @valid_statuses)
|> unique_constraint(:paddle_subscription_id) |> unique_constraint(:paddle_subscription_id)

View File

@ -112,6 +112,18 @@ defmodule PlausibleWeb.Email do
}) })
end end
def over_limit_email(user, usage) do
base_email()
# Temporary testing
|> to(["uku@plausible.io", "marko@plausible.io"])
|> tag("over-limit")
|> subject("You have outgrown your Plausible subscription tier ")
|> render("over_limit.html", %{
user: user,
usage: usage
})
end
def cancellation_email(user) do def cancellation_email(user) do
base_email() base_email()
|> to(user.email) |> to(user.email)

View File

@ -60,7 +60,7 @@
</div> </div>
<div class="mt-6 text-right"> <div class="mt-6 text-right">
<div class="mb-4 text-sm font-medium dark:text-gray-100">Due today: <b x-text="window.plans[billingCycle][volume].due_now">$6</b></div> <div class="mb-4 text-sm font-medium dark:text-gray-100">Due today: <b x-text="window.plans[billingCycle][volume].cost">$6</b></div>
<span class="inline-flex rounded-md shadow-sm"> <span class="inline-flex rounded-md shadow-sm">
<button type="button" data-theme="none" :data-product="window.plans[billingCycle][volume].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="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent paddle_button 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"> <button type="button" data-theme="none" :data-product="window.plans[billingCycle][volume].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="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent paddle_button 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">
<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> <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>

View File

@ -0,0 +1,32 @@
Hey <%= user_salutation(@user) %>,
<br /><br />
Thanks for being a Plausible Analytics subscriber!
<br /><br />
This is a friendly reminder that your traffic has exceeded your subscription tier two months in a row. Congrats on all that traffic!
<br /><br />
We don't enforce any hard limits at the moment, we're still counting your stats and you have access to your dashboard, but we kindly ask you to upgrade your subscription plan to accommodate your new traffic levels.
<br /><br />
In the last month, your account has used <%= @usage %> billable pageviews.
<%= if @usage <= 20_000_000 do %>
Based on that we recommend you select the <%= Plausible.Billing.Plans.suggested_plan_name(@usage) %> plan which runs at <%= Plausible.Billing.Plans.suggested_plan_cost(@usage) %>. You can also go with yearly billing to get 33% off.
<br /><br />
You can upgrade your subscription using our self-serve platform. The new charge will be prorated to reflect the amount you have already paid and the time until your current subscription is supposed to expire.
<br /><br />
<a href="https://plausible.io/settings">Click here</a> to go to your site settings. You can upgrade your subscription tier by clicking the 'change plan' link.
<% else %>
This is more than our standard plans, so please reply back to this email to get a quote for your volume.
<% end %>
<br /><br />
Were the last two months extraordinary and you don't expect these higher traffic levels to continue? Reply to this email and we'll figure it out.
<br /><br />
Have questions or need help with anything? Just reply to this email and we'll gladly help.
<br /><br />
Thanks again for using our product and for your support!
<br /><br />
Uku and Marko
<br /><br />
--
<br /><br />
<%= plausible_url() %><br />
{{{ pm:unsubscribe }}}

View File

@ -3,8 +3,8 @@ Hey <%= user_salutation(@user) %>,
Thanks for exploring Plausible, a simple and privacy-friendly alternative to Google Analytics. Your free 30-day trial is ending <%= @day %>, but you can keep using Plausible by upgrading to a paid plan. Thanks for exploring Plausible, a simple and privacy-friendly alternative to Google Analytics. Your free 30-day trial is ending <%= @day %>, but you can keep using Plausible by upgrading to a paid plan.
<br /><br /> <br /><br />
In the last month, your account has used <%= PlausibleWeb.AuthView.delimit_integer(@usage) %> billable pageviews<%= if @custom_events > 0, do: " and custom events in total", else: "" %>. In the last month, your account has used <%= PlausibleWeb.AuthView.delimit_integer(@usage) %> billable pageviews<%= if @custom_events > 0, do: " and custom events in total", else: "" %>.
<%= if @usage <= 4_500_000 do %> <%= if @usage <= 20_000_000 do %>
Based on that we recommend you select the <%= suggested_plan_name(@usage) %> plan which runs at <%= suggested_plan_cost(@usage) %>. Based on that we recommend you select the <%= Plausible.Billing.Plans.suggested_plan_name(@usage) %> plan which runs at <%= Plausible.Billing.Plans.suggested_plan_cost(@usage) %>.
You can also go with yearly billing to get 33% off on your plan. You can also go with yearly billing to get 33% off on your plan.
<br /><br /> <br /><br />

View File

@ -20,60 +20,4 @@ defmodule PlausibleWeb.EmailView do
"" ""
end end
end end
def suggested_plan_name(usage) do
cond do
usage <= 9_000 ->
"10k/mo"
usage <= 90_000 ->
"100k/mo"
usage <= 180_000 ->
"200k/mo"
usage <= 450_000 ->
"500k/mo"
usage <= 900_000 ->
"1m/mo"
usage <= 1_800_000 ->
"2m/mo"
usage <= 4_500_000 ->
"5m/mo"
true ->
throw("Huge account")
end
end
def suggested_plan_cost(usage) do
cond do
usage <= 9_000 ->
"$6/mo"
usage <= 90_000 ->
"$12/mo"
usage <= 180_000 ->
"$18/mo"
usage <= 450_000 ->
"$27/mo"
usage <= 900_000 ->
"$48/mo"
usage <= 1_800_000 ->
"$69/mo"
usage <= 4_500_000 ->
"$99/mo"
true ->
throw("Huge account")
end
end
end end

View File

@ -0,0 +1,61 @@
defmodule Plausible.Workers.CheckUsage do
use Plausible.Repo
use Oban.Worker, queue: :check_usage
defmacro yesterday() do
quote do
fragment("now() - INTERVAL '1 day'")
end
end
defmacro last_day_of_month(day) do
quote do
fragment(
"(date_trunc('month', ?::date) + interval '1 month' - interval '1 day')::date",
unquote(day)
)
end
end
defmacro day_of_month(date) do
quote do
fragment("EXTRACT(day from ?::date)", unquote(date))
end
end
defmacro least(left, right) do
quote do
fragment("least(?, ?)", unquote(left), unquote(right))
end
end
@impl Oban.Worker
def perform(_args, _job, billing_mod \\ Plausible.Billing) do
yesterday = Timex.today() |> Timex.shift(days: -1)
active_subscribers =
Repo.all(
from u in Plausible.Auth.User,
join: s in Plausible.Billing.Subscription,
on: s.user_id == u.id,
where: s.status == "active",
# 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]
)
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)
if last_last_month > allowance && last_month > allowance do
template = PlausibleWeb.Email.over_limit_email(subscriber, last_month)
Plausible.Mailer.send_email(template)
end
end
:ok
end
end

View File

@ -0,0 +1,9 @@
defmodule Plausible.Repo.Migrations.AddLastPaymentDetails do
use Ecto.Migration
def change do
alter table(:subscriptions) do
add :last_bill_date, :date
end
end
end

View File

@ -220,18 +220,20 @@ defmodule Plausible.BillingTest do
end end
describe "subscription_payment_succeeded" do describe "subscription_payment_succeeded" do
test "sets the next bill amount and date" do test "sets the next bill amount and date, last bill date" do
user = insert(:user) user = insert(:user)
subscription = insert(:subscription, user: user) subscription = insert(:subscription, user: user)
Billing.subscription_payment_succeeded(%{ Billing.subscription_payment_succeeded(%{
"alert_name" => "subscription_payment_succeeded", "alert_name" => "subscription_payment_succeeded",
"subscription_id" => subscription.paddle_subscription_id "subscription_id" => subscription.paddle_subscription_id,
"event_time" => "2019-06-10 09:40:20"
}) })
subscription = Repo.get_by(Plausible.Billing.Subscription, user_id: user.id) subscription = Repo.get_by(Plausible.Billing.Subscription, user_id: user.id)
assert subscription.next_bill_date == ~D[2019-07-10] assert subscription.next_bill_date == ~D[2019-07-10]
assert subscription.next_bill_amount == "6.00" assert subscription.next_bill_amount == "6.00"
assert subscription.last_bill_date == ~D[2019-06-10]
end end
test "ignores if the subscription cannot be found" do test "ignores if the subscription cannot be found" do

View File

@ -0,0 +1,13 @@
defmodule Plausible.Billing.PlansTest do
use Plausible.DataCase
use Bamboo.Test, shared: true
alias Plausible.Billing.Plans
test "suggested plan name" do
assert Plans.suggested_plan_name(110_000) == "200k/mo"
end
test "suggested plan cost" do
assert Plans.suggested_plan_cost(110_000) == "$18/mo"
end
end

View File

@ -0,0 +1,146 @@
defmodule Plausible.Workers.CheckUsageTest do
use Plausible.DataCase
use Bamboo.Test
import Double
import Plausible.TestUtils
alias Plausible.Workers.CheckUsage
alias Plausible.Billing.Plans
setup [:create_user, :create_site]
@paddle_id_10k Plans.plans()[:monthly][:"10k"][:product_id]
test "ignores user without subscription" do
CheckUsage.perform(nil, nil)
assert_no_emails_delivered()
end
test "ignores user with subscription but no usage", %{user: user} do
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), days: -1)
)
CheckUsage.perform(nil, nil)
assert_no_emails_delivered()
end
test "does not send an email if account has been over the limit for one billing month", %{
user: user
} do
billing_stub =
stub(Plausible.Billing, :last_two_billing_months_usage, fn _user -> {9_000, 11_000} end)
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), days: -1)
)
CheckUsage.perform(nil, nil, billing_stub)
assert_no_emails_delivered()
end
test "sends an email when an account is over their limit for two consecutive billing months", %{
user: user
} do
billing_stub =
stub(Plausible.Billing, :last_two_billing_months_usage, fn _user -> {11_000, 11_000} end)
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), days: -1)
)
CheckUsage.perform(nil, nil, billing_stub)
assert_email_delivered_with(
# to: [user],
subject: "You have outgrown your Plausible subscription tier "
)
end
describe "timing" do
test "checks usage one day after the last_bill_date", %{
user: user
} do
billing_stub =
stub(Plausible.Billing, :last_two_billing_months_usage, fn _user -> {11_000, 11_000} end)
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), days: -1)
)
CheckUsage.perform(nil, nil, billing_stub)
assert_email_delivered_with(
# to: [user],
subject: "You have outgrown your Plausible subscription tier "
)
end
test "for yearly subscriptions, does not check exactly one month after last_bill_date", %{
user: user
} do
billing_stub =
stub(Plausible.Billing, :last_two_billing_months_usage, fn _user -> {11_000, 11_000} end)
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), months: -1)
)
CheckUsage.perform(nil, nil, billing_stub)
assert_no_emails_delivered()
end
test "for yearly subscriptions, checks usage one month + one day after the last_bill_date", %{
user: user
} do
billing_stub =
stub(Plausible.Billing, :last_two_billing_months_usage, fn _user -> {11_000, 11_000} end)
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), months: -1, days: -1)
)
CheckUsage.perform(nil, nil, billing_stub)
assert_email_delivered_with(
# to: [user],
subject: "You have outgrown your Plausible subscription tier "
)
end
test "for yearly subscriptions, checks usage multiple months + one day after the last_bill_date",
%{
user: user
} do
billing_stub =
stub(Plausible.Billing, :last_two_billing_months_usage, fn _user -> {11_000, 11_000} end)
insert(:subscription,
user: user,
paddle_plan_id: @paddle_id_10k,
last_bill_date: Timex.shift(Timex.today(), months: -2, days: -1)
)
CheckUsage.perform(nil, nil, billing_stub)
assert_email_delivered_with(
# to: [user],
subject: "You have outgrown your Plausible subscription tier "
)
end
end
end

View File

@ -134,10 +134,24 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
assert email.html_body =~ "we recommend you select the 5m/mo plan which runs at $99/mo." assert email.html_body =~ "we recommend you select the 5m/mo plan which runs at $99/mo."
end end
test "suggests 10m/mo plan" do
user = insert(:user)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", {9_000_000, 0})
assert email.html_body =~ "we recommend you select the 10m/mo plan which runs at $150/mo."
end
test "suggests 20m/mo plan" do
user = insert(:user)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", {19_000_000, 0})
assert email.html_body =~ "we recommend you select the 20m/mo plan which runs at $225/mo."
end
test "does not suggest a plan above that" do test "does not suggest a plan above that" do
user = insert(:user) user = insert(:user)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", {10_000_000, 0}) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", {50_000_000, 0})
assert email.html_body =~ "please reply back to this email to get a quote for your volume" assert email.html_body =~ "please reply back to this email to get a quote for your volume"
end end
end end