PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 trunk 1.2.0 All 47 releases
fluent-cart / app / Modules / Subscriptions / Services / SubscriptionGatewayGate.php

SubscriptionGatewayGate.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/Subscriptions/Services/SubscriptionGatewayGate.php

299 lines 11.5 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\Modules\Subscriptions\Services;
4
5 use FluentCart\Api\PaymentMethods;
6 use FluentCart\App\Models\Order;
7 use FluentCart\App\Services\OrderService;
8 use FluentCart\App\Services\URL;
9 use FluentCart\Framework\Support\Arr;
10
11 /**
12 * Checkout gateway visibility for every subscription cart shape: a new
13 * subscription, a renewal invoice, and a reactivation.
14 *
15 * Two rules, one per cart shape:
16 * - New subscription: the store setting decides. `store_managed` admits only
17 * gateways the store can bill itself; `gateway_managed` admits only gateways
18 * that own a schedule, unless the Manual Fallback setting is on.
19 * - Renewal/reactivation: the subscription decides — its `collection_method`
20 * plus how it was BORN (the `management_mode` stamp). A gateway-managed
21 * `automatic` sub keeps its vendor schedule; everything else is store-billed.
22 *
23 * Full decision tables: dev-docs/subscription-engine/payment-rendering-and-conversion-guide.md
24 */
25 class SubscriptionGatewayGate
26 {
27 public function register()
28 {
29 add_filter('fluent_cart/checkout_active_payment_methods', [$this, 'filterCheckoutPaymentMethods'], 10, 2);
30 // add_action('fluent_cart/before_payment_methods', [$this, 'renderSubscriptionGatewayNotice']);
31 }
32
33 public function filterCheckoutPaymentMethods($methods, $data)
34 {
35 $cart = Arr::get($data, 'cart');
36
37 if (!$cart || !$cart->hasSubscription()) {
38 return $methods;
39 }
40
41 $checkoutData = (array) $cart->checkout_data;
42
43 if (Arr::get($checkoutData, 'renewal_order')) {
44 return $this->gatewaysForExistingSubscription(
45 $methods,
46 SubscriptionManagementMode::resolveRenewalSubscription($cart),
47 $cart
48 );
49 }
50
51 if (Arr::get($checkoutData, 'renew_data.subscription_hash')) {
52 return $this->gatewaysForExistingSubscription(
53 $methods,
54 SubscriptionManagementMode::resolveReactivationSubscription($cart),
55 $cart
56 );
57 }
58
59 return $this->gatewaysForNewSubscription($methods, $cart);
60 }
61
62 private function gatewaysForNewSubscription($methods, $cart)
63 {
64 $zeroPayable = self::getPayableNow($cart) <= 0;
65
66 if (SubscriptionManagementMode::isStoreManaged()) {
67 return self::storeBilledOnly($methods, $zeroPayable, SubscriptionManagementMode::isSystemChargeEnabled());
68 }
69
70 if (self::manualFallbackOnGatewayManage()) {
71 return $zeroPayable ? self::zeroFirstPaymentCapable($methods) : $methods;
72 }
73
74 return self::subscriptionCapableOnly($methods);
75 }
76
77 /**
78 * $subscription is null when the cart names one that can't be resolved — the
79 * charge itself will fail later with `no_subscription`, so nothing is gated.
80 */
81 private function gatewaysForExistingSubscription($methods, $subscription, $cart)
82 {
83 if (!$subscription) {
84 return $methods;
85 }
86
87 $storeManaged = SubscriptionManagementMode::isStoreManaged();
88 $collectionMethod = $subscription->collection_method;
89
90 if (!$storeManaged && $collectionMethod === 'automatic') {
91 // Keeps its vendor schedule; a one-time gateway would strand it.
92 $eligible = self::subscriptionCapableOnly($methods);
93 } elseif (!$storeManaged
94 && $collectionMethod === 'manual'
95 && !SubscriptionManagementMode::isSubscriptionStoreManaged($subscription)) {
96 // Born gateway-managed and still unstamped: paying via a
97 // subscription-capable gateway converts it to automatic, anything else
98 // leaves it billing manually forever — opt-in only. Except before the
99 // due date, where converting would cost the customer days (see
100 // isAdvanceRenewal()).
101 if (self::isAdvanceRenewal($cart)) {
102 $eligible = self::oneTimePaymentOnly($methods);
103 } else {
104 $eligible = self::manualFallbackOnGatewayManage()
105 ? $methods
106 : self::subscriptionCapableOnly($methods);
107 }
108 } else {
109 // A `system` sub is auto-charged whatever the store setting says today.
110 $autoChargeable = $collectionMethod === 'system'
111 || SubscriptionManagementMode::isSystemChargeEnabled();
112
113 $eligible = self::storeBilledOnly($methods, self::getPayableNow($cart) <= 0, $autoChargeable);
114 }
115
116 return self::currentMethodFirst($eligible, $subscription);
117 }
118
119 private static function subscriptionCapableOnly($methods)
120 {
121 return array_filter($methods, function ($gateway) {
122 return $gateway->has('subscriptions');
123 });
124 }
125
126 private static function oneTimePaymentOnly($methods)
127 {
128 return array_filter($methods, function ($gateway) {
129 return !$gateway->has('subscriptions');
130 });
131 }
132
133 /**
134 * Renewal invoices are created days ahead of their due date. Paying one early
135 * through a subscription-capable gateway converts the subscription to automatic
136 * (Stripe.php / PayPal.php) and the vendor schedule starts from TODAY — the days
137 * between now and the due date are forfeited. Store-billed payment keeps the
138 * cadence instead: handleRenewalPaid() anchors the next date to due_date.
139 *
140 * Only gateway-managed carts can convert, so only they need this guard.
141 */
142 private static function isAdvanceRenewal($cart): bool
143 {
144 if (!$cart || !$cart->order_id) {
145 return false;
146 }
147
148 $order = Order::query()->find($cart->order_id);
149
150 if (!$order) {
151 return false;
152 }
153
154 $dueDate = $order->getMeta('due_date');
155
156 return $dueDate && strtotime($dueDate) > time();
157 }
158
159 /** Nothing due today: only a gateway that can start the schedule at zero. */
160 private static function zeroFirstPaymentCapable($methods)
161 {
162 return array_filter($methods, function ($gateway) {
163 return $gateway->has('subscriptions') || $gateway->has('offline');
164 });
165 }
166
167 /**
168 * One-time gateways, plus subscription-capable ones that opt into store
169 * billing (`manual_subscription`) — shouldChargeSubscriptionAsOneTime() routes
170 * both to a single payment, so no vendor schedule is created. `manual_subscription`
171 * only claims a one-time-charge path exists; it says nothing about being able to
172 * vault a card without charging it, so it plays no part in the zero-payable case.
173 * With nothing due today only a gateway that can vault without charging AND is
174 * wired for later auto-charge (`system_subscription`) qualifies.
175 */
176 private static function storeBilledOnly($methods, $zeroPayable, $autoChargeable)
177 {
178 return array_filter($methods, function ($gateway) use ($zeroPayable, $autoChargeable) {
179 if ($zeroPayable) {
180 return $gateway->has('offline')
181 || ($autoChargeable && $gateway->has('system_subscription') && $gateway->supportsSetupWithoutCharge());
182 }
183
184 return $gateway->has('offline')
185 || !$gateway->has('subscriptions')
186 || $gateway->has('manual_subscription');
187 });
188 }
189
190 private static function currentMethodFirst($methods, $subscription)
191 {
192 $current = $subscription->current_payment_method;
193
194 if (!$current || !isset($methods[$current])) {
195 return $methods;
196 }
197
198 return array_merge([$current => $methods[$current]], $methods);
199 }
200
201 private static function manualFallbackOnGatewayManage(): bool
202 {
203 return SubscriptionManagementMode::isManualFallbackEnabled();
204 }
205
206 /**
207 * Admin-only notice on new-subscription carts explaining which gateways were
208 * hidden and why. Renewal/reactivation carts don't get it — they follow the
209 * subscription, not the store setting.
210 */
211 public function renderSubscriptionGatewayNotice($data)
212 {
213 $cart = Arr::get((array) $data, 'cart');
214
215 if (!$this->isNewSubscriptionCart($cart)) {
216 return;
217 }
218
219 $storeManaged = SubscriptionManagementMode::isStoreManaged();
220 $zeroPayable = self::getPayableNow($cart) <= 0;
221 $manualFallback = !$storeManaged && self::manualFallbackOnGatewayManage();
222
223 if ($manualFallback && !$zeroPayable) {
224 return;
225 }
226
227 if ($zeroPayable) {
228 echo '<div class="fct-alert fct_zero_payment_notice">'
229 . esc_html__('No payment is due today. Future payments for your subscription will be collected using the payment method you select.', 'fluent-cart')
230 . '</div>';
231 }
232
233 if (!current_user_can('manage_options')) {
234 return;
235 }
236
237 $allMethods = PaymentMethods::getActiveMethodInstance($cart);
238 $eligible = $this->gatewaysForNewSubscription($allMethods, $cart);
239
240 $hiddenTitles = [];
241 foreach ($allMethods as $gateway) {
242 if (!in_array($gateway, $eligible, true)) {
243 $hiddenTitles[] = $gateway->getMeta('title') ?: $gateway->getMeta('route');
244 }
245 }
246
247 if (!$hiddenTitles) {
248 return;
249 }
250
251 if ($zeroPayable) {
252 $reason = __('the first payment of this subscription is zero and they cannot process a zero-amount checkout', 'fluent-cart');
253 } elseif ($storeManaged) {
254 $reason = __('they don\'t yet support taking a subscription\'s first payment as a plain one-time charge under store-managed billing', 'fluent-cart');
255 } else {
256 $reason = __('they create their own subscription schedule, which would bill alongside the renewals FluentCart generates', 'fluent-cart');
257 }
258
259 if ($storeManaged) {
260 $hint = $zeroPayable
261 ? __('Enable Automatic Charge in the subscription settings and use a gateway that can save a payment method without charging it, or keep offline methods active.', 'fluent-cart')
262 : __('These payment methods do not yet support store-managed billing.', 'fluent-cart');
263 } else {
264 $hint = __('Under gateway-managed billing, only gateways with native subscription support can appear here. Enable Manual Fallback in the subscription settings to also allow non-subscription gateways (including offline methods) to fall back to manual billing.', 'fluent-cart');
265 }
266
267 /* translators: %1$s: comma-separated payment method names, %2$s: why they were hidden, %3$s: guidance on how to offer more payment methods */
268 $adminText = sprintf(
269 __('Admin note (only visible to you): %1$s hidden because %2$s. %3$s', 'fluent-cart'),
270 implode(', ', $hiddenTitles),
271 $reason,
272 $hint
273 );
274
275 echo '<div class="fct-alert fct_zero_payment_admin_notice">'
276 . esc_html($adminText)
277 . ' <a href="' . esc_url(URL::getDashboardUrl('settings/payments')) . '" target="_blank">'
278 . esc_html__('Payment settings', 'fluent-cart')
279 . '</a></div>';
280 }
281
282 private function isNewSubscriptionCart($cart): bool
283 {
284 if (!$cart || !$cart->hasSubscription()) {
285 return false;
286 }
287
288 $checkoutData = (array) $cart->checkout_data;
289
290 return !Arr::get($checkoutData, 'renewal_order')
291 && !Arr::get($checkoutData, 'renew_data.subscription_hash');
292 }
293
294 private static function getPayableNow($cart)
295 {
296 return OrderService::getItemsAmountTotal($cart->cart_data ?? [], false, false);
297 }
298 }
299