diff --git a/app/Actions/DeleteUserAction.php b/app/Actions/DeleteUserAction.php new file mode 100644 index 00000000..1d48e148 --- /dev/null +++ b/app/Actions/DeleteUserAction.php @@ -0,0 +1,64 @@ +stripe_customer_id) { + $stripe = new StripeClient(config('stripe.secret')); + + $stripeSubscriptions = $stripe->subscriptions->all([ + 'customer' => $user->stripe_customer_id, + 'status' => 'active' + ]); + + $activeStripeSubscriptions = 0; + + foreach ($stripeSubscriptions as $subscription) { + // Determine whether or not subscription is active by looking at "ended_at" + if (!$subscription->ended_at) { + $activeStripeSubscriptions++; + } + } + + if ($activeStripeSubscriptions > 0) { + throw new UserActiveStripeSubscriptionException(); + } + } + + // Commit to deleting + if ($user->avatar) { + Storage::delete('public/avatars/' . $user->avatar); + } + + if (Helper::arePlansEnabled() && $user->stripe_customer_id) { + $stripe = new StripeClient(config('stripe.secret')); + + $stripe->customers->delete($user->stripe_customer_id); + } + + $user->fill([ + 'avatar' => null, + 'name' => null, + 'email' => null, + 'stripe_customer_id' => null + ])->save(); + } +} diff --git a/app/Exceptions/UserActiveStripeSubscriptionException.php b/app/Exceptions/UserActiveStripeSubscriptionException.php new file mode 100644 index 00000000..4c780ab5 --- /dev/null +++ b/app/Exceptions/UserActiveStripeSubscriptionException.php @@ -0,0 +1,10 @@ +execute(Auth::id()); + } catch (UserActiveStripeSubscriptionException $e) { + $request->session()->flash('delete_user_error', 'active_stripe_subscription'); + + return redirect()->route('settings.account'); + } + + Auth::logout(); + + return redirect()->route('login'); + } + public function getSpaces() { return view('settings.spaces.index', [ diff --git a/app/Jobs/SendWeeklyReports.php b/app/Jobs/SendWeeklyReports.php index 43a0edb4..8f40bfa6 100644 --- a/app/Jobs/SendWeeklyReports.php +++ b/app/Jobs/SendWeeklyReports.php @@ -49,6 +49,10 @@ public function handle() ORDER BY spendings.amount DESC LIMIT 1', [$space->id, $lastWeekDate, $currentDate]); foreach ($space->users as $user) { + if (!$user->email) { + continue; + } + // Only send if user wants to receive report if ($user->weekly_report) { Mail::to($user->email)->queue(new WeeklyReport( diff --git a/app/Models/User.php b/app/Models/User.php index a0f87c8c..b68afbfe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,7 +28,7 @@ class User extends Authenticatable ]; // Accessors - public function getAvatarAttribute($avatar) + public function getAvatarPathAttribute($avatar) { return $avatar ? '/storage/avatars/' . $avatar : 'https://via.placeholder.com/250'; } diff --git a/database/migrations/2020_08_22_232957_support_anonymization_of_users_table.php b/database/migrations/2020_08_22_232957_support_anonymization_of_users_table.php new file mode 100644 index 00000000..7cacdc95 --- /dev/null +++ b/database/migrations/2020_08_22_232957_support_anonymization_of_users_table.php @@ -0,0 +1,24 @@ +string('name')->nullable()->change(); + $table->string('email')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('users', function ($table) { + $table->string('name')->nullable(false)->change(); + $table->string('email')->nullable(false)->change(); + }); + } +} diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index b4570f75..6f93345f 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -6,6 +6,7 @@ import { Chrome } from 'vue-color'; import ButtonDropdown from './components/ButtonDropdown.vue'; import DatePicker from './components/DatePicker.vue'; import BarChart from './components/BarChart.vue'; +import DeleteUserButton from './components/DeleteUserButton.vue'; import Dropdown from './components/Dropdown.vue'; import TransactionWizard from './components/TransactionWizard.vue'; import ValidationError from './components/ValidationError.vue'; @@ -21,6 +22,7 @@ Vue.component('button-dropdown', ButtonDropdown); Vue.component('datepicker', DatePicker); // TODO DEPRECATE Vue.component('date-picker', DatePicker); Vue.component('barchart', BarChart); +Vue.component('delete-user-button', DeleteUserButton); Vue.component('dropdown', Dropdown); Vue.component('transaction-wizard', TransactionWizard); Vue.component('validation-error', ValidationError); diff --git a/resources/assets/js/components/DeleteUserButton.vue b/resources/assets/js/components/DeleteUserButton.vue new file mode 100644 index 00000000..8fd088f8 --- /dev/null +++ b/resources/assets/js/components/DeleteUserButton.vue @@ -0,0 +1,55 @@ + + + diff --git a/resources/assets/sass/components/input.scss b/resources/assets/sass/components/input.scss index cf8e3ab9..6db5e8ca 100644 --- a/resources/assets/sass/components/input.scss +++ b/resources/assets/sass/components/input.scss @@ -140,10 +140,26 @@ button.button { background: none; } + &.button--link-danger { + padding: 0; + font-size: 16px; + color: $colors-red; + background: none; + } + &.button--wide { padding: 12.5px 20px; width: 100%; } + + &.button--light { + background: #EEE; + color: #000; + } + + &.button--danger { + background: $colors-red; + } } .button, .button-dropdown { diff --git a/resources/views/activities/index.blade.php b/resources/views/activities/index.blade.php index 0b5b13b1..9ab1bd98 100644 --- a/resources/views/activities/index.blade.php +++ b/resources/views/activities/index.blade.php @@ -10,7 +10,7 @@
@if ($activity->user) - + @endif
{{ __('activities.' . $activity->action) }} #{{ $activity->entity_id }}
diff --git a/resources/views/layout.blade.php b/resources/views/layout.blade.php index e75fa64c..8073de4c 100644 --- a/resources/views/layout.blade.php +++ b/resources/views/layout.blade.php @@ -100,7 +100,7 @@
  • - +
    • diff --git a/resources/views/settings/account.blade.php b/resources/views/settings/account.blade.php index 314fe5ff..fd5d734e 100644 --- a/resources/views/settings/account.blade.php +++ b/resources/views/settings/account.blade.php @@ -27,3 +27,12 @@
  • @endsection + +@section('settings_body_formless') +
    + @if (session('delete_user_error') === 'active_stripe_subscription') +
    Unable to delete user, you still have a premium plan (and would continue to be billed otherwise)
    + @endif + +
    +@endsection diff --git a/resources/views/settings/profile.blade.php b/resources/views/settings/profile.blade.php index e33d31ea..90d7961d 100644 --- a/resources/views/settings/profile.blade.php +++ b/resources/views/settings/profile.blade.php @@ -9,7 +9,7 @@
    - + @include('partials.validation_error', ['payload' => 'avatar'])
    diff --git a/routes/web.php b/routes/web.php index 92723f06..01283e8c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -120,6 +120,7 @@ Route::post('/settings', [SettingsController::class, 'postIndex']); Route::get('/settings/profile', [SettingsController::class, 'getProfile'])->name('profile'); Route::get('/settings/account', [SettingsController::class, 'getAccount'])->name('account'); + Route::post('/settings/account/delete', [SettingsController::class, 'postAccountDelete'])->name('account.delete'); Route::get('/settings/preferences', [SettingsController::class, 'getPreferences'])->name('preferences'); Route::get('/settings/billing', [SettingsController::class, 'getBilling'])->name('billing')->middleware('stripe'); Route::post('/settings/billing/upgrade', [SettingsController::class, 'postUpgrade'])->name('billing.upgrade')->middleware('stripe'); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php new file mode 100644 index 00000000..2d3b31b7 --- /dev/null +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -0,0 +1,54 @@ +expectException(UserNotFoundException::class); + + (new DeleteUserAction())->execute(999); + } + + public function testSuccessfulAvatarCleared(): void + { + $image = \Image::canvas(500, 500, '#CCC'); + + Storage::put('public/avatars/yabadabadoo.png', (string) $image->encode()); + + $user = factory(User::class)->create([ + 'avatar' => 'yabadabadoo.png' + ]); + + $this->assertFileExists(storage_path() . '/app/public/avatars/yabadabadoo.png'); + + (new DeleteUserAction())->execute($user->id); + + $this->assertFileNotExists(storage_path() . '/app/public/avatars/yabadabadoo.png'); + } + + public function testSuccessfulColumnsCleared(): void + { + $user = factory(User::class)->create([ + 'name' => 'John Doe', + 'email' => 'jdoe@gmail.com' + ]); + + $this->assertNotNull($user->name); + $this->assertNotNull($user->email); + + (new DeleteUserAction())->execute($user->id); + + $user->refresh(); + + $this->assertNull($user->name); + $this->assertNull($user->email); + } +}