Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.43% covered (danger)
32.43%
36 / 111
42.86% covered (danger)
42.86%
6 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlaySubscriptionController
32.43% covered (danger)
32.43%
36 / 111
42.86% covered (danger)
42.86%
6 / 14
117.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createUserAdvancedPlan
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 createUserExpertPlan
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 createUserFreemiumPlan
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getUserPlan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureAddon
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 info
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableTime
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getAvailableSessions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getMonthlySessionsUsed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMonthlySecondsUsed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActivePersonasCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 destroy
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Http\Controllers\v2\RolePlay;
4
5use App\Http\Controllers\Controller;
6use App\Http\Models\AddOns;
7use App\Http\Models\Admin\Company;
8use App\Http\Models\Auth\User;
9use App\Http\Models\UserAddOns;
10use App\Http\Repositories\interfaces\IRolePlayConversationsRepository;
11use App\Http\Repositories\interfaces\IRolePlayProjectsRepository;
12use App\Http\Requests\v2\RolePlay\DestroyUserAddOnRequest;
13use App\Http\Services\RoleplayAddonLifecycleService;
14use App\Traits\SubscriptionTrait;
15use Illuminate\Http\JsonResponse;
16use Illuminate\Http\Request;
17use Stripe\Checkout\Session;
18use Stripe\Exception\ApiErrorException;
19use Stripe\Stripe;
20use Stripe\Subscription;
21
22/**
23 * RolePlay Subscription Controller
24 *
25 * Exposes the RolePlay add-on lifecycle to the roleplay frontend: ensuring
26 * the caller has a resolved plan, surfacing current usage vs. quota, and
27 * proxying Stripe checkout session creation / cancellation for upgrades
28 * and churn. Plan resolution itself lives in {@see RoleplayAddonLifecycleService}.
29 */
30class RolePlaySubscriptionController extends Controller
31{
32    use SubscriptionTrait;
33
34    public function __construct(
35        private readonly RoleplayAddonLifecycleService $roleplayAddonLifecycleService,
36        private readonly IRolePlayConversationsRepository $conversationsRepository,
37        private readonly IRolePlayProjectsRepository $projectsRepository,
38    ) {
39        Stripe::setApiKey(config('app.stripe.secret'));
40    }
41
42    /**
43     * Create a locked-in Advanced monthly addon for the user (internal seed
44     * used by onboarding / support tooling — not a user-facing upgrade path).
45     */
46    private function createUserAdvancedPlan(User $user): UserAddOns
47    {
48        $addOn = AddOns::where('identifier', 'roleplay-advanced-monthly')->where('recurrency_type', 'monthly')->first();
49
50        return UserAddOns::create([
51            'user_id' => $user->id,
52            'add_on_id' => $addOn->id,
53            'product' => 'RolePlay',
54            'status' => 'active',
55            'starts_at' => now(),
56            'ends_at' => null,
57            'quantity' => 1,
58            'stripe_id' => null,
59        ]);
60    }
61
62    /**
63     * Create a locked-in Expert monthly addon for the user (internal seed
64     * used by onboarding / support tooling — not a user-facing upgrade path).
65     */
66    private function createUserExpertPlan(User $user): UserAddOns
67    {
68        $addOn = AddOns::where('identifier', 'roleplay-expert-monthly')->where('recurrency_type', 'monthly')->first();
69
70        return UserAddOns::create([
71            'user_id' => $user->id,
72            'add_on_id' => $addOn->id,
73            'product' => 'RolePlay',
74            'status' => 'active',
75            'starts_at' => now(),
76            'ends_at' => null,
77            'quantity' => 1,
78            'stripe_id' => null,
79        ]);
80    }
81
82    /**
83     * Create the zero-cost Beginner/Freemium addon for a user who has no
84     * active paid assignment. Invoked by the lifecycle service as a fallback.
85     */
86    private function createUserFreemiumPlan(User $user): UserAddOns
87    {
88        $freemiumAddOn = AddOns::where('identifier', 'roleplay-beginner')->first();
89
90        return UserAddOns::create([
91            'user_id' => $user->id,
92            'add_on_id' => $freemiumAddOn->id,
93            'product' => 'RolePlay',
94            'status' => 'active',
95            'starts_at' => now(),
96            'ends_at' => null,
97            'quantity' => 1,
98            'stripe_id' => null,
99        ]);
100    }
101
102    /**
103     * Resolve the user's active RolePlay plan, applying company addon
104     * overrides and freemium fallback semantics. Thin delegate to
105     * {@see RoleplayAddonLifecycleService::ensureAddonForUser()}.
106     */
107    private function getUserPlan(User $user): UserAddOns
108    {
109        // Delegate to the lifecycle service so corporate addons and freemium
110        // fallback are applied consistently everywhere a plan is resolved.
111        return $this->roleplayAddonLifecycleService->ensureAddonForUser($user);
112    }
113
114    /**
115     * POST /v2/role-play/ensure-addon
116     *
117     * Lazily ensures the authenticated user has a valid RolePlay addon
118     * assignment and returns it. Used by the roleplay frontend on app boot
119     * so that corporate-assigned users always land on the right plan.
120     *
121     * @response 200 {
122     *   "result": {
123     *     "id": "...",
124     *     "user_id": "...",
125     *     "add_on_id": "...",
126     *     "product": "RolePlay",
127     *     "status": "active",
128     *     "source": "company|individual",
129     *     "starts_at": 1700000000,
130     *     "ends_at": null
131     *   }
132     * }
133     */
134    public function ensureAddon(Request $request): JsonResponse
135    {
136        $user = $request->user();
137        $addon = $this->roleplayAddonLifecycleService->ensureAddonForUser($user);
138
139        return response()->json([
140            'status' => 'success',
141            'data' => $addon->toArray(),
142        ]);
143    }
144
145    /**
146     * Return the caller's RolePlay subscription snapshot — resolved plan,
147     * quota utilization for the current calendar month, persona count, and
148     * the company's `roleplay_addon_access` flag that gates purchase UI.
149     *
150     * @response 200 {
151     *   "result": {
152     *     "status": "success",
153     *     "data": {
154     *       "user": {...},
155     *       "plan": {"id": "...", "add_on_id": "...", "source": "individual|company", "status": "active"},
156     *       "add_on": {"identifier": "roleplay-advanced-monthly", "features": {...}},
157     *       "roleplay_addon_access": "can_purchase|view_only|hidden",
158     *       "available_time_seconds": 3600,
159     *       "available_sessions": 30,
160     *       "sessions_used_this_month": 12,
161     *       "seconds_used_this_month": 540,
162     *       "personas_count": 4
163     *     }
164     *   }
165     * }
166     */
167    public function info(Request $request): JsonResponse
168    {
169        $user = $request->user();
170        $plan = $this->getUserPlan($user);
171        $addOn = AddOns::find($plan->add_on_id);
172
173        $sessionsUsedThisMonth = $this->getMonthlySessionsUsed($user);
174        $secondsUsedThisMonth = $this->getMonthlySecondsUsed($user);
175        $personasCount = $this->getActivePersonasCount($user);
176
177        $availableTime = $this->getAvailableTime($addOn, $secondsUsedThisMonth);
178        $availableSessions = $this->getAvailableSessions($addOn, $sessionsUsedThisMonth);
179
180        $planArray = $plan->toArray();
181        $planArray['source'] = $plan->source;
182
183        // Surface the company's roleplay access setting so the roleplay
184        // frontend can hide management controls (Change Plan / Cancel /
185        // pricing) for users in companies set to view_only or hidden.
186        // `$user->company` is already eager-loaded via User::$with.
187        $roleplayAccess = $user->company?->roleplay_addon_access ?? Company::ROLEPLAY_ACCESS_CAN_PURCHASE;
188
189        return response()->json([
190            'status' => 'success',
191            'data' => [
192                'user' => $user,
193                'plan' => $planArray,
194                'add_on' => $addOn,
195                'roleplay_addon_access' => $roleplayAccess,
196                'available_time_seconds' => $availableTime,
197                'available_sessions' => $availableSessions,
198                // Raw per-cycle and lifetime usage counters consumed by the
199                // Settings → Current Usage cards. Exposed separately from the
200                // "available_*" fields so unlimited plans can still display
201                // the real number of sessions/minutes consumed this month
202                // instead of always rendering 0.
203                'sessions_used_this_month' => $sessionsUsedThisMonth,
204                'seconds_used_this_month' => $secondsUsedThisMonth,
205                'personas_count' => $personasCount,
206            ],
207        ]);
208    }
209
210    private function getAvailableTime(AddOns $product, int $usedSeconds): int
211    {
212        $total = (int) ($product->features['monthly_total_time_credits'] ?? 0);
213        if ($total === -1) {
214            return -1;
215        }
216
217        return max(0, $total - $usedSeconds);
218    }
219
220    private function getAvailableSessions(AddOns $product, int $usedSessions): int
221    {
222        $total = (int) ($product->features['monthly_roleplay_credits'] ?? 0);
223        if ($total === -1) {
224            return -1;
225        }
226
227        return max(0, $total - $usedSessions);
228    }
229
230    /**
231     * Total number of non-failed role-play sessions the user has started
232     * in the current calendar month. Used to populate the Sessions card
233     * even when the plan is unlimited.
234     */
235    private function getMonthlySessionsUsed(User $user): int
236    {
237        return $this->conversationsRepository->countForUserThisMonth($user);
238    }
239
240    /**
241     * Total seconds of role-play conversation recorded in the current
242     * calendar month. Derived from stored `duration` fields so it
243     * reflects real time spent regardless of the plan's time credit.
244     */
245    private function getMonthlySecondsUsed(User $user): int
246    {
247        return $this->conversationsRepository->sumDurationForUserThisMonth($user);
248    }
249
250    /**
251     * Lifetime count of active (non-soft-deleted) personas the user
252     * currently owns. Personas are a lifetime quota — they do NOT reset
253     * each billing cycle — so this intentionally ignores the cycle
254     * window.
255     */
256    private function getActivePersonasCount(User $user): int
257    {
258        return $this->projectsRepository->countForUser($user);
259    }
260
261    /**
262     * Create a Stripe Checkout session for subscribing the authenticated
263     * user to the given addon. Returns the Checkout `sessionId` for the
264     * frontend to hand off to Stripe.js.
265     *
266     * @param  AddOns  $addOn  Route-model-bound AddOns record (the target plan).
267     *
268     * @response 200 {"result": {"status": "success", "data": {"sessionId": "cs_test_..."}}}
269     * @response 400 {"result": {"status": "error", "message": "Failed to create subscription: ..."}}
270     */
271    public function create(Request $request, AddOns $addOn): JsonResponse
272    {
273        $user = $request->user();
274        try {
275            $session = Session::create([
276                'payment_method_types' => ['card'],
277                'customer' => $user->stripe_id,
278                'line_items' => [[
279                    'price' => $addOn->stripe_price_id,
280                    'quantity' => 1,
281                ]],
282                'mode' => 'subscription',
283                'success_url' => config('app.role_play_url').'settings/subscription?session_id={CHECKOUT_SESSION_ID}',
284                'cancel_url' => config('app.role_play_url').'settings/subscription',
285            ]);
286
287            return response()->json([
288                'status' => 'success',
289                'data' => [
290                    'sessionId' => $session->id,
291                ],
292            ], 200);
293        } catch (ApiErrorException $e) {
294            return response()->json([
295                'status' => 'error',
296                'message' => 'Failed to create subscription: '.$e->getMessage(),
297            ], 400);
298        }
299    }
300
301    /**
302     * Cancel the Stripe subscription backing a user's RolePlay addon.
303     * The UserAddOns row is updated by the Stripe webhook handler; this
304     * endpoint just issues the cancel against the Stripe API.
305     *
306     * Authorization (ownership) is enforced by DestroyUserAddOnRequest.
307     *
308     * @param  UserAddOns  $userAddOn  Route-model-bound UserAddOns record.
309     *
310     * @response 200 {"result": {"status": "success", "message": "Subscription canceled successfully."}}
311     * @response 500 {"result": {"status": "error", "message": "Failed to cancel subscription: ..."}}
312     */
313    public function destroy(DestroyUserAddOnRequest $request, UserAddOns $userAddOn): JsonResponse
314    {
315        try {
316            $subscription = Subscription::retrieve($userAddOn->stripe_id);
317            $subscription->cancel();
318
319            return response()->json([
320                'status' => 'success',
321                'message' => 'Subscription canceled successfully.',
322            ], 200);
323        } catch (ApiErrorException $e) {
324            return response()->json([
325                'status' => 'error',
326                'message' => 'Failed to cancel subscription: '.$e->getMessage(),
327            ], 500);
328        }
329    }
330}