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

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