PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / Core / AbstractPaymentGateway.php

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

604 lines 21.9 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 'mode' => $subscription->order ? $subscription->order->mode : 'current',
330 ]);
331 if (is_wp_error($cancelResult)) {
332 fluent_cart_error_log(
333 'Failed to cancel vendor subscription during conversion to manual',
334 $cancelResult->get_error_message()
335 );
336 return;
337 }
338 }
339 }
340
341 $subscription->collection_method = 'manual';
342 $subscription->current_payment_method = $this->getMeta('route');
343 $subscription->vendor_subscription_id = null;
344 $subscription->save();
345
346 $subscription->addLog(
347 'Converted to manual billing',
348 sprintf('Subscription converted from automatic to manual — paid via %s', $this->getMeta('label')),
349 'warning'
350 );
351
352 do_action('fluent_cart/subscription_converted_to_manual', [
353 'subscription' => $subscription,
354 'payment_method' => $this->getMeta('route'),
355 ]);
356 }
357
358 public function validatePaymentMethod($data)
359 {
360 $isZeroPayment = Arr::get($data, 'isZeroPayment', false);
361 if (!$this->isEnabled() && !$isZeroPayment) {
362 return [
363 'isValid' => false,
364 'reason' => sprintf(
365 /* translators: %s is the payment method name */
366 __('Selected payment method %s is not active!', 'fluent-cart'),
367 $this->getMeta('route')
368 )
369 ];
370 }
371
372 return [
373 'isValid' => true,
374 ];
375 }
376
377 public function updateOrderDataByOrder($order, $transactionData, $transaction)
378 {
379 if ($order == null) {
380 return;
381 }
382
383 $transaction->fill($transactionData);
384 $transaction->save();
385
386
387 $paymentStatus = Status::syncPaymentStatus(Arr::get($transactionData, 'status'));
388 $orderStatus = !in_array($paymentStatus, [Status::TRANSACTION_SUCCEEDED, Status::PAYMENT_PAID]) ? $paymentStatus : Status::ORDER_PROCESSING;
389
390 $statusHelper = (new StatusHelper())->setOrder($order);
391 $statusHelper->updateTransactionData($transactionData, $transaction);
392
393 if ($amount = Arr::get($transactionData, 'total')) {
394 $statusHelper->updateTotalPaid($amount);
395 }
396
397 $statusHelper->changeOrderStatus($orderStatus, $paymentStatus, $this->getMeta('title'), $this->getMeta('slug'));
398
399 //If product is digital and processing then trigger status to completed
400 if ($order->fulfillment_type == 'digital' && $orderStatus === Status::ORDER_PROCESSING && $order->total_amount <= $order->total_paid) {
401 $statusHelper->changeOrderStatus(Status::ORDER_COMPLETED, $paymentStatus, $this->getMeta('title'), $this->getMeta('slug'));
402 }
403
404 do_action('fluent_cart/payments/after_payment_' . $paymentStatus, [
405 'order' => $order
406 ]);
407 }
408
409 public function getCheckoutItems(): array
410 {
411 return (CartCheckoutHelper::make())->getItems();
412 }
413
414 public function getSettings(): BaseGatewaySettings
415 {
416 return $this->settings;
417 }
418
419 public function renderStoreModeNotice(): string
420 {
421 if ((new StoreSettings())->get('order_mode') == 'test') {
422 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>';
423 }
424 return '<div class="mt-5"><span class="text-success-500">' . __('Your Store is in Live mode', 'fluent-cart') . '</span></div>';
425 }
426
427 public function beforeRenderPaymentMethod($hasSubscription): void
428 {
429 $this->enqueue($hasSubscription);
430 }
431
432 public function getEnqueueVersion()
433 {
434 return FLUENTCART_VERSION;
435 }
436
437 public function getEnqueueScriptSrc($hasSubscription): array
438 {
439 return [];
440 }
441
442 public function getEnqueueStyleSrc(): array
443 {
444 return [];
445 }
446
447 public function getTransactionUrl($url, $data)
448 {
449 return $url;
450 }
451
452 public function getSubscriptionUrl($url, $data)
453 {
454 return $url;
455 }
456
457 public function processRefund($transaction, $amount, $args)
458 {
459 return new \WP_Error('not_implemented', __('Refund process is not implemented for this payment gateway.', 'fluent-cart'));
460 }
461
462 public function syncRemoteTransaction(OrderTransaction $transaction)
463 {
464 return new \WP_Error('not_implemented', __('Remote transaction sync is not available for this payment method.', 'fluent-cart'));
465 }
466
467 public function enqueue($hasSubscription): void
468 {
469 $styles = $this->getEnqueueStyleSrc();
470 $scripts = $this->getEnqueueScriptSrc($hasSubscription);
471
472 foreach ($styles as $style) {
473 wp_enqueue_style(
474 Arr::get($style, 'handle'),
475 Arr::get($style, 'src'),
476 Arr::get($style, 'deps', null),
477 Arr::get($style, 'version', $this->getEnqueueVersion())
478 );
479 }
480
481 $handleToEnqueue = '';
482 $scriptHandles = [];
483 foreach ($scripts as $script) {
484 $handle = Arr::get($script, 'handle');
485 if (empty($handleToEnqueue)) {
486 $handleToEnqueue = $handle;
487 }
488 $scriptHandles[] = $handle;
489 wp_enqueue_script(
490 $handle,
491 Arr::get($script, 'src'),
492 Arr::get($script, 'deps', null),
493 Arr::get($script, 'version', $this->getEnqueueVersion()),
494 Arr::get($script, 'in_footer', false),
495 );
496 }
497
498 // Add filter to prevent consent plugins from blocking payment gateway scripts
499 if (!empty($scriptHandles) && is_array($scriptHandles)) {
500 $gatewayInstance = $this;
501
502 add_filter('script_loader_tag', function($tag, $handle, $src) use ($scriptHandles, $gatewayInstance) {
503
504 if (!in_array($handle, $scriptHandles, true)) {
505 return $tag;
506 }
507
508 // This makes payment scripts load as "necessary" cookies
509 $attributes = [
510 'data-category="necessary"',
511 'data-consent-category="necessary"',
512 'data-cookieconsent="ignore"',
513 'data-no-optimize="1"',
514 'data-cfasync="false"',
515 ];
516
517 $serviceName = '';
518
519 if (is_object($gatewayInstance)) {
520 if (method_exists($gatewayInstance, 'getConsentServiceName')) {
521 $serviceName = (string) $gatewayInstance->getConsentServiceName();
522 }
523
524 if (empty($serviceName) && method_exists($gatewayInstance, 'getMeta')) {
525 $serviceName = (string) $gatewayInstance->getMeta('title');
526 }
527 }
528
529 if (!empty($serviceName)) {
530 $serviceName = esc_attr($serviceName);
531 $attributes[] = 'data-usercentrics="' . $serviceName . '"';
532 $attributes[] = 'data-service="' . $serviceName . '"';
533 }
534
535 $tag = preg_replace(
536 '/<script(\s+)/',
537 '<script$1' . implode(' ', $attributes) . ' ',
538 $tag,
539 1
540 );
541
542 return $tag;
543 }, 10, 3);
544 }
545
546 if (!empty($handleToEnqueue)) {
547 foreach ($this->getLocalizeData() as $key => $val) {
548 wp_localize_script($handleToEnqueue, $key, $val);
549 }
550 }
551
552
553 }
554
555 public function prepare($mode, $hasSubscription)
556 {
557 $this->beforeRenderPaymentMethod($hasSubscription);
558 $this->render($mode);
559 $route = $this->getMeta('route');
560 do_action('fluent_cart/after_render_payment_method_' . $route);
561 }
562
563 public function render($mode = 'logo')
564 {
565 $content = '';
566 if ($mode === 'logo') {
567 $content .= '<img src="' . esc_url($this->getMeta('logo') ?? '') . '"alt="' . esc_attr($this->getMeta('title')) . '"/>';
568 } elseif ($mode === 'radio') {
569 $content .= '<span class="checkmark">' . '</span>';
570 } else {
571 $content .= '<span>' . $this->getMeta('title') . '</span>';
572 }
573 echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
574 }
575
576 public function getLocalizeData(): array
577 {
578 // $example = [
579 // 'var_name' => [
580 // 'key' => 'value'
581 // ],
582 //
583 // 'var_name_two' => [
584 // 'key' => 'value'
585 // ],
586 // ];
587 return [];
588 }
589
590 /**
591 * Get the consent service name for this payment gateway
592 * Used by consent management plugins (Usercentrics, CookieYes, etc.)
593 * Override in child classes to specify the service name
594 *
595 * @return string|null Service name or null to use gateway slug
596 */
597 public function getConsentServiceName(): ?string
598 {
599 // By default, return null and let the system use gateway meta title
600 return null;
601 }
602
603 }
604