PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.21
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.21
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.3.21, at app/Services/Reminders/SubscriptionReminderService.php

655 lines 21.4 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 if (!$this->isEligible($subscription)) {
232 return 0;
233 }
234
235 $billingAt = $this->getBillingTimestamp($subscription);
236 if (!$billingAt) {
237 return 0;
238 }
239
240 if ($this->isTrialSubscription($subscription)) {
241 if ($this->isTrialEndRemindersEnabled()) {
242 return $this->queueTrialEndReminder($subscription);
243 }
244 return 0;
245 }
246
247 $billingCycle = $this->getBillingCycle($subscription);
248
249 if (!$this->isBillingCycleEnabled($billingCycle)) {
250 return 0;
251 }
252
253 $now = time();
254 $cycleKey = $this->getCycleKey($subscription, $billingAt);
255 $queued = 0;
256
257 $reminderDays = $this->getRenewalDays($subscription);
258
259 foreach ($reminderDays as $daysBefore) {
260 $target = $billingAt - ((int)$daysBefore * DAY_IN_SECONDS);
261 if ($now >= $target && $now < $billingAt) {
262 $stage = 'before_' . (int)$daysBefore;
263 if ($this->queueRenewalStage($subscription, $stage, $cycleKey)) {
264 $queued++;
265 }
266 }
267 }
268
269 return $queued;
270 }
271
272 protected function queueTrialEndReminder(Subscription $subscription): int
273 {
274 if (empty($subscription->next_billing_date)) {
275 return 0;
276 }
277
278 $trialEndAt = strtotime($subscription->next_billing_date . ' UTC');
279
280 if (!$trialEndAt || $trialEndAt <= time()) {
281 return 0;
282 }
283
284 $now = time();
285 $cycleKey = $this->getCycleKey($subscription, $trialEndAt);
286 $queued = 0;
287
288 $trialReminderDays = $this->getTrialEndReminderDays();
289
290 foreach ($trialReminderDays as $daysBefore) {
291 $target = $trialEndAt - ((int)$daysBefore * DAY_IN_SECONDS);
292 if ($now >= $target && $now < $trialEndAt) {
293 $stage = 'trial_end_' . (int)$daysBefore;
294 if ($this->queueTrialStage($subscription, $stage, $cycleKey)) {
295 $queued++;
296 }
297 }
298 }
299
300 return $queued;
301 }
302
303 protected function queueRenewalStage(Subscription $subscription, string $stage, string $cycleKey): bool
304 {
305 $state = $this->normalizeReminderState($this->getCachedRenewalMeta($subscription));
306
307 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
308 return false;
309 }
310
311 if ($this->isStageQueuedRecently($state, $cycleKey, $stage)) {
312 return false;
313 }
314
315 $args = [$subscription->id, $stage, $cycleKey];
316
317 if (function_exists('as_next_scheduled_action')) {
318 $existing = as_next_scheduled_action(static::RENEWAL_ASYNC_HOOK, $args, 'fluent-cart');
319 if ($existing) {
320 $state = $this->markStageQueued($state, $cycleKey, $stage);
321 $this->saveRenewalMeta($subscription, $state);
322 return false;
323 }
324 }
325
326 $state = $this->markStageQueued($state, $cycleKey, $stage);
327 $this->saveRenewalMeta($subscription, $state);
328
329 if (function_exists('as_enqueue_async_action')) {
330 $result = as_enqueue_async_action(static::RENEWAL_ASYNC_HOOK, $args, 'fluent-cart');
331 if ($result === 0) {
332 $state = $this->clearStageQueue($state, $cycleKey, $stage);
333 $this->saveRenewalMeta($subscription, $state);
334 return false;
335 }
336 return true;
337 }
338
339 return $this->sendRenewal($subscription->id, $stage, $cycleKey);
340 }
341
342 protected function queueTrialStage(Subscription $subscription, string $stage, string $cycleKey): bool
343 {
344 $state = $this->normalizeReminderState($this->getCachedTrialMeta($subscription));
345
346 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
347 return false;
348 }
349
350 if ($this->isStageQueuedRecently($state, $cycleKey, $stage)) {
351 return false;
352 }
353
354 $args = [$subscription->id, $stage, $cycleKey];
355
356 if (function_exists('as_next_scheduled_action')) {
357 $existing = as_next_scheduled_action(static::TRIAL_ASYNC_HOOK, $args, 'fluent-cart');
358 if ($existing) {
359 $state = $this->markStageQueued($state, $cycleKey, $stage);
360 $this->saveTrialMeta($subscription, $state);
361 return false;
362 }
363 }
364
365 $state = $this->markStageQueued($state, $cycleKey, $stage);
366 $this->saveTrialMeta($subscription, $state);
367
368 if (function_exists('as_enqueue_async_action')) {
369 $result = as_enqueue_async_action(static::TRIAL_ASYNC_HOOK, $args, 'fluent-cart');
370 if ($result === 0) {
371 $state = $this->clearStageQueue($state, $cycleKey, $stage);
372 $this->saveTrialMeta($subscription, $state);
373 return false;
374 }
375 return true;
376 }
377
378 return $this->sendTrial($subscription->id, $stage, $cycleKey);
379 }
380
381 /*
382 |--------------------------------------------------------------------------
383 | Meta Cache
384 |--------------------------------------------------------------------------
385 */
386
387 protected function preloadRenewalMeta(array $subscriptionIds): void
388 {
389 if (empty($subscriptionIds)) {
390 return;
391 }
392
393 $metas = SubscriptionMeta::query()
394 ->whereIn('subscription_id', $subscriptionIds)
395 ->where('meta_key', static::RENEWAL_META_KEY)
396 ->get();
397
398 foreach ($metas as $meta) {
399 $this->subscriptionMetaCache[$meta->subscription_id] = $meta->meta_value;
400 }
401
402 foreach ($subscriptionIds as $id) {
403 if (!array_key_exists($id, $this->subscriptionMetaCache)) {
404 $this->subscriptionMetaCache[$id] = [];
405 }
406 }
407 }
408
409 protected function preloadTrialMeta(array $subscriptionIds): void
410 {
411 if (empty($subscriptionIds)) {
412 return;
413 }
414
415 $metas = SubscriptionMeta::query()
416 ->whereIn('subscription_id', $subscriptionIds)
417 ->where('meta_key', static::TRIAL_META_KEY)
418 ->get();
419
420 foreach ($metas as $meta) {
421 $this->trialMetaCache[$meta->subscription_id] = $meta->meta_value;
422 }
423
424 foreach ($subscriptionIds as $id) {
425 if (!array_key_exists($id, $this->trialMetaCache)) {
426 $this->trialMetaCache[$id] = [];
427 }
428 }
429 }
430
431 protected function getCachedRenewalMeta(Subscription $subscription): array
432 {
433 if (array_key_exists($subscription->id, $this->subscriptionMetaCache)) {
434 $value = $this->subscriptionMetaCache[$subscription->id];
435 return is_array($value) ? $value : [];
436 }
437
438 return $subscription->getMeta(static::RENEWAL_META_KEY, []) ?: [];
439 }
440
441 protected function getCachedTrialMeta(Subscription $subscription): array
442 {
443 if (array_key_exists($subscription->id, $this->trialMetaCache)) {
444 $value = $this->trialMetaCache[$subscription->id];
445 return is_array($value) ? $value : [];
446 }
447
448 return $subscription->getMeta(static::TRIAL_META_KEY, []) ?: [];
449 }
450
451 protected function saveRenewalMeta(Subscription $subscription, array $state): void
452 {
453 $subscription->updateMeta(static::RENEWAL_META_KEY, $state);
454 $this->subscriptionMetaCache[$subscription->id] = $state;
455 }
456
457 protected function saveTrialMeta(Subscription $subscription, array $state): void
458 {
459 $subscription->updateMeta(static::TRIAL_META_KEY, $state);
460 $this->trialMetaCache[$subscription->id] = $state;
461 }
462
463 /*
464 |--------------------------------------------------------------------------
465 | Settings
466 |--------------------------------------------------------------------------
467 */
468
469 protected function getRenewalDays(Subscription $subscription): array
470 {
471 $billingCycle = $this->getBillingCycle($subscription);
472
473 switch ($billingCycle) {
474 case 'yearly':
475 return $this->getYearlyRenewalDays();
476 case 'monthly':
477 return $this->getMonthlyRenewalDays();
478 case 'quarterly':
479 return $this->getQuarterlyRenewalDays();
480 case 'half_yearly':
481 return $this->getHalfYearlyRenewalDays();
482 default:
483 return [];
484 }
485 }
486
487 protected function getBillingCycle(Subscription $subscription): string
488 {
489 $interval = $subscription->billing_interval ?? '';
490
491 $intervalMap = [
492 'daily' => 'daily',
493 'weekly' => 'weekly',
494 'monthly' => 'monthly',
495 'quarterly' => 'quarterly',
496 'half_yearly' => 'half_yearly',
497 'yearly' => 'yearly',
498 ];
499
500 $cycle = $intervalMap[strtolower($interval)] ?? 'unsupported';
501
502 return apply_filters('fluent_cart/reminders/billing_cycle', $cycle, $subscription);
503 }
504
505 protected function isBillingCycleEnabled(string $cycle): bool
506 {
507 switch ($cycle) {
508 case 'daily':
509 case 'weekly':
510 case 'unsupported':
511 return false;
512 case 'monthly':
513 return $this->storeSettings->get('monthly_renewal_reminders_enabled', 'no') === 'yes';
514 case 'quarterly':
515 return $this->storeSettings->get('quarterly_renewal_reminders_enabled', 'no') === 'yes';
516 case 'half_yearly':
517 return $this->storeSettings->get('half_yearly_renewal_reminders_enabled', 'no') === 'yes';
518 case 'yearly':
519 return $this->storeSettings->get('yearly_renewal_reminders_enabled', 'yes') === 'yes';
520 default:
521 return false;
522 }
523 }
524
525 protected function getYearlyRenewalDays(): array
526 {
527 $days = $this->parseDayList(
528 $this->storeSettings->get('yearly_renewal_reminder_days', '30'),
529 [30],
530 7,
531 90
532 );
533
534 return apply_filters('fluent_cart/reminders/yearly_before_days', $days);
535 }
536
537 protected function getMonthlyRenewalDays(): array
538 {
539 $days = $this->parseDayList(
540 $this->storeSettings->get('monthly_renewal_reminder_days', '7'),
541 [7],
542 3,
543 28
544 );
545
546 return apply_filters('fluent_cart/reminders/monthly_before_days', $days);
547 }
548
549 protected function getQuarterlyRenewalDays(): array
550 {
551 $days = $this->parseDayList(
552 $this->storeSettings->get('quarterly_renewal_reminder_days', '14'),
553 [14],
554 7,
555 60
556 );
557
558 return apply_filters('fluent_cart/reminders/quarterly_before_days', $days);
559 }
560
561 protected function getHalfYearlyRenewalDays(): array
562 {
563 $days = $this->parseDayList(
564 $this->storeSettings->get('half_yearly_renewal_reminder_days', '21'),
565 [21],
566 7,
567 60
568 );
569
570 return apply_filters('fluent_cart/reminders/half_yearly_before_days', $days);
571 }
572
573 protected function getTrialEndReminderDays(): array
574 {
575 $days = $this->parseDayList(
576 $this->storeSettings->get('trial_end_reminder_days', '3'),
577 [3],
578 1,
579 14
580 );
581
582 return apply_filters('fluent_cart/reminders/trial_end_days', $days);
583 }
584
585 protected function isTrialEndRemindersEnabled(): bool
586 {
587 return $this->storeSettings->get('trial_end_reminders_enabled', 'yes') === 'yes';
588 }
589
590 protected function getReminderStatuses(): array
591 {
592 return [
593 Status::SUBSCRIPTION_ACTIVE,
594 Status::SUBSCRIPTION_TRIALING,
595 ];
596 }
597
598 /*
599 |--------------------------------------------------------------------------
600 | Eligibility & Helpers
601 |--------------------------------------------------------------------------
602 */
603
604 protected function isEligible(Subscription $subscription): bool
605 {
606 if (!in_array($subscription->status, $this->getReminderStatuses(), true)) {
607 return false;
608 }
609
610 if (empty($subscription->next_billing_date)) {
611 return false;
612 }
613
614 // Skip if the subscription has been upgraded to a new plan (subscription or lifetime)
615 if (Arr::get($subscription->config, 'upgraded_to_order_id')) {
616 return false;
617 }
618
619 return true;
620 }
621
622 protected function isTrialSubscription(Subscription $subscription): bool
623 {
624 return $subscription->status === Status::SUBSCRIPTION_TRIALING
625 && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') !== 'yes';
626 }
627
628 protected function getBillingTimestamp(Subscription $subscription): int
629 {
630 if (!$subscription->next_billing_date) {
631 return 0;
632 }
633
634 $billingDate = (string)$subscription->next_billing_date;
635 if (!preg_match('/^\d{4}-\d{2}-\d{2}/', $billingDate)) {
636 return 0;
637 }
638
639 $timestamp = strtotime($billingDate . ' UTC');
640
641 return ($timestamp && $timestamp > 0) ? (int)$timestamp : 0;
642 }
643
644 protected function getCycleKey(Subscription $subscription, int $billingAt): string
645 {
646 return md5(implode('|', [
647 'subscription',
648 $subscription->id,
649 $billingAt,
650 (string)$subscription->status,
651 (int)$subscription->recurring_total,
652 ]));
653 }
654 }
655