PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.26
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.26
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 trunk All 48 releases
fluent-cart / app / Services / Report / RetentionSnapshotService.php

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

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