PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.3
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 / Reminders / SubscriptionReminderService.php

SubscriptionReminderService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.3, at app/Services/Reminders/SubscriptionReminderService.php

661 lines 21.7 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\Reminders;
4
5 use FluentCart\App\Helpers\Status;
6 use FluentCart\App\Models\Subscription;
7 use FluentCart\App\Models\SubscriptionMeta;
8 use FluentCart\Framework\Support\Arr;
9
10 class SubscriptionReminderService extends ReminderService
11 {
12 const RENEWAL_META_KEY = 'renewal_reminder_state';
13 const TRIAL_META_KEY = 'trial_reminder_state';
14 const RENEWAL_ASYNC_HOOK = 'fluent_cart/reminders/send_subscription_renewal';
15 const TRIAL_ASYNC_HOOK = 'fluent_cart/reminders/send_trial_end';
16
17 protected array $subscriptionMetaCache = [];
18
19 protected array $trialMetaCache = [];
20
21 public function isEnabled(): bool
22 {
23 // Check if any billing cycle reminders are enabled
24 $hasRenewalReminders =
25 $this->storeSettings->get('yearly_renewal_reminders_enabled', 'yes') === 'yes' ||
26 $this->storeSettings->get('monthly_renewal_reminders_enabled', 'no') === 'yes' ||
27 $this->storeSettings->get('quarterly_renewal_reminders_enabled', 'no') === 'yes' ||
28 $this->storeSettings->get('half_yearly_renewal_reminders_enabled', 'no') === 'yes';
29
30 // Check if trial end reminders are enabled
31 $hasTrialReminders = $this->storeSettings->get('trial_end_reminders_enabled', 'yes') === 'yes';
32
33 return $hasRenewalReminders || $hasTrialReminders;
34 }
35
36 /*
37 |--------------------------------------------------------------------------
38 | Sending
39 |--------------------------------------------------------------------------
40 */
41
42 public function sendRenewal($subscriptionId, $stage, $cycleKey): bool
43 {
44 try {
45 $subscription = Subscription::query()
46 ->with(['customer', 'order'])
47 ->find($subscriptionId);
48
49 if (!$subscription || !$subscription->customer) {
50 return false;
51 }
52
53 $state = $this->normalizeReminderState($subscription->getMeta(static::RENEWAL_META_KEY, []));
54 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
55 return false;
56 }
57
58 if (!$subscription->next_billing_date) {
59 return false;
60 }
61
62 $billingAt = $this->getBillingTimestamp($subscription);
63 if (!$billingAt) {
64 $state = $this->clearStageQueue($state, $cycleKey, $stage);
65 $subscription->updateMeta(static::RENEWAL_META_KEY, $state);
66 return false;
67 }
68
69 $currentCycle = $this->getCycleKey($subscription, $billingAt);
70 if ($cycleKey !== $currentCycle || !$this->isEligible($subscription)) {
71 $state = $this->clearStageQueue($state, $cycleKey, $stage);
72 $subscription->updateMeta(static::RENEWAL_META_KEY, $state);
73 return false;
74 }
75
76 do_action('fluent_cart/subscription_renewal_reminder', [
77 'subscription' => $subscription,
78 'order' => $subscription->order,
79 'customer' => $subscription->customer,
80 'reminder' => [
81 'stage' => $stage,
82 'billing_cycle'=> $this->getBillingCycle($subscription),
83 'billing_date' => gmdate('Y-m-d H:i:s', $billingAt),
84 ]
85 ]);
86
87 $state = $this->markStageSent($state, $cycleKey, $stage);
88 $subscription->updateMeta(static::RENEWAL_META_KEY, $state);
89
90 return true;
91 } catch (\Throwable $e) {
92 fluent_cart_error_log(
93 'Renewal reminder send error',
94 sprintf('Subscription #%d, stage: %s — %s', $subscriptionId, $stage, $e->getMessage())
95 );
96 return false;
97 }
98 }
99
100 public function sendTrial($subscriptionId, $stage, $cycleKey): bool
101 {
102 try {
103 $subscription = Subscription::query()
104 ->with(['customer', 'order'])
105 ->find($subscriptionId);
106
107 if (!$subscription || !$subscription->customer) {
108 return false;
109 }
110
111 if (!$this->isTrialSubscription($subscription) || !$subscription->next_billing_date) {
112 return false;
113 }
114
115 $state = $this->normalizeReminderState($subscription->getMeta(static::TRIAL_META_KEY, []));
116 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
117 return false;
118 }
119
120 return $this->sendTrialEndReminder($subscription, $stage, $cycleKey, $state);
121 } catch (\Throwable $e) {
122 fluent_cart_error_log(
123 'Trial reminder send error',
124 sprintf('Subscription #%d, stage: %s — %s', $subscriptionId, $stage, $e->getMessage())
125 );
126 return false;
127 }
128 }
129
130 protected function sendTrialEndReminder(Subscription $subscription, string $stage, string $cycleKey, array $state): bool
131 {
132 if (empty($subscription->next_billing_date)) {
133 return false;
134 }
135
136 $trialEndAt = strtotime($subscription->next_billing_date . ' UTC');
137
138 if (!$trialEndAt) {
139 $state = $this->clearStageQueue($state, $cycleKey, $stage);
140 $subscription->updateMeta(static::TRIAL_META_KEY, $state);
141 return false;
142 }
143
144 $currentCycle = $this->getCycleKey($subscription, $trialEndAt);
145 if ($cycleKey !== $currentCycle) {
146 $state = $this->clearStageQueue($state, $cycleKey, $stage);
147 $subscription->updateMeta(static::TRIAL_META_KEY, $state);
148 return false;
149 }
150
151 do_action('fluent_cart/subscription_trial_end_reminder', [
152 'subscription' => $subscription,
153 'order' => $subscription->order,
154 'customer' => $subscription->customer,
155 'reminder' => [
156 'stage' => $stage,
157 'trial_end_date' => gmdate('Y-m-d H:i:s', $trialEndAt),
158 ]
159 ]);
160
161 $state = $this->markStageSent($state, $cycleKey, $stage);
162 $subscription->updateMeta(static::TRIAL_META_KEY, $state);
163
164 return true;
165 }
166
167 public function clearState(Subscription $subscription): void
168 {
169 $subscription->deleteMeta(static::RENEWAL_META_KEY);
170 $subscription->deleteMeta(static::TRIAL_META_KEY);
171 }
172
173 /*
174 |--------------------------------------------------------------------------
175 | Scanning & Queueing
176 |--------------------------------------------------------------------------
177 */
178
179 public function queueActions($startedAt, $maxRuntime): array
180 {
181 $renewalQueued = 0;
182 $trialQueued = 0;
183 $lastId = 0;
184 $batchSize = $this->getScanBatchSize();
185
186 while (!$this->isRuntimeExpired($startedAt, $maxRuntime)) {
187 $subscriptions = Subscription::query()
188 ->where('id', '>', $lastId)
189 ->whereNotNull('next_billing_date')
190 ->whereIn('status', $this->getReminderStatuses())
191 ->orderBy('id', 'ASC')
192 ->limit($batchSize)
193 ->get();
194
195 if ($subscriptions->isEmpty()) {
196 break;
197 }
198
199 $ids = $subscriptions->pluck('id')->toArray();
200 $this->preloadRenewalMeta($ids);
201 $this->preloadTrialMeta($ids);
202
203 foreach ($subscriptions as $subscription) {
204 $count = $this->queueForSubscription($subscription);
205 if ($count && $this->isTrialSubscription($subscription)) {
206 $trialQueued += $count;
207 } else {
208 $renewalQueued += $count;
209 }
210
211 if ($this->isRuntimeExpired($startedAt, $maxRuntime)) {
212 break;
213 }
214 }
215
216 $lastId = $subscriptions->last()->id;
217
218 if ($subscriptions->count() < $batchSize) {
219 break;
220 }
221 }
222
223 $this->subscriptionMetaCache = [];
224 $this->trialMetaCache = [];
225
226 return ['renewal' => $renewalQueued, 'trial' => $trialQueued];
227 }
228
229 protected function queueForSubscription(Subscription $subscription): int
230 {
231 // Store-billed (manual/system) subscriptions use the renewal order email +
232 // renewal reminders instead — queueing these too would double-remind
233 if ($subscription->usesRenewalEngine()) {
234 return 0;
235 }
236
237 if (!$this->isEligible($subscription)) {
238 return 0;
239 }
240
241 $billingAt = $this->getBillingTimestamp($subscription);
242 if (!$billingAt) {
243 return 0;
244 }
245
246 if ($this->isTrialSubscription($subscription)) {
247 if ($this->isTrialEndRemindersEnabled()) {
248 return $this->queueTrialEndReminder($subscription);
249 }
250 return 0;
251 }
252
253 $billingCycle = $this->getBillingCycle($subscription);
254
255 if (!$this->isBillingCycleEnabled($billingCycle)) {
256 return 0;
257 }
258
259 $now = time();
260 $cycleKey = $this->getCycleKey($subscription, $billingAt);
261 $queued = 0;
262
263 $reminderDays = $this->getRenewalDays($subscription);
264
265 foreach ($reminderDays as $daysBefore) {
266 $target = $billingAt - ((int)$daysBefore * DAY_IN_SECONDS);
267 if ($now >= $target && $now < $billingAt) {
268 $stage = 'before_' . (int)$daysBefore;
269 if ($this->queueRenewalStage($subscription, $stage, $cycleKey)) {
270 $queued++;
271 }
272 }
273 }
274
275 return $queued;
276 }
277
278 protected function queueTrialEndReminder(Subscription $subscription): int
279 {
280 if (empty($subscription->next_billing_date)) {
281 return 0;
282 }
283
284 $trialEndAt = strtotime($subscription->next_billing_date . ' UTC');
285
286 if (!$trialEndAt || $trialEndAt <= time()) {
287 return 0;
288 }
289
290 $now = time();
291 $cycleKey = $this->getCycleKey($subscription, $trialEndAt);
292 $queued = 0;
293
294 $trialReminderDays = $this->getTrialEndReminderDays();
295
296 foreach ($trialReminderDays as $daysBefore) {
297 $target = $trialEndAt - ((int)$daysBefore * DAY_IN_SECONDS);
298 if ($now >= $target && $now < $trialEndAt) {
299 $stage = 'trial_end_' . (int)$daysBefore;
300 if ($this->queueTrialStage($subscription, $stage, $cycleKey)) {
301 $queued++;
302 }
303 }
304 }
305
306 return $queued;
307 }
308
309 protected function queueRenewalStage(Subscription $subscription, string $stage, string $cycleKey): bool
310 {
311 $state = $this->normalizeReminderState($this->getCachedRenewalMeta($subscription));
312
313 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
314 return false;
315 }
316
317 if ($this->isStageQueuedRecently($state, $cycleKey, $stage)) {
318 return false;
319 }
320
321 $args = [$subscription->id, $stage, $cycleKey];
322
323 if (function_exists('as_next_scheduled_action')) {
324 $existing = as_next_scheduled_action(static::RENEWAL_ASYNC_HOOK, $args, 'fluent-cart');
325 if ($existing) {
326 $state = $this->markStageQueued($state, $cycleKey, $stage);
327 $this->saveRenewalMeta($subscription, $state);
328 return false;
329 }
330 }
331
332 $state = $this->markStageQueued($state, $cycleKey, $stage);
333 $this->saveRenewalMeta($subscription, $state);
334
335 if (function_exists('as_enqueue_async_action')) {
336 $result = as_enqueue_async_action(static::RENEWAL_ASYNC_HOOK, $args, 'fluent-cart');
337 if ($result === 0) {
338 $state = $this->clearStageQueue($state, $cycleKey, $stage);
339 $this->saveRenewalMeta($subscription, $state);
340 return false;
341 }
342 return true;
343 }
344
345 return $this->sendRenewal($subscription->id, $stage, $cycleKey);
346 }
347
348 protected function queueTrialStage(Subscription $subscription, string $stage, string $cycleKey): bool
349 {
350 $state = $this->normalizeReminderState($this->getCachedTrialMeta($subscription));
351
352 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
353 return false;
354 }
355
356 if ($this->isStageQueuedRecently($state, $cycleKey, $stage)) {
357 return false;
358 }
359
360 $args = [$subscription->id, $stage, $cycleKey];
361
362 if (function_exists('as_next_scheduled_action')) {
363 $existing = as_next_scheduled_action(static::TRIAL_ASYNC_HOOK, $args, 'fluent-cart');
364 if ($existing) {
365 $state = $this->markStageQueued($state, $cycleKey, $stage);
366 $this->saveTrialMeta($subscription, $state);
367 return false;
368 }
369 }
370
371 $state = $this->markStageQueued($state, $cycleKey, $stage);
372 $this->saveTrialMeta($subscription, $state);
373
374 if (function_exists('as_enqueue_async_action')) {
375 $result = as_enqueue_async_action(static::TRIAL_ASYNC_HOOK, $args, 'fluent-cart');
376 if ($result === 0) {
377 $state = $this->clearStageQueue($state, $cycleKey, $stage);
378 $this->saveTrialMeta($subscription, $state);
379 return false;
380 }
381 return true;
382 }
383
384 return $this->sendTrial($subscription->id, $stage, $cycleKey);
385 }
386
387 /*
388 |--------------------------------------------------------------------------
389 | Meta Cache
390 |--------------------------------------------------------------------------
391 */
392
393 protected function preloadRenewalMeta(array $subscriptionIds): void
394 {
395 if (empty($subscriptionIds)) {
396 return;
397 }
398
399 $metas = SubscriptionMeta::query()
400 ->whereIn('subscription_id', $subscriptionIds)
401 ->where('meta_key', static::RENEWAL_META_KEY)
402 ->get();
403
404 foreach ($metas as $meta) {
405 $this->subscriptionMetaCache[$meta->subscription_id] = $meta->meta_value;
406 }
407
408 foreach ($subscriptionIds as $id) {
409 if (!array_key_exists($id, $this->subscriptionMetaCache)) {
410 $this->subscriptionMetaCache[$id] = [];
411 }
412 }
413 }
414
415 protected function preloadTrialMeta(array $subscriptionIds): void
416 {
417 if (empty($subscriptionIds)) {
418 return;
419 }
420
421 $metas = SubscriptionMeta::query()
422 ->whereIn('subscription_id', $subscriptionIds)
423 ->where('meta_key', static::TRIAL_META_KEY)
424 ->get();
425
426 foreach ($metas as $meta) {
427 $this->trialMetaCache[$meta->subscription_id] = $meta->meta_value;
428 }
429
430 foreach ($subscriptionIds as $id) {
431 if (!array_key_exists($id, $this->trialMetaCache)) {
432 $this->trialMetaCache[$id] = [];
433 }
434 }
435 }
436
437 protected function getCachedRenewalMeta(Subscription $subscription): array
438 {
439 if (array_key_exists($subscription->id, $this->subscriptionMetaCache)) {
440 $value = $this->subscriptionMetaCache[$subscription->id];
441 return is_array($value) ? $value : [];
442 }
443
444 return $subscription->getMeta(static::RENEWAL_META_KEY, []) ?: [];
445 }
446
447 protected function getCachedTrialMeta(Subscription $subscription): array
448 {
449 if (array_key_exists($subscription->id, $this->trialMetaCache)) {
450 $value = $this->trialMetaCache[$subscription->id];
451 return is_array($value) ? $value : [];
452 }
453
454 return $subscription->getMeta(static::TRIAL_META_KEY, []) ?: [];
455 }
456
457 protected function saveRenewalMeta(Subscription $subscription, array $state): void
458 {
459 $subscription->updateMeta(static::RENEWAL_META_KEY, $state);
460 $this->subscriptionMetaCache[$subscription->id] = $state;
461 }
462
463 protected function saveTrialMeta(Subscription $subscription, array $state): void
464 {
465 $subscription->updateMeta(static::TRIAL_META_KEY, $state);
466 $this->trialMetaCache[$subscription->id] = $state;
467 }
468
469 /*
470 |--------------------------------------------------------------------------
471 | Settings
472 |--------------------------------------------------------------------------
473 */
474
475 protected function getRenewalDays(Subscription $subscription): array
476 {
477 $billingCycle = $this->getBillingCycle($subscription);
478
479 switch ($billingCycle) {
480 case 'yearly':
481 return $this->getYearlyRenewalDays();
482 case 'monthly':
483 return $this->getMonthlyRenewalDays();
484 case 'quarterly':
485 return $this->getQuarterlyRenewalDays();
486 case 'half_yearly':
487 return $this->getHalfYearlyRenewalDays();
488 default:
489 return [];
490 }
491 }
492
493 protected function getBillingCycle(Subscription $subscription): string
494 {
495 $interval = $subscription->billing_interval ?? '';
496
497 $intervalMap = [
498 'daily' => 'daily',
499 'weekly' => 'weekly',
500 'monthly' => 'monthly',
501 'quarterly' => 'quarterly',
502 'half_yearly' => 'half_yearly',
503 'yearly' => 'yearly',
504 ];
505
506 $cycle = $intervalMap[strtolower($interval)] ?? 'unsupported';
507
508 return apply_filters('fluent_cart/reminders/billing_cycle', $cycle, $subscription);
509 }
510
511 protected function isBillingCycleEnabled(string $cycle): bool
512 {
513 switch ($cycle) {
514 case 'daily':
515 case 'weekly':
516 case 'unsupported':
517 return false;
518 case 'monthly':
519 return $this->storeSettings->get('monthly_renewal_reminders_enabled', 'no') === 'yes';
520 case 'quarterly':
521 return $this->storeSettings->get('quarterly_renewal_reminders_enabled', 'no') === 'yes';
522 case 'half_yearly':
523 return $this->storeSettings->get('half_yearly_renewal_reminders_enabled', 'no') === 'yes';
524 case 'yearly':
525 return $this->storeSettings->get('yearly_renewal_reminders_enabled', 'yes') === 'yes';
526 default:
527 return false;
528 }
529 }
530
531 protected function getYearlyRenewalDays(): array
532 {
533 $days = $this->parseDayList(
534 $this->storeSettings->get('yearly_renewal_reminder_days', '30'),
535 [30],
536 7,
537 90
538 );
539
540 return apply_filters('fluent_cart/reminders/yearly_before_days', $days);
541 }
542
543 protected function getMonthlyRenewalDays(): array
544 {
545 $days = $this->parseDayList(
546 $this->storeSettings->get('monthly_renewal_reminder_days', '7'),
547 [7],
548 3,
549 28
550 );
551
552 return apply_filters('fluent_cart/reminders/monthly_before_days', $days);
553 }
554
555 protected function getQuarterlyRenewalDays(): array
556 {
557 $days = $this->parseDayList(
558 $this->storeSettings->get('quarterly_renewal_reminder_days', '14'),
559 [14],
560 7,
561 60
562 );
563
564 return apply_filters('fluent_cart/reminders/quarterly_before_days', $days);
565 }
566
567 protected function getHalfYearlyRenewalDays(): array
568 {
569 $days = $this->parseDayList(
570 $this->storeSettings->get('half_yearly_renewal_reminder_days', '21'),
571 [21],
572 7,
573 60
574 );
575
576 return apply_filters('fluent_cart/reminders/half_yearly_before_days', $days);
577 }
578
579 protected function getTrialEndReminderDays(): array
580 {
581 $days = $this->parseDayList(
582 $this->storeSettings->get('trial_end_reminder_days', '3'),
583 [3],
584 1,
585 14
586 );
587
588 return apply_filters('fluent_cart/reminders/trial_end_days', $days);
589 }
590
591 protected function isTrialEndRemindersEnabled(): bool
592 {
593 return $this->storeSettings->get('trial_end_reminders_enabled', 'yes') === 'yes';
594 }
595
596 protected function getReminderStatuses(): array
597 {
598 return [
599 Status::SUBSCRIPTION_ACTIVE,
600 Status::SUBSCRIPTION_TRIALING,
601 ];
602 }
603
604 /*
605 |--------------------------------------------------------------------------
606 | Eligibility & Helpers
607 |--------------------------------------------------------------------------
608 */
609
610 protected function isEligible(Subscription $subscription): bool
611 {
612 if (!in_array($subscription->status, $this->getReminderStatuses(), true)) {
613 return false;
614 }
615
616 if (empty($subscription->next_billing_date)) {
617 return false;
618 }
619
620 // Skip if the subscription has been upgraded to a new plan (subscription or lifetime)
621 if (Arr::get($subscription->config, 'upgraded_to_order_id')) {
622 return false;
623 }
624
625 return true;
626 }
627
628 protected function isTrialSubscription(Subscription $subscription): bool
629 {
630 return $subscription->status === Status::SUBSCRIPTION_TRIALING
631 && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') !== 'yes';
632 }
633
634 protected function getBillingTimestamp(Subscription $subscription): int
635 {
636 if (!$subscription->next_billing_date) {
637 return 0;
638 }
639
640 $billingDate = (string)$subscription->next_billing_date;
641 if (!preg_match('/^\d{4}-\d{2}-\d{2}/', $billingDate)) {
642 return 0;
643 }
644
645 $timestamp = strtotime($billingDate . ' UTC');
646
647 return ($timestamp && $timestamp > 0) ? (int)$timestamp : 0;
648 }
649
650 protected function getCycleKey(Subscription $subscription, int $billingAt): string
651 {
652 return md5(implode('|', [
653 'subscription',
654 $subscription->id,
655 $billingAt,
656 (string)$subscription->status,
657 (int)$subscription->recurring_total,
658 ]));
659 }
660 }
661