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 / PaymentMethods / Core / AbstractPaymentGateway.php

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

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