From e298c63dd2beb1e8f048cbf32a1dc36e66ce4061 Mon Sep 17 00:00:00 2001
From: Alexis Saettler <alexis@saettler.org>
Date: Thu, 26 Aug 2021 14:16:39 +0200
Subject: [PATCH] feat: allow to update a subscription frequency (#5436)

---
 .github/workflows/tests.yml                   |   2 +
 app/Helpers/AccountHelper.php                 |  11 ++
 app/Helpers/DateHelper.php                    |   2 +-
 app/Helpers/InstanceHelper.php                |  40 ++++++
 .../Settings/SubscriptionsController.php      | 109 ++++++++++++++---
 .../Account/Settings/DestroyAccount.php       |   6 +-
 app/Traits/StripeCall.php                     |   2 +
 app/Traits/Subscription.php                   |  71 +++++------
 phpstan.neon                                  |   4 +
 resources/lang/en/settings.php                |  20 ++-
 .../views/partials/subscription.blade.php     |  11 +-
 .../settings/subscriptions/account.blade.php  |  62 +++++++---
 .../settings/subscriptions/blank.blade.php    |  14 ++-
 .../downgrade-checklist.blade.php             |   2 +-
 .../settings/subscriptions/update.blade.php   |  73 +++++++++++
 routes/web.php                                |   2 +
 tests/Feature/AccountSubscriptionTest.php     | 115 +++++++++++++-----
 tests/Unit/Helpers/AccountHelperTest.php      |  69 +++--------
 tests/Unit/Helpers/InstanceHelperTest.php     |  47 +++++++
 19 files changed, 495 insertions(+), 167 deletions(-)
 create mode 100644 resources/views/settings/subscriptions/update.blade.php

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index df782175a2b..9c573ad86eb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -115,11 +115,13 @@ jobs:
         run: phpdbg -dmemory_limit=4G -qrr vendor/bin/phpunit -c phpunit.xml --testsuite ${{ matrix.testsuite }} --log-junit ./results/junit/results${{ matrix.testsuite }}.xml --coverage-clover ./results/coverage/coverage${{ matrix.testsuite }}.xml
         env:
           DB_CONNECTION: ${{ matrix.connection }}
+          STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }}
       - name: Run Unit test suite
         if: matrix.php-version != env.default-php-version
         run: vendor/bin/phpunit -c phpunit.xml --testsuite ${{ matrix.testsuite }} --log-junit ./results/junit/results${{ matrix.testsuite }}.xml
         env:
           DB_CONNECTION: ${{ matrix.connection }}
+          STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }}
 
       - name: Fix results files
         if: matrix.php-version == env.default-php-version
diff --git a/app/Helpers/AccountHelper.php b/app/Helpers/AccountHelper.php
index 30a607943f3..0c83894c796 100644
--- a/app/Helpers/AccountHelper.php
+++ b/app/Helpers/AccountHelper.php
@@ -43,6 +43,17 @@ public static function hasReachedContactLimit(Account $account): bool
         return $account->allContacts()->real()->active()->count() >= config('monica.number_of_allowed_contacts_free_account');
     }
 
+    /**
+     * Indicate whether an account has not reached the contact limit of free accounts.
+     *
+     * @param Account $account
+     * @return bool
+     */
+    public static function isBelowContactLimit(Account $account): bool
+    {
+        return $account->allContacts()->real()->active()->count() <= config('monica.number_of_allowed_contacts_free_account');
+    }
+
     /**
      * Check if the account can be downgraded, based on a set of rules.
      *
diff --git a/app/Helpers/DateHelper.php b/app/Helpers/DateHelper.php
index 52825bbb19b..be128c98062 100644
--- a/app/Helpers/DateHelper.php
+++ b/app/Helpers/DateHelper.php
@@ -141,7 +141,7 @@ public static function getShortDate($date): string
     /**
      * Return a date in a full format like "October 29, 1981".
      *
-     * @param string $date
+     * @param string|int $date
      * @return string
      */
     public static function getFullDate($date): string
diff --git a/app/Helpers/InstanceHelper.php b/app/Helpers/InstanceHelper.php
index 4bd1d4ee05e..9cdd1d01ab5 100644
--- a/app/Helpers/InstanceHelper.php
+++ b/app/Helpers/InstanceHelper.php
@@ -47,6 +47,46 @@ public static function getPlanInformationFromConfig(string $timePeriod): ?array
         ];
     }
 
+    /**
+     * Get the plan information for the given time period.
+     *
+     * @param  \Laravel\Cashier\Subscription $subscription
+     * @return array|null
+     */
+    public static function getPlanInformationFromSubscription(\Laravel\Cashier\Subscription $subscription): ?array
+    {
+        try {
+            $stripeSubscription = $subscription->asStripeSubscription();
+            $plan = $stripeSubscription->plan;
+        } catch (\Stripe\Exception\ApiErrorException $e) {
+            $stripeSubscription = null;
+            $plan = null;
+        }
+
+        if (is_null($stripeSubscription) || is_null($plan)) {
+            return [
+                'type' => $subscription->stripe_plan,
+                'name' => $subscription->name,
+                'id' => $subscription->stripe_id,
+                'price' => '?',
+                'friendlyPrice' => '?',
+                'nextBillingDate' => '',
+            ];
+        }
+
+        $currency = Currency::where('iso', strtoupper($plan->currency))->first();
+        $amount = MoneyHelper::format($plan->amount, $currency);
+
+        return [
+            'type' => $plan->interval === 'month' ? 'monthly' : 'annual',
+            'name' => $subscription->name,
+            'id' => $plan->id,
+            'price' => $plan->amount,
+            'friendlyPrice' => $amount,
+            'nextBillingDate' => DateHelper::getFullDate($stripeSubscription->current_period_end),
+        ];
+    }
+
     /**
      * Get changelogs entries.
      *
diff --git a/app/Http/Controllers/Settings/SubscriptionsController.php b/app/Http/Controllers/Settings/SubscriptionsController.php
index 9497da915b0..e588d90a6e9 100644
--- a/app/Http/Controllers/Settings/SubscriptionsController.php
+++ b/app/Http/Controllers/Settings/SubscriptionsController.php
@@ -31,12 +31,12 @@ class SubscriptionsController extends Controller
      */
     public function index()
     {
-        $account = auth()->user()->account;
-
         if (! config('monica.requires_subscription')) {
             return redirect()->route('settings.index');
         }
 
+        $account = auth()->user()->account;
+
         $subscription = $account->getSubscribedPlan();
         if (! $account->isSubscribed() && (! $subscription || $subscription->ended())) {
             return view('settings.subscriptions.blank', [
@@ -44,27 +44,22 @@ public function index()
             ]);
         }
 
-        try {
-            $nextBillingDate = $account->getNextBillingDate();
-        } catch (StripeException $e) {
-            $nextBillingDate = trans('app.unknown');
-        }
-
         $hasInvoices = $account->hasStripeId() && $account->hasInvoices();
         $invoices = null;
         if ($hasInvoices) {
             $invoices = $account->invoices();
         }
 
-        $planInformation = InstanceHelper::getPlanInformationFromConfig($subscription->name);
-
-        if ($planInformation === null) {
-            abort(404);
+        try {
+            $planInformation = $this->stripeCall(function () use ($subscription) {
+                return InstanceHelper::getPlanInformationFromSubscription($subscription);
+            });
+        } catch (StripeException $e) {
+            $planInformation = null;
         }
 
         return view('settings.subscriptions.account', [
             'planInformation' => $planInformation,
-            'nextBillingDate' => $nextBillingDate,
             'subscription' => $subscription,
             'hasInvoices' => $hasInvoices,
             'invoices' => $invoices,
@@ -89,6 +84,10 @@ public function upgrade(Request $request)
         }
 
         $plan = $request->query('plan');
+        if ($plan !== 'monthly' && $plan !== 'annual') {
+            abort(404);
+        }
+
         $planInformation = InstanceHelper::getPlanInformationFromConfig($plan);
 
         if ($planInformation === null) {
@@ -102,6 +101,78 @@ public function upgrade(Request $request)
         ]);
     }
 
+    /**
+     * Display the update view page.
+     *
+     * @param Request $request
+     * @return View|Factory|RedirectResponse
+     */
+    public function update(Request $request)
+    {
+        if (! config('monica.requires_subscription')) {
+            return redirect()->route('settings.index');
+        }
+
+        $account = auth()->user()->account;
+
+        $subscription = $account->getSubscribedPlan();
+        if (! $account->isSubscribed() && (! $subscription || $subscription->ended())) {
+            return view('settings.subscriptions.blank', [
+                'numberOfCustomers' => InstanceHelper::getNumberOfPaidSubscribers(),
+            ]);
+        }
+
+        $planInformation = InstanceHelper::getPlanInformationFromSubscription($subscription);
+
+        if ($planInformation === null) {
+            abort(404);
+        }
+
+        $plans = collect();
+        foreach (['monthly', 'annual'] as $plan) {
+            $plans->push(InstanceHelper::getPlanInformationFromConfig($plan));
+        }
+
+        $legacyPlan = null;
+        if (! $plans->contains(function ($value) use ($planInformation) {
+            return $value['id'] === $planInformation['id'];
+        })) {
+            $legacyPlan = $planInformation;
+        }
+
+        return view('settings.subscriptions.update', [
+            'planInformation' => $planInformation,
+            'plans' => $plans,
+            'legacyPlan' => $legacyPlan,
+        ]);
+    }
+
+    /**
+     * Process the update process.
+     *
+     * @param Request $request
+     * @return View|Factory|RedirectResponse
+     */
+    public function processUpdate(Request $request)
+    {
+        $account = auth()->user()->account;
+
+        $subscription = $account->getSubscribedPlan();
+        if (! $account->isSubscribed() && ! $subscription) {
+            return redirect()->route('settings.index');
+        }
+
+        try {
+            $account->updateSubscription($request->input('frequency'), $subscription);
+        } catch (StripeException $e) {
+            return back()
+                ->withInput()
+                ->withErrors($e->getMessage());
+        }
+
+        return redirect()->route('settings.subscriptions.index');
+    }
+
     /**
      * Display the confirm view page.
      *
@@ -200,7 +271,7 @@ public function downgrade()
             ->with('numberOfPendingInvitations', $account->invitations()->count())
             ->with('numberOfUsers', $account->users()->count())
             ->with('accountHasLimitations', AccountHelper::hasLimitations($account))
-            ->with('hasReachedContactLimit', AccountHelper::hasReachedContactLimit($account))
+            ->with('hasReachedContactLimit', ! AccountHelper::isBelowContactLimit($account))
             ->with('canDowngrade', AccountHelper::canDowngrade($account));
     }
 
@@ -211,17 +282,19 @@ public function downgrade()
      */
     public function processDowngrade()
     {
-        if (! AccountHelper::canDowngrade(auth()->user()->account)) {
+        $account = auth()->user()->account;
+
+        if (! AccountHelper::canDowngrade($account)) {
             return redirect()->route('settings.subscriptions.downgrade');
         }
 
-        $subscription = auth()->user()->account->getSubscribedPlan();
-        if (! auth()->user()->account->isSubscribed() && ! $subscription) {
+        $subscription = $account->getSubscribedPlan();
+        if (! $account->isSubscribed() && ! $subscription) {
             return redirect()->route('settings.index');
         }
 
         try {
-            auth()->user()->account->subscriptionCancel();
+            $account->subscriptionCancel();
         } catch (StripeException $e) {
             return back()
                 ->withInput()
diff --git a/app/Services/Account/Settings/DestroyAccount.php b/app/Services/Account/Settings/DestroyAccount.php
index 547cbc66885..22e5616e698 100644
--- a/app/Services/Account/Settings/DestroyAccount.php
+++ b/app/Services/Account/Settings/DestroyAccount.php
@@ -78,11 +78,7 @@ private function destroyPhotos(Account $account)
     private function cancelStripe(Account $account)
     {
         if ($account->isSubscribed() && ! $account->has_access_to_paid_version_for_free) {
-            try {
-                $account->subscriptionCancel();
-            } catch (StripeException $e) {
-                throw new StripeException();
-            }
+            $account->subscriptionCancel();
         }
     }
 }
diff --git a/app/Traits/StripeCall.php b/app/Traits/StripeCall.php
index 8f4eb5bccd1..94943660272 100644
--- a/app/Traits/StripeCall.php
+++ b/app/Traits/StripeCall.php
@@ -43,6 +43,8 @@ private function stripeCall($callback)
         } catch (\Stripe\Exception\ApiErrorException $e) {
             $errorMessage = $e->getMessage();
             Log::error('Stripe error: '.(string) $e, $e->getJsonBody() ?: []);
+        } catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
+            throw $e;
         } catch (\Exception $e) {
             $errorMessage = $e->getMessage();
             Log::error('Stripe error: '.(string) $e);
diff --git a/app/Traits/Subscription.php b/app/Traits/Subscription.php
index 039a64528b9..0d971a4d15c 100644
--- a/app/Traits/Subscription.php
+++ b/app/Traits/Subscription.php
@@ -2,7 +2,6 @@
 
 namespace App\Traits;
 
-use App\Helpers\DateHelper;
 use Laravel\Cashier\Billable;
 use App\Helpers\InstanceHelper;
 
@@ -34,6 +33,39 @@ public function subscribe(string $payment_method, string $planName)
         });
     }
 
+    /**
+     * Update an existing subscription.
+     *
+     * @param string $planName
+     * @param \Laravel\Cashier\Subscription $subscription
+     * @return \Laravel\Cashier\Subscription
+     */
+    public function updateSubscription(string $planName, \Laravel\Cashier\Subscription $subscription)
+    {
+        $oldPlan = $subscription->stripe_plan;
+        $plan = InstanceHelper::getPlanInformationFromConfig($planName);
+        if ($plan === null) {
+            abort(404);
+        }
+
+        if ($oldPlan === $planName) {
+            // No change
+            return $subscription;
+        }
+
+        $subscription = $this->stripeCall(function () use ($subscription, $plan) {
+            return $subscription->swap($plan['id']);
+        });
+
+        if ($subscription->stripe_plan !== $oldPlan && $subscription->stripe_plan === $plan['id']) {
+            $subscription->forceFill([
+                'name' => $plan['name'],
+            ])->save();
+        }
+
+        return $subscription;
+    }
+
     /**
      * Check if the account is currently subscribed to a plan.
      *
@@ -45,8 +77,7 @@ public function isSubscribed()
             return true;
         }
 
-        return $this->subscribed(config('monica.paid_plan_monthly_friendly_name'))
-            || $this->subscribed(config('monica.paid_plan_annual_friendly_name'));
+        return $this->getSubscribedPlan() !== null;
     }
 
     /**
@@ -56,13 +87,7 @@ public function isSubscribed()
      */
     public function getSubscribedPlan()
     {
-        $subscription = $this->subscription(config('monica.paid_plan_monthly_friendly_name'));
-
-        if (! $subscription) {
-            $subscription = $this->subscription(config('monica.paid_plan_annual_friendly_name'));
-        }
-
-        return $subscription;
+        return $this->subscriptions()->recurring()->first();
     }
 
     /**
@@ -74,11 +99,7 @@ public function getSubscribedPlanId()
     {
         $plan = $this->getSubscribedPlan();
 
-        if (! is_null($plan)) {
-            return $plan->stripe_plan;
-        }
-
-        return '';
+        return is_null($plan) ? '' : $plan->stripe_plan;
     }
 
     /**
@@ -122,24 +143,4 @@ public function hasInvoices()
     {
         return $this->subscriptions()->count() > 0;
     }
-
-    /**
-     * Get the next billing date for the account.
-     *
-     * @return string
-     */
-    public function getNextBillingDate()
-    {
-        // Weird method to get the next billing date from Laravel Cashier
-        // see https://stackoverflow.com/questions/41576568/get-next-billing-date-from-laravel-cashier
-        return $this->stripeCall(function () {
-            $subscriptions = $this->asStripeCustomer()['subscriptions'];
-            if (! $subscriptions || count($subscriptions->data) <= 0) {
-                return '';
-            }
-            $timestamp = $subscriptions->data[0]['current_period_end'];
-
-            return DateHelper::getFullDate($timestamp);
-        });
-    }
 }
diff --git a/phpstan.neon b/phpstan.neon
index b1247ea318c..aa527589b61 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -38,3 +38,7 @@ parameters:
           path: */Traits/Searchable.php
         - message: '#Access to an undefined property Faker\\Generator::\$state\.#'
           path: */Console/Commands/SetupTest.php
+        - message: '#Access to an undefined property Stripe\\Subscription::\$plan\.#'
+          path: */Helpers/InstanceHelper.php
+        - message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\HasMany::recurring\(\)\.#'
+          path: */Traits/Subscription.php
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index 9756778f89a..4a7fbdf252d 100644
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -178,9 +178,18 @@
     'users_invitation_need_subscription' => 'Adding more users requires a subscription.',
 
     'subscriptions_account_current_plan' => 'Your current plan',
+    'subscriptions_account_current_legacy' => 'Current plan, not selectable anymore:',
     'subscriptions_account_current_paid_plan' => 'You are on the :name plan. Thanks so much for being a subscriber.',
+
+    'subscriptions_account_next_billing_title' => 'Next bill',
     'subscriptions_account_next_billing' => 'Your subscription will auto-renew on <strong>:date</strong>.',
-    'subscriptions_account_cancel' => 'You can <a href=":url">cancel subscription</a> anytime.',
+    'subscriptions_account_bill_monthly' => 'We’ll bill you <strong>:price</strong> for another <strong>month</strong>.',
+    'subscriptions_account_bill_annual' => 'We’ll bill you <strong>:price</strong> for another <strong>year</strong>.',
+    'subscriptions_account_change' => 'Change plan',
+
+    'subscriptions_account_cancel_title' => 'Cancel subscription',
+    'subscriptions_account_cancel_action' => 'Cancel subscription',
+    'subscriptions_account_cancel' => 'You can cancel your subscription at any time.',
     'subscriptions_account_free_plan' => 'You are on the free plan.',
     'subscriptions_account_free_plan_upgrade' => 'You can upgrade your account to the :name plan, which costs $:price per month. Here are the advantages:',
     'subscriptions_account_free_plan_benefits_users' => 'Unlimited number of users',
@@ -190,6 +199,9 @@
     'subscriptions_account_upgrade' => 'Upgrade your account',
     'subscriptions_account_upgrade_title' => 'Upgrade Monica today and have more meaningful relationships.',
     'subscriptions_account_upgrade_choice' => 'Pick a plan below and join over :customers persons who upgraded their Monica.',
+    'subscriptions_account_update_title' => 'Update Monica subscription',
+    'subscriptions_account_update_description' => 'You can change your subscription’s frequency here.',
+    'subscriptions_account_update_information' => 'You will be billed immediately for the new amount. Your subscription will extend to the new period, depending on your choice.',
     'subscriptions_account_invoices' => 'Invoices',
     'subscriptions_account_invoices_download' => 'Download',
     'subscriptions_account_invoices_subscription' => 'Subscription from :startDate to :endDate',
@@ -203,6 +215,7 @@
     'subscriptions_downgrade_rule_invitations_constraint' => 'You currently have <a href=":url">1 pending invitation</a>.|You currently have <a href=":url">:count pending invitations</a>.',
     'subscriptions_downgrade_rule_contacts' => 'You must not have more than :number active contacts',
     'subscriptions_downgrade_rule_contacts_constraint' => 'You currently have <a href=":url">1 contact</a>.|You currently have <a href=":url">:count contacts</a>.',
+    'subscriptions_downgrade_rule_contacts_archive' => 'We can also <a href=":url">archive all your contacts for you</a> – that would clear this rule and let you proceed with your account’s downgrade process.',
     'subscriptions_downgrade_cta' => 'Downgrade',
     'subscriptions_downgrade_success' => 'You are back to the Free plan!',
     'subscriptions_downgrade_thanks' => 'Thanks so much for trying the paid plan. We keep adding new features on Monica all the time – so you might want to come back in the future to see if you might be interested in taking a subscription again.',
@@ -229,13 +242,12 @@
     'subscriptions_payment_success' => 'The payment was successful.',
 
     'subscriptions_pdf_title' => 'Your :name monthly subscription',
+    'subscriptions_plan_frequency_year' => ':amount / year',
+    'subscriptions_plan_frequency_month' => ':amount / month',
     'subscriptions_plan_choose' => 'Choose this plan',
     'subscriptions_plan_year_title' => 'Pay annually',
-    'subscriptions_plan_year_cost' => '$45/year',
-    'subscriptions_plan_year_cost_save' => 'you’ll save 25%',
     'subscriptions_plan_year_bonus' => 'Peace of mind for a whole year',
     'subscriptions_plan_month_title' => 'Pay monthly',
-    'subscriptions_plan_month_cost' => '$5/month',
     'subscriptions_plan_month_bonus' => 'Cancel any time',
     'subscriptions_plan_include1' => 'Included with your upgrade:',
     'subscriptions_plan_include2' => 'Unlimited number of contacts • Unlimited number of users • Reminders by email • Import with vCard • Personalization of the contact sheet',
diff --git a/resources/views/partials/subscription.blade.php b/resources/views/partials/subscription.blade.php
index 36cbc28527a..e30097ac04b 100644
--- a/resources/views/partials/subscription.blade.php
+++ b/resources/views/partials/subscription.blade.php
@@ -1,7 +1,16 @@
 @if (($subscription = auth()->user()->account->getSubscribedPlan()) && $subscription->hasIncompletePayment())
 
 <div class="alert alert-success">
-  {!! trans('settings.subscriptions_account_confirm_payment', ['url' => route('settings.subscriptions.confirm', $subscription->latestPayment()->id)]) !!}
+  {!! trans('settings.subscriptions_account_confirm_payment', ['url' => route('settings.subscriptions.confirm', $subscription->latestPayment() ? $subscription->latestPayment()->id : '')]) !!}
 </div>
 
+@if (! app()->environment('production'))
+<p>
+  <a href="{{ route('settings.subscriptions.forceCompletePaymentOnTesting') }}">
+    {{-- No translation needed --}}
+    Force payment success (test).
+  </a>
+</p>
+@endif
+
 @endif
diff --git a/resources/views/settings/subscriptions/account.blade.php b/resources/views/settings/subscriptions/account.blade.php
index 50f2ee7fb17..ecf9e3678e3 100644
--- a/resources/views/settings/subscriptions/account.blade.php
+++ b/resources/views/settings/subscriptions/account.blade.php
@@ -39,24 +39,55 @@
 
             <p>{{ trans('settings.subscriptions_account_current_paid_plan', ['name' => $planInformation['name']]) }}</p>
 
-            @if ($subscription->hasIncompletePayment())
-              @include('partials.subscription')
-              @if (! app()->environment('production'))
-              <p>
-                <a href="{{ route('settings.subscriptions.forceCompletePaymentOnTesting') }}">
-                  {{-- No translation needed --}}
-                  Force payment success (test).
-                </a>
-              </p>
-              @endif
-            @else
-
-            <p>{!! trans('settings.subscriptions_account_next_billing', ['date' => $nextBillingDate]) !!}</p>
-            <p>{!! trans('settings.subscriptions_account_cancel', ['url' => route('settings.subscriptions.downgrade')]) !!}</p>
+            @include('partials.subscription')
+
+            <div class="dt dt--fixed w-100 collapse br--top br--bottom">
+              <div class="dt-row">
+                <div class="dtc">
+                  <div class="pa2 b">
+                    {{ trans('settings.subscriptions_account_next_billing_title') }}
+                  </div>
+                </div>
+                <div class="dtc w-60">
+                  <div class="ph2">
+                    {!! trans('settings.subscriptions_account_next_billing', ['date' => $planInformation['nextBillingDate']]) !!}
+                  </div>
+                  <div class="ph2 pb2">
+                    {!! trans('settings.subscriptions_account_bill_' . $planInformation['type'], ['price' => $planInformation['friendlyPrice']]) !!}
+                  </div>
+                </div>
+                <div class="dtc {{ htmldir() == 'ltr' ? 'tr' : 'tl' }}">
+                  <div class="pa2">
+                    <a href="{{ route('settings.subscriptions.update') }}">{{ trans('settings.subscriptions_account_change') }}</a>
+                  </div>
+                </div>
+              </div>
+
+              <div class="dt-row">
+                <div class="dtc">
+                  <div class="pa2 b">
+                    {{ trans('settings.subscriptions_account_cancel_title') }}
+                  </div>
+                </div>
+                <div class="dtc">
+                  <div class="pa2">
+                    {{ trans('settings.subscriptions_account_cancel') }}
+                  </div>
+                </div>
+                <div class="dtc {{ htmldir() == 'ltr' ? 'tr' : 'tl' }}">
+                  <div class="pa2">
+                    <a href="{{ route('settings.subscriptions.downgrade') }}">
+                      {{ trans('settings.subscriptions_account_cancel_action') }}
+                    </a>
+                  </div>
+                </div>
+              </div>
+            </div>
+
 
             {{-- Only display invoices if the subscription exists or existed --}}
             @if ($hasInvoices)
-              <div class="invoices">
+              <div class="invoices pt4">
                 <h3>{{ trans('settings.subscriptions_account_invoices') }}</h3>
                 <ul class="table">
                   @foreach ($invoices as $invoice)
@@ -80,7 +111,6 @@
                 </ul>
               </div>
             @endif
-            @endif
 
           </div>
         </div>
diff --git a/resources/views/settings/subscriptions/blank.blade.php b/resources/views/settings/subscriptions/blank.blade.php
index 8abb90e7f5f..266b52bdc5d 100644
--- a/resources/views/settings/subscriptions/blank.blade.php
+++ b/resources/views/settings/subscriptions/blank.blade.php
@@ -42,8 +42,11 @@
                   <div class="b--purple ba pt3 br3 bw1 relative">
                     <img src="img/settings/subscription/best_value.png" class="absolute" style="top: -30px; left: -20px;">
                     <h3 class="tc mb3 pt3">{{ trans('settings.subscriptions_plan_year_title') }}</h3>
-                    <p class="tc mb4">
-                      <a href="settings/subscriptions/upgrade?plan=annual" class="btn btn-primary pv3">{{ trans('settings.subscriptions_plan_choose') }}</a>
+                    <p class="tc">
+                      <a href="{{ route('settings.subscriptions.upgrade') }}?plan=annual" class="btn btn-primary pv3">{{ trans('settings.subscriptions_plan_choose') }}</a>
+                    </p>
+                    <p class="tc mt2">
+                      {{ trans('settings.subscriptions_plan_frequency_year', ['amount' => \App\Helpers\InstanceHelper::getPlanInformationFromConfig('annual')['friendlyPrice']]) }}
                     </p>
                     <ul class="mb4 center ph4">
                       <li class="mb3 relative ml4">
@@ -64,8 +67,11 @@
                 <div class="{{ htmldir() == 'ltr' ? 'fl' : 'fr' }} w-50-ns w-100 pa3">
                   <div class="b--gray-monica ba pt3 br3 bw1">
                     <h3 class="tc mb3 pt3">{{ trans('settings.subscriptions_plan_month_title') }}</h3>
-                    <p class="tc mb4">
-                      <a href="settings/subscriptions/upgrade?plan=monthly" class="btn btn-primary pv3">{{ trans('settings.subscriptions_plan_choose') }}</a>
+                    <p class="tc">
+                      <a href="{{ route('settings.subscriptions.upgrade') }}?plan=monthly" class="btn btn-primary pv3">{{ trans('settings.subscriptions_plan_choose') }}</a>
+                    </p>
+                    <p class="tc mt2">
+                      {{ trans('settings.subscriptions_plan_frequency_month', ['amount' => \App\Helpers\InstanceHelper::getPlanInformationFromConfig('monthly')['friendlyPrice']]) }}
                     </p>
                     <ul class="mb4 center ph4">
                       <li class="mb3 relative ml4">
diff --git a/resources/views/settings/subscriptions/downgrade-checklist.blade.php b/resources/views/settings/subscriptions/downgrade-checklist.blade.php
index 4c7d8a579b0..2e828aaaed2 100644
--- a/resources/views/settings/subscriptions/downgrade-checklist.blade.php
+++ b/resources/views/settings/subscriptions/downgrade-checklist.blade.php
@@ -54,7 +54,7 @@
               <span class="rule-title">{{ trans('settings.subscriptions_downgrade_rule_contacts', ['number' => config('monica.number_of_allowed_contacts_free_account')]) }}</span>
               <span class="rule-to-succeed">{!! trans_choice('settings.subscriptions_downgrade_rule_contacts_constraint', $numberOfActiveContacts, ['url' => '/people', 'count' => $numberOfActiveContacts]) !!}</span>
               @if ($hasReachedContactLimit)
-              <span class="rule-to-succeed">We can also <a href="/settings/subscriptions/archive">archive all your contacts for you</a> - that would clear this rule and let you proceed with your account’s downgrade process.</span>
+                <span class="rule-to-succeed">{!! trans('settings.subscriptions_downgrade_rule_contacts_archive', ['url' => route('settings.subscriptions.archive')]) !!}</span>
               @endif
             </li>
 
diff --git a/resources/views/settings/subscriptions/update.blade.php b/resources/views/settings/subscriptions/update.blade.php
new file mode 100644
index 00000000000..594cd6dec2a
--- /dev/null
+++ b/resources/views/settings/subscriptions/update.blade.php
@@ -0,0 +1,73 @@
+@extends('layouts.skeleton')
+
+@section('content')
+
+<div class="settings">
+
+  {{-- Breadcrumb --}}
+  <div class="breadcrumb">
+    <div class="{{ Auth::user()->getFluidLayout() }}">
+      <div class="row">
+        <div class="col-12">
+          <ul class="horizontal">
+            <li>
+              <a href="{{ route('dashboard.index') }}">{{ trans('app.breadcrumb_dashboard') }}</a>
+            </li>
+            <li>
+              <a href="{{ route('settings.index') }}">{{ trans('app.breadcrumb_settings') }}</a>
+            </li>
+            <li>
+              {{ trans('app.breadcrumb_settings_subscriptions') }}
+            </li>
+          </ul>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="main-content">
+    <div class="{{ Auth::user()->getFluidLayout() }}">
+      <div class="row">
+        <div class="col-12 col-sm-8 offset-sm-2">
+
+          <h2 class="tc mt4 fw4">{{ trans('settings.subscriptions_account_update_title') }}</h2>
+
+          <p class="tc mb4">{{ trans('settings.subscriptions_account_update_description') }}</p>
+
+          @if ($legacyPlan)
+            <div>
+              <input type="radio" class="mr1" id="frequencycurrent" name="frequency" checked disabled>
+              <label for="frequencycurrent" class="pointer">
+                {{ trans('settings.subscriptions_account_current_legacy') }}
+                {{ $legacyPlan['name'] }} – {{ $legacyPlan['friendlyPrice'] }}
+              </label>
+           </div>
+          @endif
+
+          <form action="{{ route('settings.subscriptions.update') }}" method="POST">
+            @csrf
+
+            @foreach ($plans as $plan)
+              <div>
+                <input type="radio" class="mr1" id="frequency{{ $plan['id'] }}" name="frequency" value="{{ $plan['type'] }}" @if($planInformation['id'] === $plan['id']) checked @endif>
+                <label for="frequency{{ $plan['id'] }}" class="pointer">
+                  {{ $plan['name'] }} – {{ $plan['friendlyPrice'] }}
+                </label>
+              </div>
+            @endforeach
+
+            <p class="ma3 alert alert-success">{{ trans('settings.subscriptions_account_update_information') }}</p>
+
+            <div class="tc">
+              <button type="submit" class="btn btn-primary">{{ trans('app.confirm') }}</button>
+              <a href="{{ route('settings.subscriptions.index') }}" class="btn btn-secondary">{{ trans('app.cancel') }}</a>
+            </div>
+          </form>
+
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+@endsection
diff --git a/routes/web.php b/routes/web.php
index 97fdf4abbd3..5ee89f3e45a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -259,6 +259,8 @@
             Route::get('/settings/subscriptions', 'Settings\\SubscriptionsController@index')->name('index');
             Route::get('/settings/subscriptions/upgrade', 'Settings\\SubscriptionsController@upgrade')->name('upgrade');
             Route::get('/settings/subscriptions/upgrade/success', 'Settings\\SubscriptionsController@upgradeSuccess')->name('upgrade.success');
+            Route::get('/settings/subscriptions/update', 'Settings\\SubscriptionsController@update')->name('update');
+            Route::post('/settings/subscriptions/update', 'Settings\\SubscriptionsController@processUpdate');
             Route::get('/settings/subscriptions/confirmPayment/{id}', 'Settings\\SubscriptionsController@confirmPayment')->name('confirm');
             Route::post('/settings/subscriptions/processPayment', 'Settings\\SubscriptionsController@processPayment')->name('payment');
             Route::get('/settings/subscriptions/invoice/{invoice}', 'Settings\\SubscriptionsController@downloadInvoice')->name('invoice');
diff --git a/tests/Feature/AccountSubscriptionTest.php b/tests/Feature/AccountSubscriptionTest.php
index 5d2605820fc..74e8080be0f 100644
--- a/tests/Feature/AccountSubscriptionTest.php
+++ b/tests/Feature/AccountSubscriptionTest.php
@@ -5,7 +5,6 @@
 use Stripe\Plan;
 use Stripe\Stripe;
 use Stripe\Product;
-use Stripe\ApiResource;
 use Tests\FeatureTestCase;
 use Illuminate\Support\Str;
 use Laravel\Cashier\Subscription;
@@ -28,7 +27,12 @@ class AccountSubscriptionTest extends FeatureTestCase
     /**
      * @var string
      */
-    protected static $planId;
+    protected static $monthlyPlanId;
+
+    /**
+     * @var string
+     */
+    protected static $annualPlanId;
 
     public function setUp(): void
     {
@@ -40,9 +44,12 @@ public function setUp(): void
             config([
                 'services.stripe.secret' => env('STRIPE_SECRET'),
                 'monica.requires_subscription' => true,
+                'monica.paid_plan_monthly_friendly_name' => 'Monthly',
+                'monica.paid_plan_monthly_id' => 'monthly',
+                'monica.paid_plan_monthly_price' => 100,
                 'monica.paid_plan_annual_friendly_name' => 'Annual',
                 'monica.paid_plan_annual_id' => 'annual',
-                'monica.paid_plan_annual_price' => 100,
+                'monica.paid_plan_annual_price' => 500,
             ]);
         }
     }
@@ -54,10 +61,11 @@ public static function setUpBeforeClass(): void
         }
 
         Stripe::setApiVersion('2019-03-14');
-        Stripe::setApiKey(getenv('STRIPE_SECRET'));
+        Stripe::setApiKey(env('STRIPE_SECRET'));
 
-        static::$productId = static::$stripePrefix.'product-1'.Str::random(10);
-        static::$planId = static::$stripePrefix.'monthly-10-'.Str::random(10);
+        static::$productId = static::$stripePrefix.'product-'.Str::random(10);
+        static::$monthlyPlanId = static::$stripePrefix.'monthly-'.Str::random(10);
+        static::$annualPlanId = static::$stripePrefix.'annual-'.Str::random(10);
 
         Product::create([
             'id' => static::$productId,
@@ -66,12 +74,21 @@ public static function setUpBeforeClass(): void
         ]);
 
         Plan::create([
-            'id' => static::$planId,
+            'id' => static::$monthlyPlanId,
+            'nickname' => 'Monthly',
+            'currency' => 'USD',
+            'interval' => 'month',
+            'billing_scheme' => 'per_unit',
+            'amount' => 100,
+            'product' => static::$productId,
+        ]);
+        Plan::create([
+            'id' => static::$annualPlanId,
             'nickname' => 'Annual',
             'currency' => 'USD',
             'interval' => 'year',
             'billing_scheme' => 'per_unit',
-            'amount' => 100,
+            'amount' => 500,
             'product' => static::$productId,
         ]);
     }
@@ -80,19 +97,27 @@ public static function tearDownAfterClass(): void
     {
         parent::tearDownAfterClass();
 
-        if (static::$planId) {
-            static::deleteStripeResource(new Plan(static::$planId));
+        if (static::$monthlyPlanId) {
+            static::deleteStripeResource(new Plan(static::$monthlyPlanId));
+            static::$monthlyPlanId = null;
+        }
+        if (static::$annualPlanId) {
+            static::deleteStripeResource(new Plan(static::$annualPlanId));
+            static::$annualPlanId = null;
         }
         if (static::$productId) {
             static::deleteStripeResource(new Product(static::$productId));
+            static::$productId = null;
         }
     }
 
-    protected static function deleteStripeResource(ApiResource $resource)
+    protected static function deleteStripeResource($resource)
     {
         try {
-            $resource->delete();
-        } catch (InvalidRequest $e) {
+            if (method_exists($resource, 'delete')) {
+                $resource->delete();
+            }
+        } catch (\Stripe\Exception\ApiErrorException $e) {
             //
         }
     }
@@ -131,22 +156,6 @@ public function test_it_get_the_plan_name()
         $this->assertEquals('Annual', $user->account->getSubscribedPlanName());
     }
 
-    public function test_it_get_next_billing_date()
-    {
-        $user = $this->signin();
-
-        factory(Subscription::class)->create([
-            'account_id' => $user->account_id,
-            'name' => 'Annual',
-            'stripe_plan' => 'annual',
-            'stripe_id' => 'test',
-            'quantity' => 1,
-        ]);
-
-        $this->expectException(\App\Exceptions\StripeException::class);
-        $user->account->getNextBillingDate();
-    }
-
     public function test_it_throw_an_error_on_cancel()
     {
         $user = $this->signin();
@@ -214,7 +223,7 @@ public function test_it_subscribe_with_2nd_auth()
             'plan' => 'annual',
         ]);
 
-        $response->assertSee('Your payment is currently incomplete, please');
+        $response->assertSee('Extra confirmation is needed to process your payment.');
     }
 
     public function test_it_subscribe_with_error()
@@ -246,6 +255,50 @@ public function test_it_does_not_subscribe()
 
             return;
         }
-        $this->fails();
+        $this->fail();
+    }
+
+    public function test_it_get_blank_page_on_update_if_not_subscribed()
+    {
+        $this->signin();
+
+        $response = $this->get('/settings/subscriptions/update');
+
+        $response->assertSee('Upgrade Monica today and have more meaningful relationships.');
+    }
+
+    public function test_it_get_subscription_update()
+    {
+        $user = $this->signin();
+        $user->email = 'test_it_subscribe@monica-test.com';
+        $user->save();
+
+        $response = $this->post('/settings/subscriptions/processPayment', [
+            'payment_method' => 'pm_card_visa',
+            'plan' => 'annual',
+        ]);
+
+        $response = $this->get('/settings/subscriptions/update');
+
+        $response->assertSee('Monthly – $1.00');
+        $response->assertSee('Annual – $5.00');
+    }
+
+    public function test_it_process_subscription_update()
+    {
+        $user = $this->signin();
+        $user->email = 'test_it_subscribe@monica-test.com';
+        $user->save();
+
+        $response = $this->post('/settings/subscriptions/processPayment', [
+            'payment_method' => 'pm_card_visa',
+            'plan' => 'monthly',
+        ]);
+
+        $response = $this->followingRedirects()->post('/settings/subscriptions/update', [
+            'frequency' => 'annual',
+        ]);
+
+        $response->assertSee('You are on the Annual plan.');
     }
 }
diff --git a/tests/Unit/Helpers/AccountHelperTest.php b/tests/Unit/Helpers/AccountHelperTest.php
index 8af67079d1f..0533e805aeb 100644
--- a/tests/Unit/Helpers/AccountHelperTest.php
+++ b/tests/Unit/Helpers/AccountHelperTest.php
@@ -26,10 +26,7 @@ public function user_has_limitations_if_not_subscribed_or_exempted_of_subscripti
             'has_access_to_paid_version_for_free' => true,
         ]);
 
-        $this->assertEquals(
-            false,
-            AccountHelper::hasLimitations($account)
-        );
+        $this->assertFalse(AccountHelper::hasLimitations($account));
 
         // Check that if the ENV variable REQUIRES_SUBSCRIPTION has an effect
         $account = factory(Account::class)->make([
@@ -38,10 +35,7 @@ public function user_has_limitations_if_not_subscribed_or_exempted_of_subscripti
 
         config(['monica.requires_subscription' => false]);
 
-        $this->assertEquals(
-            false,
-            AccountHelper::hasLimitations($account)
-        );
+        $this->assertFalse(AccountHelper::hasLimitations($account));
     }
 
     /** @test */
@@ -53,23 +47,20 @@ public function account_has_reached_contact_limit_on_free_plan(): void
         ]);
 
         config(['monica.number_of_allowed_contacts_free_account' => 1]);
-        $this->assertTrue(
-            AccountHelper::hasReachedContactLimit($account)
-        );
+        $this->assertTrue(AccountHelper::hasReachedContactLimit($account));
+        $this->assertFalse(AccountHelper::isBelowContactLimit($account));
 
         factory(Contact::class)->state('partial')->create([
             'account_id' => $account->id,
         ]);
 
         config(['monica.number_of_allowed_contacts_free_account' => 3]);
-        $this->assertFalse(
-            AccountHelper::hasReachedContactLimit($account)
-        );
+        $this->assertFalse(AccountHelper::hasReachedContactLimit($account));
+        $this->assertTrue(AccountHelper::isBelowContactLimit($account));
 
         config(['monica.number_of_allowed_contacts_free_account' => 100]);
-        $this->assertFalse(
-            AccountHelper::hasReachedContactLimit($account)
-        );
+        $this->assertFalse(AccountHelper::hasReachedContactLimit($account));
+        $this->assertTrue(AccountHelper::isBelowContactLimit($account));
 
         $account = factory(Account::class)->create();
         factory(Contact::class, 2)->create([
@@ -82,9 +73,8 @@ public function account_has_reached_contact_limit_on_free_plan(): void
         ]);
 
         config(['monica.number_of_allowed_contacts_free_account' => 3]);
-        $this->assertTrue(
-            AccountHelper::hasReachedContactLimit($account)
-        );
+        $this->assertTrue(AccountHelper::hasReachedContactLimit($account));
+        $this->assertTrue(AccountHelper::isBelowContactLimit($account));
     }
 
     /** @test */
@@ -97,10 +87,7 @@ public function user_can_downgrade_with_only_one_user_and_no_pending_invitations
             'account_id' => $contact->account_id,
         ]);
 
-        $this->assertEquals(
-            true,
-            AccountHelper::canDowngrade($contact->account)
-        );
+        $this->assertTrue(AccountHelper::canDowngrade($contact->account));
     }
 
     /** @test */
@@ -112,10 +99,7 @@ public function user_cant_downgrade_with_two_users(): void
             'account_id' => $contact->account_id,
         ]);
 
-        $this->assertEquals(
-            false,
-            AccountHelper::canDowngrade($contact->account)
-        );
+        $this->assertFalse(AccountHelper::canDowngrade($contact->account));
     }
 
     /** @test */
@@ -127,10 +111,7 @@ public function user_cant_downgrade_with_pending_invitations(): void
             'account_id' => $account->id,
         ]);
 
-        $this->assertEquals(
-            false,
-            AccountHelper::canDowngrade($account)
-        );
+        $this->assertFalse(AccountHelper::canDowngrade($account));
     }
 
     /** @test */
@@ -143,9 +124,7 @@ public function user_cant_downgrade_with_too_many_contacts(): void
             'account_id' => $account->id,
         ]);
 
-        $this->assertFalse(
-            AccountHelper::canDowngrade($account)
-        );
+        $this->assertFalse(AccountHelper::canDowngrade($account));
     }
 
     /** @test */
@@ -153,10 +132,7 @@ public function it_gets_the_default_gender_for_the_account(): void
     {
         $account = factory(Account::class)->create();
 
-        $this->assertEquals(
-            Gender::UNKNOWN,
-            AccountHelper::getDefaultGender($account)
-        );
+        $this->assertEquals(Gender::UNKNOWN, AccountHelper::getDefaultGender($account));
 
         $gender = factory(Gender::class)->create([
             'account_id' => $account->id,
@@ -164,10 +140,7 @@ public function it_gets_the_default_gender_for_the_account(): void
         $account->default_gender_id = $gender->id;
         $account->save();
 
-        $this->assertEquals(
-            $gender->type,
-            AccountHelper::getDefaultGender($account)
-        );
+        $this->assertEquals($gender->type, AccountHelper::getDefaultGender($account));
     }
 
     /** @test */
@@ -181,10 +154,7 @@ public function get_reminders_for_month_returns_no_reminders(): void
         ]);
 
         // check if there are reminders for the month of March
-        $this->assertEquals(
-            0,
-            AccountHelper::getUpcomingRemindersForMonth($account, 3)->count()
-        );
+        $this->assertCount(0, AccountHelper::getUpcomingRemindersForMonth($account, 3));
     }
 
     /** @test */
@@ -207,10 +177,7 @@ public function get_reminders_for_month_returns_reminders_for_given_month(): voi
             $reminder->schedule($user);
         }
 
-        $this->assertEquals(
-            3,
-            AccountHelper::getUpcomingRemindersForMonth($account, 2)->count()
-        );
+        $this->assertCount(3, AccountHelper::getUpcomingRemindersForMonth($account, 2));
     }
 
     /** @test */
diff --git a/tests/Unit/Helpers/InstanceHelperTest.php b/tests/Unit/Helpers/InstanceHelperTest.php
index 5e41d05337e..be1cfb90eb2 100644
--- a/tests/Unit/Helpers/InstanceHelperTest.php
+++ b/tests/Unit/Helpers/InstanceHelperTest.php
@@ -2,6 +2,7 @@
 
 namespace Tests\Unit\Helpers;
 
+use Mockery;
 use Tests\TestCase;
 use function Safe\json_decode;
 use App\Helpers\InstanceHelper;
@@ -92,6 +93,52 @@ public function it_fetches_the_annually_plan_information()
         );
     }
 
+    /** @test */
+    public function it_fetches_subscription_information()
+    {
+        $stripeSubscription = (object) [
+            'plan' => (object) [
+                'currency' => 'USD',
+                'amount' => 500,
+                'interval' => 'month',
+                'id' => 'monthly',
+            ],
+            'current_period_end' => 1629976560,
+        ];
+
+        $subscription = Mockery::mock('\Laravel\Cashier\Subscription');
+        $subscription->shouldReceive('asStripeSubscription')
+            ->andReturn($stripeSubscription);
+        $subscription->shouldReceive('getAttribute')
+            ->with('name')
+            ->andReturn('Monthly');
+
+        $this->assertEquals(
+            'monthly',
+            InstanceHelper::getPlanInformationFromSubscription($subscription)['type']
+        );
+
+        $this->assertEquals(
+            'Monthly',
+            InstanceHelper::getPlanInformationFromSubscription($subscription)['name']
+        );
+
+        $this->assertEquals(
+            'monthly',
+            InstanceHelper::getPlanInformationFromSubscription($subscription)['id']
+        );
+
+        $this->assertEquals(
+            500,
+            InstanceHelper::getPlanInformationFromSubscription($subscription)['price']
+        );
+
+        $this->assertEquals(
+            '$5.00',
+            InstanceHelper::getPlanInformationFromSubscription($subscription)['friendlyPrice']
+        );
+    }
+
     /** @test */
     public function it_returns_null_when_fetching_an_unknown_plan_information()
     {