PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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 / RetentionSnapshotService.php

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

1,040 lines 38.0 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\Database\Migrations\RetentionSnapshotsMigrator;
8
9 class RetentionSnapshotService
10 {
11 /**
12 * Statuses to exclude from analysis (never really started)
13 */
14 protected array $excludedStatuses = [
15 Status::SUBSCRIPTION_PENDING,
16 Status::SUBSCRIPTION_INTENDED,
17 'incomplete_expired',
18 ];
19
20 /**
21 * Active statuses
22 */
23 protected array $activeStatuses = [
24 Status::SUBSCRIPTION_ACTIVE,
25 Status::SUBSCRIPTION_TRIALING,
26 ];
27
28 /**
29 * Generate retention snapshots
30 *
31 * @param int|null $productIdFilter Optional product ID to filter by
32 * @param callable|null $progressCallback Optional callback for progress updates: fn(string $message, string $level = 'info')
33 * @return array Result with 'success', 'message', and 'stats'
34 */
35 public function generate(?int $productIdFilter = null, ?callable $progressCallback = null): array
36 {
37 try {
38 $this->log('Starting retention snapshot generation...', 'info', $progressCallback);
39
40 // Step 1: Ensure table exists
41 $this->log('Step 1: Ensuring database table exists...', 'info', $progressCallback);
42 $this->ensureTableExists();
43 $this->log('Table ready.', 'success', $progressCallback);
44
45 // Step 2: Truncate existing data
46 $this->log('Step 2: Clearing existing data...', 'info', $progressCallback);
47 $this->truncateTable();
48 $this->log('Data cleared.', 'success', $progressCallback);
49
50 // Step 3: Get all customer-product pairs
51 $this->log('Step 3: Fetching customer-product pairs...', 'info', $progressCallback);
52 $pairs = $this->getCustomerProductPairs($productIdFilter);
53 $totalPairs = count($pairs);
54 $this->log("Found {$totalPairs} unique customer-product pairs.", 'success', $progressCallback);
55
56 if ($totalPairs === 0) {
57 $this->log('No data to process. Exiting.', 'warning', $progressCallback);
58 return [
59 'success' => false,
60 'message' => __('No data to process', 'fluent-cart'),
61 'stats' => [],
62 ];
63 }
64
65 // Step 4: Get date range
66 $this->log('Step 4: Calculating date range...', 'info', $progressCallback);
67 $dateRange = $this->getDateRange($productIdFilter);
68 $this->log(
69 "Date range: {$dateRange['first_month']} to {$dateRange['last_month']} ({$dateRange['total_months']} months)",
70 'success',
71 $progressCallback
72 );
73
74 // Step 5 & 6: Build timelines and aggregate snapshots
75 $this->log('Step 5 & 6: Building timelines and aggregating snapshots...', 'info', $progressCallback);
76 $inserted = $this->buildTimelinesAndAggregate($pairs, $dateRange, $progressCallback);
77 $this->log("Aggregation and insertion complete. Total records: {$inserted}", 'success', $progressCallback);
78
79 // Get stats
80 $stats = $this->getStats();
81
82 return [
83 'success' => true,
84 'message' => __('Retention snapshots generated successfully', 'fluent-cart'),
85 'stats' => $stats,
86 ];
87 } catch (\Exception $e) {
88 $this->log('Error: ' . $e->getMessage(), 'error', $progressCallback);
89 return [
90 'success' => false,
91 'message' => sprintf(
92 /* translators: %1$s: error message */
93 __('Error: %1$s', 'fluent-cart'),
94 $e->getMessage()
95 ),
96 'stats' => [],
97 ];
98 }
99 }
100
101 /**
102 * Log a message
103 */
104 protected function log(string $message, string $level = 'info', ?callable $callback = null): void
105 {
106 if ($callback) {
107 call_user_func($callback, $message, $level);
108 }
109 }
110
111 /**
112 * Ensure the retention snapshots table exists
113 */
114 protected function ensureTableExists(): void
115 {
116 RetentionSnapshotsMigrator::migrate();
117 }
118
119 /**
120 * Truncate the snapshots table
121 */
122 protected function truncateTable(): void
123 {
124 App::db()->table('fct_retention_snapshots')->truncate();
125 }
126
127 /**
128 * Get all unique customer-product pairs
129 */
130 protected function getCustomerProductPairs(?int $productIdFilter = null): array
131 {
132 $query = App::db()->query()
133 ->from('fct_subscriptions')
134 ->select(['customer_id', 'product_id'])
135 ->whereNotNull('customer_id')
136 ->whereNotNull('product_id')
137 ->where('customer_id', '>', 0)
138 ->where('product_id', '>', 0)
139 ->whereNotIn('status', $this->excludedStatuses)
140 ->groupBy(['customer_id', 'product_id']);
141
142 if ($productIdFilter) {
143 $query->where('product_id', $productIdFilter);
144 }
145
146 return $query->get()->toArray();
147 }
148
149 /**
150 * Get the date range for analysis
151 */
152 protected function getDateRange(?int $productIdFilter = null): array
153 {
154 $query = App::db()->query()
155 ->from('fct_subscriptions')
156 ->selectRaw('MIN(created_at) as first_date, MAX(created_at) as last_date')
157 ->whereNotIn('status', $this->excludedStatuses);
158
159 if ($productIdFilter) {
160 $query->where('product_id', $productIdFilter);
161 }
162
163 $result = $query->first();
164
165 $firstDate = new \DateTime($result->first_date);
166 $lastDate = new \DateTime(); // Use today as the end date
167
168 $firstMonth = $firstDate->format('Y-m');
169 $lastMonth = $lastDate->format('Y-m');
170
171 // Calculate total months
172 $interval = $firstDate->diff($lastDate);
173 $totalMonths = ($interval->y * 12) + $interval->m + 1;
174
175 // Generate all months
176 $months = [];
177 $current = new \DateTime($firstMonth . '-01');
178 $end = new \DateTime($lastMonth . '-01');
179
180 while ($current <= $end) {
181 $months[] = $current->format('Y-m');
182 $current->modify('+1 month');
183 }
184
185 return [
186 'first_month' => $firstMonth,
187 'last_month' => $lastMonth,
188 'total_months' => $totalMonths,
189 'months' => $months,
190 ];
191 }
192
193 /**
194 * Build customer timelines - for each customer-product pair, determine their state in each month
195 */
196 protected function buildCustomerTimelines(array $pairs, array $dateRange, ?callable $progressCallback = null): array
197 {
198 $timelines = [];
199
200 // OPTIMIZATION: Fetch ALL subscriptions in one query, then group in memory
201 $this->log('Fetching all subscriptions in one query...', 'info', $progressCallback);
202
203 $allSubscriptions = App::db()->query()
204 ->from('fct_subscriptions')
205 ->whereNotNull('customer_id')
206 ->whereNotNull('product_id')
207 ->where('customer_id', '>', 0)
208 ->where('product_id', '>', 0)
209 ->whereNotIn('status', $this->excludedStatuses)
210 ->orderBy('created_at', 'ASC')
211 ->get();
212
213 $this->log('Fetched ' . count($allSubscriptions) . ' subscriptions.', 'info', $progressCallback);
214
215 // Fetch last payment dates for all subscriptions in one query
216 $this->log('Fetching last payment dates...', 'info', $progressCallback);
217
218 $lastPayments = App::db()->query()
219 ->from('fct_order_transactions')
220 ->select(['subscription_id', App::db()->raw('MAX(created_at) as last_payment')])
221 ->whereNotNull('subscription_id')
222 ->where('status', 'succeeded')
223 ->groupBy('subscription_id')
224 ->get();
225
226 // Index last payments by subscription_id
227 $lastPaymentMap = [];
228 foreach ($lastPayments as $lp) {
229 $lastPaymentMap[$lp->subscription_id] = $lp->last_payment;
230 }
231 unset($lastPayments);
232
233 $this->log('Found last payment dates for ' . count($lastPaymentMap) . ' subscriptions.', 'info', $progressCallback);
234
235 // Attach last_payment to each subscription
236 foreach ($allSubscriptions as &$sub) {
237 $sub->last_payment = isset($lastPaymentMap[$sub->id]) ? $lastPaymentMap[$sub->id] : null;
238 }
239 unset($sub);
240 unset($lastPaymentMap);
241
242 $this->log('Grouping by customer-product pairs...', 'info', $progressCallback);
243
244 // Group subscriptions by customer_id + product_id in memory
245 $grouped = [];
246 foreach ($allSubscriptions as $sub) {
247 $key = "{$sub->customer_id}_{$sub->product_id}";
248 if (!isset($grouped[$key])) {
249 $grouped[$key] = [];
250 }
251 $grouped[$key][] = $sub;
252 }
253
254 // Free memory
255 unset($allSubscriptions);
256
257 $this->log('Grouped into ' . count($grouped) . ' customer-product pairs.', 'info', $progressCallback);
258 $this->log('Processing timelines...', 'info', $progressCallback);
259
260 foreach ($grouped as $key => $subscriptions) {
261 if (empty($subscriptions)) {
262 continue;
263 }
264
265 $firstSub = $subscriptions[0];
266 $customerId = $firstSub->customer_id;
267 $productId = $firstSub->product_id;
268
269 // Determine cohort (first subscription month)
270 $cohort = (new \DateTime($firstSub->created_at))->format('Y-m');
271
272 // Build monthly state
273 $monthlyState = [];
274 foreach ($dateRange['months'] as $month) {
275 $state = $this->getCustomerStateForMonth($subscriptions, $month);
276 $monthlyState[$month] = $state;
277 }
278
279 $timelines[$key] = [
280 'customer_id' => $customerId,
281 'product_id' => $productId,
282 'cohort' => $cohort,
283 'monthly' => $monthlyState,
284 ];
285 }
286
287 return $timelines;
288 }
289
290 /**
291 * Build timelines and aggregate - optimized to fetch all data once but insert in batches
292 */
293 protected function buildTimelinesAndAggregate(
294 array $pairs,
295 array $dateRange,
296 ?callable $progressCallback
297 ): int {
298 // Fetch ALL subscriptions in one query
299 $this->log('Fetching all subscriptions...', 'info', $progressCallback);
300
301 $allSubscriptions = App::db()->query()
302 ->from('fct_subscriptions')
303 ->whereNotNull('customer_id')
304 ->whereNotNull('product_id')
305 ->where('customer_id', '>', 0)
306 ->where('product_id', '>', 0)
307 ->whereNotIn('status', $this->excludedStatuses)
308 ->orderBy('created_at', 'ASC')
309 ->get();
310
311 $this->log('Fetched ' . count($allSubscriptions) . ' subscriptions.', 'info', $progressCallback);
312
313 // Fetch last payment dates
314 $this->log('Fetching last payment dates...', 'info', $progressCallback);
315
316 $lastPayments = App::db()->query()
317 ->from('fct_order_transactions')
318 ->select(['subscription_id', App::db()->raw('MAX(created_at) as last_payment')])
319 ->whereNotNull('subscription_id')
320 ->where('status', 'succeeded')
321 ->groupBy('subscription_id')
322 ->get();
323
324 // Index last payments by subscription_id
325 $lastPaymentMap = [];
326 foreach ($lastPayments as $lp) {
327 $lastPaymentMap[$lp->subscription_id] = $lp->last_payment;
328 }
329 unset($lastPayments);
330
331 // Attach last_payment to each subscription
332 foreach ($allSubscriptions as &$sub) {
333 $sub->last_payment = $lastPaymentMap[$sub->id] ?? null;
334 }
335 unset($sub, $lastPaymentMap);
336
337 $this->log('Grouping by customer-product pairs...', 'info', $progressCallback);
338
339 // Group subscriptions by customer_id + product_id
340 $grouped = [];
341 foreach ($allSubscriptions as $sub) {
342 $key = "{$sub->customer_id}_{$sub->product_id}";
343 if (!isset($grouped[$key])) {
344 $grouped[$key] = [];
345 }
346 $grouped[$key][] = $sub;
347 }
348 unset($allSubscriptions);
349
350 $this->log('Processing timelines and aggregating...', 'info', $progressCallback);
351
352 // Build aggregates directly without building full timelines array
353 $aggregates = [];
354 $processedCount = 0;
355 $totalCount = count($grouped);
356
357 foreach ($grouped as $key => $subscriptions) {
358 if (empty($subscriptions)) {
359 continue;
360 }
361
362 $firstSub = $subscriptions[0];
363 $customerId = $firstSub->customer_id;
364 $productId = $firstSub->product_id;
365 $cohort = (new \DateTime($firstSub->created_at))->format('Y-m');
366
367 // Get cohort baseline
368 $cohortState = $this->getCustomerStateForMonth($subscriptions, $cohort);
369
370 // Only include customers who were active at their cohort month
371 if (!$cohortState['is_active']) {
372 continue;
373 }
374
375 $cohortMrr = $cohortState['mrr'];
376 $cohortDate = new \DateTime($cohort . '-01');
377
378 // Track for each period from cohort onwards
379 foreach ($dateRange['months'] as $period) {
380 $periodDate = new \DateTime($period . '-01');
381
382 // Only track periods from cohort month onwards
383 if ($periodDate < $cohortDate) {
384 continue;
385 }
386
387 $periodState = $this->getCustomerStateForMonth($subscriptions, $period);
388
389 // Calculate period offset
390 $interval = $cohortDate->diff($periodDate);
391 $periodOffset = ($interval->y * 12) + $interval->m;
392
393 // Aggregate for specific product
394 $this->addToAggregate(
395 $aggregates,
396 $cohort,
397 $period,
398 $productId,
399 $periodOffset,
400 $cohortMrr,
401 $periodState
402 );
403
404 // Aggregate for all products
405 $this->addToAggregate(
406 $aggregates,
407 $cohort,
408 $period,
409 'all',
410 $periodOffset,
411 $cohortMrr,
412 $periodState
413 );
414 }
415
416 $processedCount++;
417 if ($processedCount % 1000 == 0) {
418 $this->log("Processed {$processedCount}/{$totalCount} customer-product pairs", 'info', $progressCallback);
419 }
420 }
421 unset($grouped);
422
423 // Insert aggregates in batches
424 $this->log('Inserting snapshots to database...', 'info', $progressCallback);
425 $totalInserted = $this->insertAggregates('fct_retention_snapshots', $aggregates, $progressCallback);
426
427 unset($aggregates);
428 if (function_exists('gc_collect_cycles')) {
429 gc_collect_cycles();
430 }
431
432 return $totalInserted;
433 }
434
435 /**
436 * Build timelines and aggregate in batches to minimize memory usage
437 *
438 * Instead of building ALL timelines in memory, we:
439 * 1. Fetch subscriptions for a batch of customer-product pairs
440 * 2. Build timelines for that batch
441 * 3. Aggregate and insert to DB
442 * 4. Clear memory and repeat
443 */
444 protected function buildAndAggregateInBatches(
445 array $pairs,
446 array $dateRange,
447 ?callable $progressCallback,
448 ?int $productIdFilter
449 ): int {
450 $table = 'fct_retention_snapshots';
451 $batchSize = 100; // Process 100 customer-product pairs at a time
452 $totalPairs = count($pairs);
453 $processedPairs = 0;
454
455 // Global aggregates accumulator (across all batches)
456 $globalAggregates = [];
457
458 // Fetch last payment dates for all subscriptions in one query (this is memory-efficient)
459 $this->log('Fetching last payment dates...', 'info', $progressCallback);
460 $lastPayments = App::db()->query()
461 ->from('fct_order_transactions')
462 ->select(['subscription_id', App::db()->raw('MAX(created_at) as last_payment')])
463 ->whereNotNull('subscription_id')
464 ->where('status', 'succeeded')
465 ->groupBy('subscription_id')
466 ->get();
467
468 $lastPaymentMap = [];
469 foreach ($lastPayments as $lp) {
470 $lastPaymentMap[$lp->subscription_id] = $lp->last_payment;
471 }
472 unset($lastPayments);
473
474 $this->log('Processing in batches of ' . $batchSize . ' customer-product pairs...', 'info', $progressCallback);
475
476 // Process pairs in batches
477 foreach (array_chunk($pairs, $batchSize) as $batchIndex => $batchPairs) {
478 $batchNum = $batchIndex + 1;
479 $this->log("Processing batch {$batchNum} ({$processedPairs}/{$totalPairs} pairs)...", 'info', $progressCallback);
480
481 // Get customer IDs and product IDs from this batch
482 $customerIds = array_unique(array_column($batchPairs, 'customer_id'));
483 $productIds = array_unique(array_column($batchPairs, 'product_id'));
484
485 // Fetch subscriptions for this batch only
486 $query = App::db()->query()
487 ->from('fct_subscriptions')
488 ->whereIn('customer_id', $customerIds)
489 ->whereIn('product_id', $productIds)
490 ->whereNotNull('customer_id')
491 ->whereNotNull('product_id')
492 ->where('customer_id', '>', 0)
493 ->where('product_id', '>', 0)
494 ->whereNotIn('status', $this->excludedStatuses)
495 ->orderBy('created_at', 'ASC');
496
497 $batchSubscriptions = $query->get();
498
499 // Attach last payment dates
500 foreach ($batchSubscriptions as &$sub) {
501 $sub->last_payment = $lastPaymentMap[$sub->id] ?? null;
502 }
503 unset($sub);
504
505 // Group by customer-product key
506 $grouped = [];
507 foreach ($batchSubscriptions as $sub) {
508 $key = "{$sub->customer_id}_{$sub->product_id}";
509 if (!isset($grouped[$key])) {
510 $grouped[$key] = [];
511 }
512 $grouped[$key][] = $sub;
513 }
514 unset($batchSubscriptions);
515
516 // Build timelines for this batch
517 $timelines = [];
518 foreach ($grouped as $key => $subscriptions) {
519 if (empty($subscriptions)) {
520 continue;
521 }
522
523 $firstSub = $subscriptions[0];
524 $customerId = $firstSub->customer_id;
525 $productId = $firstSub->product_id;
526 $cohort = (new \DateTime($firstSub->created_at))->format('Y-m');
527
528 // Build monthly state
529 $monthlyState = [];
530 foreach ($dateRange['months'] as $month) {
531 $state = $this->getCustomerStateForMonth($subscriptions, $month);
532 $monthlyState[$month] = $state;
533 }
534
535 $timelines[$key] = [
536 'customer_id' => $customerId,
537 'product_id' => $productId,
538 'cohort' => $cohort,
539 'monthly' => $monthlyState,
540 ];
541 }
542 unset($grouped);
543
544 // Aggregate into global aggregates (don't insert yet)
545 $this->accumulateAggregates($globalAggregates, $timelines, $dateRange);
546 $processedPairs += count($batchPairs);
547
548 $this->log("Batch {$batchNum} complete. Processed {$processedPairs}/{$totalPairs} pairs", 'info', $progressCallback);
549
550 // Clear memory
551 unset($timelines);
552 if (function_exists('gc_collect_cycles')) {
553 gc_collect_cycles();
554 }
555 }
556
557 // Now insert all aggregates in batches
558 $this->log('All batches processed. Inserting snapshots to database...', 'info', $progressCallback);
559 $totalInserted = $this->insertAggregates($table, $globalAggregates, $progressCallback);
560
561 unset($globalAggregates);
562 if (function_exists('gc_collect_cycles')) {
563 gc_collect_cycles();
564 }
565
566 return $totalInserted;
567 }
568
569 /**
570 * Accumulate timelines into global aggregates array
571 * This merges data from multiple batches without creating duplicates
572 */
573 protected function accumulateAggregates(array &$globalAggregates, array $timelines, array $dateRange): void
574 {
575 foreach ($timelines as $timeline) {
576 $cohort = $timeline['cohort'];
577 $productId = $timeline['product_id'];
578 $monthly = $timeline['monthly'];
579
580 // Get cohort baseline (state at cohort month)
581 $cohortState = $monthly[$cohort] ?? ['is_active' => false, 'mrr' => 0];
582
583 // Only include customers who were active at their cohort month
584 if (!$cohortState['is_active']) {
585 continue;
586 }
587
588 $cohortMrr = $cohortState['mrr'];
589
590 // Track for each period from cohort onwards
591 $cohortDate = new \DateTime($cohort . '-01');
592
593 foreach ($dateRange['months'] as $period) {
594 $periodDate = new \DateTime($period . '-01');
595
596 // Only track periods from cohort month onwards
597 if ($periodDate < $cohortDate) {
598 continue;
599 }
600
601 $periodState = $monthly[$period] ?? ['is_active' => false, 'mrr' => 0];
602
603 // Calculate period offset
604 $interval = $cohortDate->diff($periodDate);
605 $periodOffset = ($interval->y * 12) + $interval->m;
606
607 // Aggregate for specific product
608 $this->addToAggregate(
609 $globalAggregates,
610 $cohort,
611 $period,
612 $productId,
613 $periodOffset,
614 $cohortMrr,
615 $periodState
616 );
617
618 // Aggregate for all products (product_id = NULL)
619 $this->addToAggregate(
620 $globalAggregates,
621 $cohort,
622 $period,
623 'all', // Will be converted to NULL
624 $periodOffset,
625 $cohortMrr,
626 $periodState
627 );
628 }
629 }
630 }
631
632 /**
633 * Insert aggregates into database in batches
634 */
635 protected function insertAggregates(string $table, array $aggregates, ?callable $progressCallback): int
636 {
637 $snapshots = [];
638 $batchInsertThreshold = 10000;
639 $totalInserted = 0;
640
641 // Convert aggregates to final snapshot format and insert in batches
642 foreach ($aggregates as $cohort => $periods) {
643 foreach ($periods as $period => $products) {
644 foreach ($products as $productKey => $data) {
645 $productId = $productKey === 'all' ? null : $productKey;
646
647 $retentionRateCustomers = $data['cohort_customers'] > 0
648 ? round(($data['retained_customers'] / $data['cohort_customers']) * 100, 2)
649 : 0;
650
651 $retentionRateMrr = $data['cohort_mrr'] > 0
652 ? round(($data['retained_mrr'] / $data['cohort_mrr']) * 100, 2)
653 : 0;
654
655 $snapshots[] = [
656 'cohort' => $cohort,
657 'period' => $period,
658 'product_id' => $productId,
659 'period_offset' => $data['period_offset'],
660 'cohort_customers' => $data['cohort_customers'],
661 'cohort_mrr' => $data['cohort_mrr'],
662 'retained_customers' => $data['retained_customers'],
663 'retained_mrr' => $data['retained_mrr'],
664 'new_customers' => 0,
665 'churned_customers' => $data['cohort_customers'] - $data['retained_customers'],
666 'retention_rate_customers' => $retentionRateCustomers,
667 'retention_rate_mrr' => $retentionRateMrr,
668 'created_at' => current_time('mysql'),
669 'updated_at' => current_time('mysql'),
670 ];
671
672 // Batch insert when threshold is reached
673 if (count($snapshots) >= $batchInsertThreshold) {
674 $inserted = $this->insertBatch($table, $snapshots);
675 $totalInserted += $inserted;
676 $this->log("Inserted batch of {$inserted} records (total: {$totalInserted})", 'info', $progressCallback);
677
678 // Clear memory
679 $snapshots = [];
680 if (function_exists('gc_collect_cycles')) {
681 gc_collect_cycles();
682 }
683 }
684 }
685 }
686 }
687
688 // Insert remaining snapshots
689 if (!empty($snapshots)) {
690 $inserted = $this->insertBatch($table, $snapshots);
691 $totalInserted += $inserted;
692 $this->log("Inserted final batch of {$inserted} records", 'info', $progressCallback);
693 }
694
695 return $totalInserted;
696 }
697
698 /**
699 * Determine customer's state for a given month
700 */
701 protected function getCustomerStateForMonth($subscriptions, string $month): array
702 {
703 $monthStart = new \DateTime($month . '-01');
704 $monthEnd = (clone $monthStart)->modify('last day of this month')->setTime(23, 59, 59);
705
706 $isActive = false;
707 $mrr = 0;
708
709 foreach ($subscriptions as $sub) {
710 $createdAt = new \DateTime($sub->created_at);
711
712 // Skip subscriptions created after this month
713 if ($createdAt > $monthEnd) {
714 continue;
715 }
716
717 // Check if subscription was active during this month
718 $wasActiveThisMonth = $this->wasSubscriptionActiveInMonth($sub, $monthStart, $monthEnd);
719
720 if ($wasActiveThisMonth) {
721 $isActive = true;
722 // Use the subscription's recurring amount as MRR (normalized to monthly)
723 $mrr = max($mrr, $this->normalizeToMonthlyMrr($sub));
724 }
725 }
726
727 return [
728 'is_active' => $isActive,
729 'mrr' => $mrr,
730 ];
731 }
732
733 /**
734 * Check if a subscription was active during a given month
735 *
736 * A subscription is considered active in a month if:
737 * - It was created on or before the end of that month, AND
738 * - It had not ended before the start of that month
739 *
740 * For ended subscriptions, we use the END date (expire_at, or canceled_at + billing period)
741 * For active subscriptions, we check if they existed during that month
742 */
743 protected function wasSubscriptionActiveInMonth($sub, \DateTime $monthStart, \DateTime $monthEnd): bool
744 {
745 $createdAt = new \DateTime($sub->created_at);
746
747 // Subscription must have started by end of month
748 if ($createdAt > $monthEnd) {
749 return false;
750 }
751
752 // Determine the subscription's effective end date
753 $endDate = $this->getSubscriptionEndDate($sub);
754
755 // If no end date, subscription is still ongoing
756 // But we need to check if it was created by this month
757 if (!$endDate) {
758 return $createdAt <= $monthEnd;
759 }
760
761 // Subscription was active in this month if:
762 // - It started on or before the end of this month, AND
763 // - It ended on or after the start of this month
764 return $createdAt <= $monthEnd && $endDate >= $monthStart;
765 }
766
767 /**
768 * Get the effective end date of a subscription
769 * Returns null if subscription is still ongoing
770 *
771 * We use last_payment + billing_interval as the effective end date.
772 * This represents when the subscription actually stopped providing revenue.
773 */
774 protected function getSubscriptionEndDate($sub): ?\DateTime
775 {
776 // If subscription is currently active/trialing, it's still ongoing
777 if (in_array($sub->status, $this->activeStatuses)) {
778 return null;
779 }
780
781 // For non-active subscriptions, use last_payment + billing_interval as end date
782 // This is the most accurate indicator of when the customer stopped paying
783 if (!empty($sub->last_payment) && $sub->last_payment !== '0000-00-00 00:00:00') {
784 $lastPayment = new \DateTime($sub->last_payment);
785 $interval = $this->getBillingIntervalMonths($sub->billing_interval ?? 'yearly');
786 return (clone $lastPayment)->modify("+{$interval} months");
787 }
788
789 // Fallback: if no payment history, use created_at + billing_interval
790 // (they paid at creation)
791 $createdAt = new \DateTime($sub->created_at);
792 $interval = $this->getBillingIntervalMonths($sub->billing_interval ?? 'yearly');
793 return (clone $createdAt)->modify("+{$interval} months");
794 }
795
796 /**
797 * Get billing interval in months
798 */
799 protected function getBillingIntervalMonths(string $interval): int
800 {
801 switch ($interval) {
802 case 'yearly':
803 case 'annual':
804 return 12;
805 case 'half_yearly':
806 return 6;
807 case 'quarterly':
808 return 3;
809 case 'weekly':
810 return 1; // Approximate to 1 month
811 case 'daily':
812 return 1; // Approximate to 1 month
813 default:
814 return 1; // monthly
815 }
816 }
817
818 /**
819 * Normalize recurring amount to monthly MRR
820 */
821 protected function normalizeToMonthlyMrr($sub): int
822 {
823 $amount = (int) ($sub->recurring_amount ?? 0);
824 $interval = $sub->billing_interval ?? 'monthly';
825
826 switch ($interval) {
827 case 'yearly':
828 case 'annual':
829 return (int) round($amount / 12);
830 case 'half_yearly':
831 return (int) round($amount / 6);
832 case 'quarterly':
833 return (int) round($amount / 3);
834 case 'weekly':
835 return (int) round($amount * 4.33);
836 case 'daily':
837 return (int) round($amount * 30);
838 default:
839 return $amount; // monthly
840 }
841 }
842
843 /**
844 * Aggregate timelines into cohort snapshots
845 * Returns the number of snapshots inserted (not the snapshots themselves to save memory)
846 */
847 protected function aggregateSnapshots(array $timelines, array $dateRange, ?callable $progressCallback = null): int
848 {
849 $table = 'fct_retention_snapshots';
850
851 $snapshots = [];
852 $batchInsertThreshold = 10000; // Insert every 10k records to manage memory
853 $totalInserted = 0;
854
855 // Initialize snapshot structure
856 // We need: by cohort, by period, by product_id (and NULL for all)
857 $aggregates = [];
858
859 foreach ($timelines as $timeline) {
860 $cohort = $timeline['cohort'];
861 $productId = $timeline['product_id'];
862 $monthly = $timeline['monthly'];
863
864 // Get cohort baseline (state at cohort month)
865 $cohortState = $monthly[$cohort] ?? ['is_active' => false, 'mrr' => 0];
866
867 // Only include customers who were active at their cohort month
868 if (!$cohortState['is_active']) {
869 continue;
870 }
871
872 $cohortMrr = $cohortState['mrr'];
873
874 // Track for each period from cohort onwards
875 $periodOffset = 0;
876 $cohortDate = new \DateTime($cohort . '-01');
877
878 foreach ($dateRange['months'] as $period) {
879 $periodDate = new \DateTime($period . '-01');
880
881 // Only track periods from cohort month onwards
882 if ($periodDate < $cohortDate) {
883 continue;
884 }
885
886 $periodState = $monthly[$period] ?? ['is_active' => false, 'mrr' => 0];
887
888 // Calculate period offset
889 $interval = $cohortDate->diff($periodDate);
890 $periodOffset = ($interval->y * 12) + $interval->m;
891
892 // Aggregate for specific product
893 $this->addToAggregate(
894 $aggregates,
895 $cohort,
896 $period,
897 $productId,
898 $periodOffset,
899 $cohortMrr,
900 $periodState
901 );
902
903 // Aggregate for all products (product_id = NULL)
904 $this->addToAggregate(
905 $aggregates,
906 $cohort,
907 $period,
908 'all', // Will be converted to NULL
909 $periodOffset,
910 $cohortMrr,
911 $periodState
912 );
913 }
914 }
915
916 // Convert aggregates to final snapshot format and insert in batches
917 foreach ($aggregates as $cohort => $periods) {
918 foreach ($periods as $period => $products) {
919 foreach ($products as $productKey => $data) {
920 $productId = $productKey === 'all' ? null : $productKey;
921
922 $retentionRateCustomers = $data['cohort_customers'] > 0
923 ? round(($data['retained_customers'] / $data['cohort_customers']) * 100, 2)
924 : 0;
925
926 $retentionRateMrr = $data['cohort_mrr'] > 0
927 ? round(($data['retained_mrr'] / $data['cohort_mrr']) * 100, 2)
928 : 0;
929
930 $snapshots[] = [
931 'cohort' => $cohort,
932 'period' => $period,
933 'product_id' => $productId,
934 'period_offset' => $data['period_offset'],
935 'cohort_customers' => $data['cohort_customers'],
936 'cohort_mrr' => $data['cohort_mrr'],
937 'retained_customers' => $data['retained_customers'],
938 'retained_mrr' => $data['retained_mrr'],
939 'new_customers' => 0, // Can be calculated separately if needed
940 'churned_customers' => $data['cohort_customers'] - $data['retained_customers'],
941 'retention_rate_customers' => $retentionRateCustomers,
942 'retention_rate_mrr' => $retentionRateMrr,
943 'created_at' => current_time('mysql'),
944 'updated_at' => current_time('mysql'),
945 ];
946
947 // Batch insert when threshold is reached
948 if (count($snapshots) >= $batchInsertThreshold) {
949 $inserted = $this->insertBatch($table, $snapshots);
950 $totalInserted += $inserted;
951 $this->log("Inserted batch of {$inserted} records (total: {$totalInserted})", 'info', $progressCallback);
952
953 // Clear memory
954 $snapshots = [];
955 if (function_exists('gc_collect_cycles')) {
956 gc_collect_cycles();
957 }
958 }
959 }
960 }
961 }
962
963 // Insert remaining snapshots
964 if (!empty($snapshots)) {
965 $inserted = $this->insertBatch($table, $snapshots);
966 $totalInserted += $inserted;
967 $this->log("Inserted final batch of {$inserted} records", 'info', $progressCallback);
968 }
969
970 return $totalInserted;
971 }
972
973 /**
974 * Add data to aggregate array
975 */
976 protected function addToAggregate(
977 array &$aggregates,
978 string $cohort,
979 string $period,
980 $productId,
981 int $periodOffset,
982 int $cohortMrr,
983 array $periodState
984 ): void {
985 if (!isset($aggregates[$cohort][$period][$productId])) {
986 $aggregates[$cohort][$period][$productId] = [
987 'period_offset' => $periodOffset,
988 'cohort_customers' => 0,
989 'cohort_mrr' => 0,
990 'retained_customers' => 0,
991 'retained_mrr' => 0,
992 ];
993 }
994
995 // Always increment cohort baseline
996 $aggregates[$cohort][$period][$productId]['cohort_customers']++;
997 $aggregates[$cohort][$period][$productId]['cohort_mrr'] += $cohortMrr;
998
999 // Increment retained if active in this period
1000 if ($periodState['is_active']) {
1001 $aggregates[$cohort][$period][$productId]['retained_customers']++;
1002 $aggregates[$cohort][$period][$productId]['retained_mrr'] += $periodState['mrr'];
1003 }
1004 }
1005
1006 /**
1007 * Insert a batch of records
1008 */
1009 protected function insertBatch(string $table, array $batch): int
1010 {
1011 if (empty($batch)) {
1012 return 0;
1013 }
1014
1015 App::db()->table($table)->insert($batch);
1016
1017 return count($batch);
1018 }
1019
1020 /**
1021 * Get statistics from the generated snapshots
1022 */
1023 public function getStats(): array
1024 {
1025 $stats = App::db()->table('fct_retention_snapshots')
1026 ->selectRaw('COUNT(*) as total_records')
1027 ->selectRaw('COUNT(DISTINCT cohort) as unique_cohorts')
1028 ->selectRaw('COUNT(DISTINCT period) as unique_periods')
1029 ->selectRaw('COUNT(DISTINCT product_id) as unique_products')
1030 ->first();
1031
1032 return [
1033 'total_records' => (int) ($stats->total_records ?? 0),
1034 'unique_cohorts' => (int) ($stats->unique_cohorts ?? 0),
1035 'unique_periods' => (int) ($stats->unique_periods ?? 0),
1036 'unique_products' => (int) (($stats->unique_products ?? 0) + 1), // Include 'all'
1037 ];
1038 }
1039 }
1040