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.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 / Payments / SubscriptionHelper.php

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

450 lines 16.1 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\Payments;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\Order;
10 use FluentCart\App\Models\OrderTransaction;
11 use FluentCart\App\Models\Subscription;
12 use FluentCart\App\Services\DateTime\DateTime;
13 use FluentCart\Framework\Support\Arr;
14
15 class SubscriptionHelper
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
51 /*
52 * @param $subscriptionModel
53 * @return string|null
54 *
55 * */
56 public static function getNextBillingDate(Subscription $subscriptionModel)
57 {
58 // only null case
59 if ($subscriptionModel->status === Status::SUBSCRIPTION_COMPLETED || ($subscriptionModel->bill_times > 0 && $subscriptionModel->bill_count >= $subscriptionModel->bill_times)) {
60 return null;
61 }
62
63 // assuming on expired we update the canceled_at, removes this comment when verified
64 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) {
65 return $subscriptionModel->canceled_at;
66 }
67
68 // Trial handling
69 if ($subscriptionModel->bill_count == 0 && !empty($subscriptionModel->trial_days)) {
70 if (!empty($subscriptionModel->trial_ends_at)) {
71 return $subscriptionModel->trial_ends_at;
72 }
73 return gmdate('Y-m-d H:i:s', strtotime($subscriptionModel->created_at . " +{$subscriptionModel->trial_days} days"));
74 }
75
76 if (!empty($subscriptionModel->next_billing_date) && strtotime($subscriptionModel->next_billing_date) > time()) {
77 return $subscriptionModel->next_billing_date;
78 }
79
80
81 if ($subscriptionModel->bill_count == 0) {
82 $parentOrder = $subscriptionModel->order;
83 $baseDate = $parentOrder ? self::resolvePaidAnchor($parentOrder) : $subscriptionModel->created_at;
84
85 } elseif (!empty($subscriptionModel->next_billing_date) && strtotime($subscriptionModel->next_billing_date) < time()) {
86 $baseDate = $subscriptionModel->next_billing_date;
87 } else {
88 $baseDate = DateTime::gmtNow()->format('Y-m-d H:i:s');
89 }
90
91 return gmdate('Y-m-d H:i:s', self::addBillingInterval(
92 $baseDate,
93 strtolower($subscriptionModel->billing_interval),
94 self::getBillingSchedule($subscriptionModel)
95 ));
96 }
97
98 /*
99 * @param $trialDays
100 * @param $billTimes
101 * @param $interval
102 *
103 * */
104 public static function getSubscriptionCancelAtTimeStamp($trialDays, $billTimes, $interval)
105 {
106 if (!$billTimes && !$trialDays) {
107 return null;
108 }
109
110 // Use the passed arguments instead of accessing non-existent $this->subscription
111 if ($interval == 'daily') {
112 $interval = 'day';
113 }
114
115 $interValMaps = [
116 'day' => 'days',
117 'weekly' => 'weeks',
118 'monthly' => 'months',
119 'yearly' => 'years'
120 ];
121
122 if (isset($interValMaps[$interval]) && $billTimes > 0) {
123 $interval = $interValMaps[$interval];
124 }
125
126 $timestamp = strtotime('+ ' . $billTimes . ' ' . $interval);
127
128 // Add trial days if provided
129 if ($trialDays > 0) {
130 $timestamp = $timestamp + $trialDays * 24 * 60 * 60; // Add trial days in seconds
131 }
132
133 return $timestamp;
134 }
135
136
137 // can be used to catch 1 day trial loop-hole
138 public static function checkTrailDaysLoopHole($subscription, $trialDays)
139 {
140 $billCount = Arr::get($subscription, 'bill_count');
141 $billingInterval = Arr::get($subscription, 'billing_interval');
142 $billingIntervalInDays = 0;
143 switch ($billingInterval) {
144 case 'monthly':
145 $billingIntervalInDays = 30;
146 break;
147 case 'quarterly':
148 $billingIntervalInDays = 90;
149 break;
150 case 'half_yearly':
151 $billingIntervalInDays = 182;
152 break;
153 case 'yearly':
154 $billingIntervalInDays = 365;
155 break;
156 case 'weekly':
157 $billingIntervalInDays = 7;
158 break;
159 case 'daily':
160 $billingIntervalInDays = 1;
161 break;
162 }
163
164 // get the days from now to the created at date - original trial days,
165 $daysSinceCreated = ceil(ceil((time() - strtotime($subscription->created_at)) / 86400)) - intval($subscription->trial_days);
166 $expectedBillCount = floor($daysSinceCreated / $billingIntervalInDays);
167
168 if ($expectedBillCount > $billCount) {
169 $trialDays = 0;
170 }
171
172 return $trialDays;
173 }
174
175 /**
176 * Safely convert a date string or Unix timestamp to a GMT datetime string.
177 * Returns null when the value is falsy, zero, or a negative timestamp
178 * (guards against strtotime() returning false or a year-0 negative value).
179 *
180 * @param string|int|null $value
181 * @return string|null
182 */
183 public static function safeTimestampToDatetime($value): ?string
184 {
185 if (!$value) {
186 return null;
187 }
188 $ts = is_numeric($value) ? (int) $value : strtotime($value);
189 if (!$ts || $ts <= 0) {
190 return null;
191 }
192 return gmdate('Y-m-d H:i:s', $ts);
193 }
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
216 public static function getSubscriptionsGracePeriodDays()
217 {
218 $defaults = [
219 'daily' => 1,
220 'weekly' => 3,
221 'monthly' => 7,
222 'quarterly' => 15,
223 'half_yearly' => 15,
224 'yearly' => 15,
225 ];
226
227 $gracePeriods = apply_filters('fluent_cart/subscription/grace_period_days', $defaults);
228
229 if (!is_array($gracePeriods)) {
230 $gracePeriods = [];
231 }
232
233 foreach ($defaults as $interval => $defaultDays) {
234 $days = $gracePeriods[$interval] ?? $defaultDays;
235 $gracePeriods[$interval] = is_numeric($days) ? max(0, (int) $days) : $defaultDays;
236 }
237
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;
447 }
448
449 }
450