| @@ -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 | } |