PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
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.2, at app/Services/Payments/SubscriptionHelper.php

392 lines 13.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\Payments;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Models\Order;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Services\DateTime\DateTime;
11 use FluentCart\Framework\Support\Arr;
12
13 class SubscriptionHelper
14 {
15 /*
16 * @param $subscriptionModel
17 * @return string|null
18 *
19 * */
20 public static function getNextBillingDate(Subscription $subscriptionModel)
21 {
22 // only null case
23 if ($subscriptionModel->status === Status::SUBSCRIPTION_COMPLETED || ($subscriptionModel->bill_times > 0 && $subscriptionModel->bill_count >= $subscriptionModel->bill_times)) {
24 return null;
25 }
26
27 // assuming on expired we update the canceled_at, removes this comment when verified
28 if ($subscriptionModel->status === Status::SUBSCRIPTION_CANCELED || $subscriptionModel->status === Status::SUBSCRIPTION_EXPIRED) {
29 return $subscriptionModel->canceled_at;
30 }
31
32 // Trial handling
33 if ($subscriptionModel->bill_count == 0 && !empty($subscriptionModel->trial_days)) {
34 if (!empty($subscriptionModel->trial_ends_at)) {
35 return $subscriptionModel->trial_ends_at;
36 }
37 return gmdate('Y-m-d H:i:s', strtotime($subscriptionModel->created_at . " +{$subscriptionModel->trial_days} days"));
38 }
39
40 if (!empty($subscriptionModel->next_billing_date) && strtotime($subscriptionModel->next_billing_date) > time()) {
41 return $subscriptionModel->next_billing_date;
42 }
43
44
45 if ($subscriptionModel->bill_count == 0) {
46 $baseDate = $subscriptionModel->created_at;
47
48 } elseif (!empty($subscriptionModel->next_billing_date) && strtotime($subscriptionModel->next_billing_date) < time()) {
49 $baseDate = $subscriptionModel->next_billing_date;
50 } else {
51 $baseDate = DateTime::gmtNow()->format('Y-m-d H:i:s');
52 }
53
54 return gmdate('Y-m-d H:i:s', self::addBillingInterval(
55 $baseDate,
56 strtolower($subscriptionModel->billing_interval),
57 self::getBillingSchedule($subscriptionModel)
58 ));
59 }
60
61 /*
62 * @param $trialDays
63 * @param $billTimes
64 * @param $interval
65 *
66 * */
67 public static function getSubscriptionCancelAtTimeStamp($trialDays, $billTimes, $interval)
68 {
69 if (!$billTimes && !$trialDays) {
70 return null;
71 }
72
73 // Use the passed arguments instead of accessing non-existent $this->subscription
74 if ($interval == 'daily') {
75 $interval = 'day';
76 }
77
78 $interValMaps = [
79 'day' => 'days',
80 'weekly' => 'weeks',
81 'monthly' => 'months',
82 'yearly' => 'years'
83 ];
84
85 if (isset($interValMaps[$interval]) && $billTimes > 0) {
86 $interval = $interValMaps[$interval];
87 }
88
89 $timestamp = strtotime('+ ' . $billTimes . ' ' . $interval);
90
91 // Add trial days if provided
92 if ($trialDays > 0) {
93 $timestamp = $timestamp + $trialDays * 24 * 60 * 60; // Add trial days in seconds
94 }
95
96 return $timestamp;
97 }
98
99
100 // can be used to catch 1 day trial loop-hole
101 public static function checkTrailDaysLoopHole($subscription, $trialDays)
102 {
103 $billCount = Arr::get($subscription, 'bill_count');
104 $billingInterval = Arr::get($subscription, 'billing_interval');
105 $billingIntervalInDays = 0;
106 switch ($billingInterval) {
107 case 'monthly':
108 $billingIntervalInDays = 30;
109 break;
110 case 'quarterly':
111 $billingIntervalInDays = 90;
112 break;
113 case 'half_yearly':
114 $billingIntervalInDays = 182;
115 break;
116 case 'yearly':
117 $billingIntervalInDays = 365;
118 break;
119 case 'weekly':
120 $billingIntervalInDays = 7;
121 break;
122 case 'daily':
123 $billingIntervalInDays = 1;
124 break;
125 }
126
127 // get the days from now to the created at date - original trial days,
128 $daysSinceCreated = ceil(ceil((time() - strtotime($subscription->created_at)) / 86400)) - intval($subscription->trial_days);
129 $expectedBillCount = floor($daysSinceCreated / $billingIntervalInDays);
130
131 if ($expectedBillCount > $billCount) {
132 $trialDays = 0;
133 }
134
135 return $trialDays;
136 }
137
138 /**
139 * Safely convert a date string or Unix timestamp to a GMT datetime string.
140 * Returns null when the value is falsy, zero, or a negative timestamp
141 * (guards against strtotime() returning false or a year-0 negative value).
142 *
143 * @param string|int|null $value
144 * @return string|null
145 */
146 public static function safeTimestampToDatetime($value): ?string
147 {
148 if (!$value) {
149 return null;
150 }
151 $ts = is_numeric($value) ? (int) $value : strtotime($value);
152 if (!$ts || $ts <= 0) {
153 return null;
154 }
155 return gmdate('Y-m-d H:i:s', $ts);
156 }
157
158 public static function getSubscriptionsGracePeriodDays()
159 {
160 $defaults = [
161 'daily' => 1,
162 'weekly' => 3,
163 'monthly' => 7,
164 'quarterly' => 15,
165 'half_yearly' => 15,
166 'yearly' => 15,
167 ];
168
169 $gracePeriods = apply_filters('fluent_cart/subscription/grace_period_days', $defaults);
170
171 if (!is_array($gracePeriods)) {
172 $gracePeriods = [];
173 }
174
175 foreach ($defaults as $interval => $defaultDays) {
176 $days = $gracePeriods[$interval] ?? $defaultDays;
177 $gracePeriods[$interval] = is_numeric($days) ? max(0, (int) $days) : $defaultDays;
178 }
179
180 return array_intersect_key($gracePeriods, $defaults);
181 }
182
183 /**
184 * Grace period (days past due before expiry) for a billing interval, resolved
185 * from the per-interval grace map. Defaults to 7 for unknown intervals.
186 */
187 public static function getGracePeriodDaysForInterval(string $interval): int
188 {
189 $map = self::getSubscriptionsGracePeriodDays();
190
191 foreach ($map as $key => $days) {
192 if (strpos($interval, $key) !== false) {
193 return (int) $days;
194 }
195 }
196
197 return 7;
198 }
199
200 /**
201 * Custom billing schedule stored in the subscription's config, or null.
202 *
203 * Shape: ['period' => day|week|month|year, 'interval' => N, 'anchor' => []].
204 * Written by migrators for cadences the billing_interval enum cannot express
205 * (every 2 weeks, every 4 months) and for calendar-synced billing (fixed day
206 * of week / day of month / month of year). When present it overrides the
207 * slug in addBillingInterval(); the slug itself stays a native enum value
208 * (the schedule's base period) so validation, grace periods, and the UI keep
209 * working — and so a site without config support bills the base period
210 * rather than daily.
211 *
212 * Anchor keys: week → weekday (ISO 1-7); month → day (1-31, 31 = last day
213 * of month); year → day + month.
214 *
215 * @return array|null
216 */
217 public static function getBillingSchedule(Subscription $subscription)
218 {
219 $config = $subscription->config;
220 $schedule = is_array($config) ? Arr::get($config, 'billing_schedule') : null;
221
222 if (!is_array($schedule)) {
223 return null;
224 }
225
226 $period = Arr::get($schedule, 'period');
227
228 if (!in_array($period, ['day', 'week', 'month', 'year'], true)) {
229 return null;
230 }
231
232 return [
233 'period' => $period,
234 'interval' => max(1, (int) Arr::get($schedule, 'interval', 1)),
235 'anchor' => self::sanitizeScheduleAnchor($period, Arr::get($schedule, 'anchor')),
236 ];
237 }
238
239 /**
240 * Keep only anchor keys valid for the period and inside calendar range.
241 * gmmktime() silently renormalizes out-of-range values (month 15 rolls
242 * into the next year, day -3 into the previous month), so a corrupt
243 * anchor value must be dropped — addSchedulePeriod() then falls back to
244 * the current date part, keeping the cycle length correct.
245 */
246 private static function sanitizeScheduleAnchor($period, $anchor)
247 {
248 if (!is_array($anchor)) {
249 return [];
250 }
251
252 $clean = [];
253
254 if ($period === 'week') {
255 $weekday = (int) Arr::get($anchor, 'weekday');
256 if ($weekday >= 1 && $weekday <= 7) {
257 $clean['weekday'] = $weekday;
258 }
259 }
260
261 if ($period === 'month' || $period === 'year') {
262 $day = (int) Arr::get($anchor, 'day');
263 if ($day >= 1 && $day <= 31) {
264 $clean['day'] = $day;
265 }
266 }
267
268 if ($period === 'year') {
269 $month = (int) Arr::get($anchor, 'month');
270 if ($month >= 1 && $month <= 12) {
271 $clean['month'] = $month;
272 }
273 }
274
275 return $clean;
276 }
277
278 /**
279 * Advance a GMT datetime by one whole billing cycle, calendar-accurate.
280 *
281 * Month-based intervals keep the day-of-month, clamping into shorter target
282 * months (Jan 31 + monthly = Feb 28/29, then back to the 31st the cycle
283 * after) — a flat day count (monthly = 30 days) walks a subscription's
284 * billing day backwards roughly five days a year. Day/week intervals are
285 * exact multiples already. Unknown intervals keep the day-count contract of
286 * PaymentHelper::getIntervalDays() and its
287 * `fluent_cart/subscription_interval_in_days` filter, including its
288 * zero-progress edge (a filter returning 0 advances nothing, as before).
289 *
290 * When $schedule (see getBillingSchedule()) is given it wins over the slug:
291 * the cycle is interval × period with the anchor re-applied, so a migrated
292 * every-2-weeks-on-Friday subscription stays on Fridays even after a late
293 * payment rebases the cycle.
294 *
295 * @param string|int $fromDate GMT datetime string or UTC timestamp
296 * @param string $interval billing_interval slug
297 * @param array|null $schedule config-defined schedule, overrides $interval
298 * @return int advanced UTC timestamp
299 */
300 public static function addBillingInterval($fromDate, $interval, $schedule = null)
301 {
302 $fromTs = is_numeric($fromDate) ? (int) $fromDate : (int) strtotime($fromDate);
303
304 if (is_array($schedule) && !empty($schedule['period'])) {
305 return self::addSchedulePeriod($fromTs, $schedule);
306 }
307
308 $monthsMap = [
309 Status::BILLING_MONTHLY => 1,
310 Status::BILLING_QUARTERLY => 3,
311 Status::BILLING_HALF_YEARLY => 6,
312 Status::BILLING_YEARLY => 12,
313 ];
314
315 if (isset($monthsMap[$interval])) {
316 $hour = (int) gmdate('H', $fromTs);
317 $min = (int) gmdate('i', $fromTs);
318 $sec = (int) gmdate('s', $fromTs);
319 $year = (int) gmdate('Y', $fromTs);
320 $month = (int) gmdate('n', $fromTs) + $monthsMap[$interval];
321
322 $firstOfTarget = gmmktime($hour, $min, $sec, $month, 1, $year);
323 $day = min((int) gmdate('j', $fromTs), (int) gmdate('t', $firstOfTarget));
324
325 return gmmktime($hour, $min, $sec, $month, $day, $year);
326 }
327
328 if ($interval === Status::BILLING_DAILY) {
329 return $fromTs + DAY_IN_SECONDS;
330 }
331
332 if ($interval === Status::BILLING_WEEKLY) {
333 return $fromTs + (7 * DAY_IN_SECONDS);
334 }
335
336 return $fromTs + (PaymentHelper::getIntervalDays($interval) * DAY_IN_SECONDS);
337 }
338
339 /**
340 * Advance by interval × period, then re-apply the anchor: week cycles snap
341 * forward to the anchor weekday, month/year cycles keep the anchor day
342 * clamped into short months (anchor 31 bills Feb 28, back to the 31st the
343 * month after). Always moves at least one day forward.
344 */
345 private static function addSchedulePeriod($fromTs, array $schedule)
346 {
347 $n = max(1, (int) Arr::get($schedule, 'interval', 1));
348 $anchor = is_array(Arr::get($schedule, 'anchor')) ? $schedule['anchor'] : [];
349 $hour = (int) gmdate('H', $fromTs);
350 $min = (int) gmdate('i', $fromTs);
351 $sec = (int) gmdate('s', $fromTs);
352
353 switch (Arr::get($schedule, 'period')) {
354 case 'day':
355 return $fromTs + ($n * DAY_IN_SECONDS);
356
357 case 'week':
358 $ts = $fromTs + ($n * 7 * DAY_IN_SECONDS);
359 $weekday = (int) Arr::get($anchor, 'weekday', 0);
360
361 if ($weekday >= 1 && $weekday <= 7) {
362 $ts += ((($weekday - (int) gmdate('N', $ts)) + 7) % 7) * DAY_IN_SECONDS;
363 }
364
365 return $ts;
366
367 case 'month':
368 $year = (int) gmdate('Y', $fromTs);
369 $month = (int) gmdate('n', $fromTs) + $n;
370 $anchorDay = (int) Arr::get($anchor, 'day', 0) ?: (int) gmdate('j', $fromTs);
371
372 $firstOfTarget = gmmktime($hour, $min, $sec, $month, 1, $year);
373 $day = min($anchorDay, (int) gmdate('t', $firstOfTarget));
374
375 return gmmktime($hour, $min, $sec, $month, $day, $year);
376
377 case 'year':
378 $year = (int) gmdate('Y', $fromTs) + $n;
379 $anchorMonth = (int) Arr::get($anchor, 'month', 0) ?: (int) gmdate('n', $fromTs);
380 $anchorDay = (int) Arr::get($anchor, 'day', 0) ?: (int) gmdate('j', $fromTs);
381
382 $firstOfTarget = gmmktime($hour, $min, $sec, $anchorMonth, 1, $year);
383 $day = min($anchorDay, (int) gmdate('t', $firstOfTarget));
384
385 return gmmktime($hour, $min, $sec, $anchorMonth, $day, $year);
386 }
387
388 return $fromTs + DAY_IN_SECONDS;
389 }
390
391 }
392