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() {