settings = $settings; $this->methodSlug = $this->getMeta('slug'); if ($subscriptions) { $this->supportedFeatures[] = 'subscriptions'; } $this->subscriptions = $subscriptions; // register global hooks $this->init(); } public function init(): void { add_filter('fluent_cart/transaction/url_' . $this->methodSlug, [$this, 'getTransactionUrl'], 10, 2); add_filter('fluent_cart/subscription/url_' . $this->methodSlug, [$this, 'getSubscriptionUrl'], 10, 2); } public function has(string $feature): bool { return in_array($feature, $this->supportedFeatures); } public function getMeta($key = '') { $meta = $this->meta(); $gatewaySettings = $this->settings->get(); if (isset($gatewaySettings['checkout_label']) && !empty($gatewaySettings['checkout_label'])) { $meta['title'] = $gatewaySettings['checkout_label']; } if (isset($gatewaySettings['checkout_logo']) && !empty($gatewaySettings['checkout_logo'])) { $meta['logo'] = $gatewaySettings['checkout_logo']; } if (!empty($gatewaySettings['checkout_instructions'])) { $instructionsText = trim(wp_strip_all_tags(html_entity_decode($gatewaySettings['checkout_instructions'], ENT_QUOTES))); // Nbsp survives strip_tags/html_entity_decode as a literal // non-breaking space, so a rich-text editor can save "

 

" // and still look non-empty to a bare !empty() check. $instructionsText = str_replace("\xC2\xA0", '', $instructionsText); if (trim($instructionsText) !== '') { $meta['instructions'] = $gatewaySettings['checkout_instructions']; } } if ($key !== '') { return Arr::get($meta, $key, ''); } return $meta; } public function isUpcoming(): bool { return $this->getMeta('upcoming'); } public function setStoreSettings(StoreSettings $settings): void { $this->storeSettings = $settings; } public function storeSettings(): StoreSettings { return $this->storeSettings; } public function isCurrencySupported(): bool { return true; } public function isEnabled(): bool { return $this->settings->get('is_active') === 'yes'; } public static function validateSettings($data): array { return $data; } public static function beforeSettingsUpdate($data, $oldSettings): array { return $data; } public function updateSettings($data) { if ($this->isUpcoming()) { wp_send_json([ 'status' => 'failed', 'message' => __('Payment method is upcoming! Not available for right now!', 'fluent-cart') ], 422); } $oldSettings = $this->settings->get(); $settings = wp_parse_args($data, $oldSettings); $settings = Helper::sanitize($settings, $this->fields()); $is_active = Arr::get($settings, 'is_active', 'no'); // validate if the settings/credentials are correct if ('yes' === $is_active) { $response = static::validateSettings($settings); if (isset($response['status']) && $response['status'] === 'failed') { wp_send_json( [ 'status' => 'failed', 'message' => $response['message'] ? $response['message'] : __('Invalid credentials!', 'fluent-cart'), 'data' => [] ], 422 ); } } $settings = static::beforeSettingsUpdate($settings, $oldSettings); // unset($settings['payment_mode']); unset($settings['provider']); fluent_cart_update_option($this->settings->methodHandler, $settings); return $settings; } /** * Back-compat wrapper — the canonical entry point is * OrderTransaction::getSuccessUrl(); call that directly. */ public function getSuccessUrl($transaction, $args = []) { return $transaction->getSuccessUrl($args); } public static function getCancelUrl(): string { $checkoutPage = (new StoreSettings())->getCheckoutPage(); // get cart hash from url $cartHash = App::request()->get('fct_cart_hash', ''); if ($cartHash) { return add_query_arg([ 'fct_cart_hash' => $cartHash ], $checkoutPage); } return $checkoutPage; } public function paymentFailedNote($content, $data) { $request = Arr::get($data, 'request'); $trx_hash = $request->getSafe('trx_hash', 'sanitize_text_field'); $transaction = OrderTransaction::query()->where('uuid', $trx_hash)->first(); if (!$transaction) { return __('Transaction not found!', 'fluent-cart'); } $order = (new Orders())->getById($transaction->order_id); if (!$order || $transaction->status === Status::TRANSACTION_SUCCEEDED) { return ''; } $failedtitle = __('Payment Failed', 'fluent-cart'); $hasLog = ActivityResource::getQuery()->where('module_id', $order->id) ->where('module_name', 'Order') ->where('status', 'error') ->where('title', $failedtitle) ->count(); if (!$hasLog) { $content = 'Payment Failed Reason: ' . $request->getSafe('reason', 'sanitize_text_field'); fluent_cart_error_log($failedtitle, $content, [ 'module_id' => $order->id, 'module_name' => 'Order' ]); } ob_start(); (new ReceiptRenderer())->renderConfirmationError([ 'order' => $order, 'failed_reason' => $request->getSafe('reason', 'sanitize_text_field'), 'custom_payment_url' => PaymentHelper::getCustomPaymentLink($order->uuid) ]); return ob_get_clean(); } protected function getListenerUrl($args = null) { return (new PaymentHelper($this->getMeta('route')))->listenerUrl($args); } public function getOrderByHash($orderHash) { return (new Orders())->getByHash($orderHash); } public function validateSubscriptions($items): bool { return (new CartResource())->hasSubscriptionProduct($items); } /** * Whether this gateway can save a payment method WITHOUT an accompanying * charge (SetupIntent-style), enabling `system` subscriptions on carts with * nothing payable now (free trials). Gateways declaring `system_subscription` * SHOULD override this when their API supports zero-amount setup. */ public function supportsSetupWithoutCharge(): bool { return false; } /** * Charge a system (auto-charged, store-billed) subscription's renewal invoice * off-session using the stored token. Gateways declaring the * `system_subscription` capability MUST override this. A successful charge must * flow through the gateway's normal charge-confirmation path (so * syncOrderStatuses / handleRenewalPaid run). Return WP_Error on failure. * * @param \FluentCart\App\Services\Payments\PaymentInstance $paymentInstance * @param array $args ['attempt' => int] — attempt number, used for idempotency keys * @return true|'processing'|\WP_Error true = payment confirmed; 'processing' = * charge accepted, the gateway webhook will * confirm it (invoice stays scheduled) */ public function chargeRenewal(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance, $args = []) { return new \WP_Error('not_supported', __('This payment method cannot charge saved payment methods.', 'fluent-cart')); } /** * Re-check an async (processing) renewal charge against the gateway. Called by * the SystemChargeService reconciliation loop when a charge was accepted but * the confirming webhook has not arrived. A settled payment must be confirmed * through the gateway's normal charge-confirmation path before returning true. * * @param \FluentCart\App\Services\Payments\PaymentInstance $paymentInstance * @return true|'processing'|\WP_Error true = settled and confirmed; * 'processing' = still settling, check again * later; WP_Error = definitively failed */ public function reconcileRenewalCharge(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance) { return new \WP_Error('not_supported', __('This payment method cannot reconcile pending charges.', 'fluent-cart')); } /** * Store-managed subscription mode: a manual subscription's payment — the first * order or a renewal invoice — must be taken as a plain one-time charge. No * vendor subscription may be created and no manual→automatic conversion may * happen. Every gateway with a subscription branch in * makePaymentFromPaymentInstance() must consult this BEFORE its conversion / * vendor-subscription logic and route to its single-payment path when true. */ protected function shouldChargeSubscriptionAsOneTime(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance): bool { $subscription = $paymentInstance->subscription; // Manual and system subscriptions are both store-billed — any interactive // payment against them (first order or renewal invoice) is a one-time charge. if (!$subscription || !in_array($subscription->collection_method, ['manual', 'system'], true)) { return false; } // Stamped at creation: a subscription born under store-managed mode must // never be converted to automatic billing, even after the merchant switches // the store setting back to gateway-managed. if (\FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::isSubscriptionStoreManaged($subscription)) { return true; } // Unstamped manual subscriptions (gateway-managed fallback / pre-feature) // charge one-time only while the store is currently store-managed; under // gateway-managed they keep today's manual→automatic conversion behavior. return \FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode::isStoreManaged(); } //technically not reachable , as conversion to manual subscription is not possible as of now. protected function maybeConvertToManualSubscription(\FluentCart\App\Services\Payments\PaymentInstance $paymentInstance): void { $subscription = $paymentInstance->subscription; // Manual AND system are already store-billed — nothing to convert. Without // the system check, a COD payment against a system renewal invoice would // silently downgrade the subscription to manual and disable auto-charging. if (!$subscription || in_array($subscription->collection_method, ['manual', 'system'], true)) { return; } if ($subscription->vendor_subscription_id) { $oldGateway = App::gateway($subscription->current_payment_method); if ($oldGateway && $oldGateway->has('subscriptions') && $oldGateway->subscriptions) { $cancelResult = $oldGateway->subscriptions->cancel($subscription->vendor_subscription_id, [ 'subscription_id' => $subscription->id, 'parent_order_id' => $subscription->parent_order_id, ]); if (is_wp_error($cancelResult)) { fluent_cart_error_log( 'Failed to cancel vendor subscription during conversion to manual', $cancelResult->get_error_message() ); return; } } } $subscription->collection_method = 'manual'; $subscription->current_payment_method = $this->getMeta('route'); $subscription->vendor_subscription_id = null; $subscription->save(); $subscription->addLog( 'Converted to manual billing', sprintf('Subscription converted from automatic to manual — paid via %s', $this->getMeta('label')), 'warning' ); do_action('fluent_cart/subscription_converted_to_manual', [ 'subscription' => $subscription, 'payment_method' => $this->getMeta('route'), ]); } public function validatePaymentMethod($data) { $isZeroPayment = Arr::get($data, 'isZeroPayment', false); if (!$this->isEnabled() && !$isZeroPayment) { return [ 'isValid' => false, 'reason' => sprintf( /* translators: %s is the payment method name */ __('Selected payment method %s is not active!', 'fluent-cart'), $this->getMeta('route') ) ]; } return [ 'isValid' => true, ]; } public function updateOrderDataByOrder($order, $transactionData, $transaction) { if ($order == null) { return; } $transaction->fill($transactionData); $transaction->save(); $paymentStatus = Status::syncPaymentStatus(Arr::get($transactionData, 'status')); $orderStatus = !in_array($paymentStatus, [Status::TRANSACTION_SUCCEEDED, Status::PAYMENT_PAID]) ? $paymentStatus : Status::ORDER_PROCESSING; $statusHelper = (new StatusHelper())->setOrder($order); $statusHelper->updateTransactionData($transactionData, $transaction); if ($amount = Arr::get($transactionData, 'total')) { $statusHelper->updateTotalPaid($amount); } $statusHelper->changeOrderStatus($orderStatus, $paymentStatus, $this->getMeta('title'), $this->getMeta('slug')); //If product is digital and processing then trigger status to completed if ($order->fulfillment_type == 'digital' && $orderStatus === Status::ORDER_PROCESSING && $order->total_amount <= $order->total_paid) { $statusHelper->changeOrderStatus(Status::ORDER_COMPLETED, $paymentStatus, $this->getMeta('title'), $this->getMeta('slug')); } do_action('fluent_cart/payments/after_payment_' . $paymentStatus, [ 'order' => $order ]); } public function getCheckoutItems(): array { return (CartCheckoutHelper::make())->getItems(); } public function getSettings(): BaseGatewaySettings { return $this->settings; } public function renderStoreModeNotice(): string { if ((new StoreSettings())->get('order_mode') == 'test') { return '
' . __('Your Store is in test mode, change Store\'s \'Order Mode\' to \'Live\' and update related settings to enable live payment!', 'fluent-cart') . '
'; } return '
' . __('Your Store is in Live mode', 'fluent-cart') . '
'; } public function beforeRenderPaymentMethod($hasSubscription): void { $this->enqueue($hasSubscription); } public function getEnqueueVersion() { return FLUENTCART_VERSION; } public function getEnqueueScriptSrc($hasSubscription): array { return []; } public function getEnqueueStyleSrc(): array { return []; } public function getTransactionUrl($url, $data) { return $url; } public function getSubscriptionUrl($url, $data) { return $url; } public function processRefund($transaction, $amount, $args) { return new \WP_Error('not_implemented', __('Refund process is not implemented for this payment gateway.', 'fluent-cart')); } public function syncRemoteTransaction(OrderTransaction $transaction) { return new \WP_Error('not_implemented', __('Remote transaction sync is not available for this payment method.', 'fluent-cart')); } public function enqueue($hasSubscription): void { $styles = $this->getEnqueueStyleSrc(); $scripts = $this->getEnqueueScriptSrc($hasSubscription); foreach ($styles as $style) { wp_enqueue_style( Arr::get($style, 'handle'), Arr::get($style, 'src'), Arr::get($style, 'deps', null), Arr::get($style, 'version', $this->getEnqueueVersion()) ); } $handleToEnqueue = ''; $scriptHandles = []; foreach ($scripts as $script) { $handle = Arr::get($script, 'handle'); if (empty($handleToEnqueue)) { $handleToEnqueue = $handle; } $scriptHandles[] = $handle; wp_enqueue_script( $handle, Arr::get($script, 'src'), Arr::get($script, 'deps', null), Arr::get($script, 'version', $this->getEnqueueVersion()), Arr::get($script, 'in_footer', false), ); } // Add filter to prevent consent plugins from blocking payment gateway scripts if (!empty($scriptHandles) && is_array($scriptHandles)) { $gatewayInstance = $this; add_filter('script_loader_tag', function($tag, $handle, $src) use ($scriptHandles, $gatewayInstance) { if (!in_array($handle, $scriptHandles, true)) { return $tag; } // This makes payment scripts load as "necessary" cookies $attributes = [ 'data-category="necessary"', 'data-consent-category="necessary"', 'data-cookieconsent="ignore"', 'data-no-optimize="1"', 'data-cfasync="false"', ]; $serviceName = ''; if (is_object($gatewayInstance)) { if (method_exists($gatewayInstance, 'getConsentServiceName')) { $serviceName = (string) $gatewayInstance->getConsentServiceName(); } if (empty($serviceName) && method_exists($gatewayInstance, 'getMeta')) { $serviceName = (string) $gatewayInstance->getMeta('title'); } } if (!empty($serviceName)) { $serviceName = esc_attr($serviceName); $attributes[] = 'data-usercentrics="' . $serviceName . '"'; $attributes[] = 'data-service="' . $serviceName . '"'; } $tag = preg_replace( '/getLocalizeData() as $key => $val) { wp_localize_script($handleToEnqueue, $key, $val); } } } public function prepare($mode, $hasSubscription) { $this->beforeRenderPaymentMethod($hasSubscription); $this->render($mode); $route = $this->getMeta('route'); do_action('fluent_cart/after_render_payment_method_' . $route); } public function render($mode = 'logo') { $content = ''; if ($mode === 'logo') { $content .= '' . esc_attr($this->getMeta('title')) . ''; } elseif ($mode === 'radio') { $content .= '' . ''; } else { $content .= '' . $this->getMeta('title') . ''; } echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } public function getLocalizeData(): array { // $example = [ // 'var_name' => [ // 'key' => 'value' // ], // // 'var_name_two' => [ // 'key' => 'value' // ], // ]; return []; } /** * Get the consent service name for this payment gateway * Used by consent management plugins (Usercentrics, CookieYes, etc.) * Override in child classes to specify the service name * * @return string|null Service name or null to use gateway slug */ public function getConsentServiceName(): ?string { // By default, return null and let the system use gateway meta title return null; } }