← All changes
|
app/Modules/Subscriptions/Services/SystemChargeService.php
+53
-0
1.6.3
→
1.6.5
View file →
| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | use FluentCart\App\Models\Order; |
| 8 | 8 | use FluentCart\App\Models\OrderTransaction; |
| 9 | 9 | use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway; |
| 10 | 10 | use FluentCart\App\Services\Payments\PaymentInstance; |
| 11 | +use FluentCart\Api\StoreSettings; | |
| 11 | 12 | use FluentCart\App\Services\Payments\SubscriptionHelper; |
| 12 | 13 | use FluentCart\Framework\Support\Arr; |
| 13 | 14 | |
| 14 | 15 | /** |
| @@ -340,8 +341,17 @@ | ||
| 340 | 341 | if (!self::isSystemBillingEnabled()) { |
| 341 | 342 | return new \WP_Error('system_billing_disabled', __('Automatic charging is disabled.', 'fluent-cart')); |
| 342 | 343 | } |
| 343 | 344 | |
| 345 | + if (!SubscriptionHelper::canProcessInMode($invoice->mode)) { | |
| 346 | + return new \WP_Error('store_mode_mismatch', sprintf( | |
| 347 | + /* translators: 1: the invoice's payment mode (live/test), 2: the store's current mode (live/test) */ | |
| 348 | + __('This invoice is in %1$s mode but the store is currently in %2$s mode. Switch the store mode to charge it.', 'fluent-cart'), | |
| 349 | + $invoice->mode, | |
| 350 | + (new StoreSettings())->get('order_mode') | |
| 351 | + )); | |
| 352 | + } | |
| 353 | + | |
| 344 | 354 | if ($invoice->type !== Status::ORDER_TYPE_RENEWAL |
| 345 | 355 | || !in_array($invoice->payment_status, [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED], true) |
| 346 | 356 | ) { |
| 347 | 357 | return new \WP_Error('invalid_invoice', __('Only an open (pending or scheduled) renewal order can be charged.', 'fluent-cart')); |
| @@ -481,8 +491,9 @@ | ||
| 481 | 491 | if (!self::isSystemBillingEnabled()) { |
| 482 | 492 | return; |
| 483 | 493 | } |
| 484 | 494 | |
| 495 | + /** @var Order|null $order */ | |
| 485 | 496 | $order = Order::query()->find($orderId); |
| 486 | 497 | |
| 487 | 498 | if (!$order || $order->type !== Status::ORDER_TYPE_RENEWAL) { |
| 488 | 499 | return; |
| @@ -515,8 +526,35 @@ | ||
| 515 | 526 | ); |
| 516 | 527 | return; |
| 517 | 528 | } |
| 518 | 529 | |
| 530 | + // Invoice mode vs store mode at fire time — a store flipped to test (or a | |
| 531 | + // clone left in test mode) must not charge a live invoice. Hold, don't | |
| 532 | + // fail: re-arm a daily re-check so the charge fires once modes match | |
| 533 | + // again (or the subscription_mode_guard setting is turned off). | |
| 534 | + if (!SubscriptionHelper::canProcessInMode($order->mode)) { | |
| 535 | + if (function_exists('as_schedule_single_action')) { | |
| 536 | + // No as_next_scheduled_action() dedup needed here (unlike | |
| 537 | + // scheduleCharge): the firing action is already consumed, so | |
| 538 | + // this is the only pending copy. | |
| 539 | + as_schedule_single_action(time() + DAY_IN_SECONDS, self::HOOK, [$order->id, $attempt], self::SCHEDULER_GROUP); | |
| 540 | + } | |
| 541 | + | |
| 542 | + // Log the transition into held once, not on every daily re-check — | |
| 543 | + // a long-lived clone would otherwise grow fct_activity unbounded. | |
| 544 | + if (!$order->getMeta('mode_guard_hold_logged')) { | |
| 545 | + $order->updateMeta('mode_guard_hold_logged', 'yes'); | |
| 546 | + $subscription->addLog( | |
| 547 | + 'Automatic charge held', | |
| 548 | + sprintf('Scheduled charge for renewal order #%s held — the invoice is in %s mode but the store is in %s mode. Will re-check daily.', $order->invoice_no ?: $order->id, $order->mode, (new StoreSettings())->get('order_mode')), | |
| 549 | + 'warning' | |
| 550 | + ); | |
| 551 | + } | |
| 552 | + return; | |
| 553 | + } | |
| 554 | + | |
| 555 | + $order->deleteMeta('mode_guard_hold_logged'); | |
| 556 | + | |
| 519 | 557 | // Capability re-check at fire time: the gateway may have been deactivated |
| 520 | 558 | // or removed since the subscription was created. |
| 521 | 559 | $gateway = App::gateway($subscription->current_payment_method); |
| 522 | 560 | if (!$gateway instanceof AbstractPaymentGateway || !$gateway->has('system_subscription')) { |
| @@ -626,8 +664,23 @@ | ||
| 626 | 664 | |
| 627 | 665 | $chargeState = $subscription->getMeta('system_charge_state', []) ?: []; |
| 628 | 666 | |
| 629 | 667 | if (Arr::get($chargeState, 'status') !== 'processing' || (int) Arr::get($chargeState, 'order_id') !== (int) $order->id) { |
| 668 | + return; | |
| 669 | + } | |
| 670 | + | |
| 671 | + // Invoice mode vs store mode — same clone risk as the billing pause | |
| 672 | + // above: retrieving a live PaymentIntent from a test-mode clone would | |
| 673 | + // settle the copied order and fire renewal-paid side effects. Re-arm | |
| 674 | + // (budget untouched) so reconciliation resumes once modes match. | |
| 675 | + if (!SubscriptionHelper::canProcessInMode($order->mode)) { | |
| 676 | + if (function_exists('as_schedule_single_action') | |
| 677 | + && function_exists('as_next_scheduled_action') | |
| 678 | + && !as_next_scheduled_action(self::RECONCILE_HOOK, [(int) $orderId], self::SCHEDULER_GROUP) | |
| 679 | + ) { | |
| 680 | + as_schedule_single_action(time() + self::RECONCILE_INTERVAL, self::RECONCILE_HOOK, [(int) $orderId], self::SCHEDULER_GROUP); | |
| 681 | + } | |
| 682 | + | |
| 630 | 683 | return; |
| 631 | 684 | } |
| 632 | 685 | |
| 633 | 686 | $attempt = (int) Arr::get($chargeState, 'attempts', 1); |