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 / ReminderService.php

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

517 lines 18.6 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\Api\StoreSettings;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\Subscription;
9 use FluentCart\App\Services\Email\EmailNotifications;
10 use FluentCart\App\Services\Payments\PaymentHelper;
11 use FluentCart\Framework\Support\Arr;
12
13 class ReminderService
14 {
15 const DEFAULT_SCAN_BATCH_SIZE = 100;
16 const MIN_SCAN_BATCH_SIZE = 10;
17 const MAX_SCAN_BATCH_SIZE = 500;
18
19 protected StoreSettings $storeSettings;
20
21 public function __construct()
22 {
23 $this->storeSettings = new StoreSettings();
24 }
25
26 public function runHourlyScan(): array
27 {
28 $stats = [
29 'renewal_queued' => 0,
30 'trial_queued' => 0,
31 'processed_at_gmt' => gmdate('Y-m-d H:i:s')
32 ];
33
34 if (!$this->isRemindersEnabled()) {
35 return $stats;
36 }
37
38 try {
39 $startedAt = time();
40 $maxRuntime = 20;
41
42 $subscriptionService = new SubscriptionReminderService();
43
44 if ($subscriptionService->isEnabled()) {
45 $result = $subscriptionService->queueActions($startedAt, $maxRuntime);
46 $stats['renewal_queued'] = $result['renewal'];
47 $stats['trial_queued'] = $result['trial'];
48 }
49
50 $renewalReminderService = new RenewalReminderService();
51
52 if ($renewalReminderService->isEnabled()) {
53 $stats['renewal_order_queued'] = $renewalReminderService->queueActions($startedAt, $maxRuntime);
54 }
55 } catch (\Throwable $e) {
56 fluent_cart_error_log('Reminder hourly scan error', $e->getMessage());
57 }
58
59 return $stats;
60 }
61
62 protected function isRemindersEnabled(): bool
63 {
64 return $this->storeSettings->get('reminders_enabled', 'no') === 'yes';
65 }
66
67 /**
68 * Get reminder permissions for a subscription (used by detail API).
69 */
70 public function getSubscriptionReminderPermissions(Subscription $subscription): array
71 {
72 $canSendRenewal = false;
73 $canSendTrialEnd = false;
74
75 if (!$this->isRemindersEnabled()) {
76 return compact('canSendRenewal', 'canSendTrialEnd');
77 }
78
79 $status = $subscription->status;
80 $isSimulatedTrial = $status === Status::SUBSCRIPTION_TRIALING
81 && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') === 'yes';
82
83 // Active subscriptions and simulated trials are eligible for renewal reminders
84 if (($status === Status::SUBSCRIPTION_ACTIVE || $isSimulatedTrial)
85 && $subscription->next_billing_date
86 && $this->isNotificationEnabled('subscription_renewal_reminder')
87 ) {
88 $canSendRenewal = true;
89 }
90
91 // Only real trials (not simulated) are eligible for trial end reminders
92 if ($status === Status::SUBSCRIPTION_TRIALING
93 && !$isSimulatedTrial
94 && $subscription->next_billing_date
95 && $this->isNotificationEnabled('subscription_trial_end_reminder')
96 ) {
97 $canSendTrialEnd = true;
98 }
99
100 return compact('canSendRenewal', 'canSendTrialEnd');
101 }
102
103 /**
104 * Check if a payment reminder can be sent for an order (used by detail API).
105 */
106 public function canSendPaymentReminder(array $orderData): bool
107 {
108 if (!in_array(Arr::get($orderData, 'payment_status'), RenewalReminderService::getReminderPaymentStatuses(), true)) {
109 return false;
110 }
111
112 return max((int)Arr::get($orderData, 'total_amount', 0) - (int)Arr::get($orderData, 'total_paid', 0), 0) > 0;
113 }
114
115 /**
116 * Check if at least one notification for the given event is active.
117 */
118 protected function isNotificationEnabled(string $event): bool
119 {
120 $notifications = EmailNotifications::getNotifications();
121 foreach ($notifications as $notification) {
122 if ($notification['event'] === $event && Arr::get($notification, 'settings.active') === 'yes') {
123 return true;
124 }
125 }
126 return false;
127 }
128
129 /**
130 * Send a manual reminder for a specific order or subscription.
131 *
132 * @param string $event The reminder event hook (renewal_reminder_overdue, subscription_renewal_reminder, subscription_trial_end_reminder)
133 * @param int $entityId The order ID or subscription ID
134 * @return array{success: bool, message: string}
135 */
136 public function sendManualReminder(string $event, int $entityId): array
137 {
138 if (!$this->isRemindersEnabled()) {
139 return [
140 'success' => false,
141 'message' => __('Reminders are disabled for this store.', 'fluent-cart'),
142 ];
143 }
144
145 // renewal_reminder_overdue has no active/inactive toggle of its own
146 // (manage_toggle: 'no', on-demand) — only the scheduled renewal/trial
147 // reminders have a real per-event switch worth checking.
148 if ($event !== 'renewal_reminder_overdue' && !$this->isNotificationEnabled($event)) {
149 return [
150 'success' => false,
151 'message' => __('This reminder notification is currently disabled.', 'fluent-cart'),
152 ];
153 }
154
155 try {
156 switch ($event) {
157 case 'renewal_reminder_overdue':
158 return $this->sendRenewalOrderReminder($entityId);
159 case 'subscription_renewal_reminder':
160 return $this->sendManualRenewalReminder($entityId);
161 case 'subscription_trial_end_reminder':
162 return $this->sendManualTrialReminder($entityId);
163 default:
164 return [
165 'success' => false,
166 'message' => __('Unknown reminder type', 'fluent-cart')
167 ];
168 }
169 } catch (\Throwable $e) {
170 fluent_cart_error_log('Manual reminder send error', $e->getMessage());
171 return [
172 'success' => false,
173 'message' => __('An unexpected error occurred while sending the reminder.', 'fluent-cart'),
174 ];
175 }
176 }
177
178 protected function sendRenewalOrderReminder(int $orderId): array
179 {
180 $order = Order::query()->with(['customer'])->find($orderId);
181
182 if (!$order || !$order->customer) {
183 return ['success' => false, 'message' => __('Order or customer not found', 'fluent-cart')];
184 }
185
186 if (!in_array($order->payment_status, RenewalReminderService::getReminderPaymentStatuses(), true)) {
187 return ['success' => false, 'message' => __('Order is not eligible for payment reminder', 'fluent-cart')];
188 }
189
190 $outstanding = max((int)$order->total_amount - (int)$order->total_paid, 0);
191 if ($outstanding <= 0) {
192 return ['success' => false, 'message' => __('No outstanding amount on this order', 'fluent-cart')];
193 }
194
195 $createdAt = (string)$order->created_at;
196 $base = strtotime($createdAt . ' UTC');
197 if (!$base || $base <= 0) {
198 return ['success' => false, 'message' => __('Invalid order date', 'fluent-cart')];
199 }
200
201 $dueDays = (int)$this->storeSettings->get('renewal_reminder_due_days', 0);
202 $dueAt = $base + (max($dueDays, 0) * DAY_IN_SECONDS);
203
204 $orderRef = !empty($order->invoice_no) ? (string)$order->invoice_no : '#' . (string)$order->id;
205
206 $data = [
207 'order' => $order,
208 'customer' => $order->customer,
209 'reminder' => [
210 'stage' => 'manual',
211 'order_id' => (int)$order->id,
212 'order_ref' => $orderRef,
213 'due_at' => gmdate('Y-m-d H:i:s', $dueAt),
214 'due_amount' => $outstanding,
215 'payment_link' => PaymentHelper::getCustomPaymentLink($order->uuid),
216 ]
217 ];
218
219 do_action('fluent_cart/renewal_reminder_overdue', $data);
220
221 $state = $this->normalizeReminderState($order->getMeta(RenewalReminderService::META_KEY, []));
222 $cycleKey = md5(implode('|', ['order', $order->id, $dueAt, (int)$order->total_amount, (int)$order->total_paid]));
223 $cycleState = $this->getCycleState($state, $cycleKey);
224
225 foreach (array_keys($cycleState['queue']) as $stage) {
226 $cycleState['sent'][$stage] = gmdate('Y-m-d H:i:s');
227 }
228 $cycleState['queue'] = [];
229 $cycleState['sent']['manual_' . time()] = gmdate('Y-m-d H:i:s');
230
231 $state = $this->setCycleState($state, $cycleKey, $cycleState);
232 $state['updated_at'] = gmdate('Y-m-d H:i:s');
233 $order->updateMeta(RenewalReminderService::META_KEY, $state);
234
235 return ['success' => true, 'message' => __('Payment reminder sent successfully', 'fluent-cart')];
236 }
237
238 protected function sendManualRenewalReminder(int $subscriptionId): array
239 {
240 $subscription = Subscription::query()->with(['customer', 'order'])->find($subscriptionId);
241
242 if (!$subscription || !$subscription->customer) {
243 return ['success' => false, 'message' => __('Subscription or customer not found', 'fluent-cart')];
244 }
245
246 $isSimulatedTrial = $subscription->status === Status::SUBSCRIPTION_TRIALING
247 && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') === 'yes';
248
249 if ($subscription->status !== Status::SUBSCRIPTION_ACTIVE && !$isSimulatedTrial) {
250 return ['success' => false, 'message' => __('Subscription is not active', 'fluent-cart')];
251 }
252
253 if (!$subscription->next_billing_date) {
254 return ['success' => false, 'message' => __('No next billing date set', 'fluent-cart')];
255 }
256
257 $billingAt = strtotime($subscription->next_billing_date . ' UTC');
258 if (!$billingAt || $billingAt <= 0) {
259 return ['success' => false, 'message' => __('Invalid billing date', 'fluent-cart')];
260 }
261
262 $intervalMap = [
263 'daily' => 'daily', 'weekly' => 'weekly', 'monthly' => 'monthly',
264 'quarterly' => 'quarterly', 'half_yearly' => 'half_yearly', 'yearly' => 'yearly',
265 ];
266 $billingCycle = $intervalMap[strtolower($subscription->billing_interval ?? '')] ?? 'unsupported';
267
268 do_action('fluent_cart/subscription_renewal_reminder', [
269 'subscription' => $subscription,
270 'order' => $subscription->order,
271 'customer' => $subscription->customer,
272 'reminder' => [
273 'stage' => 'manual',
274 'billing_cycle' => $billingCycle,
275 'billing_date' => gmdate('Y-m-d H:i:s', $billingAt),
276 ]
277 ]);
278
279 $state = $this->normalizeReminderState($subscription->getMeta(SubscriptionReminderService::RENEWAL_META_KEY, []));
280 $cycleKey = md5(implode('|', ['subscription', $subscription->id, $billingAt, (string)$subscription->status, (int)$subscription->recurring_total]));
281 $cycleState = $this->getCycleState($state, $cycleKey);
282
283 foreach (array_keys($cycleState['queue']) as $stage) {
284 $cycleState['sent'][$stage] = gmdate('Y-m-d H:i:s');
285 }
286 $cycleState['queue'] = [];
287 $cycleState['sent']['manual_' . time()] = gmdate('Y-m-d H:i:s');
288
289 $state = $this->setCycleState($state, $cycleKey, $cycleState);
290 $state['updated_at'] = gmdate('Y-m-d H:i:s');
291 $subscription->updateMeta(SubscriptionReminderService::RENEWAL_META_KEY, $state);
292
293 return ['success' => true, 'message' => __('Renewal reminder sent successfully', 'fluent-cart')];
294 }
295
296 protected function sendManualTrialReminder(int $subscriptionId): array
297 {
298 $subscription = Subscription::query()->with(['customer', 'order'])->find($subscriptionId);
299
300 if (!$subscription || !$subscription->customer) {
301 return ['success' => false, 'message' => __('Subscription or customer not found', 'fluent-cart')];
302 }
303
304 $isRealTrial = $subscription->status === Status::SUBSCRIPTION_TRIALING
305 && Arr::get($subscription->config, 'is_trial_days_simulated', 'no') !== 'yes';
306
307 if (!$isRealTrial) {
308 return ['success' => false, 'message' => __('Subscription is not in a trial period', 'fluent-cart')];
309 }
310
311 if (!$subscription->next_billing_date) {
312 return ['success' => false, 'message' => __('No trial end date set', 'fluent-cart')];
313 }
314
315 $trialEndAt = strtotime($subscription->next_billing_date . ' UTC');
316 if (!$trialEndAt || $trialEndAt <= 0) {
317 return ['success' => false, 'message' => __('Invalid trial end date', 'fluent-cart')];
318 }
319
320 do_action('fluent_cart/subscription_trial_end_reminder', [
321 'subscription' => $subscription,
322 'order' => $subscription->order,
323 'customer' => $subscription->customer,
324 'reminder' => [
325 'stage' => 'manual',
326 'trial_end_date' => gmdate('Y-m-d H:i:s', $trialEndAt),
327 ]
328 ]);
329
330 $state = $this->normalizeReminderState($subscription->getMeta(SubscriptionReminderService::TRIAL_META_KEY, []));
331 $cycleKey = md5(implode('|', ['subscription', $subscription->id, $trialEndAt, (string)$subscription->status, (int)$subscription->recurring_total]));
332 $cycleState = $this->getCycleState($state, $cycleKey);
333
334 foreach (array_keys($cycleState['queue']) as $stage) {
335 $cycleState['sent'][$stage] = gmdate('Y-m-d H:i:s');
336 }
337 $cycleState['queue'] = [];
338 $cycleState['sent']['manual_' . time()] = gmdate('Y-m-d H:i:s');
339
340 $state = $this->setCycleState($state, $cycleKey, $cycleState);
341 $state['updated_at'] = gmdate('Y-m-d H:i:s');
342 $subscription->updateMeta(SubscriptionReminderService::TRIAL_META_KEY, $state);
343
344 return ['success' => true, 'message' => __('Trial ending reminder sent successfully', 'fluent-cart')];
345 }
346
347 /*
348 |--------------------------------------------------------------------------
349 | Shared Utilities
350 |--------------------------------------------------------------------------
351 */
352
353 protected function getScanBatchSize(): int
354 {
355 $size = (int)apply_filters('fluent_cart/reminders/scan_batch_size', static::DEFAULT_SCAN_BATCH_SIZE);
356
357 if ($size < static::MIN_SCAN_BATCH_SIZE) {
358 return static::MIN_SCAN_BATCH_SIZE;
359 }
360
361 return min($size, static::MAX_SCAN_BATCH_SIZE);
362 }
363
364 protected function isRuntimeExpired(int $startedAt, int $maxRuntime): bool
365 {
366 return (time() - $startedAt) >= $maxRuntime;
367 }
368
369 protected function parseDayList($values, array $defaults, int $minimum, int $maximum = 365): array
370 {
371 if (is_numeric($values) && !is_array($values)) {
372 $values = [(int)$values];
373 } elseif (is_string($values)) {
374 $values = explode(',', $values);
375 } elseif (!is_array($values)) {
376 $values = $defaults;
377 }
378
379 $values = array_slice($values, 0, 10);
380
381 $days = [];
382 foreach ($values as $value) {
383 $day = (int)trim((string)$value);
384 if ($day < $minimum || $day > $maximum) {
385 continue;
386 }
387 $days[$day] = $day;
388 }
389
390 if (empty($days)) {
391 $days = array_combine($defaults, $defaults);
392 }
393
394 rsort($days, SORT_NUMERIC);
395
396 return array_values($days);
397 }
398
399 /*
400 |--------------------------------------------------------------------------
401 | Reminder State Management
402 |--------------------------------------------------------------------------
403 */
404
405 protected function normalizeReminderState($state): array
406 {
407 if (!is_array($state)) {
408 $state = [];
409 }
410
411 $cycles = Arr::get($state, 'cycles', []);
412 if (!is_array($cycles)) {
413 $cycles = [];
414 }
415
416 $state['cycles'] = $cycles;
417
418 return $state;
419 }
420
421 protected function isStageAlreadySent(array $state, string $cycleKey, string $stage): bool
422 {
423 return !empty(Arr::get($state, "cycles.$cycleKey.sent.$stage"));
424 }
425
426 protected function isStageQueuedRecently(array $state, string $cycleKey, string $stage): bool
427 {
428 $queuedAt = (int)Arr::get($state, "cycles.$cycleKey.queue.$stage", 0);
429 if (!$queuedAt) {
430 return false;
431 }
432
433 return ($queuedAt + (6 * HOUR_IN_SECONDS)) > time();
434 }
435
436 protected function markStageQueued(array $state, string $cycleKey, string $stage): array
437 {
438 $cycleState = $this->getCycleState($state, $cycleKey);
439 $cycleState['queue'][$stage] = time();
440 $state = $this->setCycleState($state, $cycleKey, $cycleState);
441 $state['updated_at'] = gmdate('Y-m-d H:i:s');
442
443 return $state;
444 }
445
446 protected function markStageSent(array $state, string $cycleKey, string $stage): array
447 {
448 $cycleState = $this->getCycleState($state, $cycleKey);
449 $cycleState['sent'][$stage] = gmdate('Y-m-d H:i:s');
450
451 if (isset($cycleState['queue'][$stage])) {
452 unset($cycleState['queue'][$stage]);
453 }
454
455 $state = $this->setCycleState($state, $cycleKey, $cycleState);
456 $state['updated_at'] = gmdate('Y-m-d H:i:s');
457
458 return $state;
459 }
460
461 protected function clearStageQueue(array $state, string $cycleKey, string $stage): array
462 {
463 $cycleState = $this->getCycleState($state, $cycleKey);
464 if (isset($cycleState['queue'][$stage])) {
465 unset($cycleState['queue'][$stage]);
466 }
467
468 $state = $this->setCycleState($state, $cycleKey, $cycleState);
469 $state['updated_at'] = gmdate('Y-m-d H:i:s');
470
471 return $state;
472 }
473
474 protected function getCycleState(array $state, string $cycleKey): array
475 {
476 $cycleState = Arr::get($state, "cycles.$cycleKey", []);
477 if (!is_array($cycleState)) {
478 $cycleState = [];
479 }
480
481 $cycleState['sent'] = Arr::get($cycleState, 'sent', []);
482 $cycleState['queue'] = Arr::get($cycleState, 'queue', []);
483
484 if (!is_array($cycleState['sent'])) {
485 $cycleState['sent'] = [];
486 }
487
488 if (!is_array($cycleState['queue'])) {
489 $cycleState['queue'] = [];
490 }
491
492 return $cycleState;
493 }
494
495 protected function setCycleState(array $state, string $cycleKey, array $cycleState): array
496 {
497 $cycles = Arr::get($state, 'cycles', []);
498 if (!is_array($cycles)) {
499 $cycles = [];
500 }
501
502 if (isset($cycles[$cycleKey])) {
503 unset($cycles[$cycleKey]);
504 }
505
506 $cycles[$cycleKey] = $cycleState;
507
508 if (count($cycles) > 5) {
509 $cycles = array_slice($cycles, -5, null, true);
510 }
511
512 $state['cycles'] = $cycles;
513
514 return $state;
515 }
516 }
517