From 6ca7425dc9c76e1a271316096619af05c6adaff2 Mon Sep 17 00:00:00 2001
From: Uku Taht <Uku.taht@gmail.com>
Date: Wed, 21 Apr 2021 15:57:38 +0300
Subject: [PATCH] Send renewal notification for annual subscriptions (#949)

* Add background job to send renewal notification

* Use copy from Marko

* Add expiration email in case the user has cancelled

* Add queue config

* Use correct tag for expiration email
---
 config/runtime.exs                            |   5 +-
 lib/plausible/billing/plans.ex                |   4 +
 lib/plausible_web/email.ex                    |  28 ++++
 .../yearly_expiration_notification.html.eex   |  13 ++
 .../yearly_renewal_notification.html.eex      |  13 ++
 lib/workers/notify_annual_renewal.ex          |  50 +++++++
 ...0075623_add_sent_renewal_notifications.exs |  10 ++
 test/workers/notify_annual_renewal_test.exs   | 134 ++++++++++++++++++
 8 files changed, 256 insertions(+), 1 deletion(-)
 create mode 100644 lib/plausible_web/templates/email/yearly_expiration_notification.html.eex
 create mode 100644 lib/plausible_web/templates/email/yearly_renewal_notification.html.eex
 create mode 100644 lib/workers/notify_annual_renewal.ex
 create mode 100644 priv/repo/migrations/20210420075623_add_sent_renewal_notifications.exs
 create mode 100644 test/workers/notify_annual_renewal_test.exs

diff --git a/config/runtime.exs b/config/runtime.exs
index 7c9cc142..82aaf85e 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -195,6 +195,8 @@ if config_env() == :prod && !disable_cron do
     {"0 12 * * *", Plausible.Workers.SendTrialNotifications},
     # Daily at 14
     {"0 14 * * *", Plausible.Workers.CheckUsage},
+    # Daily at 15
+    {"0 15 * * *", Plausible.Workers.NotifyAnnualRenewal},
     # Every 10 minutes
     {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates}
   ]
@@ -213,7 +215,8 @@ if config_env() == :prod && !disable_cron do
   extra_queues = [
     provision_ssl_certificates: 1,
     trial_notification_emails: 1,
-    check_usage: 1
+    check_usage: 1,
+    notify_annual_renewal: 1
   ]
 
   config :plausible, Oban,
diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex
index fe9f9d6c..e10b09d8 100644
--- a/lib/plausible/billing/plans.ex
+++ b/lib/plausible/billing/plans.ex
@@ -69,6 +69,10 @@ defmodule Plausible.Billing.Plans do
     }
   end
 
+  def yearly_plan_ids do
+    Enum.map(@yearly_plans, fn plan -> plan[:product_id] end)
+  end
+
   def for_product_id(product_id) do
     Enum.find(@all_plans, fn plan -> plan[:product_id] == product_id end)
   end
diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex
index 6df1d1db..42ca64ba 100644
--- a/lib/plausible_web/email.ex
+++ b/lib/plausible_web/email.ex
@@ -124,6 +124,34 @@ defmodule PlausibleWeb.Email do
     })
   end
 
+  def yearly_renewal_notification(user) do
+    date = Timex.format!(user.subscription.next_bill_date, "{Mfull} {D}, {YYYY}")
+
+    base_email()
+    |> to(user)
+    |> tag("yearly-renewal")
+    |> subject("Your Plausible subscription is up for renewal")
+    |> render("yearly_renewal_notification.html", %{
+      user: user,
+      date: date,
+      next_bill_amount: user.subscription.next_bill_amount
+    })
+  end
+
+  def yearly_expiration_notification(user) do
+    date = Timex.format!(user.subscription.next_bill_date, "{Mfull} {D}, {YYYY}")
+
+    base_email()
+    |> to(user)
+    |> tag("yearly-expiration")
+    |> subject("Your Plausible subscription is about to expire")
+    |> render("yearly_expiration_notification.html", %{
+      user: user,
+      date: date,
+      next_bill_amount: user.subscription.next_bill_amount
+    })
+  end
+
   def cancellation_email(user) do
     base_email()
     |> to(user.email)
diff --git a/lib/plausible_web/templates/email/yearly_expiration_notification.html.eex b/lib/plausible_web/templates/email/yearly_expiration_notification.html.eex
new file mode 100644
index 00000000..8ee223a8
--- /dev/null
+++ b/lib/plausible_web/templates/email/yearly_expiration_notification.html.eex
@@ -0,0 +1,13 @@
+Hey <%= user_salutation(@user) %>,
+<br /><br />
+Time flies! This is a reminder that your annual subscription for Plausible Analytics will expire on <%= @date %>.
+<br /><br />
+You need to renew your subscription on <%= link("account settings page", to: "#{plausible_url()}/billing/upgrade") %> if you want to continue using Plausible to count your website stats in a privacy-friendly way.
+<br /><br />
+If you don't want to continue your subscription, there's no action required. You will lose access to the stats on <%= @date %>.
+<br /><br />
+Have a question, feedback or need some guidance? Just reply to this email to get in touch!
+<br /><br />
+<br /><br />
+Thanks for being a Plausible Analytics subscriber!<br />
+<%= plausible_url() %><br />
diff --git a/lib/plausible_web/templates/email/yearly_renewal_notification.html.eex b/lib/plausible_web/templates/email/yearly_renewal_notification.html.eex
new file mode 100644
index 00000000..d61a2467
--- /dev/null
+++ b/lib/plausible_web/templates/email/yearly_renewal_notification.html.eex
@@ -0,0 +1,13 @@
+Hey <%= user_salutation(@user) %>,
+<br /><br />
+Time flies! This is a reminder that your annual subscription for Plausible Analytics is due to renew on <%= @date %>. We will automatically charge $<%= @next_bill_amount %> from your preferred billing method.
+<br /><br />
+There's no action required if you're happy to continue using Plausible to count your website stats in a privacy-friendly way.
+<br /><br />
+If you don't want to continue your subscription, you can cancel it on your <%= link("account settings page", to: "#{plausible_url()}/billing/upgrade") %>.
+<br /><br />
+Have a question, feedback or need some guidance? Just reply to this email to get in touch!
+<br /><br />
+<br /><br />
+Thanks for being a Plausible Analytics subscriber!<br />
+<%= plausible_url() %><br />
diff --git a/lib/workers/notify_annual_renewal.ex b/lib/workers/notify_annual_renewal.ex
new file mode 100644
index 00000000..25666a8b
--- /dev/null
+++ b/lib/workers/notify_annual_renewal.ex
@@ -0,0 +1,50 @@
+defmodule Plausible.Workers.NotifyAnnualRenewal do
+  use Plausible.Repo
+  use Oban.Worker, queue: :notify_annual_renewal
+
+  @yearly_plans Plausible.Billing.Plans.yearly_plan_ids()
+
+  @impl Oban.Worker
+  @doc """
+  Sends a notification at most 7 days and at least 1 day before the renewal of an annual subscription
+  """
+  def perform(_args, _job) do
+    users =
+      Repo.all(
+        from u in Plausible.Auth.User,
+          left_join: sent in "sent_renewal_notifications",
+          join: s in Plausible.Billing.Subscription,
+          on: s.user_id == u.id,
+          where: s.paddle_plan_id in @yearly_plans,
+          where:
+            s.next_bill_date > fragment("now()::date") and
+              s.next_bill_date <= fragment("now()::date + INTERVAL '7 days'"),
+          where: is_nil(sent.id) or sent.timestamp < fragment("now() - INTERVAL '1 month'"),
+          preload: [subscription: s]
+      )
+
+    for user <- users do
+      case user.subscription.status do
+        "active" ->
+          template = PlausibleWeb.Email.yearly_renewal_notification(user)
+          Plausible.Mailer.send_email(template)
+
+        "deleted" ->
+          template = PlausibleWeb.Email.yearly_expiration_notification(user)
+          Plausible.Mailer.send_email(template)
+
+        _ ->
+          Sentry.capture_message("Invalid subscription for renewal", user: user)
+      end
+
+      Repo.insert_all("sent_renewal_notifications", [
+        %{
+          user_id: user.id,
+          timestamp: NaiveDateTime.utc_now()
+        }
+      ])
+    end
+
+    :ok
+  end
+end
diff --git a/priv/repo/migrations/20210420075623_add_sent_renewal_notifications.exs b/priv/repo/migrations/20210420075623_add_sent_renewal_notifications.exs
new file mode 100644
index 00000000..2174a1fa
--- /dev/null
+++ b/priv/repo/migrations/20210420075623_add_sent_renewal_notifications.exs
@@ -0,0 +1,10 @@
+defmodule Plausible.Repo.Migrations.AddSentRenewalNotifications do
+  use Ecto.Migration
+
+  def change do
+    create table(:sent_renewal_notifications) do
+      add :user_id, references(:users), null: false, on_delete: :delete_all
+      add :timestamp, :naive_datetime
+    end
+  end
+end
diff --git a/test/workers/notify_annual_renewal_test.exs b/test/workers/notify_annual_renewal_test.exs
new file mode 100644
index 00000000..ebb5124d
--- /dev/null
+++ b/test/workers/notify_annual_renewal_test.exs
@@ -0,0 +1,134 @@
+defmodule Plausible.Workers.NotifyAnnualRenewalTest do
+  use Plausible.DataCase
+  use Bamboo.Test
+  import Plausible.TestUtils
+  alias Plausible.Workers.NotifyAnnualRenewal
+  alias Plausible.Billing.Plans
+
+  setup [:create_user, :create_site]
+  @monthly_plan Plans.plans()[:monthly][:"10k"][:product_id]
+  @yearly_plan Plans.plans()[:yearly][:"10k"][:product_id]
+
+  test "ignores user without subscription" do
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_no_emails_delivered()
+  end
+
+  test "ignores user with monthly subscription", %{user: user} do
+    insert(:subscription,
+      user: user,
+      paddle_plan_id: @monthly_plan,
+      next_bill_date: Timex.shift(Timex.today(), days: 7)
+    )
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_no_emails_delivered()
+  end
+
+  test "ignores user with yearly subscription that's not due for renewal in 7 days", %{user: user} do
+    insert(:subscription,
+      user: user,
+      paddle_plan_id: @yearly_plan,
+      next_bill_date: Timex.shift(Timex.today(), days: 10)
+    )
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_no_emails_delivered()
+  end
+
+  test "sends renewal notification to user whose subscription is due for renewal in 7 days", %{
+    user: user
+  } do
+    insert(:subscription,
+      user: user,
+      paddle_plan_id: @yearly_plan,
+      next_bill_date: Timex.shift(Timex.today(), days: 7)
+    )
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_email_delivered_with(
+      to: [{user.name, user.email}],
+      subject: "Your Plausible subscription is up for renewal"
+    )
+  end
+
+  test "sends renewal notification to user whose subscription is due for renewal in 2 days", %{
+    user: user
+  } do
+    insert(:subscription,
+      user: user,
+      paddle_plan_id: @yearly_plan,
+      next_bill_date: Timex.shift(Timex.today(), days: 2)
+    )
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_email_delivered_with(
+      to: [{user.name, user.email}],
+      subject: "Your Plausible subscription is up for renewal"
+    )
+  end
+
+  test "does not send renewal notification multiple times", %{user: user} do
+    insert(:subscription,
+      user: user,
+      paddle_plan_id: @yearly_plan,
+      next_bill_date: Timex.shift(Timex.today(), days: 7)
+    )
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_email_delivered_with(
+      to: [{user.name, user.email}],
+      subject: "Your Plausible subscription is up for renewal"
+    )
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_no_emails_delivered()
+  end
+
+  test "sends a renewal notification again a year after the previous one", %{user: user} do
+    insert(:subscription,
+      user: user,
+      paddle_plan_id: @yearly_plan,
+      next_bill_date: Timex.shift(Timex.today(), days: 7)
+    )
+
+    Repo.insert_all("sent_renewal_notifications", [
+      %{
+        user_id: user.id,
+        timestamp: Timex.shift(Timex.today(), years: -1) |> Timex.to_naive_datetime()
+      }
+    ])
+
+    NotifyAnnualRenewal.perform(nil, nil)
+
+    assert_email_delivered_with(
+      to: [{user.name, user.email}],
+      subject: "Your Plausible subscription is up for renewal"
+    )
+  end
+
+  describe "expiration" do
+    test "if user subscription is 'deleted', notify them about expiration instead", %{user: user} do
+      insert(:subscription,
+        user: user,
+        paddle_plan_id: @yearly_plan,
+        next_bill_date: Timex.shift(Timex.today(), days: 7),
+        status: "deleted"
+      )
+
+      NotifyAnnualRenewal.perform(nil, nil)
+
+      assert_email_delivered_with(
+        to: [{user.name, user.email}],
+        subject: "Your Plausible subscription is about to expire"
+      )
+    end
+  end
+end
-- 
GitLab