PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
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 / Modules / PaymentMethods / Core / AbstractPaymentGateway.php

AbstractPaymentGateway.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.0, at app/Modules/PaymentMethods/Core/AbstractPaymentGateway.php

593 lines 21.6 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\PaymentMethods\Core;
4
5 use FluentCart\Api\Helper;
6 use FluentCart\Api\Orders;
7 use FluentCart\Api\Resource\FrontendResource\CartResource;
8 use FluentCart\Api\StoreSettings;
9 use FluentCart\App\App;
10 use FluentCart\App\Helpers\CartCheckoutHelper;
11 use FluentCart\App\Helpers\Status;
12 use FluentCart\App\Helpers\StatusHelper;
13 use FluentCart\App\Models\OrderTransaction;
14 use FluentCart\Api\Resource\ActivityResource;
15 use FluentCart\App\Services\Payments\PaymentHelper;
16 use FluentCart\App\Services\Renderer\Receipt\ReceiptRenderer;
17 use FluentCart\Framework\Support\Arr;
18
19 abstract class AbstractPaymentGateway implements PaymentGatewayInterface
20 {
21
22 private $methodSlug = '';
23
24 public array $supportedFeatures = [];
25
26 public StoreSettings $storeSettings;
27
28 public ?AbstractSubscriptionModule $subscriptions;
29
30 public BaseGatewaySettings $settings;
31
32 public function __construct(BaseGatewaySettings $settings, ?AbstractSubscriptionModule $subscriptions = null)
33 {
34 $this->settings = $settings;
35 $this->methodSlug = $this->getMeta('slug');
36
37 if ($subscriptions) {
38 $this->supportedFeatures[] = 'subscriptions';
39 }
40 $this->subscriptions = $subscriptions;
41
42 // register global hooks
43 $this->init();
44 }
45
46 public function init(): void
47 {
48 add_filter('fluent_cart/transaction/url_' . $this->methodSlug, [$this, 'getTransactionUrl'], 10, 2);
49 add_filter('fluent_cart/subscription/url_' . $this->methodSlug, [$this, 'getSubscriptionUrl'], 10, 2);
50 }
51
52 public function has(string $feature): bool
53 {
54 return in_array($feature, $this->supportedFeatures);
55 }
56
57 public function getMeta($key = '')
58 {
59 $meta = $this->meta();
60
61 $gatewaySettings = $this->settings->get();
62
63 if (isset($gatewaySettings['checkout_label']) && !empty($gatewaySettings['checkout_label'])) {
64 $meta['title'] = $gatewaySettings['checkout_label'];
65 }
66
67 if (isset($gatewaySettings['checkout_logo']) && !empty($gatewaySettings['checkout_logo'])) {
68 $meta['logo'] = $gatewaySettings['checkout_logo'];
69 }
70
71 if (isset($gatewaySettings['checkout_instructions']) && !empty($gatewaySettings['checkout_instructions'])) {
72 $meta['instructions'] = $gatewaySettings['checkout_instructions'];
73 }
74
75 if ($key !== '') {
76 return Arr::get($meta, $key, '');
77 }
78 return $meta;
79 }
80
81 public function isUpcoming(): bool
82 {
83 return $this->getMeta('upcoming');
84 }
85
86 public function setStoreSettings(StoreSettings $settings): void
87 {
88 $this->storeSettings = $settings;
89 }
90
91 public function storeSettings(): StoreSettings
92 {
93 return $this->storeSettings;
94 }
95
96 public function isCurrencySupported(): bool
97 {
98 return true;
99 }
100
101 public function isEnabled(): bool
102 {
103 return $this->settings->get('is_active') === 'yes';
104 }
105
106 public static function validateSettings($data): array
107 {
108 return $data;
109 }
110
111 public static function beforeSettingsUpdate($data, $oldSettings): array
112 {
113 return $data;
114 }
115
116 public function updateSettings($data)
117 {
118 if ($this->isUpcoming()) {
119 wp_send_json([
120 'status' => 'failed',
121 'message' => __('Payment method is upcoming! Not available for right now!', 'fluent-cart')
122 ], 422);
123 }
124
125 $oldSettings = $this->settings->get();
126 $settings = wp_parse_args($data, $oldSettings);
127 $settings = Helper::sanitize($settings, $this->fields());
128 $is_active = Arr::get($settings, 'is_active', 'no');
129 // validate if the settings/credentials are correct
130 if ('yes' === $is_active) {
131 $response = static::validateSettings($settings);
132 if (isset($response['status']) && $response['status'] === 'failed') {
133 wp_send_json(
134 [
135 'status' => 'failed',
136 'message' => $response['message'] ? $response['message'] : __('Invalid credentials!', 'fluent-cart'),
137 'data' => []
138 ],
139 422
140 );
141 }
142 }
143
144 $settings = static::beforeSettingsUpdate($settings, $oldSettings);
145 // unset($settings['payment_mode']);
146 unset($settings['provider']);
147 fluent_cart_update_option($this->settings->methodHandler, $settings);
148
149 return $settings;
150 }
151
152 public function getSuccessUrl($transaction, $args = [])
153 {
154 $paymentHelper = new PaymentHelper($this->getMeta('route'));
155 return $paymentHelper->successUrl($transaction->uuid, $args);
156 }
157
158 public static function getCancelUrl(): string
159 {
160 $checkoutPage = (new StoreSettings())->getCheckoutPage();
161 // get cart hash from url
162 $cartHash = App::request()->get('fct_cart_hash', '');
163 if ($cartHash) {
164 return add_query_arg([
165 'fct_cart_hash' => $cartHash
166 ], $checkoutPage);
167 }
168 return $checkoutPage;
169 }
170
171 public function paymentFailedNote($content, $data)
172 {
173 $request = Arr::get($data, 'request');
174 $trx_hash = $request->getSafe('trx_hash', 'sanitize_text_field');
175 $transaction = OrderTransaction::query()->where('uuid', $trx_hash)->first();
176 if (!$transaction) {
177 return __('Transaction not found!', 'fluent-cart');
178 }
179
180 $order = (new Orders())->getById($transaction->order_id);
181
182 if (!$order || $transaction->status === Status::TRANSACTION_SUCCEEDED) {
183 return '';
184 }
185
186 $failedtitle = __('Payment Failed', 'fluent-cart');
187 $hasLog = ActivityResource::getQuery()->where('module_id', $order->id)
188 ->where('module_name', 'Order')
189 ->where('status', 'error')
190 ->where('title', $failedtitle)
191 ->count();
192
193 if (!$hasLog) {
194 $content = 'Payment Failed Reason: ' . $request->getSafe('reason', 'sanitize_text_field');
195 fluent_cart_error_log($failedtitle, $content, [
196 'module_id' => $order->id,
197 'module_name' => 'Order'
198 ]);
199 }
200
201 ob_start();
202 (new ReceiptRenderer())->renderConfirmationError([
203 'order' => $order,
204 'failed_reason' => $request->getSafe('reason', 'sanitize_text_field'),
205 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid)
206 ]);
207 return ob_get_clean();
208 }
209
210 protected function getListenerUrl($args = null)
211 {
212 return (new PaymentHelper($this->getMeta('route')))->listenerUrl($args);
213 }
214
215 public function getOrderByHash($orderHash)
216 {
217 return (new Orders())->getByHash($orderHash);
218 }
219
220 public function validateSubscriptions($items): bool
221 {
222 return (new CartResource())->hasSubscriptionProduct($items);
223 }
224
225 /**
226 * Whether this gateway can save a payment method WITHOUT an accompanying
227 * charge (SetupIntent-style), enabling `system` subscriptions on carts with
228 * nothing payable now (free trials). Gateways declaring `system_subscription`
229 * SHOULD override this when their API supports zero-amount setup.
230 */
231 public function supportsSetupWithoutCharge(): bool
232 {
233 return false;
234 }
235
236 /**
237 * Charge a system (auto-charged, store-billed) subscription's renewal invoice
238 * off-session using the stored token. Gateways declaring the
239 * `system_subscription` capability MUST override this. A successful charge must
240 * flow through the gateway's normal charge-confirmation path (so
241 * syncOrderStatuses / handleRenewalPaid run). Return WP_Error on failure.
242 *
243 * @param \FluentCart\App\Services\Payments\PaymentInstance $paymentInstance
244 * @param array $args ['attempt' => int] — attempt number, used for idempotency keys
245 * @return true|'processing'|\WP_Error true = payment confirmed; 'processing' =
246 * charge accepted, the gateway webhook will
247 * confirm it (invoice stays scheduled)
248 */
249 public function chargeRenewal(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance, $args = [])
250 {
251 return new \WP_Error('not_supported', __('This payment method cannot charge saved payment methods.', 'fluent-cart'));
252 }
253
254 /**
255 * Re-check an async (processing) renewal charge against the gateway. Called by
256 * the SystemChargeService reconciliation loop when a charge was accepted but
257 * the confirming webhook has not arrived. A settled payment must be confirmed
258 * through the gateway's normal charge-confirmation path before returning true.
259 *
260 * @param \FluentCart\App\Services\Payments\PaymentInstance $paymentInstance
261 * @return true|'processing'|\WP_Error true = settled and confirmed;
262 * 'processing' = still settling, check again
263 * later; WP_Error = definitively failed
264 */
265 public function reconcileRenewalCharge(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance)
266 {
267 return new \WP_Error('not_supported', __('This payment method cannot reconcile pending charges.', 'fluent-cart'));
268 }
269
270 /**
271 * Store-managed subscription mode: a manual subscription's payment — the first
272 * order or a renewal invoice — must be taken as a plain one-time charge. No
273 * vendor subscription may be created and no manual→automatic conversion may
274 * happen. Every gateway with a subscription branch in
275 * makePaymentFromPaymentInstance() must consult this BEFORE its conversion /
276 * vendor-subscription logic and route to its single-payment path when true.
277 */
278 protected function shouldChargeSubscriptionAsOneTime(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance): bool
279 {
280 $subscription = $paymentInstance->subscription;
281
282 // Manual and system subscriptions are both store-billed — any interactive
283 // payment against them (first order or renewal invoice) is a one-time charge.
284 if (!$subscription || !in_array($subscription->collection_method, ['manual', 'system'], true)) {
285 return false;
286 }
287
288 // Stamped at creation: a subscription born under store-managed mode must
289 // never be converted to automatic billing, even after the merchant switches
290 // the store setting back to gateway-managed.
291 if (\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::isSubscriptionStoreManaged($subscription)) {
292 return true;
293 }
294
295 // Unstamped manual subscriptions (gateway-managed fallback / pre-feature)
296 // charge one-time only while the store is currently store-managed; under
297 // gateway-managed they keep today's manual→automatic conversion behavior.
298 return \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::isStoreManaged();
299 }
300
301 //technically not reachable , as conversion to manual subscription is not possible as of now.
302 protected function maybeConvertToManualSubscription(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance): void
303 {
304 $subscription = $paymentInstance->subscription;
305 // Manual AND system are already store-billed — nothing to convert. Without
306 // the system check, a COD payment against a system renewal invoice would
307 // silently downgrade the subscription to manual and disable auto-charging.
308 if (!$subscription || in_array($subscription->collection_method, ['manual', 'system'], true)) {
309 return;
310 }
311
312 if ($subscription->vendor_subscription_id) {
313 $oldGateway = App::gateway($subscription->current_payment_method);
314 if ($oldGateway && $oldGateway->has('subscriptions') && $oldGateway->subscriptions) {
315 $cancelResult = $oldGateway->subscriptions->cancel($subscription->vendor_subscription_id, [
316 'subscription_id' => $subscription->id,
317 'parent_order_id' => $subscription->parent_order_id,
318 ]);
319 if (is_wp_error($cancelResult)) {
320 fluent_cart_error_log(
321 'Failed to cancel vendor subscription during conversion to manual',
322 $cancelResult->get_error_message()
323 );
324 return;
325 }
326 }
327 }
328
329 $subscription->collection_method = 'manual';
330 $subscription->current_payment_method = $this->getMeta('route');
331 $subscription->vendor_subscription_id = null;
332 $subscription->save();
333
334 $subscription->addLog(
335 'Converted to manual billing',
336 sprintf('Subscription converted from automatic to manual — paid via %s', $this->getMeta('label')),
337 'warning'
338 );
339
340 do_action('fluent_cart/subscription_converted_to_manual', [
341 'subscription' => $subscription,
342 'payment_method' => $this->getMeta('route'),
343 ]);
344 }
345
346 public function validatePaymentMethod($data)
347 {
348 $isZeroPayment = Arr::get($data, 'isZeroPayment', false);
349 if (!$this->isEnabled() && !$isZeroPayment) {
350 return [
351 'isValid' => false,
352 'reason' => sprintf(
353 /* translators: %s is the payment method name */
354 __('Selected payment method %s is not active!', 'fluent-cart'),
355 $this->getMeta('route')
356 )
357 ];
358 }
359
360 return [
361 'isValid' => true,
362 ];
363 }
364
365 public function updateOrderDataByOrder($order, $transactionData, $transaction)
366 {
367 if ($order == null) {
368 return;
369 }
370
371 $transaction->fill($transactionData);
372 $transaction->save();
373
374
375 $paymentStatus = Status::syncPaymentStatus(Arr::get($transactionData, 'status'));
376 $orderStatus = !in_array($paymentStatus, [Status::TRANSACTION_SUCCEEDED, Status::PAYMENT_PAID]) ? $paymentStatus : Status::ORDER_PROCESSING;
377
378 $statusHelper = (new StatusHelper())->setOrder($order);
379 $statusHelper->updateTransactionData($transactionData, $transaction);
380
381 if ($amount = Arr::get($transactionData, 'total')) {
382 $statusHelper->updateTotalPaid($amount);
383 }
384
385 $statusHelper->changeOrderStatus($orderStatus, $paymentStatus, $this->getMeta('title'), $this->getMeta('slug'));
386
387 //If product is digital and processing then trigger status to completed
388 if ($order->fulfillment_type == 'digital' && $orderStatus === Status::ORDER_PROCESSING && $order->total_amount <= $order->total_paid) {
389 $statusHelper->changeOrderStatus(Status::ORDER_COMPLETED, $paymentStatus, $this->getMeta('title'), $this->getMeta('slug'));
390 }
391
392 do_action('fluent_cart/payments/after_payment_' . $paymentStatus, [
393 'order' => $order
394 ]);
395 }
396
397 public function getCheckoutItems(): array
398 {
399 return (CartCheckoutHelper::make())->getItems();
400 }
401
402 public function getSettings(): BaseGatewaySettings
403 {
404 return $this->settings;
405 }
406
407 public function renderStoreModeNotice(): string
408 {
409 if ((new StoreSettings())->get('order_mode') == 'test') {
410 return '<div class="mt-5"><span class="text-warning-500">' . __('Your Store is in test mode, change Store\'s \'Order Mode\' to \'Live\' and update related settings to enable live payment!', 'fluent-cart') . '</span></div>';
411 }
412 return '<div class="mt-5"><span class="text-success-500">' . __('Your Store is in Live mode', 'fluent-cart') . '</span></div>';
413 }
414
415 public function beforeRenderPaymentMethod($hasSubscription): void
416 {
417 $this->enqueue($hasSubscription);
418 }
419
420 public function getEnqueueVersion()
421 {
422 return FLUENTCART_VERSION;
423 }
424
425 public function getEnqueueScriptSrc($hasSubscription): array
426 {
427 return [];
428 }
429
430 public function getEnqueueStyleSrc(): array
431 {
432 return [];
433 }
434
435 public function getTransactionUrl($url, $data)
436 {
437 return $url;
438 }
439
440 public function getSubscriptionUrl($url, $data)
441 {
442 return $url;
443 }
444
445 public function processRefund($transaction, $amount, $args)
446 {
447 return new \WP_Error('not_implemented', __('Refund process is not implemented for this payment gateway.', 'fluent-cart'));
448 }
449
450 public function syncRemoteTransaction(OrderTransaction $transaction)
451 {
452 return new \WP_Error('not_implemented', __('Remote transaction sync is not available for this payment method.', 'fluent-cart'));
453 }
454
455 public function enqueue($hasSubscription): void
456 {
457 $styles = $this->getEnqueueStyleSrc();
458 $scripts = $this->getEnqueueScriptSrc($hasSubscription);
459
460 foreach ($styles as $style) {
461 wp_enqueue_style(
462 Arr::get($style, 'handle'),
463 Arr::get($style, 'src'),
464 Arr::get($style, 'deps', null),
465 Arr::get($style, 'version', $this->getEnqueueVersion())
466 );
467 }
468
469 $handleToEnqueue = '';
470 $scriptHandles = [];
471 foreach ($scripts as $script) {
472 $handle = Arr::get($script, 'handle');
473 if (empty($handleToEnqueue)) {
474 $handleToEnqueue = $handle;
475 }
476 $scriptHandles[] = $handle;
477 wp_enqueue_script(
478 $handle,
479 Arr::get($script, 'src'),
480 Arr::get($script, 'deps', null),
481 Arr::get($script, 'version', $this->getEnqueueVersion()),
482 Arr::get($script, 'in_footer', false),
483 );
484 }
485
486 // Add filter to prevent consent plugins from blocking payment gateway scripts
487 if (!empty($scriptHandles) && is_array($scriptHandles)) {
488 $gatewayInstance = $this;
489
490 add_filter('script_loader_tag', function($tag, $handle, $src) use ($scriptHandles, $gatewayInstance) {
491
492 if (!in_array($handle, $scriptHandles, true)) {
493 return $tag;
494 }
495
496 // This makes payment scripts load as "necessary" cookies
497 $attributes = [
498 'data-category="necessary"',
499 'data-consent-category="necessary"',
500 'data-cookieconsent="ignore"',
501 'data-no-optimize="1"',
502 'data-cfasync="false"',
503 ];
504
505 $serviceName = '';
506
507 if (is_object($gatewayInstance)) {
508 if (method_exists($gatewayInstance, 'getConsentServiceName')) {
509 $serviceName = (string) $gatewayInstance->getConsentServiceName();
510 }
511
512 if (empty($serviceName) && method_exists($gatewayInstance, 'getMeta')) {
513 $serviceName = (string) $gatewayInstance->getMeta('title');
514 }
515 }
516
517 if (!empty($serviceName)) {
518 $serviceName = esc_attr($serviceName);
519 $attributes[] = 'data-usercentrics="' . $serviceName . '"';
520 $attributes[] = 'data-service="' . $serviceName . '"';
521 }
522
523 $tag = preg_replace(
524 '/<script(\s+)/',
525 '<script$1' . implode(' ', $attributes) . ' ',
526 $tag,
527 1
528 );
529
530 return $tag;
531 }, 10, 3);
532 }
533
534 if (!empty($handleToEnqueue)) {
535 foreach ($this->getLocalizeData() as $key => $val) {
536 wp_localize_script($handleToEnqueue, $key, $val);
537 }
538 }
539
540
541 }
542
543 public function prepare($mode, $hasSubscription)
544 {
545 $this->beforeRenderPaymentMethod($hasSubscription);
546 $this->render($mode);
547 $route = $this->getMeta('route');
548 do_action_deprecated('fluent-cart/after_render_payment_method_' . $route, [], '1.3.16', 'fluent_cart/after_render_payment_method_' . $route, 'Use fluent_cart/after_render_payment_method_' . $route . ' instead of fluent-cart/after_render_payment_method_' . $route . '. It will be removed in v1.4.3.');
549 do_action('fluent_cart/after_render_payment_method_' . $route);
550 }
551
552 public function render($mode = 'logo')
553 {
554 $content = '';
555 if ($mode === 'logo') {
556 $content .= '<img src="' . esc_url($this->getMeta('logo') ?? '') . '"alt="' . esc_attr($this->getMeta('title')) . '"/>';
557 } elseif ($mode === 'radio') {
558 $content .= '<span class="checkmark">' . '</span>';
559 } else {
560 $content .= '<span>' . $this->getMeta('title') . '</span>';
561 }
562 echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
563 }
564
565 public function getLocalizeData(): array
566 {
567 // $example = [
568 // 'var_name' => [
569 // 'key' => 'value'
570 // ],
571 //
572 // 'var_name_two' => [
573 // 'key' => 'value'
574 // ],
575 // ];
576 return [];
577 }
578
579 /**
580 * Get the consent service name for this payment gateway
581 * Used by consent management plugins (Usercentrics, CookieYes, etc.)
582 * Override in child classes to specify the service name
583 *
584 * @return string|null Service name or null to use gateway slug
585 */
586 public function getConsentServiceName(): ?string
587 {
588 // By default, return null and let the system use gateway meta title
589 return null;
590 }
591
592 }
593