woocommerce-paypal-payments/modules/ppcp-local-alternative-payment-methods/src/LocalAlternativePaymentMethodsModule.php

436 lines
19 KiB
PHP

<?php
/**
* The local alternative payment methods module.
*
* @package WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods
*/
declare (strict_types=1);
namespace WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods;
use WC_Order;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use WooCommerce\PayPalCommerce\Assets\AssetGetter;
use WooCommerce\PayPalCommerce\Settings\Data\Definition\FeaturesDefinition;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsProvider;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Helper\FeesUpdater;
/**
* Class LocalAlternativePaymentMethodsModule
*/
class LocalAlternativePaymentMethodsModule implements ServiceModule, ExecutableModule
{
use ModuleClassNameIdTrait;
/**
* Payment methods configuration.
*
* @var array
*/
private array $payment_methods = array();
/**
* {@inheritDoc}
*/
public function services(): array
{
return require __DIR__ . '/../services.php';
}
/**
* {@inheritDoc}
*/
public function run(ContainerInterface $c): bool
{
add_action('after_setup_theme', fn() => $this->run_with_translations($c));
return \true;
}
/**
* Set up WP hooks that depend on translation features.
* Runs after the theme setup, when translations are available, which is fired
* before the `init` hook, which usually contains most of the logic.
*
* @param ContainerInterface $c The DI container.
* @return void
*/
private function run_with_translations(ContainerInterface $c): void
{
$this->payment_methods = $c->get('ppcp-local-apms.payment-methods');
$this->register_pwc_feature_flag_filters();
// When Local APMs are disabled, none of the following hooks are needed.
if (!$this->should_add_local_apm_gateways($c)) {
return;
}
add_action('wp_enqueue_scripts', function () use ($c) {
if (!is_checkout() && !is_cart() && !is_wc_endpoint_url('order-pay')) {
return;
}
$asset_getter = $c->get('ppcp-local-apms.asset_getter');
assert($asset_getter instanceof AssetGetter);
$asset_version = $c->get('ppcp.asset-version');
wp_enqueue_style('ppcp-local-apms-gateway', $asset_getter->get_asset_url('gateway.css'), array(), $asset_version);
});
/**
* The "woocommerce_payment_gateways" filter is responsible for ADDING
* custom payment gateways to WooCommerce. Here, we add all the local
* APM gateways to the filtered list, so they become available later on.
*/
add_filter(
'woocommerce_payment_gateways',
/**
* Param types removed to avoid third-party issues.
*
* @psalm-suppress MissingClosureParamType
*/
function ($methods) use ($c) {
if (!is_array($methods)) {
return $methods;
}
$payment_methods = $c->get('ppcp-local-apms.payment-methods');
$payment_methods = apply_filters('woocommerce_paypal_payments_local_apm_payment_methods', $payment_methods);
// Only register eligible gateways when the merchant is connected.
$is_connected = $c->get('settings.flag.is-connected');
$eligibility_checks = array();
if ($is_connected) {
$eligibility_service = $c->get('settings.service.payment_methods_eligibilities');
$eligibility_checks = $eligibility_service->get_eligibility_checks();
}
foreach ($payment_methods as $key => $value) {
$gateway_id = $value['id'];
if (isset($eligibility_checks[$gateway_id]) && !$eligibility_checks[$gateway_id]()) {
continue;
}
$methods[] = $c->get('ppcp-local-apms.' . $key . '.wc-gateway');
}
return $methods;
}
);
/**
* Filters the "available gateways" list by REMOVING gateways that
* are not available for the current customer.
*/
add_filter(
'woocommerce_available_payment_gateways',
/**
* Param types removed to avoid third-party issues.
*
* @psalm-suppress MissingClosureParamType
*/
function ($methods) use ($c) {
// @phpstan-ignore empty.property
if (!is_array($methods) || is_admin() || empty(WC()->customer)) {
// Don't restrict the gateway list on wp-admin or when no customer is known.
return $methods;
}
$payment_methods = $c->get('ppcp-local-apms.payment-methods');
/**
* Filter the payment methods array before checking availability.
*
* @param array $payment_methods The payment methods configuration array.
* @return array The filtered payment methods array.
*/
$payment_methods = apply_filters('woocommerce_paypal_payments_local_apm_payment_methods', $payment_methods);
$customer_country = WC()->customer->get_billing_country() ?: WC()->customer->get_shipping_country();
$site_currency = get_woocommerce_currency();
// Remove unsupported gateways from the customer's payment options.
foreach ($payment_methods as $payment_method) {
// Empty arrays mean "allow all" - skip restriction checks.
$is_currency_supported = empty($payment_method['currencies']) || in_array($site_currency, $payment_method['currencies'], \true);
$is_country_supported = empty($payment_method['countries']) || in_array($customer_country, $payment_method['countries'], \true);
if (!$is_currency_supported || !$is_country_supported) {
unset($methods[$payment_method['id']]);
}
}
return $methods;
}
);
/**
* Adds all local APM gateways in the "payment_method_type" block registry
* to make the payment methods available in the Block Checkout.
*
* @see IntegrationRegistry::initialize
*/
add_action('woocommerce_blocks_payment_method_type_registration', function (PaymentMethodRegistry $payment_method_registry) use ($c): void {
$payment_methods = $c->get('ppcp-local-apms.payment-methods');
/**
* Filter the payment methods array before registering block payment methods.
*
* @param array $payment_methods The payment methods configuration array.
* @return array The filtered payment methods array.
*/
$payment_methods = apply_filters('woocommerce_paypal_payments_local_apm_payment_methods', $payment_methods);
foreach ($payment_methods as $key => $value) {
$payment_method_registry->register($c->get('ppcp-local-apms.' . $key . '.payment-method'));
}
});
add_filter('woocommerce_paypal_payments_localized_script_data', function (array $data) use ($c) {
$payment_methods = $c->get('ppcp-local-apms.payment-methods');
/**
* Filter the payment methods array before adding to disable-funding.
*
* @param array $payment_methods The payment methods configuration array.
* @return array The filtered payment methods array.
*/
$payment_methods = apply_filters('woocommerce_paypal_payments_local_apm_payment_methods', $payment_methods);
$default_disable_funding = $data['url_params']['disable-funding'] ?? '';
$disable_funding = array_merge(array_keys($payment_methods), array_filter(explode(',', $default_disable_funding)));
$data['url_params']['disable-funding'] = implode(',', array_unique($disable_funding));
return $data;
});
add_action('woocommerce_before_thankyou', array($this, 'handle_cancelled_local_apm'));
add_action('template_redirect', array($this, 'handle_pwc_order_received_redirect'));
add_action('woocommerce_paypal_payments_payment_capture_completed_webhook_handler', function (WC_Order $wc_order, string $order_id) use ($c) {
$payment_methods = $c->get('ppcp-local-apms.payment-methods');
if (!$this->is_local_apm($wc_order->get_payment_method(), $payment_methods)) {
return;
}
$fees_updater = $c->get('wcgateway.helper.fees-updater');
assert($fees_updater instanceof FeesUpdater);
$fees_updater->update($order_id, $wc_order);
}, 10, 2);
}
/**
* Handle cancelled local APM payments on the thank you page.
*
* @param int $order_id The order ID.
* @return void
*/
public function handle_cancelled_local_apm($order_id): void
{
$order = wc_get_order($order_id);
if (!$order instanceof WC_Order) {
return;
}
$request_uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash((string) $_SERVER['REQUEST_URI'])) : '';
$query_string = (string) wp_parse_url($request_uri, \PHP_URL_QUERY);
$params = array();
wp_parse_str(str_replace('?', '&', $query_string), $params);
$params = array_map('sanitize_text_field', $params);
$cancelled = $params['cancelled'] ?? '';
$order_key = $params['key'] ?? '';
if (!$this->is_local_apm($order->get_payment_method(), $this->payment_methods) || !$cancelled || $order->get_order_key() !== $order_key) {
return;
}
if ($order->get_payment_method() === \WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\PWCGateway::ID) {
$this->handle_cancelled_crypto_payment($order, $cancelled);
return;
}
$this->handle_cancelled_standard_apm($order, $params);
}
/**
* Handle cancelled crypto payments.
*
* @param WC_Order $order The WooCommerce order.
* @param string $cancelled The cancelled parameter value.
* @return void
*/
private function handle_cancelled_crypto_payment(WC_Order $order, string $cancelled): void
{
if (!$cancelled) {
return;
}
if ($order->get_status() === 'on-hold') {
$order->update_status('failed', __('Pay with Crypto payment was cancelled or failed.', 'woocommerce-paypal-payments'));
$order->add_order_note(__('Payment was cancelled or failed during the Pay with Crypto payment process.', 'woocommerce-paypal-payments'), 1);
}
add_filter('woocommerce_order_has_status', '__return_true');
if (!wp_doing_ajax() && !is_admin()) {
$clean_url = isset($_SERVER['REQUEST_URI']) ? remove_query_arg('cancelled', esc_url_raw(wp_unslash($_SERVER['REQUEST_URI']))) : '';
wp_safe_redirect(home_url($clean_url));
exit;
}
}
/**
* Handle cancelled standard APM payments (non-crypto).
*
* @param WC_Order $order The WooCommerce order.
* @param array $params Parsed query parameters from the return URL.
* @return void
*/
private function handle_cancelled_standard_apm(WC_Order $order, array $params = array()): void
{
$error_code = $params['errorcode'] ?? '';
if ($error_code === 'processing_error' || $error_code === 'payment_error') {
$order->update_status('failed', __("The payment can't be processed because of an error.", 'woocommerce-paypal-payments'));
add_filter('woocommerce_order_has_status', '__return_true');
}
}
/**
* Check if given payment method is a local APM.
*
* @param string $selected_payment_method Selected payment method.
* @param array $payment_methods Available local APMs.
* @return bool
*/
private function is_local_apm(string $selected_payment_method, array $payment_methods): bool
{
foreach ($payment_methods as $payment_method) {
if ($payment_method['id'] === $selected_payment_method) {
return \true;
}
}
return \false;
}
/**
* Check if the local APMs should be added to the available payment gateways.
*
* @param ContainerInterface $container Container.
* @return bool
*/
private function should_add_local_apm_gateways(ContainerInterface $container): bool
{
// APMs are only available after merchant onboarding is completed.
$is_connected = $container->get('settings.flag.is-connected');
if (!$is_connected) {
/**
* When the merchant is _not_ connected yet, we still need to
* register the APM gateways in one case:
*
* During the authentication process (which happens via a REST call)
* the gateways need to be present, so they can be correctly
* pre-configured for new merchants.
*/
return $this->is_rest_request();
}
$settings_provider = $container->get('settings.settings-provider');
assert($settings_provider instanceof SettingsProvider);
return $settings_provider->is_method_enabled(PayPalGateway::ID);
}
/**
* Register PWC feature flag filters.
*
* @return void
*/
private function register_pwc_feature_flag_filters(): void
{
/**
* Filter payment methods array to exclude PWC when feature flag is disabled.
*/
add_filter('woocommerce_paypal_payments_local_apm_payment_methods', function (array $methods) {
if ($this->is_pwc_feature_enabled()) {
return $methods;
}
// Remove PWC from payment methods array when feature flag is disabled.
return array_filter($methods, function ($method) {
return $method['id'] !== 'ppcp-pwc';
});
});
/**
* Filter APM gateway list to conditionally exclude PWC.
*/
add_filter('woocommerce_paypal_payments_gateway_group_apm', function (array $group): array {
if (!$this->is_pwc_feature_enabled()) {
$group = array_filter($group, function ($method) {
return $method['id'] !== 'ppcp-pwc';
});
}
return $group;
});
/**
* Filter todos list to conditionally exclude PWC-related todos.
*/
add_filter('woocommerce_paypal_payments_todos_list', function (array $todos): array {
if (!$this->is_pwc_feature_enabled()) {
unset($todos['enable_pwc']);
unset($todos['apply_for_pwc']);
}
return $todos;
});
/**
* Filter features list to conditionally exclude PWC feature.
*/
add_filter('woocommerce_paypal_payments_features_list', function (array $features): array {
if (!$this->is_pwc_feature_enabled()) {
unset($features[FeaturesDefinition::FEATURE_PAY_WITH_CRYPTO]);
}
return $features;
});
/**
* Filter localized script data to exclude PWC from disable-funding list.
*/
add_filter('woocommerce_paypal_payments_localized_script_data', function (array $data) {
if (!$this->is_pwc_feature_enabled()) {
return $data;
}
$current_disable_funding = $data['url_params']['disable-funding'] ?? '';
$funding_sources = array_filter(explode(',', $current_disable_funding));
$funding_sources = array_filter($funding_sources, function ($source) {
return $source !== 'pwc';
});
$data['url_params']['disable-funding'] = implode(',', array_unique($funding_sources));
return $data;
}, 11);
}
/**
* Handle PWC order received page redirect to strip token parameter.
*
* PayPal automatically appends a 'token' parameter to return URLs after crypto payments.
* When the 'token' parameter is present on the order-received page, WooCommerce displays
* a minimal page instead of the full order details.
*
* This intercepts PWC orders on 'template_redirect' (before any output buffering)
* and redirects to a clean URL without the token, allowing WooCommerce to display
* the full order-received page.
*
* Note: PWC uses ORDER_COMPLETE_ON_PAYMENT_APPROVAL, so the token serves no purpose
* but we can't prevent PayPal from appending it.
*
* @return void
*/
public function handle_pwc_order_received_redirect(): void
{
// Only run on order-received endpoint.
if (!is_wc_endpoint_url('order-received')) {
return;
}
$request_uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash((string) $_SERVER['REQUEST_URI'])) : '';
$query_string = (string) wp_parse_url($request_uri, \PHP_URL_QUERY);
$params = array();
wp_parse_str(str_replace('?', '&', $query_string), $params);
$params = array_map('sanitize_text_field', $params);
if (empty($params['token']) || !empty($params['cancelled'])) {
return;
}
// Get order ID from the URL endpoint.
global $wp;
$order_id = isset($wp->query_vars['order-received']) ? absint($wp->query_vars['order-received']) : 0;
if (!$order_id) {
return;
}
$order = wc_get_order($order_id);
if (!$order instanceof WC_Order) {
return;
}
// Only handle PWC - other payment methods may use 'token' legitimately (e.g., 3DS).
if ($order->get_payment_method() !== \WooCommerce\PayPalCommerce\LocalAlternativePaymentMethods\PWCGateway::ID) {
return;
}
wp_safe_redirect($order->get_checkout_order_received_url());
exit;
}
/**
* Checks, whether the current request is trying to access a WooCommerce REST endpoint.
*
* @return bool True, if the request path matches the WC-Rest namespace.
*/
private function is_rest_request(): bool
{
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = wp_unslash($_SERVER['REQUEST_URI'] ?? '');
return str_contains($request_uri, '/wp-json/wc/');
}
/**
* Check if PWC (Pay with Crypto) feature flag is enabled.
*
* @return bool True if PWC is enabled, false otherwise.
*/
private function is_pwc_feature_enabled(): bool
{
return apply_filters(
// phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
'woocommerce.feature-flags.woocommerce_paypal_payments.pwc_enabled',
getenv('PCP_PWC_ENABLED') !== '0'
);
}
}