Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.62% covered (success)
90.62%
87 / 96
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
RoleplayAddonLifecycleService
90.62% covered (success)
90.62%
87 / 96
71.43% covered (warning)
71.43%
10 / 14
44.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 getActiveRoleplayAddon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 ensureAddonForUser
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 applyCorporateAddonToUser
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 cancelCorporateAddonForUser
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 applyCompanyAddonToAllUsers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 cancelCompanyAddonForAllUsers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 createCorporateAddon
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 createBeginnerAddon
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 cancelExistingAddonForCorporateSwap
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 companyHasCorporateRoleplay
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isUserActive
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 isCompanyActive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getUserCompany
100.00% covered (success)
100.00%
3 / 3
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\AddOns;
7use App\Http\Models\Admin\Company;
8use App\Http\Models\Auth\User;
9use App\Http\Models\UserAddOns;
10use Illuminate\Support\Facades\Log;
11use Stripe\Exception\ApiErrorException;
12use Stripe\Stripe;
13use Stripe\Subscription as StripeSubscription;
14
15/**
16 * Centralises all corporate / freemium RolePlay addon lifecycle operations
17 * so that controllers, observers, services and scheduled jobs can stay thin.
18 *
19 * A user can have at most ONE active RolePlay UserAddOn at a time. The
20 * `source` column distinguishes corporate (company-assigned) rows from
21 * individual (Stripe-billed) rows. Corporate rows must never hold Stripe
22 * identifiers.
23 */
24class RoleplayAddonLifecycleService
25{
26    public function __construct()
27    {
28        if (config('app.stripe.secret')) {
29            Stripe::setApiKey(config('app.stripe.secret'));
30        }
31    }
32
33    /**
34     * Return the user's current active RolePlay addon, if any.
35     */
36    public function getActiveRoleplayAddon(User $user): ?UserAddOns
37    {
38        return UserAddOns::where('user_id', $user->id)
39            ->where('product', 'RolePlay')
40            ->where('status', 'active')
41            ->orderBy('created_at', 'desc')
42            ->first();
43    }
44
45    /**
46     * Lazily ensure the user has a valid RolePlay addon assignment.
47     *
48     * Rules:
49     *  - Already has an active addon → return it.
50     *  - Company has a corporate addon and everything is active → grant it.
51     *  - Otherwise → grant the freemium "beginner" addon.
52     */
53    public function ensureAddonForUser(User $user): UserAddOns
54    {
55        $existing = $this->getActiveRoleplayAddon($user);
56        if ($existing) {
57            return $existing;
58        }
59
60        $company = $this->getUserCompany($user);
61
62        if (
63            $company
64            && $this->companyHasCorporateRoleplay($company)
65            && $this->isUserActive($user)
66            && $this->isCompanyActive($company)
67        ) {
68            return $this->createCorporateAddon($user, $company->company_roleplay_addon_id);
69        }
70
71        return $this->createBeginnerAddon($user);
72    }
73
74    /**
75     * Apply a corporate addon to a user (idempotent). If the user already has
76     * an active addon for the same target addon id and source=company, reuse
77     * it. Otherwise cancel whatever individual / company addon they currently
78     * hold and create a new corporate row.
79     */
80    public function applyCorporateAddonToUser(User $user, string $addOnId): ?UserAddOns
81    {
82        try {
83            if (! $this->isUserActive($user)) {
84                return null;
85            }
86
87            $existing = $this->getActiveRoleplayAddon($user);
88
89            if ($existing && $existing->add_on_id === $addOnId && $existing->source === UserAddOns::SOURCE_COMPANY) {
90                return $existing; // idempotent no-op
91            }
92
93            if ($existing) {
94                $this->cancelExistingAddonForCorporateSwap($existing);
95            }
96
97            return $this->createCorporateAddon($user, $addOnId);
98        } catch (\Throwable $e) {
99            FlyMSGLogger::logError(__METHOD__.' user:'.$user->id, $e);
100
101            return null;
102        }
103    }
104
105    /**
106     * Cancel a user's active corporate roleplay addon and fall back to the
107     * freemium beginner addon. Safe no-op if they don't have a corporate row.
108     */
109    public function cancelCorporateAddonForUser(User $user): ?UserAddOns
110    {
111        try {
112            $existing = $this->getActiveRoleplayAddon($user);
113
114            if (! $existing || $existing->source !== UserAddOns::SOURCE_COMPANY) {
115                return null;
116            }
117
118            $existing->update([
119                'status' => 'canceled',
120                'ends_at' => now(),
121            ]);
122
123            return $this->createBeginnerAddon($user);
124        } catch (\Throwable $e) {
125            FlyMSGLogger::logError(__METHOD__.' user:'.$user->id, $e);
126
127            return null;
128        }
129    }
130
131    /**
132     * Bulk apply a new corporate addon to every active user in the company.
133     */
134    public function applyCompanyAddonToAllUsers(Company $company, string $addOnId): void
135    {
136        foreach ($company->users as $user) {
137            if (! $this->isUserActive($user)) {
138                continue;
139            }
140            $this->applyCorporateAddonToUser($user, $addOnId);
141        }
142    }
143
144    /**
145     * Bulk cancel corporate addons across every active user of a company and
146     * drop them back to beginner.
147     */
148    public function cancelCompanyAddonForAllUsers(Company $company): void
149    {
150        foreach ($company->users as $user) {
151            if (! $this->isUserActive($user)) {
152                continue;
153            }
154            $this->cancelCorporateAddonForUser($user);
155        }
156    }
157
158    // ---------------------------------------------------------------------
159    // Internal helpers
160    // ---------------------------------------------------------------------
161
162    private function createCorporateAddon(User $user, string $addOnId): UserAddOns
163    {
164        $addOn = AddOns::find($addOnId);
165        if (! $addOn) {
166            throw new \RuntimeException("Corporate RolePlay addon {$addOnId} not found");
167        }
168
169        return UserAddOns::create([
170            'user_id' => $user->id,
171            'add_on_id' => $addOn->id,
172            'product' => 'RolePlay',
173            'status' => 'active',
174            'source' => UserAddOns::SOURCE_COMPANY,
175            'starts_at' => now(),
176            'ends_at' => null,
177            'quantity' => 1,
178            'stripe_id' => null,
179            'stripe_price_id' => null,
180        ]);
181    }
182
183    private function createBeginnerAddon(User $user): UserAddOns
184    {
185        $beginner = AddOns::where('identifier', 'roleplay-beginner')->first();
186        if (! $beginner) {
187            throw new \RuntimeException('roleplay-beginner addon not found — run AddOnsSeeder');
188        }
189
190        return UserAddOns::create([
191            'user_id' => $user->id,
192            'add_on_id' => $beginner->id,
193            'product' => 'RolePlay',
194            'status' => 'active',
195            'source' => UserAddOns::SOURCE_INDIVIDUAL,
196            'starts_at' => now(),
197            'ends_at' => null,
198            'quantity' => 1,
199            'stripe_id' => null,
200            'stripe_price_id' => null,
201        ]);
202    }
203
204    /**
205     * Cancel the user's currently-held addon when swapping in a new corporate
206     * one. Both individual (Stripe) and previous corporate rows terminate
207     * immediately so the corporate addon takes effect right away — individual
208     * Stripe subscriptions are canceled instantly (no period-end grace).
209     */
210    private function cancelExistingAddonForCorporateSwap(UserAddOns $existing): void
211    {
212        if ($existing->source === UserAddOns::SOURCE_INDIVIDUAL && ! empty($existing->stripe_id)) {
213            try {
214                $sub = StripeSubscription::retrieve($existing->stripe_id);
215                $sub->cancel();
216            } catch (ApiErrorException $e) {
217                Log::warning('Failed to cancel Stripe subscription '.$existing->stripe_id.': '.$e->getMessage());
218            }
219        }
220
221        $existing->update([
222            'status' => 'canceled',
223            'ends_at' => now(),
224        ]);
225    }
226
227    private function companyHasCorporateRoleplay(Company $company): bool
228    {
229        return ! empty($company->company_roleplay_addon_id)
230            && $company->roleplay_addon_access !== Company::ROLEPLAY_ACCESS_HIDDEN;
231    }
232
233    private function isUserActive(User $user): bool
234    {
235        if (! empty($user->deleted_at)) {
236            return false;
237        }
238        $status = strtolower((string) ($user->status ?? ''));
239
240        return $status === '' || $status === 'active';
241    }
242
243    private function isCompanyActive(Company $company): bool
244    {
245        return empty($company->deactivated_at) && empty($company->deleted_at);
246    }
247
248    private function getUserCompany(User $user): ?Company
249    {
250        if (empty($user->company_id)) {
251            return null;
252        }
253
254        return Company::find($user->company_id);
255    }
256}