PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 / RenewalReminderService.php

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

469 lines 15.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\Order;
7 use FluentCart\App\Models\OrderMeta;
8 use FluentCart\App\Models\Subscription;
9 use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
10 use FluentCart\App\Services\Payments\PaymentHelper;
11 use FluentCart\App\Services\Payments\SubscriptionHelper;
12
13 class RenewalReminderService extends ReminderService
14 {
15 const META_KEY = 'renewal_reminder_state';
16 const ASYNC_HOOK = 'fluent_cart/reminders/send_renewal';
17
18 protected array $orderMetaCache = [];
19
20 public function isEnabled(): bool
21 {
22 return $this->storeSettings->get('renewal_reminders_enabled', 'no') === 'yes';
23 }
24
25 public function send($orderId, $stage, $cycleKey): bool
26 {
27 try {
28 $order = Order::query()->with(['customer'])->find($orderId);
29 if (!$order || !$order->customer) {
30 return false;
31 }
32
33 $state = $this->normalizeReminderState($order->getMeta(static::META_KEY, []));
34
35 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
36 return false;
37 }
38
39 $dueAt = $this->getDueTimestamp($order);
40 if (!$dueAt) {
41 $state = $this->clearStageQueue($state, $cycleKey, $stage);
42 $order->updateMeta(static::META_KEY, $state);
43 return false;
44 }
45
46 $currentCycle = $this->getCycleKey($order, $dueAt);
47 if ($cycleKey !== $currentCycle || !$this->isEligible($order)) {
48 $state = $this->clearStageQueue($state, $cycleKey, $stage);
49 $order->updateMeta(static::META_KEY, $state);
50 return false;
51 }
52
53 $eventName = $this->resolveEventName($stage);
54 $data = [
55 'order' => $order,
56 'customer' => $order->customer,
57 'reminder' => [
58 'stage' => $stage,
59 'order_id' => (int)$order->id,
60 'order_ref' => $this->getOrderReference($order),
61 'due_at' => gmdate('Y-m-d H:i:s', $dueAt),
62 'due_amount' => $this->getOutstandingAmount($order),
63 'payment_link' => PaymentHelper::getCustomPaymentLink($order->uuid),
64 ]
65 ];
66
67 do_action('fluent_cart/' . $eventName, $data);
68
69 if (strpos($stage, 'overdue_') === 0) {
70 // Legacy hook shipped in 1.6.0 for scheduled overdue reminders.
71 // Kept for third-party listeners; the flag tells the core mailer
72 // the staged email above was already sent, so it must not mail
73 // again — direct dispatches of the legacy hook lack the flag and
74 // still deliver.
75 $data['staged_email_dispatched'] = true;
76 do_action_deprecated(
77 'fluent_cart/renewal_reminder_overdue',
78 [$data],
79 '1.6.3',
80 'fluent_cart/' . $eventName
81 );
82 }
83
84 $state = $this->markStageSent($state, $cycleKey, $stage);
85 $order->updateMeta(static::META_KEY, $state);
86
87 return true;
88 } catch (\Throwable $e) {
89 fluent_cart_error_log(
90 'Renewal reminder send error',
91 sprintf('Order #%d, stage: %s — %s', $orderId, $stage, $e->getMessage())
92 );
93 return false;
94 }
95 }
96
97 public function clearState(Order $order): void
98 {
99 $order->deleteMeta(static::META_KEY);
100 }
101
102 public function queueActions($startedAt, $maxRuntime): int
103 {
104 $queued = 0;
105 $lastId = 0;
106 $batchSize = $this->getScanBatchSize();
107 $cutoffDate = $this->getScanCutoffDate();
108
109 while (!$this->isRuntimeExpired($startedAt, $maxRuntime)) {
110 $query = Order::query()
111 ->where('id', '>', $lastId)
112 ->where('type', Status::ORDER_TYPE_RENEWAL)
113 ->whereIn('payment_status', static::getReminderPaymentStatuses())
114 ->where('created_at', '>=', $cutoffDate)
115 ->orderBy('id', 'ASC')
116 ->limit($batchSize);
117
118 $orders = $query->get();
119
120 if ($orders->isEmpty()) {
121 break;
122 }
123
124 $this->preloadMeta($orders->pluck('id')->toArray());
125
126 foreach ($orders as $order) {
127 $queued += $this->queueForOrder($order);
128
129 if ($this->isRuntimeExpired($startedAt, $maxRuntime)) {
130 break;
131 }
132 }
133
134 $lastId = $orders->last()->id;
135
136 if ($orders->count() < $batchSize) {
137 break;
138 }
139 }
140
141 $this->orderMetaCache = [];
142
143 return $queued;
144 }
145
146 /*
147 |--------------------------------------------------------------------------
148 | Queueing
149 |--------------------------------------------------------------------------
150 */
151
152 protected function queueForOrder(Order $order): int
153 {
154 if (!$this->isEligible($order)) {
155 return 0;
156 }
157
158 $dueAt = $this->getDueTimestamp($order);
159 if (!$dueAt) {
160 return 0;
161 }
162
163 $now = time();
164 $cycleKey = $this->getCycleKey($order, $dueAt);
165 $queued = 0;
166 $overdueDays = $this->getOverdueDays();
167
168 // Due reminder stage (`before_0`) fires once the renewal order is due (billing date reached).
169 // Bounded before the first overdue stage window opens.
170 // Skip if the renewal order was created ON the due date (e.g. trial subscriptions where advance
171 // days = 0) — the renewal order creation email already notified the customer, sending a due-date
172 // reminder seconds later would be redundant.
173 $firstOverdueAfter = !empty($overdueDays) ? (int)min($overdueDays) : 1;
174 $overdueWindowStart = $dueAt + ($firstOverdueAfter * DAY_IN_SECONDS);
175 $renewalCreatedAt = $order->created_at ? (int)strtotime($order->created_at . ' UTC') : 0;
176 if ($now >= $dueAt && $now < $overdueWindowStart && $renewalCreatedAt < $dueAt) {
177 if ($this->queueStage($order, 'before_0', $cycleKey)) {
178 $queued++;
179 }
180 }
181
182 // $overdueDays is sorted descending (e.g. [7, 3, 1]).
183 // Each stage fires only within its window:
184 // overdue_1: [dueAt+1d, dueAt+3d)
185 // overdue_3: [dueAt+3d, dueAt+7d)
186 // overdue_7: [dueAt+7d, dueAt+7d+2d grace)
187 foreach ($overdueDays as $index => $daysAfter) {
188 $target = $dueAt + ((int)$daysAfter * DAY_IN_SECONDS);
189
190 if ($now < $target) {
191 continue;
192 }
193
194 if ($index === 0) {
195 // Last (largest) stage — 2-day grace for late cron runs, then hard stop
196 $upperBound = $target + (2 * DAY_IN_SECONDS);
197 } else {
198 // Window closes when the next stage's window opens
199 $upperBound = $dueAt + ((int)$overdueDays[$index - 1] * DAY_IN_SECONDS);
200 }
201
202 if ($now >= $upperBound) {
203 continue;
204 }
205
206 $stage = 'overdue_' . (int)$daysAfter;
207 if ($this->queueStage($order, $stage, $cycleKey)) {
208 $queued++;
209 }
210 }
211
212 return $queued;
213 }
214
215 protected function queueStage(Order $order, string $stage, string $cycleKey): bool
216 {
217 $state = $this->normalizeReminderState($this->getCachedMeta($order));
218
219 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
220 return false;
221 }
222
223 if ($this->isStageQueuedRecently($state, $cycleKey, $stage)) {
224 return false;
225 }
226
227 $args = [$order->id, $stage, $cycleKey];
228
229 if (function_exists('as_next_scheduled_action')) {
230 $existing = as_next_scheduled_action(static::ASYNC_HOOK, $args, 'fluent-cart');
231 if ($existing) {
232 $state = $this->markStageQueued($state, $cycleKey, $stage);
233 $this->saveMeta($order, $state);
234 return false;
235 }
236 }
237
238 $state = $this->markStageQueued($state, $cycleKey, $stage);
239 $this->saveMeta($order, $state);
240
241 if (function_exists('as_enqueue_async_action')) {
242 $result = as_enqueue_async_action(static::ASYNC_HOOK, $args, 'fluent-cart');
243 if ($result === 0) {
244 $state = $this->clearStageQueue($state, $cycleKey, $stage);
245 $this->saveMeta($order, $state);
246 return false;
247 }
248
249 return true;
250 }
251
252 return $this->send($order->id, $stage, $cycleKey);
253 }
254
255 /*
256 |--------------------------------------------------------------------------
257 | Meta Cache
258 |--------------------------------------------------------------------------
259 */
260
261 protected function preloadMeta(array $orderIds): void
262 {
263 if (empty($orderIds)) {
264 return;
265 }
266
267 $metas = OrderMeta::query()
268 ->whereIn('order_id', $orderIds)
269 ->where('meta_key', static::META_KEY)
270 ->get();
271
272 foreach ($metas as $meta) {
273 $this->orderMetaCache[$meta->order_id] = $meta->meta_value;
274 }
275
276 foreach ($orderIds as $id) {
277 if (!array_key_exists($id, $this->orderMetaCache)) {
278 $this->orderMetaCache[$id] = [];
279 }
280 }
281 }
282
283 protected function getCachedMeta(Order $order): array
284 {
285 if (array_key_exists($order->id, $this->orderMetaCache)) {
286 $value = $this->orderMetaCache[$order->id];
287 return is_array($value) ? $value : [];
288 }
289
290 return $order->getMeta(static::META_KEY, []) ?: [];
291 }
292
293 protected function saveMeta(Order $order, array $state): void
294 {
295 $order->updateMeta(static::META_KEY, $state);
296 $this->orderMetaCache[$order->id] = $state;
297 }
298
299 /*
300 |--------------------------------------------------------------------------
301 | Settings
302 |--------------------------------------------------------------------------
303 */
304
305 protected function getDueDays(): int
306 {
307 $days = (int)$this->storeSettings->get('renewal_reminder_due_days', 0);
308 $days = max($days, 0);
309
310 return (int)apply_filters('fluent_cart/reminders/renewal_due_days', $days);
311 }
312
313 protected function getOverdueDays(): array
314 {
315 $days = $this->parseDayList(
316 $this->storeSettings->get('renewal_reminder_overdue_days', '1,3,7'),
317 [1, 3, 7],
318 1
319 );
320
321 return apply_filters('fluent_cart/reminders/renewal_overdue_days', $days);
322 }
323
324 /**
325 * Canonical reminder-eligible payment statuses — shared by the hourly scan,
326 * manual "send now", and the admin UI's button-visibility gate (see
327 * ReminderService::sendManualRenewalReminder / canSendPaymentReminder).
328 * authorized and partially_paid are excluded: both reflect payment already
329 * in progress, so a "you owe money" reminder would be misleading.
330 */
331 public static function getReminderPaymentStatuses(): array
332 {
333 return [
334 Status::PAYMENT_PENDING,
335 Status::PAYMENT_FAILED,
336 Status::PAYMENT_SCHEDULED,
337 ];
338 }
339
340 /*
341 |--------------------------------------------------------------------------
342 | Eligibility & Helpers
343 |--------------------------------------------------------------------------
344 */
345
346 protected function isEligible(Order $order): bool
347 {
348 // Live invoice on a test-mode store (or vice versa): don't email — the
349 // guard's promise covers reminders too. Checked at both scan and send
350 // time so pre-queued actions copied to a clone are also caught.
351 if (!SubscriptionHelper::canProcessInMode($order->mode)) {
352 return false;
353 }
354
355 if (!in_array($order->payment_status, static::getReminderPaymentStatuses(), true)) {
356 return false;
357 }
358
359 // Voided invoice — payment_status alone can't distinguish "voided" from a
360 // genuine decline (both land on PAYMENT_FAILED), so gate on order status too.
361 if ($order->status === Status::ORDER_CANCELED) {
362 return false;
363 }
364
365 // System (auto-charge) renewal: no pay-now reminders while still retrying,
366 // scheduled or pending — reminders resume once retries are exhausted.
367 if ($order->parent_id) {
368 $subscription = Subscription::query()->where('parent_order_id', $order->parent_id)->first();
369 if ($subscription && $subscription->isSystem() && !SystemChargeService::isExhausted($subscription, $order)) {
370 return false;
371 }
372 }
373
374 return $this->getOutstandingAmount($order) > 0;
375 }
376
377 protected function getDueTimestamp(Order $order): int
378 {
379 $dueDate = $order->getMeta('due_date');
380
381 if ($dueDate) {
382 $ts = strtotime($dueDate . ' UTC');
383 return ($ts && $ts > 0) ? (int)$ts : 0;
384 }
385
386 // Fallback for renewal orders without due_date meta (legacy)
387 if (!$order->created_at) {
388 return 0;
389 }
390
391 $createdAt = (string)$order->created_at;
392 if (!preg_match('/^\d{4}-\d{2}-\d{2}/', $createdAt)) {
393 return 0;
394 }
395
396 $base = strtotime($createdAt . ' UTC');
397 if (!$base || $base <= 0) {
398 return 0;
399 }
400
401 return $base + ($this->getDueDays() * DAY_IN_SECONDS);
402 }
403
404 protected function getScanCutoffDate(): string
405 {
406 $overdueDays = $this->getOverdueDays();
407 $maxOverdue = !empty($overdueDays) ? max($overdueDays) : 0;
408 $maxAgeDays = $this->getDueDays() + $maxOverdue + 7;
409 $maxAgeDays = max($maxAgeDays, 30);
410
411 return gmdate('Y-m-d H:i:s', time() - ($maxAgeDays * DAY_IN_SECONDS));
412 }
413
414 protected function getCycleKey(Order $order, int $dueAt): string
415 {
416 return md5(implode('|', [
417 'order',
418 $order->id,
419 $dueAt,
420 (int)$order->total_amount,
421 (int)$order->total_paid,
422 ]));
423 }
424
425 protected function getOutstandingAmount(Order $order): int
426 {
427 $due = (int)$order->total_amount - (int)$order->total_paid;
428 return max($due, 0);
429 }
430
431 protected function getOrderReference(Order $order): string
432 {
433 if (!empty($order->invoice_no)) {
434 return (string)$order->invoice_no;
435 }
436
437 return '#' . (string)$order->id;
438 }
439
440 protected function resolveEventName(string $stage): string
441 {
442 if (strpos($stage, 'overdue_') === 0) {
443 return $this->resolveOverdueEventName((int)substr($stage, strlen('overdue_')));
444 }
445
446 return 'renewal_reminder_due';
447 }
448
449 /**
450 * Map an overdue stage to a notification event by its position in the
451 * configured day list, not by a fixed day threshold — the escalation tone
452 * follows the admin's schedule (e.g. 14,30 → first at 14, final at 30).
453 */
454 protected function resolveOverdueEventName(int $daysAfter): string
455 {
456 $days = $this->getOverdueDays();
457
458 if (empty($days) || $daysAfter <= min($days)) {
459 return 'renewal_overdue_first';
460 }
461
462 if ($daysAfter >= max($days)) {
463 return 'renewal_overdue_final';
464 }
465
466 return 'renewal_overdue_followup';
467 }
468 }
469