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

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

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