Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 106 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
| CancelUserPlanAction | |
0.00% |
0 / 106 |
|
0.00% |
0 / 4 |
756 | |
0.00% |
0 / 1 |
| execute | |
0.00% |
0 / 71 |
|
0.00% |
0 / 1 |
210 | |||
| runStep | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
| persistAuditLog | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
110 | |||
| cancelInstancySubscription | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Actions\Users; |
| 4 | |
| 5 | use App\Http\Models\AccountDeletionLog; |
| 6 | use App\Http\Models\Auth\User; |
| 7 | use App\Http\Models\Plans; |
| 8 | use App\Http\Models\Subscription; |
| 9 | use App\Http\Models\UserInfo; |
| 10 | use App\Http\Services\HubspotServiceV2; |
| 11 | use App\Http\Services\InstancyServiceV2; |
| 12 | use App\Http\Services\StatisticsService; |
| 13 | use App\Services\UserInfo\SubscriptionService; |
| 14 | use App\Services\UserInfo\UserInfoService; |
| 15 | use Carbon\Carbon; |
| 16 | use Illuminate\Support\Facades\Log; |
| 17 | use MongoDB\BSON\UTCDateTime; |
| 18 | use Throwable; |
| 19 | |
| 20 | class CancelUserPlanAction |
| 21 | { |
| 22 | public function execute(User $user, ?bool $onlyDeactivate = false, $cancellation_date = null): void |
| 23 | { |
| 24 | if (empty($cancellation_date)) { |
| 25 | $cancellation_date = Carbon::now(); |
| 26 | } |
| 27 | |
| 28 | $deleted = filled($user->deleted_at) && $user->deleted_at != null && ! $onlyDeactivate; |
| 29 | |
| 30 | $userInfo = UserInfo::where('user_id', $user->id)->first(); |
| 31 | |
| 32 | $data = [ |
| 33 | 'subscription_type' => 'Cancelled Account', |
| 34 | 'status' => 'Deactivated', |
| 35 | ]; |
| 36 | |
| 37 | $auditSteps = []; |
| 38 | $originalEmail = $user->email; |
| 39 | |
| 40 | // ── HubSpot opt-out for original email (deletion only) ─────────────── |
| 41 | if ($deleted) { |
| 42 | $auditSteps['hubspot_opt_out_original'] = $this->runStep( |
| 43 | fn () => (new HubspotServiceV2)->updateHubspotOptOutProperty($user->email) |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | // ── Build UserInfo payload ──────────────────────────────────────────── |
| 48 | if ($deleted) { |
| 49 | $delete_from_flymsg_email = "delete_from_flymsg_$user->email"; |
| 50 | $data = [ |
| 51 | 'deleted_at' => new UTCDateTime($cancellation_date->getTimestamp() * 1000), |
| 52 | 'status' => 'Deleted', |
| 53 | 'subscription_type' => 'Deleted Profile from FlyMSG', |
| 54 | 'email' => $delete_from_flymsg_email, |
| 55 | 'first_name' => '', |
| 56 | 'last_name' => '', |
| 57 | 'full_name' => '', |
| 58 | 'email_used_for_login' => '', |
| 59 | 'avatar' => '', |
| 60 | 'job_role' => '', |
| 61 | 'department' => '', |
| 62 | 'company' => '', |
| 63 | ]; |
| 64 | } |
| 65 | |
| 66 | // ── Stripe subscription cancellation ───────────────────────────────── |
| 67 | $subscriptionService = new SubscriptionService; |
| 68 | $stripeSubscriptionId = null; |
| 69 | |
| 70 | $userSub = $user->subscription('main'); |
| 71 | if ($userSub) { |
| 72 | $subscription = Subscription::find($userSub->id); |
| 73 | $subData = $subscriptionService->endSubscription($userSub->toArray(), $subscription, true, $cancellation_date); |
| 74 | $data = array_merge($data, $subData); |
| 75 | |
| 76 | $plan = $userSub->plan; |
| 77 | |
| 78 | if (isset($plan->identifier) && $plan->identifier != Plans::FREEMIUM_IDENTIFIER && $userSub->valid()) { |
| 79 | $stripeSubscriptionId = $userSub->stripe_id ?? null; |
| 80 | |
| 81 | if ($user->company_id) { |
| 82 | // Company-managed subscriptions: only mark cancelled in DB. |
| 83 | // Stripe is billed at the company level, not per-user. |
| 84 | $auditSteps['stripe_cancelled'] = $this->runStep( |
| 85 | fn () => $userSub->markAsCancelledOnlyInDB(), |
| 86 | ['stripe_subscription_id' => $stripeSubscriptionId, 'method' => 'db_only_company_user'] |
| 87 | ); |
| 88 | } else { |
| 89 | // Individual subscriptions: cancel at period end with Stripe so billing stops. |
| 90 | $auditSteps['stripe_cancelled'] = $this->runStep( |
| 91 | fn () => $userSub->cancel(), |
| 92 | ['stripe_subscription_id' => $stripeSubscriptionId, 'method' => 'stripe_cancel_at_period_end'] |
| 93 | ); |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | $endFreemiumData = $subscriptionService->endFreemiumSubscription($user->id, $cancellation_date); |
| 99 | $data = array_merge($data, $endFreemiumData); |
| 100 | |
| 101 | if ($userInfo) { |
| 102 | $userInfo->update($data); |
| 103 | } |
| 104 | |
| 105 | // ── HubSpot sync ───────────────────────────────────────────────────── |
| 106 | $userInfoService = new UserInfoService(new StatisticsService); |
| 107 | $auditSteps['hubspot_sync'] = $this->runStep( |
| 108 | fn () => $userInfoService->pushItToHubspot($user->id, false) |
| 109 | ); |
| 110 | |
| 111 | // ── HubSpot opt-out for anonymised email (deletion only) ───────────── |
| 112 | if ($deleted) { |
| 113 | $auditSteps['hubspot_opt_out_anonymised'] = $this->runStep( |
| 114 | fn () => (new HubspotServiceV2)->updateHubspotOptOutProperty($delete_from_flymsg_email) |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | // ── Instancy membership expiry ──────────────────────────────────────── |
| 119 | if ($user->instancy_id) { |
| 120 | $auditSteps['instancy_cancelled'] = $this->runStep( |
| 121 | fn () => $this->cancelInstancySubscription($user, $cancellation_date), |
| 122 | ['instancy_id' => $user->instancy_id] |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | // ── Persist audit log ──────────────────────────────────────────────── |
| 127 | $this->persistAuditLog($user, $originalEmail, $deleted, $auditSteps, [ |
| 128 | 'plan_name' => $userSub->plan->title ?? null, |
| 129 | 'stripe_subscription_id' => $stripeSubscriptionId, |
| 130 | 'company_id' => $user->company_id ?? null, |
| 131 | ]); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Run a step and return a structured result. |
| 136 | * |
| 137 | * @param callable $callable The work to attempt |
| 138 | * @param array $detail Extra context to include in the log entry |
| 139 | * @return array{success: bool, error: string|null, detail: array, timestamp: UTCDateTime} |
| 140 | */ |
| 141 | private function runStep(callable $callable, array $detail = []): array |
| 142 | { |
| 143 | $timestamp = new UTCDateTime(Carbon::now()->getTimestamp() * 1000); |
| 144 | try { |
| 145 | $callable(); |
| 146 | |
| 147 | return ['success' => true, 'error' => null, 'detail' => $detail, 'timestamp' => $timestamp]; |
| 148 | } catch (Throwable $e) { |
| 149 | Log::error('CancelUserPlanAction step failed', [ |
| 150 | 'detail' => $detail, |
| 151 | 'exception' => $e->getMessage(), |
| 152 | 'trace' => $e->getTraceAsString(), |
| 153 | ]); |
| 154 | |
| 155 | return [ |
| 156 | 'success' => false, |
| 157 | 'error' => $e->getMessage(), |
| 158 | 'detail' => $detail, |
| 159 | 'timestamp' => $timestamp, |
| 160 | ]; |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Write (or update) the AccountDeletionLog record. |
| 166 | */ |
| 167 | private function persistAuditLog(User $user, string $originalEmail, bool $deleted, array $steps, array $context): void |
| 168 | { |
| 169 | try { |
| 170 | $overallStatus = collect($steps)->every(fn ($s) => $s['success']) ? 'success' : 'partial_failure'; |
| 171 | |
| 172 | $authUser = auth()->user(); |
| 173 | |
| 174 | AccountDeletionLog::create([ |
| 175 | 'user_id' => (string) $user->id, |
| 176 | 'email' => $originalEmail, |
| 177 | 'action' => $deleted ? 'deleted' : 'deactivated', |
| 178 | 'triggered_by' => $authUser && $authUser->id !== $user->id ? 'admin' : 'self', |
| 179 | 'admin_id' => $authUser && $authUser->id !== $user->id ? (string) $authUser->id : null, |
| 180 | 'admin_email' => $authUser && $authUser->id !== $user->id ? $authUser->email : null, |
| 181 | 'overall_status' => $overallStatus, |
| 182 | 'steps' => $steps, |
| 183 | 'context' => $context, |
| 184 | ]); |
| 185 | } catch (Throwable $e) { |
| 186 | // Never let audit logging crash the deletion itself. |
| 187 | Log::error('CancelUserPlanAction: failed to write AccountDeletionLog', [ |
| 188 | 'user_id' => (string) $user->id, |
| 189 | 'exception' => $e->getMessage(), |
| 190 | ]); |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | private function cancelInstancySubscription(User $user, Carbon $cancellation_date): void |
| 195 | { |
| 196 | $instancyService = new InstancyServiceV2; |
| 197 | $instancyService->updateMembership($user->email, $cancellation_date->subDay()->toDateString()); |
| 198 | } |
| 199 | } |