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
← All changes | app/Services/Payments/SubscriptionHelper.php +272 -25 1.5.3 → 1.6.5 View file →
@@ -1,12 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Services\Payments;
4 4
5 +use FluentCart\Api\StoreSettings;
5 6 use FluentCart\App\App;
6 7 use FluentCart\App\Helpers\Helper;
7 8 use FluentCart\App\Helpers\Status;
8 9 use FluentCart\App\Models\Order;
10 +use FluentCart\App\Models\OrderTransaction;
9 11 use FluentCart\App\Models\Subscription;
10 12 use FluentCart\App\Services\DateTime\DateTime;
11 13 use FluentCart\Framework\Support\Arr;
12 14
@@ -11,8 +13,42 @@
11 13 use FluentCart\Framework\Support\Arr;
12 14
13 15 class SubscriptionHelper
14 16 {
17 + /**
18 + * When money actually moved for an order, not when the row was created.
19 + * A pending/COD/bank-transfer order can sit for months before it is paid,
20 + * so `created_at` is not a safe billing anchor. Prefers the latest succeeded
21 + * charge's meta.settled_at (the gateway's own settlement time), falls back
22 + * to that transaction's created_at, then the order's completed_at, and only
23 + * falls back to order created_at when nothing else exists (e.g. a $0 order).
24 + */
25 + public static function resolvePaidAnchor(Order $order)
26 + {
27 + $lastCharge = OrderTransaction::query()
28 + ->where('order_id', $order->id)
29 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
30 + ->where('status', Status::TRANSACTION_SUCCEEDED)
31 + ->orderBy('id', 'DESC')
32 + ->first();
33 +
34 + if ($lastCharge) {
35 + $settledAt = Arr::get($lastCharge->meta, 'settled_at');
36 + if (!empty($settledAt)) {
37 + return $settledAt;
38 + }
39 + if (!empty($lastCharge->created_at)) {
40 + return $lastCharge->created_at;
41 + }
42 + }
43 +
44 + if (!empty($order->completed_at)) {
45 + return $order->completed_at;
46 + }
47 +
48 + return $order->created_at;
49 + }
50 +
15 51 /*
16 52 * @param $subscriptionModel
17 53 * @return string|null
18 54 *
@@ -42,9 +78,10 @@
42 78 }
43 79
44 80
45 81 if ($subscriptionModel->bill_count == 0) {
46 - $baseDate = $subscriptionModel->created_at;
82 + $parentOrder = $subscriptionModel->order;
83 + $baseDate = $parentOrder ? self::resolvePaidAnchor($parentOrder) : $subscriptionModel->created_at;
47 84
48 85 } elseif (!empty($subscriptionModel->next_billing_date) && strtotime($subscriptionModel->next_billing_date) < time()) {
49 86 $baseDate = $subscriptionModel->next_billing_date;
50 87 } else {
@@ -50,32 +87,13 @@
50 87 } else {
51 88 $baseDate = DateTime::gmtNow()->format('Y-m-d H:i:s');
52 89 }
53 90
54 - switch (strtolower($subscriptionModel->billing_interval)) {
55 - case 'daily':
56 - $next = strtotime($baseDate . " +1 day");
57 - break;
58 - case 'weekly':
59 - $next = strtotime($baseDate . " +1 week");
60 - break;
61 - case 'monthly':
62 - $next = strtotime($baseDate . " +1 month");
63 - break;
64 - case 'quarterly':
65 - $next = strtotime($baseDate . " +3 months");
66 - break;
67 - case 'half_yearly':
68 - $next = strtotime($baseDate . " +6 months");
69 - break;
70 - case 'yearly':
71 - $next = strtotime($baseDate . " +1 year");
72 - break;
73 - default:
74 - $next = strtotime($baseDate . " +1 month"); // fallback
75 - }
76 -
77 - return gmdate('Y-m-d H:i:s', $next);
91 + return gmdate('Y-m-d H:i:s', self::addBillingInterval(
92 + $baseDate,
93 + strtolower($subscriptionModel->billing_interval),
94 + self::getBillingSchedule($subscriptionModel)
95 + ));
78 96 }
79 97
80 98 /*
81 99 * @param $trialDays
@@ -173,8 +191,29 @@
173 191 }
174 192 return gmdate('Y-m-d H:i:s', $ts);
175 193 }
176 194
195 + /**
196 + * Whether renewal work is restricted to the store's current mode. On by
197 + * default; a live store deliberately flipped to test mode can turn it off
198 + * so live subscriptions keep billing. Fail-closed: only an explicit 'no'
199 + * disables — a malformed value written past the request sanitizer must
200 + * not silently drop staging protection.
201 + */
202 + public static function isModeGuardEnabled(): bool
203 + {
204 + return (new StoreSettings())->get('subscription_mode_guard', 'yes') !== 'no';
205 + }
206 +
207 + /**
208 + * Whether renewal work (invoice creation, automatic charging) may run for
209 + * an order of the given mode under the current store mode + guard setting.
210 + */
211 + public static function canProcessInMode(string $orderMode): bool
212 + {
213 + return !self::isModeGuardEnabled() || $orderMode === (new StoreSettings())->get('order_mode');
214 + }
215 +
177 216 public static function getSubscriptionsGracePeriodDays()
178 217 {
179 218 $defaults = [
180 219 'daily' => 1,
@@ -196,7 +235,215 @@
196 235 $gracePeriods[$interval] = is_numeric($days) ? max(0, (int) $days) : $defaultDays;
197 236 }
198 237
199 238 return array_intersect_key($gracePeriods, $defaults);
239 + }
240 +
241 + /**
242 + * Grace period (days past due before expiry) for a billing interval, resolved
243 + * from the per-interval grace map. Defaults to 7 for unknown intervals.
244 + */
245 + public static function getGracePeriodDaysForInterval(string $interval): int
246 + {
247 + $map = self::getSubscriptionsGracePeriodDays();
248 +
249 + foreach ($map as $key => $days) {
250 + if (strpos($interval, $key) !== false) {
251 + return (int) $days;
252 + }
253 + }
254 +
255 + return 7;
256 + }
257 +
258 + /**
259 + * Custom billing schedule stored in the subscription's config, or null.
260 + *
261 + * Shape: ['period' => day|week|month|year, 'interval' => N, 'anchor' => []].
262 + * Written by migrators for cadences the billing_interval enum cannot express
263 + * (every 2 weeks, every 4 months) and for calendar-synced billing (fixed day
264 + * of week / day of month / month of year). When present it overrides the
265 + * slug in addBillingInterval(); the slug itself stays a native enum value
266 + * (the schedule's base period) so validation, grace periods, and the UI keep
267 + * working — and so a site without config support bills the base period
268 + * rather than daily.
269 + *
270 + * Anchor keys: week → weekday (ISO 1-7); month → day (1-31, 31 = last day
271 + * of month); year → day + month.
272 + *
273 + * @return array|null
274 + */
275 + public static function getBillingSchedule(Subscription $subscription)
276 + {
277 + $config = $subscription->config;
278 + $schedule = is_array($config) ? Arr::get($config, 'billing_schedule') : null;
279 +
280 + if (!is_array($schedule)) {
281 + return null;
282 + }
283 +
284 + $period = Arr::get($schedule, 'period');
285 +
286 + if (!in_array($period, ['day', 'week', 'month', 'year'], true)) {
287 + return null;
288 + }
289 +
290 + return [
291 + 'period' => $period,
292 + 'interval' => max(1, (int) Arr::get($schedule, 'interval', 1)),
293 + 'anchor' => self::sanitizeScheduleAnchor($period, Arr::get($schedule, 'anchor')),
294 + ];
295 + }
296 +
297 + /**
298 + * Keep only anchor keys valid for the period and inside calendar range.
299 + * gmmktime() silently renormalizes out-of-range values (month 15 rolls
300 + * into the next year, day -3 into the previous month), so a corrupt
301 + * anchor value must be dropped — addSchedulePeriod() then falls back to
302 + * the current date part, keeping the cycle length correct.
303 + */
304 + private static function sanitizeScheduleAnchor($period, $anchor)
305 + {
306 + if (!is_array($anchor)) {
307 + return [];
308 + }
309 +
310 + $clean = [];
311 +
312 + if ($period === 'week') {
313 + $weekday = (int) Arr::get($anchor, 'weekday');
314 + if ($weekday >= 1 && $weekday <= 7) {
315 + $clean['weekday'] = $weekday;
316 + }
317 + }
318 +
319 + if ($period === 'month' || $period === 'year') {
320 + $day = (int) Arr::get($anchor, 'day');
321 + if ($day >= 1 && $day <= 31) {
322 + $clean['day'] = $day;
323 + }
324 + }
325 +
326 + if ($period === 'year') {
327 + $month = (int) Arr::get($anchor, 'month');
328 + if ($month >= 1 && $month <= 12) {
329 + $clean['month'] = $month;
330 + }
331 + }
332 +
333 + return $clean;
334 + }
335 +
336 + /**
337 + * Advance a GMT datetime by one whole billing cycle, calendar-accurate.
338 + *
339 + * Month-based intervals keep the day-of-month, clamping into shorter target
340 + * months (Jan 31 + monthly = Feb 28/29, then back to the 31st the cycle
341 + * after) — a flat day count (monthly = 30 days) walks a subscription's
342 + * billing day backwards roughly five days a year. Day/week intervals are
343 + * exact multiples already. Unknown intervals keep the day-count contract of
344 + * PaymentHelper::getIntervalDays() and its
345 + * `fluent_cart/subscription_interval_in_days` filter, including its
346 + * zero-progress edge (a filter returning 0 advances nothing, as before).
347 + *
348 + * When $schedule (see getBillingSchedule()) is given it wins over the slug:
349 + * the cycle is interval × period with the anchor re-applied, so a migrated
350 + * every-2-weeks-on-Friday subscription stays on Fridays even after a late
351 + * payment rebases the cycle.
352 + *
353 + * @param string|int $fromDate GMT datetime string or UTC timestamp
354 + * @param string $interval billing_interval slug
355 + * @param array|null $schedule config-defined schedule, overrides $interval
356 + * @return int advanced UTC timestamp
357 + */
358 + public static function addBillingInterval($fromDate, $interval, $schedule = null)
359 + {
360 + $fromTs = is_numeric($fromDate) ? (int) $fromDate : (int) strtotime($fromDate);
361 +
362 + if (is_array($schedule) && !empty($schedule['period'])) {
363 + return self::addSchedulePeriod($fromTs, $schedule);
364 + }
365 +
366 + $monthsMap = [
367 + Status::BILLING_MONTHLY => 1,
368 + Status::BILLING_QUARTERLY => 3,
369 + Status::BILLING_HALF_YEARLY => 6,
370 + Status::BILLING_YEARLY => 12,
371 + ];
372 +
373 + if (isset($monthsMap[$interval])) {
374 + $hour = (int) gmdate('H', $fromTs);
375 + $min = (int) gmdate('i', $fromTs);
376 + $sec = (int) gmdate('s', $fromTs);
377 + $year = (int) gmdate('Y', $fromTs);
378 + $month = (int) gmdate('n', $fromTs) + $monthsMap[$interval];
379 +
380 + $firstOfTarget = gmmktime($hour, $min, $sec, $month, 1, $year);
381 + $day = min((int) gmdate('j', $fromTs), (int) gmdate('t', $firstOfTarget));
382 +
383 + return gmmktime($hour, $min, $sec, $month, $day, $year);
384 + }
385 +
386 + if ($interval === Status::BILLING_DAILY) {
387 + return $fromTs + DAY_IN_SECONDS;
388 + }
389 +
390 + if ($interval === Status::BILLING_WEEKLY) {
391 + return $fromTs + (7 * DAY_IN_SECONDS);
392 + }
393 +
394 + return $fromTs + (PaymentHelper::getIntervalDays($interval) * DAY_IN_SECONDS);
395 + }
396 +
397 + /**
398 + * Advance by interval × period, then re-apply the anchor: week cycles snap
399 + * forward to the anchor weekday, month/year cycles keep the anchor day
400 + * clamped into short months (anchor 31 bills Feb 28, back to the 31st the
401 + * month after). Always moves at least one day forward.
402 + */
403 + private static function addSchedulePeriod($fromTs, array $schedule)
404 + {
405 + $n = max(1, (int) Arr::get($schedule, 'interval', 1));
406 + $anchor = is_array(Arr::get($schedule, 'anchor')) ? $schedule['anchor'] : [];
407 + $hour = (int) gmdate('H', $fromTs);
408 + $min = (int) gmdate('i', $fromTs);
409 + $sec = (int) gmdate('s', $fromTs);
410 +
411 + switch (Arr::get($schedule, 'period')) {
412 + case 'day':
413 + return $fromTs + ($n * DAY_IN_SECONDS);
414 +
415 + case 'week':
416 + $ts = $fromTs + ($n * 7 * DAY_IN_SECONDS);
417 + $weekday = (int) Arr::get($anchor, 'weekday', 0);
418 +
419 + if ($weekday >= 1 && $weekday <= 7) {
420 + $ts += ((($weekday - (int) gmdate('N', $ts)) + 7) % 7) * DAY_IN_SECONDS;
421 + }
422 +
423 + return $ts;
424 +
425 + case 'month':
426 + $year = (int) gmdate('Y', $fromTs);
427 + $month = (int) gmdate('n', $fromTs) + $n;
428 + $anchorDay = (int) Arr::get($anchor, 'day', 0) ?: (int) gmdate('j', $fromTs);
429 +
430 + $firstOfTarget = gmmktime($hour, $min, $sec, $month, 1, $year);
431 + $day = min($anchorDay, (int) gmdate('t', $firstOfTarget));
432 +
433 + return gmmktime($hour, $min, $sec, $month, $day, $year);
434 +
435 + case 'year':
436 + $year = (int) gmdate('Y', $fromTs) + $n;
437 + $anchorMonth = (int) Arr::get($anchor, 'month', 0) ?: (int) gmdate('n', $fromTs);
438 + $anchorDay = (int) Arr::get($anchor, 'day', 0) ?: (int) gmdate('j', $fromTs);
439 +
440 + $firstOfTarget = gmmktime($hour, $min, $sec, $anchorMonth, 1, $year);
441 + $day = min($anchorDay, (int) gmdate('t', $firstOfTarget));
442 +
443 + return gmmktime($hour, $min, $sec, $anchorMonth, $day, $year);
444 + }
445 +
446 + return $fromTs + DAY_IN_SECONDS;
200 447 }
201 448
202 449 }