Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
CancelUserPlanAction
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 4
756
0.00% covered (danger)
0.00%
0 / 1
 execute
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
210
 runStep
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 persistAuditLog
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 cancelInstancySubscription
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Actions\Users;
4
5use App\Http\Models\AccountDeletionLog;
6use App\Http\Models\Auth\User;
7use App\Http\Models\Plans;
8use App\Http\Models\Subscription;
9use App\Http\Models\UserInfo;
10use App\Http\Services\HubspotServiceV2;
11use App\Http\Services\InstancyServiceV2;
12use App\Http\Services\StatisticsService;
13use App\Services\UserInfo\SubscriptionService;
14use App\Services\UserInfo\UserInfoService;
15use Carbon\Carbon;
16use Illuminate\Support\Facades\Log;
17use MongoDB\BSON\UTCDateTime;
18use Throwable;
19
20class 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}