PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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
← All changes | app/Modules/Subscriptions/Services/SystemChargeService.php +53 -0 1.6.1 → 1.6.6 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);