PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.27
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.27
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / Report / SubscriptionReportService.php

SubscriptionReportService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.27, at app/Services/Report/SubscriptionReportService.php

747 lines 30.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Report;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Services\Report\Concerns\Subscription\FutureRenewals;
8
9 class SubscriptionReportService extends ReportService
10 {
11 use FutureRenewals;
12
13 public function getRetentionChart($params = [])
14 {
15 $baseQuery = App::db()->query()
16 ->from('fct_subscriptions as s')
17 ->whereBetween('s.created_at', [$params['startDate'], $params['endDate']])
18 ->when($params['variationIds'], fn ($q) => $q->whereIn('s.variation_id', $params['variationIds']));
19
20 if ($params['customDays']) {
21 $query = $baseQuery->selectRaw("COUNT(*) AS day_{$params['customDays']}")
22 ->whereRaw('
23 DATEDIFF(
24 COALESCE(s.canceled_at, NOW()),
25 s.created_at
26 ) <= ?
27 ', $params['customDays']);
28 } else {
29 $baseQuery->selectRaw('DATEDIFF(COALESCE(s.canceled_at, NOW()), s.created_at) AS lifespan');
30
31 $query = App::db()->query()->selectRaw('
32 SUM(CASE WHEN lifespan <= 7 THEN 1 ELSE 0 END) AS day_7,
33 SUM(CASE WHEN lifespan BETWEEN 8 AND 15 THEN 1 ELSE 0 END) AS day_15,
34 SUM(CASE WHEN lifespan BETWEEN 16 AND 30 THEN 1 ELSE 0 END) AS day_30,
35 SUM(CASE WHEN lifespan BETWEEN 31 AND 90 THEN 1 ELSE 0 END) AS day_90,
36 SUM(CASE WHEN lifespan BETWEEN 91 AND 180 THEN 1 ELSE 0 END) AS day_180,
37 SUM(CASE WHEN lifespan BETWEEN 181 AND 365 THEN 1 ELSE 0 END) AS day_365,
38 SUM(CASE WHEN lifespan > 365 THEN 1 ELSE 0 END) AS more_than_year
39 ')->fromSub($baseQuery, 'retention_data');
40 }
41
42 return $query->first();
43 }
44
45 public function getDailySignups($params = [])
46 {
47 return App::db()->query()
48 ->selectRaw('
49 DATE(s.created_at) AS trend_date,
50 COUNT(s.id) AS value
51 ')
52 ->from('fct_subscriptions as s')
53 ->whereBetween('s.created_at', [$params['startDate'], $params['endDate']])
54 ->when($params['variationIds'], fn ($q) => $q->whereIn('s.variation_id', $params['variationIds']))
55 ->groupBy('trend_date')
56 ->orderBy('trend_date')
57 ->get();
58 }
59
60 public function getChartData(array $params)
61 {
62 $startDate = $params['startDate'];
63 $endDate = $params['endDate'];
64 $subscriptionType = $params['subscriptionType'];
65
66 $group = ReportHelper::processGroup($startDate, $endDate, $params['groupKey']);
67
68 $query = App::db()->query();
69
70 if (in_array($subscriptionType, [Status::ORDER_TYPE_SUBSCRIPTION, Status::ORDER_TYPE_RENEWAL])) {
71 $query->from('fct_orders as o')
72 ->where('o.type', $subscriptionType)
73 ->whereIn('o.status', Status::getOrderSuccessStatuses());
74
75 $query = $this->applyFilters($query, $params);
76 } else {
77 $dateColumn = 'o.expire_at';
78
79 if ($subscriptionType === Status::SUBSCRIPTION_CANCELED) {
80 $dateColumn = 'o.canceled_at';
81 }
82
83 $query->from('fct_subscriptions as o')->where('o.status', $subscriptionType)
84 ->whereBetween($dateColumn, [
85 $startDate->format('Y-m-d H:i:s'),
86 $endDate->format('Y-m-d H:i:s'),
87 ])
88 ->when($params['variationIds'], fn ($q) => $q->whereIn('o.variation_id', $params['variationIds']));
89 }
90
91 $query->selectRaw("{$group['field']}, COUNT(o.id) as count")->groupByRaw($group['by']);
92
93 $results = $query->get();
94
95 $keys = ['count'];
96 $grouped = $this->getPeriodRange($startDate, $endDate, $group['key'], $keys);
97 $totalSubscriptions = 0;
98
99 foreach ($results as $row) {
100 $grouped[$row->group] = [
101 'year' => (int) $row->year,
102 'group' => $row->group,
103 'count' => (int) $row->count,
104 ];
105
106 $totalSubscriptions += (int) $row->count;
107 }
108
109 return [
110 'grouped' => array_values($grouped),
111 'totalSubscriptions' => $totalSubscriptions,
112 ];
113 }
114
115 public function getRetentionData($params)
116 {
117 $startDate = $params['startDate'];
118 $endDate = $params['endDate'];
119
120 // We want to iterate month by month
121 $period = new \DatePeriod(
122 $startDate,
123 new \DateInterval('P1M'),
124 $endDate
125 );
126
127 $data = [];
128
129 foreach ($period as $dt) {
130 $monthStart = $dt->format('Y-m-01 00:00:00');
131 $monthEnd = $dt->format('Y-m-t 23:59:59');
132 // For labels/keys, we use the end of the month as per requirement
133 $labelDate = $dt->format('Y-m-t');
134
135 $stats = App::db()->table('fct_subscriptions')
136 ->selectRaw("
137 SUM(
138 CASE
139 WHEN created_at BETWEEN ? AND ? THEN 1
140 ELSE 0
141 END
142 ) as new_subscriptions,
143 SUM(
144 CASE
145 WHEN created_at BETWEEN ? AND ? THEN
146 CASE
147 WHEN billing_interval = 'monthly' THEN recurring_amount
148 WHEN billing_interval = 'yearly' THEN recurring_amount / 12
149 WHEN billing_interval = 'weekly' THEN (recurring_amount * 52) / 12
150 WHEN billing_interval = 'daily' THEN recurring_amount * 30
151 ELSE 0
152 END
153 ELSE 0
154 END
155 ) / 100 as new_subscriptions_mrr,
156 SUM(
157 CASE
158 WHEN created_at BETWEEN ? AND ? THEN recurring_amount
159 ELSE 0
160 END
161 ) / 100 as period_gross,
162 SUM(
163 CASE
164 WHEN canceled_at BETWEEN ? AND ? THEN 1
165 WHEN canceled_at IS NULL AND expire_at BETWEEN ? AND ? THEN 1
166 ELSE 0
167 END
168 ) as churned_subscriptions,
169 SUM(
170 CASE
171 WHEN (canceled_at BETWEEN ? AND ?) OR (canceled_at IS NULL AND expire_at BETWEEN ? AND ?) THEN
172 CASE
173 WHEN billing_interval = 'monthly' THEN recurring_amount
174 WHEN billing_interval = 'yearly' THEN recurring_amount / 12
175 WHEN billing_interval = 'weekly' THEN (recurring_amount * 52) / 12
176 WHEN billing_interval = 'daily' THEN recurring_amount * 30
177 ELSE 0
178 END
179 ELSE 0
180 END
181 ) / 100 as churned_subscriptions_mrr,
182 SUM(
183 CASE
184 WHEN created_at <= ?
185 AND (canceled_at IS NULL OR canceled_at > ?)
186 AND (expire_at IS NULL OR expire_at > ?)
187 THEN 1
188 ELSE 0
189 END
190 ) as active_subscriptions,
191 SUM(
192 CASE
193 WHEN created_at <= ?
194 AND (canceled_at IS NULL OR canceled_at > ?)
195 AND (expire_at IS NULL OR expire_at > ?)
196 AND recurring_amount > 0
197 THEN 1
198 ELSE 0
199 END
200 ) as active_paid_subscriptions,
201 SUM(
202 CASE
203 WHEN created_at <= ?
204 AND (canceled_at IS NULL OR canceled_at > ?)
205 AND (expire_at IS NULL OR expire_at > ?)
206 AND recurring_amount = 0
207 THEN 1
208 ELSE 0
209 END
210 ) as active_free_subscriptions,
211 SUM(
212 CASE
213 WHEN created_at <= ?
214 AND (canceled_at IS NULL OR canceled_at > ?)
215 AND (expire_at IS NULL OR expire_at > ?)
216 THEN
217 CASE
218 WHEN billing_interval = 'monthly' THEN recurring_amount
219 WHEN billing_interval = 'yearly' THEN recurring_amount / 12
220 WHEN billing_interval = 'weekly' THEN (recurring_amount * 52) / 12
221 WHEN billing_interval = 'daily' THEN recurring_amount * 30
222 ELSE 0
223 END
224 ELSE 0
225 END
226 ) / 100 as mrr,
227 SUM(
228 CASE
229 WHEN created_at <= ?
230 AND (canceled_at IS NULL OR canceled_at > ?)
231 AND (expire_at IS NULL OR expire_at > ?)
232 THEN 1
233 ELSE 0
234 END
235 ) as start_subscriptions,
236 SUM(
237 CASE
238 WHEN created_at <= ?
239 AND (canceled_at IS NULL OR canceled_at > ?)
240 AND (expire_at IS NULL OR expire_at > ?)
241 THEN
242 CASE
243 WHEN billing_interval = 'monthly' THEN recurring_amount
244 WHEN billing_interval = 'yearly' THEN recurring_amount / 12
245 WHEN billing_interval = 'weekly' THEN (recurring_amount * 52) / 12
246 WHEN billing_interval = 'daily' THEN recurring_amount * 30
247 ELSE 0
248 END
249 ELSE 0
250 END
251 ) / 100 as start_mrr
252 ", [
253 $monthStart, $monthEnd, // new_subscriptions
254 $monthStart, $monthEnd, // new_subscriptions_mrr
255 $monthStart, $monthEnd, // period_gross
256 $monthStart, $monthEnd, $monthStart, $monthEnd, // churned_subscriptions (canceled OR expired)
257 $monthStart, $monthEnd, $monthStart, $monthEnd, // churned_subscriptions_mrr (canceled OR expired)
258 $monthEnd, $monthEnd, $monthEnd, // active_subscriptions
259 $monthEnd, $monthEnd, $monthEnd, // active_paid_subscriptions
260 $monthEnd, $monthEnd, $monthEnd, // active_free_subscriptions
261 $monthEnd, $monthEnd, $monthEnd, // mrr
262 $monthStart, $monthStart, $monthStart, // start_subscriptions
263 $monthStart, $monthStart, $monthStart // start_mrr
264 ])
265 ->where('created_at', '<=', $monthEnd) // Optimization: Don't scan future rows
266 // ->whereIn('status', Status::getValidableSubscriptionStatuses())
267 ->whereNotIn('status', [Status::SUBSCRIPTION_PENDING, Status::SUBSCRIPTION_INTENDED])
268 ->when(!empty($params['variationIds']), function ($q) use ($params) {
269 $q->whereIn('variation_id', $params['variationIds']);
270 })
271 ->first();
272
273 $newSubscriptions = (int) $stats->new_subscriptions;
274 $newSubscriptionsMrr = round($stats->new_subscriptions_mrr, 2);
275 $activeSubscriptions = (int) $stats->active_subscriptions;
276 $startSubscriptions = (int) $stats->start_subscriptions;
277 $mrr = round($stats->mrr, 2);
278 $startMrr = round($stats->start_mrr, 2);
279
280 // Retention Rate = ((End Count - New Count) / Start Count) * 100
281 $retentionRate = 0;
282 if ($startSubscriptions > 0) {
283 $retainedCount = $activeSubscriptions - $newSubscriptions;
284 $retainedCount = max(0, $retainedCount);
285 $retentionRate = ($retainedCount / $startSubscriptions) * 100;
286 }
287
288 // MRR Retention Rate = ((End MRR - New MRR) / Start MRR) * 100
289 $retentionRateMoney = 0;
290 if ($startMrr > 0) {
291 $retainedMrr = $mrr - $newSubscriptionsMrr;
292 $retainedMrr = max(0, $retainedMrr);
293 $retentionRateMoney = ($retainedMrr / $startMrr) * 100;
294 }
295
296 $data[] = [
297 'day' => $labelDate,
298 'week' => date('Y-W', strtotime($labelDate)),
299 'group' => $dt->format('Y-m'),
300 'year' => $dt->format('Y'),
301 'new_subscriptions' => $newSubscriptions,
302 'new_subscriptions_mrr' => $newSubscriptionsMrr,
303 'churned_subscriptions' => (int) $stats->churned_subscriptions,
304 'churned_subscriptions_mrr' => round($stats->churned_subscriptions_mrr, 2),
305 'active_subscriptions' => (string) $activeSubscriptions,
306 'active_paid_subscriptions' => (string) $stats->active_paid_subscriptions,
307 'active_free_subscriptions' => (string) $stats->active_free_subscriptions,
308 'mrr' => number_format($mrr, 2, '.', ''),
309 'retention_rate' => round($retentionRate, 2),
310 'retention_rate_money' => round($retentionRateMoney, 2),
311 'period_gross' => round($stats->period_gross, 2),
312 'period_subscriptions' => $newSubscriptions,
313 ];
314 }
315
316 return $data;
317 }
318
319 /**
320 * Get cohort retention data from pre-calculated snapshots table
321 *
322 * Supports both monthly and yearly g * - Monthly: Uses period_offset 1, 2, 3... (each month)
323 * - Yearly: Aggregates cohorts by year and uses period_offset 12, 24, 36... (each year)
324 *
325 * @param array $params
326 * @return array
327 */
328 public function getCohortData($params = [])
329 {
330 $startDate = $params['startDate'] ?? null;
331 $endDate = $params['endDate'] ?? null;
332 $productIds = $params['productIds'] ?? []; // Array of product IDs (converted from variation_ids)
333 $groupBy = $params['groupBy'] ?? 'month'; // month or year
334 $metric = $params['metric'] ?? 'mrr';
335 $maxPeriods = $params['maxPeriods'] ?? 12;
336
337 if (!$startDate || !$endDate) {
338 return [];
339 }
340
341 // Convert dates to YYYY-MM format for cohort filtering
342 $startCohort = $startDate->format('Y-m');
343 $endCohort = $endDate->format('Y-m');
344
345 if ($groupBy === 'year') {
346 return $this->getCohortDataByYear($startCohort, $endCohort, $productIds, $metric, $maxPeriods);
347 }
348
349 return $this->getCohortDataByMonth($startCohort, $endCohort, $productIds, $metric, $maxPeriods);
350 }
351
352 /**
353 * Get cohort data grouped by month
354 *
355 * @param string $startCohort
356 * @param string $endCohort
357 * @param array $productIds Array of product IDs to filter by (empty = all products combined)
358 * @param string $metric
359 * @param int $maxPeriods
360 * @return array
361 */
362 protected function getCohortDataByMonth($startCohort, $endCohort, $productIds, $metric, $maxPeriods)
363 {
364 $query = App::db()->table('fct_retention_snapshots')
365 ->whereBetween('cohort', [$startCohort, $endCohort])
366 ->where('period_offset', '<=', $maxPeriods)
367 ->orderBy('cohort', 'ASC')
368 ->orderBy('period_offset', 'ASC');
369
370 if (!empty($productIds)) {
371 // Filter by specific product IDs and aggregate
372 $query->selectRaw("
373 cohort,
374 period_offset,
375 SUM(cohort_customers) as cohort_customers,
376 SUM(cohort_mrr) as cohort_mrr,
377 SUM(retained_customers) as retained_customers,
378 SUM(retained_mrr) as retained_mrr,
379 SUM(churned_customers) as churned_customers
380 ")
381 ->whereIn('product_id', $productIds)
382 ->groupBy('cohort', 'period_offset');
383 } else {
384 // Use pre-aggregated "all products" data (product_id IS NULL)
385 $query->select([
386 'cohort',
387 'period_offset',
388 'cohort_customers',
389 'cohort_mrr',
390 'retained_customers',
391 'retained_mrr',
392 'churned_customers',
393 'retention_rate_customers',
394 'retention_rate_mrr',
395 ])
396 ->whereNull('product_id');
397 }
398
399 $snapshots = $query->get();
400
401 if ($snapshots->isEmpty()) {
402 return $this->emptyResponse('month', $metric, $maxPeriods);
403 }
404
405 // If we aggregated by product_ids, we need to recalculate retention rates
406 if (!empty($productIds)) {
407 $snapshots = $snapshots->map(function ($snap) {
408 $snap->retention_rate_customers = $snap->cohort_customers > 0
409 ? round(($snap->retained_customers / $snap->cohort_customers) * 100, 2)
410 : 0;
411 $snap->retention_rate_mrr = $snap->cohort_mrr > 0
412 ? round(($snap->retained_mrr / $snap->cohort_mrr) * 100, 2)
413 : 0;
414 return $snap;
415 });
416 }
417
418 return $this->buildCohortResponse($snapshots, 'month', $metric, $maxPeriods);
419 }
420
421 /**
422 * Get cohort data grouped by year
423 *
424 * For yearly view, we use period_offset = 12 as the baseline (end of year 1, before first renewal)
425 * and show period_offset 24, 36, 48... as Year 1, Year 2, Year 3 (after 1st, 2nd, 3rd renewal)
426 *
427 * This is because at period_offset = 12, yearly subscribers haven't had a renewal opportunity yet,
428 * so they show ~100% retention. The actual churn happens after the first renewal at offset 12+.
429 *
430 * @param string $startCohort
431 * @param string $endCohort
432 * @param array $productIds Array of product IDs to filter by (empty = all products combined)
433 * @param string $metric
434 * @param int $maxPeriods
435 * @return array
436 */
437 protected function getCohortDataByYear($startCohort, $endCohort, $productIds, $metric, $maxPeriods)
438 {
439 // We need offsets 12, 24, 36... where 12 is baseline, 24 is Year 1, etc.
440 // So max offset = (maxPeriods + 1) * 12 to include enough data
441 $maxMonthOffset = ($maxPeriods + 1) * 12;
442
443 $query = App::db()->table('fct_retention_snapshots')
444 ->whereBetween('cohort', [$startCohort, $endCohort])
445 ->where('period_offset', '<=', $maxMonthOffset)
446 ->whereRaw('period_offset % 12 = 0')
447 ->where('period_offset', '>=', 12) // Start from 12 (end of year 1)
448 ->orderBy('cohort', 'ASC')
449 ->orderBy('period_offset', 'ASC');
450
451 if (!empty($productIds)) {
452 // Filter by specific product IDs
453 $query->select([
454 'cohort',
455 'period_offset',
456 'cohort_customers',
457 'cohort_mrr',
458 'retained_customers',
459 'retained_mrr',
460 'churned_customers',
461 ])
462 ->whereIn('product_id', $productIds);
463 } else {
464 // Use pre-aggregated "all products" data
465 $query->select([
466 'cohort',
467 'period_offset',
468 'cohort_customers',
469 'cohort_mrr',
470 'retained_customers',
471 'retained_mrr',
472 'churned_customers',
473 'retention_rate_customers',
474 'retention_rate_mrr',
475 ])
476 ->whereNull('product_id');
477 }
478
479 $snapshots = $query->get();
480
481 if ($snapshots->isEmpty()) {
482 return $this->emptyResponse('year', $metric, $maxPeriods);
483 }
484
485 // Group by year, then by period_offset
486 // period_offset 12 -> yearOffset 0 (baseline)
487 // period_offset 24 -> yearOffset 1 (Year 1 - after 1st renewal)
488 // period_offset 36 -> yearOffset 2 (Year 2 - after 2nd renewal)
489 $yearlyData = [];
490
491 foreach ($snapshots as $snapshot) {
492 $cohortYear = substr($snapshot->cohort, 0, 4);
493 // Shift: 12->0, 24->1, 36->2, etc.
494 $yearOffset = ((int) $snapshot->period_offset / 12) - 1;
495
496 if (!isset($yearlyData[$cohortYear])) {
497 $yearlyData[$cohortYear] = [];
498 }
499
500 if (!isset($yearlyData[$cohortYear][$yearOffset])) {
501 $yearlyData[$cohortYear][$yearOffset] = [
502 'cohort_customers' => 0,
503 'cohort_mrr' => 0,
504 'retained_customers' => 0,
505 'retained_mrr' => 0,
506 'churned_customers' => 0,
507 ];
508 }
509
510 // Aggregate: sum up all monthly cohorts for this year at this offset
511 $yearlyData[$cohortYear][$yearOffset]['cohort_customers'] += (int) $snapshot->cohort_customers;
512 $yearlyData[$cohortYear][$yearOffset]['cohort_mrr'] += (int) $snapshot->cohort_mrr;
513 $yearlyData[$cohortYear][$yearOffset]['retained_customers'] += (int) $snapshot->retained_customers;
514 $yearlyData[$cohortYear][$yearOffset]['retained_mrr'] += (int) $snapshot->retained_mrr;
515 $yearlyData[$cohortYear][$yearOffset]['churned_customers'] += (int) $snapshot->churned_customers;
516 }
517
518 // Convert to cohortGroups format expected by buildCohortResponseFromGroups
519 $cohortGroups = [];
520
521 foreach ($yearlyData as $cohortYear => $offsets) {
522 $cohortGroups[$cohortYear] = [];
523
524 foreach ($offsets as $yearOffset => $data) {
525 // Calculate retention rates from aggregated data
526 $retentionRateCustomers = $data['cohort_customers'] > 0
527 ? round(($data['retained_customers'] / $data['cohort_customers']) * 100, 2)
528 : 0;
529 $retentionRateMrr = $data['cohort_mrr'] > 0
530 ? round(($data['retained_mrr'] / $data['cohort_mrr']) * 100, 2)
531 : 0;
532
533 $cohortGroups[$cohortYear][] = (object) [
534 'cohort' => $cohortYear,
535 'period_offset' => $yearOffset,
536 'cohort_customers' => $data['cohort_customers'],
537 'cohort_mrr' => $data['cohort_mrr'],
538 'retained_customers' => $data['retained_customers'],
539 'retained_mrr' => $data['retained_mrr'],
540 'churned_customers' => $data['churned_customers'],
541 'retention_rate_customers' => $retentionRateCustomers,
542 'retention_rate_mrr' => $retentionRateMrr,
543 ];
544 }
545 }
546
547 return $this->buildCohortResponseFromGroups($cohortGroups, 'year', $metric, $maxPeriods);
548 }
549
550 /**
551 * Build cohort response from raw snapshots
552 */
553 protected function buildCohortResponse($snapshots, $groupBy, $metric, $maxPeriods)
554 {
555 $cohortGroups = [];
556 foreach ($snapshots as $snapshot) {
557 $cohort = $snapshot->cohort;
558 if (!isset($cohortGroups[$cohort])) {
559 $cohortGroups[$cohort] = [];
560 }
561 $cohortGroups[$cohort][] = $snapshot;
562 }
563
564 return $this->buildCohortResponseFromGroups($cohortGroups, $groupBy, $metric, $maxPeriods);
565 }
566
567 /**
568 * Build cohort response from grouped data
569 */
570 protected function buildCohortResponseFromGroups($cohortGroups, $groupBy, $metric, $maxPeriods)
571 {
572 $cohortData = [];
573 $weightedData = [];
574
575 foreach ($cohortGroups as $cohortPeriod => $snapshots) {
576 // Get baseline from offset 0
577 $baselineSnapshot = null;
578 foreach ($snapshots as $snap) {
579 if ((int) $snap->period_offset === 0) {
580 $baselineSnapshot = $snap;
581 break;
582 }
583 }
584
585 if (!$baselineSnapshot) {
586 $baselineSnapshot = $snapshots[0];
587 }
588
589 $cohortStartMrr = round((float) $baselineSnapshot->cohort_mrr / 100, 2);
590 $cohortStartCount = (int) $baselineSnapshot->cohort_customers;
591
592 $periodData = [];
593 $snapshotsByOffset = [];
594
595 foreach ($snapshots as $snap) {
596 $snapshotsByOffset[(int) $snap->period_offset] = $snap;
597 }
598
599 $prevRetainedMrr = $cohortStartMrr;
600 $prevRetainedCount = $cohortStartCount;
601
602 for ($offset = 1; $offset <= $maxPeriods; $offset++) {
603 if (!isset($snapshotsByOffset[$offset])) {
604 $periodData[] = [
605 'offset' => $offset,
606 'retained_mrr' => null,
607 'retained_count' => null,
608 'churned_mrr' => null,
609 'churned_count' => null,
610 'retention_rate_mrr' => null,
611 'retention_rate_count' => null,
612 'retention_rate_previous_mrr' => null,
613 'retention_rate_previous_count' => null,
614 'churn_rate_total_mrr' => null,
615 'churn_rate_total_count' => null,
616 ];
617 continue;
618 }
619
620 $snap = $snapshotsByOffset[$offset];
621
622 $retainedMrr = round((float) $snap->retained_mrr / 100, 2);
623 $retainedCount = (int) $snap->retained_customers;
624 $churnedCount = (int) $snap->churned_customers;
625 $churnedMrr = round($cohortStartMrr - $retainedMrr, 2);
626
627 $retentionRateMrr = (float) $snap->retention_rate_mrr;
628 $retentionRateCount = (float) $snap->retention_rate_customers;
629
630 $retentionRatePrevMrr = $prevRetainedMrr > 0
631 ? round(($retainedMrr / $prevRetainedMrr) * 100, 2)
632 : 0;
633 $retentionRatePrevCount = $prevRetainedCount > 0
634 ? round(($retainedCount / $prevRetainedCount) * 100, 2)
635 : 0;
636
637 $churnRateTotalMrr = round(100 - $retentionRateMrr, 2);
638 $churnRateTotalCount = round(100 - $retentionRateCount, 2);
639
640 $periodData[] = [
641 'offset' => $offset,
642 'retained_mrr' => $retainedMrr,
643 'retained_count' => $retainedCount,
644 'churned_mrr' => max(0, $churnedMrr),
645 'churned_count' => $churnedCount,
646 'retention_rate_mrr' => $retentionRateMrr,
647 'retention_rate_count' => $retentionRateCount,
648 'retention_rate_previous_mrr' => $retentionRatePrevMrr,
649 'retention_rate_previous_count' => $retentionRatePrevCount,
650 'churn_rate_total_mrr' => $churnRateTotalMrr,
651 'churn_rate_total_count' => $churnRateTotalCount,
652 ];
653
654 if (!isset($weightedData[$offset])) {
655 $weightedData[$offset] = [
656 'total_start_mrr' => 0,
657 'total_start_count' => 0,
658 'total_retained_mrr' => 0,
659 'total_retained_count' => 0,
660 'total_prev_retained_mrr' => 0,
661 'total_prev_retained_count' => 0,
662 ];
663 }
664 $weightedData[$offset]['total_start_mrr'] += $cohortStartMrr;
665 $weightedData[$offset]['total_start_count'] += $cohortStartCount;
666 $weightedData[$offset]['total_retained_mrr'] += $retainedMrr;
667 $weightedData[$offset]['total_retained_count'] += $retainedCount;
668 $weightedData[$offset]['total_prev_retained_mrr'] += $prevRetainedMrr;
669 $weightedData[$offset]['total_prev_retained_count'] += $prevRetainedCount;
670
671 $prevRetainedMrr = $retainedMrr;
672 $prevRetainedCount = $retainedCount;
673 }
674
675 $cohortData[] = [
676 'cohort' => $cohortPeriod,
677 'start_mrr' => $cohortStartMrr,
678 'start_count' => $cohortStartCount,
679 'periods' => $periodData,
680 ];
681 }
682
683 $weightedAverages = $this->calculateWeightedAverages($weightedData);
684
685 return [
686 'cohorts' => $cohortData,
687 'weighted_averages' => $weightedAverages,
688 'group_by' => $groupBy,
689 'metric' => $metric,
690 'max_periods' => $maxPeriods,
691 ];
692 }
693
694 /**
695 * Calculate weighted averages from accumulated data
696 */
697 protected function calculateWeightedAverages($weightedData)
698 {
699 $weightedAverages = [];
700
701 foreach ($weightedData as $offset => $data) {
702 $avgMrr = $data['total_start_mrr'] > 0
703 ? ($data['total_retained_mrr'] / $data['total_start_mrr']) * 100
704 : 0;
705 $avgCount = $data['total_start_count'] > 0
706 ? ($data['total_retained_count'] / $data['total_start_count']) * 100
707 : 0;
708
709 $avgPrevMrr = $data['total_prev_retained_mrr'] > 0
710 ? ($data['total_retained_mrr'] / $data['total_prev_retained_mrr']) * 100
711 : 0;
712 $avgPrevCount = $data['total_prev_retained_count'] > 0
713 ? ($data['total_retained_count'] / $data['total_prev_retained_count']) * 100
714 : 0;
715
716 $avgChurnMrr = 100 - $avgMrr;
717 $avgChurnCount = 100 - $avgCount;
718
719 $weightedAverages[] = [
720 'offset' => $offset,
721 'weighted_avg_mrr' => round($avgMrr, 2),
722 'weighted_avg_count' => round($avgCount, 2),
723 'weighted_avg_prev_mrr' => round($avgPrevMrr, 2),
724 'weighted_avg_prev_count' => round($avgPrevCount, 2),
725 'weighted_avg_churn_mrr' => round($avgChurnMrr, 2),
726 'weighted_avg_churn_count' => round($avgChurnCount, 2),
727 ];
728 }
729
730 return $weightedAverages;
731 }
732
733 /**
734 * Return empty response structure
735 */
736 protected function emptyResponse($groupBy, $metric, $maxPeriods)
737 {
738 return [
739 'cohorts' => [],
740 'weighted_averages' => [],
741 'group_by' => $groupBy,
742 'metric' => $metric,
743 'max_periods' => $maxPeriods,
744 ];
745 }
746 }
747