Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.49% covered (warning)
85.49%
165 / 193
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
HubspotServiceV2
85.49% covered (warning)
85.49%
165 / 193
72.73% covered (warning)
72.73%
8 / 11
47.39
0.00% covered (danger)
0.00%
0 / 1
 batchUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendToHusbpot
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 sendToHubspot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendToHubspotSafe
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 updateHubspotOptOutProperty
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 createHubspotProperties
93.88% covered (success)
93.88%
46 / 49
0.00% covered (danger)
0.00%
0 / 1
8.01
 endSalesProTrial
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 searchContactsByProfessionalStatus
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 syncUserState
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
5.03
 seedCanceledTiers
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 formatProperties
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Http\Services;
4
5use App\Helpers\FlyMSGLogger;
6use App\Http\Models\Auth\User;
7use App\Http\Models\HubspotFailedSync;
8use App\Http\Models\Plans;
9use App\Http\Models\Subscription;
10use App\Http\Models\UserInfo;
11use App\Jobs\ProcessHubspotAsyncJob;
12use App\Services\UserInfo\SubscriptionService;
13use Carbon\Carbon;
14use HubSpot\Client\Crm\Contacts\ApiException;
15use HubSpot\Client\Crm\Contacts\Model\BatchInputSimplePublicObjectBatchInput;
16use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectBatchInput;
17use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectInput;
18use HubSpot\Discovery\Discovery;
19use HubSpot\Factory;
20use Illuminate\Support\Facades\Config;
21use Illuminate\Support\Facades\Http;
22use Illuminate\Support\Facades\Log;
23use MongoDB\BSON\UTCDateTime;
24
25class HubspotServiceV2
26{
27    /**
28     * To update multiple properties for a single user
29     */
30    public function batchUpdate($hubspotId, array $properties = [])
31    {
32        ProcessHubspotAsyncJob::dispatch($hubspotId, $properties);
33    }
34
35    public function sendToHusbpot(string $hubspotId, array $properties, bool $safe = true): void
36    {
37        $user = User::firstWhere('hubspot_id', $hubspotId);
38
39        if (config('app.env') != 'production') {
40            $email = $user?->email ?? $hubspotId;
41            Log::info("Faking hubspot update for {$email}", $properties);
42
43            return;
44        }
45
46        if (empty($hubspotId) || empty($properties)) {
47            return;
48        }
49
50        $properties = $this->formatProperties($properties);
51
52        $client = Factory::createWithAccessToken(config('hubspotconfig.access_token'));
53
54        $batchInputSimplePublicObjectBatchInput = new BatchInputSimplePublicObjectBatchInput([
55            'inputs' => [
56                new SimplePublicObjectBatchInput([
57                    'properties' => $properties,
58                    'id' => $hubspotId,
59                ]),
60            ],
61        ]);
62
63        if ($safe) {
64            $this->sendToHubspotSafe($client, $batchInputSimplePublicObjectBatchInput, $user, $hubspotId, $properties);
65        } else {
66            $this->sendToHubspot($client, $batchInputSimplePublicObjectBatchInput);
67        }
68    }
69
70    private function sendToHubspot(
71        Discovery $client,
72        BatchInputSimplePublicObjectBatchInput $batchInputSimplePublicObjectBatchInput
73    ) {
74        $client->crm()->contacts()->batchApi()->update($batchInputSimplePublicObjectBatchInput);
75    }
76
77    private function sendToHubspotSafe(
78        Discovery $client,
79        BatchInputSimplePublicObjectBatchInput $batchInputSimplePublicObjectBatchInput,
80        User $user,
81        string $hubspotId,
82        array $properties,
83    ) {
84        try {
85            $this->sendToHubspot($client, $batchInputSimplePublicObjectBatchInput);
86        } catch (ApiException $e) {
87            HubspotFailedSync::create([
88                'user_id' => $user?->id ?? null,
89                'email' => $user?->email ?? null,
90                'hubspot_id' => $hubspotId,
91                'data' => json_encode($properties),
92                'error' => $e->getMessage(),
93            ]);
94        }
95    }
96
97    public function updateHubspotOptOutProperty($email)
98    {
99        if (config('app.env') != 'production') {
100            return;
101        }
102
103        try {
104            $accessToken = Config::get('hubspotconfig.access_token');
105            $url = "https://api.hubapi.com/communication-preferences/v4/statuses/{$email}/unsubscribe-all?verbose=false&channel=EMAIL&optState=OPT_OUT";
106
107            $data = [
108                'optState' => 'OPT_OUT',
109            ];
110
111            $response = Http::withHeaders([
112                'Authorization' => "Bearer {$accessToken}",
113                'Content-Type' => 'application/json',
114            ])->post($url, $data);
115
116            if ($response->successful()) {
117                Log::info("Unsubscribed {$email} from all emails.");
118            } else {
119                Log::error("Failed to unsubscribe {$email}", [
120                    'status' => $response->status(),
121                    'response' => $response->body(),
122                ]);
123            }
124        } catch (\Exception $e) {
125            Log::error('Exception in updateHubspotOptOutProperty: '.$e->getMessage());
126        }
127    }
128
129    public function createHubspotProperties(User $user)
130    {
131        if (config('app.env') != 'production') {
132            $fakeHubspotId = $user->email;
133
134            User::where(['email' => $user->email])->update(['hubspot_id' => $fakeHubspotId]);
135
136            return;
137        }
138
139        $user_contact_hubspot = [
140            'firstname' => $user->first_name,
141            'lastname' => $user->last_name,
142            'email' => $user->email,
143            'email_used_for_login' => $user->email,
144            'flymsg_id' => $user->id,
145            'signup_source' => $user->signup_source,
146        ];
147
148        if ($user->account_creation_date) {
149            // existing user
150            $user_contact_hubspot['account_creation_date'] = $user->account_creation_date;
151            $user_contact_hubspot['flymsg_id'] = $user->id;
152            $user_contact_hubspot['signup_source'] = $user->signup_source;
153        } else {
154            // new user
155            $user_contact_hubspot['account_creation_date'] = date('Y-m-d', strtotime('today midnight'));
156        }
157
158        $date_only = $user->created_at ?? $user->updated_at ?? now();
159        $date_only = explode(' ', $date_only->toDateTime()->format('Y-m-d'));
160        $createdTimestamp = \Carbon\Carbon::parse($date_only[0])->toDateString();
161        $user_contact_hubspot['df_stripe_customer_id'] = $user->stripe_id;
162        $user_contact_hubspot['freemium_subscription_start_date'] = $createdTimestamp;
163        $user_contact_hubspot['freemium_subscription_status_updated_on'] = $createdTimestamp;
164        $user_contact_hubspot['flymsg_freemium_subscription_status'] = 'Active';
165        $user_contact_hubspot['subscription_type'] = empty($user->email_verified_at) ? 'Unverified' : 'Freemium';
166
167        $field_name = 'email';
168        $searchRequest = [
169            'filterGroups' => [[
170                'filters' => [[
171                    'value' => $user->email,
172                    'propertyName' => $field_name,
173                    'operator' => 'EQ',
174                ]],
175            ]],
176        ];
177
178        $client = Factory::createWithAccessToken(config('hubspotconfig.access_token'));
179        $apiResponse = $client->crm()->contacts()->searchApi()->doSearch($searchRequest);
180
181        if (isset($apiResponse['total']) && $apiResponse['total'] > 0) {
182            $response = $apiResponse['results'][0];
183            $hubspot_id = $response['id'];
184            User::where(['email' => $user->email])->update(['hubspot_id' => $hubspot_id]);
185        } else {
186            $user = User::firstWhere('email', $user->email);
187            if (! $user || empty($user->hubspot_id)) {
188                $simplePublicObjectInputForCreate = new SimplePublicObjectInput([
189                    'properties' => $this->formatProperties($user_contact_hubspot),
190                ]);
191
192                $client = Factory::createWithAccessToken(config('hubspotconfig.access_token'));
193                $apiResponse = $client->crm()->contacts()->basicApi()->create($simplePublicObjectInputForCreate);
194                User::where(['email' => $user->email])->update(['hubspot_id' => $apiResponse['id']]);
195            }
196        }
197    }
198
199    /**
200     * Mark a Sales Pro trial as ended in HubSpot. Idempotent â€” safe to call on redelivery.
201     */
202    public function endSalesProTrial(User $user, string $reason): void
203    {
204        if (empty($user->hubspot_id)) {
205            return;
206        }
207
208        $now = now()->toDateString();
209        $salesProIdentifiers = [Plans::PROFESSIONAL_MONTHLY_IDENTIFIER, Plans::PROFESSIONAL_YEARLY_IDENTIFIER];
210
211        // If user still has a different active paid plan, use its HubSpot name.
212        $activeSub = $user->subscription('main');
213        if ($activeSub && $activeSub->valid() && $activeSub->plan &&
214            ! in_array($activeSub->plan->identifier, $salesProIdentifiers)) {
215            $subscriptionType = $activeSub->plan->hubspot_name ?? 'Cancelled Account';
216        } else {
217            $subscriptionType = 'Cancelled Account';
218        }
219
220        FlyMSGLogger::logInfo('SalesProTrialEnded', [
221            'email' => $user->email,
222            'reason' => $reason,
223            'prior_status' => 'Trial',
224            'new_status' => 'Canceled',
225        ]);
226
227        $this->batchUpdate($user->hubspot_id, [
228            'professional_subscription_status' => 'Canceled',
229            'professional_subscription_status_updated_on' => $now,
230            'professional_subscription_churn_date' => $now,
231            'professional_cancel_subscription_date' => $now,
232            'subscription_type' => $subscriptionType,
233        ]);
234    }
235
236    /**
237     * Search HubSpot contacts by professional_subscription_status value.
238     *
239     * Returns ['results' => [...], 'paging' => ...] or empty arrays in non-production.
240     */
241    public function searchContactsByProfessionalStatus(string $status, int $limit = 50, string $after = '0'): array
242    {
243        if (config('app.env') !== 'production') {
244            return ['results' => [], 'paging' => null];
245        }
246
247        $searchRequest = [
248            'filterGroups' => [[
249                'filters' => [[
250                    'value' => $status,
251                    'propertyName' => 'professional_subscription_status',
252                    'operator' => 'EQ',
253                ]],
254            ]],
255            'properties' => ['email', 'professional_subscription_status'],
256            'limit' => $limit,
257            'after' => $after,
258        ];
259
260        try {
261            $client = Factory::createWithAccessToken(config('hubspotconfig.access_token'));
262            $response = $client->crm()->contacts()->searchApi()->doSearch($searchRequest);
263
264            return [
265                'results' => $response['results'] ?? [],
266                'paging' => $response['paging'] ?? null,
267            ];
268        } catch (\Exception $e) {
269            Log::error('HubspotServiceV2::searchContactsByProfessionalStatus: '.$e->getMessage());
270
271            return ['results' => [], 'paging' => null];
272        }
273    }
274
275    /**
276     * Sync a user's subscription state to HubSpot with seed-then-merge semantics.
277     *
278     * 1. Seeds every premium tier's HubSpot fields to "Canceled".
279     * 2. Overwrites the active tier's fields from the user's active subscriptions.
280     * 3. If no premium subscription is active, ensures the freemium tier is "Active".
281     * 4. Persists the resulting field set to UserInfo and pushes it to HubSpot.
282     */
283    public function syncUserState(User $user): void
284    {
285        $userInfo = UserInfo::firstOrNew(['user_id' => $user->id]);
286        $subscriptionService = new SubscriptionService;
287
288        $data = $this->seedCanceledTiers($user, []);
289
290        $activeSubscriptions = Subscription::where('user_id', $user->id)
291            ->whereIn('stripe_status', ['active', 'trialing'])
292            ->get();
293
294        foreach ($activeSubscriptions as $subscription) {
295            $fields = $subscription->toArray();
296            if (! empty($fields['stripe_plan'])) {
297                $tierData = $subscriptionService->startSubscription($fields, $subscription);
298                $data = array_merge($data, $tierData);
299            }
300        }
301
302        if ($activeSubscriptions->isEmpty()) {
303            $freemiumData = $subscriptionService->initFreemiumSubscription($user->id);
304            $data = array_merge($data, $freemiumData);
305        }
306
307        $userInfo->fill($data);
308        $userInfo->save();
309
310        if ($user->hubspot_id) {
311            $this->batchUpdate($user->hubspot_id, $data);
312        }
313    }
314
315    /**
316     * Seed all premium tier status fields to "Canceled" in the given data array.
317     *
318     * Iterates config('plan-tiers') and for each tier:
319     *  - sets status = "Canceled"
320     *  - sets cancel_date / churn_date to max(ends_at) of historical subs, or now()
321     *  - sets updated_on = now()
322     *
323     * The per-subscription processing in syncUserState() will overwrite only the
324     * tier(s) whose subscription is currently active.
325     *
326     * @param  array<string, mixed>  $data
327     * @return array<string, mixed>
328     */
329    public function seedCanceledTiers(User $user, array $data): array
330    {
331        $tiers = config('plan-tiers');
332        $now = new UTCDateTime(Carbon::now()->timestamp * 1000);
333
334        foreach ($tiers as $tierConfig) {
335            $statusField    = $tierConfig['status_field'];
336            $cancelField    = $tierConfig['cancel_date_field'];
337            $churnField     = $tierConfig['churn_date_field'];
338            $updatedOnField = $tierConfig['updated_on_field'];
339
340            $tierPlanIds = Plans::whereIn('identifier', $tierConfig['identifiers'])
341                ->pluck('stripe_id')
342                ->filter()
343                ->values()
344                ->all();
345
346            $cancelDate = $now;
347            if (! empty($tierPlanIds)) {
348                $lastSub = Subscription::where('user_id', $user->id)
349                    ->whereIn('stripe_plan', $tierPlanIds)
350                    ->whereNotNull('ends_at')
351                    ->orderBy('ends_at', 'desc')
352                    ->first();
353
354                if ($lastSub && $lastSub->ends_at) {
355                    $cancelDate = new UTCDateTime($lastSub->ends_at->timestamp * 1000);
356                }
357            }
358
359            $data[$statusField]    = 'Canceled';
360            $data[$cancelField]    = $cancelDate;
361            $data[$churnField]     = $cancelDate;
362            $data[$updatedOnField] = $now;
363        }
364
365        return $data;
366    }
367
368    /**
369     * Format property values to prevent scientific notation in JSON encoding.
370     *
371     * PHP's json_encode() converts small floats (e.g. 0.00008333) to scientific
372     * notation (e.g. 8.333e-5), which HubSpot cannot parse. This method converts
373     * float values to their decimal string representation.
374     *
375     * @param  array<string, mixed>  $properties
376     * @return array<string, mixed>
377     */
378    private function formatProperties(array $properties): array
379    {
380        return array_map(function ($value) {
381            if (is_float($value)) {
382                return rtrim(rtrim(number_format($value, 8, '.', ''), '0'), '.');
383            }
384
385            return $value;
386        }, $properties);
387    }
388}