Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.05% covered (success)
95.05%
192 / 202
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PlanAnalyticsRepository
95.05% covered (success)
95.05%
192 / 202
50.00% covered (danger)
50.00%
2 / 4
7
0.00% covered (danger)
0.00%
0 / 1
 usersByPlan
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
2
 usageByPlan
93.85% covered (success)
93.85%
122 / 130
0.00% covered (danger)
0.00%
0 / 1
2.00
 humanizeIdentifier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 bucketToUnixSeconds
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2
3namespace App\Http\Repositories\Admin\SystemDashboard;
4
5use App\Http\Models\FlyMsgUserDailyUsage;
6use App\Http\Models\Plans;
7use App\Http\Models\Subscription;
8use Carbon\Carbon;
9use Illuminate\Support\Facades\Log;
10use MongoDB\BSON\UTCDateTime;
11use Stripe\Subscription as StripeSubscription;
12
13class PlanAnalyticsRepository
14{
15    /**
16     * Canonical plan identifier list; drives the "always emit" rows so that
17     * lifetime/teams plans (which have no stripe_id) always appear, even at
18     * user_count = 0.
19     */
20    private const CANONICAL_IDENTIFIERS = [
21        Plans::FREEMIUM_IDENTIFIER,
22        Plans::STARTER_MONTHLY_IDENTIFIER,
23        Plans::GROWTH_MONTHLY_IDENTIFIER,
24        Plans::PROFESSIONAL_MONTHLY_IDENTIFIER,
25        Plans::STARTER_YEARLY_IDENTIFIER,
26        Plans::GROWTH_YEARLY_IDENTIFIER,
27        Plans::PROFESSIONAL_YEARLY_IDENTIFIER,
28        Plans::APPSUMO_IDENTIFIER,
29        Plans::DEALFUEL_IDENTIFIER,
30        Plans::ProPlanTeamsSMB,
31        Plans::ProPlanTeamsENT,
32    ];
33
34    /**
35     * Count distinct active users grouped by plan identifier.
36     *
37     * Drives the "always emit" list from CANONICAL_IDENTIFIERS so that plans
38     * without a stripe_id (lifetime, teams) always appear. Deduplicates by
39     * identifier when the DB has multiple documents per plan (e.g. separate
40     * rows for monthly vs yearly billing cycles with different stripe_ids).
41     *
42     * @return array<int, array{identifier: string, title: string, user_count: int}>
43     */
44    public function usersByPlan(): array
45    {
46        $now = new UTCDateTime(Carbon::now()->getTimestampMs());
47
48        $excludedStatuses = [
49            StripeSubscription::STATUS_INCOMPLETE,
50            StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
51            StripeSubscription::STATUS_UNPAID,
52            StripeSubscription::STATUS_CANCELED,
53        ];
54
55        $pipeline = [
56            [
57                '$match' => [
58                    'stripe_plan' => ['$ne' => null],
59                    'stripe_status' => ['$nin' => $excludedStatuses],
60                    '$or' => [
61                        ['ends_at' => null],
62                        ['ends_at' => ['$gt' => $now]],
63                    ],
64                ],
65            ],
66            [
67                '$lookup' => [
68                    'from' => 'plans',
69                    'localField' => 'stripe_plan',
70                    'foreignField' => 'stripe_id',
71                    'as' => 'plan',
72                ],
73            ],
74            ['$unwind' => ['path' => '$plan', 'preserveNullAndEmptyArrays' => false]],
75            [
76                '$group' => [
77                    '_id' => '$plan.identifier',
78                    'title' => ['$first' => '$plan.title'],
79                    'user_ids' => ['$addToSet' => '$user_id'],
80                ],
81            ],
82            [
83                '$project' => [
84                    '_id' => 0,
85                    'identifier' => '$_id',
86                    'title' => 1,
87                    'user_count' => ['$size' => '$user_ids'],
88                ],
89            ],
90            ['$sort' => ['user_count' => -1]],
91        ];
92
93        $results = collect(Subscription::raw(fn ($c) => $c->aggregate($pipeline)));
94
95        // Build a keyed map from aggregation results (identifier → row)
96        $byIdentifier = $results->keyBy('identifier');
97
98        // Fetch DB titles for all known identifiers — no stripe_id filter so that
99        // lifetime/teams plans (which lack a stripe_id) are included.
100        $dbRows = Plans::whereNotNull('identifier')
101            ->whereNull('pricing_version')
102            ->get(['identifier', 'title'])
103            ->unique('identifier')
104            ->keyBy('identifier');
105
106        $rows = [];
107        foreach (self::CANONICAL_IDENTIFIERS as $identifier) {
108            $dbTitle = $dbRows[$identifier]?->title ?? null;
109
110            $rows[] = [
111                'identifier' => $identifier,
112                'title' => $dbTitle ?? $this->humanizeIdentifier($identifier),
113                'user_count' => (int) ($byIdentifier[$identifier]['user_count'] ?? 0),
114            ];
115        }
116
117        usort($rows, fn ($a, $b) => $b['user_count'] <=> $a['user_count']);
118
119        return $rows;
120    }
121
122    /**
123     * Average usage metrics per plan identifier, bucketed by period.
124     *
125     * Plan resolution is done inside the Mongo pipeline via a correlated $lookup
126     * on subscriptions → plans. This replaces the prior approach of embedding the
127     * entire user→plan map as a literal BSON document inside $addFields/$getField,
128     * which approached the 16 MB pipeline limit at scale and caused aggregation
129     * failures on the Weekly tab.
130     *
131     * date field on fly_msg_user_daily_usage is a string "YYYY-MM-DD".
132     *
133     * @param  string  $period  daily|weekly|monthly
134     * @param  Carbon  $from
135     * @param  Carbon  $to
136     * @return array<int, array<string, mixed>>
137     *
138     * @throws \Throwable
139     */
140    public function usageByPlan(string $period, Carbon $from, Carbon $to): array
141    {
142        $dateFormat = match ($period) {
143            'weekly'  => '%Y-%V',
144            'monthly' => '%Y-%m',
145            default   => '%Y-%m-%d',
146        };
147
148        $now = new UTCDateTime(Carbon::now()->getTimestampMs());
149        $fromStr = $from->format('Y-m-d');
150        $toStr   = $to->format('Y-m-d');
151
152        $excludedStatuses = [
153            StripeSubscription::STATUS_INCOMPLETE,
154            StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
155            StripeSubscription::STATUS_UNPAID,
156            StripeSubscription::STATUS_CANCELED,
157        ];
158
159        try {
160            $pipeline = [
161                // 1. Date range filter
162                [
163                    '$match' => [
164                        'date' => ['$gte' => $fromStr, '$lte' => $toStr],
165                    ],
166                ],
167                // 2. Resolve plan identifier via correlated $lookup (avoids inline BSON map)
168                [
169                    '$lookup' => [
170                        'from'     => 'subscriptions',
171                        'let'      => ['uid' => '$user_id'],
172                        'pipeline' => [
173                            [
174                                '$match' => [
175                                    '$expr'         => ['$eq' => ['$user_id', '$$uid']],
176                                    'stripe_plan'   => ['$ne' => null],
177                                    'stripe_status' => ['$nin' => $excludedStatuses],
178                                    '$or'           => [
179                                        ['ends_at' => null],
180                                        ['ends_at' => ['$gt' => $now]],
181                                    ],
182                                ],
183                            ],
184                            [
185                                '$lookup' => [
186                                    'from'         => 'plans',
187                                    'localField'   => 'stripe_plan',
188                                    'foreignField' => 'stripe_id',
189                                    'as'           => 'plan',
190                                ],
191                            ],
192                            ['$unwind' => ['path' => '$plan', 'preserveNullAndEmptyArrays' => false]],
193                            ['$project' => ['_id' => 0, 'identifier' => '$plan.identifier']],
194                            ['$limit' => 1],
195                        ],
196                        'as' => 'subscription_plan',
197                    ],
198                ],
199                // 3. Flatten lookup result; preserve rows with no active subscription
200                ['$unwind' => ['path' => '$subscription_plan', 'preserveNullAndEmptyArrays' => true]],
201                // 4. Resolve plan_identifier, defaulting to 'unknown'
202                [
203                    '$addFields' => [
204                        'plan_identifier' => ['$ifNull' => ['$subscription_plan.identifier', 'unknown']],
205                    ],
206                ],
207                // 5. Per-user / per-bucket sum
208                [
209                    '$group' => [
210                        '_id' => [
211                            'bucket'          => ['$dateToString' => ['format' => $dateFormat, 'date' => ['$dateFromString' => ['dateString' => '$date']]]],
212                            'user_id'         => '$user_id',
213                            'plan_identifier' => '$plan_identifier',
214                        ],
215                        'grammar'   => ['$sum' => '$fly_grammar_actions'],
216                        'shortcut'  => ['$sum' => '$flycut_count'],
217                        'paragraph' => ['$sum' => '$paragraph_rewrite_count'],
218                        'linkedin'  => ['$sum' => '$flypost_count'],
219                        'flyengage' => ['$sum' => '$flyengage_count'],
220                    ],
221                ],
222                // 6. Per-plan / per-bucket average across users
223                [
224                    '$group' => [
225                        '_id' => [
226                            'bucket'          => '$_id.bucket',
227                            'plan_identifier' => '$_id.plan_identifier',
228                        ],
229                        'grammar_avg'       => ['$avg' => '$grammar'],
230                        'shortcut_avg'      => ['$avg' => '$shortcut'],
231                        'paragraph_avg'     => ['$avg' => '$paragraph'],
232                        'linkedin_avg'      => ['$avg' => '$linkedin'],
233                        'flyengage_avg'     => ['$avg' => '$flyengage'],
234                        'active_user_count' => ['$sum' => 1],
235                    ],
236                ],
237                [
238                    '$project' => [
239                        '_id'               => 0,
240                        'bucket'            => '$_id.bucket',
241                        'identifier'        => '$_id.plan_identifier',
242                        'grammar_avg'       => ['$round' => ['$grammar_avg', 2]],
243                        'shortcut_avg'      => ['$round' => ['$shortcut_avg', 2]],
244                        'paragraph_avg'     => ['$round' => ['$paragraph_avg', 2]],
245                        'linkedin_avg'      => ['$round' => ['$linkedin_avg', 2]],
246                        'flyengage_avg'     => ['$round' => ['$flyengage_avg', 2]],
247                        'active_user_count' => 1,
248                    ],
249                ],
250                ['$sort' => ['bucket' => 1, 'identifier' => 1]],
251            ];
252
253            $results = collect(FlyMsgUserDailyUsage::raw(fn ($c) => $c->aggregate($pipeline)));
254        } catch (\Throwable $e) {
255            Log::error('[SystemDashboard] usage_by_plan aggregation failed', [
256                'period'    => $period,
257                'from'      => $fromStr,
258                'to'        => $toStr,
259                'exception' => $e->getMessage(),
260            ]);
261            throw $e;
262        }
263
264        // Resolve plan title and convert bucket to unix seconds
265        $planTitles = Plans::whereNotNull('identifier')
266            ->get(['identifier', 'title'])
267            ->unique('identifier')
268            ->pluck('title', 'identifier')
269            ->toArray();
270
271        return $results->map(function ($row) use ($planTitles, $period) {
272            $bucket       = $row['bucket'] ?? '';
273            $periodBucket = $this->bucketToUnixSeconds($bucket, $period);
274
275            return [
276                'period_bucket'     => $periodBucket,
277                'identifier'        => $row['identifier'],
278                'title'             => $planTitles[$row['identifier']] ?? $row['identifier'],
279                'grammar_avg'       => (float) ($row['grammar_avg'] ?? 0),
280                'shortcut_avg'      => (float) ($row['shortcut_avg'] ?? 0),
281                'paragraph_avg'     => (float) ($row['paragraph_avg'] ?? 0),
282                'linkedin_avg'      => (float) ($row['linkedin_avg'] ?? 0),
283                'flyengage_avg'     => (float) ($row['flyengage_avg'] ?? 0),
284                'active_user_count' => (int) ($row['active_user_count'] ?? 0),
285                'plan_resolution'   => 'current',
286            ];
287        })->values()->toArray();
288    }
289
290    /**
291     * Humanize a plan identifier as a fallback title when no DB row exists.
292     *
293     * E.g. "pro-plan-teams-smb" → "Pro Plan Teams Smb"
294     */
295    private function humanizeIdentifier(string $identifier): string
296    {
297        return ucwords(str_replace(['-', '_'], ' ', $identifier));
298    }
299
300    /**
301     * Convert an aggregation bucket string to unix seconds (start of period).
302     */
303    private function bucketToUnixSeconds(string $bucket, string $period): int
304    {
305        try {
306            return match ($period) {
307                'monthly' => Carbon::createFromFormat('Y-m', $bucket)->startOfMonth()->timestamp,
308                'weekly'  => Carbon::now()->setISODate(
309                    (int) substr($bucket, 0, 4),
310                    (int) substr($bucket, 5)
311                )->startOfWeek()->timestamp,
312                default   => Carbon::createFromFormat('Y-m-d', $bucket)->startOfDay()->timestamp,
313            };
314        } catch (\Exception) {
315            return 0;
316        }
317    }
318}