PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Services / Reminders / SubscriptionReminderService.php

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

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