PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.19
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.19
1.6.6 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 All 49 releases
fluent-cart / app / Services / Reminders / ReminderService.php

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

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