From 700a65c98a112a201788b941d74cf05511776168 Mon Sep 17 00:00:00 2001
From: Uku Taht <Uku.taht@gmail.com>
Date: Wed, 8 Sep 2021 15:15:37 +0300
Subject: [PATCH] Remove trial banner for admins & viewers (#1308)

* Start trial only when the user creates a site

* End trial when ownership is transfered
---
 lib/plausible/auth/user.ex                    |  16 ++-
 lib/plausible/billing/billing.ex              |   4 +
 lib/plausible/sites.ex                        |  12 ++
 .../controllers/auth_controller.ex            |   6 +
 .../controllers/invitation_controller.ex      |  17 ++-
 .../controllers/site_controller.ex            |  11 +-
 .../templates/site/index.html.eex             |  10 +-
 lib/plausible_web/templates/site/new.html.eex |  21 ++++
 ...08081119_allow_trial_expiry_to_be_null.exs |   9 ++
 .../controllers/auth_controller_test.exs      | 110 ++++++++++++++++++
 .../invitation_controller_test.exs            |  20 ++++
 .../controllers/site_controller_test.exs      |  13 +++
 12 files changed, 239 insertions(+), 10 deletions(-)
 create mode 100644 priv/repo/migrations/20210908081119_allow_trial_expiry_to_be_null.exs

diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex
index 1c163fe5..05e26ab4 100644
--- a/lib/plausible/auth/user.ex
+++ b/lib/plausible/auth/user.ex
@@ -37,14 +37,14 @@ defmodule Plausible.Auth.User do
     |> validate_length(:password, max: 64, message: "cannot be longer than 64 characters")
     |> validate_confirmation(:password)
     |> hash_password()
-    |> change(trial_expiry_date: trial_expiry())
+    |> start_trial
     |> unique_constraint(:email)
   end
 
   def changeset(user, attrs \\ %{}) do
     user
     |> cast(attrs, [:email, :name, :email_verified, :theme, :trial_expiry_date])
-    |> validate_required([:email, :name, :email_verified, :trial_expiry_date])
+    |> validate_required([:email, :name, :email_verified])
     |> unique_constraint(:email)
   end
 
@@ -65,6 +65,18 @@ defmodule Plausible.Auth.User do
 
   def hash_password(changeset), do: changeset
 
+  def remove_trial_expiry(user) do
+    change(user, trial_expiry_date: nil)
+  end
+
+  def start_trial(user) do
+    change(user, trial_expiry_date: trial_expiry())
+  end
+
+  def end_trial(user) do
+    change(user, trial_expiry_date: Timex.today() |> Timex.shift(days: -1))
+  end
+
   defp trial_expiry() do
     if Application.get_env(:plausible, :is_selfhost) do
       Timex.today() |> Timex.shift(years: 100)
diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex
index d4383291..e754b5ad 100644
--- a/lib/plausible/billing/billing.ex
+++ b/lib/plausible/billing/billing.ex
@@ -104,6 +104,8 @@ defmodule Plausible.Billing do
     PaddleApi.update_subscription_preview(subscription.paddle_subscription_id, new_plan_id)
   end
 
+  def needs_to_upgrade?(%Plausible.Auth.User{trial_expiry_date: nil}), do: true
+
   def needs_to_upgrade?(user) do
     if Timex.before?(user.trial_expiry_date, Timex.today()) do
       !subscription_is_active?(user.subscription)
@@ -122,6 +124,8 @@ defmodule Plausible.Billing do
   defp subscription_is_active?(%Subscription{}), do: false
   defp subscription_is_active?(nil), do: false
 
+  def on_trial?(%Plausible.Auth.User{trial_expiry_date: nil}), do: false
+
   def on_trial?(user) do
     !subscription_is_active?(user.subscription) && trial_days_left(user) >= 0
   end
diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex
index e073fc7f..a57df0b4 100644
--- a/lib/plausible/sites.ex
+++ b/lib/plausible/sites.ex
@@ -22,10 +22,22 @@ defmodule Plausible.Sites do
 
         repo.insert(membership_changeset)
       end)
+      |> maybe_start_trial(user)
       |> Repo.transaction()
     end
   end
 
+  defp maybe_start_trial(multi, user) do
+    case user.trial_expiry_date do
+      nil ->
+        changeset = Plausible.Auth.User.start_trial(user)
+        Ecto.Multi.update(multi, :user, changeset)
+
+      _ ->
+        multi
+    end
+  end
+
   def has_stats?(site) do
     if site.has_stats do
       true
diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex
index 90ba3327..011beec5 100644
--- a/lib/plausible_web/controllers/auth_controller.ex
+++ b/lib/plausible_web/controllers/auth_controller.ex
@@ -98,6 +98,12 @@ defmodule PlausibleWeb.AuthController do
       invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
       user = Plausible.Auth.User.new(params["user"])
 
+      user =
+        case invitation.role do
+          :owner -> user
+          _ -> Plausible.Auth.User.remove_trial_expiry(user)
+        end
+
       if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
         case Repo.insert(user) do
           {:ok, user} ->
diff --git a/lib/plausible_web/controllers/invitation_controller.ex b/lib/plausible_web/controllers/invitation_controller.ex
index db1020ab..51de2fef 100644
--- a/lib/plausible_web/controllers/invitation_controller.ex
+++ b/lib/plausible_web/controllers/invitation_controller.ex
@@ -17,7 +17,9 @@ defmodule PlausibleWeb.InvitationController do
 
     multi =
       if invitation.role == :owner do
-        downgrade_previous_owner(Multi.new(), invitation.site)
+        Multi.new()
+        |> downgrade_previous_owner(invitation.site)
+        |> end_trial_of_new_owner(user)
       else
         Multi.new()
       end
@@ -35,9 +37,10 @@ defmodule PlausibleWeb.InvitationController do
       |> Multi.delete(:invitation, invitation)
 
     case Repo.transaction(multi) do
-      {:ok, _} ->
+      {:ok, changes} ->
+        updated_user = Map.get(changes, :user, user)
         notify_invitation_accepted(invitation)
-        Plausible.Billing.SiteLocker.check_sites_for(user)
+        Plausible.Billing.SiteLocker.check_sites_for(updated_user)
 
         conn
         |> put_flash(:success, "You now have access to #{invitation.site.domain}")
@@ -61,6 +64,14 @@ defmodule PlausibleWeb.InvitationController do
     Multi.update_all(multi, :prev_owner, prev_owner, set: [role: :admin])
   end
 
+  defp end_trial_of_new_owner(multi, new_owner) do
+    if Plausible.Billing.on_trial?(new_owner) do
+      Ecto.Multi.update(multi, :user, Plausible.Auth.User.end_trial(new_owner))
+    else
+      multi
+    end
+  end
+
   def reject_invitation(conn, %{"invitation_id" => invitation_id}) do
     invitation =
       Repo.get_by!(Invitation, invitation_id: invitation_id)
diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex
index 7a981aa7..786dc1fb 100644
--- a/lib/plausible_web/controllers/site_controller.ex
+++ b/lib/plausible_web/controllers/site_controller.ex
@@ -50,11 +50,14 @@ defmodule PlausibleWeb.SiteController do
   end
 
   def new(conn, _params) do
-    current_user = conn.assigns[:current_user]
-    site_count = Enum.count(Plausible.Sites.owned_by(current_user))
+    current_user = conn.assigns[:current_user] |> Repo.preload(site_memberships: :site)
+
+    owned_site_count =
+      current_user.site_memberships |> Enum.filter(fn m -> m.role == :owner end) |> Enum.count()
+
     site_limit = Plausible.Billing.sites_limit(current_user)
-    is_at_limit = site_limit && site_count >= site_limit
-    is_first_site = site_count == 0
+    is_at_limit = site_limit && owned_site_count >= site_limit
+    is_first_site = Enum.empty?(current_user.site_memberships)
 
     changeset = Plausible.Site.changeset(%Plausible.Site{})
 
diff --git a/lib/plausible_web/templates/site/index.html.eex b/lib/plausible_web/templates/site/index.html.eex
index abb29e6d..62d7e5f8 100644
--- a/lib/plausible_web/templates/site/index.html.eex
+++ b/lib/plausible_web/templates/site/index.html.eex
@@ -159,7 +159,15 @@
                     You've been invited to the <span x-text="selectedInvitation && selectedInvitation.site.domain"></span> analytics dashboard as <b class="capitalize" x-text="selectedInvitation && selectedInvitation.role">Admin</b>.
                   </p>
                   <p x-show="selectedInvitation && selectedInvitation.role === 'owner'" class="mt-2 text-sm text-gray-500">
-                    If you accept the ownership transfer, you will be responsible for billing.
+                  If you accept the ownership transfer, you will be responsible for billing going forward.
+                  <%= if is_nil(@current_user.trial_expiry_date) && is_nil(@current_user.subscription) do %>
+                    <br/><br />
+                    You will have to enter your card details immediately with no 30-day trial.
+                  <% end %>
+                  <%= if Plausible.Billing.on_trial?(@current_user) do %>
+                    <br/><br />
+                    Your 30-day free trial will end immediately and you will have to enter your card details to keep using Plausible.
+                  <% end %>
                   </p>
                 </div>
               </div>
diff --git a/lib/plausible_web/templates/site/new.html.eex b/lib/plausible_web/templates/site/new.html.eex
index d3c6bca5..260aec28 100644
--- a/lib/plausible_web/templates/site/new.html.eex
+++ b/lib/plausible_web/templates/site/new.html.eex
@@ -1,6 +1,7 @@
 <div class="w-full max-w-3xl mt-4 mx-auto flex">
   <%= form_for @changeset, "/sites", [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-lg rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
     <h2 class="text-xl font-black dark:text-gray-100">Your website details</h2>
+
     <%= if @is_at_limit do %>
       <div class="rounded-md bg-yellow-50 dark:bg-transparent dark:border border-yellow-200 p-4 mt-4">
         <div class="flex">
@@ -22,6 +23,26 @@
         </div>
       </div>
     <% end %>
+
+    <%= if is_nil(@current_user.trial_expiry_date) do %>
+      <div class="rounded-md bg-blue-50 dark:bg-transparent dark:border border-blue-200 p-4 mt-4">
+        <div class="flex">
+          <div class="flex-shrink-0">
+            <svg class="h-5 w-5 text-blue-500 dark:text-blue-300" 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">
+            <div class="text-sm text-blue-700 dark:text-blue-300">
+              <p>
+              When you create your first site, your account will enter a 30 day free trial.
+              </p>
+            </div>
+          </div>
+        </div>
+      </div>
+    <% end %>
+
     <div class="my-6">
       <%= label f, :domain, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
       <p class="text-gray-500 dark:text-gray-400 text-xs mt-1">Just the naked domain or subdomain without 'www'</p>
diff --git a/priv/repo/migrations/20210908081119_allow_trial_expiry_to_be_null.exs b/priv/repo/migrations/20210908081119_allow_trial_expiry_to_be_null.exs
new file mode 100644
index 00000000..21e93570
--- /dev/null
+++ b/priv/repo/migrations/20210908081119_allow_trial_expiry_to_be_null.exs
@@ -0,0 +1,9 @@
+defmodule Plausible.Repo.Migrations.AllowTrialExpiryToBeNull do
+  use Ecto.Migration
+
+  def change do
+    alter table(:users) do
+      modify :trial_expiry_date, :date, null: true
+    end
+  end
+end
diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs
index cb44b32b..b6e3f384 100644
--- a/test/plausible_web/controllers/auth_controller_test.exs
+++ b/test/plausible_web/controllers/auth_controller_test.exs
@@ -71,6 +71,116 @@ defmodule PlausibleWeb.AuthControllerTest do
     end
   end
 
+  describe "GET /register/invitations/:invitation_id" do
+    test "shows the register form", %{conn: conn} do
+      inviter = insert(:user)
+      site = insert(:site, members: [inviter])
+
+      invitation =
+        insert(:invitation,
+          site_id: site.id,
+          inviter: inviter,
+          email: "user@email.co",
+          role: :admin
+        )
+
+      conn = get(conn, "/register/invitation/#{invitation.invitation_id}")
+
+      assert html_response(conn, 200) =~ "Enter your details"
+    end
+  end
+
+  describe "POST /register/invitation/:invitation_id" do
+    setup do
+      inviter = insert(:user)
+      site = insert(:site, members: [inviter])
+
+      invitation =
+        insert(:invitation,
+          site_id: site.id,
+          inviter: inviter,
+          email: "user@email.co",
+          role: :admin
+        )
+
+      {:ok, %{site: site, invitation: invitation}}
+    end
+
+    test "registering sends an activation link", %{conn: conn, invitation: invitation} do
+      post(conn, "/register/invitation/#{invitation.invitation_id}",
+        user: %{
+          name: "Jane Doe",
+          email: "user@example.com",
+          password: "very-secret",
+          password_confirmation: "very-secret"
+        }
+      )
+
+      assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject})
+      assert user_email == "user@example.com"
+      assert subject =~ "is your Plausible email verification code"
+    end
+
+    test "creates user record", %{conn: conn, invitation: invitation} do
+      post(conn, "/register/invitation/#{invitation.invitation_id}",
+        user: %{
+          name: "Jane Doe",
+          email: "user@example.com",
+          password: "very-secret",
+          password_confirmation: "very-secret"
+        }
+      )
+
+      user = Repo.get_by(Plausible.Auth.User, email: "user@example.com")
+      assert user.name == "Jane Doe"
+    end
+
+    test "leaves trial_expiry_date null when invitation role is not :owner", %{
+      conn: conn,
+      invitation: invitation
+    } do
+      post(conn, "/register/invitation/#{invitation.invitation_id}",
+        user: %{
+          name: "Jane Doe",
+          email: "user@example.com",
+          password: "very-secret",
+          password_confirmation: "very-secret"
+        }
+      )
+
+      user = Repo.get_by(Plausible.Auth.User, email: "user@example.com")
+      assert is_nil(user.trial_expiry_date)
+    end
+
+    test "logs the user in", %{conn: conn, invitation: invitation} do
+      conn =
+        post(conn, "/register/invitation/#{invitation.invitation_id}",
+          user: %{
+            name: "Jane Doe",
+            email: "user@example.com",
+            password: "very-secret",
+            password_confirmation: "very-secret"
+          }
+        )
+
+      assert get_session(conn, :current_user_id)
+    end
+
+    test "user is redirected to activation after registration", %{conn: conn} do
+      conn =
+        post(conn, "/register",
+          user: %{
+            name: "Jane Doe",
+            email: "user@example.com",
+            password: "very-secret",
+            password_confirmation: "very-secret"
+          }
+        )
+
+      assert redirected_to(conn) == "/activate"
+    end
+  end
+
   describe "GET /activate" do
     setup [:create_user, :log_in]
 
diff --git a/test/plausible_web/controllers/invitation_controller_test.exs b/test/plausible_web/controllers/invitation_controller_test.exs
index 4e9671a5..9c10e672 100644
--- a/test/plausible_web/controllers/invitation_controller_test.exs
+++ b/test/plausible_web/controllers/invitation_controller_test.exs
@@ -101,6 +101,26 @@ defmodule PlausibleWeb.Site.InvitationControllerTest do
 
       assert Repo.reload!(site).locked
     end
+
+    test "ownership transfer - will end the trial of the new owner immediately", %{
+      conn: conn,
+      user: user
+    } do
+      Repo.update_all(from(u in Plausible.Auth.User, where: u.id == ^user.id),
+        set: [trial_expiry_date: Timex.today() |> Timex.shift(days: 7)]
+      )
+
+      inviter = insert(:user)
+      site = insert(:site, locked: false)
+
+      invitation =
+        insert(:invitation, site_id: site.id, inviter: inviter, email: user.email, role: :owner)
+
+      post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
+
+      assert Timex.before?(Repo.reload!(user).trial_expiry_date, Timex.today())
+      assert Repo.reload!(site).locked
+    end
   end
 
   describe "POST /sites/invitations/:invitation_id/reject" do
diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs
index 3a6dc0bd..9794854d 100644
--- a/test/plausible_web/controllers/site_controller_test.exs
+++ b/test/plausible_web/controllers/site_controller_test.exs
@@ -104,6 +104,19 @@ defmodule PlausibleWeb.SiteControllerTest do
       assert Repo.exists?(Plausible.Site, domain: "example.com")
     end
 
+    test "starts trial if user does not have trial yet", %{conn: conn, user: user} do
+      Plausible.Auth.User.remove_trial_expiry(user) |> Repo.update!()
+
+      post(conn, "/sites", %{
+        "site" => %{
+          "domain" => "example.com",
+          "timezone" => "Europe/London"
+        }
+      })
+
+      assert Repo.reload!(user).trial_expiry_date
+    end
+
     test "sends welcome email if this is the user's first site", %{conn: conn} do
       post(conn, "/sites", %{
         "site" => %{
-- 
GitLab