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

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

391 lines 11.8 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\Services\Payments\PaymentHelper;
9
10 class InvoiceReminderService extends ReminderService
11 {
12 const META_KEY = 'invoice_reminder_state';
13 const ASYNC_HOOK = 'fluent_cart/reminders/send_invoice';
14
15 protected array $orderMetaCache = [];
16
17 public function isEnabled(): bool
18 {
19 return $this->storeSettings->get('invoice_reminders_enabled', 'no') === 'yes';
20 }
21
22 public function send($orderId, $stage, $cycleKey): bool
23 {
24 try {
25 $order = Order::query()->with(['customer'])->find($orderId);
26 if (!$order || !$order->customer) {
27 return false;
28 }
29
30 $state = $this->normalizeReminderState($order->getMeta(static::META_KEY, []));
31
32 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
33 return false;
34 }
35
36 $dueAt = $this->getDueTimestamp($order);
37 if (!$dueAt) {
38 $state = $this->clearStageQueue($state, $cycleKey, $stage);
39 $order->updateMeta(static::META_KEY, $state);
40 return false;
41 }
42
43 $currentCycle = $this->getCycleKey($order, $dueAt);
44 if ($cycleKey !== $currentCycle || !$this->isEligible($order)) {
45 $state = $this->clearStageQueue($state, $cycleKey, $stage);
46 $order->updateMeta(static::META_KEY, $state);
47 return false;
48 }
49
50 $eventName = $this->resolveEventName($stage);
51 $data = [
52 'order' => $order,
53 'customer' => $order->customer,
54 'reminder' => [
55 'stage' => $stage,
56 'order_id' => (int)$order->id,
57 'order_ref' => $this->getOrderReference($order),
58 'due_at' => gmdate('Y-m-d H:i:s', $dueAt),
59 'due_amount' => $this->getOutstandingAmount($order),
60 'payment_link' => PaymentHelper::getCustomPaymentLink($order->uuid),
61 ]
62 ];
63
64 do_action('fluent_cart/' . $eventName, $data);
65
66 $state = $this->markStageSent($state, $cycleKey, $stage);
67 $order->updateMeta(static::META_KEY, $state);
68
69 return true;
70 } catch (\Throwable $e) {
71 fluent_cart_error_log(
72 'Invoice reminder send error',
73 sprintf('Order #%d, stage: %s — %s', $orderId, $stage, $e->getMessage())
74 );
75 return false;
76 }
77 }
78
79 public function clearState(Order $order): void
80 {
81 $order->deleteMeta(static::META_KEY);
82 }
83
84 public function queueActions($startedAt, $maxRuntime): int
85 {
86 $queued = 0;
87 $lastId = 0;
88 $batchSize = $this->getScanBatchSize();
89 $cutoffDate = $this->getScanCutoffDate();
90
91 while (!$this->isRuntimeExpired($startedAt, $maxRuntime)) {
92 $query = Order::query()
93 ->where('id', '>', $lastId)
94 ->whereIn('payment_status', $this->getReminderPaymentStatuses())
95 ->where('created_at', '>=', $cutoffDate)
96 ->orderBy('id', 'ASC')
97 ->limit($batchSize);
98
99 $orders = $query->get();
100
101 if ($orders->isEmpty()) {
102 break;
103 }
104
105 $this->preloadMeta($orders->pluck('id')->toArray());
106
107 foreach ($orders as $order) {
108 $queued += $this->queueForOrder($order);
109
110 if ($this->isRuntimeExpired($startedAt, $maxRuntime)) {
111 break;
112 }
113 }
114
115 $lastId = $orders->last()->id;
116
117 if ($orders->count() < $batchSize) {
118 break;
119 }
120 }
121
122 $this->orderMetaCache = [];
123
124 return $queued;
125 }
126
127 /*
128 |--------------------------------------------------------------------------
129 | Queueing
130 |--------------------------------------------------------------------------
131 */
132
133 protected function queueForOrder(Order $order): int
134 {
135 if (!$this->isEligible($order)) {
136 return 0;
137 }
138
139 $dueAt = $this->getDueTimestamp($order);
140 if (!$dueAt) {
141 return 0;
142 }
143
144 $now = time();
145 $cycleKey = $this->getCycleKey($order, $dueAt);
146 $queued = 0;
147 $overdueDays = $this->getOverdueDays();
148
149 // @todo uncomment when invoice feature is deployed
150 // Due reminder stage (`before_0`) should fire once the invoice is due.
151 // Keep this bounded before the first overdue stage starts.
152 // $firstOverdueAfter = !empty($overdueDays) ? (int)min($overdueDays) : 1;
153 // $overdueWindowStart = $dueAt + ($firstOverdueAfter * DAY_IN_SECONDS);
154 // if ($now >= $dueAt && $now < $overdueWindowStart) {
155 // if ($this->queueStage($order, 'before_0', $cycleKey)) {
156 // $queued++;
157 // }
158 // }
159
160 // $overdueDays is sorted descending (e.g. [7, 3, 1]).
161 // Each stage fires only within its window:
162 // overdue_1: [dueAt+1d, dueAt+3d)
163 // overdue_3: [dueAt+3d, dueAt+7d)
164 // overdue_7: [dueAt+7d, dueAt+7d+2d grace)
165 foreach ($overdueDays as $index => $daysAfter) {
166 $target = $dueAt + ((int)$daysAfter * DAY_IN_SECONDS);
167
168 if ($now < $target) {
169 continue;
170 }
171
172 if ($index === 0) {
173 // Last (largest) stage — 2-day grace for late cron runs, then hard stop
174 $upperBound = $target + (2 * DAY_IN_SECONDS);
175 } else {
176 // Window closes when the next stage's window opens
177 $upperBound = $dueAt + ((int)$overdueDays[$index - 1] * DAY_IN_SECONDS);
178 }
179
180 if ($now >= $upperBound) {
181 continue;
182 }
183
184 $stage = 'overdue_' . (int)$daysAfter;
185 if ($this->queueStage($order, $stage, $cycleKey)) {
186 $queued++;
187 }
188 }
189
190 return $queued;
191 }
192
193 protected function queueStage(Order $order, string $stage, string $cycleKey): bool
194 {
195 $state = $this->normalizeReminderState($this->getCachedMeta($order));
196
197 if ($this->isStageAlreadySent($state, $cycleKey, $stage)) {
198 return false;
199 }
200
201 if ($this->isStageQueuedRecently($state, $cycleKey, $stage)) {
202 return false;
203 }
204
205 $args = [$order->id, $stage, $cycleKey];
206
207 if (function_exists('as_next_scheduled_action')) {
208 $existing = as_next_scheduled_action(static::ASYNC_HOOK, $args, 'fluent-cart');
209 if ($existing) {
210 $state = $this->markStageQueued($state, $cycleKey, $stage);
211 $this->saveMeta($order, $state);
212 return false;
213 }
214 }
215
216 $state = $this->markStageQueued($state, $cycleKey, $stage);
217 $this->saveMeta($order, $state);
218
219 if (function_exists('as_enqueue_async_action')) {
220 $result = as_enqueue_async_action(static::ASYNC_HOOK, $args, 'fluent-cart');
221 if ($result === 0) {
222 $state = $this->clearStageQueue($state, $cycleKey, $stage);
223 $this->saveMeta($order, $state);
224 return false;
225 }
226
227 return true;
228 }
229
230 return $this->send($order->id, $stage, $cycleKey);
231 }
232
233 /*
234 |--------------------------------------------------------------------------
235 | Meta Cache
236 |--------------------------------------------------------------------------
237 */
238
239 protected function preloadMeta(array $orderIds): void
240 {
241 if (empty($orderIds)) {
242 return;
243 }
244
245 $metas = OrderMeta::query()
246 ->whereIn('order_id', $orderIds)
247 ->where('meta_key', static::META_KEY)
248 ->get();
249
250 foreach ($metas as $meta) {
251 $this->orderMetaCache[$meta->order_id] = $meta->meta_value;
252 }
253
254 foreach ($orderIds as $id) {
255 if (!array_key_exists($id, $this->orderMetaCache)) {
256 $this->orderMetaCache[$id] = [];
257 }
258 }
259 }
260
261 protected function getCachedMeta(Order $order): array
262 {
263 if (array_key_exists($order->id, $this->orderMetaCache)) {
264 $value = $this->orderMetaCache[$order->id];
265 return is_array($value) ? $value : [];
266 }
267
268 return $order->getMeta(static::META_KEY, []) ?: [];
269 }
270
271 protected function saveMeta(Order $order, array $state): void
272 {
273 $order->updateMeta(static::META_KEY, $state);
274 $this->orderMetaCache[$order->id] = $state;
275 }
276
277 /*
278 |--------------------------------------------------------------------------
279 | Settings
280 |--------------------------------------------------------------------------
281 */
282
283 protected function getDueDays(): int
284 {
285 $days = (int)$this->storeSettings->get('invoice_reminder_due_days', 0);
286 $days = max($days, 0);
287
288 return (int)apply_filters('fluent_cart/reminders/invoice_due_days', $days);
289 }
290
291 protected function getOverdueDays(): array
292 {
293 $days = $this->parseDayList(
294 $this->storeSettings->get('invoice_reminder_overdue_days', '1,3,7'),
295 [1, 3, 7],
296 1
297 );
298
299 return apply_filters('fluent_cart/reminders/invoice_overdue_days', $days);
300 }
301
302 protected function getReminderPaymentStatuses(): array
303 {
304 return [
305 Status::PAYMENT_PENDING,
306 Status::PAYMENT_PARTIALLY_PAID,
307 Status::PAYMENT_FAILED,
308 Status::PAYMENT_AUTHORIZED,
309 ];
310 }
311
312 /*
313 |--------------------------------------------------------------------------
314 | Eligibility & Helpers
315 |--------------------------------------------------------------------------
316 */
317
318 protected function isEligible(Order $order): bool
319 {
320 if (!in_array($order->payment_status, $this->getReminderPaymentStatuses(), true)) {
321 return false;
322 }
323
324 return $this->getOutstandingAmount($order) > 0;
325 }
326
327 protected function getDueTimestamp(Order $order): int
328 {
329 if (!$order->created_at) {
330 return 0;
331 }
332
333 $createdAt = (string)$order->created_at;
334 if (!preg_match('/^\d{4}-\d{2}-\d{2}/', $createdAt)) {
335 return 0;
336 }
337
338 $base = strtotime($createdAt . ' UTC');
339 if (!$base || $base <= 0) {
340 return 0;
341 }
342
343 return $base + ($this->getDueDays() * DAY_IN_SECONDS);
344 }
345
346 protected function getScanCutoffDate(): string
347 {
348 $overdueDays = $this->getOverdueDays();
349 $maxOverdue = !empty($overdueDays) ? max($overdueDays) : 0;
350 $maxAgeDays = $this->getDueDays() + $maxOverdue + 7;
351 $maxAgeDays = max($maxAgeDays, 30);
352
353 return gmdate('Y-m-d H:i:s', time() - ($maxAgeDays * DAY_IN_SECONDS));
354 }
355
356 protected function getCycleKey(Order $order, int $dueAt): string
357 {
358 return md5(implode('|', [
359 'order',
360 $order->id,
361 $dueAt,
362 (int)$order->total_amount,
363 (int)$order->total_paid,
364 ]));
365 }
366
367 protected function getOutstandingAmount(Order $order): int
368 {
369 $due = (int)$order->total_amount - (int)$order->total_paid;
370 return max($due, 0);
371 }
372
373 protected function getOrderReference(Order $order): string
374 {
375 if (!empty($order->invoice_no)) {
376 return (string)$order->invoice_no;
377 }
378
379 return '#' . (string)$order->id;
380 }
381
382 protected function resolveEventName(string $stage): string
383 {
384 if (strpos($stage, 'overdue_') === 0) {
385 return 'invoice_reminder_overdue';
386 }
387
388 return 'invoice_reminder_due';
389 }
390 }
391