This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,53 @@
<?php
/**
* Overwrites the default payment settings sections in WooCommerce
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Payments_Admin_Sections_Overwrite Class.
*/
class WC_Payments_Admin_Sections_Overwrite {
/**
* WC_Payments_Account instance to get information about the account.
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_Payments_Admin_Sections_Overwrite constructor.
*
* @param WC_Payments_Account $account WC_Payments_Account instance.
*/
public function __construct( WC_Payments_Account $account ) {
$this->account = $account;
}
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_filter( 'woocommerce_get_sections_checkout', [ $this, 'add_checkout_sections' ] );
}
/**
* Adds an "all payment methods" and a "woopayments" section to the gateways settings page
*
* @param array $default_sections the sections for the payment gateways tab.
*
* @return array
*/
public function add_checkout_sections( array $default_sections ): array {
$sections_to_render = [];
$sections_to_render['woocommerce_payments'] = 'WooPayments';
$sections_to_render[''] = __( 'All payment methods', 'woocommerce-payments' );
return $sections_to_render;
}
}
@@ -0,0 +1,106 @@
<?php
/**
* Class WC_Payments_Admin_Settings
*
* @package WooCommerce\Payments\Admin
*/
/**
* WC_Payments_Admin_Settings class.
*/
class WC_Payments_Admin_Settings {
/**
* WC_Payment_Gateway_WCPay.
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* Set of parameters to build the URL to the gateway's settings page.
*
* @var string[]
*/
private static $settings_url_params = [
'page' => 'wc-settings',
'tab' => 'checkout',
'section' => WC_Payment_Gateway_WCPay::GATEWAY_ID,
];
/**
* Initialize class actions.
*
* @param WC_Payment_Gateway_WCPay $gateway Payment Gateway.
*/
public function __construct( WC_Payment_Gateway_WCPay $gateway ) {
$this->gateway = $gateway;
}
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'woocommerce_woocommerce_payments_admin_notices', [ $this, 'display_test_mode_notice' ] );
add_filter( 'plugin_action_links_' . plugin_basename( WCPAY_PLUGIN_FILE ), [ $this, 'add_plugin_links' ] );
}
/**
* Add notice explaining test mode when it's enabled.
*/
public function display_test_mode_notice() {
if ( WC_Payments::mode()->is_test() ) {
?>
<div id="wcpay-test-mode-notice" class="notice notice-warning">
<p>
<b><?php esc_html_e( 'Test mode active: ', 'woocommerce-payments' ); ?></b>
<?php
printf(
/* translators: %s: WooPayments */
esc_html__( "All transactions are simulated. Customers can't make real purchases through %s.", 'woocommerce-payments' ),
'WooPayments'
);
?>
</p>
</div>
<?php
}
}
/**
* Adds links to the plugin's row in the "Plugins" Wp-Admin page.
*
* @see https://codex.wordpress.org/Plugin_API/Filter_Reference/plugin_action_links_(plugin_file_name)
* @param array $links The existing list of links that will be rendered.
* @return array The list of links that will be rendered, after adding some links specific to this plugin.
*/
public function add_plugin_links( $links ) {
$plugin_links = [
'<a href="' . esc_attr( self::get_settings_url() ) . '">' . esc_html__( 'Settings', 'woocommerce-payments' ) . '</a>',
];
return array_merge( $plugin_links, $links );
}
/**
* Whether the current page is the WooPayments settings page.
*
* @return bool
*/
public static function is_current_page_settings() {
return count( self::$settings_url_params ) === count( array_intersect_assoc( $_GET, self::$settings_url_params ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
/**
* Returns the URL of the configuration screen for this gateway, for use in internal links.
*
* @param array $query_args Optional additional query args to append to the URL.
*
* @return string URL of the configuration screen for this gateway
*/
public static function get_settings_url( $query_args = [] ) {
return admin_url( add_query_arg( array_merge( self::$settings_url_params, $query_args ), 'admin.php' ) ); // nosemgrep: audit.php.wp.security.xss.query-arg -- constant string is passed in.
}
}
@@ -0,0 +1,66 @@
<?php
/**
* Class WC_Payments_REST_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for transactions.
*/
class WC_Payments_REST_Controller extends WP_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v3';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
protected $api_client;
/**
* WC_Payments_REST_Controller constructor.
*
* @param WC_Payments_API_Client $api_client - WooCommerce Payments API client.
*/
public function __construct( WC_Payments_API_Client $api_client ) {
$this->api_client = $api_client;
}
/**
* Forwards request to API client with taking care of API_Exception.
*
* @param string $api_method - API method name.
* @param array $args - API method args.
*
* @return WP_Error|mixed - Method result of WP_Error in case of API_Exception.
*/
public function forward_request( $api_method, $args ) {
try {
$response = call_user_func_array( [ $this->api_client, $api_method ], $args );
} catch ( API_Exception $e ) {
$response = new WP_Error( $e->get_error_code(), $e->getMessage() );
}
return rest_ensure_response( $response );
}
/**
* Verify access.
*
* Override this method if custom permissions required.
*/
public function check_permission() {
return current_user_can( 'manage_woocommerce' );
}
}
@@ -0,0 +1,80 @@
<?php
/**
* Class WC_REST_Payments_Accounts_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* REST controller for account details and status.
*/
class WC_REST_Payments_Accounts_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/accounts';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/payments/accounts',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_account_data' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Get account details via API.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error
*/
public function get_account_data( $request ) {
$account = WC_Payments::get_account_service()->get_cached_account_data();
if ( [] === $account ) {
$default_currency = get_woocommerce_currency();
$status = WC_Payments_Account::is_on_boarding_disabled() ? 'ONBOARDING_DISABLED' : 'NOACCOUNT';
$account = [
'card_present_eligible' => false,
'country' => WC()->countries->get_base_country(),
'current_deadline' => null,
'has_overdue_requirements' => false,
'has_pending_requirements' => false,
'statement_descriptor' => '',
'status' => $status,
'store_currencies' => [
'default' => $default_currency,
'supported' => [
$default_currency,
],
],
'customer_currencies' => [
'supported' => [
$default_currency,
],
],
];
}
if ( false !== $account ) {
// Add extra properties to account if necessary.
$account['card_present_eligible'] = false;
$account['test_mode'] = WC_Payments::mode()->is_test();
$account['test_mode_onboarding'] = WC_Payments::mode()->is_test_mode_onboarding();
}
return rest_ensure_response( $account );
}
}
@@ -0,0 +1,89 @@
<?php
/**
* Class WC_REST_Payments_Authorizations_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request;
use WCPay\Core\Server\Request\List_Authorizations;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for authorizations.
*/
class WC_REST_Payments_Authorizations_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/authorizations';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_authorizations' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_authorizations_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<payment_intent_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_authorization' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve authorizations to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_authorizations( WP_REST_Request $request ) {
$wcpay_request = List_Authorizations::from_rest_request( $request );
return $wcpay_request->handle_rest_request();
}
/**
* Retrieve authorization to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_authorization( WP_REST_Request $request ) {
$payment_intent_id = $request->get_param( 'payment_intent_id' );
$request = Request::get( WC_Payments_API_Client::AUTHORIZATIONS_API, $payment_intent_id );
$request->assign_hook( 'wcpay_get_authorization_request' );
return $request->handle_rest_request();
}
/**
* Retrieve authorizations summary to respond with via API.
*/
public function get_authorizations_summary() {
$request = Request::get( WC_Payments_API_Client::AUTHORIZATIONS_API . '/summary' );
$request->assign_hook( 'wc_pay_get_authorizations_summary' );
return $request->handle_rest_request();
}
}
@@ -0,0 +1,65 @@
<?php
/**
* Class WC_REST_Payments_Capital_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for Capital loans functionality.
*/
class WC_REST_Payments_Capital_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/capital';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/active_loan_summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_active_loan_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/loans',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_loans' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve the summary of the active Capital loan.
*/
public function get_active_loan_summary() {
$request = Request::get( WC_Payments_API_Client::CAPITAL_API . '/active_loan_summary' );
$request->assign_hook( 'wcpay_get_active_loan_summary_request' );
return $request->handle_rest_request();
}
/**
* Retrieve all the past and present Capital loans.
*/
public function get_loans() {
$request = Request::get( WC_Payments_API_Client::CAPITAL_API . '/loans' );
$request->assign_hook( 'wcpay_get_loans_request' );
return $request->handle_rest_request();
}
}
@@ -0,0 +1,126 @@
<?php
/**
* Class WC_REST_Payments_Charges_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Get_Charge;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for charges.
*/
class WC_REST_Payments_Charges_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/charges';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<charge_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_charge' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/order/(?P<order_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'generate_charge_from_order' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve charge to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_charge( $request ) {
$charge_id = $request->get_param( 'charge_id' );
try {
$wcpay_request = Get_Charge::create( $charge_id );
$charge = $wcpay_request->send();
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( 'wcpay_get_charge', $e->getMessage() ) );
}
return rest_ensure_response( $charge );
}
/**
* Generates a charge-like object from an order.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function generate_charge_from_order( $request ) {
$order_id = $request['order_id'];
$order = wc_get_order( $order_id );
if ( false === $order ) {
return new WP_Error( 'wcpay_missing_order', __( 'Order not found', 'woocommerce-payments' ), [ 'status' => 404 ] );
}
$currency = $order->get_currency();
$amount = WC_Payments_Utils::prepare_amount( $order->get_total(), $currency );
$billing_details = WC_Payments::get_order_service()->get_billing_data_from_order( $order ); // TODO: Inject order_service after #7464 is fixed.
$date_created = $order->get_date_created();
$intent_id = $order->get_meta( '_intent_id' );
$intent_status = $order->get_meta( '_intent_status' );
$charge = [
'id' => $order->get_id(),
'amount' => $amount,
'amount_captured' => 0,
'amount_refunded' => 0,
'application_fee_amount' => 0,
'balance_transaction' => [
'currency' => $currency,
'amount' => $amount,
'fee' => 0,
],
'billing_details' => $billing_details,
'created' => $date_created ? $date_created->getTimestamp() : null,
'currency' => $currency,
'disputed' => false,
'outcome' => false,
'order' => $this->api_client->build_order_info( $order ),
'paid' => false,
'paydown' => null,
'payment_intent' => ! empty( $intent_id ) ? $intent_id : null,
'payment_method_details' => [
'card' => [
'country' => $order->get_billing_country(),
'checks' => [],
'network' => '',
],
'type' => 'card',
],
'refunded' => false,
'refunds' => null,
'status' => ! empty( $intent_status ) ? $intent_status : $order->get_status(),
];
$charge = $this->api_client->add_formatted_address_to_charge_object( $charge );
return rest_ensure_response( $charge );
}
}
@@ -0,0 +1,56 @@
<?php
/**
* Class WC_REST_Payments_Connection_Tokens_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* REST controller for terminal tokens.
*/
class WC_REST_Payments_Connection_Tokens_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/connection_tokens';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/payments/connection_tokens',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_token' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Create a connection token via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function create_token( $request ) {
$response = $this->forward_request( 'create_token', [ $request ] );
// As an aid to mobile clients, tuck in the test_mode flag in the response returned to the request.
if ( is_a( $response, 'WP_REST_Response' ) ) {
if ( property_exists( $response, 'data' ) ) {
if ( is_array( $response->data ) ) {
$response->data['test_mode'] = WC_Payments::mode()->is_test();
}
}
}
return $response;
}
}
@@ -0,0 +1,280 @@
<?php
/**
* Class WC_REST_Payments_Customer_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for customers.
*/
class WC_REST_Payments_Customer_Controller extends WC_Payments_REST_Controller {
/**
* Onboarding Service.
*
* @var WC_Payments_Customer_Service
*/
protected $customer_service;
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/customers';
/**
* Constructor.
*
* @param WC_Payments_API_Client $api_client WooCommerce Payments API client.
* @param WC_Payments_Customer_Service $customer_service Token service.
*/
public function __construct(
WC_Payments_API_Client $api_client,
WC_Payments_Customer_Service $customer_service
) {
parent::__construct( $api_client );
$this->customer_service = $customer_service;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<customer_id>\w+)/payment_methods',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_customer_payment_methods' ],
'permission_callback' => [ $this, 'check_permission' ],
],
'schema' => [ $this, 'get_item_schema' ],
]
);
}
/**
* Retrieve transaction to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_customer_payment_methods( $request ) {
$customer_id = $request->get_param( 'customer_id' );
$payment_methods_types = WC_Payments::get_gateway()->get_upe_enabled_payment_method_ids() ?? [];
$payment_methods = [];
// Perhaps we can fetch it directly from server and avoid looping to get payment methods from cache.
foreach ( $payment_methods_types as $type ) {
try {
$payment_methods[] = $this->customer_service->get_payment_methods_for_customer( $customer_id, $type );
} catch ( API_Exception $e ) {
wp_send_json_error(
wp_strip_all_tags( $e->getMessage() ),
403
);
}
}
$payment_methods = array_merge( ...$payment_methods );
$data = [];
foreach ( $payment_methods as $payment_method ) {
$response = $this->prepare_item_for_response( $payment_method, $request );
$data[] = $this->prepare_response_for_collection( $response );
}
return rest_ensure_response( $data );
}
/**
* Prepare each item for response.
*
* @param array|mixed $item Item to prepare.
* @param WP_REST_Request $request Request instance.
*
* @return WP_REST_Response|WP_Error
*/
public function prepare_item_for_response( $item, $request ) {
$prepared_item = [];
$prepared_item['id'] = $item['id'];
$prepared_item['type'] = $item['type'];
$prepared_item['billing_details'] = $item['billing_details'];
if ( array_key_exists( 'card', $item ) ) {
$prepared_item['card'] = [
'brand' => $item['card']['brand'],
'last4' => $item['card']['last4'],
'exp_month' => $item['card']['exp_month'],
'exp_year' => $item['card']['exp_year'],
];
}
if ( array_key_exists( 'card', $item ) ) {
$prepared_item['card'] = [
'brand' => $item['card']['brand'],
'last4' => $item['card']['last4'],
'exp_month' => $item['card']['exp_month'],
'exp_year' => $item['card']['exp_year'],
];
} elseif ( array_key_exists( 'sepa_debit', $item ) ) {
$prepared_item['sepa_debit'] = [
'last4' => $item['sepa_debit']['last4'],
];
} elseif ( array_key_exists( 'link', $item ) ) {
$prepared_item['link'] = [
'email' => $item['link']['email'],
];
}
$context = $request['context'] ?? 'view';
$prepared_item = $this->add_additional_fields_to_object( $prepared_item, $request );
$prepared_item = $this->filter_response_by_context( $prepared_item, $context );
return rest_ensure_response( $prepared_item );
}
/**
* Item schema.
*
* @return array
*/
public function get_item_schema() {
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'payment_method',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'ID for the payment method.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'type' => [
'description' => __( 'Type of the payment method.', 'woocommerce-payments' ),
'type' => 'string',
'enum' => [ 'card', 'sepa_debit', 'link' ],
'context' => [ 'view' ],
],
'billing_details' => [
'description' => __( 'Billing details for the payment method.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'address' => [
'description' => __( 'Address associated with the billing details.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'city' => [
'description' => __( 'City of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'country' => [
'description' => __( 'Country of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'line1' => [
'description' => __( 'Line 1 of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'line2' => [
'description' => __( 'Line 2 of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'postal_code' => [
'description' => __( 'Postal code of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'state' => [
'description' => __( 'State of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
],
],
'email' => [
'description' => __( 'Email associated with the billing details.', 'woocommerce-payments' ),
'type' => 'string',
'format' => 'email',
'context' => [ 'view' ],
],
'name' => [
'description' => __( 'Name associated with the billing details.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'phone' => [
'description' => __( 'Phone number associated with the billing details.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
],
],
'card' => [
'description' => __( 'Card details for the payment method.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'brand' => [
'description' => __( 'Brand of the card.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'last4' => [
'description' => __( 'Last 4 digits of the card.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'exp_month' => [
'description' => __( 'Expiration month of the card.', 'woocommerce-payments' ),
'type' => 'integer',
'context' => [ 'view' ],
],
'exp_year' => [
'description' => __( 'Expiration year of the card.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
],
],
'sepa_debit' => [
'description' => __( 'SEPA Debit details for the payment method.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'last4' => [
'description' => __( 'Last 4 digits of the SEPA Debit.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
],
],
'link' => [
'description' => __( 'Link details for the payment method.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'email' => [
'description' => __( 'Email associated with the link.', 'woocommerce-payments' ),
'type' => 'string',
'format' => 'email',
'context' => [ 'view' ],
],
],
],
],
];
}
}
@@ -0,0 +1,173 @@
<?php
/**
* Class WC_REST_Payments_Deposits_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request;
use WCPay\Core\Server\Request\List_Deposits;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for deposits.
*/
class WC_REST_Payments_Deposits_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/deposits';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_deposits' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_deposits_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/download',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_deposits_export' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<deposit_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_deposit' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/overview-all',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_all_deposits_overviews' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'manual_deposit' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve deposits to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_deposits( $request ) {
$wcpay_request = List_Deposits::from_rest_request( $request );
return $wcpay_request->handle_rest_request();
}
/**
* Retrieve deposits summary to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_deposits_summary( $request ) {
$filters = $this->get_deposits_filters( $request );
return $this->forward_request( 'get_deposits_summary', [ $filters ] );
}
/**
* Retrieve an overview of all deposits from the API.
*/
public function get_all_deposits_overviews() {
$request = Request::get( WC_Payments_API_Client::DEPOSITS_API . '/overview-all' );
$request->assign_hook( 'wcpay_get_all_deposits_overviews' );
return $request->handle_rest_request();
}
/**
* Retrieve deposit to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_deposit( $request ) {
$deposit_id = $request->get_param( 'deposit_id' );
$wcpay_request = Request::get( WC_Payments_API_Client::DEPOSITS_API, $deposit_id );
$wcpay_request->assign_hook( 'wcpay_get_deposit' );
return $wcpay_request->handle_rest_request();
}
/**
* Initiate deposits export via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_deposits_export( $request ) {
$user_email = $request->get_param( 'user_email' );
$locale = $request->get_param( 'locale' );
$filters = $this->get_deposits_filters( $request );
return $this->forward_request( 'get_deposits_export', [ $filters, $user_email, $locale ] );
}
/**
* Extract deposits filters from request
*
* @param WP_REST_Request $request Full data about the request.
*/
private function get_deposits_filters( $request ) {
return array_filter(
[
'match' => $request->get_param( 'match' ),
'store_currency_is' => $request->get_param( 'store_currency_is' ),
'date_before' => $request->get_param( 'date_before' ),
'date_after' => $request->get_param( 'date_after' ),
'date_between' => $request->get_param( 'date_between' ),
'status_is' => $request->get_param( 'status_is' ),
'status_is_not' => $request->get_param( 'status_is_not' ),
],
static function ( $filter ) {
return null !== $filter;
}
);
}
/**
* Trigger a manual deposit.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function manual_deposit( $request ) {
$params = $request->get_params();
return $this->forward_request( 'manual_deposit', [ $params['type'], $params['currency'] ] );
}
}
@@ -0,0 +1,180 @@
<?php
/**
* Class WC_REST_Payments_Disputes_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\List_Disputes;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for disputes.
*/
class WC_REST_Payments_Disputes_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/disputes';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_disputes' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_disputes_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/download',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_disputes_export' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<dispute_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_dispute' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<dispute_id>\w+)',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'update_dispute' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<dispute_id>\w+)/close',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'close_dispute' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve disputes to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_disputes( WP_REST_Request $request ) {
$wcpay_request = List_Disputes::from_rest_request( $request );
return $wcpay_request->handle_rest_request();
}
/**
* Retrieve transactions summary to respond with via API.
*
* @param WP_REST_Request $request Request data.
* @return WP_REST_Response|WP_Error
*/
public function get_disputes_summary( WP_REST_Request $request ) {
$filters = $this->get_disputes_filters( $request );
return $this->forward_request( 'get_disputes_summary', [ $filters ] );
}
/**
* Retrieve dispute to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_dispute( $request ) {
$dispute_id = $request->get_param( 'dispute_id' );
return $this->forward_request( 'get_dispute', [ $dispute_id ] );
}
/**
* Update and respond with dispute via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function update_dispute( $request ) {
$params = $request->get_params();
return $this->forward_request(
'update_dispute',
[
$params['dispute_id'],
$params['evidence'],
$params['submit'],
$params['metadata'],
]
);
}
/**
* Close and respond with dispute via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function close_dispute( $request ) {
$dispute_id = $request->get_param( 'dispute_id' );
return $this->forward_request( 'close_dispute', [ $dispute_id ] );
}
/**
* Initiate disputes export via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_disputes_export( $request ) {
$user_email = $request->get_param( 'user_email' );
$locale = $request->get_param( 'locale' );
$filters = $this->get_disputes_filters( $request );
return $this->forward_request( 'get_disputes_export', [ $filters, $user_email, $locale ] );
}
/**
* Extract disputes filters from request
* The reason to map the filter properties is to keep consitency between the front-end models.
*
* @param WP_REST_Request $request Full data about the request.
*/
private function get_disputes_filters( $request ) {
return array_filter(
[
'match' => $request->get_param( 'match' ),
'currency_is' => $request->get_param( 'store_currency_is' ),
'created_before' => $request->get_param( 'date_before' ),
'created_after' => $request->get_param( 'date_after' ),
'created_between' => $request->get_param( 'date_between' ),
'search' => $request->get_param( 'search' ),
'status_is' => $request->get_param( 'status_is' ),
'status_is_not' => $request->get_param( 'status_is_not' ),
],
static function ( $filter ) {
return null !== $filter;
}
);
}
}
@@ -0,0 +1,148 @@
<?php
/**
* Class WC_REST_Payments_Documents_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\List_Documents;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for documents.
*/
class WC_REST_Payments_Documents_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/documents';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_documents' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_documents_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<document_id>[\w-]+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_document' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve documents to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_documents( $request ) {
$wcpay_request = List_Documents::from_rest_request( $request );
return $wcpay_request->handle_rest_request();
}
/**
* Retrieve documents summary to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_documents_summary( $request ) {
$filters = $this->get_documents_filters( $request );
return $this->forward_request( 'get_documents_summary', [ $filters ] );
}
/**
* Retrieve and serve a document for API requests.
* This method serves the document directly and halts execution, skipping the REST return
* and preventing additional data to be sent.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_document( $request ) {
$response = [];
$document_id = $request->get_param( 'document_id' );
try {
$response = $this->api_client->get_document( $document_id );
} catch ( API_Exception $e ) {
$message = sprintf(
/* translators: %1: The document ID. %2: The error message.*/
esc_html__( 'There was an error accessing document %1$s. %2$s', 'woocommerce-payments' ),
$document_id,
$e->getMessage()
);
wp_die( esc_html( $message ), '', (int) $e->get_http_code() );
}
// WooCommerce core only includes Tracks in admin, not the REST API, so we need to use this wc_admin method
// that includes WC_Tracks in case it's not loaded.
if ( function_exists( 'wc_admin_record_tracks_event' ) ) {
wc_admin_record_tracks_event(
'wcpay_document_downloaded',
[
'document_id' => $document_id,
'mode' => WC_Payments::mode()->is_test() ? 'test' : 'live',
]
);
}
// Set the headers to match what was returned from the server.
if ( ! headers_sent() ) {
nocache_headers();
status_header( $response['response']['code'], $response['response']['message'] ?? '' );
header( 'Content-Type: ' . $response['headers']['content-type'] );
header( 'Content-Disposition: ' . $response['headers']['content-disposition'] ?? '' );
}
// We should output the server's file without escaping.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $response['body'];
exit;
}
/**
* Extract documents filters from request
*
* @param WP_REST_Request $request Full data about the request.
*/
private function get_documents_filters( $request ) {
return array_filter(
[
'match' => $request->get_param( 'match' ),
'date_before' => $request->get_param( 'date_before' ),
'date_after' => $request->get_param( 'date_after' ),
'date_between' => $request->get_param( 'date_between' ),
'type_is' => $request->get_param( 'type_is' ),
'type_is_not' => $request->get_param( 'type_is_not' ),
],
static function ( $filter ) {
return null !== $filter;
}
);
}
}
@@ -0,0 +1,200 @@
<?php
/**
* Class WC_REST_Payments_Files_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* REST controller for files.
*/
class WC_REST_Payments_Files_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/file';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'upload_file' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<file_id>\w+)/details',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_file_detail' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<file_id>\w+)/content',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_file_content' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<file_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_file' ],
'permission_callback' => [],
]
);
}
/**
* Create file and respond with file object via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function upload_file( $request ) {
return $this->forward_request( 'upload_file', [ $request ] );
}
/**
* Retrieve a file content via API.
*
* @param WP_REST_Request $request - request object.
*
* @return WP_Error|WP_HTTP_Response
*/
public function get_file( WP_REST_Request $request ) {
$file_id = $request->get_param( 'file_id' );
$as_account = (bool) $request->get_param( 'as_account' );
$file_service = new WC_Payments_File_Service();
$purpose = get_transient( WC_Payments_File_Service::CACHE_KEY_PREFIX_PURPOSE . $file_id . '_' . ( $as_account ? '1' : '0' ) );
if ( ! $purpose ) {
$file = $this->forward_request( 'get_file', [ $file_id, $as_account ] );
if ( is_wp_error( $file ) ) {
return $this->file_error_response( $file );
}
$purpose = $file->get_data()['purpose'];
set_transient( WC_Payments_File_Service::CACHE_KEY_PREFIX_PURPOSE . $file_id, $purpose, WC_Payments_File_Service::CACHE_PERIOD );
}
if ( ! $file_service->is_file_public( $purpose ) && ! $this->check_permission() ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to do that.', 'woocommerce-payments' ),
[ 'status' => rest_authorization_required_code() ]
);
}
$result = $this->forward_request( 'get_file_contents', [ $file_id, $as_account ] );
if ( is_wp_error( $result ) ) {
return $this->file_error_response( $result );
}
/**
* WP_REST_Server will convert the response data to JSON prior to output it.
* Using this filter to prevent it, and output the data from WP_HTTP_Response instead.
*/
add_filter(
'rest_pre_serve_request',
function ( bool $served, WP_HTTP_Response $response ): bool {
echo $response->get_data(); // @codingStandardsIgnoreLine
return true;
},
10,
2
);
return new WP_HTTP_Response(
base64_decode( $result->get_data()['file_content'] ), // @codingStandardsIgnoreLine
200,
[
'Content-Type' => $result->get_data()['content_type'],
'Content-Disposition' => 'inline',
]
);
}
/**
* Retrieve file details via the API.
*
* Example response:
* {
* "id": "file_1Np1S5J5cIRIG92xknlr0iND",
* "object": "file",
* "created": 1694405421,
* "expires_at": 1717733421,
* "filename": "Screenshot 2023-09-04 at 5.08.31\u202fPM.png",
* "purpose": "dispute_evidence",
* "size": 21444,
* "title": null,
* "type": "png",
* }
*
* @param WP_REST_Request $request Full data about the request.
*
* @return mixed|WP_Error
*/
public function get_file_detail( WP_REST_Request $request ) {
$file_id = $request->get_param( 'file_id' );
$as_account = (bool) $request->get_param( 'as_account' );
return $this->forward_request( 'get_file', [ $file_id, $as_account ] );
}
/**
* Retrieve file contents via the API as a base64 encoded string.
*
* Example response:
* {
* "content_type": "image\/png",
* "file_content": "iVBORw.......",
* }
*
* @param WP_REST_Request $request Full data about the request.
*
* @return mixed|WP_Error
*/
public function get_file_content( WP_REST_Request $request ) {
$file_id = $request->get_param( 'file_id' );
$as_account = (bool) $request->get_param( 'as_account' );
return $this->forward_request( 'get_file_contents', [ $file_id, $as_account ] );
}
/**
* Convert error response
*
* @param WP_Error $error - error.
*
* @return WP_Error
*/
private function file_error_response( WP_Error $error ): WP_Error {
$error_status_code = 'resource_missing' === $error->get_error_code() ? WP_Http::NOT_FOUND : WP_Http::INTERNAL_SERVER_ERROR;
return new WP_Error(
$error->get_error_code(),
$error->get_error_message(),
[ 'status' => $error_status_code ]
);
}
}
@@ -0,0 +1,47 @@
<?php
/**
* Class WC_REST_Payments_Orders_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* REST controller for order processing.
*/
class WC_REST_Payments_Fraud_Outcomes_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/fraud_outcomes';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>\w+)/latest',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_latest_fraud_outcome' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve charge to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_latest_fraud_outcome( $request ) {
$id = $request->get_param( 'id' );
return $this->forward_request( 'get_latest_fraud_outcome', [ $id ] );
}
}
@@ -0,0 +1,288 @@
<?php
/**
* Class WC_REST_Payments_Onboarding_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Logger;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for account details and status.
*/
class WC_REST_Payments_Onboarding_Controller extends WC_Payments_REST_Controller {
const RESULT_BAD_REQUEST = 'bad_request';
/**
* Onboarding Service.
*
* @var WC_Payments_Onboarding_Service
*/
protected $onboarding_service;
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/onboarding';
/**
* Constructor.
*
* @param WC_Payments_API_Client $api_client WooCommerce Payments API client.
* @param WC_Payments_Onboarding_Service $onboarding_service Onboarding Service class instance.
*/
public function __construct(
WC_Payments_API_Client $api_client,
WC_Payments_Onboarding_Service $onboarding_service
) {
parent::__construct( $api_client );
$this->onboarding_service = $onboarding_service;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/kyc/session',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_embedded_kyc_session' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'progressive' => [
'required' => false,
'description' => 'Whether the session is for progressive onboarding.',
'type' => 'string',
],
'self_assessment' => [
'required' => false,
'description' => 'The self-assessment data.',
'type' => 'object',
'properties' => [
'country' => [
'type' => 'string',
'description' => 'The country code where the company is legally registered.',
'required' => true,
],
'business_type' => [
'type' => 'string',
'description' => 'The company incorporation type.',
'required' => true,
],
'mcc' => [
'type' => 'string',
'description' => 'The merchant category code. This can either be a true MCC or an MCCs tree item id from the onboarding form.',
'required' => true,
],
'annual_revenue' => [
'type' => 'string',
'description' => 'The estimated annual revenue bucket id.',
'required' => true,
],
'go_live_timeframe' => [
'type' => 'string',
'description' => 'The timeframe bucket for the estimated first live transaction.',
'required' => true,
],
'url' => [
'type' => 'string',
'description' => 'The URL of the store.',
'required' => true,
],
],
],
],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/kyc/finalize',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'finalize_embedded_kyc' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'source' => [
'required' => false,
'description' => 'The very first entry point the merchant entered our onboarding flow.',
'type' => 'string',
],
'from' => [
'required' => false,
'description' => 'The previous step in the onboarding flow leading the merchant to arrive at the current step.',
'type' => 'string',
],
],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/business_types',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_business_types' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/router/po_eligible',
[
'methods' => WP_REST_Server::CREATABLE,
'args' => [
'business' => [
'required' => true,
'description' => 'The context about the merchant\'s business (self-assessment data).',
'type' => 'object',
'properties' => [
'country' => [
'type' => 'string',
'description' => 'The country code where the company is legally registered.',
'required' => true,
],
'type' => [
'type' => 'string',
'description' => 'The company incorporation type.',
'required' => true,
],
'mcc' => [
'type' => 'string',
'description' => 'The merchant category code. This can either be a true MCC or an MCCs tree item id from the onboarding form.',
'required' => true,
],
],
],
'store' => [
'required' => true,
'description' => 'The context about the merchant\'s store (self-assessment data).',
'type' => 'object',
'properties' => [
'annual_revenue' => [
'type' => 'string',
'description' => 'The estimated annual revenue bucket id.',
'required' => true,
],
'go_live_timeframe' => [
'type' => 'string',
'description' => 'The timeframe bucket for the estimated first live transaction.',
'required' => true,
],
],
],
'woo_store_stats' => [
'required' => false,
'description' => 'Context about the merchant\'s current WooCommerce store.',
'type' => 'object',
],
],
'callback' => [ $this, 'get_progressive_onboarding_eligible' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Create an account embedded KYC session via the API.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_Error|WP_REST_Response
*/
public function get_embedded_kyc_session( WP_REST_Request $request ) {
$self_assessment_data = ! empty( $request->get_param( 'self_assessment' ) ) ? wc_clean( wp_unslash( $request->get_param( 'self_assessment' ) ) ) : [];
$progressive = ! empty( $request->get_param( 'progressive' ) ) && 'true' === $request->get_param( 'progressive' );
$account_session = $this->onboarding_service->create_embedded_kyc_session(
$self_assessment_data,
$progressive
);
if ( $account_session ) {
$account_session['locale'] = get_user_locale();
}
return rest_ensure_response( $account_session );
}
/**
* Finalize the embedded KYC session via the API.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error
*/
public function finalize_embedded_kyc( WP_REST_Request $request ) {
$source = $request->get_param( 'source' ) ?? '';
$from = $request->get_param( 'from' ) ?? '';
$actioned_notes = WC_Payments_Onboarding_Service::get_actioned_notes();
// Call the API to finalize the onboarding.
try {
$response = $this->onboarding_service->finalize_embedded_kyc(
get_user_locale(),
$source,
$actioned_notes
);
} catch ( Exception $e ) {
return new WP_Error( self::RESULT_BAD_REQUEST, $e->getMessage(), [ 'status' => 400 ] );
}
// Handle some post-onboarding tasks and get the redirect params.
$finalize = WC_Payments::get_account_service()->finalize_embedded_connection(
$response['mode'],
[
'promo' => $response['promotion_id'] ?? '',
'from' => $from,
'source' => $source,
]
);
// Return the response, the client will handle the redirect.
return rest_ensure_response(
array_merge(
$response,
$finalize
)
);
}
/**
* Get business types via API.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error
*/
public function get_business_types( WP_REST_Request $request ) {
$business_types = $this->onboarding_service->get_cached_business_types();
return rest_ensure_response( [ 'data' => $business_types ] );
}
/**
* Get progressive onboarding eligibility via API.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error
*/
public function get_progressive_onboarding_eligible( WP_REST_Request $request ) {
return $this->forward_request(
'get_onboarding_po_eligible',
[
'business_info' => $request->get_param( 'business' ),
'store_info' => $request->get_param( 'store' ),
'woo_store_stats' => $request->get_param( 'woo_store_stats' ) ?? [],
]
);
}
}
@@ -0,0 +1,591 @@
<?php
/**
* Class WC_REST_Payments_Orders_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
use WCPay\Core\Server\Request\Create_Intention;
use WCPay\Core\Server\Request\Get_Intention;
use WCPay\Logger;
use WCPay\Constants\Order_Status;
use WCPay\Constants\Intent_Status;
use WCPay\Constants\Payment_Method;
/**
* REST controller for order processing.
*/
class WC_REST_Payments_Orders_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/orders';
/**
* Instance of WC_Payment_Gateway_WCPay
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* WC_Payments_Customer_Service instance for working with customer information
*
* @var WC_Payments_Customer_Service
*/
private $customer_service;
/**
* WC_Payments_Order_Service instance for updating order statuses.
*
* @var WC_Payments_Order_Service
*/
private $order_service;
/**
* WC_Payments_REST_Controller constructor.
*
* @param WC_Payments_API_Client $api_client WooCommerce Payments API client.
* @param WC_Payment_Gateway_WCPay $gateway WooCommerce Payments payment gateway.
* @param WC_Payments_Customer_Service $customer_service Customer class instance.
* @param WC_Payments_Order_Service $order_service Order Service class instance.
*/
public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $gateway, WC_Payments_Customer_Service $customer_service, WC_Payments_Order_Service $order_service ) {
parent::__construct( $api_client );
$this->gateway = $gateway;
$this->customer_service = $customer_service;
$this->order_service = $order_service;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<order_id>\w+)/capture_terminal_payment',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'capture_terminal_payment' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'payment_intent_id' => [
'required' => true,
],
],
]
);
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<order_id>\w+)/capture_authorization',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'capture_authorization' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'payment_intent_id' => [
'required' => true,
],
],
]
);
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<order_id>\w+)/cancel_authorization',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'cancel_authorization' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'payment_intent_id' => [
'required' => true,
],
],
]
);
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<order_id>\w+)/create_terminal_intent',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_terminal_intent' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<order_id>\d+)/create_customer',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_customer' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Given an intent ID and an order ID, add the intent ID to the order and capture it.
* Use-cases: Mobile apps using it for `card_present` and `interac_present` payment types.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function capture_terminal_payment( WP_REST_Request $request ) {
try {
$intent_id = $request['payment_intent_id'];
$order_id = $request['order_id'];
// Do not process non-existing orders.
$order = wc_get_order( $order_id );
if ( false === $order ) {
return new WP_Error( 'wcpay_missing_order', __( 'Order not found', 'woocommerce-payments' ), [ 'status' => 404 ] );
}
// Do not process orders with refund(s).
if ( 0 < $order->get_total_refunded() ) {
return new WP_Error(
'wcpay_refunded_order_uncapturable',
__( 'Payment cannot be captured for partially or fully refunded orders.', 'woocommerce-payments' ),
[ 'status' => 400 ]
);
}
// Do not process already processed orders to prevent double-charging.
$processed_order_intent_statuses = [
Intent_Status::SUCCEEDED,
Intent_Status::CANCELED,
Intent_Status::PROCESSING,
];
$stored_intent_id = $order->get_meta( WC_Payments_Order_Service::INTENT_ID_META_KEY );
$stored_intent_status = $order->get_meta( WC_Payments_Order_Service::INTENTION_STATUS_META_KEY );
if (
in_array( $stored_intent_status, $processed_order_intent_statuses, true ) ||
( $stored_intent_id && $stored_intent_id !== $intent_id )
) {
return new WP_Error( 'wcpay_payment_uncapturable', __( 'The payment cannot be captured for completed or processed orders.', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
// Do not process intents that can't be captured.
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$intent_metadata = is_array( $intent->get_metadata() ) ? $intent->get_metadata() : [];
$intent_meta_order_id_raw = $intent_metadata['order_id'] ?? '';
$intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0;
if ( $intent_meta_order_id !== $order->get_id() ) {
Logger::error( 'Payment capture rejected due to failed validation: order id on intent is incorrect or missing.' );
return new WP_Error( 'wcpay_intent_order_mismatch', __( 'The payment cannot be captured', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
if ( ! $intent->is_authorized() ) {
return new WP_Error( 'wcpay_payment_uncapturable', __( 'The payment cannot be captured', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
// Update the order: set the payment method and attach intent attributes.
$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
$order->set_payment_method_title( __( 'WooCommerce In-Person Payments', 'woocommerce-payments' ) );
$this->order_service->attach_intent_info_to_order( $order, $intent );
$this->order_service->update_order_status_from_intent( $order, $intent );
// Certain payments (eg. Interac) are captured on the client-side (mobile app).
// The client may send us the captured intent to link it to its WC order.
// Doing so via this endpoint is more reliable than depending on the payment_intent.succeeded event.
$is_intent_captured = Intent_Status::SUCCEEDED === $intent->get_status();
$result_for_captured_intent = [
'status' => Intent_Status::SUCCEEDED,
'id' => $intent->get_id(),
];
$result = $is_intent_captured ? $result_for_captured_intent : $this->gateway->capture_charge( $order, false, $intent_metadata );
if ( Intent_Status::SUCCEEDED !== $result['status'] ) {
$http_code = $result['http_code'] ?? 502;
$error_code = $result['error_code'] ?? null;
$extra_details = $result['extra_details'] ?? [];
return new WP_Error(
'wcpay_capture_error',
sprintf(
// translators: %s: the error message.
__( 'Payment capture failed to complete with the following message: %s', 'woocommerce-payments' ),
$result['message'] ?? __( 'Unknown error', 'woocommerce-payments' )
),
[
'status' => $http_code,
'extra_details' => $extra_details,
'error_type' => $error_code,
]
);
}
// Store receipt generation URL for mobile applications in order meta-data.
$order->add_meta_data( 'receipt_url', get_rest_url( null, sprintf( '%s/payments/readers/receipts/%s', $this->namespace, $intent->get_id() ) ) );
// Add payment method for future subscription payments.
$generated_card = $intent->get_charge()->get_payment_method_details()[ Payment_Method::CARD_PRESENT ]['generated_card'] ?? null;
// If we don't get a generated card, e.g. because a digital wallet was used, we can still return that the initial payment was successful.
// The subscription will not be activated and customers will need to provide a new payment method for renewals.
if ( $generated_card ) {
$has_subscriptions = function_exists( 'wcs_order_contains_subscription' ) &&
function_exists( 'wcs_get_subscriptions_for_order' ) &&
function_exists( 'wcs_is_manual_renewal_required' ) &&
wcs_order_contains_subscription( $order_id );
if ( $has_subscriptions ) {
$token = WC_Payments::get_token_service()->add_payment_method_to_user( $generated_card, $order->get_user() );
$this->gateway->add_token_to_order( $order, $token );
foreach ( wcs_get_subscriptions_for_order( $order ) as $subscription ) {
$subscription->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
// Where the setting doesn't force manual renewals, we should turn them off, because we have an auto-renewal token now.
if ( ! wcs_is_manual_renewal_required() ) {
$subscription->set_requires_manual_renewal( false );
}
$subscription->save();
}
}
}
// Actualize order status.
$this->order_service->mark_terminal_payment_completed( $order, $intent_id, $result['status'] );
return rest_ensure_response(
[
'status' => $result['status'],
'id' => $result['id'],
]
);
} catch ( \Throwable $e ) {
Logger::error( 'Failed to capture a terminal payment via REST API: ' . $e );
return new WP_Error( 'wcpay_server_error', __( 'Unexpected server error', 'woocommerce-payments' ), [ 'status' => 500 ] );
}
}
/**
* Captures an authorization.
* Use-cases: Merchants manually capturing a payment when they enable "capture later" option.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function capture_authorization( WP_REST_Request $request ) {
try {
$intent_id = $request['payment_intent_id'];
$order_id = $request['order_id'];
// Do not process non-existing orders.
$order = wc_get_order( $order_id );
if ( false === $order ) {
return new WP_Error( 'wcpay_missing_order', __( 'Order not found', 'woocommerce-payments' ), [ 'status' => 404 ] );
}
// Do not process orders with refund(s).
if ( 0 < $order->get_total_refunded() ) {
return new WP_Error(
'wcpay_refunded_order_uncapturable',
__( 'Payment cannot be captured for partially or fully refunded orders.', 'woocommerce-payments' ),
[ 'status' => 400 ]
);
}
// Do not process intents that can't be captured.
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$intent_metadata = is_array( $intent->get_metadata() ) ? $intent->get_metadata() : [];
$intent_meta_order_id_raw = $intent_metadata['order_id'] ?? '';
$intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0;
if ( $intent_meta_order_id !== $order->get_id() ) {
Logger::error( 'Payment capture rejected due to failed validation: order id on intent is incorrect or missing.' );
return new WP_Error( 'wcpay_intent_order_mismatch', __( 'The payment cannot be captured', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
if ( ! $intent->is_authorized() ) {
return new WP_Error( 'wcpay_payment_uncapturable', __( 'The payment cannot be captured', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
$this->add_fraud_outcome_manual_entry( $order, 'approve' );
$result = $this->gateway->capture_charge( $order, true, $intent_metadata );
if ( Intent_Status::SUCCEEDED !== $result['status'] ) {
$error_code = $result['error_code'] ?? null;
$extra_details = $result['extra_details'] ?? [];
return new WP_Error(
'wcpay_capture_error',
sprintf(
// translators: %s: the error message.
__( 'Payment capture failed to complete with the following message: %s', 'woocommerce-payments' ),
$result['message'] ?? __( 'Unknown error', 'woocommerce-payments' )
),
[
'status' => $result['http_code'] ?? 502,
'extra_details' => $extra_details,
'error_type' => $error_code,
]
);
}
$order->save_meta_data();
return rest_ensure_response(
[
'status' => $result['status'],
'id' => $result['id'],
]
);
} catch ( \Throwable $e ) {
Logger::error( 'Failed to capture an authorization via REST API: ' . $e );
return new WP_Error( 'wcpay_server_error', __( 'Unexpected server error', 'woocommerce-payments' ), [ 'status' => 500 ] );
}
}
/**
* Returns customer id from order. Create or update customer if needed.
* Use-cases:
* - It was used by older versions of our mobile apps to add the customer details to Payment Intents.
* - It is used by the apps to set customer details on Payment Intents for an order containing subscriptions. Required for capturing renewal payments off session.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function create_customer( $request ) {
try {
$order_id = $request['order_id'];
// Do not process non-existing orders.
$order = wc_get_order( $order_id );
if ( false === $order || ! ( $order instanceof WC_Order ) ) {
return new WP_Error( 'wcpay_missing_order', __( 'Order not found', 'woocommerce-payments' ), [ 'status' => 404 ] );
}
$disallowed_order_statuses = apply_filters(
'wcpay_create_customer_disallowed_order_statuses',
[
Order_Status::COMPLETED,
Order_Status::CANCELLED,
Order_Status::REFUNDED,
Order_Status::FAILED,
]
);
if ( $order->has_status( $disallowed_order_statuses ) ) {
return new WP_Error( 'wcpay_invalid_order_status', __( 'Invalid order status', 'woocommerce-payments' ), [ 'status' => 400 ] );
}
$order_user = $order->get_user();
$customer_id = $this->order_service->get_customer_id_for_order( $order );
$customer_data = WC_Payments_Customer_Service::map_customer_data( $order );
$is_guest_customer = false === $order_user;
// If the order is created for a registered customer, try extracting it's Stripe customer ID.
if ( ! $customer_id && ! $is_guest_customer ) {
$customer_id = $this->customer_service->get_customer_id_by_user_id( $order_user->ID );
}
$order_user = $is_guest_customer ? new WP_User() : $order_user;
$customer_id = $customer_id
? $this->customer_service->update_customer_for_user( $customer_id, $order_user, $customer_data )
: $this->customer_service->create_customer_for_user( $order_user, $customer_data );
$this->order_service->set_customer_id_for_order( $order, $customer_id );
$order->save();
return rest_ensure_response(
[
'id' => $customer_id,
]
);
} catch ( \Throwable $e ) {
Logger::error( 'Failed to create / update customer from order via REST API: ' . $e );
return new WP_Error( 'wcpay_server_error', __( 'Unexpected server error', 'woocommerce-payments' ), [ 'status' => 500 ] );
}
}
/**
* Create a new in-person payment intent for the given order ID without confirming it.
* Use-cases: Mobile apps using it for `card_present` payment types. (`interac_present` is handled by the apps via Stripe SDK).
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function create_terminal_intent( $request ) {
// Do not process non-existing orders.
$order = wc_get_order( $request['order_id'] );
if ( false === $order ) {
return new WP_Error( 'wcpay_missing_order', __( 'Order not found', 'woocommerce-payments' ), [ 'status' => 404 ] );
}
try {
$currency = strtolower( $order->get_currency() );
$customer_id = $request->get_param( 'customer_id' );
$metadata = $request->get_param( 'metadata' ) ?? [];
$metadata['order_number'] = $order->get_order_number();
$wcpay_server_request = Create_Intention::create();
$wcpay_server_request->set_currency_code( $currency );
$wcpay_server_request->set_amount( WC_Payments_Utils::prepare_amount( $order->get_total(), $currency ) );
if ( $customer_id ) {
$wcpay_server_request->set_customer( $customer_id );
}
$wcpay_server_request->set_metadata( $metadata );
$wcpay_server_request->set_payment_method_types( $this->get_terminal_intent_payment_method( $request ) );
$wcpay_server_request->set_capture_method( 'manual' === $this->get_terminal_intent_capture_method( $request ) );
$wcpay_server_request->set_hook_args( $order );
$intent = $wcpay_server_request->send();
return rest_ensure_response(
[
'id' => ! empty( $intent ) ? $intent->get_id() : null,
]
);
} catch ( \Throwable $e ) {
Logger::error( 'Failed to create an intention via REST API: ' . $e );
return new WP_Error( 'wcpay_server_error', __( 'Unexpected server error', 'woocommerce-payments' ), [ 'status' => 500 ] );
}
}
/**
* Return terminal intent payment method array based on payment methods request.
*
* @param WP_REST_Request $request Request object.
* @param array $default_value - default value.
*
* @return array|null
* @throws \Exception
*/
public function get_terminal_intent_payment_method( $request, array $default_value = [ Payment_Method::CARD_PRESENT ] ): array {
$payment_methods = $request->get_param( 'payment_methods' );
if ( null === $payment_methods ) {
return $default_value;
}
if ( ! is_array( $payment_methods ) ) {
throw new \Exception( 'Invalid param \'payment_methods\'!' );
}
foreach ( $payment_methods as $value ) {
if ( ! in_array( $value, Payment_Method::IPP_ALLOWED_PAYMENT_METHODS, true ) ) {
throw new \Exception( 'One or more payment methods are not supported!' );
}
}
return $payment_methods;
}
/**
* Return terminal intent capture method based on capture method request.
*
* @param WP_REST_Request $request Request object.
* @param string $default_value default value.
*
* @return string|null
* @throws \Exception
*/
public function get_terminal_intent_capture_method( $request, string $default_value = 'manual' ): string {
$capture_method = $request->get_param( 'capture_method' );
if ( null === $capture_method ) {
return $default_value;
}
if ( ! in_array( $capture_method, [ 'manual', 'automatic' ], true ) ) {
throw new \Exception( 'Invalid param \'capture_method\'!' );
}
return $capture_method;
}
/**
* Cancels an authorization.
* Use-cases: Merchants manually canceling when blocking an on hold review by Fraud & Risk tools.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function cancel_authorization( WP_REST_Request $request ) {
try {
$intent_id = $request['payment_intent_id'];
$order_id = $request['order_id'];
// Do not process non-existing orders.
$order = wc_get_order( $order_id );
if ( false === $order ) {
return new WP_Error( 'wcpay_missing_order', __( 'Order not found', 'woocommerce-payments' ), [ 'status' => 404 ] );
}
// Do not process orders with refund(s).
if ( 0 < $order->get_total_refunded() ) {
return new WP_Error(
'wcpay_refunded_order_uncapturable',
__( 'Payment cannot be canceled for partially or fully refunded orders.', 'woocommerce-payments' ),
[ 'status' => 400 ]
);
}
// Do not process intents that can't be canceled.
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$intent_metadata = is_array( $intent->get_metadata() ) ? $intent->get_metadata() : [];
$intent_meta_order_id_raw = $intent_metadata['order_id'] ?? '';
$intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0;
if ( $intent_meta_order_id !== $order->get_id() ) {
Logger::error( 'Payment cancellation rejected due to failed validation: order id on intent is incorrect or missing.' );
return new WP_Error( 'wcpay_intent_order_mismatch', __( 'The payment cannot be canceled', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
if ( ! in_array( $intent->get_status(), [ Intent_Status::REQUIRES_CAPTURE ], true ) ) {
return new WP_Error( 'wcpay_payment_uncapturable', __( 'The payment cannot be canceled', 'woocommerce-payments' ), [ 'status' => 409 ] );
}
$this->add_fraud_outcome_manual_entry( $order, 'block' );
$result = $this->gateway->cancel_authorization( $order );
if ( Intent_Status::CANCELED !== $result['status'] ) {
return new WP_Error(
'wcpay_cancel_error',
sprintf(
// translators: %s: the error message.
__( 'Payment cancel failed to complete with the following message: %s', 'woocommerce-payments' ),
$result['message'] ?? __( 'Unknown error', 'woocommerce-payments' )
),
[ 'status' => $result['http_code'] ?? 502 ]
);
}
$order->save_meta_data();
return rest_ensure_response(
[
'status' => $result['status'],
'id' => $result['id'],
]
);
} catch ( \Throwable $e ) {
Logger::error( 'Failed to cancel an authorization via REST API: ' . $e );
return new WP_Error( 'wcpay_server_error', __( 'Unexpected server error', 'woocommerce-payments' ), [ 'status' => 500 ] );
}
}
/**
* Adds the fraud_outcome_manual_entry meta to the order.
*
* @param \WC_Order $order Order object.
* @param string $action User action.
*/
private function add_fraud_outcome_manual_entry( $order, $action ) {
$current_user = wp_get_current_user();
$order->add_meta_data(
'_wcpay_fraud_outcome_manual_entry',
[
'type' => 'fraud_outcome_manual_' . $action,
'user' => [
'id' => $current_user->ID,
'username' => $current_user->user_login,
],
'action' => 'block' === $action ? 'blocked' : 'approved',
'datetime' => time(),
]
);
}
}
@@ -0,0 +1,54 @@
<?php
/**
* Class WC_REST_Payments_Payment_Intents_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Create_And_Confirm_Intention;
use WCPay\Logger;
use WCPay\Exceptions\Rest_Request_Exception;
use WCPay\Constants\Payment_Type;
use WCPay\Internal\Service\Level3Service;
use WCPay\Internal\Service\OrderService;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for charges.
*/
class WC_REST_Payments_Payment_Intents_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/payment_intents';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<payment_intent_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_payment_intent' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve charge to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_payment_intent( $request ) {
$payment_intent_id = $request->get_param( 'payment_intent_id' );
return $this->forward_request( 'get_intent', [ $payment_intent_id ] );
}
}
@@ -0,0 +1,374 @@
<?php
/**
* Class WC_REST_Payments_Payment_Intents_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Create_And_Confirm_Intention;
use WCPay\Logger;
use WCPay\Exceptions\Rest_Request_Exception;
use WCPay\Constants\Payment_Type;
use WCPay\Internal\Service\Level3Service;
use WCPay\Internal\Service\OrderService;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for charges.
*/
class WC_REST_Payments_Payment_Intents_Create_Controller extends WC_Payments_REST_Controller {
/**
* Instance of WC_Payment_Gateway_WCPay
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* Order service instance.
*
* @var OrderService
*/
private $order_service;
/**
* Level3 service instance.
*
* @var Level3Service
*/
private $level3_service;
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/payment_intents';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_payment_intent' ],
'permission_callback' => [ $this, 'check_permission' ],
'schema' => [ $this, 'get_item_schema' ],
]
);
}
/**
* WC_REST_Payments_Payment_Intents_Create_Controller constructor.
*
* @param WC_Payments_API_Client $api_client WooCommerce Payments API client.
* @param WC_Payment_Gateway_WCPay $gateway WooCommerce Payments payment gateway.
* @param OrderService $order_service The new order servie.
* @param Level3Service $level3_service Level3 service instance.
*/
public function __construct(
WC_Payments_API_Client $api_client,
WC_Payment_Gateway_WCPay $gateway,
OrderService $order_service,
Level3Service $level3_service
) {
parent::__construct( $api_client );
$this->gateway = $gateway;
$this->order_service = $order_service;
$this->level3_service = $level3_service;
}
/**
* Create a payment intent.
*
* @param WP_REST_Request $request data about the request.
*
* @throws Rest_Request_Exception
*/
public function create_payment_intent( $request ) {
try {
$order_id = $request->get_param( 'order_id' );
$order = wc_get_order( $order_id );
if ( ! $order ) {
throw new Rest_Request_Exception( __( 'Order not found', 'woocommerce-payments' ) );
}
$wcpay_server_request = Create_And_Confirm_Intention::create();
$currency = strtolower( $order->get_currency() );
$amount = WC_Payments_Utils::prepare_amount( $order->get_total(), $currency );
$wcpay_server_request->set_currency_code( $currency );
$wcpay_server_request->set_amount( $amount );
$metadata = $this->order_service->get_payment_metadata( $order_id, Payment_Type::SINGLE() );
$wcpay_server_request->set_metadata( $metadata );
$wcpay_server_request->set_customer( $request->get_param( 'customer' ) );
$wcpay_server_request->set_level3( $this->level3_service->get_data_from_order( $order_id ) );
$wcpay_server_request->set_payment_method( $request->get_param( 'payment_method' ) );
$wcpay_server_request->set_payment_method_types( [ 'card' ] );
$wcpay_server_request->set_off_session( true );
$wcpay_server_request->set_capture_method( $this->gateway->get_option( 'manual_capture' ) && ( 'yes' === $this->gateway->get_option( 'manual_capture' ) ) );
$wcpay_server_request->assign_hook( 'wcpay_create_and_confirm_intent_request_api' );
$intent = $wcpay_server_request->send();
$response = $this->prepare_item_for_response( $intent, $request );
return rest_ensure_response( $this->prepare_response_for_collection( $response ) );
} catch ( \Throwable $e ) {
Logger::error( 'Failed to create an intention via REST API: ' . $e );
return new WP_Error( 'wcpay_server_error', $e->getMessage(), [ 'status' => 500 ] );
}
}
/**
* Item schema.
*
* @return array
*/
public function get_item_schema() {
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'payment_intent',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'ID for the payment intent.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'amount' => [
'description' => __( 'The amount of the transaction.', 'woocommerce-payments' ),
'type' => 'integer',
'context' => [ 'view' ],
],
'currency' => [
'description' => __( 'The currency of the transaction.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'created' => [
'description' => __( 'The date when the payment intent was created.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'customer' => [
'description' => __( 'The customer id of the intent', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'status' => [
'description' => __( 'The status of the payment intent.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'charge' => [
'description' => __( 'Charge object associated with this payment intention.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'id' => [
'description' => 'ID for the charge.',
'type' => 'string',
'context' => [ 'view' ],
],
'amount' => [
'description' => 'The amount of the charge.',
'type' => 'integer',
'context' => [ 'view' ],
],
'payment_method_details' => [
'description' => 'Details for the payment method used for the charge.',
'type' => 'object',
'properties' => [
'card' => [
'description' => 'Details for a card payment method.',
'type' => 'object',
'properties' => [
'amount_authorized' => [
'description' => 'The amount authorized by the card.',
'type' => 'integer',
],
'brand' => [
'description' => 'The brand of the card.',
'type' => 'string',
],
'capture_before' => [
'description' => 'Timestamp for when the authorization must be captured.',
'type' => 'string',
],
'country' => [
'description' => 'The ISO country code.',
'type' => 'string',
],
'exp_month' => [
'description' => 'The expiration month of the card.',
'type' => 'integer',
],
'exp_year' => [
'description' => 'The expiration year of the card.',
'type' => 'integer',
],
'last4' => [
'description' => 'The last 4 digits of the card.',
'type' => 'string',
],
'three_d_secure' => [
'description' => 'Details for 3D Secure authentication.',
'type' => 'object',
],
],
],
],
],
'billing_details' => [
'description' => __( 'Billing details for the payment method.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'address' => [
'description' => __( 'Address associated with the billing details.', 'woocommerce-payments' ),
'type' => 'object',
'context' => [ 'view' ],
'properties' => [
'city' => [
'description' => __( 'City of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'country' => [
'description' => __( 'Country of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'line1' => [
'description' => __( 'Line 1 of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'line2' => [
'description' => __( 'Line 2 of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'postal_code' => [
'description' => __( 'Postal code of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'state' => [
'description' => __( 'State of the billing address.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
],
],
'email' => [
'description' => __( 'Email associated with the billing details.', 'woocommerce-payments' ),
'type' => 'string',
'format' => 'email',
'context' => [ 'view' ],
],
'name' => [
'description' => __( 'Name associated with the billing details.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
'phone' => [
'description' => __( 'Phone number associated with the billing details.', 'woocommerce-payments' ),
'type' => 'string',
'context' => [ 'view' ],
],
],
],
'payment_method' => [
'description' => 'The payment method associated with this charge.',
'type' => 'string',
'context' => [ 'view' ],
],
'application_fee_amount' => [
'description' => 'The application fee amount.',
'type' => 'integer',
'context' => [ 'view' ],
],
'status' => [
'description' => 'The status of the payment intent created.',
'type' => 'string',
'context' => [ 'view' ],
],
],
],
],
];
}
/**
* Prepare each item for response.
*
* @param array|mixed $item Item to prepare.
* @param WP_REST_Request $request Request instance.
*
* @return WP_REST_Response|WP_Error
*/
public function prepare_item_for_response( $item, $request ) {
$prepared_item = [];
$prepared_item['id'] = $item->get_id();
$prepared_item['amount'] = $item->get_amount();
$prepared_item['currency'] = $item->get_currency();
$prepared_item['created'] = $item->get_created()->format( 'Y-m-d H:i:s' );
$prepared_item['customer'] = $item->get_customer_id();
$prepared_item['payment_method'] = $item->get_payment_method_id();
$prepared_item['status'] = $item->get_status();
try {
$charge = $item->get_charge();
$prepared_item['charge']['id'] = $charge->get_id();
$prepared_item['charge']['amount'] = $charge->get_amount();
$prepared_item['charge']['application_fee_amount'] = $charge->get_application_fee_amount();
$prepared_item['charge']['status'] = $charge->get_status();
$billing_details = $charge->get_billing_details();
if ( isset( $billing_details['address'] ) ) {
$prepared_item['charge']['billing_details']['address']['city'] = $billing_details['address']['city'] ?? '';
$prepared_item['charge']['billing_details']['address']['country'] = $billing_details['address']['country'] ?? '';
$prepared_item['charge']['billing_details']['address']['line1'] = $billing_details['address']['line1'] ?? '';
$prepared_item['charge']['billing_details']['address']['line2'] = $billing_details['address']['line2'] ?? '';
$prepared_item['charge']['billing_details']['address']['postal_code'] = $billing_details['address']['postal_code'] ?? '';
$prepared_item['charge']['billing_details']['address']['state'] = $billing_details['address']['state'] ?? '';
}
$prepared_item['charge']['billing_details']['email'] = $billing_details['email'] ?? '';
$prepared_item['charge']['billing_details']['name'] = $billing_details['name'] ?? '';
$prepared_item['charge']['billing_details']['phone'] = $billing_details['phone'] ?? '';
$payment_method_details = $charge->get_payment_method_details();
if ( isset( $payment_method_details['card'] ) ) {
$prepared_item['charge']['payment_method_details']['card']['amount_authorized'] = $payment_method_details['card']['amount_authorized'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['brand'] = $payment_method_details['card']['brand'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['capture_before'] = $payment_method_details['card']['capture_before'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['country'] = $payment_method_details['card']['country'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['exp_month'] = $payment_method_details['card']['exp_month'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['exp_year'] = $payment_method_details['card']['exp_year'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['last4'] = $payment_method_details['card']['last4'] ?? '';
$prepared_item['charge']['payment_method_details']['card']['three_d_secure'] = $payment_method_details['card']['three_d_secure'] ?? '';
}
} catch ( \Throwable $e ) {
Logger::error( 'Failed to prepare payment intent for response: ' . $e );
}
$context = $request['context'] ?? 'view';
$prepared_item = $this->add_additional_fields_to_object( $prepared_item, $request );
$prepared_item = $this->filter_response_by_context( $prepared_item, $context );
return rest_ensure_response( $prepared_item );
}
}
@@ -0,0 +1,360 @@
<?php
/**
* Class WC_REST_Payments_Reader_Charges
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Get_Charge;
use WCPay\Core\Server\Request\Get_Intention;
use WCPay\Constants\Intent_Status;
use WCPay\Core\Server\Request;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
require_once WCPAY_ABSPATH . 'includes/in-person-payments/class-wc-payments-printed-receipt-sample-order.php';
/**
* REST controller for reader charges.
*/
class WC_REST_Payments_Reader_Controller extends WC_Payments_REST_Controller {
const STORE_READERS_TRANSIENT_KEY = 'wcpay_store_terminal_readers';
const PREVIEW_RECEIPT_CHARGE_DATA = [
'amount_captured' => 0,
'payment_method_details' => [
'card_present' => [
'brand' => 'Sample',
'last4' => '0000',
'receipt' => [
'application_preferred_name' => 'Sample, Receipts preview',
'dedicated_file_name' => '0000',
'account_type' => 'Sample',
],
],
],
];
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/readers';
/**
* Instance of WC_Payment_Gateway_WCPay.
*
* @var WC_Payment_Gateway_WCPay
*/
private $wcpay_gateway;
/**
* Instance of WC_Payments_In_Person_Payments_Receipts_Service.
*
* @var WC_Payments_In_Person_Payments_Receipts_Service
*/
private $receipts_service;
/**
* WC_REST_Payments_Reader_Controller
*
* @param WC_Payments_API_Client $api_client WC_Payments_API_Client.
* @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay.
* @param WC_Payments_In_Person_Payments_Receipts_Service $receipts_service WC_Payments_In_Person_Payments_Receipts_Service.
* @return void
*/
public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $wcpay_gateway, WC_Payments_In_Person_Payments_Receipts_Service $receipts_service ) {
parent::__construct( $api_client );
$this->wcpay_gateway = $wcpay_gateway;
$this->receipts_service = $receipts_service;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_all_readers' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'register_reader' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'location' => [
'type' => 'string',
'required' => true,
],
'registration_code' => [
'type' => 'string',
'required' => true,
],
'label' => [
'type' => 'string',
],
'metadata' => [
'type' => 'object',
],
],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/charges/(?P<transaction_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/receipts/preview',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'preview_print_receipt' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/receipts/(?P<payment_intent_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'generate_print_receipt' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve payment readers charges to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function get_summary( $request ) {
$transaction_id = $request->get_param( 'transaction_id' );
try {
// retrieve transaction details to get the charge date.
$transaction = $this->api_client->get_transaction( $transaction_id );
if ( empty( $transaction ) ) {
return rest_ensure_response( [] );
}
$summary = $this->api_client->get_readers_charge_summary( gmdate( 'Y-m-d', $transaction['created'] ) );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( 'wcpay_get_summary', $e->getMessage() ) );
}
return rest_ensure_response( $summary );
}
/**
* Proxies the get all readers request to the server.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function get_all_readers( $request ) {
try {
return rest_ensure_response( $this->fetch_readers() );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( $e->get_error_code(), $e->getMessage() ) );
}
}
/**
* Links a card reader to an account and terminal location.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function register_reader( $request ) {
try {
$response = $this->api_client->register_terminal_reader(
$request->get_param( 'location' ),
$request->get_param( 'registration_code' ),
$request->get_param( 'label' ),
$request->get_param( 'metadata' )
);
$reader = wp_array_slice_assoc( $response, [ 'id', 'livemode', 'device_type', 'label', 'location', 'metadata', 'status' ] );
return rest_ensure_response( $reader );
} catch ( API_Exception $e ) {
return rest_ensure_response(
new WP_Error(
$e->get_error_code(),
$e->getMessage(),
[ 'status' => $e->get_http_code() ]
)
);
}
}
/**
* Check if the reader status is active
*
* @param array $readers The readers charges object.
* @param string $id The reader ID.
* @return bool
*/
private function is_reader_active( $readers, $id ) {
foreach ( $readers as $reader ) {
if ( $reader['reader_id'] === $id && 'active' === $reader['status'] ) {
return true;
}
}
return false;
}
/**
* Attempts to read readers from transient and re-populates it if needed.
*
* @return array Terminal readers.
* @throws API_Exception If request to server fails.
*/
private function fetch_readers(): array {
$readers = get_transient( static::STORE_READERS_TRANSIENT_KEY );
if ( ! $readers ) {
// Retrieve terminal readers.
$request = Request::get( WC_Payments_API_Client::TERMINAL_READERS_API );
$request->assign_hook( 'wcpay_get_terminal_readers_request' );
$readers_data = $request->send();
// Retrieve the readers by charges.
$reader_by_charges = $this->api_client->get_readers_charge_summary( gmdate( 'Y-m-d', time() ) );
$readers = [];
foreach ( $readers_data as $reader ) {
$readers[] = [
'id' => $reader['id'],
'livemode' => $reader['livemode'],
'device_type' => $reader['device_type'],
'label' => $reader['label'],
'location' => $reader['location'],
'metadata' => $reader['metadata'],
'status' => $reader['status'],
'is_active' => $this->is_reader_active( $reader_by_charges, $reader['id'] ),
];
}
set_transient( static::STORE_READERS_TRANSIENT_KEY, $readers, 2 * HOUR_IN_SECONDS );
}
return $readers;
}
/**
* Renders HTML for a print receipt
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_HTTP_Response|WP_Error
* @throws \RuntimeException Error collecting data.
*/
public function generate_print_receipt( $request ) {
try {
/* Collect the data, available on the server side. */
$wcpay_request = Get_Intention::create( $request->get_param( 'payment_intent_id' ) );
$payment_intent = $wcpay_request->send();
if ( Intent_Status::SUCCEEDED !== $payment_intent->get_status() ) {
throw new \RuntimeException( __( 'Invalid payment intent', 'woocommerce-payments' ) );
}
$charge = $payment_intent->get_charge();
$charge_id = $charge ? $charge->get_id() : null;
$charge_request = Get_Charge::create( $charge_id );
$charge_array = $charge_request->send();
/* Collect receipt data, stored on the store side. */
$order = wc_get_order( $charge_array['order']['number'] );
if ( false === $order ) {
throw new \RuntimeException( __( 'Order not found', 'woocommerce-payments' ) );
}
// Retrieve branding logo file ID.
$branding_logo = $this->wcpay_gateway->get_option( 'account_branding_logo', '' );
/* Collect merchant settings */
$settings = [
'branding_logo' => ( ! empty( $branding_logo ) ) ? $this->api_client->get_file_contents( $branding_logo, false ) : [],
'business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ),
'support_info' => [
'address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ),
'phone' => $this->wcpay_gateway->get_option( 'account_business_support_phone' ),
'email' => $this->wcpay_gateway->get_option( 'account_business_support_email' ),
],
];
/* Generate receipt */
$response = [ 'html_content' => $this->receipts_service->get_receipt_markup( $settings, $order, $charge_array ) ];
} catch ( \Throwable $e ) {
$error_status_code = $e instanceof API_Exception ? $e->get_http_code() : 500;
$response = new WP_Error( 'generate_print_receipt_error', $e->getMessage(), [ 'status' => $error_status_code ] );
}
return rest_ensure_response( $response );
}
/**
* Returns HTML to preview a print receipt
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_HTTP_Response|WP_Error
* @throws \RuntimeException Error collecting data.
*/
public function preview_print_receipt( WP_REST_Request $request ) {
$preview = $this->receipts_service->get_receipt_markup(
$this->create_print_preview_receipt_settings_data( $request->get_json_params() ),
new WC_Payments_Printed_Receipt_Sample_Order(),
self::PREVIEW_RECEIPT_CHARGE_DATA
);
return rest_ensure_response( [ 'html_content' => $preview ] );
}
/**
* Creates settings data to be used on the printed receipt preview. Defaults to stored settings if one parameter is not provided.
*
* @param array $receipt_settings Array of settings to use to create the receipt preview.
* @return array
*/
private function create_print_preview_receipt_settings_data( array $receipt_settings ): array {
$support_address = empty( $receipt_settings['accountBusinessSupportAddress'] ) ? $this->wcpay_gateway->get_option( 'account_business_support_address' ) : $receipt_settings['accountBusinessSupportAddress'];
return [
'business_name' => empty( $receipt_settings['accountBusinessName'] ) ? $this->wcpay_gateway->get_option( 'account_business_name' ) : $receipt_settings['accountBusinessName'],
'support_info' => [
'address' => [
'line1' => $support_address['line1'],
'line2' => $support_address['line2'],
'city' => $support_address['city'],
'state' => $support_address['state'],
'postal_code' => $support_address['postal_code'],
'country' => $support_address['country'],
],
'phone' => empty( $receipt_settings['accountBusinessSupportPhone'] ) ? $this->wcpay_gateway->get_option( 'account_business_support_phone' ) : $receipt_settings['accountBusinessSupportPhone'],
'email' => empty( $receipt_settings['accountBusinessSupportEmail'] ) ? $this->wcpay_gateway->get_option( 'account_business_support_email' ) : $receipt_settings['accountBusinessSupportEmail'],
],
];
}
}
@@ -0,0 +1,81 @@
<?php
/**
* Class WC_REST_Payments_Timeline_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Refund_Charge;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for the timeline, which includes all events related to an intention.
*/
class WC_REST_Payments_Refunds_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/refund';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'process_refund' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Makes direct refund bypassing any order checks.
*
* @internal Not intended for usage in integrations or outside of WooCommerce Payments.
* @param WP_REST_Request $request Full data about the request.
*/
public function process_refund( $request ) {
$order_id = $request->get_param( 'order_id' );
$charge_id = $request->get_param( 'charge_id' );
$amount = $request->get_param( 'amount' );
$reason = $request->get_param( 'reason' );
if ( $order_id ) {
$order = wc_get_order( $order_id );
if ( $order ) {
$result = wc_create_refund(
[
'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ),
'reason' => $reason,
'order_id' => $order_id,
'refund_payment' => true,
'restock_items' => true,
]
);
return rest_ensure_response( $result );
}
}
try {
$refund_request = Refund_Charge::create( $charge_id );
$refund_request->set_charge( $charge_id );
$refund_request->set_amount( $amount );
$refund_request->set_reason( $reason );
$refund_request->set_source( 'transaction_details_no_order' );
$response = $refund_request->send();
return rest_ensure_response( $response );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( 'wcpay_refund_payment', $e->getMessage() ) );
}
}
}
@@ -0,0 +1,71 @@
<?php
/**
* Class WC_REST_Payments_Reporting_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Get_Reporting_Payment_Activity;
use WCPay\Core\Server\Request\Request_Utils;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for customers.
*/
class WC_REST_Payments_Reporting_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/reporting';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/payment_activity',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_payment_activity' ],
'permission_callback' => [ $this, 'check_permission' ],
],
]
);
}
/**
* Retrieve the Payment Activity data.
*
* @param WP_REST_Request $request The request.
*/
public function get_payment_activity( $request ) {
$wcpay_request = Get_Reporting_Payment_Activity::create();
$date_start_in_store_timezone = $this->format_date_to_wp_timezone( $request->get_param( 'date_start' ) );
$date_end_in_store_timezone = $this->format_date_to_wp_timezone( $request->get_param( 'date_end' ) );
$wcpay_request->set_date_start( $date_start_in_store_timezone );
$wcpay_request->set_date_end( $date_end_in_store_timezone );
$wcpay_request->set_timezone( $request->get_param( 'timezone' ) );
$wcpay_request->set_currency( $request->get_param( 'currency' ) );
return $wcpay_request->handle_rest_request();
}
/**
* Formats a date string to the WordPress timezone.
*
* @param string $date_string The date string to be formatted.
* @return string The formatted date string in the 'Y-m-d\TH:i:s' format.
*/
private function format_date_to_wp_timezone( $date_string ) {
$date = Request_Utils::format_transaction_date_by_timezone( $date_string, '+00:00' );
$date = new DateTime( $date );
return $date->format( 'Y-m-d\\TH:i:s' );
}
}
@@ -0,0 +1,149 @@
<?php
/**
* Class WC_REST_Payments_Survey_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* REST controller for settings.
*/
class WC_REST_Payments_Survey_Controller extends WP_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v3';
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/survey';
/**
* The HTTP client, used to forward the request to WPCom.
*
* @var WC_Payments_Http
*/
protected $http_client;
/**
* The constructor.
* WC_REST_Payments_Survey_Controller constructor.
*
* @param WC_Payments_Http_Interface $http_client - The HTTP client, used to forward the request to WPCom.
*/
public function __construct( WC_Payments_Http_Interface $http_client ) {
$this->http_client = $http_client;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/payments-overview',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'submit_payments_overview_survey' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'rating' => [
'type' => 'string',
'required' => true,
'enum' => [
'very-unhappy',
'unhappy',
'neutral',
'happy',
'very-happy',
],
'validate_callback' => 'rest_validate_request_arg',
],
'comments' => [
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_filter_nohtml_kses',
],
],
]
);
}
/**
* Submits the overview survey trhough the WPcom API.
*
* @param WP_REST_Request $request the request being made.
*
* @return WP_REST_Response
*/
public function submit_payments_overview_survey( WP_REST_Request $request ): WP_REST_Response {
$comments = $request->get_param( 'comments' ) ?? '';
$rating = $request->get_param( 'rating' ) ?? '';
if ( empty( $rating ) ) {
return new WP_REST_Response(
[
'success' => false,
'err' => 'No answers provided',
],
400
);
}
$request_args = [
'url' => WC_Payments_API_Client::ENDPOINT_BASE . '/marketing/survey',
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json',
'X-Forwarded-For' => \WC_Geolocation::get_ip_address(),
],
];
$request_body = wp_json_encode(
[
'site_id' => $this->http_client->get_blog_id(),
'survey_id' => 'wcpay-payment-activity',
'survey_responses' => [
'rating' => $rating,
'comments' => [ 'text' => $comments ],
'wcpay-version' => [ 'text' => WCPAY_VERSION_NUMBER ],
],
]
);
$is_site_specific = true;
$use_user_token = true;
$wpcom_response = $this->http_client->remote_request(
$request_args,
$request_body,
$is_site_specific,
$use_user_token
);
$wpcom_response_status_code = wp_remote_retrieve_response_code( $wpcom_response );
if ( 200 === $wpcom_response_status_code ) {
update_option( 'wcpay_survey_payment_overview_submitted', true );
}
return new WP_REST_Response( $wpcom_response, $wpcom_response_status_code );
}
/**
* Verify access.
*
* Override this method if custom permissions required.
*
* @return bool
*/
public function check_permission() {
return current_user_can( 'manage_woocommerce' );
}
}
@@ -0,0 +1,329 @@
<?php
/**
* Class WC_REST_Payments_Terminal_Locations_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
use WCPay\Core\Server\Request;
use WCPay\Exceptions\API_Exception;
/**
* REST controller for account details and status.
*/
class WC_REST_Payments_Terminal_Locations_Controller extends WC_Payments_REST_Controller {
const STORE_LOCATIONS_TRANSIENT_KEY = 'wcpay_store_terminal_locations';
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/terminal/locations';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/store',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_store_location' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<location_id>\w+)',
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_location' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<location_id>\w+)',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'update_location' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'display_name' => [
'type' => 'string',
'required' => false,
],
'address' => [
'type' => 'object',
'required' => false,
],
],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<location_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_location' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_location' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'display_name' => [
'type' => 'string',
'required' => true,
],
'address' => [
'type' => 'object',
'required' => true,
],
],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_all_locations' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Get store terminal location.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_REST_Response|WP_Error
*/
public function get_store_location( $request ) {
$store_address = WC()->countries;
$location_address = array_filter(
[
'city' => $store_address->get_base_city(),
'country' => $store_address->get_base_country(),
'line1' => $store_address->get_base_address(),
'line2' => $store_address->get_base_address_2(),
'postal_code' => $store_address->get_base_postcode(),
'state' => $store_address->get_base_state(),
]
);
// If address is not populated, emit an error and specify the URL where this can be done.
// See also https://tosbourn.com/list-of-countries-without-a-postcode/ when launching in new countries.
$is_address_populated = isset( $location_address['country'], $location_address['city'], $location_address['postal_code'], $location_address['line1'] );
if ( ! $is_address_populated ) {
return rest_ensure_response(
new \WP_Error(
'store_address_is_incomplete',
admin_url(
add_query_arg(
[
'page' => 'wc-settings',
'tab' => 'general',
],
'admin.php'
)
)
)
);
}
try {
// Check the existing locations to see if one of them matches the store.
// Originally we picked `get_bloginfo` for generating names, but later switched to `site_url` for max immutability.
$store_hostname = str_replace( [ 'https://', 'http://' ], '', get_site_url() );
$possible_names = [ get_bloginfo(), $store_hostname ];
foreach ( $this->fetch_locations() as $location ) {
if ( in_array( $location['display_name'], $possible_names, true ) ) {
$matching_address_fields = array_intersect( $location['address'], $location_address );
if ( count( $matching_address_fields ) === count( $location_address ) ) {
return rest_ensure_response( $this->extract_location_fields( $location ) );
}
}
}
// If the location is missing, Create a new one and actualize the transient.
$location = $this->api_client->create_terminal_location( $store_hostname, $location_address );
$this->reload_locations();
return rest_ensure_response( $this->extract_location_fields( $location ) );
} catch ( API_Exception $e ) {
$error = new WP_Error( $e->get_error_code(), $e->getMessage() );
// Stripe will return a 400 for incorrect city, state, or country. Ideally, we should return
// a more appropriate error code like 'store_address_is_incorrect', but that will break older mobile app clients.
// Until we have a more granular versioning support for WCPay REST endpoints, this is the best we can do.
if ( 'invalid_request_error' === $e->get_error_code() ) {
$error = new WP_Error(
'store_address_is_incomplete',
admin_url(
add_query_arg(
[
'page' => 'wc-settings',
'tab' => 'general',
],
'admin.php'
)
)
);
}
return rest_ensure_response( $error );
}
}
/**
* Proxies the delete location request to the server.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function delete_location( $request ) {
try {
// Delete the location and reload the transient.
$location = $this->api_client->delete_terminal_location( $request->get_param( 'location_id' ) );
$this->reload_locations();
return rest_ensure_response( $location );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( $e->get_error_code(), $e->getMessage() ) );
}
}
/**
* Proxies the update location request to the server.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function update_location( $request ) {
try {
// Update the location and reload the transient.
$location = $this->api_client->update_terminal_location( $request->get_param( 'location_id' ), $request['display_name'], $request['address'] );
$this->reload_locations();
return rest_ensure_response( $this->extract_location_fields( $location ) );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( $e->get_error_code(), $e->getMessage() ) );
}
}
/**
* Proxies the get location request to the server.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function get_location( $request ) {
try {
// Check if the location is already in the transient.
$location_id = $request->get_param( 'location_id' );
foreach ( $this->fetch_locations() as $location ) {
if ( $location['id'] === $location_id ) {
return rest_ensure_response( $this->extract_location_fields( $location ) );
}
}
// If the location is missing, fetch it individually and reload the transient.
$request = Request::get( WC_Payments_API_Client::TERMINAL_LOCATIONS_API, $location_id );
$request->assign_hook( 'wcpay_get_terminal_location' );
$location = $request->send();
$this->reload_locations();
return rest_ensure_response( $this->extract_location_fields( $location ) );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( $e->get_error_code(), $e->getMessage() ) );
}
}
/**
* Proxies the create location request to the server.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function create_location( $request ) {
try {
// Create location and reload the transient.
$location = $this->api_client->create_terminal_location( $request['display_name'], $request['address'] );
$this->reload_locations();
return rest_ensure_response( $this->extract_location_fields( $location ) );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( $e->get_error_code(), $e->getMessage() ) );
}
}
/**
* Proxies the get all locations request to the server.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function get_all_locations( $request ) {
try {
return rest_ensure_response( array_map( [ $this, 'extract_location_fields' ], $this->fetch_locations() ) );
} catch ( API_Exception $e ) {
return rest_ensure_response( new WP_Error( $e->get_error_code(), $e->getMessage() ) );
}
}
/**
* Extracts the relevant fields from a terminal location object.
*
* @param array $location The location.
* @return array The picked fields from location object.
*/
private function extract_location_fields( array $location ): array {
return [
'id' => $location['id'],
'address' => $location['address'],
'display_name' => $location['display_name'],
'livemode' => $location['livemode'],
];
}
/**
* Attempts to read locations from transient and re-populates it if needed.
*
* @return array Terminal locations.
* @throws API_Exception If request to server fails.
*/
private function fetch_locations(): array {
$locations = get_transient( static::STORE_LOCATIONS_TRANSIENT_KEY );
if ( ! $locations ) {
$request = Request::get( WC_Payments_API_Client::TERMINAL_LOCATIONS_API );
$request->assign_hook( 'wcpay_get_terminal_locations' );
$locations = $request->send();
set_transient( static::STORE_LOCATIONS_TRANSIENT_KEY, $locations, DAY_IN_SECONDS );
}
return $locations;
}
/**
* Refreshes the locations stored in transient.
*
* @return void
* @throws API_Exception If request to server fails.
*/
private function reload_locations() {
$request = Request::get( WC_Payments_API_Client::TERMINAL_LOCATIONS_API );
$request->assign_hook( 'wcpay_get_terminal_locations' );
$locations = $request->send();
set_transient( static::STORE_LOCATIONS_TRANSIENT_KEY, $locations, DAY_IN_SECONDS );
}
}
@@ -0,0 +1,45 @@
<?php
/**
* Class WC_REST_Payments_Timeline_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* REST controller for the timeline, which includes all events related to an intention.
*/
class WC_REST_Payments_Timeline_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/timeline';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<intention_id>\w+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_timeline' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve timeline to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_timeline( $request ) {
$intention_id = $request->get_param( 'intention_id' );
return $this->forward_request( 'get_timeline', [ $intention_id ] );
}
}
@@ -0,0 +1,188 @@
<?php
/**
* Class WC_REST_Payments_Tos_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\Add_Account_Tos_Agreement;
use WCPay\Exceptions\Rest_Request_Exception;
use WCPay\Logger;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for Terms of Services routes.
*/
class WC_REST_Payments_Tos_Controller extends WC_Payments_REST_Controller {
/**
* Result codes for returning to the WCPay server API. They don't have any special meaning, but can will be logged
* and are therefore useful when debugging how we reacted to a webhook.
*/
const RESULT_SUCCESS = 'success';
const RESULT_BAD_REQUEST = 'bad_request';
const RESULT_ERROR = 'error';
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/tos';
/**
* Instance of WC_Payment_Gateway_WCPay
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* WC Payments Account.
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_REST_Payments_Webhook_Controller constructor.
*
* @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance.
* @param WC_Payment_Gateway_WCPay $gateway WC_Payment_Gateway_WCPay instance.
* @param WC_Payments_Account $account WC_Payments_Account instance.
*/
public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $gateway, WC_Payments_Account $account ) {
parent::__construct( $api_client );
$this->gateway = $gateway;
$this->account = $account;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'handle_tos' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/reactivate',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'reactivate' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/stripe_track_connected',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'remove_stripe_connect_track' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Record ToS acceptance.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response
* @throws Rest_Request_Exception Throw if accept param is missing.
*/
public function handle_tos( $request ) {
$body = $request->get_json_params();
try {
if ( ! isset( $body['accept'] ) ) {
throw new Rest_Request_Exception( __( 'ToS accept parameter is missing', 'woocommerce-payments' ) );
}
$is_accepted = (bool) $body['accept'];
Logger::debug( sprintf( 'ToS acceptance request received. Accept: %s', $is_accepted ? 'yes' : 'no' ) );
if ( $is_accepted ) {
$this->handle_tos_accepted();
} else {
$this->handle_tos_declined();
}
} catch ( Rest_Request_Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( [ 'result' => self::RESULT_BAD_REQUEST ], 400 );
} catch ( Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( [ 'result' => self::RESULT_ERROR ], 500 );
}
return new WP_REST_Response( [ 'result' => self::RESULT_SUCCESS ] );
}
/**
* Process ToS accepted.
*/
private function handle_tos_accepted() {
$this->gateway->enable();
// Accessing directly, because a user must be already logged in.
$current_user = wp_get_current_user();
$user_name = $current_user->user_login;
$request = Add_Account_Tos_Agreement::create();
$request->set_source( 'settings-popup' );
$request->set_user_name( $user_name );
$request->send();
$this->account->refresh_account_data();
}
/**
* Process ToS declined.
*/
private function handle_tos_declined() {
// TODO: maybe record ToS declined data.
$this->gateway->disable();
}
/**
* Activates the gateway again, after it's been disabled.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response
*/
public function reactivate( $request ) {
try {
$this->gateway->enable();
Logger::debug( 'Gateway re-enabled after ToS decline.' );
} catch ( Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( [ 'result' => self::RESULT_ERROR ], 500 );
}
return new WP_REST_Response( [ 'result' => self::RESULT_SUCCESS ] );
}
/**
* Deletes _wcpay_onboarding_stripe_connected option after KYC completion has been tracked.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response
*/
public function remove_stripe_connect_track( $request ) {
delete_option( '_wcpay_onboarding_stripe_connected' );
return new WP_REST_Response( [ 'result' => self::RESULT_SUCCESS ] );
}
}
@@ -0,0 +1,272 @@
<?php
/**
* Class WC_REST_Payments_Transactions_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request\List_Transactions;
use WCPay\Core\Server\Request\List_Fraud_Outcome_Transactions;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for transactions.
*/
class WC_REST_Payments_Transactions_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/transactions';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_transactions' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/download',
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_transactions_export' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_transactions_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/search',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_transactions_search_autocomplete' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/fraud-outcomes',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_fraud_outcome_transactions' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/fraud-outcomes/summary',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_fraud_outcome_transactions_summary' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/fraud-outcomes/search',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_fraud_outcome_transactions_search_autocomplete' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/fraud-outcomes/download',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_fraud_outcome_transactions_export' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve transactions to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_transactions( $request ) {
$wcpay_request = List_Transactions::from_rest_request( $request );
return $wcpay_request->handle_rest_request();
}
/**
* Retrieve fraud outcome transactions to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_fraud_outcome_transactions( $request ) {
$wcpay_request = List_Fraud_Outcome_Transactions::from_rest_request( $request );
return $this->forward_request( 'list_fraud_outcome_transactions', [ $wcpay_request ] );
}
/**
* Retrieve fraud outcome transactions summary to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_fraud_outcome_transactions_summary( $request ) {
$wcpay_request = List_Fraud_Outcome_Transactions::from_rest_request( $request );
return $this->forward_request( 'list_fraud_outcome_transactions_summary', [ $wcpay_request ] );
}
/**
* Retrieve transactions search options to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_fraud_outcome_transactions_search_autocomplete( $request ) {
$wcpay_request = List_Fraud_Outcome_Transactions::from_rest_request( $request );
return $this->forward_request( 'get_fraud_outcome_transactions_search_autocomplete', [ $wcpay_request ] );
}
/**
* Initiate transactions export via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_fraud_outcome_transactions_export( $request ) {
$wcpay_request = List_Fraud_Outcome_Transactions::from_rest_request( $request );
return $this->forward_request( 'get_fraud_outcome_transactions_export', [ $wcpay_request ] );
}
/**
* Initiate transactions export via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_transactions_export( $request ) {
$user_email = $request->get_param( 'user_email' );
$deposit_id = $request->get_param( 'deposit_id' );
$locale = $request->get_param( 'locale' );
$filters = $this->get_transactions_filters( $request );
return $this->forward_request( 'get_transactions_export', [ $filters, $user_email, $deposit_id, $locale ] );
}
/**
* Retrieve transactions summary to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_transactions_summary( $request ) {
$deposit_id = $request->get_param( 'deposit_id' );
$filters = $this->get_transactions_filters( $request );
return $this->forward_request( 'get_transactions_summary', [ $filters, $deposit_id ] );
}
/**
* Retrieve transactions search options to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function get_transactions_search_autocomplete( $request ) {
$search_term = $request->get_param( 'search_term' );
return $this->forward_request( 'get_transactions_search_autocomplete', [ $search_term ] );
}
/**
* Extract transactions filters from request
*
* @param WP_REST_Request $request Full data about the request.
*/
private function get_transactions_filters( $request ) {
$date_between_filter = $request->get_param( 'date_between' );
$user_timezone = $request->get_param( 'user_timezone' );
if ( ! is_null( $date_between_filter ) ) {
$date_between_filter = array_map(
function ( $transaction_date ) use ( $user_timezone ) {
return $this->format_transaction_date_with_timestamp( $transaction_date, $user_timezone );
},
$date_between_filter
);
}
return array_filter(
[
'match' => $request->get_param( 'match' ),
'date_before' => $this->format_transaction_date_with_timestamp( $request->get_param( 'date_before' ), $user_timezone ),
'date_after' => $this->format_transaction_date_with_timestamp( $request->get_param( 'date_after' ), $user_timezone ),
'date_between' => $date_between_filter,
'type_is' => $request->get_param( 'type_is' ),
'type_is_not' => $request->get_param( 'type_is_not' ),
'source_device_is' => $request->get_param( 'source_device_is' ),
'source_device_is_not' => $request->get_param( 'source_device_is_not' ),
'channel_is' => $request->get_param( 'channel_is' ),
'channel_is_not' => $request->get_param( 'channel_is_not' ),
'customer_country_is' => $request->get_param( 'customer_country_is' ),
'customer_country_is_not' => $request->get_param( 'customer_country_is_not' ),
'risk_level_is' => $request->get_param( 'risk_level_is' ),
'risk_level_is_not' => $request->get_param( 'risk_level_is_not' ),
'store_currency_is' => $request->get_param( 'store_currency_is' ),
'customer_currency_is' => $request->get_param( 'customer_currency_is' ),
'customer_currency_is_not' => $request->get_param( 'customer_currency_is_not' ),
'source_is' => $request->get_param( 'source_is' ),
'source_is_not' => $request->get_param( 'source_is_not' ),
'loan_id_is' => $request->get_param( 'loan_id_is' ),
'search' => $request->get_param( 'search' ),
],
static function ( $filter ) {
return null !== $filter;
}
);
}
/**
* Formats the incoming transaction date as per the blog's timezone.
*
* @param string|null $transaction_date Transaction date to format.
* @param string|null $user_timezone User's timezone passed from client.
*
* @return string|null The formatted transaction date as per timezone.
*/
private function format_transaction_date_with_timestamp( $transaction_date, $user_timezone ) {
if ( is_null( $transaction_date ) || is_null( $user_timezone ) ) {
return $transaction_date;
}
// Get blog timezone.
$blog_time = new DateTime( $transaction_date );
$blog_time->setTimezone( new DateTimeZone( wp_timezone_string() ) );
// Get local timezone.
$local_time = new DateTime( $transaction_date );
$local_time->setTimezone( new DateTimeZone( $user_timezone ) );
// Compute time difference in minutes.
$time_difference = ( strtotime( $local_time->format( 'Y-m-d H:i:s' ) ) - strtotime( $blog_time->format( 'Y-m-d H:i:s' ) ) ) / 60;
// Shift date by time difference.
$formatted_date = new DateTime( $transaction_date );
date_modify( $formatted_date, $time_difference . 'minutes' );
return $formatted_date->format( 'Y-m-d H:i:s' );
}
}
@@ -0,0 +1,89 @@
<?php
/**
* Class WC_REST_Payments_VAT_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Core\Server\Request;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for vat.
*/
class WC_REST_Payments_VAT_Controller extends WC_Payments_REST_Controller {
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/vat';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<vat_number>[\w\.\%]+)',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'validate_vat' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::EDITABLE,
'args' => [
'vat_number' => [
'type' => 'string',
'format' => 'text-field',
'required' => false,
],
'name' => [
'type' => 'string',
'format' => 'text-field',
'required' => true,
],
'address' => [
'type' => 'string',
'format' => 'textarea-field',
'required' => true,
],
],
'callback' => [ $this, 'save_vat_details' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Validate VAT number to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function validate_vat( $request ) {
$vat_number = sanitize_text_field( $request->get_param( 'vat_number' ) );
$server_request = Request::get( WC_Payments_API_Client::VAT_API, $vat_number );
$server_request->assign_hook( 'wcpay_validate_vat_request' );
return $server_request->handle_rest_request();
}
/**
* Save VAT details and respond via API.
*
* @param WP_REST_Request $request Full data about the request.
*/
public function save_vat_details( $request ) {
$vat_number = $request->get_param( 'vat_number' );
$name = $request->get_param( 'name' );
$address = $request->get_param( 'address' );
return $this->forward_request( 'save_vat_details', [ $vat_number, $name, $address ] );
}
}
@@ -0,0 +1,91 @@
<?php
/**
* Class WC_REST_Payments_Webhook_Controller
*
* @package WooCommerce\Payments\Admin
*/
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
use WCPay\Logger;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for webhooks.
*/
class WC_REST_Payments_Webhook_Controller extends WC_Payments_REST_Controller {
/**
* Result codes for returning to the WCPay server API. They don't have any special meaning, but can will be logged
* and are therefore useful when debugging how we reacted to a webhook.
*/
const RESULT_SUCCESS = 'success';
const RESULT_BAD_REQUEST = 'bad_request';
const RESULT_ERROR = 'error';
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/webhook';
/**
* Webhook Processing Service.
*
* @var WC_Payments_Webhook_Processing_Service
*/
private $webhook_processing_service;
/**
* WC_REST_Payments_Webhook_Controller constructor.
*
* @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance.
* @param WC_Payments_Webhook_Processing_Service $webhook_processing_service WC_Payments_Webhook_Processing_Service instance.
*/
public function __construct(
WC_Payments_API_Client $api_client,
WC_Payments_Webhook_Processing_Service $webhook_processing_service
) {
parent::__construct( $api_client );
$this->webhook_processing_service = $webhook_processing_service;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'handle_webhook' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve transactions to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response
*/
public function handle_webhook( $request ) {
$body = $request->get_json_params();
try {
$this->webhook_processing_service->process( $body );
} catch ( Invalid_Webhook_Data_Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( [ 'result' => self::RESULT_BAD_REQUEST ], 400 );
} catch ( Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( [ 'result' => self::RESULT_ERROR ], 500 );
}
return new WP_REST_Response( [ 'result' => self::RESULT_SUCCESS ] );
}
}
@@ -0,0 +1,16 @@
<?php
/**
* Class WC_REST_UPE_Flag_Toggle_Controller
*
* @package WooCommerce\Payments\Admin
*/
/**
* REST controller for UPE feature flag. Needs to stay in the codebase to avoid error on plugin update for versions 6.9.2 or earlier.
*/
class WC_REST_UPE_Flag_Toggle_Controller extends WP_REST_Controller {
/**
* Register routes.
*/
public function register_routes() {}
}
@@ -0,0 +1,106 @@
<?php
/**
* Class WC_REST_WooPay_Session_Controller
*
* @package WooCommerce\Payments\Admin
*/
defined( 'ABSPATH' ) || exit;
use WCPay\WooPay\WooPay_Session;
use Automattic\Jetpack\Connection\Rest_Authentication;
use WCPay\Logger;
/**
* REST controller to check get WooPay extension data for user.
*/
class WC_REST_WooPay_Session_Controller extends WP_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'payments/woopay';
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'session';
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_session_data' ],
'permission_callback' => [ $this, 'check_permission' ],
'args' => [
'email' => [
'type' => 'string',
'format' => 'email',
'required' => true,
],
],
]
);
}
/**
* Retrieve WooPay session data.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|WP_REST_Response The initial session request data.
*/
public function get_session_data( WP_REST_Request $request ): WP_REST_Response {
try {
// phpcs:ignore
/**
* @psalm-suppress UndefinedClass
*/
$response = WooPay_Session::get_init_session_request( null, null, null, $request );
return rest_ensure_response( $response );
} catch ( Exception $e ) {
$error = new WP_Error( 'wcpay_server_error', $e->getMessage(), [ 'status' => 400 ] );
Logger::log( 'Error validating cart token from WooPay request: ' . $e->getMessage() );
return rest_convert_error_to_response( $error );
}
}
/**
* Check permission confirms that the request is from WooPay.
*
* @return bool True if request is from WooPay and has a valid signature.
*/
public function check_permission() {
return $this->is_request_from_woopay() && $this->has_valid_request_signature();
}
/**
* Returns true if the request that's currently being processed is signed with the blog token.
*
* @return bool True if the request signature is valid.
*/
private function has_valid_request_signature(): bool {
return apply_filters( 'wcpay_woopay_is_signed_with_blog_token', Rest_Authentication::is_signed_with_blog_token() );
}
/**
* Returns true if the request that's currently being processed is from WooPay, false
* otherwise.
*
* @return bool True if request is from WooPay.
*/
private function is_request_from_woopay(): bool {
return isset( $_SERVER['HTTP_USER_AGENT'] ) && 'WooPay' === $_SERVER['HTTP_USER_AGENT'];
}
}
@@ -0,0 +1,376 @@
<?php
/**
* Class WC_Payments_Task_Disputes
*
* @package WooCommerce\Payments\Tasks
*/
namespace WooCommerce\Payments\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use WCPay\Database_Cache;
use WC_Payments_Utils;
use WC_Payments_API_Client;
defined( 'ABSPATH' ) || exit;
/**
* WC Onboarding Task displayed if disputes awaiting response.
*
* Note: this task is separate to the Payments → Overview disputes task, which is defined in client/overview/task-list/tasks.js.
*/
class WC_Payments_Task_Disputes extends Task {
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $api_client;
/**
* Database_Cache instance.
*
* @var Database_Cache
*/
private $database_cache;
/**
* Disputes due within 7 days.
*
* @var array|null
*/
private $disputes_due_within_7d;
/**
* Disputes due within 1 day.
*
* @var array|null
*/
private $disputes_due_within_1d;
/**
* A memory cache of all disputes needing response.
*
* @var array|null
*/
private $disputes_needing_response = null;
/**
* WC_Payments_Task_Disputes constructor.
*/
public function __construct() {
$this->api_client = \WC_Payments::get_payments_api_client();
$this->database_cache = \WC_Payments::get_database_cache();
parent::__construct();
}
/**
* Initialize the task.
*/
private function fetch_relevant_disputes() {
$this->disputes_due_within_7d = $this->get_disputes_needing_response_within_days( 7 );
$this->disputes_due_within_1d = $this->get_disputes_needing_response_within_days( 1 );
}
/**
* Gets the task ID.
*
* @return string
*/
public function get_id() {
return 'woocommerce_payments_disputes_task';
}
/**
* Gets the task title.
*
* @return string
*/
public function get_title() {
if ( null === $this->disputes_needing_response ) {
$this->fetch_relevant_disputes();
}
if ( count( (array) $this->disputes_due_within_7d ) === 1 ) {
$dispute = $this->disputes_due_within_7d[0];
$amount = WC_Payments_Utils::interpret_stripe_amount( $dispute['amount'], $dispute['currency'] );
$amount_formatted = WC_Payments_Utils::format_currency( $amount, $dispute['currency'] );
if ( count( (array) $this->disputes_due_within_1d ) > 0 ) {
return sprintf(
/* translators: %s is a currency formatted amount */
__( 'Respond to a dispute for %s Last day', 'woocommerce-payments' ),
$amount_formatted
);
}
return sprintf(
/* translators: %s is a currency formatted amount */
__( 'Respond to a dispute for %s', 'woocommerce-payments' ),
$amount_formatted
);
}
$active_disputes = $this->get_disputes_needing_response();
if ( ! is_array( $active_disputes ) || count( $active_disputes ) === 0 ) {
return '';
}
$dispute_currencies = array_unique( array_column( $active_disputes, 'currency' ) );
// If multiple currencies, use simple task title without total amounts.
if ( count( $dispute_currencies ) > 1 ) {
return sprintf(
// translators: %d is a number greater than 1.
__( 'Respond to %d active disputes', 'woocommerce-payments' ),
count( $active_disputes )
);
}
// If single currency, calculate total amount and include in task title.
$dispute_total = array_reduce(
$active_disputes,
function ( $total, $dispute ) {
return $total + ( $dispute['amount'] ?? 0 );
},
0
);
$dispute_total_formatted = WC_Payments_Utils::format_currency(
WC_Payments_Utils::interpret_stripe_amount( $dispute_total, $dispute_currencies[0] ),
$dispute_currencies[0]
);
return sprintf(
/* translators: %d is a number greater than 1. %s is a formatted amount, eg: $10.00 */
__( 'Respond to %1$d active disputes for a total of %2$s', 'woocommerce-payments' ),
count( $active_disputes ),
$dispute_total_formatted
);
}
/**
* Get the parent list ID.
*
* This function prior to WC 6.4.0 was abstract and so is needed for backwards compatibility.
*
* @return string
*/
public function get_parent_id() {
// WC 6.4.0 compatibility.
if ( is_callable( 'parent::get_parent_id' ) ) {
return parent::get_parent_id();
}
return 'extended';
}
/**
* Gets the task subtitle.
*
* @return string
*/
public function get_additional_info() {
if ( count( (array) $this->disputes_due_within_7d ) === 1 ) {
$local_timezone = new \DateTimeZone( wp_timezone_string() );
$dispute = $this->disputes_due_within_7d[0];
$due_by_local_time = ( new \DateTime( $dispute['due_by'] ) )->setTimezone( $local_timezone );
// Sum of Unix timestamp and timezone offset in seconds.
$due_by_ts = $due_by_local_time->getTimestamp() + $due_by_local_time->getOffset();
if ( count( (array) $this->disputes_due_within_1d ) > 0 ) {
return sprintf(
/* translators: %s is time, eg: 11:59 PM */
__( 'Respond today by %s', 'woocommerce-payments' ),
date_i18n( wc_time_format(), $due_by_ts )
);
}
$now = new \DateTime( 'now', $local_timezone );
$diff = $now->diff( $due_by_local_time );
return sprintf(
/* translators: %1$s is a date, eg: Jan 1, 2021. %2$s is the number of days left, eg: 2 days. */
__( 'By %1$s %2$s left to respond', 'woocommerce-payments' ),
date_i18n( wc_date_format(), $due_by_ts ),
/* translators: %s is the number of days left, e.g. 1 day. */
sprintf( _n( '%d day', '%d days', $diff->days, 'woocommerce-payments' ), $diff->days )
);
}
if ( count( (array) $this->disputes_due_within_1d ) > 0 ) {
return sprintf(
/* translators: %d is the number of disputes. */
__(
'Final day to respond to %d of the disputes',
'woocommerce-payments'
),
count( (array) $this->disputes_due_within_1d )
);
}
return sprintf(
/* translators: %d is the number of disputes. */
__(
'Last week to respond to %d of the disputes',
'woocommerce-payments'
),
count( (array) $this->disputes_due_within_7d )
);
}
/**
* Gets the task's action URL.
*
* @return string
*/
public function get_action_url() {
$disputes = $this->disputes_due_within_7d;
if ( count( (array) $disputes ) === 1 ) {
$dispute = $disputes[0];
return admin_url(
add_query_arg(
[
'page' => 'wc-admin',
'path' => '%2Fpayments%2Ftransactions%2Fdetails',
'id' => $dispute['charge_id'],
],
'admin.php'
)
);
}
return admin_url(
add_query_arg(
[
'page' => 'wc-admin',
'path' => '%2Fpayments%2Fdisputes',
'filter' => 'awaiting_response',
],
'admin.php'
)
);
}
/**
* Get the estimated time to complete the task.
*
* @return string
*/
public function get_time() {
return '';
}
/**
* Gets the task content.
*
* @return string
*/
public function get_content() {
return '';
}
/**
* Get whether the task is completed.
*
* @return bool
*/
public function is_complete() {
return false;
}
/**
* Get whether the task is visible.
*
* @return bool
*/
public function can_view() {
if ( null === $this->disputes_needing_response ) {
$this->fetch_relevant_disputes();
}
return count( (array) $this->disputes_due_within_7d ) > 0;
}
/**
* Get disputes needing response within the given number of days.
*
* @param int $num_days Number of days in the future to check for disputes needing response.
*
* @return array Disputes needing response within the given number of days.
*/
private function get_disputes_needing_response_within_days( $num_days ) {
$to_return = [];
$active_disputes = $this->get_disputes_needing_response();
if ( ! is_array( $active_disputes ) ) {
return $to_return;
}
foreach ( $active_disputes as $dispute ) {
if ( ! $dispute['due_by'] ) {
continue;
}
// Compare UTC times.
$now_utc = new \DateTime( 'now', new \DateTimeZone( 'UTC' ) );
$due_by_utc = new \DateTime( $dispute['due_by'], new \DateTimeZone( 'UTC' ) );
if ( $now_utc > $due_by_utc ) {
continue;
}
$diff = $now_utc->diff( $due_by_utc );
// If the dispute is due within the given number of days, add it to the list.
if ( $diff->days <= $num_days ) {
$to_return[] = $dispute;
}
}
return $to_return;
}
/**
* Gets disputes awaiting a response. ie have a 'needs_response' or 'warning_needs_response' status.
*
* @return array|null Array of disputes awaiting a response. Null on failure.
*/
private function get_disputes_needing_response() {
if ( null !== $this->disputes_needing_response ) {
return $this->disputes_needing_response;
}
$this->disputes_needing_response = $this->database_cache->get_or_add(
Database_Cache::ACTIVE_DISPUTES_KEY,
function () {
try {
$response = $this->api_client->get_disputes(
[
'pagesize' => 50,
'search' => [ 'warning_needs_response', 'needs_response' ],
]
);
} catch ( \Exception $e ) {
// Ensure an array is always returned, even if the API call fails.
return [];
}
$active_disputes = $response['data'] ?? [];
// sort by due_by date ascending.
usort(
$active_disputes,
function ( $a, $b ) {
$a_due_by = new \DateTime( $a['due_by'] );
$b_due_by = new \DateTime( $b['due_by'] );
return $a_due_by <=> $b_due_by;
}
);
return $active_disputes;
},
'is_array'
);
return $this->disputes_needing_response;
}
}
@@ -0,0 +1,56 @@
<?php
/**
* Class Tracker
*
* @package WooCommerce\Payments
*/
namespace WCPay;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* An API for adding track events that will get unloaded
* at a later stage and pushed to WP.com.
*/
class Tracker {
/**
* A key value array event_name => properties.
*
* @var array $admin_events
*/
protected static $admin_events = [];
/**
* Record an event in Tracks
*
* This only sends track events for admin logged in users. This is a limitation of the
* WC_Tracks and related classes.
*
* The event name will be prefixed before sending it see Core_Tracks_Wrapper.
*
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
*/
public static function track_admin( $event_name, $properties = [] ) {
self::$admin_events[ $event_name ] = $properties;
}
/**
* Remove a track event.
*
* @param string $event_name The name of the event that should be removed.
*/
public static function remove_admin_event( $event_name ) {
if ( isset( self::$admin_events ) ) {
unset( self::$admin_events[ $event_name ] );
}
}
/**
* Remove a track event.
*/
public static function get_admin_events() {
return self::$admin_events;
}
}
@@ -0,0 +1,46 @@
<?php
/**
* Class Core Tracks Wrapper
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use WC_Tracks;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* Moves all events from Tracker to Tracks loader
*/
function record_tracker_events() {
foreach ( Tracker::get_admin_events() as $event => $properties ) {
WC_Tracks::record_event( $event, $properties );
Tracker::remove_admin_event( $event );
}
}
// Loaded on admin_init to ensure that we are in admin and that WC_Tracks is loaded.
add_action(
'admin_init',
function () {
if ( ! class_exists( 'WC_Tracks' ) ) {
return;
}
// Move all events with priority 1 just before the admin_footer hook adds footer pixels.
add_action( 'admin_footer', 'WCPay\record_tracker_events', 1 );
/**
* Send all events that were not handled in `admin_footer`.
*
* Between shutdown and admin footer many things can happen. Admin footer loads
* scripts in the markup images that will call tracks from the browsers
* side (which means it's faster as we're not doing network calls to wp.com server
* side). Anything that is added afterward must be sent from the
* server, but doing it on shutdown means it's not blocking anything.
*/
add_action( 'shutdown', __NAMESPACE__ . '\\record_tracker_events', 1 );
}
);
@@ -0,0 +1,139 @@
<?php
/**
* Compatibility_Service class
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use WC_Payments;
use WC_Payments_API_Client;
use WCPay\Exceptions\API_Exception;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* Class to send compatibility data to the server.
*/
class Compatibility_Service {
const UPDATE_COMPATIBILITY_DATA = 'wcpay_update_compatibility_data';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* Constructor for Compatibility_Service.
*
* @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client.
*/
public function __construct( WC_Payments_API_Client $payments_api_client ) {
$this->payments_api_client = $payments_api_client;
}
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'woocommerce_payments_account_refreshed', [ $this, 'update_compatibility_data' ] );
add_action( 'after_switch_theme', [ $this, 'update_compatibility_data' ] );
add_filter( 'wc_payments_get_onboarding_data_args', [ $this, 'add_compatibility_onboarding_data' ] );
}
/**
* Schedules the sending of the compatibility data to send only the last update in T minutes.
*
* @return void
*/
public function update_compatibility_data() {
// This will delete the previous compatibility requests in the last two minutes, and only send the last update to the server, ensuring there's only one update in two minutes.
WC_Payments::get_action_scheduler_service()->schedule_job( time() + 2 * MINUTE_IN_SECONDS, self::UPDATE_COMPATIBILITY_DATA );
}
/**
* Gets the data we need to confirm compatibility and sends it to the server.
*
* @return void
*/
public function update_compatibility_data_hook() {
$this->payments_api_client->update_compatibility_data( $this->get_compatibility_data() );
}
/**
* Adds the compatibility data to the onboarding args.
*
* @param array $args The args being sent when onboarding.
*
* @return array
*/
public function add_compatibility_onboarding_data( $args ): array {
$args['compatibility_data'] = $this->get_compatibility_data();
return $args;
}
/**
* Gets the compatibility data.
*
* @return array
*/
private function get_compatibility_data(): array {
$active_plugins = get_option( 'active_plugins', [] );
$post_types_count = $this->get_post_types_count();
$wc_permalinks = get_option( 'woocommerce_permalinks', [] );
$wc_shop_permalink = $this->get_permalink_for_page_id( 'shop' );
$wc_cart_permalink = $this->get_permalink_for_page_id( 'cart' );
$wc_checkout_permalink = $this->get_permalink_for_page_id( 'checkout' );
return [
'woopayments_version' => WCPAY_VERSION_NUMBER,
'woocommerce_version' => WC_VERSION,
'woocommerce_permalinks' => $wc_permalinks,
'woocommerce_shop' => $wc_shop_permalink,
'woocommerce_cart' => $wc_cart_permalink,
'woocommerce_checkout' => $wc_checkout_permalink,
'blog_theme' => get_stylesheet(),
'active_plugins' => $active_plugins,
'post_types_count' => $post_types_count,
];
}
/**
* Gets the count of public posts for each post type.
*
* @return array<\WP_Post_Type|string, int>
*/
private function get_post_types_count(): array {
$post_types = get_post_types(
[
'public' => true,
]
);
$post_types_count = [];
foreach ( $post_types as $post_type ) {
$post_types_count[ $post_type ] = (int) wp_count_posts( $post_type )->publish;
}
return $post_types_count;
}
/**
* Gets the permalink for a page ID.
*
* @param string $page_id The page ID to get the permalink for.
*
* @return string The permalink for the page ID, or 'Not set' if the permalink is not available.
*/
private function get_permalink_for_page_id( string $page_id ): string {
$permalink = get_permalink( wc_get_page_id( $page_id ) );
return $permalink ? $permalink : 'Not set';
}
}
@@ -0,0 +1,422 @@
<?php
/**
* Class Database_Cache
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyCacheInterface;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* A class for caching data as an option in the database.
*/
class Database_Cache implements MultiCurrencyCacheInterface {
const ACCOUNT_KEY = 'wcpay_account_data';
const ONBOARDING_FIELDS_DATA_KEY = 'wcpay_onboarding_fields_data';
const BUSINESS_TYPES_KEY = 'wcpay_business_types_data';
const PAYMENT_PROCESS_FACTORS_KEY = 'wcpay_payment_process_factors';
const FRAUD_SERVICES_KEY = 'wcpay_fraud_services_data';
const RECOMMENDED_PAYMENT_METHODS = 'wcpay_recommended_payment_methods';
/**
* Refresh during AJAX calls is avoided, but white-listing
* a key here will allow the refresh to happen.
*
* @var string[]
*/
const AJAX_ALLOWED_KEYS = [
self::PAYMENT_PROCESS_FACTORS_KEY,
];
/**
* Payment methods cache key prefix. Used in conjunction with the customer_id to cache a customer's payment methods.
*/
const PAYMENT_METHODS_KEY_PREFIX = 'wcpay_pm_';
/**
* Dispute status counts cache key.
*
* @var string
*/
const DISPUTE_STATUS_COUNTS_KEY = 'wcpay_dispute_status_counts_cache';
/**
* Active disputes cache key.
*
* @var string
*/
const ACTIVE_DISPUTES_KEY = 'wcpay_active_dispute_cache';
/**
* Cache key for authorization summary data like count, total amount, etc.
*
* @var string
*/
const AUTHORIZATION_SUMMARY_KEY = 'wcpay_authorization_summary_cache';
/**
* Cache key for authorization summary data like count, total amount, etc in test mode.
*
* @var string
*/
const AUTHORIZATION_SUMMARY_KEY_TEST_MODE = 'wcpay_test_authorization_summary_cache';
/**
* Cache key for eligible connect incentive data.
*/
const CONNECT_INCENTIVE_KEY = 'wcpay_connect_incentive';
/**
* Tracking info cache key.
*
* @var string
*/
const TRACKING_INFO_KEY = 'wcpay_tracking_info_cache';
/**
* Refresh disabled flag, controlling the behaviour of the get_or_add function.
*
* @var bool
*/
private $refresh_disabled;
/**
* In-memory cache for the duration of a single request.
*
* This is used to avoid multiple database reads for the same data and as a backstop in case the database write fails,
* thus ensuring the cache generator is not called multiple times (which would mean multiple API calls to our platform).
*
* @var array
*/
private $in_memory_cache = [];
/**
* Class constructor.
*/
public function __construct() {
$this->refresh_disabled = false;
}
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'action_scheduler_before_execute', [ $this, 'disable_refresh' ] );
}
/**
* Gets a value from cache or regenerates and adds it to the cache.
*
* @param string $key The options key to cache the data under.
* @param callable $generator Function/callable regenerating the missing value. If null or false is returned, it will be treated as an error.
* @param callable $validate_data Function/callable validating the data after it is retrieved from the cache. If it returns false, the cache will be refreshed.
* @param boolean $force_refresh Regenerates the cache regardless of its state if true.
* @param boolean $refreshed Is set to true if the cache has been refreshed without errors and with a non-empty value.
*
* @return mixed|null The cached value. NULL on failure to regenerate or validate the data.
*/
public function get_or_add( string $key, callable $generator, callable $validate_data, bool $force_refresh = false, bool &$refreshed = false ) {
$cache_contents = $this->get_from_cache( $key );
$data = null;
$old_data = null;
// If the stored data is valid, prepare it for return in case we don't need to refresh.
// Also initialize old_data in case of errors.
if ( is_array( $cache_contents ) && array_key_exists( 'data', $cache_contents ) && $validate_data( $cache_contents['data'] ) ) {
$data = $cache_contents['data'];
$old_data = $data;
}
if ( $this->should_refresh_cache( $key, $cache_contents, $validate_data, $force_refresh ) ) {
try {
$data = $generator();
$errored = ( false === $data || null === $data );
} catch ( \Throwable $e ) {
$errored = true;
}
$refreshed = ! $errored;
if ( $errored ) {
// Still return the old data on error and refresh the cache with it.
$data = $old_data;
}
$this->write_to_cache( $key, $data, $errored );
}
return $data;
}
/**
* Gets a value from the cache.
*
* @param string $key The key to look for.
* @param bool $force If set, return from the cache without checking for expiry.
*
* @return mixed|null The cache contents. NULL if the cache is expired or missing.
*/
public function get( string $key, bool $force = false ) {
$cache_contents = $this->get_from_cache( $key );
if ( is_array( $cache_contents ) && array_key_exists( 'data', $cache_contents ) ) {
if ( ! $force && $this->is_expired( $key, $cache_contents ) ) {
return null;
}
return $cache_contents['data'];
}
return null;
}
/**
* Stores a value in the cache.
*
* @param string $key The key to store the value under.
* @param mixed $data The value to store.
*
* @return void
*/
public function add( string $key, $data ) {
$this->write_to_cache( $key, $data, false );
}
/**
* Deletes a value from the cache.
*
* @param string $key The key to delete.
*
* @return void
*/
public function delete( string $key ) {
// Remove from the in-memory cache.
unset( $this->in_memory_cache[ $key ] );
// Remove from the DB cache.
if ( delete_option( $key ) ) {
// Clear the WP object cache to ensure the new data is fetched by other processes.
wp_cache_delete( $key, 'options' );
}
}
/**
* Deletes all cache entries that are keyed with a certain prefix.
*
* This is useful when you use dynamic cache keys.
*
* Note: Only key prefixes with known, static prefixes are allowed, for protection purposes.
*
* @param string $key_prefix The cache key prefix.
*
* @return void
*/
public function delete_by_prefix( string $key_prefix ) {
// Protection against accidentally deleting all options or options that are not related to WCPay caching.
// Feel free to update this statement as more prefix cache keys are used.
if ( strncmp( $key_prefix, self::PAYMENT_METHODS_KEY_PREFIX, strlen( self::PAYMENT_METHODS_KEY_PREFIX ) ) !== 0 ) {
return; // Maybe throw exception here...
}
global $wpdb;
$options = $wpdb->get_results( $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", $key_prefix . '%' ) );
foreach ( $options as $option ) {
$this->delete( $option->option_name );
}
}
/**
* Hook function allowing the cache refresh to be selectively disabled in certain situations
* (such as while running an Action Scheduler job). While the refresh is disabled, get_or_add
* will only return the cached value and never regenerate it, even if it's expired.
*
* @return void
*/
public function disable_refresh() {
$this->refresh_disabled = true;
}
/**
* Validates the cache contents and, given the passed params and the current application state, determines whether the cache should be refreshed.
* See get_or_add.
*
* @param string $key The cache key.
* @param mixed $cache_contents The cache contents.
* @param callable $validate_data Callback used to validate the cached data by the callee.
* @param boolean $force_refresh Whether a refresh should be forced.
*
* @return boolean True if the cache needs to be refreshed.
*/
private function should_refresh_cache( string $key, $cache_contents, callable $validate_data, bool $force_refresh ): bool {
// Always refresh if the flag is set.
if ( $force_refresh ) {
return true;
}
// Do not refresh if doing ajax or the refresh has been disabled (running an AS job).
if (
defined( 'DOING_CRON' )
|| ( wp_doing_ajax() && ! in_array( $key, self::AJAX_ALLOWED_KEYS, true ) )
|| $this->refresh_disabled ) {
return false;
}
// The value of false means that there was never something cached.
if ( false === $cache_contents ) {
return true;
}
// Non-array, empty array, or missing expected fields mean corrupted data.
// This also handles potential legacy data, which might have those keys missing.
if ( ! is_array( $cache_contents )
|| empty( $cache_contents )
|| ! array_key_exists( 'data', $cache_contents )
|| ! isset( $cache_contents['fetched'] )
|| ! array_key_exists( 'errored', $cache_contents )
) {
return true;
}
// If the data is not errored but invalid, we should refresh it.
if ( ! $cache_contents['errored'] && ! $validate_data( $cache_contents['data'] ) ) {
return true;
}
// Refresh the expired data.
if ( $this->is_expired( $key, $cache_contents ) ) {
return true;
}
return false;
}
/**
* Get the cache contents for a certain key.
*
* @param string $key The cache key.
*
* @return array|false The cache contents (array with `data`, `fetched`, and `errored` entries).
* False if there is no cached data.
*/
private function get_from_cache( string $key ) {
// Check the in-memory cache first.
if ( isset( $this->in_memory_cache[ $key ] ) ) {
return $this->in_memory_cache[ $key ];
}
// Read from the DB cache.
$data = get_option( $key );
// Store the data in the in-memory cache, including the case when there is no data cached (`false`).
$this->in_memory_cache[ $key ] = $data;
return $data;
}
/**
* Wraps the data in the cache metadata and stores it.
*
* @param string $key The key to store the data under.
* @param mixed $data The data to store.
* @param boolean $errored Whether the refresh operation resulted in an error before this has been called.
*
* @return void
*/
private function write_to_cache( string $key, $data, bool $errored ) {
// Add the data and expiry time to the array we're caching.
$cache_contents = [];
$cache_contents['data'] = $data;
$cache_contents['fetched'] = time();
$cache_contents['errored'] = $errored;
// Write the in-memory cache.
$this->in_memory_cache[ $key ] = $cache_contents;
// Create or update the DB option cache.
// Note: Since we are adding the current time to the option value, WP will ALWAYS write the option because
// the cache contents value is different from the current one, even if the data is the same.
// A `false` result ONLY means that the DB write failed.
// Yes, there is the possibility that we attempt to write the same data multiple times within the SAME second,
// and we will mistakenly think that the DB write failed. We are OK with this false positive,
// since the actual data is the same.
$result = update_option( $key, $cache_contents, 'no' );
if ( false !== $result ) {
// If the DB cache write succeeded, clear the WP object cache to ensure the new data is fetched by other processes.
wp_cache_delete( $key, 'options' );
}
}
/**
* Checks if the cache contents are expired.
*
* @param string $key The cache key.
* @param array $cache_contents The cache contents.
*
* @return boolean True if the contents are expired. False otherwise.
*/
private function is_expired( string $key, array $cache_contents ): bool {
$ttl = $this->get_ttl( $key, $cache_contents );
$expires = $cache_contents['fetched'] + $ttl;
$now = time();
return $expires < $now;
}
/**
* Given the key and the cache contents, and based on the application state, determines the cache TTL.
*
* @param string $key The cache key.
* @param array $cache_contents The cache contents.
*
* @return integer The cache TTL.
*/
private function get_ttl( string $key, array $cache_contents ): int {
switch ( $key ) {
case self::ACCOUNT_KEY:
if ( is_admin() ) {
// Fetches triggered from the admin panel should be more frequent.
if ( $cache_contents['errored'] ) {
// Attempt to refresh the data quickly if the last fetch was an error.
$ttl = 2 * MINUTE_IN_SECONDS;
} else {
// If the data was fetched successfully, fetch it every 2h.
$ttl = 2 * HOUR_IN_SECONDS;
}
} else {
// Non-admin requests should always refresh only after 24h since the last fetch.
$ttl = DAY_IN_SECONDS;
}
break;
case self::CURRENCIES_KEY:
// Refresh the errored currencies quickly, otherwise cache for 6h.
$ttl = $cache_contents['errored'] ? 2 * MINUTE_IN_SECONDS : 6 * HOUR_IN_SECONDS;
break;
case self::BUSINESS_TYPES_KEY:
case self::ONBOARDING_FIELDS_DATA_KEY:
// Cache these for a week.
$ttl = WEEK_IN_SECONDS;
break;
case self::CONNECT_INCENTIVE_KEY:
$ttl = $cache_contents['data']['ttl'] ?? HOUR_IN_SECONDS * 6;
break;
case self::PAYMENT_PROCESS_FACTORS_KEY:
$ttl = 2 * HOUR_IN_SECONDS;
break;
case self::TRACKING_INFO_KEY:
$ttl = $cache_contents['errored'] ? 2 * MINUTE_IN_SECONDS : MONTH_IN_SECONDS;
break;
default:
// Default to 24h.
$ttl = DAY_IN_SECONDS;
break;
}
return apply_filters( 'wcpay_database_cache_ttl', $ttl, $key, $cache_contents );
}
}
@@ -0,0 +1,226 @@
<?php
/**
* Class WC_Payments_Duplicate_Payment_Prevention_Service
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use Exception;
use WC_Order;
use WC_Payment_Gateway_WCPay;
use WC_Payments_Order_Service;
use WCPay\Constants\Intent_Status;
use WCPay\Core\Server\Request\Get_Intention;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Used for methods, which detect existing payments or payment intents,
* and prevent creating duplicate payments.
*/
class Duplicate_Payment_Prevention_Service {
/**
* Key name for saving the current processing order_id to WC Session with the purpose
* of preventing duplicate payments in a single order.
*
* @type string
*/
const SESSION_KEY_PROCESSING_ORDER = 'wcpay_processing_order';
/**
* Flag to indicate that a previous order with the same cart content has already paid.
*
* @type string
*/
const FLAG_PREVIOUS_ORDER_PAID = 'wcpay_paid_for_previous_order';
/**
* Flag to indicate that a previous intention attached to the order was successful.
*/
const FLAG_PREVIOUS_SUCCESSFUL_INTENT = 'wcpay_previous_successful_intent';
/**
* WC_Payments_Order_Service instance.
*
* @var WC_Payments_Order_Service
*/
protected $order_service;
/**
* Gateway instance.
*
* @var WC_Payment_Gateway_WCPay
*/
protected $gateway;
/**
* Initializes all dependencies and hooks, related to the service.
*
* @param WC_Payment_Gateway_WCPay $gateway The main gateway.
* @param WC_Payments_Order_Service $order_service The order service instance.
*/
public function init( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Order_Service $order_service ) {
$this->gateway = $gateway;
$this->order_service = $order_service;
}
/**
* Checks if the attached payment intent was successful for the current order.
*
* @param WC_Order $order Current order to check.
*
* @return array|void A successful response in case the attached intent was successful, null if none.
*/
public function check_payment_intent_attached_to_order_succeeded( WC_Order $order ) {
$intent_id = (string) $order->get_meta( '_intent_id', true );
if ( empty( $intent_id ) ) {
return;
}
// We only care about payment intent.
$is_payment_intent = 'pi_' === substr( $intent_id, 0, 3 );
if ( ! $is_payment_intent ) {
return;
}
try {
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
/** @var \WC_Payments_API_Abstract_Intention $intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
$intent = $request->send();
$intent_status = $intent->get_status();
} catch ( Exception $e ) {
Logger::error( 'Failed to fetch attached payment intent: ' . $e );
return;
}
if ( ! $intent->is_authorized() ) {
return;
}
$intent_meta_order_id_raw = $intent->get_metadata()['order_id'] ?? '';
$intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0;
$intent_meta_order_number_raw = $intent->get_metadata()['order_number'] ?? '';
$intent_meta_order_number = is_numeric( $intent_meta_order_number_raw ) ? intval( $intent_meta_order_number_raw ) : 0;
$paid_on_woopay = filter_var( $intent->get_metadata()['paid_on_woopay'] ?? false, FILTER_VALIDATE_BOOLEAN );
$is_woopay_order = $order->get_id() === $intent_meta_order_number;
if ( ! ( $paid_on_woopay && $is_woopay_order ) && $intent_meta_order_id !== $order->get_id() ) {
return;
}
if ( Intent_Status::SUCCEEDED === $intent_status ) {
$this->remove_session_processing_order( $order->get_id() );
}
$this->order_service->update_order_status_from_intent( $order, $intent );
$return_url = $this->gateway->get_return_url( $order );
$return_url = add_query_arg( self::FLAG_PREVIOUS_SUCCESSFUL_INTENT, 'yes', $return_url );
return [ // nosemgrep: audit.php.wp.security.xss.query-arg -- https://woocommerce.github.io/code-reference/classes/WC-Payment-Gateway.html#method_get_return_url is passed in.
'result' => 'success',
'redirect' => $return_url,
];
}
/**
* Checks if the current order has the same content with the session processing order, which was already paid (ex. by a webhook).
*
* @param WC_Order $current_order Current order in process_payment.
*
* @return array|void A successful response in case the session processing order was paid, null if none.
*/
public function check_against_session_processing_order( WC_Order $current_order ) {
$session_order_id = $this->get_session_processing_order();
if ( null === $session_order_id ) {
return;
}
$session_order = wc_get_order( $session_order_id );
if ( ! is_a( $session_order, 'WC_Order' ) ) {
return;
}
if ( $current_order->get_cart_hash() !== $session_order->get_cart_hash() ) {
return;
}
if ( ! $session_order->has_status( wc_get_is_paid_statuses() ) ) {
return;
}
if ( ! $current_order->has_status( wc_get_is_pending_statuses() ) ) {
return;
}
if ( $session_order->get_id() === $current_order->get_id() ) {
return;
}
if ( $session_order->get_customer_id() !== $current_order->get_customer_id() ) {
return;
}
$session_order->add_order_note(
sprintf(
/* translators: order ID integer number */
__( 'WooCommerce Payments: detected and deleted order ID %d, which has duplicate cart content with this order.', 'woocommerce-payments' ),
$current_order->get_id()
)
);
$current_order->delete();
$this->remove_session_processing_order( $session_order_id );
$return_url = $this->gateway->get_return_url( $session_order );
$return_url = add_query_arg( self::FLAG_PREVIOUS_ORDER_PAID, 'yes', $return_url );
return [ // nosemgrep: audit.php.wp.security.xss.query-arg -- https://woocommerce.github.io/code-reference/classes/WC-Payment-Gateway.html#method_get_return_url is passed in.
'result' => 'success',
'redirect' => $return_url,
];
}
/**
* Update the processing order ID for the current session.
*
* @param int $order_id Order ID.
*
* @return void
*/
public function maybe_update_session_processing_order( int $order_id ) {
if ( WC()->session ) {
WC()->session->set( self::SESSION_KEY_PROCESSING_ORDER, $order_id );
}
}
/**
* Remove the provided order ID from the current session if it matches with the ID in the session.
*
* @param int $order_id Order ID to remove from the session.
*
* @return void
*/
public function remove_session_processing_order( int $order_id ) {
$current_session_id = $this->get_session_processing_order();
if ( $order_id === $current_session_id && WC()->session ) {
WC()->session->set( self::SESSION_KEY_PROCESSING_ORDER, null );
}
}
/**
* Get the processing order ID for the current session.
*
* @return integer|null Order ID. Null if the value is not set.
*/
protected function get_session_processing_order() {
$session = WC()->session;
if ( null === $session ) {
return null;
}
$val = $session->get( self::SESSION_KEY_PROCESSING_ORDER );
return null === $val ? null : absint( $val );
}
}
@@ -0,0 +1,232 @@
<?php
/**
* Class Duplicates_Detection_Service
*
* @package WooCommerce\Payments
*/
namespace WCPay;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WC_Payments;
use WCPay\Payment_Methods\Affirm_Payment_Method;
use WCPay\Payment_Methods\Afterpay_Payment_Method;
use WCPay\Payment_Methods\Bancontact_Payment_Method;
use WCPay\Payment_Methods\Becs_Payment_Method;
use WCPay\Payment_Methods\CC_Payment_Method;
use WCPay\Payment_Methods\Eps_Payment_Method;
use WCPay\Payment_Methods\Ideal_Payment_Method;
use WCPay\Payment_Methods\Klarna_Payment_Method;
use WCPay\Payment_Methods\P24_Payment_Method;
use WCPay\Payment_Methods\Sepa_Payment_Method;
/**
* Class handling detection of payment methods enabled by multiple plugins simultaneously.
*/
class Duplicates_Detection_Service {
/**
* Registered gateways.
*
* @var array
*/
private $registered_gateways = null;
/**
* Gateways qualified by duplicates detector.
*
* @var array
*/
private $gateways_qualified_by_duplicates_detector = [];
/**
* Find duplicates.
*
* @return array Duplicated gateways.
*/
public function find_duplicates() {
try {
$this->gateways_qualified_by_duplicates_detector = [];
$this->search_for_cc()
->search_for_additional_payment_methods()
->search_for_payment_request_buttons()
->keep_gateways_enabled_in_woopayments()
->keep_duplicates_only();
// Return payment method IDs list so that front-end can successfully compare with its own list.
return $this->gateways_qualified_by_duplicates_detector;
} catch ( \Exception $e ) {
Logger::warning( 'Duplicates detection service failed silently with the following error: ' . $e->getMessage() );
// Fail silently and return an empty array in case of any exception.
return [];
}
}
/**
* Search for credit card gateways.
*
* @return Duplicates_Detection_Service
*/
private function search_for_cc() {
$keywords = [ 'credit_card', 'creditcard', 'cc', 'card' ];
$special_keywords = [ 'woocommerce_payments', 'stripe' ];
foreach ( $this->get_enabled_gateways() as $gateway ) {
if ( $this->gateway_contains_keyword( $gateway->id, $keywords ) || in_array( $gateway->id, $special_keywords, true ) ) {
$this->gateways_qualified_by_duplicates_detector[ CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID ][] = $gateway->id;
}
}
return $this;
}
/**
* Search for additional payment methods.
*
* @return Duplicates_Detection_Service
*/
private function search_for_additional_payment_methods() {
$keywords = [
'bancontact' => Bancontact_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'sepa' => Sepa_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'p24' => P24_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'przelewy24' => P24_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'ideal' => Ideal_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'becs' => Becs_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'eps' => Eps_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'affirm' => Affirm_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'afterpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'clearpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
'klarna' => Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID,
];
foreach ( $this->get_enabled_gateways() as $gateway ) {
foreach ( $keywords as $keyword => $payment_method ) {
if ( strpos( $gateway->id, $keyword ) !== false ) {
$this->gateways_qualified_by_duplicates_detector[ $payment_method ][] = $gateway->id;
break;
}
}
}
return $this;
}
/**
* Search for payment request buttons.
*
* @return Duplicates_Detection_Service
*/
private function search_for_payment_request_buttons() {
$prb_payment_method = 'apple_pay_google_pay';
$keywords = [
'apple_pay',
'applepay',
'google_pay',
'googlepay',
];
foreach ( $this->get_registered_gateways() as $gateway ) {
if ( 'yes' === $gateway->enabled ) {
foreach ( $keywords as $keyword ) {
if ( strpos( $gateway->id, $keyword ) !== false ) {
$this->gateways_qualified_by_duplicates_detector[ $prb_payment_method ][] = $gateway->id;
break;
} elseif ( 'yes' === $gateway->get_option( 'payment_request' ) ) {
$this->gateways_qualified_by_duplicates_detector[ $prb_payment_method ][] = $gateway->id;
break;
} elseif ( 'yes' === $gateway->get_option( 'express_checkout_enabled' ) ) {
$this->gateways_qualified_by_duplicates_detector[ $prb_payment_method ][] = $gateway->id;
break;
}
}
}
}
return $this;
}
/**
* Keep only WooCommerce Payments enabled gateways.
*
* @return Duplicates_Detection_Service
*/
private function keep_gateways_enabled_in_woopayments() {
$woopayments_gateway_ids = array_map(
function ( $gateway ) {
return $gateway->id; },
array_values( WC_Payments::get_payment_gateway_map() )
);
foreach ( $this->gateways_qualified_by_duplicates_detector as $gateway_id => $gateway_ids ) {
if ( empty( array_intersect( $gateway_ids, $woopayments_gateway_ids ) ) ) {
unset( $this->gateways_qualified_by_duplicates_detector[ $gateway_id ] );
}
}
return $this;
}
/**
* Filter payment methods found to keep duplicates only.
*
* @return Duplicates_Detection_Service
*/
private function keep_duplicates_only() {
foreach ( $this->gateways_qualified_by_duplicates_detector as $gateway_id => $gateway_ids ) {
if ( count( $gateway_ids ) < 2 ) {
unset( $this->gateways_qualified_by_duplicates_detector[ $gateway_id ] );
}
}
return $this;
}
/**
* Filter enabled gateways only.
*
* @return array Enabled gateways only.
*/
private function get_enabled_gateways() {
return array_filter(
$this->get_registered_gateways(),
function ( $gateway ) {
return 'yes' === $gateway->enabled;
}
);
}
/**
* Check if gateway ID contains any of the keywords.
*
* @param string $gateway_id Gateway ID.
* @param array $keywords Keywords to search for.
*
* @return bool True if gateway ID contains any of the keywords, false otherwise.
*/
private function gateway_contains_keyword( $gateway_id, $keywords ) {
foreach ( $keywords as $keyword ) {
if ( strpos( $gateway_id, $keyword ) !== false ) {
return true;
}
}
return false;
}
/**
* Lazy load registered gateways.
*
* @return array Registered gateways.
*/
private function get_registered_gateways() {
if ( null === $this->registered_gateways ) {
$this->registered_gateways = WC()->payment_gateways->payment_gateways();
}
return $this->registered_gateways;
}
}
@@ -0,0 +1,172 @@
<?php
/**
* A class that interacts with Explat A/B tests.
*
* This class is experimental. It is a fork of the jetpack-abtest package and
* updated for use with ExPlat. These changes are planned to be contributed
* back to the upstream Jetpack package. If accepted, this class should then
* be superseded by the Jetpack class using Composer.
*
* This class should not be used externally.
*
* @package WooCommerce\Payments
* @link https://packagist.org/packages/automattic/jetpack-abtest
*/
namespace WCPay;
/**
* This class provides an interface to the Explat A/B tests.
*
* @internal This class is experimental and should not be used externally due to planned breaking changes.
*/
final class Experimental_Abtest {
/**
* A variable to hold the tests we fetched, and their variations for the current user.
*
* @var array
*/
private $tests = [];
/**
* ExPlat Anonymous ID.
*
* @var string
*/
private $anon_id = null;
/**
* ExPlat Platform name.
*
* @var string
*/
private $platform = 'woocommerce';
/**
* Whether trcking consent is given.
*
* @var bool
*/
private $consent = false;
/**
* Constructor.
*
* @param string $anon_id ExPlat anonymous ID.
* @param string $platform ExPlat platform name.
* @param bool $consent Whether tracking consent is given.
*/
public function __construct( string $anon_id, string $platform, bool $consent ) {
$this->anon_id = $anon_id;
$this->platform = $platform;
$this->consent = $consent;
}
/**
* Retrieve the test variation for a provided A/B test.
*
* @param string $test_name Name of the A/B test.
* @return mixed A/B test variation, or null on failure.
*/
public function get_variation( $test_name ) {
// Default to the control variation when users haven't consented to tracking.
if ( ! $this->consent ) {
return 'control';
}
$variation = $this->fetch_variation( $test_name );
// If there was an error retrieving a variation, conceal the error for the consumer.
if ( is_wp_error( $variation ) ) {
return 'control';
}
return $variation;
}
/**
* Fetch and cache the test variation for a provided A/B test from WP.com.
*
* ExPlat returns a null value when the assigned variation is control or
* an assignment has not been set. In these instances, this method returns
* a value of "control".
*
* @param string $test_name Name of the A/B test.
* @return string|array|\WP_Error A/B test variation, or error on failure.
*/
protected function fetch_variation( $test_name ) {
// Make sure test name exists.
if ( ! $test_name ) {
return new \WP_Error( 'test_name_not_provided', 'A/B test name has not been provided.' );
}
// Make sure test name is a valid one.
if ( ! preg_match( '/^[[:alnum:]_]+$/', $test_name ) ) {
return new \WP_Error( 'invalid_test_name', 'Invalid A/B test name.' );
}
// Return internal-cached test variations.
if ( isset( $this->tests[ $test_name ] ) ) {
return $this->tests[ $test_name ];
}
// Return external-cached test variations.
if ( ! empty( get_transient( 'abtest_variation_' . $test_name ) ) ) {
return get_transient( 'abtest_variation_' . $test_name );
}
// Make the request to the WP.com API.
$response = $this->request_variation( $test_name );
// Bail if there was an error or malformed response.
if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) {
return new \WP_Error( 'failed_to_fetch_data', 'Unable to fetch the requested data.' );
}
// Decode the results.
$results = json_decode( $response['body'], true );
// Bail if there were no results or there is no test variation returned.
if ( ! is_array( $results ) || empty( $results['variations'] ) ) {
return new \WP_Error( 'unexpected_data_format', 'Data was not returned in the expected format.' );
}
// Store the variation in our internal cache.
$this->tests[ $test_name ] = $results['variations'][ $test_name ];
$variation = $results['variations'][ $test_name ] ?? 'control';
// Store the variation in our external cache.
if ( ! empty( $results['ttl'] ) ) {
set_transient( 'abtest_variation_' . $test_name, $variation, $results['ttl'] );
}
return $variation;
}
/**
* Perform the request for a variation of a provided A/B test from WP.com.
*
* @param string $test_name Name of the A/B test.
* @return array|\WP_Error A/B test variation error on failure.
*/
protected function request_variation( $test_name ) {
$args = [
'experiment_name' => $test_name,
'anon_id' => $this->anon_id,
'woo_country_code' => get_option( 'woocommerce_default_country' ),
];
$url = add_query_arg(
$args,
sprintf( // nosemgrep: audit.php.wp.security.xss.query-arg -- constant value is passed in to add_query_arg.
'https://public-api.wordpress.com/wpcom/v2/experiments/0.1.0/assignments/%s',
$this->platform
)
);
$get = wp_remote_get( $url );
return $get;
}
}
@@ -0,0 +1,29 @@
<?php
/**
* Class LoggerContext
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use WCPay\Internal\LoggerContext as InternalLoggerContext;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* A wrapper class for accessing LoggerContext as a singletone.
*/
class Logger_Context {
/**
* Sets a context value.
*
* @param string $key The key to set.
* @param string|int|float|bool|null $value The value to set. Null removes value.
*
* @return void
*/
public static function set_value( $key, $value ) {
wcpay_get_container()->get( InternalLoggerContext::class )->set_value( $key, $value );
}
}
@@ -0,0 +1,139 @@
<?php
/**
* Class Logger
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use WCPay\Internal\Logger as InternalLogger;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* A wrapper class for interacting with WC_Logger.
*/
class Logger {
/**
* Add a log entry.
*
* Note that this depends on WC_Payments gateway property to be initialized as
* we need this to access the plugins debug setting to figure out if the setting
* is turned on.
*
* @param string $message Log message.
*
* @param string $level One of the following:
* 'emergency': System is unusable.
* 'alert': Action must be taken immediately.
* 'critical': Critical conditions.
* 'error': Error conditions.
* 'warning': Warning conditions.
* 'notice': Normal but significant condition.
* 'info': Informational messages.
* 'debug': Debug-level messages.
* @param array<string, string> $context Context data.
*/
public static function log( $message, $level = 'info', $context = [] ) {
wcpay_get_container()->get( InternalLogger::class )->log( $message, $level, $context );
}
/**
* Checks if the gateway setting logging toggle is enabled.
*
* @return bool Depending on the enable_logging setting.
*/
public static function can_log() {
return wcpay_get_container()->get( InternalLogger::class )->can_log();
}
/**
* Creates a log entry of type emergency
*
* @param string $message To send to the log file.
*/
public static function emergency( $message ) {
self::log( $message, 'emergency' );
}
/**
* Creates a log entry of type alert
*
* @param string $message To send to the log file.
*/
public static function alert( $message ) {
self::log( $message, 'alert' );
}
/**
* Creates a log entry of type critical
*
* @param string $message To send to the log file.
*/
public static function critical( $message ) {
self::log( $message, 'critical' );
}
/**
* Creates a log entry of type error
*
* @param string $message To send to the log file.
*/
public static function error( $message ) {
self::log( $message, 'error' );
}
/**
* Creates a log entry of type warning
*
* @param string $message To send to the log file.
*/
public static function warning( $message ) {
self::log( $message, 'warning' );
}
/**
* Creates a log entry of type notice
*
* @param string $message To send to the log file.
*/
public static function notice( $message ) {
self::log( $message, 'notice' );
}
/**
* Creates a log entry of type info
*
* @param string $message To send to the log file.
*/
public static function info( $message ) {
self::log( $message, 'info' );
}
/**
* Creates a log entry of type debug
*
* @param string $message To send to the log file.
*/
public static function debug( $message ) {
self::log( $message, 'debug' );
}
/**
* Formats an object for logging.
*
* @param string $label Label for the object.
* @param mixed $object Object to format.
* @return string
*/
public static function format_object( $label, $object ) {
try {
$encoded = wp_json_encode( $object, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR );
} catch ( \JsonException $e ) {
return sprintf( 'Error encoding object "%s": %s', $label, $e->getMessage() );
}
return sprintf( '%s (JSON): %s', $label, $encoded );
}
}
@@ -0,0 +1,501 @@
<?php
/**
* Class Payment_Information
*
* @package WooCommerce\Payments
*/
namespace WCPay;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Constants\Payment_Type;
use WCPay\Constants\Payment_Initiated_By;
use WCPay\Constants\Payment_Capture_Type;
use WCPay\Exceptions\Invalid_Payment_Method_Exception;
use WCPay\Payment_Methods\CC_Payment_Gateway;
/**
* Mostly a wrapper containing information on a single payment.
*/
class Payment_Information {
/**
* Key used to indicate that an error occurred during the payment method creation in the client.
*
* @type string
*/
const PAYMENT_METHOD_ERROR = 'woocommerce_payments_payment_method_error';
/**
* The ID of the payment method used for this payment.
*
* @var string
*/
private $payment_method;
/**
* The order object.
*
* @var ?\WC_Order
*/
private $order;
/**
* The payment token used for this payment.
*
* @var ?\WC_Payment_Token
*/
private $token;
/**
* The CVC confirmation used for this payment.
*
* @var string
*/
private $cvc_confirmation;
/**
* Indicates whether the payment is merchant-initiated (true) or customer-initiated (false).
*
* @var ?Payment_Initiated_By
*/
private $payment_initiated_by;
/**
* Indicates whether the payment will be only authorized (true) or captured immediately (false).
*
* @var ?Payment_Capture_Type
*/
private $manual_capture;
/**
* The type of the payment. `single`, `recurring`, etc.
*
* @var Payment_Type
*/
private $payment_type;
/**
* Indicates whether the payment method should be saved to the store.
*
* @var bool
*/
private $save_payment_method_to_store = false;
/**
* Indicates whether the payment method should be saved to the platform.
*
* @var bool
*/
private $save_payment_method_to_platform = false;
/**
* Indicates whether user is changing payment method for subscriptions order.
*
* @var bool
*/
private $is_changing_payment_method_for_subscription = false;
/**
* The attached fingerprint.
*
* @var string
*/
private $fingerprint = '';
/**
* The Stripe ID of the payment method used for this payment.
*
* @var string
*/
private $payment_method_stripe_id;
/**
* The WCPay Customer ID that owns the payment token.
*
* @var string
*/
private $customer_id;
/**
* Will be set if there was an error during setup.
*
* @var ?\WP_Error
*/
private $error = null;
/**
* Payment information constructor.
*
* @param string $payment_method The ID of the payment method used for this payment.
* @param \WC_Order $order The order object.
* @param Payment_Type $payment_type The type of the payment.
* @param \WC_Payment_Token $token The payment token used for this payment.
* @param Payment_Initiated_By $payment_initiated_by Indicates whether the payment is merchant-initiated or customer-initiated.
* @param Payment_Capture_Type $manual_capture Indicates whether the payment will be only authorized or captured immediately.
* @param string $cvc_confirmation The CVC confirmation for this payment method.
* @param string $fingerprint The attached fingerprint.
* @param string $payment_method_stripe_id The Stripe ID of the payment method used for this payment.
* @param string $customer_id The WCPay Customer ID that owns the payment token.
*
* @throws Invalid_Payment_Method_Exception When no payment method is found in the provided request.
*/
public function __construct(
string $payment_method,
?\WC_Order $order = null,
?Payment_Type $payment_type = null,
?\WC_Payment_Token $token = null,
?Payment_Initiated_By $payment_initiated_by = null,
?Payment_Capture_Type $manual_capture = null,
?string $cvc_confirmation = null,
string $fingerprint = '',
?string $payment_method_stripe_id = null,
?string $customer_id = null
) {
if ( empty( $payment_method ) && empty( $token ) && ! \WC_Payments::is_network_saved_cards_enabled() ) {
// If network-wide cards are enabled, a payment method or token may not be specified and the platform default one will be used.
throw new Invalid_Payment_Method_Exception(
esc_html__( 'Invalid or missing payment details. Please ensure the provided payment method is correctly entered.', 'woocommerce-payments' ),
'payment_method_not_provided'
);
}
$this->payment_method = $payment_method;
$this->order = $order;
$this->token = $token;
$this->payment_initiated_by = $payment_initiated_by ?? Payment_Initiated_By::CUSTOMER();
$this->manual_capture = $manual_capture ?? Payment_Capture_Type::AUTOMATIC();
$this->payment_type = $payment_type ?? Payment_Type::SINGLE();
$this->cvc_confirmation = $cvc_confirmation;
$this->fingerprint = $fingerprint;
$this->payment_method_stripe_id = $payment_method_stripe_id;
$this->customer_id = $customer_id;
}
/**
* Returns true if payment was initiated by the merchant, false otherwise.
*
* @return bool True if payment was initiated by the merchant, false otherwise.
*/
public function is_merchant_initiated(): bool {
return $this->payment_initiated_by->equals( Payment_Initiated_By::MERCHANT() );
}
/**
* Returns the payment method ID.
*
* @return string The payment method ID.
*/
public function get_payment_method(): string {
// Use the token if we have it.
if ( $this->is_using_saved_payment_method() ) {
return $this->token->get_token();
}
return $this->payment_method;
}
/**
* Returns the order object.
*
* @return ?\WC_Order The order object.
*/
public function get_order(): ?\WC_Order {
return $this->order;
}
/**
* Returns the payment token.
*
* TODO: Once php requirement is bumped to >= 7.1.0 change return type to ?\WC_Payment_Token
* since the return type is nullable, as per
* https://www.php.net/manual/en/functions.returning-values.php#functions.returning-values.type-declaration
*
* @return ?\WC_Payment_Token The payment token.
*/
public function get_payment_token(): ?\WC_Payment_Token {
return $this->token;
}
/**
* Update the payment token associated with this payment.
*
* @param \WC_Payment_Token $token The new payment token.
*/
public function set_token( \WC_Payment_Token $token ) {
$this->token = $token;
}
/**
* Returns true if the payment token is not empty, false otherwise.
*
* @return bool True if payment token is not empty, false otherwise.
*/
public function is_using_saved_payment_method(): bool {
return ! empty( $this->token );
}
/**
* Returns true if the payment should be only authorized, false if it should be captured immediately.
*
* @return bool True if the payment should be only authorized, false if it should be captured immediately.
*/
public function is_using_manual_capture(): bool {
return $this->manual_capture->equals( Payment_Capture_Type::MANUAL() );
}
/**
* Payment information constructor.
*
* @param array $request Associative array containing payment request information.
* @param \WC_Order $order The order object.
* @param Payment_Type $payment_type The type of the payment.
* @param Payment_Initiated_By $payment_initiated_by Indicates whether the payment is merchant-initiated or customer-initiated.
* @param Payment_Capture_Type $manual_capture Indicates whether the payment will be only authorized or captured immediately.
* @param string $payment_method_stripe_id The Stripe ID of the payment method used for this payment.
*
* @throws \Exception - If no payment method is found in the provided request.
*/
public static function from_payment_request(
array $request,
?\WC_Order $order = null,
?Payment_Type $payment_type = null,
?Payment_Initiated_By $payment_initiated_by = null,
?Payment_Capture_Type $manual_capture = null,
?string $payment_method_stripe_id = null
): Payment_Information {
$payment_method = self::get_payment_method_from_request( $request );
$token = self::get_token_from_request( $request );
$cvc_confirmation = self::get_cvc_confirmation_from_request( $request );
$fingerprint = self::get_fingerprint_from_request( $request );
if ( isset( $request['is_woopay'] ) && $request['is_woopay'] ) {
$order->add_meta_data( 'is_woopay', true, true );
$order->save_meta_data();
}
$payment_information = new Payment_Information( $payment_method, $order, $payment_type, $token, $payment_initiated_by, $manual_capture, $cvc_confirmation, $fingerprint, $payment_method_stripe_id );
if ( self::PAYMENT_METHOD_ERROR === $payment_method ) {
$error_message = $request['wcpay-payment-method-error-message'] ?? __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' );
$error_code = $request['wcpay-payment-method-error-code'] ?? 'unknown-error';
$error = new \WP_Error( $error_code, $error_message );
$payment_information->set_error( $error );
}
return $payment_information;
}
/**
* Extracts the payment method from the provided request.
*
* @param array $request Associative array containing payment request information.
*
* @return string
*/
public static function get_payment_method_from_request( array $request ): string {
foreach ( [ 'wcpay-payment-method', 'wcpay-payment-method-sepa' ] as $key ) {
if ( ! empty( $request[ $key ] ) ) {
$normalized = wc_clean( $request[ $key ] );
return is_string( $normalized ) ? $normalized : '';
}
}
return '';
}
/**
* Extract the payment token from the provided request.
*
* TODO: Once php requirement is bumped to >= 7.1.0 set return type to ?\WC_Payment_Token
* since the return type is nullable, as per
* https://www.php.net/manual/en/functions.returning-values.php#functions.returning-values.type-declaration
*
* @param array $request Associative array containing payment request information.
*
* @return \WC_Payment_Token|NULL
*/
public static function get_token_from_request( array $request ) {
$payment_method = $request['payment_method'] ?? null;
$token_request_key = 'wc-' . $payment_method . '-payment-token';
if (
! isset( $request[ $token_request_key ] ) ||
'new' === $request[ $token_request_key ]
) {
return null;
}
//phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$token = \WC_Payment_Tokens::get( wc_clean( $request[ $token_request_key ] ) );
// If the token doesn't belong to this gateway or the current user it's invalid.
if ( ! $token || $payment_method !== $token->get_gateway_id() || $token->get_user_id() !== get_current_user_id() ) {
return null;
}
return $token;
}
/**
* Extract the payment CVC confirmation from the provided request.
*
* @param array $request Associative array containing payment request information.
*
* @return string|NULL
*/
public static function get_cvc_confirmation_from_request( array $request ) {
$payment_method = $request['payment_method'] ?? null;
if ( null === $payment_method ) {
return null;
}
$cvc_request_key = 'wc-' . $payment_method . '-payment-cvc-confirmation';
if (
! isset( $request[ $cvc_request_key ] ) ||
'new' === $request[ $cvc_request_key ]
) {
return null;
}
return $request[ $cvc_request_key ];
}
/**
* Extracts the fingerprint data from the provided request.
*
* @param array $request Associative array containing payment request information.
*
* @return string
*/
public static function get_fingerprint_from_request( array $request ) {
if ( ! empty( $request['wcpay-fingerprint'] ) ) {
$normalized = wc_clean( $request['wcpay-fingerprint'] );
return is_string( $normalized ) ? $normalized : '';
}
return '';
}
/**
* Changes the type of the payment.
*
* @param Payment_Type $type The new type.
*/
public function set_payment_type( $type ) {
$this->payment_type = $type;
}
/**
* Retrieves the type of the payment.
*
* @return Payment_Type The payment type.
*/
public function get_payment_type() {
return $this->payment_type;
}
/**
* Forces the payment method to be saved to merchant store when the payment gets processed.
*/
public function must_save_payment_method_to_store() {
$this->save_payment_method_to_store = true;
}
/**
* Forces the payment method to be saved to platform when the payment gets processed.
*/
public function must_save_payment_method_to_platform() {
$this->save_payment_method_to_platform = true;
}
/**
* Indicates whether the payment method needs to be saved to the store for later usage.
*
* @return bool The flag.
*/
public function should_save_payment_method_to_store() {
return ! $this->is_using_saved_payment_method() && $this->save_payment_method_to_store;
}
/**
* Indicates whether the payment method needs to be saved to the platform for later usage.
*
* @return bool The flag.
*/
public function should_save_payment_method_to_platform() {
return ! $this->is_using_saved_payment_method() && $this->save_payment_method_to_platform;
}
/**
* Mark that we are changing payment for subscriptions order or not.
*
* @param bool $is_changing_payment_method_for_subscription Whether or not we are changing payment for subscriptions order.
*/
public function set_is_changing_payment_method_for_subscription( bool $is_changing_payment_method_for_subscription ) {
$this->is_changing_payment_method_for_subscription = $is_changing_payment_method_for_subscription;
}
/**
* Returns the flag of whether or not we are changing payment for subscriptions order.
*
* @return bool Whether or not we are changing payment for subscriptions order.
*/
public function is_changing_payment_method_for_subscription(): bool {
return $this->is_changing_payment_method_for_subscription;
}
/**
* Returns the payment method CVC confirmation.
*
* @return string|NULL The payment method CVC confirmation.
*/
public function get_cvc_confirmation() {
return $this->cvc_confirmation;
}
/**
* Returns the attached fingerprint.
*
* @return string The attached fingerprint.
*/
public function get_fingerprint() {
return $this->fingerprint;
}
/**
* Returns the Stripe ID of payment method.
*
* @return string The Stripe ID of payment method.
*/
public function get_payment_method_stripe_id() {
return $this->payment_method_stripe_id;
}
/**
* Returns the WCPay Customer ID that owns the payment token.
*
* @return string The WCPay Customer ID.
*/
public function get_customer_id() {
return $this->customer_id;
}
/**
* Sets the error data.
*
* @param \WP_Error $error The error to be set.
* @return void
*/
public function set_error( \WP_Error $error ) {
$this->error = $error;
}
/**
* Returns the error data.
*
* @return ?\WP_Error
*/
public function get_error() {
return $this->error;
}
}
@@ -0,0 +1,111 @@
<?php
/**
* Class Session_Rate_Limiter
*
* @package WooCommerce\Payments
*/
namespace WCPay;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* A wrapper class for keeping track of events in registries, and to trigger a rate limiter after a threshold.
*/
class Session_Rate_Limiter {
/**
* Key used in the session to store card declined transactions.
*
* @type string
*/
const SESSION_KEY_DECLINED_CARD_REGISTRY = 'wcpay_card_declined_registry';
/**
* Key used to store the registry in the session
*
* @var string
*/
protected $key;
/**
* Number of elements in the registry needed to enable the rate limiter
*
* @var int
*/
protected $threshold;
/**
* Number of seconds the limiter is enabled for after the threshold is reached
*
* @var int
*/
protected $delay;
/**
* Session_Rate_Limiter constructor.
*
* @param string $key - Key for the registry.
* @param int $threshold - Number of elements in the registry before enabling the limiter.
* @param int $delay - Number of seconds the limiter will be in use after threshold is reached.
*/
public function __construct(
$key,
$threshold,
$delay
) {
$this->key = $key;
$this->threshold = $threshold;
$this->delay = $delay;
}
/**
* Saves an event in an specified registry using a key.
* If the number of events in the registry match the threshold,
* a new rate limiter is enabled with the given delay.
*
* The registry of declined card attemps is cleaned after a new rate limiter is enabled.
*/
public function bump() {
if ( ! isset( WC()->session ) ) {
return;
}
$registry = WC()->session->get( $this->key ) ?? [];
$registry[] = time();
WC()->session->set( $this->key, $registry );
}
/**
* Checks if the rate limiter is enabled.
*
* Returns a boolean.
*
* @return bool The rate limiter is in use.
*/
public function is_limited(): bool {
if ( ! isset( WC()->session ) ) {
return false;
}
if ( 'yes' === get_option( 'wcpay_session_rate_limiter_disabled_' . $this->key ) ) {
return false;
}
$registry = WC()->session->get( $this->key ) ?? [];
if ( ( is_countable( $registry ) ? count( $registry ) : 0 ) >= $this->threshold ) {
$start_time_limiter = end( $registry );
$next_try_allowed_at = $start_time_limiter + $this->delay;
$is_limited = time() <= $next_try_allowed_at;
if ( ! $is_limited ) {
WC()->session->set( $this->key, [] );
}
return $is_limited;
}
return false;
}
}
@@ -0,0 +1,122 @@
<?php
/**
* Class WC_Payment_Token_WCPay_Link
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* WooCommerce Stripe Link Payment Token.
*
* Representation of a payment token for Link.
*
* @class WC_Payment_Token_WCPay_Link
*/
class WC_Payment_Token_WCPay_Link extends WC_Payment_Token {
/**
* Class Constant so other code can be unambiguous.
*
* @type string
*/
const TYPE = 'wcpay_link';
/**
* The payment method type of this token.
*
* @var string
*/
protected $type = self::TYPE;
/**
* Stores Link payment token data.
*
* @var array
*/
protected $extra_data = [
'email' => '',
];
/**
* Get payment method type to display to user.
*
* @param string $deprecated Deprecated since WooCommerce 3.0.
* @return string
*/
public function get_display_name( $deprecated = '' ) {
$display = sprintf(
/* translators: customer email */
__( 'Stripe Link email ending in %s', 'woocommerce-payments' ),
$this->get_redacted_email()
);
return $display;
}
/**
* Hook prefix.
*/
protected function get_hook_prefix() {
return 'woocommerce_payments_token_wcpay_link_get_';
}
/**
* Returns the customer email.
*
* @param string $context What the value is for. Valid values are view and edit.
*
* @return string Customer email.
*/
public function get_email( $context = 'view' ) {
return $this->get_prop( 'email', $context );
}
/**
* Set the customer email.
*
* @param string $email Customer email.
*/
public function set_email( $email ) {
$this->set_prop( 'email', $email );
}
/**
* Returns redacted/shortened customer email
*
* @param string $context What the value is for. Valid values are view and edit.
* @return string Redacted/shortened customer email.
*/
public function get_redacted_email( $context = 'view' ) {
return $this->redact_email_address( $this->get_email( $context ) );
}
/**
* Returns the type of this payment token (CC, eCheck, or something else).
*
* @param string $deprecated Deprecated since WooCommerce 3.0.
* @return string Payment Token Type (CC, eCheck)
*/
public function get_type( $deprecated = '' ) {
return self::TYPE;
}
/**
* Transforms email address into redacted/shortened format like ***xxxx@x.com.
* Using shortened length of four characters will mimic CC last-4 digits of card number.
*
* @param string $email Email address.
* @return string Redacted/shortened email address.
*/
private function redact_email_address( $email ) {
$placeholder = '***';
$shortened_length = 4;
list( $handle, $domain ) = explode( '@', $email );
$redacted_handle = strlen( $handle ) > $shortened_length ? substr( $handle, - $shortened_length ) : $handle;
return "$placeholder$redacted_handle@$domain";
}
}
@@ -0,0 +1,105 @@
<?php
/**
* Class WC_Payment_Token_WCPay_SEPA
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* WooCommerce Stripe SEPA Direct Debit Payment Token.
*
* Representation of a payment token for SEPA.
*
* @class WC_Payment_Token_WCPay_SEPA
*/
class WC_Payment_Token_WCPay_SEPA extends WC_Payment_Token {
/**
* Class Constant so other code can be unambiguous.
*
* @type string
*/
const TYPE = 'wcpay_sepa';
/**
* The payment method type of this token.
*
* @var string
*/
protected $type = self::TYPE;
/**
* Stores SEPA payment token data.
*
* @var array
*/
protected $extra_data = [
'last4' => '',
];
/**
* Get type to display to user.
*
* @param string $deprecated Deprecated since WooCommerce 3.0.
* @return string
*/
public function get_display_name( $deprecated = '' ) {
$display = sprintf(
/* translators: last 4 digits of IBAN account */
__( 'SEPA IBAN ending in %s', 'woocommerce-payments' ),
$this->get_last4()
);
return $display;
}
/**
* Hook prefix.
*/
protected function get_hook_prefix() {
return 'woocommerce_payments_token_wcpay_sepa_get_';
}
/**
* Validate SEPA payment tokens.
*
* These fields are required by all SEPA payment tokens:
* last4 - string Last 4 digits of the iBAN
*
* @return boolean True if the passed data is valid
*/
public function validate() {
if ( false === parent::validate() ) {
return false;
}
if ( ! $this->get_last4( 'edit' ) ) {
return false;
}
return true;
}
/**
* Returns the last four digits.
*
* @param string $context What the value is for. Valid values are view and edit.
* @return string Last 4 digits
*/
public function get_last4( $context = 'view' ) {
return $this->get_prop( 'last4', $context );
}
/**
* Set the last four digits.
*
* @param string $last4 SEPA Debit last four digits.
*/
public function set_last4( $last4 ) {
$this->set_prop( 'last4', $last4 );
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
<?php
/**
* WC_Payments_Action_Scheduler_Service class
*
* @package WooCommerce\Payments
*/
use WCPay\Compatibility_Service;
use WCPay\Constants\Order_Mode;
defined( 'ABSPATH' ) || exit;
/**
* Class which handles setting up all ActionScheduler hooks.
*/
class WC_Payments_Action_Scheduler_Service {
const GROUP_ID = 'woocommerce_payments';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* WC_Payments_Order_Service instance for updating order statuses.
*
* @var WC_Payments_Order_Service
*/
private $order_service;
/**
* Compatibility service instance for updating compatibility data.
*
* @var Compatibility_Service
*/
private $compatibility_service;
/**
* Constructor for WC_Payments_Action_Scheduler_Service.
*
* @param WC_Payments_API_Client $payments_api_client - WooCommerce Payments API client.
* @param WC_Payments_Order_Service $order_service - Order Service.
* @param Compatibility_Service $compatibility_service - Compatibility service instance.
*/
public function __construct(
WC_Payments_API_Client $payments_api_client,
WC_Payments_Order_Service $order_service,
Compatibility_Service $compatibility_service
) {
$this->payments_api_client = $payments_api_client;
$this->order_service = $order_service;
$this->compatibility_service = $compatibility_service;
$this->add_action_scheduler_hooks();
}
/**
* Attach hooks for all ActionScheduler actions.
*
* @return void
*/
public function add_action_scheduler_hooks() {
add_action( 'wcpay_track_new_order', [ $this, 'track_new_order_action' ] );
add_action( 'wcpay_track_update_order', [ $this, 'track_update_order_action' ] );
add_action( WC_Payments_Order_Service::ADD_FEE_BREAKDOWN_TO_ORDER_NOTES, [ $this->order_service, 'add_fee_breakdown_to_order_notes' ], 10, 3 );
add_action( Compatibility_Service::UPDATE_COMPATIBILITY_DATA, [ $this->compatibility_service, 'update_compatibility_data_hook' ], 10, 0 );
}
/**
* This function is a hook that will be called by ActionScheduler when an order is created.
* It will make a request to the Payments API to track this event.
*
* @param array $order_id The ID of the order that has been created.
*
* @return bool
*/
public function track_new_order_action( $order_id ) {
return $this->track_order( $order_id, false );
}
/**
* This function is a hook that will be called by ActionScheduler when an order is updated.
* It will make a request to the Payments API to track this event.
*
* @param int $order_id The ID of the order which has been updated.
*
* @return bool
*/
public function track_update_order_action( $order_id ) {
return $this->track_order( $order_id, true );
}
/**
* Track an order by making a request to the Payments API.
*
* @param mixed $order_id The ID of the order which has been updated/created.
* @param bool $is_update Is this an update event. If false, it is assumed this is a creation event.
*
* @return bool
*/
private function track_order( $order_id, $is_update = false ) {
// Get the order details.
$order = wc_get_order( $order_id );
if ( ! $order ) {
return false;
}
// If we do not have a valid payment method for this order, don't send the request.
$payment_method = $this->order_service->get_payment_method_id_for_order( $order );
if ( empty( $payment_method ) ) {
return false;
}
$order_mode = $order->get_meta( WC_Payments_Order_Service::WCPAY_MODE_META_KEY );
if ( $order_mode ) {
$current_mode = WC_Payments::mode()->is_test() ? Order_Mode::TEST : Order_Mode::PRODUCTION;
if ( $current_mode !== $order_mode ) {
// If mode doesn't match make sure to stop order tracking to prevent order tracking issues.
// False will be returned so maybe future crons will have correct mode.
return false;
}
}
// Send the order data to the Payments API to track it.
$response = $this->payments_api_client->track_order(
array_merge(
$order->get_data(),
[
'_payment_method_id' => $payment_method,
'_stripe_customer_id' => $this->order_service->get_customer_id_for_order( $order ),
'_wcpay_mode' => $order_mode,
]
),
$is_update
);
if ( 'success' === $response['result'] && ! $is_update ) {
// Update the metadata to reflect that the order creation event has been fired.
$order->add_meta_data( '_new_order_tracking_complete', 'yes' );
$order->save_meta_data();
}
return ( 'success' === ( $response['result'] ?? null ) );
}
/**
* Schedule an action scheduler job.
*
* Also, unschedules (replaces) any previous instances of the same job.
* This prevents duplicate jobs, for example when multiple events fire as part of the order update process.
* We will only replace a job which has the same $hook, $args AND $group.
*
* @param int $timestamp When the job will run.
* @param string $hook The hook to trigger.
* @param array $args Optional. An array containing the arguments to be passed to the hook.
* Defaults to an empty array.
* @param string $group Optional. The AS group the action will be created under.
* Defaults to 'woocommerce_payments'.
*
* @return void
*/
public function schedule_job( int $timestamp, string $hook, array $args = [], string $group = self::GROUP_ID ) {
// The `action_scheduler_init` hook was introduced in ActionScheduler 3.5.5 (WooCommerce 7.9.0).
if ( version_compare( WC()->version, '7.9.0', '>=' ) ) {
// If the ActionScheduler is already initialized, schedule the job.
if ( did_action( 'action_scheduler_init' ) ) {
$this->schedule_action_and_prevent_duplicates( $timestamp, $hook, $args, $group );
} else {
// The ActionScheduler is not initialized yet; we need to schedule the job when it fires the init hook.
add_action(
'action_scheduler_init',
function () use ( $timestamp, $hook, $args, $group ) {
$this->schedule_action_and_prevent_duplicates( $timestamp, $hook, $args, $group );
}
);
}
} else {
$this->schedule_action_and_prevent_duplicates( $timestamp, $hook, $args, $group );
}
}
/**
* Checks to see if there is a Pending action with the same hook already.
*
* @param string $hook Hook name.
*
* @return bool
*/
public function pending_action_exists( string $hook ): bool {
$actions = as_get_scheduled_actions(
[
'hook' => $hook,
'status' => ActionScheduler_Store::STATUS_PENDING,
'group' => self::GROUP_ID,
]
);
return ( is_countable( $actions ) ? count( $actions ) : 0 ) > 0;
}
/**
* Schedule an action while unscheduling any scheduled actions that are exactly the same.
*
* We will look for scheduled actions with the same name, args and group when unscheduling.
*
* @param int $timestamp When the action will run.
* @param string $action The action name to schedule.
* @param array $args Optional. An array containing the arguments to be passed to the action.
* Defaults to an empty array.
* @param string $group Optional. The ActionScheduler group the action will be created under.
* Defaults to 'woocommerce_payments'.
*
* @return void
*/
private function schedule_action_and_prevent_duplicates( int $timestamp, string $action, array $args = [], string $group = self::GROUP_ID ) {
// Unschedule any previously scheduled actions with the same name, args, and group combination.
// It is more efficient/performant to check if the action is already scheduled before unscheduling it.
// @see https://github.com/Automattic/woocommerce-payments/issues/6662.
if ( as_has_scheduled_action( $action, $args, $group ) ) {
as_unschedule_action( $action, $args, $group );
}
as_schedule_single_action( $timestamp, $action, $args, $group );
}
}
@@ -0,0 +1,420 @@
<?php
/**
* Class WC_Payments_Apple_Pay_Registration
*
* Adapted from WooCommerce Stripe Gateway extension.
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use WCPay\Logger;
use WCPay\Exceptions\API_Exception;
use WCPay\Tracker;
/**
* WC_Payments_Apple_Pay_Registration class.
*/
class WC_Payments_Apple_Pay_Registration {
const DOMAIN_ASSOCIATION_FILE_NAME = 'apple-developer-merchantid-domain-association';
const DOMAIN_ASSOCIATION_FILE_DIR = '.well-known';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* The WCPay account object.
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_Payment_Gateway_WCPay instance.
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* Current domain name.
*
* @var string
*/
private $domain_name;
/**
* Stores Apple Pay domain verification issues.
*
* @var string
*/
private $apple_pay_verify_notice;
/**
* Initialize class actions.
*
* @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client.
* @param WC_Payments_Account $account WooCommerce Payments account.
* @param WC_Payment_Gateway_WCPay $gateway WooCommerce Payments gateway.
*/
public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Account $account, WC_Payment_Gateway_WCPay $gateway ) {
$this->domain_name = wp_parse_url( get_site_url(), PHP_URL_HOST );
$this->apple_pay_verify_notice = '';
$this->payments_api_client = $payments_api_client;
$this->account = $account;
$this->gateway = $gateway;
}
/**
* Initializes this class's hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'init', [ $this, 'add_domain_association_rewrite_rule' ], 5 );
add_action( 'woocommerce_woocommerce_payments_updated', [ $this, 'verify_domain_on_update' ] );
add_action( 'init', [ $this, 'init' ] );
}
/**
* Initialize hooks.
*
* @return void
*/
public function init() {
add_action( 'admin_init', [ $this, 'verify_domain_on_domain_name_change' ] );
add_filter( 'query_vars', [ $this, 'whitelist_domain_association_query_param' ], 10, 1 );
add_action( 'parse_request', [ $this, 'parse_domain_association_request' ], 10, 1 );
add_action( 'woocommerce_woocommerce_payments_admin_notices', [ $this, 'display_error_notice' ] );
add_action( 'add_option_woocommerce_woocommerce_payments_settings', [ $this, 'verify_domain_on_new_settings' ], 10, 2 );
add_action( 'update_option_woocommerce_woocommerce_payments_settings', [ $this, 'verify_domain_on_updated_settings' ], 10, 2 );
}
/**
* Whether the gateway and Express Checkout Buttons (prerequisites for Apple Pay) are enabled.
*
* @return bool Whether Apple Pay required settings are enabled.
*/
private function is_enabled() {
return $this->gateway->is_enabled() && 'yes' === $this->gateway->get_option( 'payment_request' );
}
/**
* Whether the gateway and Express Checkout Buttons were enabled in previous settings.
*
* @param array|null $prev_settings Gateway settings.
*
* @return bool Whether Apple Pay required settings are enabled.
*/
private function was_enabled( $prev_settings ) {
$gateway_enabled = 'yes' === ( $prev_settings['enabled'] ?? 'no' );
$payment_request_enabled = 'yes' === ( $prev_settings['payment_request'] ?? 'no' );
return $gateway_enabled && $payment_request_enabled;
}
/**
* Trigger Apple Pay registration upon domain name change.
*/
public function verify_domain_on_domain_name_change() {
$verified_domain = $this->gateway->get_option( 'apple_pay_verified_domain' );
if ( $this->domain_name !== $verified_domain ) {
$this->verify_domain_if_configured();
}
}
/**
* Verify domain upon plugin update only in case the domain association file has changed.
*/
public function verify_domain_on_update() {
if ( $this->is_enabled() && ! $this->is_hosted_domain_association_file_up_to_date() ) {
$this->verify_domain_if_configured();
}
}
/**
* Vefifies if hosted domain association file is up to date
* with the file from the plugin directory.
*
* @return bool Whether file is up to date or not.
*/
private function is_hosted_domain_association_file_up_to_date() {
$fullpath = untrailingslashit( ABSPATH ) . '/' . self::DOMAIN_ASSOCIATION_FILE_DIR . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME;
if ( ! file_exists( $fullpath ) ) {
return false;
}
// Contents of domain association file from plugin dir.
$new_contents = @file_get_contents( WCPAY_ABSPATH . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME ); // @codingStandardsIgnoreLine
// Get file contents from local path and remote URL and check if either of which matches.
$local_contents = @file_get_contents( $fullpath ); // @codingStandardsIgnoreLine
$url = get_site_url() . '/' . self::DOMAIN_ASSOCIATION_FILE_DIR . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME;
$response = @wp_remote_get( $url ); // @codingStandardsIgnoreLine
$remote_contents = @wp_remote_retrieve_body( $response ); // @codingStandardsIgnoreLine
return $local_contents === $new_contents || $remote_contents === $new_contents;
}
/**
* Copies and overwrites domain association file.
*
* @return null|string Error message.
*/
private function copy_and_overwrite_domain_association_file() {
$well_known_dir = untrailingslashit( ABSPATH ) . '/' . self::DOMAIN_ASSOCIATION_FILE_DIR;
$fullpath = $well_known_dir . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME;
if ( ! is_dir( $well_known_dir ) && ! @mkdir( $well_known_dir, 0755 ) && ! is_dir( $well_known_dir ) ) { // @codingStandardsIgnoreLine
return __( 'Unable to create domain association folder to domain root.', 'woocommerce-payments' );
}
if ( ! @copy( WCPAY_ABSPATH . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME, $fullpath ) ) { // @codingStandardsIgnoreLine
return __( 'Unable to copy domain association file to domain root.', 'woocommerce-payments' );
}
}
/**
* Updates the Apple Pay domain association file.
* Reports failure only if file isn't already being served properly.
*/
public function update_domain_association_file() {
if ( $this->is_hosted_domain_association_file_up_to_date() ) {
return;
}
$error_message = $this->copy_and_overwrite_domain_association_file();
if ( isset( $error_message ) ) {
$url = get_site_url() . '/' . self::DOMAIN_ASSOCIATION_FILE_DIR . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME;
Logger::log(
'Error: ' . $error_message . ' ' .
/* translators: expected domain association file URL */
sprintf( __( 'To enable Apple Pay, domain association file must be hosted at %s.', 'woocommerce-payments' ), $url )
);
} else {
Logger::log( __( 'Domain association file updated.', 'woocommerce-payments' ) );
}
}
/**
* Adds a rewrite rule for serving the domain association file from the proper location.
*/
public function add_domain_association_rewrite_rule() {
$regex = '^\\' . self::DOMAIN_ASSOCIATION_FILE_DIR . '\/' . self::DOMAIN_ASSOCIATION_FILE_NAME . '$';
$redirect = 'index.php?' . self::DOMAIN_ASSOCIATION_FILE_NAME . '=1';
add_rewrite_rule( $regex, $redirect, 'top' );
}
/**
* Add to the list of publicly allowed query variables.
*
* @param array $query_vars - provided public query vars.
* @return array Updated public query vars.
*/
public function whitelist_domain_association_query_param( $query_vars ) {
$query_vars[] = self::DOMAIN_ASSOCIATION_FILE_NAME;
return $query_vars;
}
/**
* Serve domain association file when proper query param is provided.
*
* @param object $wp WordPress environment object.
*/
public function parse_domain_association_request( $wp ) {
if (
! isset( $wp->query_vars[ self::DOMAIN_ASSOCIATION_FILE_NAME ] ) ||
'1' !== $wp->query_vars[ self::DOMAIN_ASSOCIATION_FILE_NAME ]
) {
return;
}
$path = WCPAY_ABSPATH . '/' . self::DOMAIN_ASSOCIATION_FILE_NAME;
header( 'Content-Type: text/plain;charset=utf-8' );
echo esc_html( @file_get_contents( $path ) ); // @codingStandardsIgnoreLine
exit;
}
/**
* Returns the string representation of the current mode. One of:
* - 'dev'
* - 'test'
* - 'live'
*
* @return string A string representation of the current mode.
*/
private function get_gateway_mode_string() {
if ( WC_Payments::mode()->is_dev() ) {
return 'dev';
} elseif ( WC_Payments::mode()->is_test() ) {
return 'test';
}
return 'live';
}
/**
* Processes the Stripe domain registration.
*/
public function register_domain() {
$error = null;
try {
$registration_response = $this->payments_api_client->register_domain( $this->domain_name );
if ( isset( $registration_response['id'] ) && ( isset( $registration_response['apple_pay']['status'] ) && 'active' === $registration_response['apple_pay']['status'] ) ) {
$this->gateway->update_option( 'apple_pay_verified_domain', $this->domain_name );
$this->gateway->update_option( 'apple_pay_domain_set', 'yes' );
Logger::log( __( 'Your domain has been verified with Apple Pay!', 'woocommerce-payments' ) );
Tracker::track_admin(
'wcpay_apple_pay_domain_registration_success',
[
'domain' => $this->domain_name,
'mode' => $this->get_gateway_mode_string(),
]
);
return;
} elseif ( isset( $registration_response['apple_pay']['status_details']['error_message'] ) ) {
$error = $registration_response['apple_pay']['status_details']['error_message'];
}
} catch ( API_Exception $e ) {
$error = $e->getMessage();
}
// Display error message in notice.
$this->apple_pay_verify_notice = $error;
$this->gateway->update_option( 'apple_pay_verified_domain', $this->domain_name );
$this->gateway->update_option( 'apple_pay_domain_set', 'no' );
Logger::log( 'Error registering domain with Apple: ' . $error );
Tracker::track_admin(
'wcpay_apple_pay_domain_registration_failure',
[
'domain' => $this->domain_name,
'reason' => $error,
'mode' => $this->get_gateway_mode_string(),
]
);
}
/**
* Process the Apple Pay domain verification if proper settings are configured.
*/
public function verify_domain_if_configured() {
// If Express Checkout Buttons are not enabled,
// do not attempt to register domain.
if ( ! $this->is_enabled() ) {
return;
}
// Ensure that domain association file will be served.
flush_rewrite_rules();
// The rewrite rule method doesn't work if permalinks are set to Plain.
// Create/update domain association file by copying it from the plugin folder as a fallback.
$this->update_domain_association_file();
// Register the domain.
$this->register_domain();
}
/**
* Conditionally process the Apple Pay domain verification after settings are initially set.
*
* @param string $option Option name.
* @param array $settings Settings array.
*/
public function verify_domain_on_new_settings( $option, $settings ) {
$this->verify_domain_on_updated_settings( [], $settings );
}
/**
* Conditionally process the Apple Pay domain verification after settings are updated.
*
* @param array $prev_settings Settings before update.
* @param array $settings Settings after update.
*/
public function verify_domain_on_updated_settings( $prev_settings, $settings ) {
// If Gateway or Express Checkout Buttons weren't enabled, then might need to verify now.
if ( ! $this->was_enabled( $prev_settings ) ) {
$this->verify_domain_if_configured();
}
}
/**
* Display Apple Pay registration errors.
*/
public function display_error_notice() {
if ( ! $this->is_enabled() || ! $this->account->get_is_live() ) {
return;
}
$empty_notice = empty( $this->apple_pay_verify_notice );
$domain_set = $this->gateway->get_option( 'apple_pay_domain_set' );
// Don't display error notice if verification notice is empty and
// apple_pay_domain_set option equals to '' or 'yes'.
if ( $empty_notice && 'no' !== $domain_set ) {
return;
}
/**
* Apple pay is enabled by default and domain verification initializes
* when setting screen is displayed. So if domain verification is not set,
* something went wrong so lets notify user.
*/
$allowed_html = [
'a' => [
'href' => [],
'title' => [],
],
];
$payment_request_button_text = __( 'Express checkouts:', 'woocommerce-payments' );
$verification_failed_without_error = __( 'Apple Pay domain verification failed.', 'woocommerce-payments' );
$verification_failed_with_error = __( 'Apple Pay domain verification failed with the following error:', 'woocommerce-payments' );
$check_log_text = WC_Payments_Utils::esc_interpolated_html(
/* translators: a: Link to the logs page */
__( 'Please check the <a>logs</a> for more details on this issue. Debug log must be enabled under <strong>Advanced settings</strong> to see recorded logs.', 'woocommerce-payments' ),
[
'a' => '<a href="' . admin_url( 'admin.php?page=wc-status&tab=logs' ) . '">',
'strong' => '<strong>',
]
);
$learn_more_text = WC_Payments_Utils::esc_interpolated_html(
__( '<a>Learn more</a>.', 'woocommerce-payments' ),
[
'a' => '<a href="https://woocommerce.com/document/woopayments/payment-methods/apple-pay/#domain-registration" target="_blank">',
]
);
?>
<div class="notice notice-error apple-pay-message">
<?php if ( $empty_notice ) : ?>
<p>
<strong><?php echo esc_html( $payment_request_button_text ); ?></strong>
<?php echo esc_html( $verification_failed_without_error ); ?>
<?php echo $learn_more_text; /* @codingStandardsIgnoreLine */ ?>
</p>
<?php else : ?>
<p>
<strong><?php echo esc_html( $payment_request_button_text ); ?></strong>
<?php echo esc_html( $verification_failed_with_error ); ?>
<?php echo $learn_more_text; /* @codingStandardsIgnoreLine */ ?>
</p>
<p><i><?php echo wp_kses( make_clickable( esc_html( $this->apple_pay_verify_notice ) ), $allowed_html ); ?></i></p>
<?php endif; ?>
<p><?php echo $check_log_text; /* @codingStandardsIgnoreLine */ ?></p>
</div>
<?php
}
}
@@ -0,0 +1,122 @@
<?php
/**
* Class WC_Payments_Blocks_Payment_Method
*
* @package WooCommerce\Payments
*/
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
use WCPay\Fraud_Prevention\Fraud_Prevention_Service;
use WCPay\WC_Payments_Checkout;
use WCPay\WooPay\WooPay_Utilities;
/**
* The payment method, which allows the gateway to work with WooCommerce Blocks.
*/
class WC_Payments_Blocks_Payment_Method extends AbstractPaymentMethodType {
/**
* The gateway instance.
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* WC Payments Checkout
*
* @var WC_Payments_Checkout
*/
private $wc_payments_checkout;
/**
* Initializes the class.
*/
public function initialize() {
$this->name = WC_Payment_Gateway_WCPay::GATEWAY_ID;
$this->gateway = WC_Payments::get_gateway();
$this->wc_payments_checkout = WC_Payments::get_wc_payments_checkout();
}
/**
* Checks whether the gateway is active.
*
* @return boolean True when active.
*/
public function is_active() {
return $this->gateway->is_available();
}
/**
* Defines all scripts, necessary for the payment method.
*
* @return string[] A list of script handles.
*/
public function get_payment_method_script_handles() {
if ( ( is_cart() || is_checkout() || is_product() || has_block( 'woocommerce/checkout' ) || has_block( 'woocommerce/cart' ) || is_admin() ) ) {
WC_Payments_Utils::enqueue_style(
'wc-blocks-checkout-style',
plugins_url( 'dist/blocks-checkout.css', WCPAY_PLUGIN_FILE ),
[],
WC_Payments::get_file_version( 'dist/checkout.css' ),
'all'
);
}
wp_register_script(
'stripe',
'https://js.stripe.com/v3/',
[],
'3.0',
true
);
WC_Payments::register_script_with_dependencies( 'WCPAY_BLOCKS_CHECKOUT', 'dist/blocks-checkout', [ 'stripe' ] );
wp_set_script_translations( 'WCPAY_BLOCKS_CHECKOUT', 'woocommerce-payments' );
wp_add_inline_script(
'WCPAY_BLOCKS_CHECKOUT',
'var wcBlocksCheckoutData = ' . wp_json_encode(
[
'amount' => WC()->cart ? WC()->cart->get_total( '' ) : 0,
'currency' => get_woocommerce_currency(),
'storeCountry' => WC()->countries->get_base_country(),
'billingCountry' => WC()->customer ? WC()->customer->get_billing_country() : 'US',
]
) . ';',
'before'
);
Fraud_Prevention_Service::maybe_append_fraud_prevention_token();
return [ 'WCPAY_BLOCKS_CHECKOUT' ];
}
/**
* Loads the data about the gateway, which will be exposed in JavaScript.
*
* @return array An associative array, containing all necessary values.
*/
public function get_payment_method_data() {
$is_woopay_eligible = WC_Payments_Features::is_woopay_eligible(); // Feature flag.
$is_woopay_enabled = 'yes' === $this->gateway->get_option( 'platform_checkout', 'no' );
$woopay_config = [];
if ( $is_woopay_eligible && $is_woopay_enabled ) {
$woopay_config = [
'woopayHost' => WooPay_Utilities::get_woopay_url(),
];
}
return array_merge(
[
'title' => $this->gateway->get_option( 'title', '' ),
'description' => $this->gateway->get_option( 'description', '' ),
'is_admin' => is_admin(), // Used to display payment method preview in wp-admin.
],
$woopay_config,
$this->wc_payments_checkout->get_payment_fields_js_config()
);
}
}
@@ -0,0 +1,464 @@
<?php
/**
* Class WC_Payments_Captured_Event_Note
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Utility class generating detailed captured note for successful payments.
*/
class WC_Payments_Captured_Event_Note {
const HTML_BLACK_BULLET = '<span style="font-size: 7px;vertical-align: middle;">&#9679;</span>';
const HTML_WHITE_BULLET = '<span style="font-size: 7px;vertical-align: middle;">&#9675;</span>';
const HTML_SPACE = '&nbsp;';
const HTML_BR = '<br>';
/**
* Captured event data.
*
* @var array
*/
private $captured_event;
/**
* Constructor.
*
* @param array $captured_event Captured event data.
*
* @throws Exception
*/
public function __construct( array $captured_event ) {
$is_captured_event = isset( $captured_event['type'] ) && 'captured' === $captured_event['type'];
if ( ! $is_captured_event ) {
throw new Exception( 'Not a captured event' );
}
$this->captured_event = $captured_event;
}
/**
* Generate the HTML note.
*
* @return string
*/
public function generate_html_note(): string {
$lines = [];
$fx_string = $this->compose_fx_string();
if ( null !== $fx_string ) {
$lines[] = $fx_string;
}
$lines[] = $this->compose_fee_string();
$fee_breakdown_lines = $this->compose_fee_break_down();
if ( null !== $fee_breakdown_lines ) {
$lines = array_merge( $lines, $fee_breakdown_lines );
}
$lines[] = $this->compose_net_string();
$html = '';
foreach ( $lines as $line ) {
$html .= '<p>' . $line . '</p>' . PHP_EOL;
}
return '<div class="captured-event-details">' . PHP_EOL
. $html
. '</div>';
}
/**
* Generate FX string.
*
* @return string|null
*/
public function compose_fx_string() {
if ( ! $this->is_fx_event() ) {
return null;
}
$customer_currency = $this->captured_event['transaction_details']['customer_currency'];
$customer_amount_captured = $this->captured_event['transaction_details']['customer_amount_captured'];
$store_currency = $this->captured_event['transaction_details']['store_currency'];
$store_amount_captured = $this->captured_event['transaction_details']['store_amount_captured'];
return $this->format_fx( $customer_currency, $customer_amount_captured, $store_currency, $store_amount_captured );
}
/**
* Generate fee string.
*
* @return string
*/
public function compose_fee_string(): string {
$data = $this->captured_event;
$fee_rates = $data['fee_rates'];
$percentage = $fee_rates['percentage'];
$fixed_currency = $fee_rates['fixed_currency'];
$fixed = WC_Payments_Utils::interpret_stripe_amount( (int) $fee_rates['fixed'], $fixed_currency );
$history = $fee_rates['history'];
$fee_currency = $data['transaction_details']['store_currency'];
$fee_amount = WC_Payments_Utils::interpret_stripe_amount( (int) $data['transaction_details']['store_fee'], $fee_currency );
$base_fee_label = $this->is_base_fee_only()
? __( 'Base fee', 'woocommerce-payments' )
: __( 'Fee', 'woocommerce-payments' );
$is_capped = isset( $history[0]['capped'] ) && true === $history[0]['capped'];
if ( $this->is_base_fee_only() && $is_capped ) {
return sprintf(
'%1$s (capped at %2$s): %3$s',
$base_fee_label,
WC_Payments_Utils::format_currency( $fixed, $fixed_currency ),
WC_Payments_Utils::format_currency( - $fee_amount, $fee_currency )
);
}
$is_same_symbol = $this->has_same_currency_symbol( $data['transaction_details']['store_currency'], $data['transaction_details']['customer_currency'] );
return sprintf(
'%1$s (%2$s%% + %3$s%4$s): %5$s%6$s',
$base_fee_label,
self::format_fee( $percentage ),
WC_Payments_Utils::format_currency( $fixed, $fixed_currency ),
$is_same_symbol ? ' ' . $data['transaction_details']['customer_currency'] : '',
WC_Payments_Utils::format_currency( -$fee_amount, $fee_currency ),
$is_same_symbol ? " $fee_currency" : ''
);
}
/**
* Generate an array including HTML formatted breakdown lines.
*
* @return array<string>|null
*/
public function compose_fee_break_down() {
$fee_history_strings = $this->get_fee_breakdown();
if ( null === $fee_history_strings ) {
return null;
}
if ( 0 === count( $fee_history_strings ) ) {
return null;
}
$res = [];
foreach ( $fee_history_strings as $type => $fee ) {
$res[] = self::HTML_BLACK_BULLET . ' ' . ( 'discount' === $type
? $fee['label']
: $fee
);
if ( 'discount' === $type ) {
$res[] = str_repeat( self::HTML_SPACE . ' ', 2 ) . self::HTML_WHITE_BULLET . ' ' . $fee['variable'];
$res[] = str_repeat( self::HTML_SPACE . ' ', 2 ) . self::HTML_WHITE_BULLET . ' ' . $fee['fixed'];
}
}
return $res;
}
/**
* Generate net string.
*
* @return string
*/
public function compose_net_string(): string {
$data = $this->captured_event['transaction_details'];
// Determine the type of payment and select the appropriate amounts and currencies.
if ( $this->is_fx_event() ) {
// For fx events, we need the store amount and currency to display the net amount
// in the store currency.
$amount = $data['store_amount'];
$captured_amount = $data['store_amount_captured'];
$fee = $data['store_fee'];
$currency = $data['store_currency'];
} else {
$amount = $data['customer_amount'];
$captured_amount = $data['customer_amount_captured'];
$fee = $data['customer_fee'];
$currency = $data['customer_currency'];
}
$gross_amount = $captured_amount ?? $amount;
$net = WC_Payments_Utils::interpret_stripe_amount( (int) ( $gross_amount - $fee ), $currency );
// Format and return the net string.
return sprintf(
/* translators: %s is a monetary amount */
__( 'Net payout: %s', 'woocommerce-payments' ),
WC_Payments_Utils::format_explicit_currency( $net, $currency )
);
}
/**
* Returns an associative array containing fee breakdown.
* Keys are fee types such as base, additional-fx, etc, except for "discount" that is an associative array including more discount details.
*
* @return array|null
*/
public function get_fee_breakdown() {
$data = $this->captured_event;
if ( ! isset( $data['fee_rates']['history'] ) ) {
return null;
}
$history = $data['fee_rates']['history'];
// Hide breakdown when there's only a base fee.
if ( $this->is_base_fee_only() ) {
return null;
}
$fee_history_strings = [];
foreach ( $history as $fee ) {
$label_type = $fee['type'];
if ( $fee['additional_type'] ?? '' ) {
$label_type .= '-' . $fee['additional_type'];
}
$percentage_rate = (float) $fee['percentage_rate'];
$fixed_rate = (int) $fee['fixed_rate'];
$currency = strtoupper( $fee['currency'] );
$is_capped = isset( $fee['capped'] ) && true === $fee['capped'];
$percentage_rate_formatted = self::format_fee( $percentage_rate );
$fix_rate_formatted = WC_Payments_Utils::format_currency(
WC_Payments_Utils::interpret_stripe_amount( $fixed_rate ),
$currency
);
if ( $this->has_same_currency_symbol( $data['transaction_details']['customer_currency'], $data['transaction_details']['store_currency'] ) ) {
$fix_rate_formatted = $fix_rate_formatted . ' ' . $data['transaction_details']['store_currency'];
}
$label = sprintf(
$this->fee_label_mapping( $fixed_rate, $is_capped )[ $label_type ],
$percentage_rate_formatted,
$fix_rate_formatted
);
if ( 'discount' === $label_type ) {
$fee_history_strings[ $label_type ] = [
'label' => $label,
'variable' => sprintf(
/* translators: %s is a percentage number */
__( 'Variable fee: %s', 'woocommerce-payments' ),
$percentage_rate_formatted
) . '%',
'fixed' => sprintf(
/* translators: %s is a monetary amount */
__( 'Fixed fee: %s', 'woocommerce-payments' ),
$fix_rate_formatted
),
];
} else {
$fee_history_strings[ $label_type ] = $label;
}
}
return $fee_history_strings;
}
/**
* Check if this is a FX event.
*
* @return bool
*/
private function is_fx_event(): bool {
$customer_currency = $this->captured_event['transaction_details']['customer_currency'] ?? null;
$store_currency = $this->captured_event['transaction_details']['store_currency'] ?? null;
return ! (
is_null( $customer_currency )
|| is_null( $store_currency )
|| $customer_currency === $store_currency
);
}
/**
* Return a boolean indicating whether only fee applied is the base fee.
*
* @return bool True if the only applied fee is the base fee
*/
private function is_base_fee_only(): bool {
if ( ! isset( $this->captured_event['fee_rates']['history'] ) ) {
return false;
}
$history = $this->captured_event['fee_rates']['history'];
return 1 === ( is_countable( $history ) ? count( $history ) : 0 ) && 'base' === $history[0]['type'];
}
/**
* Get the mapping format for all types of fees.
*
* @param int $fixed_rate Fixed rate amount in Stripe format.
* @param bool $is_capped True if the fee is capped.
*
* @return array An associative array with keys are fee types, values are string formats.
*/
private function fee_label_mapping( int $fixed_rate, bool $is_capped ) {
$res = [];
$res['base'] = $is_capped
/* translators: %2$s is the capped fee */
? __( 'Base fee: capped at %2$s', 'woocommerce-payments' )
:
( 0 !== $fixed_rate
/* translators: %1$s% is the fee percentage and %2$s is the fixed rate */
? __( 'Base fee: %1$s%% + %2$s', 'woocommerce-payments' )
/* translators: %1$s% is the fee percentage */
: __( 'Base fee: %1$s%%', 'woocommerce-payments' )
);
$res['additional-international'] = 0 !== $fixed_rate
/* translators: %1$s% is the fee percentage and %2$s is the fixed rate */
? __( 'International card fee: %1$s%% + %2$s', 'woocommerce-payments' )
/* translators: %1$s% is the fee percentage */
: __( 'International card fee: %1$s%%', 'woocommerce-payments' );
$res['additional-fx'] = 0 !== $fixed_rate
/* translators: %1$s% is the fee percentage and %2$s is the fixed rate */
? __( 'Currency conversion fee: %1$s%% + %2$s', 'woocommerce-payments' )
/* translators: %1$s% is the fee percentage */
: __( 'Currency conversion fee: %1$s%%', 'woocommerce-payments' );
$res['additional-wcpay-subscription'] = 0 !== $fixed_rate
/* translators: %1$s% is the fee percentage and %2$s is the fixed rate */
? __( 'Subscription transaction fee: %1$s%% + %2$s', 'woocommerce-payments' )
/* translators: %1$s% is the fee percentage */
: __( 'Subscription transaction fee: %1$s%%', 'woocommerce-payments' );
$res['discount'] = __( 'Discount', 'woocommerce-payments' );
return $res;
}
/**
* Return a given decimal fee as a percentage with a maximum of 3 decimal places.
*
* @param float $percentage Percentage as float.
*
* @return string
*/
private function format_fee( float $percentage ): string {
return (string) round( $percentage * 100, 3 );
}
/**
* Format FX string based on the two provided currencies.
*
* @param string $from_currency 3-letter code for original currency.
* @param int $from_amount Amount (Stripe-type) for original currency.
* @param string $to_currency 3-letter code for converted currency.
* @param int $to_amount Amount (Stripe-type) for converted currency.
*
* @return string Formatted FX string.
*/
private function format_fx(
string $from_currency,
int $from_amount,
string $to_currency,
int $to_amount
): string {
$exchange_rate = (float) ( 0 !== $from_amount
? $to_amount / $from_amount
: 0 );
if ( WC_Payments_Utils::is_zero_decimal_currency( strtolower( $to_currency ) ) ) {
$exchange_rate *= 100;
}
if ( WC_Payments_Utils::is_zero_decimal_currency( strtolower( $from_currency ) ) ) {
$exchange_rate /= 100;
}
$to_display_amount = WC_Payments_Utils::interpret_stripe_amount( $to_amount, $to_currency );
return sprintf(
'%1$s → %2$s: %3$s',
self::format_explicit_currency_with_base( 1, $from_currency, $to_currency, true ),
self::format_exchange_rate( $exchange_rate, $to_currency ),
WC_Payments_Utils::format_explicit_currency( $to_display_amount, $to_currency, false )
);
}
/**
* Format exchange rate.
*
* @param float $rate Exchange rate.
* @param string $currency 3-letter currency code.
*
* @return string
*/
private function format_exchange_rate( float $rate, string $currency ): string {
$num_decimals = $rate > 1 ? 5 : 6;
$formatted = WC_Payments_Utils::format_explicit_currency(
$rate,
$currency,
true,
[ 'decimals' => $num_decimals ]
);
$func_remove_ending_zeros = function ( $str ) {
return rtrim( $str, '0' );
};
// Remove ending zeroes after the decimal separator if they exist.
return implode(
' ',
array_map(
$func_remove_ending_zeros,
explode( ' ', $formatted )
)
);
}
/**
* Format amount for a given currency but according to the base currency's format.
*
* @param float $amount Amount.
* @param string $currency 3-letter currency code.
* @param string $base_currency 3-letter base currency code.
* @param bool $skip_symbol Optional. If true, trims off the short currency symbol. Default false.
*
* @return string
*/
private function format_explicit_currency_with_base( float $amount, string $currency, string $base_currency, bool $skip_symbol = false ) {
$custom_format = WC_Payments_Utils::get_currency_format_for_wc_price( $base_currency );
unset( $custom_format['currency'] );
// Given this is used to display the $amount, the decimals for $base_currency shouldn't interfere with decimals for $currency.
$custom_format['decimals'] = WC_Payments_Utils::get_currency_format_for_wc_price( $currency )['decimals'];
return WC_Payments_Utils::format_explicit_currency( $amount, $currency, $skip_symbol, $custom_format );
}
/**
* Compare does two currencies have the same symbol.
*
* @param string $base_currency Base currency.
* @param string $currency Currency to compare.
*
* @return bool
*/
private function has_same_currency_symbol( string $base_currency, string $currency ): bool {
return strcasecmp( $base_currency, $currency ) !== 0 && get_woocommerce_currency_symbol( $base_currency ) === get_woocommerce_currency_symbol( $currency );
}
}
@@ -0,0 +1,519 @@
<?php
/**
* Class WC_Payments_Checkout
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use Exception;
use Jetpack_Options;
use WC_AJAX;
use WC_Checkout;
use WC_Payments;
use WC_Payments_Account;
use WC_Payments_Customer_Service;
use WC_Payments_Fraud_Service;
use WC_Payments_Utils;
use WC_Payments_Features;
use WCPay\Constants\Payment_Method;
use WCPay\Fraud_Prevention\Fraud_Prevention_Service;
use WC_Payment_Gateway_WCPay;
use WCPay\WooPay\WooPay_Utilities;
use WCPay\Payment_Methods\UPE_Payment_Method;
use WCPay\WooPay\WooPay_Session;
/**
* WC_Payments_Checkout
*/
class WC_Payments_Checkout {
/**
* WC Payments Gateway.
*
* @var WC_Payment_Gateway_WCPay
*/
protected $gateway;
/**
* WooPay Utilities.
*
* @var WooPay_Utilities
*/
protected $woopay_util;
/**
* WC Payments Account.
*
* @var WC_Payments_Account
*/
protected $account;
/**
* WC Payments Customer Service
*
* @var WC_Payments_Customer_Service
*/
protected $customer_service;
/**
* WC_Payments_Fraud_Service instance to get information about fraud services.
*
* @var WC_Payments_Fraud_Service
*/
protected $fraud_service;
/**
* Construct.
*
* @param WC_Payment_Gateway_WCPay $gateway WC Payment Gateway.
* @param WooPay_Utilities $woopay_util WooPay Utilities.
* @param WC_Payments_Account $account WC Payments Account.
* @param WC_Payments_Customer_Service $customer_service WC Payments Customer Service.
* @param WC_Payments_Fraud_Service $fraud_service Fraud service instance.
*/
public function __construct(
WC_Payment_Gateway_WCPay $gateway,
WooPay_Utilities $woopay_util,
WC_Payments_Account $account,
WC_Payments_Customer_Service $customer_service,
WC_Payments_Fraud_Service $fraud_service
) {
$this->gateway = $gateway;
$this->woopay_util = $woopay_util;
$this->account = $account;
$this->customer_service = $customer_service;
$this->fraud_service = $fraud_service;
}
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'wc_payments_set_gateway', [ $this, 'set_gateway' ] );
add_action( 'wc_payments_add_upe_payment_fields', [ $this, 'payment_fields' ] );
add_action( 'wp', [ $this->gateway, 'maybe_process_upe_redirect' ] );
add_action( 'wp_ajax_save_upe_appearance', [ $this->gateway, 'save_upe_appearance_ajax' ] );
add_action( 'wp_ajax_nopriv_save_upe_appearance', [ $this->gateway, 'save_upe_appearance_ajax' ] );
add_action( 'switch_theme', [ $this->gateway, 'clear_upe_appearance_transient' ] );
add_action( 'woocommerce_woocommerce_payments_updated', [ $this->gateway, 'clear_upe_appearance_transient' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts_for_zero_order_total' ], 11 );
add_action( 'woocommerce_after_checkout_form', [ $this, 'maybe_load_checkout_scripts' ] );
}
/**
* Registers all scripts, necessary for the gateway.
*/
public function register_scripts() {
if ( wp_script_is( 'wcpay-upe-checkout', 'enqueued' ) ) {
return;
}
// Register Stripe's JavaScript using the same ID as the Stripe Gateway plugin. This prevents this JS being
// loaded twice in the event a site has both plugins enabled. We still run the risk of different plugins
// loading different versions however. If Stripe release a v4 of their JavaScript, we could consider
// changing the ID to stripe_v4. This would allow older plugins to keep using v3 while we used any new
// feature in v4. Stripe have allowed loading of 2 different versions of stripe.js in the past (
// https://stripe.com/docs/stripe-js/elements/migrating).
wp_register_script(
'stripe',
'https://js.stripe.com/v3/',
[],
'3.0',
true
);
$script_dependencies = [ 'stripe', 'wc-checkout', 'wp-i18n' ];
if ( $this->gateway->supports( 'tokenization' ) ) {
$script_dependencies[] = 'woocommerce-tokenization-form';
}
Fraud_Prevention_Service::maybe_append_fraud_prevention_token();
WC_Payments::register_script_with_dependencies( 'wcpay-upe-checkout', 'dist/checkout', $script_dependencies );
}
/**
* Registers scripts necessary for the gateway, even when cart order total is 0.
* This is done so that if the cart is modified via AJAX on checkout,
* the scripts are still loaded.
*/
public function register_scripts_for_zero_order_total() {
if (
isset( WC()->cart ) &&
! WC()->cart->is_empty() &&
! WC()->cart->needs_payment() &&
is_checkout() &&
! has_block( 'woocommerce/checkout' ) &&
! wp_script_is( 'wcpay-upe-checkout', 'enqueued' )
) {
$this->load_checkout_scripts();
}
}
/**
* Sometimes the filters can remove the payment gateway from the checkout page which results in the payment fields not being displayed.
* This could prevent loading of the payment fields (checkout) scripts.
* This function ensures that these scripts are loaded.
*/
public function maybe_load_checkout_scripts() {
if ( is_checkout() && ! wp_script_is( 'wcpay-upe-checkout', 'enqueued' ) ) {
$this->load_checkout_scripts();
}
}
/**
* Generates the configuration values, needed for payment fields.
*
* Isolated as a separate method in order to be available both
* during the classic checkout, as well as the checkout block.
*
* @return array
*/
public function get_payment_fields_js_config() {
// Needed to init the hooks.
WC_Checkout::instance();
// The registered card gateway is more reliable than $this->gateway, but if it isn't available for any reason, fall back to the gateway provided to this checkout class.
$gateway = WC_Payments::get_gateway() ?? $this->gateway;
$js_config = [
'publishableKey' => $this->account->get_publishable_key( WC_Payments::mode()->is_test() ),
'testMode' => WC_Payments::mode()->is_test(),
'accountId' => $this->account->get_stripe_account_id(),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'wcAjaxUrl' => WC_AJAX::get_endpoint( '%%endpoint%%' ),
'createSetupIntentNonce' => wp_create_nonce( 'wcpay_create_setup_intent_nonce' ),
'initWooPayNonce' => wp_create_nonce( 'wcpay_init_woopay_nonce' ),
'saveUPEAppearanceNonce' => wp_create_nonce( 'wcpay_save_upe_appearance_nonce' ),
'genericErrorMessage' => __( 'There was a problem processing the payment. Please check your email inbox and refresh the page to try again.', 'woocommerce-payments' ),
'fraudServices' => $this->fraud_service->get_fraud_services_config(),
'features' => $this->gateway->supports,
'forceNetworkSavedCards' => WC_Payments::is_network_saved_cards_enabled() || $gateway->should_use_stripe_platform_on_checkout_page(),
'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ),
'isPreview' => is_preview(),
'isSavedCardsEnabled' => $this->gateway->is_saved_cards_enabled(),
'isPaymentRequestEnabled' => $this->gateway->is_payment_request_enabled(),
'isTokenizedCartEceEnabled' => WC_Payments_Features::is_tokenized_cart_ece_enabled(),
'isWooPayEnabled' => $this->woopay_util->should_enable_woopay( $this->gateway ) && $this->woopay_util->should_enable_woopay_on_cart_or_checkout(),
'isWoopayExpressCheckoutEnabled' => $this->woopay_util->is_woopay_express_checkout_enabled(),
'isWoopayFirstPartyAuthEnabled' => $this->woopay_util->is_woopay_first_party_auth_enabled(),
'isWooPayEmailInputEnabled' => $this->woopay_util->is_woopay_email_input_enabled(),
'isWooPayDirectCheckoutEnabled' => WC_Payments_Features::is_woopay_direct_checkout_enabled(),
'isWooPayGlobalThemeSupportEnabled' => $this->gateway->is_woopay_global_theme_support_enabled(),
'woopayHost' => WooPay_Utilities::get_woopay_url(),
'platformTrackerNonce' => wp_create_nonce( 'platform_tracks_nonce' ),
'accountIdForIntentConfirmation' => apply_filters( 'wc_payments_account_id_for_intent_confirmation', '' ),
'wcpayVersionNumber' => WCPAY_VERSION_NUMBER,
'woopaySignatureNonce' => wp_create_nonce( 'woopay_signature_nonce' ),
'woopaySessionNonce' => wp_create_nonce( 'woopay_session_nonce' ),
'woopayMerchantId' => Jetpack_Options::get_option( 'id' ),
'icon' => $this->gateway->get_icon_url(),
'woopayMinimumSessionData' => WooPay_Session::get_woopay_minimum_session_data(),
];
/**
* Allows filtering of the JS config for the payment fields.
*
* @param array $js_config The JS config for the payment fields.
*/
$payment_fields = apply_filters( 'wcpay_payment_fields_js_config', $js_config );
$payment_fields['accountDescriptor'] = $this->gateway->get_account_statement_descriptor();
$payment_fields['addPaymentReturnURL'] = wc_get_account_endpoint_url( 'payment-methods' );
$payment_fields['gatewayId'] = WC_Payment_Gateway_WCPay::GATEWAY_ID;
$payment_fields['isCheckout'] = is_checkout();
$payment_fields['paymentMethodsConfig'] = $this->get_enabled_payment_method_config();
$payment_fields['testMode'] = WC_Payments::mode()->is_test();
$payment_fields['upeAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_APPEARANCE_TRANSIENT );
$payment_fields['upeAddPaymentMethodAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_ADD_PAYMENT_METHOD_APPEARANCE_TRANSIENT );
$payment_fields['upeBnplProductPageAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_BNPL_PRODUCT_PAGE_APPEARANCE_TRANSIENT );
$payment_fields['upeBnplClassicCartAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_BNPL_CLASSIC_CART_APPEARANCE_TRANSIENT );
$payment_fields['upeBnplCartBlockAppearance'] = get_transient( WC_Payment_Gateway_WCPay::UPE_BNPL_CART_BLOCK_APPEARANCE_TRANSIENT );
$payment_fields['wcBlocksUPEAppearance'] = get_transient( WC_Payment_Gateway_WCPay::WC_BLOCKS_UPE_APPEARANCE_TRANSIENT );
$payment_fields['wcBlocksUPEAppearanceTheme'] = get_transient( WC_Payment_Gateway_WCPay::WC_BLOCKS_UPE_APPEARANCE_THEME_TRANSIENT );
$payment_fields['cartContainsSubscription'] = $this->gateway->is_subscription_item_in_cart();
$payment_fields['currency'] = get_woocommerce_currency();
$cart_total = ( WC()->cart ? WC()->cart->get_total( '' ) : 0 );
$payment_fields['cartTotal'] = WC_Payments_Utils::prepare_amount( $cart_total, get_woocommerce_currency() );
$enabled_billing_fields = [];
foreach ( WC()->checkout()->get_checkout_fields( 'billing' ) as $billing_field => $billing_field_options ) {
if ( ! isset( $billing_field_options['enabled'] ) || $billing_field_options['enabled'] ) {
$enabled_billing_fields[ $billing_field ] = [
'required' => $billing_field_options['required'],
];
}
}
$payment_fields['enabledBillingFields'] = $enabled_billing_fields;
if ( is_wc_endpoint_url( 'order-pay' ) ) {
if ( $this->gateway->is_subscriptions_enabled() && $this->gateway->is_changing_payment_method_for_subscription() ) {
$payment_fields['isChangingPayment'] = true;
$payment_fields['addPaymentReturnURL'] = esc_url_raw( home_url( add_query_arg( [] ) ) );
if ( $this->gateway->is_setup_intent_success_creation_redirection() && isset( $_GET['_wpnonce'] ) && wp_verify_nonce( wc_clean( wp_unslash( $_GET['_wpnonce'] ) ) ) ) {
$setup_intent_id = isset( $_GET['setup_intent'] ) ? wc_clean( wp_unslash( $_GET['setup_intent'] ) ) : '';
$token = $this->gateway->create_token_from_setup_intent( $setup_intent_id, wp_get_current_user() );
if ( null !== $token ) {
$payment_fields['newTokenFormId'] = '#wc-' . $token->get_gateway_id() . '-payment-token-' . $token->get_id();
}
}
return $payment_fields; // nosemgrep: audit.php.wp.security.xss.query-arg -- server generated url is passed in.
}
$payment_fields['isOrderPay'] = true;
$order_id = absint( get_query_var( 'order-pay' ) );
$payment_fields['orderId'] = $order_id;
$order = wc_get_order( $order_id );
if ( is_a( $order, 'WC_Order' ) ) {
$order_currency = $order->get_currency();
$payment_fields['currency'] = $order_currency;
$payment_fields['cartTotal'] = WC_Payments_Utils::prepare_amount( $order->get_total(), $order_currency );
$payment_fields['orderReturnURL'] = esc_url_raw(
add_query_arg(
[
'wc_payment_method' => WC_Payment_Gateway_WCPay::GATEWAY_ID,
'_wpnonce' => wp_create_nonce( 'wcpay_process_redirect_order_nonce' ),
],
$this->gateway->get_return_url( $order )
)
);
}
}
// Get the store base country.
$payment_fields['storeCountry'] = WC()->countries->get_base_country();
// Get the WooCommerce Store API endpoint.
$payment_fields['storeApiURL'] = get_rest_url( null, 'wc/store' );
/**
* Allows filtering for the payment fields.
*
* @param array $payment_fields The payment fields.
*/
return apply_filters( 'wcpay_payment_fields_js_config', $payment_fields ); // nosemgrep: audit.php.wp.security.xss.query-arg -- server generated url is passed in.
}
/**
* Gets payment method settings to pass to client scripts
*
* @return array
*/
public function get_enabled_payment_method_config() {
$settings = [];
$enabled_payment_methods = $this->gateway->get_payment_method_ids_enabled_at_checkout();
foreach ( $enabled_payment_methods as $payment_method_id ) {
// Link by Stripe should be validated with available fees.
if ( Payment_Method::LINK === $payment_method_id ) {
if ( ! in_array( Payment_Method::LINK, array_keys( $this->account->get_fees() ), true ) ) {
continue;
}
}
$payment_method = $this->gateway->wc_payments_get_payment_method_by_id( $payment_method_id );
$account_country = $this->account->get_account_country();
$settings[ $payment_method_id ] = [
'isReusable' => $payment_method->is_reusable(),
'title' => $payment_method->get_title( $account_country ),
'icon' => $payment_method->get_icon( $account_country ),
'darkIcon' => $payment_method->get_dark_icon( $account_country ),
'showSaveOption' => $this->should_upe_payment_method_show_save_option( $payment_method ),
'countries' => $payment_method->get_countries(),
];
$gateway_for_payment_method = $this->gateway->wc_payments_get_payment_gateway_by_id( $payment_method_id );
$settings[ $payment_method_id ]['testingInstructions'] = WC_Payments_Utils::esc_interpolated_html(
/* translators: link to Stripe testing page */
$payment_method->get_testing_instructions( $account_country ),
[
'a' => '<a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" target="_blank">',
'strong' => '<strong>',
'number' => '<button type="button" class="js-woopayments-copy-test-number" aria-label="' . esc_attr( __( 'Click to copy the test number to clipboard', 'woocommerce-payments' ) ) . '" title="' . esc_attr( __( 'Copy to clipboard', 'woocommerce-payments' ) ) . '"><i></i><span>',
]
);
$should_enable_network_saved_cards = Payment_Method::CARD === $payment_method_id && WC_Payments::is_network_saved_cards_enabled();
$settings[ $payment_method_id ]['forceNetworkSavedCards'] = $should_enable_network_saved_cards || $gateway_for_payment_method->should_use_stripe_platform_on_checkout_page();
}
return $settings;
}
/**
* Checks if the save option for a payment method should be displayed or not.
*
* @param UPE_Payment_Method $payment_method UPE Payment Method instance.
* @return bool - True if the payment method is reusable and the saved cards feature is enabled for the gateway and there is no subscription item in the cart, false otherwise.
*/
private function should_upe_payment_method_show_save_option( $payment_method ) {
if ( $payment_method->get_id() === Payment_Method::CARD && is_user_logged_in() && WC_Payments_Features::is_woopay_enabled() ) {
return false;
}
if ( $payment_method->is_reusable() ) {
return $this->gateway->is_saved_cards_enabled() && ! $this->gateway->is_subscription_item_in_cart();
}
return false;
}
/**
* Renders the UPE input fields needed to get the user's payment information on the checkout page.
*
* We also add the JavaScript which drives the UI.
*/
public function payment_fields() {
try {
$display_tokenization = $this->gateway->supports( 'tokenization' ) && ( is_checkout() || is_add_payment_method_page() );
/**
* Localizing scripts within shortcodes does not work in WP 5.9,
* but we need `$this->get_payment_fields_js_config` to be called
* before `$this->saved_payment_methods()`.
*/
if ( ! wp_script_is( 'wcpay-upe-checkout', 'enqueued' ) ) {
$payment_fields = $this->get_payment_fields_js_config();
wp_enqueue_script( 'wcpay-upe-checkout' );
/**
* We can't localize the script right away since at this point is not registered yet.
* We also need to make sure it that it only runs once (using a dummy action), even if
* there are multiple payment methods available; otherwise the data will be overwritten
* which is pointless.
*
* The same applies for `wcpayCustomerData` a few lines below.
*/
add_action(
'wp_footer',
function () use ( $payment_fields ) {
if ( ! did_action( '__wcpay_upe_config_localized' ) ) {
wp_localize_script( 'wcpay-upe-checkout', 'wcpay_upe_config', $payment_fields );
}
do_action( '__wcpay_upe_config_localized' );
}
);
$prepared_customer_data = $this->customer_service->get_prepared_customer_data();
if ( ! empty( $prepared_customer_data ) ) {
add_action(
'wp_footer',
function () use ( $prepared_customer_data ) {
if ( ! did_action( '__wcpay_customer_data_localized' ) ) {
wp_localize_script( 'wcpay-upe-checkout', 'wcpayCustomerData', $prepared_customer_data );
}
do_action( '__wcpay_customer_data_localized' );
}
);
}
WC_Payments_Utils::enqueue_style(
'wcpay-upe-checkout',
plugins_url( 'dist/checkout.css', WCPAY_PLUGIN_FILE ),
[],
WC_Payments::get_file_version( 'dist/checkout.css' ),
'all'
);
}
?>
<div class="wcpay-upe-form"
data-payment-method-type="<?php echo esc_attr( $this->gateway->get_stripe_id() ); ?>"
>
<?php
// Output the form HTML.
if ( ! empty( $this->gateway->get_description() ) ) :
?>
<p><?php echo wp_kses_post( $this->gateway->get_description() ); ?></p>
<?php
endif;
if ( WC_Payments::mode()->is_test() && false !== $this->gateway->get_payment_method()->get_testing_instructions( $this->account->get_account_country() ) ) :
?>
<p class="testmode-info">
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo WC_Payments_Utils::esc_interpolated_html(
/* translators: link to Stripe testing page */
$this->gateway->get_payment_method()->get_testing_instructions( $this->account->get_account_country() ),
[
'a' => '<a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" target="_blank">',
'strong' => '<strong>',
'number' => '<button type="button" class="js-woopayments-copy-test-number" aria-label="' . esc_attr( __( 'Click to copy the test number to clipboard', 'woocommerce-payments' ) ) . '" title="' . esc_attr( __( 'Copy to clipboard', 'woocommerce-payments' ) ) . '"><i></i><span>',
]
);
?>
</p>
<?php
endif;
if ( $display_tokenization ) {
$this->gateway->tokenization_script();
// avoid showing saved payment methods on my-accounts add payment method page.
if ( ! is_add_payment_method_page() ) {
$this->gateway->saved_payment_methods();
}
}
?>
<fieldset style="padding: 7px" class="wc-payment-form">
<?php
$this->gateway->display_gateway_html();
if ( $this->gateway->is_saved_cards_enabled() && $this->gateway->should_support_saved_payments() ) {
$force_save_payment = ( $display_tokenization && ! apply_filters( 'wc_payments_display_save_payment_method_checkbox', $display_tokenization ) ) || is_add_payment_method_page();
if ( is_user_logged_in() || $force_save_payment ) {
$this->gateway->save_payment_method_checkbox( $force_save_payment );
}
}
?>
</fieldset>
</div>
<?php
do_action( 'wcpay_payment_fields_upe', $this->gateway->id );
} catch ( \Exception $e ) {
// Output the error message.
Logger::log( 'Error: ' . $e->getMessage() );
?>
<div>
<?php
echo esc_html__( 'An error was encountered when preparing the payment form. Please try again later.', 'woocommerce-payments' );
?>
</div>
<?php
}
}
/**
* Set gateway
*
* @param string $payment_method_id Payment method ID.
* @return void
*/
public function set_gateway( $payment_method_id ) {
if ( null !== $payment_method_id ) {
$this->gateway = $this->gateway->wc_payments_get_payment_gateway_by_id( $payment_method_id );
}
}
/**
* Load the checkout scripts.
*/
private function load_checkout_scripts() {
WC_Payments::get_gateway()->tokenization_script();
$script_handle = 'wcpay-upe-checkout';
$js_object = 'wcpay_upe_config';
wp_localize_script( $script_handle, $js_object, WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config() );
wp_enqueue_script( $script_handle );
}
}
@@ -0,0 +1,580 @@
<?php
/**
* Class WC_Payments_Customer
*
* @package WooCommerce\Payments
*/
use WCPay\Database_Cache;
use WCPay\Exceptions\API_Exception;
use WCPay\Logger;
use WCPay\Constants\Payment_Method;
defined( 'ABSPATH' ) || exit;
/**
* Class handling any customer functionality
*/
class WC_Payments_Customer_Service {
/**
* Deprecated Stripe customer ID option.
*
* This option was used to store the customer_id in a WC_User options before we decoupled live and test customers.
*/
const DEPRECATED_WCPAY_CUSTOMER_ID_OPTION = '_wcpay_customer_id';
/**
* Live Stripe customer ID option.
*
* This option is used to store new live mode customers in a WC_User options. Customers stored in the deprecated
* option are migrated to this one.
*/
const WCPAY_LIVE_CUSTOMER_ID_OPTION = '_wcpay_customer_id_live';
/**
* Test Stripe customer ID option.
*
* This option is used to store new test mode customer IDs in a WC_User options.
*/
const WCPAY_TEST_CUSTOMER_ID_OPTION = '_wcpay_customer_id_test';
/**
* Key used to store customer id for non logged in users in WooCommerce Session.
*/
const CUSTOMER_ID_SESSION_KEY = 'wcpay_customer_id';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* WC_Payments_Account instance to get information about the account
*
* @var WC_Payments_Account
*/
private $account;
/**
* Database_Cache instance to get information about the account
*
* @var Database_Cache
*/
private $database_cache;
/**
* WC_Payments_Session_Service instance for working with session information
*
* @var WC_Payments_Session_Service
*/
private $session_service;
/**
* WC_Payments_Order_Service instance
*
* @var WC_Payments_Order_Service
*/
private $order_service;
/**
* Class constructor
*
* @param WC_Payments_API_Client $payments_api_client Payments API client.
* @param WC_Payments_Account $account WC_Payments_Account instance.
* @param Database_Cache $database_cache Database_Cache instance.
* @param WC_Payments_Session_Service $session_service Session Service class instance.
* @param WC_Payments_Order_Service $order_service Order Service class instance.
*/
public function __construct(
WC_Payments_API_Client $payments_api_client,
WC_Payments_Account $account,
Database_Cache $database_cache,
WC_Payments_Session_Service $session_service,
WC_Payments_Order_Service $order_service
) {
$this->payments_api_client = $payments_api_client;
$this->account = $account;
$this->database_cache = $database_cache;
$this->session_service = $session_service;
$this->order_service = $order_service;
}
/**
* Initialize hooks
*/
public function init_hooks() {
/*
* Adds the WooCommerce Payments customer ID found in the user session
* to the WordPress user as metadata.
*
* This is helpful in scenarios where the shopper begins the checkout flow
* logged out (i.e. guest checkout) and a user account is created for them
* during checkout.
*
* This occurs when a user account is necessary for checkout, e.g. when the shopper
* purchases a subscription product.
*/
add_action( 'woocommerce_created_customer', [ $this, 'add_customer_id_to_user' ] );
}
/**
* Get WCPay customer ID for the given WordPress user ID
*
* @param int|null $user_id The user ID to look for a customer ID with.
*
* @return string|null WCPay customer ID or null if not found.
*/
public function get_customer_id_by_user_id( $user_id ) {
// User ID might be 0 if fetched from a WP_User instance for a user who isn't logged in.
if ( null === $user_id || 0 === $user_id ) {
// Try to retrieve the customer id from the session if stored previously.
$customer_id = WC()->session ? WC()->session->get( self::CUSTOMER_ID_SESSION_KEY ) : null;
return is_string( $customer_id ) ? $customer_id : null;
}
$customer_id = get_user_option( $this->get_customer_id_option(), $user_id );
// If customer_id is false it could mean that it hasn't been migrated from the deprecated key.
if ( false === $customer_id ) {
$this->maybe_migrate_deprecated_customer( $user_id );
// Customer might've been migrated in maybe_migrate_deprecated_customer, so we need to fetch it again.
$customer_id = get_user_option( $this->get_customer_id_option(), $user_id );
}
return $customer_id ? $customer_id : null;
}
/**
* Create a customer and associate it with a WordPress user.
*
* @param WP_User|null $user User to create a customer for.
* @param array $customer_data Customer data.
*
* @return string The created customer's ID
*
* @throws API_Exception Error creating customer.
*/
public function create_customer_for_user( ?WP_User $user, array $customer_data = [] ): string {
// Include the session ID for the user.
$customer_data['session_id'] = $this->session_service->get_sift_session_id() ?? null;
// Create a customer on the WCPay server.
$customer_id = $this->payments_api_client->create_customer( $customer_data );
if ( $user instanceof WP_User && $user->ID > 0 ) {
$this->update_user_customer_id( $user->ID, $customer_id );
}
if ( isset( WC()->session ) ) {
// Save the customer id in the session for non logged in users to reuse it in payments.
WC()->session->set( self::CUSTOMER_ID_SESSION_KEY, $customer_id );
}
return $customer_id;
}
/**
* Manages customer details held on WCPay server for WordPress user associated with an order.
*
* @param int|null $user_id ID of the WP user to associate with the customer.
* @param WC_Order $order Woo Order.
*
* @return string WooPayments customer ID.
* @throws API_Exception Throws when server API request fails.
*/
public function get_or_create_customer_id_from_order( ?int $user_id, WC_Order $order ): string {
// Determine the customer making the payment, create one if we don't have one already.
$customer_id = $this->get_customer_id_by_user_id( $user_id );
$customer_data = self::map_customer_data( $order, new WC_Customer( $user_id ?? 0 ) );
$user = null === $user_id ? null : get_user_by( 'id', $user_id );
if ( null !== $customer_id ) {
$this->update_customer_for_user( $customer_id, $user, $customer_data );
return $customer_id;
}
return $this->create_customer_for_user( $user, $customer_data );
}
/**
* Update the customer details held on the WCPay server associated with the given WordPress user.
*
* @param string $customer_id WCPay customer ID.
* @param WP_User|null $user WordPress user.
* @param array $customer_data Customer data.
*
* @return string The updated customer's ID. Can be different to the ID parameter if the customer was re-created.
*
* @throws API_Exception Error updating the customer.
*/
public function update_customer_for_user( string $customer_id, ?WP_User $user, array $customer_data ): string {
try {
// Update the customer on the WCPay server.
$this->payments_api_client->update_customer(
$customer_id,
$customer_data
);
// We successfully updated the existing customer, so return the passed in ID unchanged.
return $customer_id;
} catch ( API_Exception $e ) {
// If we failed to find the customer we wanted to update, then create a new customer and associate it to the
// current user instead. This might happen if the customer was deleted from the server, the linked WCPay
// account was changed, or if users were imported from another site.
if ( $e->get_error_code() === 'resource_missing' ) {
// Create a new customer to associate with this user. We'll return the new customer ID.
return $this->recreate_customer( $user, $customer_data );
}
// For any other type of exception, just re-throw.
throw $e;
}
}
/**
* Sets a payment method as default for a customer.
*
* @param string $customer_id The customer ID.
* @param string $payment_method_id The payment method ID.
*/
public function set_default_payment_method_for_customer( $customer_id, $payment_method_id ) {
$this->payments_api_client->update_customer(
$customer_id,
[
'invoice_settings' => [
'default_payment_method' => $payment_method_id,
],
]
);
}
/**
* Gets all payment methods for a customer.
*
* @param string $customer_id The customer ID.
* @param string $type Type of payment methods to fetch.
*
* @throws API_Exception We only handle 'resource_missing' code types and rethrow anything else.
*/
public function get_payment_methods_for_customer( $customer_id, $type = 'card' ) {
if ( ! $customer_id ) {
return [];
}
$cache_payment_methods = ! WC_Payments::is_network_saved_cards_enabled();
$cache_key = Database_Cache::PAYMENT_METHODS_KEY_PREFIX . $customer_id . '_' . $type;
if ( $cache_payment_methods ) {
$payment_methods = $this->database_cache->get( $cache_key );
if ( is_array( $payment_methods ) ) {
return $payment_methods;
}
}
try {
$payment_methods = $this->payments_api_client->get_payment_methods( $customer_id, $type )['data'];
if ( $cache_payment_methods ) {
$this->database_cache->add( $cache_key, $payment_methods );
}
return $payment_methods;
} catch ( API_Exception $e ) {
// If we failed to find the payment methods, we can simply return empty payment methods as this customer
// will be recreated when the user successfully adds a payment method.
if ( $e->get_error_code() === 'resource_missing' ) {
return [];
}
// Rethrow for error codes we don't care about in this function.
throw $e;
}
}
/**
* Updates a customer payment method.
*
* @param string $payment_method_id The payment method ID.
* @param WC_Order $order Order to be used on the update.
*/
public function update_payment_method_with_billing_details_from_order( $payment_method_id, $order ) {
$billing_details = $this->order_service->get_billing_data_from_order( $order );
if ( ! empty( $billing_details ) ) {
$this->payments_api_client->update_payment_method(
$payment_method_id,
[
'billing_details' => $billing_details,
]
);
}
}
/**
* Clear payment methods cache for a user.
*
* @param int $user_id WC user ID.
*/
public function clear_cached_payment_methods_for_user( $user_id ) {
if ( WC_Payments::is_network_saved_cards_enabled() ) {
return; // No need to do anything, payment methods will never be cached in this case.
}
$retrievable_payment_method_types = [ Payment_Method::CARD, Payment_Method::LINK, Payment_Method::SEPA ];
$customer_id = $this->get_customer_id_by_user_id( $user_id );
foreach ( $retrievable_payment_method_types as $type ) {
$this->database_cache->delete( Database_Cache::PAYMENT_METHODS_KEY_PREFIX . $customer_id . '_' . $type );
}
}
/**
* Given a WC_Order or WC_Customer, returns an array representing a Stripe customer object.
* At least one parameter has to not be null.
*
* @param WC_Order $wc_order The Woo order to parse.
* @param WC_Customer $wc_customer The Woo customer to parse.
*
* @return array Customer data.
*/
public static function map_customer_data( ?WC_Order $wc_order = null, ?WC_Customer $wc_customer = null ): array {
if ( null === $wc_customer && null === $wc_order ) {
return [];
}
// Where available, the order data takes precedence over the customer.
$object_to_parse = $wc_order ?? $wc_customer;
$name = $object_to_parse->get_billing_first_name() . ' ' . $object_to_parse->get_billing_last_name();
$description = '';
if ( null !== $wc_customer && ! empty( $wc_customer->get_username() ) ) {
// We have a logged in user, so add their username to the customer description.
// translators: %1$s Name, %2$s Username.
$description = sprintf( __( 'Name: %1$s, Username: %2$s', 'woocommerce-payments' ), $name, $wc_customer->get_username() );
} else {
// Current user is not logged in.
// translators: %1$s Name.
$description = sprintf( __( 'Name: %1$s, Guest', 'woocommerce-payments' ), $name );
}
$data = [
'name' => $name,
'description' => $description,
'email' => $object_to_parse->get_billing_email(),
'phone' => $object_to_parse->get_billing_phone(),
'address' => [
'line1' => $object_to_parse->get_billing_address_1(),
'line2' => $object_to_parse->get_billing_address_2(),
'postal_code' => $object_to_parse->get_billing_postcode(),
'city' => $object_to_parse->get_billing_city(),
'state' => $object_to_parse->get_billing_state(),
'country' => $object_to_parse->get_billing_country(),
],
];
if ( ! empty( $object_to_parse->get_shipping_postcode() ) ) {
$data['shipping'] = [
'name' => $object_to_parse->get_shipping_first_name() . ' ' . $object_to_parse->get_shipping_last_name(),
'address' => [
'line1' => $object_to_parse->get_shipping_address_1(),
'line2' => $object_to_parse->get_shipping_address_2(),
'postal_code' => $object_to_parse->get_shipping_postcode(),
'city' => $object_to_parse->get_shipping_city(),
'state' => $object_to_parse->get_shipping_state(),
'country' => $object_to_parse->get_shipping_country(),
],
];
}
return $data;
}
/**
* Delete all saved payment methods that are stored inside the database cache driver.
*
* @return void
*/
public function delete_cached_payment_methods() {
$this->database_cache->delete_by_prefix( Database_Cache::PAYMENT_METHODS_KEY_PREFIX );
}
/**
* Recreates the customer for this user.
*
* @param WP_User|null $user User to recreate a customer for.
* @param array $customer_data Customer data.
*
* @return string The newly created customer's ID
*
* @throws API_Exception Error creating customer.
*/
private function recreate_customer( ?WP_User $user, array $customer_data ): string {
if ( $user instanceof WP_User && $user->ID > 0 ) {
$result = delete_user_option( $user->ID, $this->get_customer_id_option() );
if ( ! $result ) {
// Log the error, but continue since we'll be trying to update this option in create_customer.
Logger::error( 'Failed to delete old customer ID for user ' . $user->ID );
}
}
return $this->create_customer_for_user( $user, $customer_data );
}
/**
* Returns the name of the customer option meta, taking test mode into account.
*
* @return string The customer ID option name.
*/
private function get_customer_id_option(): string {
return WC_Payments::mode()->is_test()
? self::WCPAY_TEST_CUSTOMER_ID_OPTION
: self::WCPAY_LIVE_CUSTOMER_ID_OPTION;
}
/**
* Migrate any customer ID that might be in the DEPRECATED_WCPAY_CUSTOMER_ID_OPTION.
*
* @param int $user_id The user ID to look for a customer ID with.
*/
private function maybe_migrate_deprecated_customer( $user_id ) {
$customer_id = get_user_option( self::DEPRECATED_WCPAY_CUSTOMER_ID_OPTION, $user_id );
if ( false !== $customer_id ) {
// A customer was found in the deprecated key. Migrate it to the appropriate one and delete the old meta.
// If an account is live mode, we optimistically assume that the customer is live mode, to avoid losing
// live mode customer data. If the account is not live mode, it can only have test mode objects, so we
// can safely migrate them to the test key.
// If is_live cannot be determined, default it to true to avoid considering a live account as test.
$account_is_live = null === $this->account->get_is_live() || $this->account->get_is_live();
$customer_option_id = $account_is_live
? self::WCPAY_LIVE_CUSTOMER_ID_OPTION
: self::WCPAY_TEST_CUSTOMER_ID_OPTION;
if ( update_user_option( $user_id, $customer_option_id, $customer_id ) ) {
delete_user_option( $user_id, self::DEPRECATED_WCPAY_CUSTOMER_ID_OPTION );
} else {
Logger::error( 'Failed to store new customer ID for user ' . $user_id . '; legacy customer was kept.' );
}
}
}
/**
* Get the WCPay customer ID associated with an order, or create one if none found.
*
* @param WC_Order $order WC Order object.
*
* @return string|null WCPay customer ID.
* @throws API_Exception If there's an error creating customer.
*/
public function get_customer_id_for_order( $order ) {
$customer_id = null;
$user = $order->get_user();
if ( false !== $user ) {
// Determine the customer making the payment, create one if we don't have one already.
$customer_id = $this->get_customer_id_by_user_id( $user->ID );
if ( null === $customer_id ) {
$customer_data = self::map_customer_data( $order, new WC_Customer( $user->ID ) );
$customer_id = $this->create_customer_for_user( $user, $customer_data );
}
}
return $customer_id;
}
/**
* Updates the given user with the given WooCommerce Payments
* customer ID.
*
* @param int $user_id The WordPress user ID.
* @param string $customer_id The WooCommerce Payments customer ID.
*/
public function update_user_customer_id( int $user_id, string $customer_id ) {
$global = WC_Payments::is_network_saved_cards_enabled();
$result = update_user_option( $user_id, $this->get_customer_id_option(), $customer_id, $global );
if ( ! $result ) {
Logger::error( 'Failed to update customer ID for user ' . $user_id );
}
}
/**
* Adds the WooCommerce Payments customer ID found in the user session
* to the WordPress user as metadata.
*
* @param int $user_id The WordPress user ID.
*/
public function add_customer_id_to_user( $user_id ) {
// Not processing a checkout, bail.
if (
! ( defined( 'WOOCOMMERCE_CHECKOUT' ) && WOOCOMMERCE_CHECKOUT ) &&
! ( function_exists( 'wcs_is_checkout_blocks_api_request' ) && wcs_is_checkout_blocks_api_request( 'v1/checkout' ) )
) {
return;
}
// Retrieve the WooCommerce Payments customer ID from the user session.
$customer_id = WC()->session ? WC()->session->get( self::CUSTOMER_ID_SESSION_KEY ) : null;
if ( ! $customer_id ) {
return;
}
$this->update_user_customer_id( $user_id, $customer_id );
}
/**
* Prepares customer data to be used on 'Pay for Order' or 'Add Payment Method' pages.
* Customer data is retrieved from order when on Pay for Order.
* Customer data is retrieved from customer when on 'Add Payment Method'.
*
* @return array|null An array with customer data or nothing.
*/
public function get_prepared_customer_data() {
if ( ! isset( $_GET['pay_for_order'] ) && ! is_add_payment_method_page() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return null;
}
global $wp;
$user_email = '';
$firstname = '';
$lastname = '';
$billing_country = '';
$address = null;
if ( isset( $_GET['pay_for_order'] ) && 'true' === $_GET['pay_for_order'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$order_id = absint( $wp->query_vars['order-pay'] );
$order = wc_get_order( $order_id );
if ( is_a( $order, 'WC_Order' ) ) {
$firstname = $order->get_billing_first_name();
$lastname = $order->get_billing_last_name();
$user_email = $order->get_billing_email();
$billing_country = $order->get_billing_country();
$address = [
'city' => $order->get_billing_city(),
'country' => $order->get_billing_country(),
'line1' => $order->get_billing_address_1(),
'line2' => $order->get_billing_address_2(),
'postal_code' => $order->get_billing_postcode(),
'state' => $order->get_billing_state(),
];
}
}
if ( is_add_payment_method_page() ) {
$user = wp_get_current_user();
if ( $user->ID ) {
$firstname = $user->user_firstname;
$lastname = $user->user_lastname;
$user_email = get_user_meta( $user->ID, 'billing_email', true );
$user_email = ! empty( $user_email ) ? $user_email : $user->user_email;
$billing_country = get_user_meta( $user->ID, 'billing_country', true );
}
}
return [
'name' => $firstname . ' ' . $lastname,
'email' => $user_email,
'billing_country' => $billing_country,
'address' => $address,
];
}
}
@@ -0,0 +1,121 @@
<?php
/**
* WC_Payments_DB class
*
* @package WooCommerce\Payments
*/
defined( 'ABSPATH' ) || exit;
/**
* Wrapper class for accessing the database.
*/
class WC_Payments_DB {
const META_KEY_INTENT_ID = '_intent_id';
const META_KEY_CHARGE_ID = '_charge_id';
/**
* Retrieve an order from the DB using a corresponding Stripe charge ID.
*
* @param string $charge_id Charge ID corresponding to an order ID.
*
* @return boolean|WC_Order|WC_Order_Refund
*/
public function order_from_charge_id( $charge_id ) {
$order_id = $this->order_id_from_meta_key_value( self::META_KEY_CHARGE_ID, $charge_id );
if ( $order_id ) {
return $this->order_from_order_id( $order_id );
}
return false;
}
/**
* Retrieve orders from the DB using a list of corresponding Stripe charge IDs.
*
* @param array $charge_ids List of charge IDs corresponding to an order ID.
*
* @return array[]
*/
public function orders_with_charge_id_from_charge_ids( array $charge_ids ): array {
// The order ID is saved to DB in `WC_Payment_Gateway_WCPay::process_payment()`.
$orders = wc_get_orders(
[
'limit' => count( $charge_ids ),
'meta_key' => self::META_KEY_CHARGE_ID, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $charge_ids, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_compare' => 'IN',
]
);
return array_map(
function ( WC_Order $order ): array {
return [
'order' => $order,
'charge_id' => $order->get_meta( self::META_KEY_CHARGE_ID ),
];
},
$orders
);
}
/**
* Retrieve an order from the DB using a corresponding Stripe intent ID.
*
* @param string $intent_id Intent ID corresponding to an order ID.
*
* @return boolean|WC_Order|WC_Order_Refund
*/
public function order_from_intent_id( $intent_id ) {
$order_id = $this->order_id_from_meta_key_value( self::META_KEY_INTENT_ID, $intent_id );
if ( $order_id ) {
return $this->order_from_order_id( $order_id );
}
return false;
}
/**
* Retrieve an order ID from the DB using a meta key value pair.
*
* @param string $meta_key Either '_intent_id' or '_charge_id'.
* @param string $meta_value Value for the meta key.
*
* @return null|string
*/
private function order_id_from_meta_key_value( $meta_key, $meta_value ) {
// Don't proceed if the meta key or value is empty.
if ( ! $meta_key || ! $meta_value ) {
return null;
}
$orders = wc_get_orders(
[
'limit' => 1,
'meta_key' => $meta_key, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $meta_value, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
]
);
if ( $orders && ! empty( $orders ) ) {
/**
* As wc_get_orders may also return stdClass, Psalm infers error.
*
* @psalm-suppress UndefinedMethod
*/
return (string) $orders[0]->get_id();
}
return null;
}
/**
* Retrieve an order using order ID.
*
* @param string $order_id WC Order Id.
*
* @return bool|WC_Order|WC_Order_Refund
*/
public function order_from_order_id( $order_id ) {
return wc_get_order( ( $order_id ) );
}
}
@@ -0,0 +1,349 @@
<?php
/**
* WC_Payments_Dependency_Service class
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Database_Cache;
/**
* Validates dependencies (core, plugins, versions) for WCPAY
* Used in the plugin main class for validation.
*/
class WC_Payments_Dependency_Service {
const WOOCORE_NOT_FOUND = 'woocore_disabled';
const WOOCORE_INCOMPATIBLE = 'woocore_outdated';
const WOOADMIN_NOT_FOUND = 'wc_admin_not_found';
const WOOADMIN_INCOMPATIBLE = 'wc_admin_outdated';
const WP_INCOMPATIBLE = 'wp_outdated';
const DEV_ASSETS_NOT_BUILT = 'dev_assets_not_built';
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_filter( 'admin_notices', [ $this, 'display_admin_notices' ] );
}
/**
* Checks if all the dependencies needed to run WooPayments are present
*
* @return bool True if all required dependencies are met.
*/
public function has_valid_dependencies() {
if ( defined( 'WCPAY_TEST_ENV' ) && WCPAY_TEST_ENV ) {
return true;
}
return empty( $this->get_invalid_dependencies( true ) );
}
/**
* Render admin notices for unmet dependencies. Called on the admin_notices hook.
*
* @return null.
*/
public function display_admin_notices() {
// Do not show alerts while installing plugins.
if ( self::is_at_plugin_install_page() ) {
return;
}
// Show a message when assets are not built in a dev build.
if ( ! $this->are_assets_built() ) {
WC_Payments::display_admin_error( $this->get_notice_for_invalid_dependency( self::DEV_ASSETS_NOT_BUILT ) );
}
$invalid_dependencies = $this->get_invalid_dependencies();
if ( ! empty( $invalid_dependencies ) ) {
WC_Payments::display_admin_error( $this->get_notice_for_invalid_dependency( $invalid_dependencies[0] ) );
}
}
/**
* Returns an array of invalid dependencies
*
* @param bool $check_account_connection - if should bypass dependency version validation when an account is connected.
*
* @return array of invalid dependencies as string constants.
*/
public function get_invalid_dependencies( bool $check_account_connection = false ) {
$invalid_dependencies = [];
// Either ignore the account connection check or check if there's a cached account connection.
$ignore_when_account_is_connected = $check_account_connection && self::has_cached_account_connection();
if ( ! $this->is_woo_core_active() ) {
$invalid_dependencies[] = self::WOOCORE_NOT_FOUND;
}
if ( ! $ignore_when_account_is_connected && ! $this->is_woo_core_version_compatible() ) {
$invalid_dependencies[] = self::WOOCORE_INCOMPATIBLE;
}
if ( ! $this->is_wc_admin_enabled() ) {
$invalid_dependencies[] = self::WOOADMIN_NOT_FOUND;
}
if ( ! $ignore_when_account_is_connected && ! $this->is_wc_admin_version_compatible() ) {
$invalid_dependencies[] = self::WOOADMIN_INCOMPATIBLE;
}
if ( ! $ignore_when_account_is_connected && ! $this->is_wp_version_compatible() ) {
$invalid_dependencies[] = self::WP_INCOMPATIBLE;
}
return $invalid_dependencies;
}
/**
* Checks if WooCommerce is installed and activated.
*
* @return bool True if WooCommerce is installed and activated.
*/
public function is_woo_core_active() {
// Check if WooCommerce is installed and active.
return class_exists( 'WooCommerce' );
}
/**
* Checks if the version of WooCommerce is compatible with WooPayments.
*
* @return bool True if WooCommerce version is greater than or equal the minimum accepted
*/
public function is_woo_core_version_compatible() {
$plugin_headers = WC_Payments::get_plugin_headers();
$wc_version = $plugin_headers['WCRequires'];
// Check if the version of WooCommerce is compatible with WooPayments.
return ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, $wc_version, '>=' ) );
}
/**
* Checks if the WooCommerce version has WooCommerce Admin bundled (WC 4.0+)
* but it's disabled using a filter.
*
* @return bool True if WC Admin is found
*/
public function is_wc_admin_enabled() {
// Check if the current WooCommerce version has WooCommerce Admin bundled (WC 4.0+) but it's disabled using a filter.
if ( ! defined( 'WC_ADMIN_VERSION_NUMBER' ) || apply_filters( 'woocommerce_admin_disabled', false ) ) {
return false;
}
return true;
}
/**
* Checks if the version of WC Admin is compatible with WooPayments.
*
* @return bool True if WC Admin version is greater than or equal the minimum accepted
*/
public function is_wc_admin_version_compatible() {
// Check if the version of WooCommerce Admin is compatible with WooPayments.
return ( defined( 'WC_ADMIN_VERSION_NUMBER' ) && version_compare( WC_ADMIN_VERSION_NUMBER, WCPAY_MIN_WC_ADMIN_VERSION, '>=' ) );
}
/**
* Checks if the version of WordPress is compatible with WooPayments.
*
* @return bool True if WordPress version is greater than or equal the minimum accepted
*/
public function is_wp_version_compatible() {
$plugin_headers = WC_Payments::get_plugin_headers();
$wp_version = $plugin_headers['RequiresWP'];
return version_compare( get_bloginfo( 'version' ), $wp_version, '>=' );
}
/**
* Checks some of the asset files to confirm scripts and styles have been correctly built.
*
* @return bool TRUE if assets have been built or FALSE otherwise.
*/
public function are_assets_built() {
return ( file_exists( WCPAY_ABSPATH . 'dist/index.js' ) && file_exists( WCPAY_ABSPATH . 'dist/index.css' ) );
}
/**
* Get the error constant of an invalid dependency, and transforms it into HTML to be used in an Admin Notice.
*
* @param string $code - invalid dependency constant.
*
* @return string HTML to render admin notice for the unmet dependency.
*/
private function get_notice_for_invalid_dependency( $code ) {
$plugin_headers = WC_Payments::get_plugin_headers();
$wp_version = $plugin_headers['RequiresWP'];
$wc_version = $plugin_headers['WCRequires'];
$error_message = '';
switch ( $code ) {
case self::WOOCORE_NOT_FOUND:
$error_message = WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %1$s: WooPayments, %2$s: WooCommerce */
__( '%1$s requires <a>%2$s</a> to be installed and active.', 'woocommerce-payments' ),
'WooPayments',
'WooCommerce'
),
[ 'a' => '<a href="https://wordpress.org/plugins/woocommerce">' ]
);
if ( current_user_can( 'install_plugins' ) ) {
if ( is_wp_error( validate_plugin( 'woocommerce/woocommerce.php' ) ) ) {
// WooCommerce is not installed.
$activate_url = wp_nonce_url( admin_url( 'update.php?action=install-plugin&plugin=woocommerce' ), 'install-plugin_woocommerce' );
$activate_text = __( 'Install WooCommerce', 'woocommerce-payments' );
} else {
// WooCommerce is installed, so it just needs to be enabled.
$activate_url = wp_nonce_url( admin_url( 'plugins.php?action=activate&plugin=woocommerce/woocommerce.php' ), 'activate-plugin_woocommerce/woocommerce.php' );
$activate_text = __( 'Activate WooCommerce', 'woocommerce-payments' );
}
$error_message .= ' <a href="' . $activate_url . '">' . $activate_text . '</a>';
}
break;
case self::WOOCORE_INCOMPATIBLE:
$error_message = WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %1: WooPayments, %2: current WooCommerce Payment version, %3: WooCommerce, %4: required WC version number, %5: currently installed WC version number */
__( '%1$s %2$s requires <strong>%3$s %4$s</strong> or greater to be installed (you are using %5$s). ', 'woocommerce-payments' ),
'WooPayments',
WCPAY_VERSION_NUMBER,
'WooCommerce',
$wc_version,
WC_VERSION
),
[ 'strong' => '<strong>' ]
);
if ( current_user_can( 'update_plugins' ) ) {
// Take the user to the "plugins" screen instead of trying to update WooCommerce inline. WooCommerce adds important information
// on its plugin row regarding the currently installed extensions and their compatibility with the latest WC version.
$error_message .= '<br/>' . WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %1$s: WooCommerce, %2$s: WooPayments, a1: link to the Plugins page, a2: link to the page having all previous versions */
__( '<a1>Update %1$s</a1> <strong>(recommended)</strong> or manually re-install <a2>a previous version</a2> of %2$s.', 'woocommerce-payments' ),
'WooCommerce',
'WooPayments'
),
[
'a1' => '<a href="' . admin_url( 'plugins.php' ) . '">',
'strong' => '<strong>',
'a2' => '<a href="https://wordpress.org/plugins/woocommerce-payments/advanced/#download-previous-link" target="_blank">',
]
);
}
break;
case self::WOOADMIN_NOT_FOUND:
$error_message = WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %1$s: WooPayments, %2$s: WooCommerce Admin */
__( '%1$s requires %2$s to be enabled. Please remove the <code>woocommerce_admin_disabled</code> filter to use %1$s.', 'woocommerce-payments' ),
'WooPayments',
'WooCommerce Admin'
),
[ 'code' => '<code>' ]
);
break;
case self::WOOADMIN_INCOMPATIBLE:
$error_message = WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %1: WooPayments, %2: WooCommerce Admin, %3: required WC-Admin version number, %4: currently installed WC-Admin version number */
__( '%1$s requires <strong>%2$s %3$s</strong> or greater to be installed (you are using %4$s).', 'woocommerce-payments' ),
'WooPayments',
'WooCommerce Admin',
WCPAY_MIN_WC_ADMIN_VERSION,
WC_ADMIN_VERSION_NUMBER
),
[ 'strong' => '<strong>' ]
);
// Let's assume for now that any WC-Admin version bundled with WooCommerce will meet our minimum requirements.
$error_message .= ' ' . __( 'There is a newer version of WooCommerce Admin bundled with WooCommerce.', 'woocommerce-payments' );
if ( current_user_can( 'deactivate_plugins' ) ) {
$deactivate_url = wp_nonce_url( admin_url( 'plugins.php?action=deactivate&plugin=woocommerce-admin/woocommerce-admin.php' ), 'deactivate-plugin_woocommerce-admin/woocommerce-admin.php' );
$error_message .= ' <a href="' . $deactivate_url . '">' . __( 'Use the bundled version of WooCommerce Admin', 'woocommerce-payments' ) . '</a>';
}
break;
case self::WP_INCOMPATIBLE:
$error_message = WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %1: WooPayments, %2: required WP version number, %3: currently installed WP version number */
__( '%1$s requires <strong>WordPress %2$s</strong> or greater (you are using %3$s).', 'woocommerce-payments' ),
'WooPayments',
$wp_version,
get_bloginfo( 'version' )
),
[ 'strong' => '<strong>' ]
);
if ( current_user_can( 'update_core' ) ) {
$error_message .= ' <a href="' . admin_url( 'update-core.php' ) . '">' . __( 'Update WordPress', 'woocommerce-payments' ) . '</a>';
}
break;
case self::DEV_ASSETS_NOT_BUILT:
$error_message = WC_Payments_Utils::esc_interpolated_html(
sprintf(
/* translators: %s: WooPayments */
__(
'You have installed a development version of %s which requires files to be built. From the plugin directory, run <code>npm run build:client</code> to build and minify assets. Alternatively, you can download a pre-built version of the plugin from the <a1>WordPress.org repository</a1> or by visiting the <a2>releases page in the GitHub repository</a2>.',
'woocommerce-payments'
),
'WooPayments'
),
[
'code' => '<code>',
'a1' => '<a href="https://wordpress.org/plugins/woocommerce-payments/">',
'a2' => '<a href="https://github.com/automattic/woocommerce-payments/releases/">',
]
);
break;
}
return $error_message;
}
/**
* Checks if current page is plugin installation process page.
*
* @return bool True when installing plugin.
*/
private static function is_at_plugin_install_page() {
$cur_screen = get_current_screen();
return $cur_screen && 'update' === $cur_screen->id && 'plugins' === $cur_screen->parent_base;
}
/**
* Check if the current WCPay Account has cache data.
*
* @return bool True if the cache data exists in wp_options.
*/
private static function has_cached_account_connection(): bool {
$account_data = get_option( Database_Cache::ACCOUNT_KEY );
return isset( $account_data['data'] ) && is_array( $account_data['data'] ) && ! empty( $account_data['data'] );
}
}
@@ -0,0 +1,164 @@
<?php
/**
* Class WC_Payments_Explicit_Price_Formatter
*
* @package WooCommerce\Payments
*/
use WCPay\MultiCurrency\MultiCurrency;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class for displaying the explicit prices on total amounts.
*
* Also used for consistent rendering of price values with currency (get_explicit_price_with_currency()).
*/
class WC_Payments_Explicit_Price_Formatter {
/**
* The Multi-Currency instance for checking the number of enabled currencies
*
* @var MultiCurrency
*/
private static $multi_currency_instance = null;
/**
* Inits the formatter, registering the necessary hooks.
*/
public static function init() {
add_filter( 'woocommerce_cart_total', [ __CLASS__, 'get_explicit_price' ], 100 );
add_filter( 'woocommerce_get_formatted_order_total', [ __CLASS__, 'get_explicit_price' ], 100, 2 );
add_action( 'woocommerce_admin_order_totals_after_tax', [ __CLASS__, 'register_formatted_woocommerce_price_filter' ] );
add_action( 'woocommerce_admin_order_totals_after_total', [ __CLASS__, 'unregister_formatted_woocommerce_price_filter' ] );
}
/**
* Overrides the MultiCurrency instance that the class uses.
* Mostly for testing purposes.
*
* @param MultiCurrency $multi_currency MultCurrency class.
*
* @return void
*/
public static function set_multi_currency_instance( MultiCurrency $multi_currency ) {
self::$multi_currency_instance = $multi_currency;
}
/**
* Checks if the method should output explicit price
*
* @return bool Whether if it should return explicit price or not
*/
public static function should_output_explicit_price() {
// If customer Multi-Currency is disabled, don't use explicit currencies.
// Because it'll have only the store currency active, same as count == 1.
if ( ! WC_Payments_Features::is_customer_multi_currency_enabled() ) {
return false;
}
// If the MultiCurrency instance hasn't been defined yet, fetch the instance.
if ( null === self::$multi_currency_instance ) {
self::$multi_currency_instance = WC_Payments_Multi_Currency();
}
// If the instance isn't initialized yet, skip the checks.
if ( ! self::$multi_currency_instance->is_initialized() ) {
return false;
}
// If no additional currencies are enabled, skip it.
if ( ! self::$multi_currency_instance->has_additional_currencies_enabled() ) {
return false;
}
return true;
}
/**
* Registers the get_explicit_price filter for the order details screen.
*
* There are no hooks that enable us to filter the output on the order details screen.
* So, we need to add a filter to formatted_woocommerce_price. We use specific actions
* to register and unregister the filter, so that only the appropriate prices are affected.
*/
public static function register_formatted_woocommerce_price_filter() {
add_filter( 'wc_price_args', [ __CLASS__, 'get_explicit_price_args' ], 100 );
}
/**
* Unregisters the get_explicit_price filter for the order details screen.
*
* There are no hooks that enable us to filter the output on the order details screen.
* So, we need to add a filter to formatted_woocommerce_price. We use specific actions
* to register and unregister the filter, so that only the appropriate prices are affected.
*/
public static function unregister_formatted_woocommerce_price_filter() {
remove_filter( 'wc_price_args', [ __CLASS__, 'get_explicit_price_args' ], 100 );
}
/**
* Returns the price suffixed with the appropriate currency code, if not already.
*
* @param string $price The price.
* @param WC_Abstract_Order|null $order The order.
*
* @return string
*/
public static function get_explicit_price( string $price, ?WC_Abstract_Order $order = null ) {
if ( null === $order ) {
$currency_code = get_woocommerce_currency();
} else {
$currency_code = $order->get_currency();
}
return static::get_explicit_price_with_currency( $price, $currency_code );
}
/**
* Returns a formatted price string suffixed with the appropriate currency code (if necessary).
*
* In multi-currency stores, order and price values are rendered with currency suffix.
* This method only renders the currency suffix if appropriate (see `should_output_explicit_price`).
*
* @param string $price A price value (as a string).
* @param ?string $currency_code Currency of the price.
*
* @return string Price value with currency code suffix if necessary.
*/
public static function get_explicit_price_with_currency( string $price, ?string $currency_code ) {
if ( false === static::should_output_explicit_price() ) {
return $price;
}
if ( empty( $currency_code ) ) {
return $price;
}
$price_to_check = html_entity_decode( wp_strip_all_tags( $price ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
if ( false === strpos( $price_to_check, trim( $currency_code ) ) ) {
return $price . ' ' . $currency_code;
}
return $price;
}
/**
* Alters the price formatting arguments to include explicit format
*
* @param array $args Price formatting args passed through `wc_price_args` filter.
*
* @return array The modified arguments
*/
public static function get_explicit_price_args( $args ) {
if ( false === static::should_output_explicit_price() ) {
return $args;
}
if ( false === strpos( $args['price_format'], $args['currency'] ) ) {
$args['price_format'] = sprintf( '%s&nbsp;%s', $args['price_format'], $args['currency'] );
}
return $args;
}
}
@@ -0,0 +1,394 @@
<?php
/**
* Class WC_Payments_Features
*
* @package WooCommerce\Payments
*/
use WCPay\Constants\Country_Code;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* WC Payments Features class
*/
class WC_Payments_Features {
/**
* If you need to remove or deprecate a flag:
* - Please update the `Erase_Deprecated_Flags_And_Options` migration with:
* - The next version of WooPayments.
* - The flag to be deleted.
*/
const WCPAY_SUBSCRIPTIONS_FLAG_NAME = '_wcpay_feature_subscriptions';
const STRIPE_BILLING_FLAG_NAME = '_wcpay_feature_stripe_billing';
const WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_express_checkout';
const WOOPAY_FIRST_PARTY_AUTH_FLAG_NAME = '_wcpay_feature_woopay_first_party_auth';
const WOOPAY_DIRECT_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_direct_checkout';
const AUTH_AND_CAPTURE_FLAG_NAME = '_wcpay_feature_auth_and_capture';
const DISPUTE_ISSUER_EVIDENCE = '_wcpay_feature_dispute_issuer_evidence';
const TOKENIZED_CART_ECE_FLAG_NAME = '_wcpay_feature_tokenized_cart_ece';
const PAYMENT_OVERVIEW_WIDGET_FLAG_NAME = '_wcpay_feature_payment_overview_widget';
const WOOPAY_GLOBAL_THEME_SUPPORT_FLAG_NAME = '_wcpay_feature_woopay_global_theme_support';
/**
* Indicates whether card payments are enabled for this (Stripe) account.
*
* @return bool True if account can accept card payments, false otherwise.
*/
public static function are_payments_enabled() {
$account = WC_Payments::get_database_cache()->get( WCPay\Database_Cache::ACCOUNT_KEY, true );
return is_array( $account ) && ( $account['payments_enabled'] ?? false );
}
/**
* Checks whether the "tokenized cart" feature for PRBs is enabled.
*
* @return bool
*/
public static function is_tokenized_cart_ece_enabled(): bool {
return '1' === get_option( self::TOKENIZED_CART_ECE_FLAG_NAME, '1' );
}
/**
* Checks if WooPay is enabled.
*
* @return bool
*/
public static function is_woopay_enabled() {
$is_woopay_eligible = self::is_woopay_eligible(); // Feature flag.
$is_woopay_enabled = 'yes' === WC_Payments::get_gateway()->get_option( 'platform_checkout' );
$is_woopay_express_button_enabled = self::is_woopay_express_checkout_enabled();
return $is_woopay_eligible && $is_woopay_enabled && $is_woopay_express_button_enabled;
}
/**
* Checks whether the customer Multi-Currency feature is enabled
*
* @return bool
*/
public static function is_customer_multi_currency_enabled() {
return '1' === get_option( '_wcpay_feature_customer_multi_currency', '1' );
}
/**
* Checks whether WCPay Subscriptions is enabled.
*
* @return bool
*/
public static function is_wcpay_subscriptions_enabled() {
// After completing the WooCommerce onboarding, check if the merchant has chosen Subscription product types and enable the feature flag.
if ( (bool) get_option( 'wcpay_check_subscriptions_eligibility_after_onboarding', false ) ) {
if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '7.9.0', '<' ) ) {
self::maybe_enable_wcpay_subscriptions_after_onboarding( [], get_option( 'woocommerce_onboarding_profile', [] ) );
}
delete_option( 'wcpay_check_subscriptions_eligibility_after_onboarding' );
}
return apply_filters( 'wcpay_is_wcpay_subscriptions_enabled', '1' === get_option( self::WCPAY_SUBSCRIPTIONS_FLAG_NAME, '0' ) );
}
/**
* Returns whether the store is eligible to use WCPay Subscriptions (the free subscriptions bundled in WooPayments)
*
* Stores are eligible for the WCPay Subscriptions feature if:
* 1. The store has existing WCPay Subscriptions, or
* 2. The store has Stripe Billing product metadata on at least 1 product subscription product.
*
* @return bool
*/
public static function is_wcpay_subscriptions_eligible() {
/**
* Check if they have at least 1 WCPay Subscription.
*
* Note: this is only possible if WCPay Subscriptions is enabled, otherwise the wcs_get_subscriptions function wouldn't exist.
*/
if ( function_exists( 'wcs_get_subscriptions' ) ) {
$wcpay_subscriptions = wcs_get_subscriptions(
[
'subscriptions_per_page' => 1,
'subscription_status' => 'any',
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
[
'key' => '_wcpay_subscription_id',
'compare' => 'EXISTS',
],
],
]
);
if ( ( is_countable( $wcpay_subscriptions ) ? count( $wcpay_subscriptions ) : 0 ) > 0 ) {
return true;
}
}
/**
* Check if they have at least 1 Stripe Billing enabled product.
*/
$stripe_billing_meta_query_handler = function ( $query, $query_vars ) {
if ( ! empty( $query_vars['stripe_billing_product'] ) ) {
$query['meta_query'][] = [
'key' => '_wcpay_product_hash',
'compare' => 'EXISTS',
];
}
return $query;
};
add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $stripe_billing_meta_query_handler, 10, 2 );
$subscription_products = wc_get_products(
[
'limit' => 1,
'type' => [ 'subscription', 'variable-subscription' ],
'status' => 'publish',
'return' => 'ids',
'stripe_billing_product' => 'true',
]
);
remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $stripe_billing_meta_query_handler, 10, 2 );
if ( ( is_countable( $subscription_products ) ? count( $subscription_products ) : 0 ) > 0 ) {
return true;
}
return false;
}
/**
* Checks whether the merchant has chosen Subscription product types during onboarding
* WooCommerce and is elible for WCPay Subscriptions, if so, enables the feature flag.
*
* @since 6.2.0
*
* @param array $onboarding_data Onboarding data.
* @param array $updated Updated onboarding settings.
*
* @return void
*/
public static function maybe_enable_wcpay_subscriptions_after_onboarding( $onboarding_data, $updated ) {
if ( empty( $updated['product_types'] ) || ! is_array( $updated['product_types'] ) || ! in_array( 'subscriptions', $updated['product_types'], true ) ) {
return;
}
if ( ! self::is_wcpay_subscriptions_eligible() ) {
return;
}
update_option( self::WCPAY_SUBSCRIPTIONS_FLAG_NAME, '1' );
}
/**
* Checks whether woopay is enabled.
*
* @return bool
*/
public static function is_woopay_eligible() {
// Checks for the dependency on Store API AbstractCartRoute.
if ( ! class_exists( 'Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute' ) ) {
return false;
}
// read directly from cache, ignore cache expiration check.
$account = WC_Payments::get_database_cache()->get( WCPay\Database_Cache::ACCOUNT_KEY, true );
$is_account_rejected = WC_Payments::get_account_service()->is_account_rejected();
$is_account_under_review = WC_Payments::get_account_service()->is_account_under_review();
return is_array( $account )
&& ( $account['platform_checkout_eligible'] ?? false )
&& ! $is_account_rejected
&& ! $is_account_under_review;
}
/**
* Checks whether documents section is enabled.
*
* @return bool
*/
public static function is_documents_section_enabled() {
$account = WC_Payments::get_database_cache()->get( WCPay\Database_Cache::ACCOUNT_KEY );
$is_documents_enabled = is_array( $account ) && ( $account['is_documents_enabled'] ?? false );
return '1' === get_option( '_wcpay_feature_documents', $is_documents_enabled ? '1' : '0' );
}
/**
* Checks whether WooPay Express Checkout is enabled.
*
* @return bool
*/
public static function is_woopay_express_checkout_enabled() {
// Confirm woopay eligibility as well.
return '1' === get_option( self::WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME, '1' ) && self::is_woopay_eligible();
}
/**
* Checks whether WooPay First Party Auth is enabled.
*
* @return bool
*/
public static function is_woopay_first_party_auth_enabled() {
return '1' === get_option( self::WOOPAY_FIRST_PARTY_AUTH_FLAG_NAME, '1' ) && self::is_woopay_express_checkout_enabled();
}
/**
* Checks whether Payment Overview Widget is enabled.
*
* @return bool
*/
public static function is_payment_overview_widget_ui_enabled(): bool {
return '1' === get_option( self::PAYMENT_OVERVIEW_WIDGET_FLAG_NAME, '0' );
}
/**
* Checks whether WooPay Direct Checkout is enabled.
*
* @return bool True if Direct Checkout is enabled, false otherwise.
*/
public static function is_woopay_direct_checkout_enabled() {
$account_cache = WC_Payments::get_database_cache()->get( WCPay\Database_Cache::ACCOUNT_KEY, true );
$is_direct_checkout_eligible = is_array( $account_cache ) && ( $account_cache['platform_direct_checkout_eligible'] ?? false );
$is_direct_checkout_flag_enabled = '1' === get_option( self::WOOPAY_DIRECT_CHECKOUT_FLAG_NAME, '1' );
return $is_direct_checkout_eligible && $is_direct_checkout_flag_enabled && self::is_woopayments_gateway_enabled() && self::is_woopay_enabled();
}
/**
* Checks whether WooPay global theme support is eligible.
*
* @return bool
*/
public static function is_woopay_global_theme_support_eligible() {
$account_cache = WC_Payments::get_database_cache()->get( WCPay\Database_Cache::ACCOUNT_KEY, true );
return is_array( $account_cache ) && ( $account_cache['platform_global_theme_support_enabled'] ?? false );
}
/**
* Checks whether Auth & Capture (uncaptured transactions tab, capture from payment details page) is enabled.
*
* @return bool
*/
public static function is_auth_and_capture_enabled() {
return '1' === get_option( self::AUTH_AND_CAPTURE_FLAG_NAME, '1' );
}
/**
* Checks whether the Fraud and Risk Tools feature flag is enabled.
*
* @return bool
*/
public static function is_frt_review_feature_active(): bool {
return '1' === get_option( 'wcpay_frt_review_feature_active', '0' );
}
/**
* Checks whether the Fraud and Risk Tools welcome tour was dismissed.
*
* @return bool
*/
public static function is_fraud_protection_welcome_tour_dismissed(): bool {
return '1' === get_option( 'wcpay_fraud_protection_welcome_tour_dismissed', '0' );
}
/**
* Checks whether the Stripe Billing feature is enabled.
*
* @return bool
*/
public static function is_stripe_billing_enabled(): bool {
return '1' === get_option( self::STRIPE_BILLING_FLAG_NAME, '0' );
}
/**
* Checks if the site is eligible for Stripe Billing.
*
* Only US merchants are eligible for Stripe Billing.
*
* @return bool
*/
public static function is_stripe_billing_eligible() {
if ( ! function_exists( 'wc_get_base_location' ) ) {
return false;
}
$store_base_location = wc_get_base_location();
return ! empty( $store_base_location['country'] ) && Country_Code::UNITED_STATES === $store_base_location['country'];
}
/**
* Checks whether the merchant is using WCPay Subscription or opted into Stripe Billing.
*
* Note: Stripe Billing is only used when the merchant is using WooCommerce Subscriptions and turned it on or is still using WCPay Subscriptions.
*
* @return bool
*/
public static function should_use_stripe_billing() {
// We intentionally check for the existence of the 'WC_Subscriptions' class here as we want to confirm the Plugin is active.
if ( self::is_wcpay_subscriptions_enabled() && ! class_exists( 'WC_Subscriptions' ) ) {
return true;
}
if ( self::is_stripe_billing_enabled() && class_exists( 'WC_Subscriptions' ) ) {
return true;
}
return false;
}
/**
* Checks whether Dispute issuer evidence feature should be enabled. Disabled by default.
*
* @return bool
*/
public static function is_dispute_issuer_evidence_enabled(): bool {
return '1' === get_option( self::DISPUTE_ISSUER_EVIDENCE, '0' );
}
/**
* Checks whether the next deposit notice on the deposits list screen has been dismissed.
*
* @return bool
*/
public static function is_next_deposit_notice_dismissed(): bool {
return '1' === get_option( 'wcpay_next_deposit_notice_dismissed', '0' );
}
/**
* Returns feature flags as an array suitable for display on the front-end.
*
* @return bool[]
*/
public static function to_array() {
return array_filter(
[
'multiCurrency' => self::is_customer_multi_currency_enabled(),
'woopay' => self::is_woopay_eligible(),
'documents' => self::is_documents_section_enabled(),
'woopayExpressCheckout' => self::is_woopay_express_checkout_enabled(),
'isAuthAndCaptureEnabled' => self::is_auth_and_capture_enabled(),
'isDisputeIssuerEvidenceEnabled' => self::is_dispute_issuer_evidence_enabled(),
'isPaymentOverviewWidgetEnabled' => self::is_payment_overview_widget_ui_enabled(),
]
);
}
/**
* Checks if WooCommerce Payments gateway is enabled.
*
* @return bool True if WooCommerce Payments gateway is enabled, false otherwise.
*/
private static function is_woopayments_gateway_enabled() {
$woopayments_settings = get_option( 'woocommerce_woocommerce_payments_settings' );
$woopayments_enabled_setting = $woopayments_settings['enabled'] ?? 'no';
return 'yes' === $woopayments_enabled_setting;
}
}
@@ -0,0 +1,35 @@
<?php
/**
* WC_Payments_File_Service class
*
* @package WooCommerce\Payments
*/
defined( 'ABSPATH' ) || exit;
/**
* Class which handles files.
*/
class WC_Payments_File_Service {
const FILE_PURPOSE_PUBLIC = [
'business_logo',
'business_icon',
];
const CACHE_KEY_PREFIX_PURPOSE = 'file_purpoose_';
const CACHE_PERIOD = 86400; // 24 h
/**
* Check if a file purpose is public or needs permissions.
*
* @param string $purpose - file purpose.
*
* @return bool
*/
public function is_file_public( string $purpose ): bool {
return in_array( $purpose, static::FILE_PURPOSE_PUBLIC, true );
}
}
@@ -0,0 +1,374 @@
<?php
/**
* WC_Payments_Fraud_Service class
*
* @package WooCommerce\Payments
*/
defined( 'ABSPATH' ) || exit;
use WCPay\Database_Cache;
use WCPay\Exceptions\API_Exception;
use WCPay\Logger;
/**
* Class which includes all the fraud-specific logic.
*/
class WC_Payments_Fraud_Service {
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* WC_Payments_Account instance to get information about the account
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_Payments_Customer instance for working with customer information
*
* @var WC_Payments_Customer_Service
*/
private $customer_service;
/**
* WC_Payments_Session_Service instance for working with session information
*
* @var WC_Payments_Session_Service
*/
private $session_service;
/**
* Cache util for managing the database cached data.
*
* @var Database_Cache
*/
private $database_cache;
/**
* Constructor for WC_Payments_Fraud_Service.
*
* @param WC_Payments_API_Client $payments_api_client - WooCommerce Payments API client.
* @param WC_Payments_Customer_Service $customer_service - Customer class instance.
* @param WC_Payments_Account $account - Account class instance.
* @param WC_Payments_Session_Service $session_service - Session Service class instance.
* @param Database_Cache $database_cache - Database cache instance.
*/
public function __construct(
WC_Payments_API_Client $payments_api_client,
WC_Payments_Customer_Service $customer_service,
WC_Payments_Account $account,
WC_Payments_Session_Service $session_service,
Database_Cache $database_cache
) {
$this->payments_api_client = $payments_api_client;
$this->customer_service = $customer_service;
$this->account = $account;
$this->session_service = $session_service;
$this->database_cache = $database_cache;
}
/**
* Initializes this class's hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'init', [ $this, 'link_session_if_user_just_logged_in' ] );
add_action( 'admin_print_footer_scripts', [ $this, 'add_sift_js_tracker_in_admin' ] );
}
/**
* Gets the various anti-fraud services that must be included on every WCPay-related page.
*
* @return array Assoc array. Each key is the slug of a fraud service that must be incorporated to every page.
* The value is a service-specific config for it.
*/
public function get_fraud_services_config(): array {
$raw_config = null;
// First, try to get the config from the account data.
// This config takes precedence since it can be merchant-specific.
// We expect this entry to contain everything needed for the fraud services to work.
$account = $this->account->get_cached_account_data();
if ( ! empty( $account ) && isset( $account['fraud_services'] ) ) {
$raw_config = $account['fraud_services'];
}
// If the fraud services config is not available in the account data, try to get it from the server.
// This is a public, merchant-agnostic config.
// We expect the server to provide everything needed for the fraud services to work.
// If we've been given an empty array, we respect that; so no empty checks.
if ( is_null( $raw_config ) ) {
$raw_config = $this->get_cached_fraud_services();
}
if ( is_null( $raw_config ) ) {
// This was the default before adding new anti-fraud providers, preserve backwards-compatibility.
$raw_config = [ 'stripe' => [] ];
}
$services_config = [];
foreach ( $raw_config as $service_id => $config ) {
if ( ! is_array( $config ) ) {
$config = [];
}
// Apply our internal logic before allowing others to have a say through filters.
$config = $this->prepare_fraud_config( $config, $service_id );
$services_config[ $service_id ] = apply_filters( 'wcpay_prepare_fraud_config', $config, $service_id );
}
return $services_config;
}
/**
* Check if the current user has just logged in,
* and sends that information to the server to link the current browser session with the user.
*
* Called after the WooCommerce session has been initialized.
*
* @return void
*
* @throws Exception In case the main gateway class has not been initialized yet.
* This means that the method is called before the `init` hook.
*/
public function link_session_if_user_just_logged_in() {
$wpcom_blog_id = $this->payments_api_client->get_blog_id();
if ( ! $wpcom_blog_id ) {
// Don't do anything if Jetpack hasn't been connected yet.
return;
}
if ( ! $this->session_service->user_just_logged_in() ) {
return;
}
$fraud_config = $this->get_fraud_services_config();
if ( ! isset( $fraud_config['sift'] ) ) {
// If Sift isn't enabled, we don't need to link the session.
return;
}
// The session changed during the current page load, for example if the user just logged in.
// In this case, send the old session's customer ID alongside the new user_id so SIFT can link them.
$customer_id = $this->customer_service->get_customer_id_by_user_id( get_current_user_id() );
if ( ! isset( $customer_id ) ) {
return;
}
try {
$this->session_service->link_current_session_to_customer( $customer_id );
} catch ( API_Exception $e ) {
Logger::log( '[Tracking] Error when linking session with user: ' . $e->getMessage() );
}
}
/**
* Adds the Sift JS page tracker in the WP admin area, if needed.
*
* @return void
*
* @throws Exception In case the main gateway class has not been initialized yet.
* This means that the method is called before the `init` hook.
*/
public function add_sift_js_tracker_in_admin() {
// If the current page is a WooPayments dashboard page bail as there's separate logic
// that will include the Sift JS tracker on its own.
if ( isset( $_GET['path'] ) && strpos( $_GET['path'], '/payments/' ) === 0 ) { // phpcs:ignore WordPress.Security
return;
}
// Bail if this is not a WooCommerce admin page.
if ( is_callable( '\Automattic\WooCommerce\Admin\PageController::is_admin_or_embed_page' )
&& ! \Automattic\WooCommerce\Admin\PageController::is_admin_or_embed_page() ) {
return;
}
// Bail if Sift is not enabled (either globally or for the current account).
$fraud_services_config = $this->get_fraud_services_config();
if ( ! isset( $fraud_services_config['sift'] ) ) {
return;
}
?>
<script type="text/javascript">
var src = 'https://cdn.sift.com/s.js';
var _sift = ( window._sift = window._sift || [] );
_sift.push( [ '_setAccount', '<?php echo esc_attr( $fraud_services_config['sift']['beacon_key'] ); ?>' ] );
_sift.push( [ '_setUserId', '<?php echo esc_attr( $fraud_services_config['sift']['user_id'] ); ?>' ] );
_sift.push( [ '_setSessionId', '<?php echo esc_attr( $fraud_services_config['sift']['session_id'] ); ?>' ] );
_sift.push( [ '_trackPageview' ] );
if ( ! document.querySelector( '[src="' + src + '"]' ) ) {
var script = document.createElement( 'script' );
script.src = src;
script.async = true;
document.body.appendChild( script );
}
</script>
<?php
}
/**
* Checks if the cached fraud services config can be used.
*
* @param bool|string|array $fraud_services_config The cached config.
*
* @return bool True if the cached fraud services config is valid.
*/
public function is_valid_cached_fraud_services( $fraud_services_config ): bool {
// null/false means no config has been cached.
if ( null === $fraud_services_config || false === $fraud_services_config ) {
return false;
}
// Non-array values are not expected.
if ( ! is_array( $fraud_services_config ) ) {
return false;
}
return true;
}
/**
* Gets and caches the public fraud services config.
*
* @return array|null Fraud services config or null if failed to retrieve fraud services config.
*/
private function get_cached_fraud_services(): ?array {
return $this->database_cache->get_or_add(
Database_Cache::FRAUD_SERVICES_KEY,
function () {
$fraud_services = $this->fetch_public_fraud_services_config();
if ( ! $this->is_valid_cached_fraud_services( $fraud_services ) ) {
return false;
}
// Sanitize the config just to be safe by applying a sweeping `sanitize_text_field` on all the data.
// This is OK to do since we are not accepting data entries with HTML.
return WC_Payments_Utils::array_map_recursive(
$fraud_services,
function ( $value ) {
// Only apply `sanitize_text_field()` to string values since this function will cast to string.
if ( is_string( $value ) ) {
return sanitize_text_field( $value );
}
return $value;
}
);
},
[ $this, 'is_valid_cached_fraud_services' ]
);
}
/**
* Prepares the fraud config for a service.
*
* @param array $config Existing config data for the given anti-fraud service.
* @param string $service_id Identifier of the anti-fraud service provider.
*
* @return array|NULL Array with all the required data to initialize the anti-fraud script, or NULL if the service shouldn't be used.
*/
private function prepare_fraud_config( array $config, string $service_id ): ?array {
switch ( $service_id ) {
case 'sift':
return $this->prepare_sift_config( $config );
}
return $config;
}
/**
* Adds site-specific config needed to initialize the SIFT anti-fraud JS.
*
* @param array $config Associative array with the SIFT-related configuration returned from the server.
*
* @return array|null Assoc array, ready for the client to consume, or NULL if the client shouldn't enqueue this script.
*/
private function prepare_sift_config( array $config ): ?array {
$test_mode = false;
try {
$test_mode = WC_Payments::mode()->is_test();
} catch ( Exception $e ) {
Logger::log( sprintf( 'WooPayments JS settings: Could not determine if WCPay should be in test mode! Message: %s', $e->getMessage() ), 'warning' );
}
// The server returns both production and sandbox beacon keys. Use the sandbox one if test mode is enabled.
if ( $test_mode && isset( $config['sandbox_beacon_key'] ) ) {
$config['beacon_key'] = $config['sandbox_beacon_key'];
}
unset( $config['sandbox_beacon_key'] );
$config['user_id'] = $this->get_sift_user_id();
$config['session_id'] = $this->session_service->get_sift_session_id();
return $config;
}
/**
* Get the Sift user ID to use for the current request.
*
* @return string The Sift user ID to use for the current request. Empty string if we couldn't determine one.
*/
private function get_sift_user_id(): string {
$user_id = '';
if ( ! is_user_logged_in() ) {
return $user_id;
}
if ( is_admin() ) {
// In the WP admin we deal with merchant accounts, not customers.
$user_id = $this->account->get_stripe_account_id() ?? '';
} else {
$customer_id = $this->customer_service->get_customer_id_by_user_id( get_current_user_id() );
if ( isset( $customer_id ) ) {
$user_id = $customer_id;
}
}
return $user_id;
}
/**
* Fetches public fraud services config from the server.
*
* @return array|null Fraud services config or null.
*/
private function fetch_public_fraud_services_config(): ?array {
// Build the endpoint URL.
$url = WC_Payments_API_Client::ENDPOINT_BASE . '/' . WC_Payments_API_Client::ENDPOINT_REST_BASE . '/' . WC_Payments_API_Client::FRAUD_SERVICES_API;
$response = wp_remote_get(
$url,
[
'user-agent' => 'WCPay/' . WCPAY_VERSION_NUMBER . '; ' . get_bloginfo( 'url' ),
]
);
// Return early if there is an error.
if ( is_wp_error( $response ) ) {
return null;
}
$config = null;
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
// Decode the results, falling back to an empty array.
$config = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $config ) || ! is_array( $config ) ) {
$config = null;
}
}
return $config;
}
}
@@ -0,0 +1,354 @@
<?php
/**
* Class WC_Payments_Incentives_Service
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Database_Cache;
/**
* Class handling onboarding related business logic.
*/
class WC_Payments_Incentives_Service {
/**
* Cache util for managing onboarding data.
*
* @var Database_Cache
*/
private $database_cache;
/**
* Class constructor
*
* @param Database_Cache $database_cache Database cache util.
*/
public function __construct( Database_Cache $database_cache ) {
$this->database_cache = $database_cache;
}
/**
* Initialise class hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'admin_menu', [ $this, 'add_payments_menu_badge' ] );
add_filter( 'woocommerce_admin_allowed_promo_notes', [ $this, 'allowed_promo_notes' ] );
add_filter( 'woocommerce_admin_woopayments_onboarding_task_badge', [ $this, 'onboarding_task_badge' ] );
add_filter( 'woocommerce_admin_woopayments_onboarding_task_additional_data', [ $this, 'onboarding_task_additional_data' ], 20 );
}
/**
* Add badge to payments menu if there is an eligible incentive.
*
* @return void
*/
public function add_payments_menu_badge(): void {
global $menu;
if ( ! $this->get_cached_connect_incentive() ) {
return;
}
$badge = WC_Payments_Admin::MENU_NOTIFICATION_BADGE;
foreach ( $menu as $index => $menu_item ) {
if ( false === strpos( $menu_item[0], $badge ) && ( 'wc-admin&path=/payments/connect' === $menu_item[2] ) ) {
$menu[ $index ][0] .= $badge; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// One menu item with a badge is more than enough.
break;
}
}
}
/**
* Adds allowed promo notes from eligible incentive.
*
* @param array $promo_notes Current allowed promo notes.
* @return array Updated allowed promo notes.
*/
public function allowed_promo_notes( $promo_notes = [] ): array {
$incentive = $this->get_cached_connect_incentive();
// Return early if there is no eligible incentive.
if ( empty( $incentive['id'] ) ) {
return $promo_notes;
}
/**
* Suppress psalm error because we already check that `id` exists in `is_valid_cached_incentive`.
*
* @psalm-suppress PossiblyNullArrayAccess */
$promo_notes[] = $incentive['id'];
return $promo_notes;
}
/**
* Adds the WooPayments incentive badge to the onboarding task.
*
* @param string $badge Current badge.
*
* @return string
*/
public function onboarding_task_badge( string $badge ): string {
$incentive = $this->get_cached_connect_incentive();
// Return early if there is no eligible incentive.
if ( empty( $incentive['id'] ) ) {
return $badge;
}
return $incentive['task_badge'] ?? $badge;
}
/**
* Filter the onboarding task additional data to add the WooPayments incentive data to it.
*
* @param ?array $additional_data The current task additional data.
*
* @return ?array The filtered task additional data.
*/
public function onboarding_task_additional_data( ?array $additional_data ): ?array {
$incentive = $this->get_cached_connect_incentive();
// Return early if there is no eligible incentive.
if ( empty( $incentive['id'] ) ) {
return $additional_data;
}
if ( empty( $additional_data ) ) {
$additional_data = [];
}
$additional_data['wooPaymentsIncentiveId'] = $incentive['id'];
return $additional_data;
}
/**
* Gets and caches eligible connect incentive from the server.
*
* @param bool $force_refresh Forces data to be fetched from the server, rather than using the cache.
*
* @return array|null List of incentives or null.
*/
public function get_cached_connect_incentive( bool $force_refresh = false ): ?array {
// Return early if there is an account connected.
if ( WC_Payments::get_account_service()->is_stripe_connected() ) {
return null;
}
// Return early if the country is not supported.
if ( ! array_key_exists( WC()->countries->get_base_country(), WC_Payments_Utils::supported_countries() ) ) {
return null;
}
// Fingerprint the store context through a hash of certain entries.
$store_context_hash = $this->generate_context_hash( $this->get_store_context() );
// First, get the cache contents, if any.
$incentive_data = $this->database_cache->get( Database_Cache::CONNECT_INCENTIVE_KEY, true );
// Check if we need to force-refresh the cache contents.
if ( empty( $incentive_data['context_hash'] ) || ! is_string( $incentive_data['context_hash'] )
|| ! hash_equals( $store_context_hash, $incentive_data['context_hash'] ) ) {
// No cache, the hash is missing, or it doesn't match. Force refresh.
$incentive_data = $this->database_cache->get_or_add(
Database_Cache::CONNECT_INCENTIVE_KEY,
[ $this, 'fetch_connect_incentive_details' ],
'is_array',
true
);
}
if ( ! $this->is_valid_cached_incentive( $incentive_data ) ) {
return null;
}
return $incentive_data['incentive'];
}
/**
* Fetches eligible connect incentive details from the server.
*
* @return array|null Array of eligible incentive data or null.
*/
public function fetch_connect_incentive_details(): ?array {
$store_context = $this->get_store_context();
// Request incentive from WCPAY API.
$url = add_query_arg(
$store_context,
'https://public-api.wordpress.com/wpcom/v2/wcpay/incentives'
);
$response = wp_remote_get(
$url,
[
'user-agent' => 'WCPay/' . WCPAY_VERSION_NUMBER . '; ' . get_bloginfo( 'url' ),
]
);
// Return early if there is an error.
if ( is_wp_error( $response ) ) {
return null;
}
$incentive = [];
if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
// Decode the results, falling back to an empty array.
$results = json_decode( wp_remote_retrieve_body( $response ), true );
// Find a `connect_page` incentive.
if ( ! empty( $results ) ) {
$incentive = array_filter(
$results,
function ( array $incentive ) {
return isset( $incentive['type'] ) && 'connect_page' === $incentive['type'];
}
)[0] ?? [];
}
}
// Read TTL form the `cache-for` header, or default to 1 day.
$cache_for = wp_remote_retrieve_header( $response, 'cache-for' );
$incentive_data = [
'incentive' => $incentive,
];
// Set the Time-To-Live for the incentive data.
if ( '' !== $cache_for ) {
$incentive_data['ttl'] = (int) $cache_for;
} else {
$incentive_data['ttl'] = DAY_IN_SECONDS;
}
// Attach the context hash to the incentive data.
$incentive_data['context_hash'] = $this->generate_context_hash( $store_context );
return $incentive_data;
}
/**
* Check whether the incentive data fetched from the cache are valid.
* Expects an array with an `incentive` entry that is an array with at least `id`, `description`, and `tc_url` keys.
*
* @param mixed $incentive_data The incentive data returned from the cache.
*
* @return bool Whether the incentive data is valid.
*/
public function is_valid_cached_incentive( $incentive_data ): bool {
if ( ! is_array( $incentive_data )
|| empty( $incentive_data['incentive'] )
|| ! is_array( $incentive_data['incentive'] )
|| ! isset( $incentive_data['incentive']['id'] )
|| ! isset( $incentive_data['incentive']['description'] )
|| ! isset( $incentive_data['incentive']['tc_url'] ) ) {
return false;
}
return true;
}
/**
* Check if the WooPayments payment gateway is active and set up,
* or there are orders processed with it, at some moment.
*
* @return boolean
*/
private function has_wcpay(): bool {
// We consider the store to have WooPayments if there is meaningful account data in the WooPayments account cache.
// This implies that WooPayments is or was active at some point and that it was connected.
if ( $this->has_wcpay_account_data() ) {
return true;
}
// If there is at least one order processed with WooPayments, we consider the store to have WooPayments.
if ( ! empty(
wc_get_orders(
[
'payment_method' => 'woocommerce_payments',
'return' => 'ids',
'limit' => 1,
]
)
) ) {
return true;
}
return false;
}
/**
* Check if there is meaningful data in the WooPayments account cache.
*
* @return boolean
*/
private function has_wcpay_account_data(): bool {
$account_data = $this->database_cache->get( Database_Cache::ACCOUNT_KEY );
if ( ! empty( $account_data['account_id'] ) ) {
return true;
}
return false;
}
/**
* Get the store context to be used in determining eligibility.
*
* @return array The store context.
*/
private function get_store_context(): array {
return [
// Store ISO-2 country code, e.g. `US`.
'country' => WC()->countries->get_base_country(),
// Store locale, e.g. `en_US`.
'locale' => get_locale(),
// WooCommerce active for duration in seconds.
'active_for' => time() - get_option( 'woocommerce_admin_install_timestamp', time() ),
// Whether the store has paid orders in the last 90 days.
'has_orders' => ! empty(
wc_get_orders(
[
'status' => [ 'wc-completed', 'wc-processing' ],
'date_created' => '>=' . strtotime( '-90 days' ),
'return' => 'ids',
'limit' => 1,
]
)
),
// Whether the store has at least one payment gateway enabled.
'has_payments' => ! empty( WC()->payment_gateways()->get_available_payment_gateways() ),
'has_wcpay' => $this->has_wcpay(),
];
}
/**
* Generate a hash from the store context data.
*
* @param array $context The store context data.
*
* @return string The context hash.
*/
private function generate_context_hash( array $context ): string {
// Include only certain entries in the context hash.
// We need only discrete, user-interaction dependent data.
// Entries like `active_for` have no place in the hash generation since they change automatically.
return md5(
wp_json_encode(
[
'country' => $context['country'] ?? '',
'locale' => $context['locale'] ?? '',
'has_orders' => $context['has_orders'] ?? false,
'has_payments' => $context['has_payments'] ?? false,
'has_wcpay' => $context['has_wcpay'] ?? false,
]
)
);
}
}
@@ -0,0 +1,148 @@
<?php
/**
* WooCommerce Payments WC_Payments_Localization_Service Class
*
* @package WooCommerce\Payments
*/
use WCPay\MultiCurrency\Interfaces\MultiCurrencyLocalizationInterface;
defined( 'ABSPATH' ) || exit;
/**
* WC_Payments_Localization_Service.
*/
class WC_Payments_Localization_Service implements MultiCurrencyLocalizationInterface {
const WCPAY_CURRENCY_FORMAT_TRANSIENT = 'wcpay_currency_format';
const WCPAY_LOCALE_INFO_TRANSIENT = 'wcpay_locale_info';
/**
* Currency formatting map.
*
* @var array
*/
protected $currency_format = [];
/**
* Currency locale info.
*
* @var array
*/
protected $locale_info = [];
/**
* Constructor.
*/
public function __construct() {
$this->load_locale_data();
}
/**
* Retrieves the currency's format from mapped data.
*
* @param string $currency_code The currency code.
*
* @return array The currency's format.
*/
public function get_currency_format( $currency_code ): array {
// Default to USD settings if mapping not found.
$currency_format = [
'currency_pos' => 'left',
'thousand_sep' => ',',
'decimal_sep' => '.',
'num_decimals' => 2,
];
$locale = $this->get_user_locale();
$currency_options = $this->currency_format[ $currency_code ] ?? null;
if ( $currency_options ) {
// If there's no locale-specific formatting, default to the 'default' entry in the array.
$currency_format = $currency_options[ $locale ] ?? $currency_options['default'] ?? $currency_format;
}
/**
* Filter to edit formatting for a specific currency (wcpay_{currency_code}_format).
*
* This filter can be used to override the currency format for a specific currency.
* The currency code in the filter name should be used in lowercase.
*
* @since 2.8.0
*
* @param array $currency_format The currency format settings.
* @param string $locale The user's locale.
*/
return apply_filters( 'wcpay_' . strtolower( $currency_code ) . '_format', $currency_format, $locale );
}
/**
* Returns the user locale.
*
* @return string The locale.
*/
public function get_user_locale(): string {
return get_user_locale();
}
// TODO: Add tests.
/**
* Returns the locale data for a country.
*
* @param string $country Country code.
*
* @return array Array with the country's locale data. Empty array if country not found.
*/
public function get_country_locale_data( $country ): array {
return $this->locale_info[ $country ] ?? [];
}
/**
* Loads locale data from WooCommerce core (/i18n/locale-info.php) and maps it
* to be used by currency.
*
* @return void
*/
private function load_locale_data() {
$transient_currency_format_data = get_transient( self::WCPAY_CURRENCY_FORMAT_TRANSIENT );
$transient_locale_info_data = get_transient( self::WCPAY_LOCALE_INFO_TRANSIENT );
if ( $transient_currency_format_data && $transient_locale_info_data ) {
$this->currency_format = $transient_currency_format_data;
$this->locale_info = $transient_locale_info_data;
return;
}
$locale_info_path = WC()->plugin_path() . '/i18n/locale-info.php';
// The full locale data was introduced in the currency-info.php file.
// If it doesn't exist we have to use the fallback.
if ( ! file_exists( WC()->plugin_path() . '/i18n/currency-info.php' ) ) {
$locale_info_path = WCPAY_ABSPATH . 'i18n/locale-info.php';
}
$this->locale_info = include $locale_info_path;
if ( is_array( $this->locale_info ) && 0 < count( $this->locale_info ) ) {
// Extract the currency formatting options from the locale info.
foreach ( $this->locale_info as $country_data ) {
$currency_code = $country_data['currency_code'];
foreach ( $country_data['locales'] as $locale => $locale_data ) {
if ( empty( $locale_data ) ) {
continue;
}
$this->currency_format[ $currency_code ][ $locale ] = [
'currency_pos' => $locale_data['currency_pos'],
'thousand_sep' => $locale_data['thousand_sep'],
'decimal_sep' => $locale_data['decimal_sep'],
'num_decimals' => $country_data['num_decimals'],
];
}
}
set_transient( self::WCPAY_CURRENCY_FORMAT_TRANSIENT, $this->currency_format, DAY_IN_SECONDS );
set_transient( self::WCPAY_LOCALE_INFO_TRANSIENT, $this->locale_info, DAY_IN_SECONDS );
}
}
}
@@ -0,0 +1,303 @@
<?php
/**
* Class WC_Payments_Order_Success_Page
*
* @package WooCommerce\Payments
*/
use WCPay\Constants\Payment_Method;
use WCPay\Duplicate_Payment_Prevention_Service;
/**
* Class handling order success page.
*/
class WC_Payments_Order_Success_Page {
/**
* Constructor.
*/
public function __construct() {
add_filter( 'woocommerce_order_received_verify_known_shoppers', [ $this, 'determine_woopay_order_received_verify_known_shoppers' ], 11 );
add_action( 'woocommerce_before_thankyou', [ $this, 'register_payment_method_override' ] );
add_action( 'woocommerce_order_details_before_order_table', [ $this, 'unregister_payment_method_override' ] );
add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'add_notice_previous_paid_order' ], 11 );
add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'add_notice_previous_successful_intent' ], 11 );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
}
/**
* Register the hook to override the payment method name on the order received page.
*/
public function register_payment_method_override() {
// Override the payment method title on the order received page.
add_filter( 'woocommerce_order_get_payment_method_title', [ $this, 'show_woocommerce_payments_payment_method_name' ], 10, 2 );
}
/**
* Remove the hook to override the payment method name on the order received page before the order summary.
*/
public function unregister_payment_method_override() {
remove_filter( 'woocommerce_order_get_payment_method_title', [ $this, 'show_woocommerce_payments_payment_method_name' ], 10 );
}
/**
* Hooked into `woocommerce_order_get_payment_method_title` to change the payment method title on the
* order received page for WooPay and BNPL orders.
*
* @param string $payment_method_title Original payment method title.
* @param WC_Abstract_Order $abstract_order Successful received order being shown.
* @return string
*/
public function show_woocommerce_payments_payment_method_name( $payment_method_title, $abstract_order ) {
// Only change the payment method title on the order received page.
if ( ! is_order_received_page() ) {
return $payment_method_title;
}
$order_id = $abstract_order->get_id();
$order = wc_get_order( $order_id );
if ( ! $order ) {
return $payment_method_title;
}
$payment_method_id = $order->get_payment_method();
if ( stripos( $payment_method_id, 'woocommerce_payments' ) !== 0 ) {
return $payment_method_title;
}
// If this is a WooPay order, return the html for the WooPay payment method name.
if ( $order->get_meta( 'is_woopay' ) ) {
return $this->show_woopay_payment_method_name( $order );
}
$gateway = WC()->payment_gateways()->payment_gateways()[ $payment_method_id ];
if ( ! is_object( $gateway ) || ! method_exists( $gateway, 'get_payment_method' ) ) {
return $payment_method_title;
}
$payment_method = $gateway->get_payment_method( $order );
// GooglePay/ApplePay/Link/Card to be supported later.
if ( $payment_method->get_id() === Payment_Method::CARD ) {
return $this->show_card_payment_method_name( $order, $payment_method );
}
// If this is an LPM (BNPL or local payment method) order, return the html for the payment method name.
$name_output = $this->show_lpm_payment_method_name( $gateway, $payment_method );
if ( false !== $name_output ) {
return $name_output;
}
return $payment_method_title;
}
/**
* Returns the HTML to add the card brand logo and the last 4 digits of the card used to the
* payment method name on the order received page.
*
* @param WC_Order $order the order being shown.
* @param WCPay\Payment_Methods\UPE_Payment_Method $payment_method the payment method being shown.
*
* @return string
*/
public function show_card_payment_method_name( $order, $payment_method ) {
$card_brand = $order->get_meta( '_card_brand' );
if ( ! $card_brand ) {
return $payment_method->get_title();
}
ob_start();
?>
<div class="wc-payment-gateway-method-logo-wrapper wc-payment-card-logo">
<img alt="<?php echo esc_attr( $payment_method->get_title() ); ?>" src="<?php echo esc_url_raw( plugins_url( "assets/images/cards/{$card_brand}.svg", WCPAY_PLUGIN_FILE ) ); ?>">
<?php
if ( $order->get_meta( 'last4' ) ) {
echo esc_html_e( '•••', 'woocommerce-payments' ) . ' ';
echo esc_html( $order->get_meta( 'last4' ) );
}
?>
</div>
<?php
return ob_get_clean();
}
/**
* Returns the HTML to add the WooPay logo and the last 4 digits of the card used to the
* payment method name on the order received page.
*
* @param WC_Order $order the order being shown.
*
* @return string
*/
public function show_woopay_payment_method_name( $order ) {
ob_start();
?>
<div class="wc-payment-gateway-method-logo-wrapper woopay">
<img alt="WooPay" src="<?php echo esc_url_raw( plugins_url( 'assets/images/woopay.svg', WCPAY_PLUGIN_FILE ) ); ?>">
<?php
if ( $order->get_meta( 'last4' ) ) {
echo esc_html_e( 'Card ending in', 'woocommerce-payments' ) . ' ';
echo esc_html( $order->get_meta( 'last4' ) );
}
?>
</div>
<?php
return ob_get_clean();
}
/**
* Add the LPM logo to the payment method name on the order received page.
*
* @param WC_Payment_Gateway_WCPay $gateway the gateway being shown.
* @param WCPay\Payment_Methods\UPE_Payment_Method $payment_method the payment method being shown.
*
* @return string|false
*/
public function show_lpm_payment_method_name( $gateway, $payment_method ) {
$method_logo_url = apply_filters_deprecated(
'wc_payments_thank_you_page_bnpl_payment_method_logo_url',
[
$payment_method->get_payment_method_icon_for_location( 'checkout', false, $gateway->get_account_country() ),
$payment_method->get_id(),
],
'8.5.0',
'wc_payments_thank_you_page_lpm_payment_method_logo_url'
);
$method_logo_url = apply_filters(
'wc_payments_thank_you_page_lpm_payment_method_logo_url',
$method_logo_url,
$payment_method->get_id()
);
// If we don't have a logo URL here for some reason, bail.
if ( ! $method_logo_url ) {
return false;
}
ob_start();
?>
<div class="wc-payment-gateway-method-logo-wrapper wc-payment-lpm-logo wc-payment-lpm-logo--<?php echo esc_attr( $payment_method->get_id() ); ?>">
<img alt="<?php echo esc_attr( $payment_method->get_title() ); ?>" src="<?php echo esc_url_raw( $method_logo_url ); ?>">
</div>
<?php
return ob_get_clean();
}
/**
* Add the notice to the thank you page in case a recent order with the same content has already paid.
*
* @param string $text the default thank you text.
*
* @return string
*/
public function add_notice_previous_paid_order( $text ) {
if ( isset( $_GET[ Duplicate_Payment_Prevention_Service::FLAG_PREVIOUS_ORDER_PAID ] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
$text .= $this->format_addtional_thankyou_order_received_text(
__( 'We detected and prevented an attempt to pay for a duplicate order. If this was a mistake and you wish to try again, please create a new order.', 'woocommerce-payments' )
);
}
return $text;
}
/**
* Add the notice to the thank you page in case an existing intention was successful for the order.
*
* @param string $text the default thank you text.
*
* @return string
*/
public function add_notice_previous_successful_intent( $text ) {
if ( isset( $_GET[ Duplicate_Payment_Prevention_Service::FLAG_PREVIOUS_SUCCESSFUL_INTENT ] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
$text .= $this->format_addtional_thankyou_order_received_text(
__( 'We prevented multiple payments for the same order. If this was a mistake and you wish to try again, please create a new order.', 'woocommerce-payments' )
);
}
return $text;
}
/**
* Formats the additional text to be displayed on the thank you page, with the side effect
* as a workaround for an issue in Woo core 8.1.x and 8.2.x.
*
* @param string $additional_text The additional text to be displayed.
*
* @return string Formatted text.
*/
private function format_addtional_thankyou_order_received_text( string $additional_text ): string {
/**
* This condition is a workaround for Woo core 8.1.x and 8.2.x as it formatted the filtered text,
* while it should format the original text only.
*
* It's safe to remove this conditional when WooPayments requires Woo core 8.3.x or higher.
*
* @see https://github.com/woocommerce/woocommerce/pull/39758 Introduce the issue since 8.1.0.
* @see https://github.com/woocommerce/woocommerce/pull/40353 Fix the issue since 8.3.0.
*/
if ( version_compare( WC_VERSION, '8.0', '>' )
&& version_compare( WC_VERSION, '8.3', '<' )
) {
echo "
<script type='text/javascript'>
document.querySelector('.woocommerce-thankyou-order-received')?.classList?.add('woocommerce-info');
</script>
";
return ' ' . $additional_text;
}
return sprintf( '<div class="woocommerce-info">%s</div>', $additional_text );
}
/**
* Enqueue style to the order success page
*/
public function enqueue_scripts() {
if ( ! is_order_received_page() ) {
return;
}
WC_Payments_Utils::enqueue_style(
'wcpay-success-css',
plugins_url( 'assets/css/success.css', WCPAY_PLUGIN_FILE ),
[],
WC_Payments::get_file_version( 'assets/css/success.css' ),
'all',
);
}
/**
* Make sure we show the TYP page for orders paid with WooPay
* that create new user accounts, code mainly copied from
* WooCommerce WC_Shortcode_Checkout::order_received and
* WC_Shortcode_Checkout::guest_should_verify_email.
*
* @param bool $value The current value for this filter.
*/
public function determine_woopay_order_received_verify_known_shoppers( $value ) {
global $wp;
$order_id = $wp->query_vars['order-received'];
$order_key = apply_filters( 'woocommerce_thankyou_order_key', empty( $_GET['key'] ) ? '' : wc_clean( wp_unslash( $_GET['key'] ) ) );
$order = wc_get_order( $order_id );
if ( ( ! $order instanceof WC_Order ) || ! $order->get_meta( 'is_woopay' ) || ! hash_equals( $order->get_order_key(), $order_key ) ) {
return $value;
}
$verification_grace_period = (int) apply_filters( 'woocommerce_order_email_verification_grace_period', 10 * MINUTE_IN_SECONDS, $order );
$date_created = $order->get_date_created();
// We do not need to verify the email address if we are within the grace period immediately following order creation.
$is_within_grace_period = is_a( $date_created, \WC_DateTime::class, true )
&& time() - $date_created->getTimestamp() <= $verification_grace_period;
return ! $is_within_grace_period;
}
}
@@ -0,0 +1,172 @@
<?php
/**
* Class WC_Payments_Payment_Method_Messaging_Element
*
* @package WooCommerce\Payments
*/
use WCPay\Constants\Payment_Method;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC_Payments_Payment_Method_Messaging_Element class.
*/
class WC_Payments_Payment_Method_Messaging_Element {
/**
* WC_Payments_Account instance to get information about the account.
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_Payments_Gateway instance to get information about the enabled payment methods.
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* WC_Payments_Payment_Method_Messaging_Element constructor
*
* @param WC_Payments_Account $account Account instance.
* @param WC_Payment_Gateway_WCPay $gateway Gateway instance.
* @return void
*/
public function __construct( WC_Payments_Account $account, WC_Payment_Gateway_WCPay $gateway ) {
$this->account = $account;
$this->gateway = $gateway;
}
/**
* Initializes the payment method messaging element.
*
* @return string|void The HTML markup for the payment method message container.
*/
public function init() {
$is_cart_block = WC_Payments_Utils::is_cart_block();
if ( ! is_product() && ! is_cart() && ! $is_cart_block ) {
return;
}
global $product;
$currency_code = get_woocommerce_currency();
$store_country = WC()->countries->get_base_country();
$billing_country = WC()->customer->get_billing_country();
$cart_total = WC()->cart->total;
$product_variations = [];
if ( $product ) {
$get_price_fn = function ( $product ) {
return $product->get_price();
};
if ( wc_tax_enabled() && $product->is_taxable() ) {
if (
wc_prices_include_tax() &&
(
get_option( 'woocommerce_tax_display_shop' ) !== 'incl' ||
WC()->customer->get_is_vat_exempt()
)
) {
$get_price_fn = function ( $product ) {
return wc_get_price_excluding_tax( $product );
};
} elseif (
get_option( 'woocommerce_tax_display_shop' ) === 'incl'
&& ! WC()->customer->get_is_vat_exempt()
) {
$get_price_fn = function ( $product ) {
return wc_get_price_including_tax( $product );
};
}
}
$price = $get_price_fn( $product );
$product_variations = [
'base_product' => [
'amount' => WC_Payments_Utils::prepare_amount( $price, $currency_code ),
'currency' => $currency_code,
],
];
$product_price = $product_variations['base_product']['amount'];
foreach ( $product->get_children() as $variation_id ) {
$variation = wc_get_product( $variation_id );
if ( $variation ) {
$price = $get_price_fn( $variation );
$product_variations[ $variation_id ] = [
'amount' => WC_Payments_Utils::prepare_amount( $price, $currency_code ),
'currency' => $currency_code,
];
$product_price = $product_variations['base_product']['amount'];
}
}
}
$enabled_upe_payment_methods = $this->gateway->get_upe_enabled_payment_method_ids();
// Filter non BNPL out of the list of payment methods.
$bnpl_payment_methods = array_intersect( $enabled_upe_payment_methods, Payment_Method::BNPL_PAYMENT_METHODS );
// register the script.
WC_Payments::register_script_with_dependencies( 'WCPAY_PRODUCT_DETAILS', 'dist/product-details', [ 'stripe' ] );
wp_enqueue_script( 'WCPAY_PRODUCT_DETAILS' );
// Enqueue the styles.
wp_enqueue_style(
'wcpay-product-details',
plugins_url( 'dist/product-details.css', WCPAY_PLUGIN_FILE ),
[],
WC_Payments::get_file_version( 'dist/product-details.css' ),
);
$country = empty( $billing_country ) ? $store_country : $billing_country;
$script_data = [
'productId' => 'base_product',
'productVariations' => $product_variations,
'country' => $country,
'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ),
'accountId' => $this->account->get_stripe_account_id(),
'publishableKey' => $this->account->get_publishable_key( WC_Payments::mode()->is_test() ),
'paymentMethods' => array_values( $bnpl_payment_methods ),
'currencyCode' => $currency_code,
'isCart' => is_cart(),
'isCartBlock' => $is_cart_block,
'cartTotal' => WC_Payments_Utils::prepare_amount( $cart_total, $currency_code ),
'nonce' => [
'get_cart_total' => wp_create_nonce( 'wcpay-get-cart-total' ),
'is_bnpl_available' => wp_create_nonce( 'wcpay-is-bnpl-available' ),
],
'wcAjaxUrl' => WC_AJAX::get_endpoint( '%%endpoint%%' ),
];
if ( $product ) {
$script_data['isBnplAvailable'] = WC_Payments_Utils::is_any_bnpl_method_available( array_values( $bnpl_payment_methods ), $country, $currency_code, $product_price );
}
// Create script tag with config.
wp_localize_script(
'WCPAY_PRODUCT_DETAILS',
'wcpayStripeSiteMessaging',
$script_data
);
// Ensure wcpayConfig is available in the page.
$wcpay_config = rawurlencode( wp_json_encode( WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config() ) );
wp_add_inline_script(
'WCPAY_PRODUCT_DETAILS',
"
var wcpayConfig = wcpayConfig || JSON.parse( decodeURIComponent( '" . esc_js( $wcpay_config ) . "' ) );
",
'before'
);
if ( ! $is_cart_block ) {
return '<div id="payment-method-message"></div>';
}
}
}
@@ -0,0 +1,204 @@
<?php
/**
* Class WC_Payments_Payment_Request_Session_Handler
*
* @package WooCommerce\Payments
*/
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
/**
* WC_Payments_Payment_Request_Session_Handler class
*/
final class WC_Payments_Payment_Request_Session_Handler extends WC_Session_Handler {
/**
* Token from HTTP headers.
*
* @var string
*/
protected $token;
/**
* The session id to reference in the sessions table.
*
* @var string
*/
public $session_id;
/**
* Expiration timestamp.
*
* @var int
*/
protected $session_expiration;
/**
* Constructor for the session class.
*/
public function __construct() {
parent::__construct();
$this->token = wc_clean( wp_unslash( $_SERVER['HTTP_X_WOOPAYMENTS_TOKENIZED_CART_SESSION'] ?? '' ) );
}
/**
* Init hooks and session data.
*
* @throws Exception On possible token mismatch.
*/
public function init() {
$this->init_session_cookie();
if ( $this->_customer_id !== $this->_data['token_customer_id'] ) {
throw new Exception( __( 'Invalid token: cookie and session customer mismatch', 'woocommerce-payments' ) );
}
add_action( 'shutdown', [ $this, 'save_data' ], 20 );
}
/**
* Setup cookie and customer ID.
* We need to ensure that we _also_ call `init_session_from_token` when `init_session_cookie` is called.
* Otherwise, this clears everything: https://github.com/woocommerce/woocommerce/blob/de4a8ffdd474ca1879d4aa16487d6c52472a861b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php#L556-L558
*/
public function init_session_cookie() {
// If an account has been created after the session has been initialized, update the session.
// This method is called directly by WC blocks when an account is created right before placing an order.
$previous_session_data = null;
parent::init_session_cookie();
if ( is_user_logged_in() && strval( get_current_user_id() ) !== $this->_customer_id ) {
$previous_session_data = $this->_data;
/**
* This is borrowed from WooCommerce core, which also converts the user ID to a string.
* https://github.com/woocommerce/woocommerce/blob/f01e9452045e2d483649670adc2f042391774e38/plugins/woocommerce/includes/class-wc-session-handler.php#L107
*
* @psalm-suppress InvalidPropertyAssignmentValue
*/
$this->_customer_id = strval( get_current_user_id() );
}
$this->init_session_from_token();
if ( ! empty( $previous_session_data ) ) {
$this->_data = $previous_session_data;
}
}
/**
* Process the token header to load the correct session.
*/
protected function init_session_from_token() {
$default_value = [
'token_customer_id' => $this->_customer_id,
];
if ( empty( $this->token ) ) {
$this->session_id = $this->generate_customer_id();
$this->_data = $default_value;
// session_expiration can remain the same.
return;
}
$payload = JsonWebToken::get_parts( $this->token )->payload;
$this->session_id = $payload->session_id;
$this->session_expiration = $payload->exp;
$this->_data = (array) $this->get_session( $this->session_id, $default_value );
}
/**
* Delete the session from the cache and database.
*
* @param int $customer_id Customer ID.
*/
public function delete_session( $customer_id ) {
parent::delete_session( $this->session_id );
}
/**
* Update the session expiry timestamp.
*
* @param string $customer_id Customer ID.
* @param int $timestamp Timestamp to expire the cookie.
*/
public function update_session_timestamp( $customer_id, $timestamp ) {
parent::update_session_timestamp( $this->session_id, $timestamp );
}
/**
* Forget all session data without destroying it.
*/
public function forget_session() {
parent::forget_session();
$this->session_id = $this->generate_customer_id();
}
/**
* Generate a unique customer ID for both logged-in and guest customers.
*
* Uses Portable PHP password hashing framework to generate a unique cryptographically strong ID.
*
* @return string
*/
public function generate_customer_id() {
require_once ABSPATH . 'wp-includes/class-phpass.php';
$hasher = new PasswordHash( 8, false );
return 't_' . substr( md5( $hasher->get_random_bytes( 32 ) ), 2 );
}
/**
* Save data - copy of parent method with a few modifications.
*
* @param int $old_session_key session ID before user logs in.
*/
public function save_data( $old_session_key = 0 ) {
// Dirty if something changed - prevents saving nothing new.
if ( $this->_dirty ) {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"INSERT INTO $this->_table (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)
ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
$this->session_id,
maybe_serialize( $this->_data ),
$this->_session_expiration
)
);
wp_cache_set( $this->get_cache_prefix() . $this->session_id, $this->_data, WC_SESSION_CACHE_GROUP, $this->_session_expiration - time() );
$this->_dirty = false;
}
}
/**
* Gets a cache prefix. This is used in session names so the entire cache can be invalidated with 1 function call.
*
* @return string
*/
private function get_cache_prefix() {
return WC_Cache_Helper::get_cache_prefix( WC_SESSION_CACHE_GROUP );
}
/**
* Get a session variable.
* Overridden default method, so that the `cart` session data always returns a value, in order to prevent the "saved cart after login" feature to get wrong cart data.
* See "WC_Cart_Session::get_cart_from_session".
*
* @param string $key Key to get.
* @param mixed $default used if the session variable isn't set.
* @return array|string value of session variable
*/
public function get( $key, $default = null ) {
if ( 'cart' === $key && ! isset( $this->_data['cart'] ) ) {
return [];
}
return parent::get( $key, $default );
}
}
@@ -0,0 +1,191 @@
<?php
/**
* Class WC_Payments_Payment_Request_Session
*
* @package WooCommerce\Payments
*/
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC_Payments_Payment_Request_Session class.
*/
class WC_Payments_Payment_Request_Session {
/**
* Name of the parameter added to the "order received" page for orders placed with the custom session handler on product pages.
*
* @var string Name of the parameter.
*/
private static $prevent_empty_cart_parameter = 'woopayments-custom-session';
/**
* Used as a temporary reference to cart data, so it can be restored later.
*
* @var null|WC_Cart Temporary reference to cart data.
*/
private $cart_clone = null;
/**
* Init the hooks.
*
* @return void
*/
public function init() {
// adding this filter with a higher priority than the session handler of the Store API.
add_filter( 'woocommerce_session_handler', [ $this, 'add_payment_request_store_api_session_handler' ], 20 );
add_filter( 'rest_post_dispatch', [ $this, 'store_api_headers' ], 10, 3 );
// checking to ensure we're not erasing the cart on the "order received" page.
if ( $this->is_custom_session_order_received_page() ) {
add_filter( 'woocommerce_persistent_cart_enabled', '__return_false' );
add_filter( 'woocommerce_cart_session_initialize', '__return_false' );
add_action(
'woocommerce_before_cart_emptied',
[ $this, 'save_old_cart_data_for_restore' ]
);
add_action(
'woocommerce_cart_emptied',
[ $this, 'restore_old_cart_data' ]
);
}
}
/**
* Saves an instance of the current cart, so it can be restored later.
* Used on the "order received" page for orders placed with the PRB. The "order received" page empties the cart, otherwise.
*
* @return void
*/
public function save_old_cart_data_for_restore() {
$this->cart_clone = clone WC()->cart;
}
/**
* Restores the cart saved previously.
*
* @return void
*/
public function restore_old_cart_data() {
if ( ! $this->cart_clone ) {
return;
}
WC()->cart->cart_contents = $this->cart_clone->cart_contents;
WC()->cart->removed_cart_contents = $this->cart_clone->removed_cart_contents;
WC()->cart->applied_coupons = $this->cart_clone->applied_coupons;
$this->cart_clone = null;
}
/**
* Ensuring that the return URL for the "order received" page contains a query string parameter
* that can later be identified to ensure we don't clear the cart.
* This function is only executed when we're using the custom session handler on Store API requests.
*
* @param string $return_url The URL for the "Order received" page.
*
* @return string
*/
public function store_api_order_received_return_url( $return_url ) {
return add_query_arg( self::$prevent_empty_cart_parameter, '1', $return_url );
}
/**
* Check if the $_SERVER global has order received URL slug in its 'REQUEST_URI' value - just like `wcs_is_order_received_page`.
*
* Similar to WooCommerce's is_custom_session_order_received_page(), but can be used before the $wp's query vars are setup, which is essential
* when preventing the cart from being emptied on the "order received" page, if the order has been placed with WooPayments GooglePay/ApplePay on the product page.
*
* @return bool
**/
private function is_custom_session_order_received_page() {
// ignoring because we're not storing the value anywhere, just checking its existence.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
return ( false !== strpos( $_SERVER['REQUEST_URI'], 'order-received' ) ) && ( false !== strpos( $_SERVER['REQUEST_URI'], self::$prevent_empty_cart_parameter ) );
}
/**
* Generates a session token for the response headers.
*
* @return string
*/
protected function get_session_token() {
return JsonWebToken::create(
[
'session_id' => WC()->session->session_id,
'exp' => time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ),
'iss' => 'woopayments/product-page',
],
'@' . wp_salt()
);
}
/**
* Adding the session key to the Store API response, to ensure the session can be retrieved later.
*
* @param mixed $response Response to replace the requested version with.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request used to generate the response.
*
* @return mixed
*/
public function store_api_headers( $response, $server, $request ) {
if ( ! \WC_Payments_Utils::is_store_api_request() ) {
return $response;
}
$nonce = $request->get_header( 'X-WooPayments-Tokenized-Cart-Session-Nonce' );
if ( ! wp_verify_nonce( $nonce, 'woopayments_tokenized_cart_session_nonce' ) ) {
return $response;
}
$response->header( 'X-WooPayments-Tokenized-Cart-Session', $this->get_session_token() );
return $response;
}
/**
* This filter is used to add a custom session handler before processing Store API request callbacks.
* This is only necessary because the Store API SessionHandler currently doesn't provide an `init_session_cookie` method.
*
* @param string $default_session_handler The default session handler class name.
*
* @return string The session handler class name.
*/
public function add_payment_request_store_api_session_handler( $default_session_handler ) {
$nonce = wc_clean( wp_unslash( $_SERVER['HTTP_X_WOOPAYMENTS_TOKENIZED_CART_SESSION_NONCE'] ?? null ) );
if ( ! wp_verify_nonce( $nonce, 'woopayments_tokenized_cart_session_nonce' ) ) {
return $default_session_handler;
}
if ( ! \WC_Payments_Utils::is_store_api_request() ) {
return $default_session_handler;
}
if ( ! class_exists( JsonWebToken::class ) ) {
return $default_session_handler;
}
// checking if the token is valid, if it's provided.
// there can also be a case where the token is not provided, but we should still use the custom session handler.
$cart_token = wc_clean( wp_unslash( $_SERVER['HTTP_X_WOOPAYMENTS_TOKENIZED_CART_SESSION'] ?? null ) );
if (
$cart_token && ! JsonWebToken::validate( $cart_token, '@' . wp_salt() )
) {
return $default_session_handler;
}
// ensures cart contents aren't merged across different sessions for the same customer.
add_filter( 'woocommerce_persistent_cart_enabled', '__return_false' );
// when an order is placed via the Store API on product pages, we need to slightly modify the "order received" URL.
add_filter( 'woocommerce_get_return_url', [ $this, 'store_api_order_received_return_url' ] );
require_once WCPAY_ABSPATH . '/includes/class-wc-payments-payment-request-session-handler.php';
return WC_Payments_Payment_Request_Session_Handler::class;
}
}
@@ -0,0 +1,255 @@
<?php
/**
* Class WC_Payments_Redirect_Service
*
* @package WooCommerce\Payments
*/
use WCPay\Core\Server\Request\Get_Account_Capital_Link;
use WCPay\Core\Server\Request\Get_Account_Login_Data;
use WCPay\Exceptions\API_Exception;
use WCPay\Tracker;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class handling redirects business logic.
*/
class WC_Payments_Redirect_Service {
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* Constructor for WC_Payments_Session_Service.
*
* @param WC_Payments_API_Client $payments_api_client - WooCommerce Payments API client.
*/
public function __construct(
WC_Payments_API_Client $payments_api_client
) {
$this->payments_api_client = $payments_api_client;
}
/**
* Calls wp_safe_redirect and exit.
*
* This method will end the execution immediately after the redirection.
*
* @param string $location The URL to redirect to.
*/
public function redirect_to( string $location ): void {
wp_safe_redirect( $location );
exit;
}
/**
* Redirects to a wcpay-connect URL which then handles the next step for the onboarding flow.
*
* This is a sure way to ensure that the user is redirected to the correct URL to continue their onboarding.
*
* @param string $from Source of the redirect.
* @param array $additional_params Optional. Additional URL params to add to the redirect URL.
*/
public function redirect_to_wcpay_connect( string $from = '', array $additional_params = [] ): void {
// Take the user to the 'wcpay-connect' URL.
// We handle creating and redirecting to the account link there.
$params = [
'wcpay-connect' => '1',
'_wpnonce' => wp_create_nonce( 'wcpay-connect' ),
];
$params = array_merge( $params, $additional_params );
if ( '' !== $from ) {
$params['from'] = $from;
}
$connect_url = add_query_arg(
$params,
admin_url( 'admin.php' )
);
$this->redirect_to( $connect_url );
}
/**
* Redirects to the capital view offer page or overview page with error message.
*/
public function redirect_to_capital_view_offer_page(): void {
$return_url = WC_Payments_Account::get_overview_page_url();
$refresh_url = add_query_arg( [ 'wcpay-loan-offer' => '' ], admin_url( 'admin.php' ) );
try {
$request = Get_Account_Capital_Link::create();
$type = 'capital_financing_offer';
$request->set_type( $type );
$request->set_return_url( $return_url );
$request->set_refresh_url( $refresh_url );
$capital_link = $request->send();
Tracker::track_admin( 'wcpay_capital_view_offer_redirect' );
$this->redirect_to( $capital_link['url'] );
} catch ( Exception $e ) {
$this->redirect_to_overview_page_with_error( [ 'wcpay-loan-offer-error' => '1' ] );
}
}
/**
* Function to immediately redirect to the account link.
*
* @param array $args The arguments to be sent with the link request.
*/
public function redirect_to_account_link( array $args ): void {
try {
$link = $this->payments_api_client->get_link( $args );
if ( isset( $args['type'] ) && 'complete_kyc_link' === $args['type'] && isset( $link['state'] ) ) {
set_transient( 'wcpay_stripe_onboarding_state', $link['state'], DAY_IN_SECONDS );
}
$this->redirect_to( $link['url'] );
} catch ( API_Exception $e ) {
$this->redirect_to_overview_page_with_error( [ 'wcpay-server-link-error' => '1' ] );
}
}
/**
* Immediately redirect to the Connect page.
*
* Note that this function immediately ends the execution.
*
* @param string|null $error_message Optional. Error message to show in a notice.
* @param string|null $from Optional. Source of the redirect.
* @param array $additional_params Optional. Additional URL params to add to the redirect URL.
*/
public function redirect_to_connect_page( ?string $error_message = null, ?string $from = null, array $additional_params = [] ): void {
$params = [
'page' => 'wc-admin',
'path' => '/payments/connect',
];
if ( count( $params ) === count( array_intersect_assoc( $_GET, $params ) ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
// We are already on the Connect page. Do nothing.
return;
}
// If we were given an error message, store it in a very short-lived transient to show it on the page.
if ( ! empty( $error_message ) ) {
set_transient( WC_Payments_Account::ERROR_MESSAGE_TRANSIENT, $error_message, 30 );
}
$params = array_merge( $params, $additional_params );
if ( ! empty( $from ) ) {
$params['from'] = $from;
}
$this->redirect_to( admin_url( add_query_arg( $params, 'admin.php' ) ) );
}
/**
* Immediately redirect to the onboarding wizard.
*
* Note that this function immediately ends the execution.
*
* @param string|null $from Optional. Source of the redirect.
* @param array $additional_params Optional. Additional URL params to add to the redirect URL.
*/
public function redirect_to_onboarding_wizard( ?string $from = null, array $additional_params = [] ): void {
$params = [
'page' => 'wc-admin',
'path' => '/payments/onboarding',
];
if ( count( $params ) === count( array_intersect_assoc( $_GET, $params ) ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
// We are already in the onboarding wizard. Do nothing.
return;
}
$params = array_merge( $params, $additional_params );
if ( ! empty( $from ) ) {
$params['from'] = $from;
}
$this->redirect_to( admin_url( add_query_arg( $params, 'admin.php' ) ) );
}
/**
* Immediately redirect to the settings page.
*
* Note that this function immediately ends the execution.
*
* @param string|null $from Optional. Source of the redirect.
* @param array $additional_params Optional. Additional URL params to add to the redirect URL.
*/
public function redirect_to_settings_page( ?string $from = null, array $additional_params = [] ): void {
$params = [
'page' => 'wc-settings',
'tab' => 'checkout',
];
if ( count( $params ) === count( array_intersect_assoc( $_GET, $params ) ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
// We are already in the settings page. Do nothing.
return;
}
$params = array_merge( $params, $additional_params );
if ( ! empty( $from ) ) {
$params['from'] = $from;
}
$this->redirect_to( admin_url( add_query_arg( $params, 'admin.php' ) ) );
}
/**
* Redirect to the overview page.
*
* @param string $from Optional. Source of the redirect.
* @param array $additional_params Optional. Additional URL params to add to the redirect URL.
* */
public function redirect_to_overview_page( string $from = '', array $additional_params = [] ): void {
$params = $additional_params;
if ( '' !== $from ) {
$params['from'] = $from;
}
$this->redirect_to( add_query_arg( $params, WC_Payments_Account::get_overview_page_url() ) );
}
/**
* Redirect to the overview page with an error message.
*
* @param array $error The error data to show.
*/
public function redirect_to_overview_page_with_error( array $error ): void {
$overview_url_with_error = add_query_arg(
$error,
WC_Payments_Account::get_overview_page_url()
);
$this->redirect_to( $overview_url_with_error );
}
/**
* For the connected account, fetches the login url from the API and redirects to it.
*/
public function redirect_to_login(): void {
$redirect_url = WC_Payments_Account::get_overview_page_url();
$request = Get_Account_Login_Data::create();
$request->set_redirect_url( $redirect_url );
$response = $request->send();
$login_data = $response->to_array();
$this->redirect_to( $login_data['url'] );
}
}
@@ -0,0 +1,185 @@
<?php
/**
* WC_Payments_Session_Service class
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Exceptions\API_Exception;
/**
* Handles sessions and session details.
*/
class WC_Payments_Session_Service {
const SESSION_STORE_ID_OPTION = 'wcpay_session_store_id';
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* Constructor for WC_Payments_Session_Service.
*
* @param WC_Payments_API_Client $payments_api_client - WooCommerce Payments API client.
*/
public function __construct(
WC_Payments_API_Client $payments_api_client
) {
$this->payments_api_client = $payments_api_client;
}
/**
* Checks if the user has just logged in.
*
* The user just logged in if the session cookie contains a different customer ID than the one in the session.
*
* @return boolean True if the user has just logged in, false in any other case.
*/
public function user_just_logged_in(): bool {
if ( ! get_current_user_id() ) {
return false;
}
WC()->initialize_session();
$session_handler = WC()->session;
// The Store API SessionHandler (used by WooPay) doesn't provide this method.
if ( ! method_exists( $session_handler, 'get_session_cookie' ) ) {
return false;
}
$cookie = $session_handler->get_session_cookie();
if ( ! $cookie ) {
return false;
}
$cookie_customer_id = $cookie[0];
return $session_handler->get_customer_id() !== $cookie_customer_id;
}
/**
* Get the Sift session ID for the current browsing session.
*
* @return string|null The Sift session ID or null if it can't be determined.
*/
public function get_sift_session_id(): ?string {
if ( $this->user_just_logged_in() ) {
return $this->get_cookie_session_id();
}
if ( is_a( WC()->session, 'WC_Session' ) ) {
return $this->generate_session_id( $this->get_store_id(), (string) WC()->session->get_customer_id() );
}
return null; // We do not have a valid session for the current process.
}
/**
* Link a customer with the current browsing session, for tracking purposes.
*
* @param string $customer_id Stripe customer ID.
*
* @return array An array, containing a `success` flag.
*
* @throws API_Exception If an error occurs.
*/
public function link_current_session_to_customer( string $customer_id ): array {
return $this->payments_api_client->link_session_to_customer( $this->get_sift_session_id(), $customer_id );
}
/**
* Get the session ID used until now for the current browsing session.
*
* It looks for the logged in user ID stored in the session cookie, and uses that to generate a session ID.
*
* @return string|null Session ID, or null if unknown.
*/
public function get_cookie_session_id(): ?string {
$session_handler = WC()->session;
if ( ! $session_handler ) {
return null;
}
// The Store API SessionHandler (used by WooPay) doesn't provide this method.
if ( ! method_exists( $session_handler, 'get_session_cookie' ) ) {
return null;
}
$cookie = $session_handler->get_session_cookie();
if ( ! $cookie ) {
return null;
}
$cookie_customer_id = $cookie[0];
return $this->generate_session_id( $this->get_store_id(), (string) $cookie_customer_id );
}
/**
* Get the store ID for use in sessions.
*
* This is used to consistently identify the store in WooPayments sessions.
* If it doesn't exist, it is generated randomly and stored in the database.
*
* @return string The store ID or empty string if it can't be determined.
*/
public function get_store_id(): string {
// We will use a stored random store ID.
$store_id = get_option( self::SESSION_STORE_ID_OPTION, false );
if ( ! $store_id ) {
$store_id = $this->generate_store_id();
update_option( self::SESSION_STORE_ID_OPTION, $store_id );
}
return $store_id;
}
/**
* Generate a random store ID.
*
* The generated ID is case-sensitive and contains 32 characters.
*
* @return string The generated store ID.
*/
private function generate_store_id(): string {
// Prefix it with 'st_' (from store) to make it easier to identify.
$prefix = 'st_';
// We will generate 32 characters in total, including the prefix length.
$length = 32 - strlen( $prefix );
// We will use alphanumerical characters.
$include_chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
// Add some special characters, not all. Play it safe.
// See the Sift restrictions for $user_id.
// @link https://sift.com/developers/docs/curl/events-api/fields.
$include_chars .= '-$:.^!';
// Finally, shuffle them for extra randomness.
$include_chars = str_shuffle( $include_chars );
$char_length = strlen( $include_chars );
$random_string = '';
for ( $i = 0; $i < $length; $i++ ) {
$random_string .= $include_chars [ wp_rand( 0, $char_length - 1 ) ];
}
return $prefix . $random_string;
}
/**
* Generate a session ID based on the store ID and the user ID.
*
* @param string $store_id The session store ID.
* @param string $user_id The user ID.
*
* @return string
*/
private function generate_session_id( string $store_id, string $user_id ): string {
return $store_id . '_' . $user_id;
}
}
@@ -0,0 +1,42 @@
<?php
/**
* WooCommerce Payments WC_Payments_Settings_Service Class
*
* @package WooCommerce\Payments
*/
use WCPay\MultiCurrency\Interfaces\MultiCurrencySettingsInterface;
defined( 'ABSPATH' ) || exit;
/**
* WC_Payments_Settings_Service.
*/
class WC_Payments_Settings_Service implements MultiCurrencySettingsInterface {
/**
* Checks if dev mode is enabled.
*
* @return bool
*/
public function is_dev_mode(): bool {
return WC_Payments::mode()->is_dev();
}
/**
* Gets the plugin file path.
*
* @return string
*/
public function get_plugin_file_path(): string {
return WCPAY_PLUGIN_FILE;
}
/**
* Gets the plugin version.
*
* @return string
*/
public function get_plugin_version(): string {
return WCPAY_VERSION_NUMBER;
}
}
@@ -0,0 +1,269 @@
<?php
/**
* WC_Payments_Status class
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Hooks into Woo Status pages to provide extra tooling and information about WCPay.
*/
class WC_Payments_Status {
/**
* Instance of WC_Payment_Gateway_WCPay
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* Instance of WC_Payments_Http_Interface
*
* @var WC_Payments_Http_Interface
*/
private $http;
/**
* Instance of WC_Payments_Account
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_Payments_Status constructor.
*
* @param WC_Payment_Gateway_WCPay $gateway The main gateway instance.
* @param WC_Payments_Http_Interface $http A class implementing WC_Payments_Http_Interface.
* @param WC_Payments_Account $account The account service.
*/
public function __construct( $gateway, $http, $account ) {
$this->gateway = $gateway;
$this->http = $http;
$this->account = $account;
}
/**
* Initializes this class's WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'woocommerce_system_status_report', [ $this, 'render_status_report_section' ], 1 );
add_filter( 'woocommerce_debug_tools', [ $this, 'debug_tools' ] );
}
/**
* Add WCPay tools to the Woo debug tools.
*
* @param array $tools List of current available tools.
*/
public function debug_tools( $tools ) {
$tools['clear_wcpay_account_cache'] = [
'name' => sprintf(
/* translators: %s: WooPayments */
__( 'Clear %s account cache', 'woocommerce-payments' ),
'WooPayments'
),
'button' => __( 'Clear', 'woocommerce-payments' ),
'desc' => sprintf(
/* translators: %s: WooPayments */
__( 'This tool will clear the account cached values used in %s.', 'woocommerce-payments' ),
'WooPayments'
),
'callback' => [ $this->account, 'refresh_account_data' ],
];
return $tools;
}
/**
* Renders WCPay information on the status page.
*/
public function render_status_report_section() {
?>
<table class="wc_status_table widefat" cellspacing="0">
<thead>
<tr>
<th colspan="3" data-export-label="WooPayments">
<h2>WooPayments</h2>
</th>
</tr>
</thead>
<tbody>
<tr>
<td data-export-label="Version"><?php esc_html_e( 'Version', 'woocommerce-payments' ); ?>:</td>
<td class="help">
<?php
/* translators: %s: WooPayments */
echo wc_help_tip( sprintf( esc_html__( 'The current version of the %s extension.', 'woocommerce-payments' ), 'WooPayments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */
?>
</td>
<td><?php echo esc_html( WCPAY_VERSION_NUMBER ); ?></td>
</tr>
<tr>
<td data-export-label="Connected to WPCOM"><?php esc_html_e( 'Connected to WPCOM', 'woocommerce-payments' ); ?>:</td>
<td class="help">
<?php
/* translators: %s: WooPayments */
echo wc_help_tip( sprintf( esc_html__( 'Can your store connect securely to wordpress.com? Without a proper WPCOM connection %s can\'t function!', 'woocommerce-payments' ), 'WooPayments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */
?>
</td>
<td><?php echo $this->http->is_connected() ? esc_html__( 'Yes', 'woocommerce-payments' ) : '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'No', 'woocommerce-payments' ) . '</mark>'; ?></td>
</tr>
<?php if ( $this->http->is_connected() ) : ?>
<tr>
<td data-export-label="WPCOM Blog ID"><?php esc_html_e( 'WPCOM Blog ID', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'The corresponding wordpress.com blog ID for this store.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo esc_html( $this->http->is_connected() ? $this->http->get_blog_id() : '-' ); ?></td>
</tr>
<tr>
<td data-export-label="Account ID"><?php esc_html_e( 'Account ID', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'The merchant account ID you are currently using to process payments with.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo $this->gateway->is_connected() ? esc_html( $this->account->get_stripe_account_id() ?? '-' ) : '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not connected', 'woocommerce-payments' ) . '</mark>'; ?></td>
</tr>
<?php
// Only display the rest if the payment gateway is connected since many places check for this and we might get inaccurate data.
if ( $this->gateway->is_connected() ) :
?>
<tr>
<td data-export-label="Payment Gateway"><?php esc_html_e( 'Payment Gateway', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Is the payment gateway ready and enabled for use on your store?', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo $this->gateway->needs_setup() ? '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Needs setup', 'woocommerce-payments' ) . '</mark>' : ( $this->gateway->is_enabled() ? esc_html__( 'Enabled', 'woocommerce-payments' ) : esc_html__( 'Disabled', 'woocommerce-payments' ) ); ?></td>
</tr>
<tr>
<td data-export-label="Test Mode"><?php esc_html_e( 'Test Mode', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether the payment gateway has test payments enabled or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php WC_Payments::mode()->is_test() ? esc_html_e( 'Enabled', 'woocommerce-payments' ) : esc_html_e( 'Disabled', 'woocommerce-payments' ); ?></td>
</tr>
<tr>
<td data-export-label="Enabled APMs"><?php esc_html_e( 'Enabled APMs', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'What payment methods are enabled for the store.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo esc_html( implode( ',', $this->gateway->get_upe_enabled_payment_method_ids() ) ); ?></td>
</tr>
<?php if ( ! WC_Payments_Features::is_woopay_express_checkout_enabled() ) : ?>
<tr>
<td data-export-label="WooPay"><?php esc_html_e( 'WooPay Express Checkout', 'woocommerce-payments' ); ?>:</td>
<td class="help">
<?php
/* translators: %s: WooPayments */
echo wc_help_tip( sprintf( esc_html__( 'WooPay is not available, as a %s feature, or the store is not yet eligible.', 'woocommerce-payments' ), 'WooPayments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */
?>
</td>
<td><?php echo ! WC_Payments_Features::is_woopay_eligible() ? esc_html__( 'Not eligible', 'woocommerce-payments' ) : esc_html__( 'Not active', 'woocommerce-payments' ); ?></td>
</tr>
<?php else : ?>
<tr>
<td data-export-label="WooPay"><?php esc_html_e( 'WooPay Express Checkout', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether the new WooPay Express Checkout is enabled or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
$woopay_enabled_locations = $this->gateway->get_option( 'platform_checkout_button_locations', [] );
$woopay_enabled_locations = empty( $woopay_enabled_locations ) ? 'no locations enabled' : implode( ',', $woopay_enabled_locations );
echo esc_html( WC_Payments_Features::is_woopay_enabled() ? __( 'Enabled', 'woocommerce-payments' ) . ' (' . $woopay_enabled_locations . ')' : __( 'Disabled', 'woocommerce-payments' ) );
?>
</td>
</tr>
<tr>
<td data-export-label="WooPay Incompatible Extensions"><?php esc_html_e( 'WooPay Incompatible Extensions', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether there are extensions active that are have known incompatibilities with the functioning of the new WooPay Express Checkout.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php get_option( \WCPay\WooPay\WooPay_Scheduler::INVALID_EXTENSIONS_FOUND_OPTION_NAME, false ) ? esc_html_e( 'Yes', 'woocommerce-payments' ) : esc_html_e( 'No', 'woocommerce-payments' ); ?></td>
</tr>
<?php endif; ?>
<tr>
<td data-export-label="Apple Pay / Google Pay"><?php esc_html_e( 'Apple Pay / Google Pay Express Checkout', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether the store has Payment Request enabled or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
$payment_request_enabled = 'yes' === $this->gateway->get_option( 'payment_request' );
$payment_request_enabled_locations = $this->gateway->get_option( 'payment_request_button_locations', [] );
$payment_request_enabled_locations = empty( $payment_request_enabled_locations ) ? 'no locations enabled' : implode( ',', $payment_request_enabled_locations );
echo esc_html( $payment_request_enabled ? __( 'Enabled', 'woocommerce-payments' ) . ' (' . $payment_request_enabled_locations . ')' : __( 'Disabled', 'woocommerce-payments' ) );
?>
</td>
</tr>
<tr>
<td data-export-label="Fraud Protection Level"><?php esc_html_e( 'Fraud Protection Level', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'The current fraud protection level the payment gateway is using.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php echo esc_html( $this->gateway->get_option( 'current_protection_level' ) ); ?></td>
</tr>
<?php if ( $this->gateway->get_option( 'current_protection_level' ) === 'advanced' ) : ?>
<tr>
<td data-export-label="Enabled Fraud Filters"><?php esc_html_e( 'Enabled Fraud Filters', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'The advanced fraud protection filters currently enabled.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
// Process the list.
$adv_fraud_settings = json_decode( wp_json_encode( $this->gateway->get_option( 'advanced_fraud_protection_settings' ) ), true );
$list = array_filter(
array_map(
function ( $rule ) {
if ( empty( $rule['key'] ) ) {
return null;
}
switch ( $rule['key'] ) {
case 'avs_verification':
return 'AVS Verification';
case 'international_ip_address':
return 'International IP Address';
case 'ip_address_mismatch':
return 'IP Address Mismatch';
case 'address_mismatch':
return 'Address Mismatch';
case 'purchase_price_threshold':
return 'Purchase Price Threshold';
case 'order_items_threshold':
return 'Order Items Threshold';
default:
// Ignore all others.
return null;
}
},
$adv_fraud_settings
)
);
echo empty( $list ) ? '-' : esc_html( implode( ',', $list ) );
?>
</td>
</tr>
<?php endif; ?>
<tr>
<td data-export-label="Multi-currency"><?php esc_html_e( 'Multi-currency', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether the store has the Multi-currency feature enabled or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php WC_Payments_Features::is_customer_multi_currency_enabled() ? esc_html_e( 'Enabled', 'woocommerce-payments' ) : esc_html_e( 'Disabled', 'woocommerce-payments' ); ?></td>
</tr>
<tr>
<td data-export-label="Auth and Capture"><?php esc_html_e( 'Auth and Capture', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether the store has the Auth & Capture feature enabled or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td>
<?php
$manual_capture_enabled = 'yes' === $this->gateway->get_option( 'manual_capture' );
echo $manual_capture_enabled ? esc_html_e( 'Enabled', 'woocommerce-payments' ) : esc_html_e( 'Disabled', 'woocommerce-payments' );
?>
</td>
</tr>
<tr>
<td data-export-label="Documents"><?php esc_html_e( 'Documents', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether the tax documents section is enabled or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php WC_Payments_Features::is_documents_section_enabled() ? esc_html_e( 'Enabled', 'woocommerce-payments' ) : esc_html_e( 'Disabled', 'woocommerce-payments' ); ?></td>
</tr>
<?php endif; // Gateway connected. ?>
<?php endif; // Connected to WPCOM. ?>
<tr>
<td data-export-label="Logging"><?php esc_html_e( 'Logging', 'woocommerce-payments' ); ?>:</td>
<td class="help"><?php echo wc_help_tip( esc_html__( 'Whether debug logging is enabled and working or not.', 'woocommerce-payments' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.OutputNotEscaped */ ?></td>
<td><?php \WCPay\Logger::can_log() ? esc_html_e( 'Enabled', 'woocommerce-payments' ) : esc_html_e( 'Disabled', 'woocommerce-payments' ); ?></td>
</tr>
</tbody>
</table>
<?php
}
}
@@ -0,0 +1,47 @@
<?php
/**
* WC_Payments_Tasks class
*
* @package WooCommerce\Payments\Tasks
*/
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use WooCommerce\Payments\Tasks\WC_Payments_Task_Disputes;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Hooks into WC TaskLists to display WCPay tasks.
*/
class WC_Payments_Tasks {
/**
* WC_Payments_Admin_Tasks constructor.
*/
public static function init() {
// As WooCommerce Onboarding tasks need to hook into 'init' and requires an API call.
// We only add this task for users who can manage_woocommerce / view the task.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
add_action( 'init', [ __CLASS__, 'add_task_disputes_need_response' ] );
}
/**
* Adds a task to the WC 'Things to do next' task list the if disputes awaiting response.
*/
public static function add_task_disputes_need_response() {
$account_service = WC_Payments::get_account_service();
// The task is not required if the account is not connected, under review, or rejected.
if ( ! $account_service || ! $account_service->is_stripe_account_valid() || $account_service->is_account_under_review() || $account_service->is_account_rejected() ) {
return;
}
include_once WCPAY_ABSPATH . 'includes/admin/tasks/class-wc-payments-task-disputes.php';
// 'extended' = 'Things to do next' task list on WooCommerce > Home.
TaskLists::add_task( 'extended', new WC_Payments_Task_Disputes() );
}
}
@@ -0,0 +1,395 @@
<?php
/**
* WC_Payments_Token_Service class
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Logger;
use WCPay\Payment_Methods\CC_Payment_Gateway;
use WCPay\Constants\Payment_Method;
/**
* Handles and process WC payment tokens API.
* Seen in checkout page and my account->add payment method page.
*/
class WC_Payments_Token_Service {
const REUSABLE_GATEWAYS_BY_PAYMENT_METHOD = [
Payment_Method::CARD => WC_Payment_Gateway_WCPay::GATEWAY_ID,
Payment_Method::SEPA => WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . Payment_Method::SEPA,
Payment_Method::LINK => WC_Payment_Gateway_WCPay::GATEWAY_ID,
];
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* WC_Payments_Customer instance for working with customer information
*
* @var WC_Payments_Customer_Service
*/
private $customer_service;
/**
* WC_Payments_Token_Service constructor.
*
* @param WC_Payments_API_Client $payments_api_client Payments API client.
* @param WC_Payments_Customer_Service $customer_service Customer class instance.
*/
public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Customer_Service $customer_service ) {
$this->payments_api_client = $payments_api_client;
$this->customer_service = $customer_service;
}
/**
* Initializes hooks.
*/
public function init_hooks() {
add_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 );
add_action( 'woocommerce_payment_token_set_default', [ $this, 'woocommerce_payment_token_set_default' ], 10, 2 );
add_filter( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 );
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item_sepa' ], 10, 2 );
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item_link' ], 10, 2 );
add_filter( 'woocommerce_get_credit_card_type_label', [ $this, 'normalize_sepa_label' ] );
add_filter( 'woocommerce_get_credit_card_type_label', [ $this, 'normalize_stripe_link_label' ] );
}
/**
* Creates and add a token to an user, based on the payment_method object
*
* @param array $payment_method Payment method to be added.
* @param WP_User $user User to attach payment method to.
* @return WC_Payment_Token The WC object for the payment token.
*/
public function add_token_to_user( $payment_method, $user ) {
// Clear cached payment methods.
$this->customer_service->clear_cached_payment_methods_for_user( $user->ID );
switch ( $payment_method['type'] ) {
case Payment_Method::SEPA:
$token = new WC_Payment_Token_WCPay_SEPA();
$gateway_id = WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . Payment_Method::SEPA;
$token->set_gateway_id( $gateway_id );
$token->set_last4( $payment_method[ Payment_Method::SEPA ]['last4'] );
break;
case Payment_Method::LINK:
$token = new WC_Payment_Token_WCPay_Link();
$gateway_id = CC_Payment_Gateway::GATEWAY_ID;
$token->set_gateway_id( $gateway_id );
$token->set_email( $payment_method[ Payment_Method::LINK ]['email'] );
break;
case Payment_Method::CARD_PRESENT:
$token = new WC_Payment_Token_CC();
$token->set_gateway_id( CC_Payment_Gateway::GATEWAY_ID );
$token->set_expiry_month( $payment_method[ Payment_Method::CARD_PRESENT ]['exp_month'] );
$token->set_expiry_year( $payment_method[ Payment_Method::CARD_PRESENT ]['exp_year'] );
$token->set_card_type( strtolower( $payment_method[ Payment_Method::CARD_PRESENT ]['brand'] ) );
$token->set_last4( $payment_method[ Payment_Method::CARD_PRESENT ]['last4'] );
break;
default:
$token = new WC_Payment_Token_CC();
$token->set_gateway_id( CC_Payment_Gateway::GATEWAY_ID );
$token->set_expiry_month( $payment_method[ Payment_Method::CARD ]['exp_month'] );
$token->set_expiry_year( $payment_method[ Payment_Method::CARD ]['exp_year'] );
$token->set_card_type( strtolower( $payment_method[ Payment_Method::CARD ]['display_brand'] ?? $payment_method[ Payment_Method::CARD ]['networks']['preferred'] ?? $payment_method[ Payment_Method::CARD ]['brand'] ) );
$token->set_last4( $payment_method[ Payment_Method::CARD ]['last4'] );
}
$token->set_token( $payment_method['id'] );
$token->set_user_id( $user->ID );
$token->save();
return $token;
}
/**
* Adds a payment method to a user.
*
* @param string $payment_method_id Payment method to be added.
* @param WP_User $user User to attach payment method to.
* @return WC_Payment_Token_CC The newly created token.
*/
public function add_payment_method_to_user( $payment_method_id, $user ) {
$payment_method_object = $this->payments_api_client->get_payment_method( $payment_method_id );
return $this->add_token_to_user( $payment_method_object, $user );
}
/**
* Returns boolean value if payment method type matches relevant payment gateway.
*
* @param string $payment_method_type Stripe payment method type ID.
* @param string $gateway_id WC payment gateway ID.
* @return bool True, if payment method type matches gateway, false if otherwise.
*/
public function is_valid_payment_method_type_for_gateway( $payment_method_type, $gateway_id ) {
return self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD[ $payment_method_type ] === $gateway_id;
}
/**
* Gets saved tokens from API if they don't already exist in WooCommerce.
*
* @param array $tokens Array of tokens.
* @param string $user_id WC user ID.
* @param string $gateway_id WC gateway ID.
* @return array
*/
public function woocommerce_get_customer_payment_tokens( $tokens, $user_id, $gateway_id ) {
if ( ( ! empty( $gateway_id ) && ! in_array( $gateway_id, self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) || ! is_user_logged_in() ) {
return $tokens;
}
if ( count( $tokens ) >= get_option( 'posts_per_page' ) ) {
// The tokens data store is not paginated and only the first "post_per_page" (defaults to 10) tokens are retrieved.
// Having 10 saved credit cards is considered an unsupported edge case, new ones that have been stored in Stripe won't be added.
return $tokens;
}
try {
$customer_id = $this->customer_service->get_customer_id_by_user_id( $user_id );
if ( null === $customer_id ) {
return $tokens;
}
$stored_tokens = [];
foreach ( $tokens as $token ) {
if ( in_array( $token->get_gateway_id(), self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) {
$stored_tokens[ $token->get_token() ] = $token;
}
}
$retrievable_payment_method_types = $this->get_retrievable_payment_method_types( $gateway_id );
$payment_methods = [];
foreach ( $retrievable_payment_method_types as $type ) {
$payment_methods[] = $this->customer_service->get_payment_methods_for_customer( $customer_id, $type );
}
$payment_methods = array_merge( ...$payment_methods );
} catch ( Exception $e ) {
Logger::error( 'Failed to fetch payment methods for customer.' . $e );
return $tokens;
}
// Prevent unnecessary recursion, WC_Payment_Token::save() ends up calling 'woocommerce_get_customer_payment_tokens' in some cases.
remove_action( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 );
foreach ( $payment_methods as $payment_method ) {
if ( ! isset( $payment_method['type'] ) ) {
continue;
}
if ( ! isset( $stored_tokens[ $payment_method['id'] ] ) && ( $this->is_valid_payment_method_type_for_gateway( $payment_method['type'], $gateway_id ) || empty( $gateway_id ) ) ) {
$token = $this->add_token_to_user( $payment_method, get_user_by( 'id', $user_id ) );
$tokens[ $token->get_id() ] = $token;
} else {
unset( $stored_tokens[ $payment_method['id'] ] );
}
}
add_action( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 );
// Remove the payment methods that no longer exist in Stripe's side.
remove_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 );
foreach ( $stored_tokens as $token ) {
unset( $tokens[ $token->get_id() ] );
$token->delete();
}
add_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 );
return $tokens;
}
/**
* Retrieves the payment method types for which tokens should be retrieved.
*
* This function determines the appropriate payment method types based on the provided gateway ID.
* - If a gateway ID is provided, it retrieves the payment methods specific to that gateway to prevent duplication of saved tokens under incorrect payment methods during checkout.
* - If no gateway ID is provided, it retrieves the default payment methods to fetch all saved tokens, e.g., for the Blocks checkout or My Account page.
*
* @param string|null $gateway_id The optional ID of the gateway.
* @return array The list of retrievable payment method types.
*/
private function get_retrievable_payment_method_types( $gateway_id = null ) {
if ( empty( $gateway_id ) ) {
return $this->get_all_retrievable_payment_types();
} else {
return $this->get_gateway_specific_retrievable_payment_types( $gateway_id );
}
}
/**
* Returns all the enabled retrievable payment method types.
*
* @return array Enabled retrievable payment method types.
*/
private function get_all_retrievable_payment_types() {
$types = [ Payment_Method::CARD ];
if ( $this->is_payment_method_enabled( Payment_Method::SEPA ) ) {
$types[] = Payment_Method::SEPA;
}
if ( $this->is_payment_method_enabled( Payment_Method::LINK ) ) {
$types[] = Payment_Method::LINK;
}
return $types;
}
/**
* Returns retrievable payment method types for a given gateway.
*
* @param string $gateway_id The ID of the gateway.
* @return array Retrievable payment method types for the specified gateway.
*/
private function get_gateway_specific_retrievable_payment_types( $gateway_id ) {
$types = [];
foreach ( self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD as $payment_method => $gateway ) {
if ( $gateway !== $gateway_id ) {
continue;
}
// Stripe Link is part of the card gateway, so we need to check separately if Link is enabled.
if ( Payment_Method::LINK === $payment_method && ! $this->is_payment_method_enabled( Payment_Method::LINK ) ) {
continue;
}
$types[] = $payment_method;
}
return $types;
}
/**
* Checks if a payment method is enabled.
*
* @param string $payment_method The payment method to check.
* @return bool True if the payment method is enabled, false otherwise.
*/
private function is_payment_method_enabled( $payment_method ) {
return in_array( $payment_method, WC_Payments::get_gateway()->get_upe_enabled_payment_method_ids(), true );
}
/**
* Delete token from Stripe.
*
* @param string $token_id Token ID.
* @param WC_Payment_Token $token Token object.
*
* @throws Exception
*/
public function woocommerce_payment_token_deleted( $token_id, $token ) {
// If it's not reusable payment method, we don't need to perform any additional checks.
if ( ! in_array( $token->get_gateway_id(), self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) {
return;
}
// First check if it's live mode.
// Second check if it's admin.
// Third check if it's not production environment.
// When all conditions are met, we don't want to delete the payment method from Stripe.
// This is to avoid detaching the payment method from the live stripe account on non production environments.
if (
WC_Payments::mode()->is_live() &&
is_admin() &&
'production' !== wp_get_environment_type()
) {
return;
}
try {
$this->payments_api_client->detach_payment_method( $token->get_token() );
// Clear cached payment methods.
$this->customer_service->clear_cached_payment_methods_for_user( $token->get_user_id() );
} catch ( Exception $e ) {
Logger::log( 'Error detaching payment method:' . $e->getMessage() );
}
}
/**
* Set as default in Stripe.
*
* @param string $token_id Token ID.
* @param WC_Payment_Token $token Token object.
*/
public function woocommerce_payment_token_set_default( $token_id, $token ) {
if ( in_array( $token->get_gateway_id(), self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) {
$customer_id = $this->customer_service->get_customer_id_by_user_id( $token->get_user_id() );
if ( $customer_id ) {
$this->customer_service->set_default_payment_method_for_customer( $customer_id, $token->get_token() );
// Clear cached payment methods.
$this->customer_service->clear_cached_payment_methods_for_user( $token->get_user_id() );
}
}
}
/**
* Controls the output for SEPA on the my account page.
*
* @param array $item Individual list item from woocommerce_saved_payment_methods_list.
* @param WC_Payment_Token|WC_Payment_Token_WCPay_SEPA $payment_token The payment token associated with this method entry.
* @return array Filtered item
*/
public function get_account_saved_payment_methods_list_item_sepa( $item, $payment_token ) {
if ( WC_Payment_Token_WCPay_SEPA::TYPE === strtolower( $payment_token->get_type() ) ) {
$item['method']['last4'] = $payment_token->get_last4();
$item['method']['brand'] = esc_html__( 'SEPA IBAN', 'woocommerce-payments' );
}
return $item;
}
/**
* Controls the output for Stripe Link on the My account page.
*
* @param array $item Individual list item from woocommerce_saved_payment_methods_list.
* @param WC_Payment_Token|WC_Payment_Token_WCPay_Link $payment_token The payment token associated with this method entry.
* @return array Filtered item
*/
public function get_account_saved_payment_methods_list_item_link( $item, $payment_token ) {
if ( WC_Payment_Token_WCPay_Link::TYPE === strtolower( $payment_token->get_type() ) ) {
$item['method']['last4'] = $payment_token->get_redacted_email();
$item['method']['brand'] = esc_html__( 'Stripe Link email', 'woocommerce-payments' );
}
return $item;
}
/**
* Normalizes the SEPA IBAN label on My Account page.
*
* @param string $label Token label.
* @return string $label Capitalized SEPA IBAN label.
*/
public function normalize_sepa_label( $label ) {
if ( 'sepa iban' === strtolower( $label ) ) {
return __( 'SEPA IBAN', 'woocommerce-payments' );
}
return $label;
}
/**
* Normalizes the Stripe Link label on My Account page.
*
* @param string $label Token label.
* @return string $label Capitalized SEPA IBAN label.
*/
public function normalize_stripe_link_label( $label ) {
if ( 'stripe link email' === strtolower( $label ) ) {
return 'Stripe Link email';
}
return $label;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,874 @@
<?php
/**
* WC_Payments_Webhook_Processing_Service class
*
* @package WooCommerce\Payments
*/
use WCPay\Constants\Order_Status;
use WCPay\Constants\Payment_Method;
use WCPay\Core\Server\Request\Get_Intention;
use WCPay\Database_Cache;
use WCPay\Exceptions\Invalid_Payment_Method_Exception;
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
use WCPay\Exceptions\Order_Not_Found_Exception;
use WCPay\Exceptions\Rest_Request_Exception;
use WCPay\Logger;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Service to process webhook data.
*/
class WC_Payments_Webhook_Processing_Service {
/**
* Client for making requests to the WooCommerce Payments API
*
* @var WC_Payments_API_Client
*/
protected $api_client;
/**
* DB wrapper.
*
* @var WC_Payments_DB
*/
private $wcpay_db;
/**
* WC Payments Account.
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC Payments Remote Note Service.
*
* @var WC_Payments_Remote_Note_Service
*/
private $remote_note_service;
/**
* WC_Payments_Order_Service instance
*
* @var WC_Payments_Order_Service
*/
protected $order_service;
/**
* WC_Payments_In_Person_Payments_Receipts_Service
*
* @var WC_Payments_In_Person_Payments_Receipts_Service
*/
private $receipt_service;
/**
* WC_Payment_Gateway_WCPay
*
* @var WC_Payment_Gateway_WCPay
*/
private $wcpay_gateway;
/**
* WC_Payment_Gateway_WCPay
*
* @var WC_Payments_Customer_Service
*/
private $customer_service;
/**
* Database_Cache instance.
*
* @var Database_Cache
*/
private $database_cache;
/**
* WC_Payments_Webhook_Processing_Service constructor.
*
* @param WC_Payments_API_Client $api_client WooCommerce Payments API client.
* @param WC_Payments_DB $wcpay_db WC_Payments_DB instance.
* @param WC_Payments_Account $account WC_Payments_Account instance.
* @param WC_Payments_Remote_Note_Service $remote_note_service WC_Payments_Remote_Note_Service instance.
* @param WC_Payments_Order_Service $order_service WC_Payments_Order_Service instance.
* @param WC_Payments_In_Person_Payments_Receipts_Service $receipt_service WC_Payments_In_Person_Payments_Receipts_Service instance.
* @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay instance.
* @param WC_Payments_Customer_Service $customer_service WC_Payments_Customer_Service instance.
* @param Database_Cache $database_cache Database_Cache instance.
*/
public function __construct(
WC_Payments_API_Client $api_client,
WC_Payments_DB $wcpay_db,
WC_Payments_Account $account,
WC_Payments_Remote_Note_Service $remote_note_service,
WC_Payments_Order_Service $order_service,
WC_Payments_In_Person_Payments_Receipts_Service $receipt_service,
WC_Payment_Gateway_WCPay $wcpay_gateway,
WC_Payments_Customer_Service $customer_service,
Database_Cache $database_cache
) {
$this->wcpay_db = $wcpay_db;
$this->account = $account;
$this->remote_note_service = $remote_note_service;
$this->order_service = $order_service;
$this->api_client = $api_client;
$this->receipt_service = $receipt_service;
$this->wcpay_gateway = $wcpay_gateway;
$this->customer_service = $customer_service;
$this->database_cache = $database_cache;
}
/**
* Process webhook event data.
*
* @param array $event_body Body data of webhook request.
*
* @return void
*
* @throws Invalid_Webhook_Data_Exception
*/
public function process( array $event_body ) {
// Extract information about the webhook event.
$event_type = $this->read_webhook_property( $event_body, 'type' );
Logger::debug( 'Webhook received: ' . $event_type );
Logger::debug(
'Webhook body: '
. var_export( WC_Payments_Utils::redact_array( $event_body, WC_Payments_API_Client::API_KEYS_TO_REDACT ), true ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
);
if ( $this->is_webhook_mode_mismatch( $event_body ) ) {
return;
}
try {
do_action( 'woocommerce_payments_before_webhook_delivery', $event_type, $event_body );
} catch ( Exception $e ) {
Logger::error( $e );
}
switch ( $event_type ) {
case 'charge.refunded':
$this->process_webhook_refund_triggered_externally( $event_body );
break;
case 'charge.refund.updated':
$this->process_webhook_refund_updated( $event_body );
break;
case 'charge.dispute.created':
$this->process_webhook_dispute_created( $event_body );
break;
case 'charge.dispute.closed':
$this->process_webhook_dispute_closed( $event_body );
break;
case 'charge.dispute.funds_reinstated':
case 'charge.dispute.funds_withdrawn':
case 'charge.dispute.updated':
$this->process_webhook_dispute_updated( $event_body );
break;
case 'charge.expired':
$this->process_webhook_expired_authorization( $event_body );
break;
case 'account.updated':
$this->account->refresh_account_data();
$this->customer_service->delete_cached_payment_methods();
break;
case 'wcpay.notification':
$this->process_wcpay_notification( $event_body );
break;
case 'payment_intent.payment_failed':
$this->process_webhook_payment_intent_failed( $event_body );
break;
case 'payment_intent.succeeded':
$this->process_webhook_payment_intent_succeeded( $event_body );
break;
case 'payment_intent.canceled':
$this->process_webhook_payment_intent_canceled( $event_body );
break;
case 'payment_intent.amount_capturable_updated':
$this->process_webhook_payment_intent_amount_capturable_updated( $event_body );
break;
case 'invoice.upcoming':
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_upcoming( $event_body );
break;
case 'invoice.paid':
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_paid( $event_body );
break;
case 'invoice.payment_failed':
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_payment_failed( $event_body );
break;
}
try {
do_action( 'woocommerce_payments_after_webhook_delivery', $event_type, $event_body );
} catch ( Exception $e ) {
Logger::error( $e );
}
}
/**
* Check webhook mode against the gateway mode.
*
* @param array $event_body The event that triggered the webhook.
*
* @return bool Indicates whether the event's mode is different from the gateway's mode
* @throws Invalid_Webhook_Data_Exception Event mode does not match the gateway mode.
*/
private function is_webhook_mode_mismatch( array $event_body ): bool {
if ( ! $this->has_webhook_property( $event_body, 'livemode' ) ) {
return false;
}
$is_gateway_live_mode = WC_Payments::mode()->is_live();
$is_event_live_mode = $this->read_webhook_property( $event_body, 'livemode' );
if ( $is_gateway_live_mode !== $is_event_live_mode ) {
$event_id = $this->read_webhook_property( $event_body, 'id' );
Logger::error(
sprintf(
'Webhook event mode did not match the gateway mode (event ID: %s)',
$event_id
)
);
return true;
}
return false;
}
/**
* Process webhook refund updated.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
*/
private function process_webhook_refund_updated( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
// First, check the reason for the update. We're only interested in a status of failed.
$status = $this->read_webhook_property( $event_object, 'status' );
if ( 'failed' !== $status ) {
return;
}
// Fetch the details of the failed refund so that we can find the associated order and write a note.
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
$refund_id = $this->read_webhook_property( $event_object, 'id' );
$amount = $this->read_webhook_property( $event_object, 'amount' );
$currency = $this->read_webhook_property( $event_object, 'currency' );
// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
if ( ! $order ) {
throw new Invalid_Payment_Method_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
),
'order_not_found'
);
}
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
__( 'A refund of %1$s was <strong>unsuccessful</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'code' => '<code>',
]
),
WC_Payments_Explicit_Price_Formatter::get_explicit_price(
wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
$order
),
'WooPayments',
$refund_id
);
if ( $this->order_service->order_note_exists( $order, $note ) ) {
return;
}
/**
* Get refunds from order and delete refund if matches wcpay refund id.
*
* @var $wc_refunds WC_Order_Refund[]
* */
$wc_refunds = $order->get_refunds();
if ( ! empty( $wc_refunds ) ) {
foreach ( $wc_refunds as $wc_refund ) {
$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
if ( $refund_id === $wcpay_refund_id ) {
// Delete WC Refund.
$wc_refund->delete();
break;
}
}
}
// Update order status if order is fully refunded.
$current_order_status = $order->get_status();
if ( Order_Status::REFUNDED === $current_order_status ) {
$order->update_status( Order_Status::FAILED );
}
$order->add_order_note( $note );
$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
$order->save();
}
/**
* Process webhook for an expired uncaptured payment.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
*/
private function process_webhook_expired_authorization( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
// Fetch the details of the expired auth so that we can find the associated order.
$charge_id = $this->read_webhook_property( $event_object, 'id' );
// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
if ( ! $order ) {
throw new Invalid_Payment_Method_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
),
'order_not_found'
);
}
// Get the intent_id and then its status.
$intent_id = $event_object['payment_intent'] ?? $order->get_meta( '_intent_id' );
$request = Get_Intention::create( $intent_id );
$request->set_hook_args( $order );
$intent = $request->send();
$intent_status = $intent->get_status();
// TODO: Revisit this logic once we support partial captures or multiple charges for order. We'll need to handle the "payment_intent.canceled" event too.
$this->order_service->mark_payment_capture_expired( $order, $intent_id, $intent_status, $charge_id );
// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
}
/**
* Process webhook for a payment intent canceled event.
*
* @param array $event_body The event that triggered the webhook.
*
* @return void
*/
private function process_webhook_payment_intent_canceled( $event_body ) {
// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
}
/**
* Process webhook for a payment intent amount capturable updated event.
*
* @param array $event_body The event that triggered the webhook.
*
* @return void
*/
private function process_webhook_payment_intent_amount_capturable_updated( $event_body ) {
// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
}
/**
* Process webhook for a failed payment intent.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
*/
private function process_webhook_payment_intent_failed( $event_body ) {
// Check to make sure we should process this according to the payment method.
$charge_id = $event_body['data']['object']['charges']['data'][0]['id'] ?? '';
$last_payment_error = $event_body['data']['object']['last_payment_error'] ?? null;
$payment_method = $last_payment_error['payment_method'] ?? null;
$payment_method_type = $payment_method['type'] ?? null;
$actionable_methods = [
Payment_Method::CARD,
Payment_Method::CARD_PRESENT,
Payment_Method::US_BANK_ACCOUNT,
Payment_Method::BECS,
];
if ( empty( $payment_method_type ) || ! in_array( $payment_method_type, $actionable_methods, true ) ) {
return;
}
// Get the order and make sure it is an order and the payment methods match.
$order = $this->get_order_from_event_body( $event_body );
$payment_method_id = $payment_method['id'] ?? null;
if ( ! $order || empty( $payment_method_id ) ) {
return;
}
if ( Payment_Method::CARD_PRESENT !== $payment_method_type && $payment_method_id !== $order->get_meta( '_payment_method_id' ) ) {
return;
}
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$intent_id = $this->read_webhook_property( $event_object, 'id' );
$intent_status = $this->read_webhook_property( $event_object, 'status' );
if ( Payment_Method::CARD_PRESENT === $payment_method_type ) {
$this->order_service->mark_terminal_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) );
} else {
$this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) );
}
}
/**
* Process webhook for a successful payment intent.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order.
*/
private function process_webhook_payment_intent_succeeded( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$intent_id = $this->read_webhook_property( $event_object, 'id' );
$currency = $this->read_webhook_property( $event_object, 'currency' );
$order = $this->get_order_from_event_body( $event_body );
$intent_status = $this->read_webhook_property( $event_object, 'status' );
$event_charges = $this->read_webhook_property( $event_object, 'charges' );
$charges_data = $this->read_webhook_property( $event_charges, 'data' );
$charge_id = $this->read_webhook_property( $charges_data[0], 'id' );
$metadata = $this->read_webhook_property( $event_object, 'metadata' );
$payment_method_id = $charges_data[0]['payment_method'] ?? null;
if ( ! $order ) {
return;
}
// Update missing intents because webhook can be delivered before order is processed on the client.
$meta_data_to_update = [
'_intent_id' => $intent_id,
'_charge_id' => $charge_id,
'_payment_method_id' => $payment_method_id,
WC_Payments_Utils::ORDER_INTENT_CURRENCY_META_KEY => $currency,
];
// Save mandate id, necessary for some subscription renewals.
$mandate_id = $event_data['object']['charges']['data'][0]['payment_method_details']['card']['mandate'] ?? null;
if ( $mandate_id ) {
$meta_data_to_update['_stripe_mandate_id'] = $mandate_id;
}
$application_fee_amount = $charges_data[0]['application_fee_amount'] ?? null;
if ( $application_fee_amount ) {
$meta_data_to_update['_wcpay_transaction_fee'] = WC_Payments_Utils::interpret_stripe_amount( $application_fee_amount, $currency );
}
foreach ( $meta_data_to_update as $key => $value ) {
// Override existing meta data with incoming values, if present.
if ( $value ) {
$order->update_meta_data( $key, $value );
}
}
// Save the order after updating the meta data values.
$order->save();
// This is an incoming request from WCPay server rather than an outgoing request to WCPay server.
// However, the shape of the payment intent object are the same.
// Using this extraction method will reduce the code duplication.
$payment_intent = $this->api_client->deserialize_payment_intention_object_from_array( $event_object );
$this->order_service->update_order_status_from_intent( $order, $payment_intent );
$payment_method = $charges_data[0]['payment_method_details']['type'] ?? null;
// Send the customer a card reader receipt if it's an in person payment type.
if ( Payment_Method::CARD_PRESENT === $payment_method || Payment_Method::INTERAC_PRESENT === $payment_method ) {
$merchant_settings = [
'business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ),
'support_info' => [
'address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ),
'phone' => $this->wcpay_gateway->get_option( 'account_business_support_phone' ),
'email' => $this->wcpay_gateway->get_option( 'account_business_support_email' ),
],
];
$this->receipt_service->send_customer_ipp_receipt_email( $order, $merchant_settings, $charges_data[0] );
}
// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
}
/**
* Process webhook dispute created.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
*/
private function process_webhook_dispute_created( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
$reason = $this->read_webhook_property( $event_object, 'reason' );
$amount_raw = $this->read_webhook_property( $event_object, 'amount' );
$evidence = $this->read_webhook_property( $event_object, 'evidence_details' );
$status = $this->read_webhook_property( $event_object, 'status' );
$due_by = $this->read_webhook_property( $evidence, 'due_by' );
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
$currency = $order->get_currency();
$amount_string = wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount_raw, $currency ), [ 'currency' => strtoupper( $currency ) ] );
// Explicitly add currency info if needed (multi-currency stores).
$amount = WC_Payments_Explicit_Price_Formatter::get_explicit_price_with_currency( $amount_string, $currency );
// Convert due_by to a date string in the store timezone.
$due_by = date_i18n( wc_date_format(), $due_by );
if ( ! $order ) {
throw new Invalid_Webhook_Data_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
)
);
}
$this->order_service->mark_payment_dispute_created( $order, $charge_id, $amount, $reason, $due_by, $status );
// Clear dispute caches to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::DISPUTE_STATUS_COUNTS_KEY );
$this->database_cache->delete( DATABASE_CACHE::ACTIVE_DISPUTES_KEY );
}
/**
* Process webhook dispute closed.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
*/
private function process_webhook_dispute_closed( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
$status = $this->read_webhook_property( $event_object, 'status' );
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
if ( ! $order ) {
throw new Invalid_Webhook_Data_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
)
);
}
$this->order_service->mark_payment_dispute_closed( $order, $charge_id, $status );
// Clear dispute caches to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::DISPUTE_STATUS_COUNTS_KEY );
$this->database_cache->delete( DATABASE_CACHE::ACTIVE_DISPUTES_KEY );
}
/**
* Process webhook dispute updated.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
*/
private function process_webhook_dispute_updated( $event_body ) {
$event_type = $this->read_webhook_property( $event_body, 'type' );
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
if ( ! $order ) {
throw new Invalid_Webhook_Data_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
)
);
}
switch ( $event_type ) {
case 'charge.dispute.funds_withdrawn':
$message = __( 'Payment dispute and fees have been deducted from your next payout', 'woocommerce-payments' );
break;
case 'charge.dispute.funds_reinstated':
$message = __( 'Payment dispute funds have been reinstated', 'woocommerce-payments' );
break;
default:
$message = __( 'Payment dispute has been updated', 'woocommerce-payments' );
}
$note = sprintf(
/* translators: %1: the dispute message, %2: the dispute details URL */
__( '%1$s. See <a href="%2$s">dispute overview</a> for more details.', 'woocommerce-payments' ),
$message,
add_query_arg(
[ 'id' => $charge_id ],
admin_url( 'admin.php?page=wc-admin&path=/payments/transactions/details' )
)
);
if ( $this->order_service->order_note_exists( $order, $note ) ) {
return;
}
$order->add_order_note( $note );
// Clear dispute caches to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::DISPUTE_STATUS_COUNTS_KEY );
$this->database_cache->delete( DATABASE_CACHE::ACTIVE_DISPUTES_KEY );
}
/**
* Process notification data.
*
* @param array $event_body The event that triggered the webhook.
*
* @return void
*
* @throws Invalid_Webhook_Data_Exception When data is not valid.
*/
private function process_wcpay_notification( array $event_body ) {
$note = $this->read_webhook_property( $event_body, 'data' );
// Convert exception Rest_Request_Exception to Invalid_Webhook_Data_Exception
// to be compatible with the expected exception in process().
try {
$this->remote_note_service->put_note( $note );
} catch ( Rest_Request_Exception $e ) {
throw new Invalid_Webhook_Data_Exception( $e->getMessage() );
}
}
/**
* Safely get a value from the webhook event body array.
*
* @param array $array Array to read from.
* @param string $key ID to fetch on.
*
* @return string|array|int|bool
* @throws Invalid_Webhook_Data_Exception Thrown if ID not set.
*/
private function read_webhook_property( $array, $key ) {
if ( ! isset( $array[ $key ] ) ) {
throw new Invalid_Webhook_Data_Exception(
sprintf(
/* translators: %1: ID being fetched */
__( '%1$s not found in array', 'woocommerce-payments' ),
$key
)
);
}
return $array[ $key ];
}
/**
* Safely check whether a webhook contains a property.
*
* @param array $array Array to read from.
* @param string $key ID to fetch on.
*
* @return bool
*/
private function has_webhook_property( $array, $key ) {
return isset( $array[ $key ] );
}
/**
* Gets the order related to the event.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order.
*
* @return null|WC_Order
*/
private function get_order_from_event_body( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$intent_id = $this->read_webhook_property( $event_object, 'id' );
// Look up the order related to this intent.
$order = $this->wcpay_db->order_from_intent_id( $intent_id );
if ( ! $order instanceof \WC_Order ) {
// Retrieving order with order_id in case intent_id was not properly set.
Logger::debug( 'intent_id not found, using order_id to retrieve order' );
$metadata = $this->read_webhook_property( $event_object, 'metadata' );
$order_id = $metadata['order_id'] ?? null;
// If metadata order id is null, try to read from the charges metadata.
if ( null === $order_id ) {
$charges = $this->read_webhook_property( $event_object, 'charges' );
$charge = $charges[0] ?? [];
$order_id = $charge['metadata']['order_id'] ?? null;
}
if ( $order_id ) {
$order = $this->wcpay_db->order_from_order_id( $order_id );
} elseif ( ! empty( $event_object['invoice'] ) ) {
// If the payment intent contains an invoice it is a WCPay Subscription-related intent and will be handled by the `invoice.paid` event.
return null;
}
}
if ( ! $order instanceof \WC_Order ) {
throw new Invalid_Payment_Method_Exception(
sprintf(
/* translators: %1: intent ID */
__( 'Could not find order via intent ID: %1$s', 'woocommerce-payments' ),
$intent_id
),
'order_not_found'
);
}
return $order;
}
/**
* Gets the proper failure message from the code in the error.
* Error codes from https://stripe.com/docs/error-codes.
*
* @param array $error The last payment error from the payment failed event.
*
* @return string The failure message.
*/
private function get_failure_message_from_error( $error ): string {
$code = $error['code'] ?? '';
$decline_code = $error['decline_code'] ?? '';
$message = $error['message'] ?? '';
switch ( $code ) {
case 'account_closed':
return __( "The customer's bank account has been closed.", 'woocommerce-payments' );
case 'debit_not_authorized':
return __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' );
case 'insufficient_funds':
return __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' );
case 'no_account':
return __( "The customer's bank account could not be located.", 'woocommerce-payments' );
case 'payment_method_microdeposit_failed':
return __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' );
case 'payment_method_microdeposit_verification_attempts_exceeded':
return __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' );
case 'payment_intent_mandate_invalid':
return __( 'The mandate used for this renewal payment is invalid. You may need to bring the customer back to your store and ask them to resubmit their payment information.', 'woocommerce-payments' );
case 'card_declined':
switch ( $decline_code ) {
case 'debit_notification_undelivered':
return __( "The customer's bank could not send pre-debit notification for the payment.", 'woocommerce-payments' );
case 'transaction_not_approved':
return __( 'For recurring payment greater than mandate amount or INR 15000, payment was not approved by the card holder.', 'woocommerce-payments' );
}
}
// translators: %s Stripe error message.
return sprintf( __( 'With the following message: <code>%s</code>', 'woocommerce-payments' ), $message );
}
/**
* Process webhook refund for events triggered externally.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
* @throws Invalid_Webhook_Data_Exception When the refund amount is not valid.
* @throws Order_Not_Found_Exception When unable to resolve charge ID to order.
*/
private function process_webhook_refund_triggered_externally( array $event_body ): void {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$is_refunded_event = isset( $event_body['type'] ) && 'charge.refunded' === $event_body['type'];
$status = $this->read_webhook_property( $event_object, 'status' );
if ( 'succeeded' !== $status || ! $is_refunded_event ) {
return;
}
// Fetch the details of the refund so that we can find the associated order and write a note.
$charge_id = $this->read_webhook_property( $event_object, 'id' );
$refund = $this->read_webhook_property( $event_object, 'refunds' )['data'][0]; // Most recent refund.
$refund_id = $refund['id'] ?? '';
$refund_reason = $refund['reason'] ?? '';
$refund_balance_transaction_id = $refund['balance_transaction'] ?? '';
$charge_amount = $this->read_webhook_property( $event_object, 'amount' );
$currency = $this->read_webhook_property( $event_object, 'currency' );
$refunded_amount = WC_Payments_Utils::interpret_stripe_amount( $refund['amount'], $currency );
$is_partial_refund = $refund['amount'] < $charge_amount;
// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
if ( ! $order ) {
throw new Order_Not_Found_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
),
'order_not_found'
);
}
// Only care about refunds that are triggered externally, i.e. outside WP Admin.
// Refunds triggered in WP Admin are handled by WC_Payment_Gateway_WCPay::process_refund.
$wc_refunds = $order->get_refunds();
if ( ! empty( $wc_refunds ) ) {
foreach ( $wc_refunds as $wc_refund ) {
$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
if ( $refund_id === $wcpay_refund_id ) {
return;
}
}
}
if ( $charge_amount < 0 || $refunded_amount > $order->get_total() ) {
throw new Invalid_Webhook_Data_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'The refund amount is not valid for charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
)
);
}
$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, ( ! $is_partial_refund ? $order->get_items() : [] ) );
// Process the refund in the order service.
$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
}
}
@@ -0,0 +1,218 @@
<?php
/**
* WC_Payments_Webhook_Reliability_Service class
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
use WCPay\Exceptions\API_Exception;
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
use WCPay\Logger;
/**
* Improve webhook reliability by fetching failed events from the server,
* then process them with ActionScheduler
*/
class WC_Payments_Webhook_Reliability_Service {
const CONTINUOUS_FETCH_FLAG_EVENTS_LIST = 'has_more';
const CONTINUOUS_FETCH_FLAG_ACCOUNT_DATA = 'has_more_failed_events';
const WEBHOOK_FETCH_EVENTS_ACTION = 'wcpay_webhook_fetch_events';
const WEBHOOK_PROCESS_EVENT_ACTION = 'wcpay_webhook_process_event';
/**
* Client for making requests to the WooCommerce Payments API.
*
* @var WC_Payments_API_Client
*/
private $payments_api_client;
/**
* WC_Payments_Action_Scheduler_Service.
*
* @var WC_Payments_Action_Scheduler_Service
*/
private $action_scheduler_service;
/**
* Webhook Processing Service.
*
* @var WC_Payments_Webhook_Processing_Service
*/
private $webhook_processing_service;
/**
* WC_Payments_Webhook_Reliability_Service constructor.
*
* @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client.
* @param WC_Payments_Action_Scheduler_Service $action_scheduler_service Wrapper for ActionScheduler service.
* @param WC_Payments_Webhook_Processing_Service $webhook_processing_service WC_Payments_Webhook_Processing_Service instance.
*/
public function __construct(
WC_Payments_API_Client $payments_api_client,
WC_Payments_Action_Scheduler_Service $action_scheduler_service,
WC_Payments_Webhook_Processing_Service $webhook_processing_service
) {
$this->payments_api_client = $payments_api_client;
$this->action_scheduler_service = $action_scheduler_service;
$this->webhook_processing_service = $webhook_processing_service;
// Note: Sometimes the `woocommerce_payments_account_refreshed` hook is ran before ActionScheduler is initialized.
// In that case, we will not be able to schedule jobs. We will just ignore it.
add_action( 'woocommerce_payments_account_refreshed', [ $this, 'maybe_schedule_fetch_events' ] );
add_action( self::WEBHOOK_FETCH_EVENTS_ACTION, [ $this, 'fetch_events_and_schedule_processing_jobs' ] );
add_action( self::WEBHOOK_PROCESS_EVENT_ACTION, [ $this, 'process_event' ] );
}
/**
* During the account data refresh, check the relevant flag to remaining failed events on the WooCommerce Payments server,
* and decide whether scheduling a job to fetch them.
*
* @param mixed|array $account Account data retrieved from WooCommerce Payments server.
*
* @return void
*/
public function maybe_schedule_fetch_events( $account ) {
if ( ! is_array( $account ) ) {
return;
}
if ( $account[ self::CONTINUOUS_FETCH_FLAG_ACCOUNT_DATA ] ?? false ) {
$this->schedule_fetch_events();
}
}
/**
* Fetch failed events from the WooCommerce Payments server through ActionScheduler.
*
* @return void
*/
public function fetch_events_and_schedule_processing_jobs() {
try {
$payload = $this->payments_api_client->get_failed_webhook_events();
} catch ( API_Exception $e ) {
Logger::error( 'Can not fetch failed events from the server. Error:' . $e->getMessage() );
return;
}
if ( $payload[ self::CONTINUOUS_FETCH_FLAG_EVENTS_LIST ] ?? false ) {
$this->schedule_fetch_events();
}
// Save the data, and schedule a job for each event.
$events = $payload['data'] ?? [];
foreach ( $events as $event ) {
if ( ! isset( $event['id'] ) ) {
Logger::error( 'Event ID does not exist. Event data: ' . var_export( $event, true ) ); // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export
continue;
}
$this->set_event_data( $event );
$this->schedule_process_event( $event['id'] );
}
}
/**
* Process an event through ActionScheduler.
*
* @param string $event_id Event ID.
*
* @return void
*/
public function process_event( string $event_id ) {
Logger::info( 'Start processing event: ' . $event_id );
$event_data = $this->get_event_data( $event_id );
$this->delete_event_data( $event_id );
if ( null === $event_data ) {
Logger::error( 'Stop processing as no data available for event: ' . $event_id );
return;
}
try {
$this->webhook_processing_service->process( $event_data );
Logger::info( 'Successfully processed event ' . $event_id );
} catch ( Invalid_Webhook_Data_Exception $e ) {
Logger::error( 'Failed processing event ' . $event_id . '. Reason: ' . $e->getMessage() );
}
}
/**
* Schedule a job to process an event later.
*
* @param string $event_id Event ID.
*
* @return void
*/
private function schedule_process_event( string $event_id ) {
$this->action_scheduler_service->schedule_job( time(), self::WEBHOOK_PROCESS_EVENT_ACTION, [ 'event_id' => $event_id ] );
Logger::info( 'Successfully scheduled a job to process event: ' . $event_id );
}
/**
* Schedule a job to fetch failed events.
*
* We will bail if this is called too early and ActionScheduler is not initialized.
*
* @return void
*/
private function schedule_fetch_events() {
$this->action_scheduler_service->schedule_job( time(), self::WEBHOOK_FETCH_EVENTS_ACTION );
Logger::info( 'Successfully scheduled a job to fetch failed events from the server.' );
}
/**
* Get the transient name to interact with the storage.
*
* @param string $event_id Event ID.
*
* @return string
*/
private function get_transient_name_for_event_id( string $event_id ): string {
// Use md5 to overcome the limit of transient name (172 characters) while Stripe event ID can be up to 255.
return 'wcpay_failed_event_' . md5( $event_id );
}
/**
* Save the event data.
*
* @param array $event_data Event data.
*
* @return bool True if the value was set, false otherwise.
*/
public function set_event_data( array $event_data ) {
if ( ! isset( $event_data['id'] ) ) {
return false;
}
return set_transient( $this->get_transient_name_for_event_id( $event_data['id'] ), $event_data, DAY_IN_SECONDS );
}
/**
* Delete the event data.
*
* @param string $event_id Event ID.
*
* @return bool True if the event data is deleted, false otherwise.
*/
public function delete_event_data( string $event_id ): bool {
return delete_transient( $this->get_transient_name_for_event_id( $event_id ) );
}
/**
* Retrieve the event data. Return null if the data does not exist.
*
* @param string $event_id Event ID.
*
* @return ?array
*/
public function get_event_data( string $event_id ) {
$data = get_transient( $this->get_transient_name_for_event_id( $event_id ) );
return false === $data ? null : $data;
}
}
@@ -0,0 +1,417 @@
<?php
/**
* Class WC_Payments_WooPay_Button_Handler
* Adds support for the WooPay express checkout button.
*
* Borrowed heavily from the WC_Payments_Payment_Request_Button_Handler class.
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// TODO: Not sure which of these are needed yet.
use WCPay\Exceptions\Invalid_Price_Exception;
use WCPay\Logger;
use WCPay\Payment_Information;
use WCPay\WooPay\WooPay_Session;
use WCPay\WooPay\WooPay_Utilities;
/**
* WC_Payments_WooPay_Button_Handler class.
*/
class WC_Payments_WooPay_Button_Handler {
const BUTTON_LOCATIONS = 'platform_checkout_button_locations';
/**
* WC_Payments_Account instance to get information about the account
*
* @var WC_Payments_Account
*/
private $account;
/**
* WC_Payment_Gateway_WCPay instance.
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* WooPay_Utilities instance.
*
* @var WooPay_Utilities
*/
private $woopay_utilities;
/**
* Express Checkout Helper instance.
*
* @var WC_Payments_Express_Checkout_Button_Helper
*/
private $express_checkout_helper;
/**
* Initialize class actions.
*
* @param WC_Payments_Account $account Account information.
* @param WC_Payment_Gateway_WCPay $gateway WCPay gateway.
* @param WooPay_Utilities $woopay_utilities WCPay gateway.
* @param WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper Express checkout helper.
*/
public function __construct( WC_Payments_Account $account, WC_Payment_Gateway_WCPay $gateway, WooPay_Utilities $woopay_utilities, WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper ) {
$this->account = $account;
$this->gateway = $gateway;
$this->woopay_utilities = $woopay_utilities;
$this->express_checkout_helper = $express_checkout_helper;
}
/**
* Indicates eligibility for WooPay via feature flag.
*
* @var bool
*/
private $is_woopay_eligible;
/**
* Indicates whether WooPay is enabled.
*
* @var bool
*/
private $is_woopay_enabled;
/**
* Indicates whether WooPay express checkout is enabled.
*
* @var bool
*/
private $is_woopay_express_button_enabled;
/**
* Indicates whether WooPay and WooPay express checkout are enabled.
*
* @return bool
*/
public function is_woopay_enabled() {
return $this->is_woopay_eligible && $this->is_woopay_enabled && $this->is_woopay_express_button_enabled;
}
/**
* Initialize hooks.
*
* @return void
*/
public function init() {
// Checks if WCPay is enabled.
if ( ! $this->gateway->is_enabled() ) {
return;
}
// Checks if WooPay is enabled.
$this->is_woopay_eligible = WC_Payments_Features::is_woopay_eligible(); // Feature flag.
$this->is_woopay_enabled = 'yes' === $this->gateway->get_option( 'platform_checkout', 'no' );
$this->is_woopay_express_button_enabled = WC_Payments_Features::is_woopay_express_checkout_enabled();
if ( ! $this->is_woopay_enabled() ) {
return;
}
// Don't load for change payment method page.
if ( isset( $_GET['change_payment_method'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
return;
}
// Create WooPay button location option if it doesn't exist and enable all locations by default.
if ( ! array_key_exists( self::BUTTON_LOCATIONS, get_option( 'woocommerce_woocommerce_payments_settings' ) ) ) {
if ( isset( $this->gateway->get_form_fields()[ self::BUTTON_LOCATIONS ]['options'] ) ) {
$all_locations = $this->gateway->get_form_fields()[ self::BUTTON_LOCATIONS ]['options'];
$this->gateway->update_option( self::BUTTON_LOCATIONS, array_keys( $all_locations ) );
WC_Payments::woopay_tracker()->woopay_locations_updated( $all_locations, array_keys( $all_locations ) );
}
}
add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] );
add_filter( 'wcpay_payment_fields_js_config', [ $this, 'add_woopay_config' ] );
add_action( 'wp_ajax_woopay_express_checkout_button_show_error_notice', [ $this, 'show_error_notice' ] );
add_action( 'wp_ajax_nopriv_woopay_express_checkout_button_show_error_notice', [ $this, 'show_error_notice' ] );
}
/**
* Add the woopay button config to wcpay_config.
*
* @param array $config The existing config array.
*
* @return array The modified config array.
*/
public function add_woopay_config( $config ) {
$user = wp_get_current_user();
$config['woopayButton'] = $this->get_button_settings();
$config['woopayButtonNonce'] = wp_create_nonce( 'woopay_button_nonce' );
$config['addToCartNonce'] = wp_create_nonce( 'wcpay-add-to-cart' );
$config['shouldShowWooPayButton'] = $this->should_show_woopay_button();
$config['woopaySessionEmail'] = WooPay_Session::get_user_email( $user );
return $config;
}
/**
* Load public scripts and styles.
*/
public function scripts() {
// Don't load scripts if we should not show the button.
if ( ! $this->should_show_woopay_button() ) {
return;
}
WC_Payments::register_script_with_dependencies( 'WCPAY_WOOPAY_EXPRESS_BUTTON', 'dist/woopay-express-button' );
$wcpay_config = rawurlencode( wp_json_encode( WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config() ) );
wp_add_inline_script(
'WCPAY_WOOPAY_EXPRESS_BUTTON',
"
var wcpayConfig = wcpayConfig || JSON.parse( decodeURIComponent( '" . esc_js( $wcpay_config ) . "' ) );
",
'before'
);
wp_set_script_translations( 'WCPAY_WOOPAY_EXPRESS_BUTTON', 'woocommerce-payments' );
wp_enqueue_script( 'WCPAY_WOOPAY_EXPRESS_BUTTON' );
WC_Payments_Utils::enqueue_style(
'WCPAY_WOOPAY',
plugins_url( 'dist/woopay.css', WCPAY_PLUGIN_FILE ),
[],
WCPAY_VERSION_NUMBER,
'all'
);
}
/**
* Returns the error notice HTML.
*/
public function show_error_notice() {
$is_nonce_valid = check_ajax_referer( 'woopay_button_nonce', false, false );
if ( ! $is_nonce_valid ) {
wp_send_json_error(
__( 'You arent authorized to do that.', 'woocommerce-payments' ),
403
);
}
$message = isset( $_POST['message'] ) ? sanitize_text_field( wp_unslash( $_POST['message'] ) ) : '';
// $message has already been translated.
wc_add_notice( $message, 'error' );
$notice = wc_print_notices( true );
wp_send_json_success(
[
'notice' => $notice,
]
);
wp_die();
}
/**
* The settings for the `button` attribute - they depend on the "grouped settings" flag value.
*
* @return array
*/
public function get_button_settings() {
$common_settings = $this->express_checkout_helper->get_common_button_settings();
$woopay_button_settings = [
'size' => $this->gateway->get_option( 'payment_request_button_size' ),
'context' => $this->express_checkout_helper->get_button_context(),
];
return array_merge( $common_settings, $woopay_button_settings );
}
/**
* Checks whether Payment Request Button should be available on this page.
*
* @return bool
*/
public function should_show_woopay_button() {
// WCPay is not available.
$gateways = WC()->payment_gateways->get_available_payment_gateways();
if ( ! isset( $gateways['woocommerce_payments'] ) ) {
return false;
}
// WooPay is not enabled.
if ( ! $this->is_woopay_enabled() ) {
return false;
}
// Page not supported.
if ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_checkout() ) {
return false;
}
// Check if WooPay is available in the user country.
if ( ! $this->woopay_utilities->is_country_available( $this->gateway ) ) {
return false;
}
// Product page, but not available in settings.
if ( $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_available_at( 'product', self::BUTTON_LOCATIONS ) ) {
return false;
}
// Checkout page, but not available in settings.
if ( $this->express_checkout_helper->is_checkout() && ! $this->express_checkout_helper->is_available_at( 'checkout', self::BUTTON_LOCATIONS ) ) {
return false;
}
// Cart page, but not available in settings.
if ( $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_available_at( 'cart', self::BUTTON_LOCATIONS ) ) {
return false;
}
// Product page, but has unsupported product type.
if ( $this->express_checkout_helper->is_product() && ! $this->is_product_supported() ) {
Logger::log( 'Product page has unsupported product type ( WooPay Express button disabled )' );
return false;
}
// Cart has unsupported product type.
if ( ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) && ! $this->has_allowed_items_in_cart() ) {
Logger::log( 'Items in the cart have unsupported product type ( WooPay Express button disabled )' );
return false;
}
if ( ! is_user_logged_in() ) {
// On product page for a subscription product, but not logged in, making WooPay unavailable.
if ( $this->express_checkout_helper->is_product() ) {
$current_product = wc_get_product();
if ( $current_product && $this->express_checkout_helper->is_product_subscription( $current_product ) ) {
return false;
}
}
// On cart or checkout page with a subscription product in cart, but not logged in, making WooPay unavailable.
if ( ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) && class_exists( 'WC_Subscriptions_Cart' ) && WC_Subscriptions_Cart::cart_contains_subscription() ) {
// Check cart for subscription products.
return false;
}
// If guest checkout is not allowed, and customer is not logged in, disable the WooPay button.
if ( ! $this->woopay_utilities->is_guest_checkout_enabled() ) {
return false;
}
}
/**
* TODO: We need to do some research here and see if there are any product types that we
* absolutely cannot support with WooPay at this time. There are some examples in the
* `WC_Payments_Payment_Request_Button_Handler->is_product_supported()` method.
*/
return true;
}
/**
* Display the payment request button.
*/
public function display_woopay_button_html() {
if ( ! $this->should_show_woopay_button() ) {
return;
}
$settings = $this->get_button_settings();
?>
<div id="wcpay-woopay-button" data-product_page=<?php echo esc_attr( $this->express_checkout_helper->is_product() ); ?>>
<?php // The WooPay express checkout button React component will go here. This is rendered as disabled for now, until the page is initialized. ?>
<button
class="woopay-express-button"
aria-label="<?php esc_attr_e( 'WooPay', 'woocommerce-payments' ); ?>"
data-type="<?php echo esc_attr( $settings['type'] ); ?>"
data-theme="<?php echo esc_attr( $settings['theme'] ); ?>"
data-size="<?php echo esc_attr( $settings['size'] ); ?>"
style="height: <?php echo esc_attr( $settings['height'] ); ?>px; border-radius: <?php echo esc_attr( $settings['radius'] ); ?>px"
disabled
></button>
</div>
<?php
}
/**
* Whether the product page has a product compatible with the WooPay Express button.
*
* @return boolean
*/
private function is_product_supported() {
$product = $this->express_checkout_helper->get_product();
$is_supported = true;
if ( ! is_object( $product ) ) {
$is_supported = false;
}
// External/affiliate products are not supported.
if ( is_a( $product, 'WC_Product' ) && $product->is_type( 'external' ) ) {
$is_supported = false;
}
// Pre Orders products to be charged upon release are not supported.
if (
class_exists( 'WC_Pre_Orders_Product' ) &&
method_exists( 'WC_Pre_Orders_Product', 'product_is_charged_upon_release' ) &&
WC_Pre_Orders_Product::product_is_charged_upon_release( $product )
) {
$is_supported = false;
}
// WC Bookings require confirmation products are not supported.
if (
is_a( $product, 'WC_Product_Booking' ) &&
method_exists( $product, 'get_requires_confirmation' ) &&
$product->get_requires_confirmation()
) {
$is_supported = false;
}
return apply_filters( 'wcpay_woopay_button_is_product_supported', $is_supported, $product );
}
/**
* Checks the cart to see if the WooPay Express button supports all of its items.
*
* @todo Abstract this. This is a copy of the same method in the `WC_Payments_Payment_Request_Button_Handler` class.
*
* @return boolean
*/
private function has_allowed_items_in_cart() {
$is_supported = true;
/**
* Psalm throws an error here even though we check the class existence.
*
* @psalm-suppress UndefinedClass
*/
// We don't support pre-order products to be paid upon release.
if ( class_exists( 'WC_Pre_Orders_Cart' ) && class_exists( 'WC_Pre_Orders_Product' ) ) {
if (
WC_Pre_Orders_Cart::cart_contains_pre_order() &&
WC_Pre_Orders_Product::product_is_charged_upon_release( WC_Pre_Orders_Cart::get_pre_order_product() )
) {
$is_supported = false;
}
}
return apply_filters( 'wcpay_platform_checkout_button_are_cart_items_supported', $is_supported );
}
}
@@ -0,0 +1,162 @@
<?php
/**
* Class WC_Payments_WooPay_Direct_Checkout
* Adds support for WooPay direct checkout feature.
*
* @package WooCommerce\Payments
*/
use WCPay\WooPay\WooPay_Utilities;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class WC_Payments_WooPay_Direct_Checkout.
*/
class WC_Payments_WooPay_Direct_Checkout {
/**
* WooPay_Utilities instance.
*
* @var WooPay_Utilities
*/
private $woopay_utilities;
/**
* Initialize class actions.
*
* @param WooPay_Utilities $woopay_utilities WooPay utilities.
*/
public function __construct( WooPay_Utilities $woopay_utilities ) {
$this->woopay_utilities = $woopay_utilities;
}
/**
* Initialize the hooks.
*
* @return void
*/
public function init() {
add_action( 'wp_footer', [ $this, 'scripts' ] );
add_filter( 'woocommerce_create_order', [ $this, 'maybe_use_store_api_draft_order_id' ] );
}
/**
* This filter is used to ensure the session's store_api_draft_order is used, if it exists.
* This prevents a bug where the store_api_draft_order is not used and instead, a new
* order_awaiting_payment is created during the checkout request. The bug being evident
* if a product had one remaining stock and the store_api_draft_order was reserving it,
* an order would fail to be placed since when order_awaiting_payment is created, it would
* not be able to reserve the one stock.
*
* @param int $order_id The order ID being used.
* @return int|mixed The new order ID to use.
*/
public function maybe_use_store_api_draft_order_id( $order_id ) {
// Only apply this filter during the checkout request.
$is_checkout = defined( 'WOOCOMMERCE_CHECKOUT' ) && WOOCOMMERCE_CHECKOUT;
// Only apply this filter if the order ID is not already defined.
$is_already_defined_order_id = ! empty( $order_id );
// Only apply this filter if the session doesn't already have an order_awaiting_payment.
$is_order_awaiting_payment = isset( WC()->session->order_awaiting_payment );
// Only apply this filter if draft order ID exists.
$has_draft_order = ! empty( WC()->session->get( 'store_api_draft_order' ) );
if ( ! $is_checkout || $is_already_defined_order_id || $is_order_awaiting_payment || ! $has_draft_order ) {
return $order_id;
}
$draft_order_id = absint( WC()->session->get( 'store_api_draft_order' ) );
// Set the order status to "pending" payment, so that it can be resumed.
$draft_order = wc_get_order( $draft_order_id );
$draft_order->set_status( 'pending' );
$draft_order->save();
// Move $draft_order_id in session, from store_api_draft_order to order_awaiting_payment.
WC()->session->set( 'store_api_draft_order', null );
WC()->session->set( 'order_awaiting_payment', $draft_order_id );
return $order_id;
}
/**
* Enqueue scripts.
*
* @return void
*/
public function scripts() {
if ( ! $this->should_enqueue_scripts() ) {
return;
}
if ( ! $this->woopay_utilities->should_enable_woopay_on_cart_or_checkout() ) {
return;
}
// Enqueue the WCPay common config script only if it hasn't been enqueued yet.
// This may happen when Direct Checkout is being enqueued on pages that are not the cart page,
// such as the home and shop pages.
if ( function_exists( 'did_filter' ) && did_filter( 'wcpay_payment_fields_js_config' ) === 0 ) {
WC_Payments::enqueue_woopay_common_config_script();
}
WC_Payments::register_script_with_dependencies( 'WCPAY_WOOPAY_DIRECT_CHECKOUT', 'dist/woopay-direct-checkout' );
$direct_checkout_settings = [
'params' => [
'is_product_page' => $this->is_product_page(),
],
];
wp_localize_script(
'WCPAY_WOOPAY_DIRECT_CHECKOUT',
'wcpayWooPayDirectCheckout',
$direct_checkout_settings
);
wp_enqueue_script( 'WCPAY_WOOPAY_DIRECT_CHECKOUT' );
}
/**
* Check if the direct checkout scripts should be enqueued on the page.
*
* Scripts should be enqueued if:
* - The current page is the cart page.
* - The current page has a cart block.
* - The current page has the blocks mini cart widget, i.e 'woocommerce_blocks_cart_enqueue_data' has been fired.
* - The current page has the cart fragments script enqueued. which is enqueued by the shortcode mini cart widget.
*
* @return bool True if the scripts should be enqueued, false otherwise.
*/
private function should_enqueue_scripts(): bool {
return $this->is_cart_page()
|| did_action( 'woocommerce_blocks_cart_enqueue_data' ) > 0
|| ( wp_script_is( 'wc-cart-fragments', 'enqueued' ) && ! $this->is_checkout_page() );
}
/**
* Check if the current page is the cart page.
*
* @return bool True if the current page is the cart page, false otherwise.
*/
private function is_cart_page(): bool {
return is_cart() || has_block( 'woocommerce/cart' );
}
/**
* Check if the current page is the checkout page.
*
* @return bool True if the current page is the checkout page, false otherwise.
*/
private function is_checkout_page(): bool {
return is_checkout() || has_block( 'woocommerce/checkout' );
}
/**
* Check if the current page is the product page.
*
* @return bool True if the current page is the product page, false otherwise.
*/
private function is_product_page() {
return is_product() || wc_post_content_has_shortcode( 'product_page' );
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,645 @@
<?php
/**
* Class WooPay_Tracker
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use Jetpack_Tracks_Client;
use Jetpack_Tracks_Event;
use WC_Payments;
use WC_Payments_Features;
use WCPay\Constants\Country_Code;
use WP_Error;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* Track WooPay related events
*/
class WooPay_Tracker extends Jetpack_Tracks_Client {
/**
* WCPay user event prefix
*
* @var string
*/
private static $user_prefix = 'wcpay';
/**
* WooPay admin event prefix
*
* @var string
*/
private static $admin_prefix = 'wcadmin';
/**
* WCPay http interface.
*
* @var Object
*/
private $http;
/**
* Base URL for stats counter.
*
* @var string
*/
private static $pixel_base_url = 'https://pixel.wp.com/g.gif';
/**
* Constructor.
*
* @param \WC_Payments_Http_Interface $http A class implementing WC_Payments_Http_Interface.
*/
public function __construct( $http ) {
$this->http = $http;
add_action( 'wp_ajax_platform_tracks', [ $this, 'ajax_tracks' ] );
add_action( 'wp_ajax_nopriv_platform_tracks', [ $this, 'ajax_tracks' ] );
add_action( 'wp_ajax_get_identity', [ $this, 'ajax_tracks_id' ] );
add_action( 'wp_ajax_nopriv_get_identity', [ $this, 'ajax_tracks_id' ] );
// Actions that should result in recorded Tracks events.
add_action( 'woocommerce_after_checkout_form', [ $this, 'classic_checkout_start' ] );
add_action( 'woocommerce_after_cart', [ $this, 'classic_cart_page_view' ] );
add_action( 'woocommerce_after_single_product', [ $this, 'classic_product_page_view' ] );
add_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after', [ $this, 'blocks_checkout_start' ] );
add_action( 'woocommerce_blocks_enqueue_cart_block_scripts_after', [ $this, 'blocks_cart_page_view' ] );
add_action( 'woocommerce_checkout_order_processed', [ $this, 'checkout_order_processed' ], 10, 2 );
add_action( 'woocommerce_store_api_checkout_order_processed', [ $this, 'checkout_order_processed' ], 10, 2 );
add_action( 'woocommerce_payments_save_user_in_woopay', [ $this, 'must_save_payment_method_to_platform' ] );
add_action( 'wp_footer', [ $this, 'add_frontend_tracks_scripts' ] );
add_action( 'before_woocommerce_pay_form', [ $this, 'pay_for_order_page_view' ] );
add_action( 'woocommerce_thankyou', [ $this, 'thank_you_page_view' ] );
}
/**
* Override jetpack-tracking's ajax handling to use internal maybe_record_event method.
*/
public function ajax_tracks() {
// Check for nonce.
if (
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
empty( $_REQUEST['tracksNonce'] ) || ! wp_verify_nonce( $_REQUEST['tracksNonce'], 'platform_tracks_nonce' )
) {
wp_send_json_error(
__( 'You arent authorized to do that.', 'woocommerce-payments' ),
403
);
}
if ( ! isset( $_REQUEST['tracksEventName'] ) ) {
wp_send_json_error(
__( 'No valid event name or type.', 'woocommerce-payments' ),
403
);
}
$tracks_data = [];
if ( isset( $_REQUEST['tracksEventProp'] ) ) {
// tracksEventProp is a JSON-encoded string.
$event_prop = json_decode( wc_clean( wp_unslash( $_REQUEST['tracksEventProp'] ) ), true );
if ( is_array( $event_prop ) ) {
$tracks_data = $event_prop;
}
}
$this->maybe_record_event( sanitize_text_field( wp_unslash( $_REQUEST['tracksEventName'] ) ), $tracks_data );
wp_send_json_success();
}
/**
* Get tracks ID of the current user
*/
public function ajax_tracks_id() {
$tracks_id = $this->tracks_get_identity();
if ( $tracks_id ) {
wp_send_json_success( $tracks_id );
}
}
/**
* Generic method to track user events on WooPay enabled stores.
*
* @param string $event name of the event.
* @param array $data array of event properties.
*/
public function maybe_record_event( $event, $data = [] ) {
// Top level events should not be namespaced.
if ( '_aliasUser' !== $event ) {
$event = self::$user_prefix . '_' . $event;
}
return $this->tracks_record_event( $event, $data );
}
/**
* Track shopper events with the wcpay_prefix.
*
* @param string $event name of the event.
* @param array $data array of event properties.
* @param bool $record_on_frontend whether to record the event on the frontend to prevent cache break.
*/
public function maybe_record_wcpay_shopper_event( $event, $data = [], $record_on_frontend = true ) {
$is_admin_event = false;
$track_on_all_stores = true;
// Record the event immediately.
if ( ! $record_on_frontend ) {
// Top level events should not be namespaced.
if ( '_aliasUser' !== $event ) {
$event = self::$user_prefix . '_' . $event;
}
return $this->tracks_record_event( $event, $data, $is_admin_event, $track_on_all_stores );
}
// Route the event through frontend to avoid setting cookies on page load.
$data['record_event_data'] = compact( 'is_admin_event', 'track_on_all_stores' );
add_filter(
'wcpay_frontend_tracks',
function ( $tracks ) use ( $event, $data ) {
$tracks[] = [
'event' => $event,
'properties' => $data,
];
return $tracks;
}
);
}
/**
* Generic method to track admin events on all WCPay stores.
*
* @param string $event name of the event.
* @param array $data array of event properties.
*/
public function maybe_record_admin_event( $event, $data = [] ) {
// Top level events should not be namespaced.
if ( '_aliasUser' !== $event ) {
$event = self::$admin_prefix . '_' . $event;
}
$is_admin_event = true;
return $this->tracks_record_event( $event, $data, $is_admin_event );
}
/**
* Check whether the store country is eligible for Tracks.
*
* @return bool
*/
public function is_country_tracks_eligible() {
if ( ! function_exists( 'wc_get_base_location' ) ) {
return false;
}
$store_base_location = wc_get_base_location();
return ! empty( $store_base_location['country'] ) && Country_Code::UNITED_STATES === $store_base_location['country'];
}
/**
* Override parent method to omit the jetpack TOS check and include custom tracking conditions.
*
* @param bool $is_admin_event Indicate whether the event is emitted from admin area.
* @param bool $track_on_all_stores Indicate whether the event should be tracked on all stores.
*
* @return bool
*/
public function should_enable_tracking( $is_admin_event = false, $track_on_all_stores = false ) {
// Don't track if the gateway is not enabled.
$gateway = \WC_Payments::get_gateway();
if ( ! $gateway->is_enabled() ) {
return false;
}
// Don't track if the account is not connected.
$account = WC_Payments::get_account_service();
if ( is_null( $account ) || ! $account->is_stripe_connected() ) {
return false;
}
// Don't track any non-US stores.
if ( ! $this->is_country_tracks_eligible() ) {
return false;
}
// Always respect the user specific opt-out cookie.
if ( ! empty( $_COOKIE['tk_opt-out'] ) ) {
return false;
}
// Track all WooPay events from the admin area.
if ( $is_admin_event ) {
return true;
}
// For all other events ensure:
// 1. Only site pages are tracked.
// 2. Site Admin activity in site pages are not tracked.
// 3. If track_on_all_stores is enabled, track all events regardless of WooPay eligibility.
// 4. Otherwise, track only when WooPay is active.
// Track only site pages.
if ( is_admin() && ! wp_doing_ajax() ) {
return false;
}
// Don't track site admins.
if ( is_user_logged_in() && in_array( 'administrator', wp_get_current_user()->roles, true ) ) {
return false;
}
if ( $track_on_all_stores ) {
return true;
}
// For the remaining events, don't track when woopay is disabled.
$is_woopay_eligible = WC_Payments_Features::is_woopay_eligible(); // Feature flag.
$is_woopay_enabled = 'yes' === $gateway->get_option( 'platform_checkout', 'no' );
if ( ! ( $is_woopay_eligible && $is_woopay_enabled ) ) {
return false;
}
return true;
}
/**
* Record an event in Tracks - this is the preferred way to record events from PHP.
*
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
* @param bool $is_admin_event Indicate whether the event is emitted from admin area.
* @param bool $track_on_all_stores Indicate whether the event should be tracked on all stores.
*
* @return bool|array|\WP_Error|\Jetpack_Tracks_Event
*/
public function tracks_record_event( $event_name, $properties = [], $is_admin_event = false, $track_on_all_stores = false ) {
$user = wp_get_current_user();
// We don't want to track user events during unit tests/CI runs.
if ( $user instanceof \WP_User && 'wptests_capabilities' === $user->cap_key ) {
return false;
}
$properties = apply_filters( 'wcpay_tracks_event_properties', $properties, $event_name );
if ( isset( $properties['record_event_data'] ) ) {
if ( isset( $properties['record_event_data']['is_admin_event'] ) ) {
$is_admin_event = $properties['record_event_data']['is_admin_event'];
}
if ( isset( $properties['record_event_data']['track_on_all_stores'] ) ) {
$track_on_all_stores = $properties['record_event_data']['track_on_all_stores'];
}
unset( $properties['record_event_data'] );
}
if ( ! $this->should_enable_tracking( $is_admin_event, $track_on_all_stores ) ) {
return false;
}
$event_obj = $this->tracks_build_event_obj( $user, $event_name, $properties );
if ( is_wp_error( $event_obj ) ) {
return $event_obj;
}
$pixel = $event_obj->build_pixel_url( $event_obj );
if ( ! $pixel ) {
return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
}
return self::record_pixel( $pixel );
}
/**
* Procedurally build a Tracks Event Object.
*
* @param \WP_User $user WP_user object.
* @param string $event_name The name of the event.
* @param array $properties Custom properties to send with the event.
*
* @return \Jetpack_Tracks_Event|\WP_Error
*/
private function tracks_build_event_obj( $user, $event_name, $properties = [] ) {
$identity = $this->tracks_get_identity();
$site_url = get_option( 'siteurl' );
$properties['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';
$properties['blog_url'] = $site_url;
$properties['blog_id'] = \Jetpack_Options::get_option( 'id' );
$properties['user_lang'] = $user->get( 'WPLANG' );
// Add event property for test mode vs. live mode events.
$properties['test_mode'] = WC_Payments::mode()->is_test() ? 1 : 0;
$properties['wcpay_version'] = WCPAY_VERSION_NUMBER;
// Add client's user agent to the event properties.
if ( ! empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
$properties['_via_ua'] = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
}
$blog_details = [
'blog_lang' => isset( $properties['blog_lang'] ) ? $properties['blog_lang'] : get_bloginfo( 'language' ),
];
$timestamp = round( microtime( true ) * 1000 );
$timestamp_string = is_string( $timestamp ) ? $timestamp : number_format( $timestamp, 0, '', '' );
/**
* Ignore incorrect argument definition in Jetpack_Tracks_Event.
*
* @psalm-suppress InvalidArgument
*/
return new \Jetpack_Tracks_Event(
array_merge(
$blog_details,
(array) $properties,
$identity,
[
'_en' => $event_name,
'_ts' => $timestamp_string,
]
)
);
}
/**
* Get the identity to send to tracks.
*
* @return array $identity
*/
public function tracks_get_identity() {
$user_id = get_current_user_id();
// Meta is set, and user is still connected. Use WPCOM ID.
$wpcom_id = get_user_meta( $user_id, 'jetpack_tracks_wpcom_id', true );
if ( $wpcom_id && $this->http->is_user_connected( $user_id ) ) {
return [
'_ut' => 'wpcom:user_id',
'_ui' => $wpcom_id,
];
}
// User is connected, but no meta is set yet. Use WPCOM ID and set meta.
if ( $this->http->is_user_connected( $user_id ) ) {
$wpcom_user_data = $this->http->get_connected_user_data( $user_id );
update_user_meta( $user_id, 'jetpack_tracks_wpcom_id', $wpcom_user_data['ID'] );
return [
'_ut' => 'wpcom:user_id',
'_ui' => $wpcom_user_data['ID'],
];
}
// User isn't linked at all. Fall back to anonymous ID.
$anon_id = get_user_meta( $user_id, 'jetpack_tracks_anon_id', true );
if ( ! $anon_id ) {
$anon_id = \Jetpack_Tracks_Client::get_anon_id();
add_user_meta( $user_id, 'jetpack_tracks_anon_id', $anon_id, false );
}
return [
'_ut' => 'anon',
'_ui' => $anon_id,
];
}
/**
* Record a Tracks event that the classic checkout page has loaded.
*/
public function classic_checkout_start() {
$is_woopay_enabled = WC_Payments_Features::is_woopay_enabled();
$this->maybe_record_wcpay_shopper_event(
'checkout_page_view',
[
'theme_type' => 'short_code',
'woopay_enabled' => $is_woopay_enabled,
]
);
}
/**
* Record a Tracks event that the blocks checkout page has loaded.
*/
public function blocks_checkout_start() {
$is_woopay_enabled = WC_Payments_Features::is_woopay_enabled();
$this->maybe_record_wcpay_shopper_event(
'checkout_page_view',
[
'theme_type' => 'blocks',
'woopay_enabled' => $is_woopay_enabled,
]
);
}
/**
* Record a Tracks event that the classic cart page has loaded.
*/
public function classic_cart_page_view() {
$this->maybe_record_wcpay_shopper_event(
'cart_page_view',
[
'theme_type' => 'short_code',
]
);
}
/**
* Record a Tracks event that the blocks cart page has loaded.
*/
public function blocks_cart_page_view() {
$this->maybe_record_wcpay_shopper_event(
'cart_page_view',
[
'theme_type' => 'blocks',
]
);
}
/**
* Record a Tracks event that the classic cart product has loaded.
*/
public function classic_product_page_view() {
$this->maybe_record_wcpay_shopper_event(
'product_page_view',
[
'theme_type' => 'short_code',
]
);
}
/**
* Record a Tracks event that the pay-for-order page has loaded.
*/
public function pay_for_order_page_view() {
$this->maybe_record_wcpay_shopper_event(
'pay_for_order_page_view'
);
}
/**
* Bump a counter. No user identifiable information is sent.
*
* @param string $group The group to bump the stat in.
* @param string $stat_name The name of the stat to bump.
*
* @return bool
*/
public function bump_stats( $group, $stat_name ) {
$is_admin_event = false;
$track_on_all_stores = true;
if ( ! $this->should_enable_tracking( $is_admin_event, $track_on_all_stores ) ) {
return false;
}
if ( WC_Payments::mode()->is_test() ) {
return false;
}
$pixel_url = sprintf(
self::$pixel_base_url . '?v=wpcom-no-pv&x_%s=%s',
$group,
$stat_name
);
$response = wp_remote_get( esc_url_raw( $pixel_url ) );
if ( is_wp_error( $response ) ) {
return false;
}
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}
return true;
}
/**
* Record that the order has been processed.
*
* @param int $order_id The ID of the order.
*/
public function checkout_order_processed( $order_id ) {
$payment_gateway = wc_get_payment_gateway_by_order( $order_id );
$properties = [ 'payment_title' => 'other' ];
// If the order was placed using WooCommerce Payments, record the payment title using Tracks.
if ( isset( $payment_gateway->id ) && strpos( $payment_gateway->id, 'woocommerce_payments' ) === 0 ) {
$order = wc_get_order( $order_id );
$payment_title = $order->get_payment_method_title();
$properties = [ 'payment_title' => $payment_title ];
$is_woopay_order = ( isset( $_SERVER['HTTP_USER_AGENT'] ) && 'WooPay' === $_SERVER['HTTP_USER_AGENT'] );
// Don't track WooPay orders. They will be tracked on WooPay side with more details.
if ( ! $is_woopay_order ) {
$this->maybe_record_wcpay_shopper_event( 'checkout_order_placed', $properties, false );
}
// If the order was placed using a different payment gateway, just increment a counter.
} else {
$this->bump_stats( 'wcpay_order_completed_gateway', 'other' );
}
}
/**
* Record a Tracks event that user chose to save payment information in woopay.
*/
public function must_save_payment_method_to_platform() {
$this->maybe_record_event(
'woopay_registered',
[
'source' => 'checkout',
]
);
}
/**
* Record a Tracks event that Thank you page was viewed for a WCPay order.
*
* @param int $order_id The ID of the order.
* @return void
*/
public function thank_you_page_view( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || 'woocommerce_payments' !== $order->get_payment_method() ) {
return;
}
$this->maybe_record_wcpay_shopper_event( 'order_success_page_view' );
}
/**
* Record a Tracks event that the WooPay express button locations has been updated.
*
* @param array $all_locations All pages where WooPay express button can be enabled.
* @param array $platform_checkout_enabled_locations pages where WooPay express button is enabled.
*
* @return void
*/
public function woopay_locations_updated( $all_locations, $platform_checkout_enabled_locations ) {
$props = [];
foreach ( array_keys( $all_locations ) as $location ) {
$key = $location . '_enabled';
if ( in_array( $location, $platform_checkout_enabled_locations, true ) ) {
$props[ $key ] = true;
} else {
$props[ $key ] = false;
}
}
$this->maybe_record_admin_event( 'woopay_express_button_locations_updated', $props );
}
/**
* Add front-end tracks scripts to prevent cache break.
*
* @return void
*/
public function add_frontend_tracks_scripts() {
$frontent_tracks = apply_filters( 'wcpay_frontend_tracks', [] );
if ( count( $frontent_tracks ) === 0 ) {
return;
}
WC_Payments::register_script_with_dependencies( 'wcpay-frontend-tracks', 'dist/frontend-tracks' );
// Define wcpayConfig before the frontend tracks script if it hasn't been defined yet.
$wcpay_config = rawurlencode( wp_json_encode( WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config() ) );
wp_add_inline_script(
'wcpay-frontend-tracks',
"
var wcpayConfig = wcpayConfig || JSON.parse( decodeURIComponent( '" . esc_js( $wcpay_config ) . "' ) );
",
'before'
);
wp_localize_script(
'wcpay-frontend-tracks',
'wcPayFrontendTracks',
$frontent_tracks
);
wp_enqueue_script( 'wcpay-frontend-tracks' );
}
}
@@ -0,0 +1,176 @@
<?php
/**
* Class Blocks_Data_Extractor
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\Blocks\StoreApi\Schemas\CheckoutSchema;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
defined( 'ABSPATH' ) || exit; // block direct access.
/**
* Extract data fields from certain block based plugins.
*/
class Blocks_Data_Extractor {
/**
* Instance of the integration registry.
*
* @var IntegrationRegistry
*/
private $integration_registry;
/**
* Constructor.
*/
public function __construct() {
$this->integration_registry = new IntegrationRegistry();
}
/**
* Get a list of available Blocks.
*
* @return array
*/
private function get_available_blocks() {
$blocks = [];
if ( class_exists( '\AutomateWoo\Blocks\Marketing_Optin_Block' ) ) {
// phpcs:ignore
/**
* @psalm-suppress UndefinedClass
* @phpstan-ignore-next-line
*/
$blocks[] = new \Automatewoo\Blocks\Marketing_Optin_Block();
}
if ( class_exists( '\Mailchimp_Woocommerce_Newsletter_Blocks_Integration' ) ) {
// phpcs:ignore
/**
* @psalm-suppress UndefinedClass
* @phpstan-ignore-next-line
*/
$blocks[] = new \Mailchimp_Woocommerce_Newsletter_Blocks_Integration();
}
if ( class_exists( '\WCK\Blocks\CheckoutIntegration' ) ) {
// phpcs:ignore
/**
* @psalm-suppress UndefinedClass
* @phpstan-ignore-next-line
*/
$blocks[] = new \WCK\Blocks\CheckoutIntegration();
}
return $blocks;
}
/**
* Register all the blocks.
*
* @param array $blocks A list of blocks to register.
* @return void
*/
private function register_blocks( $blocks ) {
foreach ( $blocks as $block ) {
$this->integration_registry->register( $block );
}
}
/**
* Unregister all blocks.
*
* @param array $blocks A list of blocks to unregister.
* @return void
*/
private function unregister_blocks( $blocks ) {
foreach ( $blocks as $block ) {
$this->integration_registry->unregister( $block );
}
}
/**
* Mailpoet's block registration is different from the other two plugins. Data fields are passed
* from the parent class. This method fetches the data fields without registering the plugin.
*
* @return array
*/
private function get_mailpoet_data() {
// phpcs:ignore
/**
* We check whether relevant MailPoet classes exists before invoking this method.
*
* @psalm-suppress UndefinedClass
* @phpstan-ignore-next-line
*/
$mailpoet_wc_subscription = \MailPoet\DI\ContainerWrapper::getInstance()->get( \MailPoet\WooCommerce\Subscription::class );
// phpcs:ignore
/**
* @psalm-suppress UndefinedClass
* @phpstan-ignore-next-line
*/
$settings_instance = \MailPoet\Settings\SettingsController::getInstance();
$settings = [
'defaultText' => $settings_instance->get( 'woocommerce.optin_on_checkout.message', '' ),
'optinEnabled' => $settings_instance->get( 'woocommerce.optin_on_checkout.enabled', false ),
'defaultStatus' => false,
];
if ( version_compare( \MAILPOET_VERSION, '4.18.0', '<=' ) ) {
$settings['defaultStatus'] = $mailpoet_wc_subscription->isCurrentUserSubscribed();
}
return $settings;
}
/**
* Retrieve data fields.
*
* @return array
*/
public function get_data() {
$blocks = $this->get_available_blocks();
$this->register_blocks( $blocks );
$blocks_data = $this->integration_registry->get_all_registered_script_data();
if ( class_exists( 'MailPoet\DI\ContainerWrapper' ) && class_exists( 'MailPoet\WooCommerce\Subscription' ) ) {
$blocks_data += [ 'mailpoet_data' => $this->get_mailpoet_data() ];
}
$this->unregister_blocks( $blocks );
return $blocks_data;
}
/**
* Retrieves the namespaces in the Store API checkout schema.
*
* @return array
*/
public function get_checkout_schema_namespaces(): array {
$namespaces = [];
if (
class_exists( 'Automattic\WooCommerce\StoreApi\StoreApi' ) &&
class_exists( 'Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema' ) &&
class_exists( 'Automattic\WooCommerce\Blocks\StoreApi\Schemas\CheckoutSchema' )
) {
try {
$checkout_schema = StoreApi::container()->get( ExtendSchema::class )->get_endpoint_schema( CheckoutSchema::IDENTIFIER );
} catch ( \Exception $e ) {
return $namespaces;
}
$namespaces = array_keys( (array) $checkout_schema );
}
return $namespaces;
}
}
@@ -0,0 +1,186 @@
<?php
/**
* Class WC_Payments_Currency_Manager
*
* @package WooCommerce\Payments
*/
namespace WCPay;
use WC_Payment_Gateway_WCPay;
use WCPay\Constants\Payment_Method;
defined( 'ABSPATH' ) || exit;
/**
* It ensures that when a payment method is added and multi-currency is enabled, the needed currency is also added.
*/
class WC_Payments_Currency_Manager {
/**
* The WCPay gateway class instance.
*
* @var WC_Payment_Gateway_WCPay
*/
private $gateway;
/**
* Constructor
*
* @param WC_Payment_Gateway_WCPay $gateway The WCPay gateway class instance.
*/
public function __construct( WC_Payment_Gateway_WCPay $gateway ) {
$this->gateway = $gateway;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'update_option_woocommerce_woocommerce_payments_settings', [ $this, 'maybe_add_missing_currencies' ] );
add_action( 'admin_head', [ $this, 'add_payment_method_currency_dependencies_script' ] );
}
/**
* Gets the multi-currency instance or returns null if it's not available.
* This method allows for easier testing by allowing the multi-currency instance to be mocked.
*
* @return \WCPay\MultiCurrency\MultiCurrency|null
*/
public function get_multi_currency_instance() {
if ( ! function_exists( 'WC_Payments_Multi_Currency' ) ) {
return null;
}
if ( ! WC_Payments_Multi_Currency()->is_initialized() ) {
return null;
}
return WC_Payments_Multi_Currency();
}
/**
* Returns the currencies needed per enabled payment method
*
* @return array The currencies keyed with the related payment method
*/
public function get_enabled_payment_method_currencies() {
$enabled_payment_method_ids = $this->gateway->get_upe_enabled_payment_method_ids();
$account_currency = $this->gateway->get_account_domestic_currency();
$payment_methods_needing_currency = array_reduce(
$enabled_payment_method_ids,
function ( $result, $method ) use ( $account_currency ) {
if ( in_array( $method, [ 'card', 'card_present' ], true ) ) {
return $result;
}
try {
$method_key = Payment_Method::search( $method );
} catch ( \InvalidArgumentException $e ) {
return $result;
}
$class_key = ucfirst( strtolower( $method_key ? $method_key : $method ) );
$class_name = "\\WCPay\\Payment_Methods\\{$class_key}_Payment_Method";
if ( ! class_exists( $class_name ) ) {
return $result;
}
$payment_method_instance = new $class_name( null );
$result[ $method ] = [
'currencies' => $payment_method_instance->has_domestic_transactions_restrictions() ? [ $account_currency ] : $payment_method_instance->get_currencies(),
'title' => $payment_method_instance->get_title( $this->gateway->get_account_country() ),
];
return $result;
},
[]
);
return $payment_methods_needing_currency;
}
/**
* Ensures that when a payment method is added from the settings, the needed currency is also added.
*/
public function maybe_add_missing_currencies() {
$multi_currency = $this->get_multi_currency_instance();
if ( is_null( $multi_currency ) ) {
return;
}
$payment_methods_needing_currency = $this->get_enabled_payment_method_currencies();
if ( empty( $payment_methods_needing_currency ) ) {
return;
}
$enabled_currencies = $multi_currency->get_enabled_currencies();
$available_currencies = $multi_currency->get_available_currencies();
$missing_currency_codes = [];
// TODO: we need to find something about having a currency not available for the method in case of having disabled currencies in the future.
// First option, not do display it if the available currency is blocked by something else (Stripe, merchant, WCPay etc.)
// Second option, showing a notice that it can't be selected because the currency is not available to use.
// we have payments needing some currency being enabled, let's ensure the currency is present.
foreach ( $payment_methods_needing_currency as $payment_method_data ) {
$needed_currency_codes = $payment_method_data['currencies'];
foreach ( $needed_currency_codes as $needed_currency_code ) {
if ( ! isset( $available_currencies[ $needed_currency_code ] ) ) {
continue;
}
if ( isset( $enabled_currencies[ $needed_currency_code ] ) ) {
continue;
}
$missing_currency_codes[] = $needed_currency_code;
}
}
$missing_currency_codes = array_unique( $missing_currency_codes );
if ( empty( $missing_currency_codes ) ) {
return;
}
/**
* The set_enabled_currencies method throws an exception if any currencies passed are not found in the current available currencies.
* Any currencies not found are filtered out above, so we shouldn't need a try/catch here.
*/
$multi_currency->set_enabled_currencies( array_merge( array_keys( $enabled_currencies ), $missing_currency_codes ) );
}
/**
* Adds the `multiCurrencyPaymentMethodsMap` JS object to the multi-currency settings page.
*
* This object maps currencies to payment methods that require them, so the multi-currency settings page displays a notice in case of dependencies.
*/
public function add_payment_method_currency_dependencies_script() {
$multi_currency = $this->get_multi_currency_instance();
if ( is_null( $multi_currency ) || ! $multi_currency->is_multi_currency_settings_page() ) {
return;
}
$payment_methods_needing_currency = $this->get_enabled_payment_method_currencies();
if ( empty( $payment_methods_needing_currency ) ) {
return;
}
$currency_methods_map = [];
foreach ( $payment_methods_needing_currency as $method => $data ) {
foreach ( $data['currencies'] as $currency ) {
if ( ! isset( $currency_methods_map[ $currency ] ) ) {
$currency_methods_map[ $currency ] = [];
}
$currency_methods_map[ $currency ][ $method ] = $data['title'];
}
}
?>
<script type='text/javascript'>
window.multiCurrencyPaymentMethodsMap = <?php echo wp_json_encode( $currency_methods_map ); ?>;
</script>
<?php
}
}
@@ -0,0 +1,76 @@
<?php
/**
* Main functions to start MultiCurrency class.
*
* @package WooCommerce\Payments
*/
defined( 'ABSPATH' ) || exit;
/**
* Load customer multi-currency if feature is enabled or if it is the setup page.
*/
function wcpay_multi_currency_onboarding_check() {
$is_setup_page = false;
// Skip checking the HTTP referer if it is a cron job.
if ( ! defined( 'DOING_CRON' ) ) {
$http_referer = sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) );
if ( ! empty( $http_referer ) ) {
$is_setup_page = strpos( $http_referer, 'multi-currency-setup' ) !== false;
}
}
return $is_setup_page;
}
if ( ! WC_Payments_Features::is_customer_multi_currency_enabled() && ! wcpay_multi_currency_onboarding_check() ) {
return;
}
/**
* Returns the MultiCurrency singleton.
*
* @return WCPay\MultiCurrency\MultiCurrency
*/
function WC_Payments_Multi_Currency() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid
static $instance = null;
if ( is_null( $instance ) ) {
$instance = new WCPay\MultiCurrency\MultiCurrency(
WC_Payments::get_settings_service(),
WC_Payments::get_payments_api_client(),
WC_Payments::get_account_service(),
WC_Payments::get_localization_service(),
WC_Payments::get_database_cache()
);
$instance->init_hooks();
}
return $instance;
}
add_action( 'plugins_loaded', 'WC_Payments_Multi_Currency', 12 );
register_deactivation_hook( WCPAY_PLUGIN_FILE, 'wcpay_multi_currency_deactivated' );
/**
* Plugin deactivation hook.
*/
function wcpay_multi_currency_deactivated() {
WCPay\MultiCurrency\MultiCurrency::remove_woo_admin_notes();
}
if ( ! function_exists( 'wc_get_currency_switcher_markup' ) ) {
/**
* Gets the switcher widget markup.
*
* @param array $instance The widget's instance settings.
* @param array $args The widget's arguments.
*
* @return string The widget markup.
*/
function wc_get_currency_switcher_markup( array $instance = [], array $args = [] ): string {
return WC_Payments_Multi_Currency()->get_switcher_widget_markup( $instance, $args );
}
}
@@ -0,0 +1,138 @@
<?php
/**
* Admin email about payment retry failed due to authentication
*
* Email sent to admins when an attempt to automatically process a subscription renewal payment has failed
* with the `authentication_needed` error, and a retry rule has been applied to retry the payment in the future.
*
* @extends WC_Email_Failed_Order
* @package WooCommerce\Payments
*/
use WCPay\Logger;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* An email sent to the admin when payment fails to go through due to authentication_required error.
*/
class WC_Payments_Email_Failed_Authentication_Retry extends WC_Email_Failed_Order {
/**
* The details of the last retry (if any) recorded for a given order
*
* @var WCS_Retry
*/
private $retry;
/**
* Constructor
*/
public function __construct() {
$this->id = 'failed_authentication_requested';
$this->title = __( 'Payment authentication requested email', 'woocommerce-payments' );
$this->description = __( 'Payment authentication requested emails are sent to chosen recipient(s) when an attempt to automatically process a subscription renewal payment fails because the transaction requires an SCA verification, the customer is requested to authenticate the payment, and a retry rule has been applied to notify the customer again within a certain time period.', 'woocommerce-payments' );
$this->heading = __( 'Automatic renewal payment failed due to authentication required', 'woocommerce-payments' );
$this->subject = __( '[{site_title}] Automatic payment failed for {order_number}. Customer asked to authenticate payment and will be notified again {retry_time}', 'woocommerce-payments' );
$this->template_html = 'failed-renewal-authentication-requested.php';
$this->template_plain = 'plain/failed-renewal-authentication-requested.php';
$this->template_base = __DIR__ . '/emails/';
$this->recipient = $this->get_option( 'recipient', get_option( 'admin_email' ) );
// We want all the parent's methods, with none of its properties, so call its parent's constructor, rather than my parent constructor.
WC_Email::__construct();
}
/**
* Get the default e-mail subject.
*
* @return string
*/
public function get_default_subject() {
return $this->subject;
}
/**
* Get the default e-mail heading.
*
* @return string
*/
public function get_default_heading() {
return $this->heading;
}
/**
* Trigger.
*
* @param int $order_id The order ID.
* @param WC_Order|null $order Order object.
*/
public function trigger( $order_id, $order = null ) {
$this->object = $order;
$this->find['retry-time'] = '{retry_time}';
if ( class_exists( 'WCS_Retry_Manager' ) && function_exists( 'wcs_get_human_time_diff' ) ) {
$this->retry = WCS_Retry_Manager::store()->get_last_retry_for_order( wcs_get_objects_property( $order, 'id' ) );
$this->replace['retry-time'] = wcs_get_human_time_diff( $this->retry->get_time() );
} else {
Logger::log( 'WCS_Retry_Manager class or does not exist. Not able to send admin email about customer notification for authentication required for renewal payment.' );
return;
}
$this->find['order-number'] = '{order_number}';
$this->replace['order-number'] = $this->object->get_order_number();
if ( ! $this->is_enabled() || ! $this->get_recipient() ) {
return;
}
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
}
/**
* Get content html.
*
* @return string
*/
public function get_content_html() {
return wc_get_template_html(
$this->template_html,
[
'order' => $this->object,
'retry' => $this->retry,
'email_heading' => $this->get_heading(),
'sent_to_admin' => true,
'plain_text' => false,
'email' => $this,
],
'',
$this->template_base
);
}
/**
* Get content plain.
*
* @return string
*/
public function get_content_plain() {
return wc_get_template_html(
$this->template_plain,
[
'order' => $this->object,
'retry' => $this->retry,
'email_heading' => $this->get_heading(),
'sent_to_admin' => true,
'plain_text' => true,
'email' => $this,
],
'',
$this->template_base
);
}
}
@@ -0,0 +1,231 @@
<?php
/**
* Class WC_Payments_Email_Failed_Renewal_Authentication
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Failed Renewal Authentication Notification.
*
* @extends WC_Email
*/
class WC_Payments_Email_Failed_Renewal_Authentication extends WC_Email {
/**
* An instance of the email, which would normally be sent after a failed payment.
*
* @var WC_Email_Failed_Order
*/
public $original_email;
/**
* Constructor.
*
* @param WC_Email_Failed_Order[] $email_classes All existing instances of WooCommerce emails.
*/
public function __construct( $email_classes = [] ) {
$this->id = 'failed_renewal_authentication';
$this->title = __( 'Failed subscription renewal SCA authentication', 'woocommerce-payments' );
$this->description = __( 'Sent to a customer when a renewal fails because the transaction requires an SCA verification. The email contains renewal order information and payment links.', 'woocommerce-payments' );
$this->customer_email = true;
$this->template_html = 'failed-renewal-authentication.php';
$this->template_plain = 'plain/failed-renewal-authentication.php';
$this->template_base = __DIR__ . '/emails/';
// Triggers the email at the correct hook.
add_action( 'woocommerce_woocommerce_payments_payment_requires_action', [ $this, 'trigger' ] );
if ( isset( $email_classes['WCS_Email_Customer_Renewal_Invoice'] ) ) {
$this->original_email = $email_classes['WCS_Email_Customer_Renewal_Invoice'];
}
// We want all the parent's methods, with none of its properties, so call its parent's constructor, rather than my parent constructor.
parent::__construct();
}
/**
* Generates the HTML for the email while keeping the `template_base` in mind.
*
* @return string
*/
public function get_content_html() {
ob_start();
wc_get_template(
$this->template_html,
[
'order' => $this->object,
'email_heading' => $this->get_heading(),
'sent_to_admin' => false,
'plain_text' => false,
'authorization_url' => $this->get_authorization_url( $this->object ),
'email' => $this,
],
'',
$this->template_base
);
return ob_get_clean();
}
/**
* Generates the plain text for the email while keeping the `template_base` in mind.
*
* @return string
*/
public function get_content_plain() {
ob_start();
wc_get_template(
$this->template_plain,
[
'order' => $this->object,
'email_heading' => $this->get_heading(),
'sent_to_admin' => false,
'plain_text' => true,
'authorization_url' => $this->get_authorization_url( $this->object ),
'email' => $this,
],
'',
$this->template_base
);
return ob_get_clean();
}
/**
* Generates the URL, which will be used to authenticate the payment.
*
* @param WC_Order $order The order whose payment needs authentication.
* @return string
*/
public function get_authorization_url( $order ) {
return add_query_arg( 'wcpay-confirmation', 1, $order->get_checkout_payment_url( false ) ); // nosemgrep: audit.php.wp.security.xss.query-arg -- server generated url is passed in.
}
/**
* Uses specific fields from `WC_Email_Customer_Invoice` for this email.
*/
public function init_form_fields() {
parent::init_form_fields();
$base_fields = $this->form_fields;
$this->form_fields = [
'enabled' => [
'title' => _x( 'Enable/disable', 'an email notification', 'woocommerce-payments' ),
'type' => 'checkbox',
'label' => __( 'Enable this email notification', 'woocommerce-payments' ),
'default' => 'yes',
],
'subject' => $base_fields['subject'],
'heading' => $base_fields['heading'],
'email_type' => $base_fields['email_type'],
];
}
/**
* Returns the default subject of the email (modifiable in settings).
*
* @return string
*/
public function get_default_subject() {
return __( 'Payment authorization needed for renewal of {site_title} order {order_number}', 'woocommerce-payments' );
}
/**
* Returns the default heading of the email (modifiable in settings).
*
* @return string
*/
public function get_default_heading() {
return __( 'Payment authorization needed for renewal of order {order_number}', 'woocommerce-payments' );
}
/**
* Triggers the email while also disconnecting the original Subscriptions email.
*
* @param WC_Order $order The order that is being paid.
*/
public function trigger( $order ) {
if ( function_exists( 'wcs_order_contains_subscription' ) && ( wcs_order_contains_subscription( $order->get_id() ) || wcs_is_subscription( $order->get_id() ) || wcs_order_contains_renewal( $order->get_id() ) ) ) {
if ( ! $this->is_enabled() ) {
return;
}
$this->object = $order;
$this->recipient = $order->get_billing_email();
$this->find['order_date'] = '{order_date}';
$this->replace['order_date'] = wc_format_datetime( $order->get_date_created() );
$this->find['order_number'] = '{order_number}';
$this->replace['order_number'] = $order->get_order_number();
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
// Prevent the renewal email from WooCommerce Subscriptions from being sent.
if ( isset( $this->original_email ) ) {
remove_action(
'woocommerce_generated_manual_renewal_order_renewal_notification',
[
$this->original_email,
'trigger',
]
);
remove_action(
'woocommerce_order_status_failed_renewal_notification',
[
$this->original_email,
'trigger',
]
);
}
// Prevent the retry email from WooCommerce Subscriptions from being sent.
add_filter( 'wcs_get_retry_rule_raw', [ $this, 'prevent_retry_notification_email' ], 100, 3 );
// Send email to store owner indicating communication is happening with the customer to request authentication.
add_filter( 'wcs_get_retry_rule_raw', [ $this, 'set_store_owner_custom_email' ], 100, 3 );
}
}
/**
* Prevent all customer-facing retry notifications from being sent after this email.
*
* @param array $rule_array The raw details about the retry rule.
* @param int $retry_number The number of the retry.
* @param int $order_id The ID of the order that needs payment.
*
* @return array
*/
public function prevent_retry_notification_email( $rule_array, $retry_number, $order_id ) {
if ( wcs_get_objects_property( $this->object, 'id' ) === $order_id ) {
$rule_array['email_template_customer'] = '';
}
return $rule_array;
}
/**
* Send store owner a different email when the retry is related to an authentication required error.
*
* @param array $rule_array The raw details about the retry rule.
* @param int $retry_number The number of the retry.
* @param int $order_id The ID of the order that needs payment.
*
* @return array
*/
public function set_store_owner_custom_email( $rule_array, $retry_number, $order_id ) {
if (
wcs_get_objects_property( $this->object, 'id' ) === $order_id &&
'' !== $rule_array['email_template_admin'] // Only send our email if a retry admin email was already going to be sent.
) {
$rule_array['email_template_admin'] = 'WC_Payments_Email_Failed_Authentication_Retry';
}
return $rule_array;
}
}
@@ -0,0 +1,56 @@
<?php
/**
* Admin email about payment retry failed due to authentication.
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Output the email header.
*/
do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
<p>
<?php
echo esc_html(
sprintf(
// translators: %1$s: an order number, %2$s: the customer's full name, %3$s: lowercase human time diff in the form returned by wcs_get_human_time_diff(), e.g. 'in 12 hours'.
_x(
'The automatic recurring payment for order %1$s from %2$s has failed. The customer was sent an email requesting authentication of payment. If the customer does not authenticate the payment, they will be requested by email again %3$s.',
'In admin renewal failed email',
'woocommerce-payments'
),
$order->get_order_number(),
$order->get_formatted_billing_full_name(),
wcs_get_human_time_diff( $retry->get_time() )
)
);
?>
</p>
<p><?php esc_html_e( 'The renewal order is as follows:', 'woocommerce-payments' ); ?></p>
<?php
/**
* Shows the order details table.
*/
do_action( 'woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email );
/**
* Shows order meta data.
*/
do_action( 'woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email );
/**
* Shows customer details, and email address.
*/
do_action( 'woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email );
/**
* Output the email footer.
*/
do_action( 'woocommerce_email_footer', $email );
@@ -0,0 +1,24 @@
<?php
/**
* Customer email about payment retry failed due to authentication.
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
?>
<?php do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
<p>
<?php
// translators: %1$s: name of the blog, %2$s: link to payment re-authentication URL, note: no full stop due to url at the end.
echo wp_kses( sprintf( _x( 'The automatic payment to renew your subscription with %1$s has failed. To reactivate the subscription, please login and authorize the renewal from your account page: %2$s', 'In failed renewal authentication email', 'woocommerce-payments' ), esc_html( get_bloginfo( 'name' ) ), '<a href="' . esc_url( $authorization_url ) . '">' . esc_html__( 'Authorize the payment &raquo;', 'woocommerce-payments' ) . '</a>' ), [ 'a' => [ 'href' => true ] ] );
?>
</p>
<?php do_action( 'woocommerce_subscriptions_email_order_details', $order, $sent_to_admin, $plain_text, $email ); ?>
<?php do_action( 'woocommerce_email_footer', $email ); ?>
@@ -0,0 +1,50 @@
<?php
/**
* Admin email about payment retry failed due to authentication.
*
* @package WooCommerce\Payments
*/
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
echo '= ' . $email_heading . " =\n\n";
printf(
// translators: %1$s: an order number, %2$s: the customer's full name, %3$s: lowercase human time diff in the form returned by wcs_get_human_time_diff(), e.g. 'in 12 hours'.
_x(
'The automatic recurring payment for order %1$s from %2$s has failed. The customer was sent an email requesting authentication of payment. If the customer does not authenticate the payment, they will be requested by email again %3$s.',
'In admin renewal failed email',
'woocommerce-payments'
),
$order->get_order_number(),
$order->get_formatted_billing_full_name(),
wcs_get_human_time_diff( $retry->get_time() )
) . "\n\n";
printf( __( 'The renewal order is as follows:', 'woocommerce-payments' ) ) . "\n\n";
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
/**
* Shows the order details table.
*/
do_action( 'woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email );
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
/**
* Shows order meta data.
*/
do_action( 'woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email );
/**
* Shows customer details, and email address.
*/
do_action( 'woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email );
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) );
@@ -0,0 +1,25 @@
<?php
/**
* Customer email about payment retry failed due to authentication.
*
* @package WooCommerce\Payments
*/
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
echo $email_heading . "\n\n";
// translators: %1$s: name of the blog, %2$s: link to checkout payment url, note: no full stop due to url at the end.
printf( esc_html_x( 'The automatic payment to renew your subscription with %1$s has failed. To reactivate the subscription, please login and authorize the renewal from your account page: %2$s', 'In failed renewal authentication email', 'woocommerce-payments' ), esc_html( get_bloginfo( 'name' ) ), esc_attr( $authorization_url ) );
echo "\n\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
do_action( 'woocommerce_subscriptions_email_order_details', $order, $sent_to_admin, $plain_text, $email );
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
echo apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) );
@@ -0,0 +1,164 @@
<?php
/**
* Trait WC_Payments_Subscriptions_Utilities
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Utility functions related to WC Subscriptions.
*/
trait WC_Payments_Subscriptions_Utilities {
/**
* Checks if subscriptions are enabled on the site.
*
* Subscriptions functionality is enabled if the WC Subscriptions plugin is active and greater than v 2.2, or the base feature is turned on.
*
* @return bool Whether subscriptions is enabled or not.
*/
public function is_subscriptions_enabled() {
if ( $this->is_subscriptions_plugin_active() ) {
return version_compare( $this->get_subscriptions_plugin_version(), '2.2.0', '>=' );
}
// TODO update this once we know how the base library feature will be enabled.
return class_exists( 'WC_Subscriptions_Core_Plugin' );
}
/**
* Returns whether this user is changing the payment method for a subscription.
*
* @return bool
*/
public function is_changing_payment_method_for_subscription() {
if ( isset( $_GET['change_payment_method'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
return wcs_is_subscription( wc_clean( wp_unslash( $_GET['change_payment_method'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification
}
return false;
}
/**
* Returns boolean value indicating whether payment for an order will be recurring,
* as opposed to single.
*
* @param int $order_id ID for corresponding WC_Order in process.
*
* @return bool
*/
public function is_payment_recurring( $order_id ) {
if ( ! $this->is_subscriptions_enabled() ) {
return false;
}
return $this->is_changing_payment_method_for_subscription() || wcs_order_contains_subscription( $order_id );
}
/**
* Returns a boolean value indicating whether the save payment checkbox should be
* displayed during checkout.
*
* Returns `false` if the cart currently has a subscriptions or if the request has a
* `change_payment_method` GET parameter. Returns the value in `$display` otherwise.
*
* @param bool $display Bool indicating whether to show the save payment checkbox in the absence of subscriptions.
*
* @return bool Indicates whether the save payment method checkbox should be displayed or not.
*/
public function display_save_payment_method_checkbox( $display ) {
if ( WC_Subscriptions_Cart::cart_contains_subscription() || $this->is_changing_payment_method_for_subscription() ) {
return false;
}
// Only render the "Save payment method" checkbox if there are no subscription products in the cart.
return $display;
}
/**
* Returns boolean on whether current WC_Cart or WC_Subscriptions_Cart
* contains a subscription or subscription renewal item
*
* @return bool
*/
public function is_subscription_item_in_cart() {
if ( $this->is_subscriptions_enabled() ) {
return WC_Subscriptions_Cart::cart_contains_subscription() || $this->cart_contains_renewal();
}
return false;
}
/**
* Checks the cart to see if it contains a subscription product renewal.
*
* @return mixed The cart item containing the renewal as an array, else false.
*/
public function cart_contains_renewal() {
if ( ! function_exists( 'wcs_cart_contains_renewal' ) ) {
return false;
}
return wcs_cart_contains_renewal();
}
/**
* Checks if the WC Subscriptions plugin is active.
*
* @return bool Whether the plugin is active or not.
*/
public function is_subscriptions_plugin_active() {
return class_exists( 'WC_Subscriptions' );
}
/**
* Gets the version of WooCommerce Subscriptions that is active.
*
* @return null|string The plugin version. Returns null when WC Subscriptions is not active/loaded.
*/
public function get_subscriptions_plugin_version() {
return class_exists( 'WC_Subscriptions' ) ? WC_Subscriptions::$version : null;
}
/**
* Gets the version of the subscriptions-core library.
*
* @return null|string The version number of subscriptions-core or null if not active.
*/
public function get_subscriptions_core_version() {
$subscriptions_core_instance = WC_Subscriptions_Core_Plugin::instance();
// For backwards compatibility with older versions of WC Subscriptions, we need to do an existence check.
if ( method_exists( $subscriptions_core_instance, 'get_library_version' ) ) {
return $subscriptions_core_instance->get_library_version();
}
return $subscriptions_core_instance ? $subscriptions_core_instance->get_plugin_version() : null;
}
/**
* Gets the total number of subscriptions that have already been migrated.
*
* @return int The total number of subscriptions migrated.
*/
public function get_subscription_migrated_count() {
if ( ! function_exists( 'wcs_get_orders_with_meta_query' ) ) {
return 0;
}
return count(
wcs_get_orders_with_meta_query(
[
'status' => 'any',
'return' => 'ids',
'type' => 'shop_subscription',
'limit' => -1,
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
[
'key' => '_migrated_wcpay_subscription_id',
'compare' => 'EXISTS',
],
],
]
)
);
}
}
@@ -0,0 +1,126 @@
<?php
/**
* Class Base_Constant
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
use ReflectionClass;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Base constant class to hold common logic for all constants.
*/
abstract class Base_Constant implements \JsonSerializable {
/**
* Enum value
*
* @var mixed
*/
protected $value;
/**
* Static objects cache.
*
* @var array
*/
protected static $object_cache = [];
/**
* Class constructor. Keep it private to only allow initializing from __callStatic()
*
* @param string $value Constant from class.
* @throws \InvalidArgumentException
*/
private function __construct( string $value ) {
if ( $value instanceof static ) {
$value = $value->get_value();
} elseif ( ! defined( static::class . "::$value" ) ) {
throw new \InvalidArgumentException( "Constant with name '$value' does not exist." );
}
$this->value = $value;
}
/**
* Get enum class value.
*
* @return mixed
*/
public function get_value() {
return $this->value;
}
/**
* Compare to enums.
*
* @param mixed $variable Constant object to compare.
*
* @return bool
*/
final public function equals( $variable = null ): bool {
return $this === $variable;
}
/**
* Find constant in class by value.
*
* @param string $value Value to find.
*
* @return int|string
* @throws \InvalidArgumentException
*/
public static function search( string $value ) {
$class = new ReflectionClass( static::class );
$key = array_search( $value, $class->getConstants(), true );
if ( false === $key ) {
throw new \InvalidArgumentException( "Constant with value '$value' does not exist." );
}
return $key;
}
/**
* Used to created enum from constant names like CLASS::ConstantName().
*
* @param string $name Name of property or function.
* @param array $arguments Arguments of static call.
*
* @return static
* @throws \InvalidArgumentException
*/
public static function __callStatic( $name, $arguments ) {
if ( ! isset( static::$object_cache[ $name ] ) ) {
// Instantiating constants by class name using the 'new static($name)' approach is integral to this method's functionality.
// @phpstan-ignore-next-line.
static::$object_cache[ $name ] = new static( $name );
}
return static::$object_cache[ $name ];
}
/**
* Get real enum value.
*
* @return mixed|string
*/
public function __toString() {
return constant( \get_class( $this ) . '::' . $this->get_value() );
}
/**
* Specify the value which should be serialized to JSON.
*
* @return mixed|string
*/
#[\ReturnTypeWillChange]
public function jsonSerialize() {
return $this->__toString();
}
}
@@ -0,0 +1,232 @@
<?php
/**
* Class Country_Code
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Country Code constants
*
* @psalm-immutable
*/
class Country_Code extends Base_Constant {
const AFGHANISTAN = 'AF';
const ALAND_ISLANDS = 'AX';
const ALBANIA = 'AL';
const ALGERIA = 'DZ';
const AMERICAN_SAMOA = 'AS';
const ANDORRA = 'AD';
const ANGOLA = 'AO';
const ANGUILLA = 'AI';
const ANTARCTICA = 'AQ';
const ANTIGUA_AND_BARBUDA = 'AG';
const ARGENTINA = 'AR';
const ARMENIA = 'AM';
const ARUBA = 'AW';
const AUSTRALIA = 'AU';
const AUSTRIA = 'AT';
const AZERBAIJAN = 'AZ';
const BAHAMAS = 'BS';
const BAHRAIN = 'BH';
const BANGLADESH = 'BD';
const BARBADOS = 'BB';
const BELARUS = 'BY';
const BELGIUM = 'BE';
const BELIZE = 'BZ';
const BENIN = 'BJ';
const BERMUDA = 'BM';
const BHUTAN = 'BT';
const BOLIVIA = 'BO';
const BOSNIA_AND_HERZEGOVINA = 'BA';
const BOTSWANA = 'BW';
const BOUVET_ISLAND = 'BV';
const BRAZIL = 'BR';
const BRITISH_INDIAN_OCEAN_TERRITORY = 'IO';
const BRUNEI = 'BN';
const BULGARIA = 'BG';
const BURKINA_FASO = 'BF';
const BURUNDI = 'BI';
const CABO_VERDE = 'CV';
const CAMBODIA = 'KH';
const CAMEROON = 'CM';
const CANADA = 'CA';
const CARIBBEAN_NETHERLANDS = 'BQ';
const CENTRAL_AFRICAN_REPUBLIC = 'CF';
const CHAD = 'TD';
const CHILE = 'CL';
const CHINA = 'CN';
const COCOS_KEELING_ISLANDS = 'CC';
const COLOMBIA = 'CO';
const COOK_ISLANDS = 'CK';
const COMOROS = 'KM';
const CONGO = 'CG';
const COSTA_RICA = 'CR';
const CROATIA = 'HR';
const CUBA = 'CU';
const CYPRUS = 'CY';
const CZECHIA = 'CZ';
const DEMOCRATIC_REPUBLIC_OF_THE_CONGO = 'CD';
const DENMARK = 'DK';
const DJIBOUTI = 'DJ';
const DOMINICA = 'DM';
const DOMINICAN_REPUBLIC = 'DO';
const EAST_TIMOR = 'TL';
const ECUADOR = 'EC';
const EGYPT = 'EG';
const EL_SALVADOR = 'SV';
const EQUATORIAL_GUINEA = 'GQ';
const ERITREA = 'ER';
const ESTONIA = 'EE';
const ESWATINI = 'SZ';
const ETHIOPIA = 'ET';
const FIJI = 'FJ';
const FINLAND = 'FI';
const FRANCE = 'FR';
const GABON = 'GA';
const GAMBIA = 'GM';
const GEORGIA = 'GE';
const GERMANY = 'DE';
const GIBRALTAR = 'GI';
const GHANA = 'GH';
const GREECE = 'GR';
const GRENADA = 'GD';
const GUATEMALA = 'GT';
const GUINEA = 'GN';
const GUINEA_BISSAU = 'GW';
const GUYANA = 'GY';
const HAITI = 'HT';
const HONDURAS = 'HN';
const HONG_KONG = 'HK';
const HUNGARY = 'HU';
const ICELAND = 'IS';
const INDIA = 'IN';
const INDONESIA = 'ID';
const IRAN = 'IR';
const IRAQ = 'IQ';
const IRELAND = 'IE';
const ISRAEL = 'IL';
const ITALY = 'IT';
const IVORY_COAST = 'CI';
const JAMAICA = 'JM';
const JAPAN = 'JP';
const JORDAN = 'JO';
const KAZAKHSTAN = 'KZ';
const KENYA = 'KE';
const KIRIBATI = 'KI';
const KOSOVO = 'XK';
const KUWAIT = 'KW';
const KYRGYZSTAN = 'KG';
const LAOS = 'LA';
const LATVIA = 'LV';
const LEBANON = 'LB';
const LESOTHO = 'LS';
const LIBERIA = 'LR';
const LIBYA = 'LY';
const LIECHTENSTEIN = 'LI';
const LITHUANIA = 'LT';
const LUXEMBOURG = 'LU';
const MADAGASCAR = 'MG';
const MALAWI = 'MW';
const MALAYSIA = 'MY';
const MALDIVES = 'MV';
const MALI = 'ML';
const MALTA = 'MT';
const MARSHALL_ISLANDS = 'MH';
const MAURITANIA = 'MR';
const MAURITIUS = 'MU';
const MEXICO = 'MX';
const MICRONESIA = 'FM';
const MOLDOVA = 'MD';
const MONACO = 'MC';
const MONGOLIA = 'MN';
const MONTENEGRO = 'ME';
const MOROCCO = 'MA';
const MOZAMBIQUE = 'MZ';
const MYANMAR = 'MM';
const NAMIBIA = 'NA';
const NAURU = 'NR';
const NEPAL = 'NP';
const NETHERLANDS = 'NL';
const NEW_ZEALAND = 'NZ';
const NICARAGUA = 'NI';
const NIGER = 'NE';
const NIGERIA = 'NG';
const NORTH_KOREA = 'KP';
const NORTH_MACEDONIA = 'MK';
const NORWAY = 'NO';
const OMAN = 'OM';
const PAKISTAN = 'PK';
const PALAU = 'PW';
const PALESTINE = 'PS';
const PANAMA = 'PA';
const PAPUA_NEW_GUINEA = 'PG';
const PARAGUAY = 'PY';
const PERU = 'PE';
const PHILIPPINES = 'PH';
const POLAND = 'PL';
const PORTUGAL = 'PT';
const PUERTO_RICO = 'PR';
const QATAR = 'QA';
const ROMANIA = 'RO';
const RUSSIA = 'RU';
const RWANDA = 'RW';
const SAINT_BARTHELEMY = 'BL';
const SAINT_KITTS_AND_NEVIS = 'KN';
const SAINT_LUCIA = 'LC';
const SAINT_VINCENT_AND_THE_GRENADINES = 'VC';
const SAMOA = 'WS';
const SAN_MARINO = 'SM';
const SAO_TOME_AND_PRINCIPE = 'ST';
const SAUDI_ARABIA = 'SA';
const SENEGAL = 'SN';
const SERBIA = 'RS';
const SEYCHELLES = 'SC';
const SIERRA_LEONE = 'SL';
const SINGAPORE = 'SG';
const SLOVAKIA = 'SK';
const SLOVENIA = 'SI';
const SOLOMON_ISLANDS = 'SB';
const SOMALIA = 'SO';
const SOUTH_AFRICA = 'ZA';
const SOUTH_KOREA = 'KR';
const SOUTH_SUDAN = 'SS';
const SPAIN = 'ES';
const SRI_LANKA = 'LK';
const SUDAN = 'SD';
const SURINAME = 'SR';
const SWEDEN = 'SE';
const SWITZERLAND = 'CH';
const SYRIA = 'SY';
const TAIWAN = 'TW';
const TAJIKISTAN = 'TJ';
const TANZANIA = 'TZ';
const THAILAND = 'TH';
const TOGO = 'TG';
const TONGA = 'TO';
const TRINIDAD_AND_TOBAGO = 'TT';
const TUNISIA = 'TN';
const TURKEY = 'TR';
const TURKMENISTAN = 'TM';
const TUVALU = 'TV';
const UGANDA = 'UG';
const UKRAINE = 'UA';
const UNITED_ARAB_EMIRATES = 'AE';
const UNITED_KINGDOM = 'GB';
const UNITED_STATES = 'US';
const URUGUAY = 'UY';
const UZBEKISTAN = 'UZ';
const VANUATU = 'VU';
const VATICAN_CITY = 'VA';
const VENEZUELA = 'VE';
const VIETNAM = 'VN';
const YEMEN = 'YE';
const ZAMBIA = 'ZM';
const ZIMBABWE = 'ZW';
}
@@ -0,0 +1,95 @@
<?php
/**
* Class Country_Test_Cards
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class handling country-specific test card numbers for WooPayments
*/
class Country_Test_Cards extends Base_Constant {
/**
* Map of country codes to their test card numbers
* Source: https://docs.stripe.com/testing?testing-method=card-numbers#international-cards
*
* @var array
*/
private static $country_test_cards = [
Country_Code::UNITED_STATES => '4242 4242 4242 4242',
Country_Code::ARGENTINA => '4000 0003 2000 0021',
Country_Code::BRAZIL => '4000 0007 6000 0002',
Country_Code::CANADA => '4000 0012 4000 0000',
Country_Code::CHILE => '4000 0015 2000 0001',
Country_Code::COLOMBIA => '4000 0017 0000 0003',
Country_Code::COSTA_RICA => '4000 0018 8000 0005',
Country_Code::ECUADOR => '4000 0021 8000 0000',
Country_Code::MEXICO => '4000 0048 4000 8001',
Country_Code::PANAMA => '4000 0059 1000 0000',
Country_Code::PARAGUAY => '4000 0060 0000 0066',
Country_Code::PERU => '4000 0060 4000 0068',
Country_Code::URUGUAY => '4000 0085 8000 0003',
Country_Code::UNITED_ARAB_EMIRATES => '4000 0078 4000 0001',
Country_Code::AUSTRIA => '4000 0004 0000 0008',
Country_Code::BELGIUM => '4000 0005 6000 0004',
Country_Code::BULGARIA => '4000 0010 0000 0000',
Country_Code::BELARUS => '4000 0011 2000 0005',
Country_Code::CROATIA => '4000 0019 1000 0009',
Country_Code::CYPRUS => '4000 0019 6000 0008',
Country_Code::CZECHIA => '4000 0020 3000 0002',
Country_Code::DENMARK => '4000 0020 8000 0001',
Country_Code::ESTONIA => '4000 0023 3000 0009',
Country_Code::FINLAND => '4000 0024 6000 0001',
Country_Code::FRANCE => '4000 0025 0000 0003',
Country_Code::GERMANY => '4000 0027 6000 0016',
Country_Code::GIBRALTAR => '4000 0029 2000 0005',
Country_Code::GREECE => '4000 0030 0000 0030',
Country_Code::HUNGARY => '4000 0034 8000 0005',
Country_Code::IRELAND => '4000 0037 2000 0005',
Country_Code::ITALY => '4000 0038 0000 0008',
Country_Code::LATVIA => '4000 0042 8000 0005',
Country_Code::LIECHTENSTEIN => '4000 0043 8000 0004',
Country_Code::LITHUANIA => '4000 0044 0000 0000',
Country_Code::LUXEMBOURG => '4000 0044 2000 0006',
Country_Code::MALTA => '4000 0047 0000 0007',
Country_Code::NETHERLANDS => '4000 0052 8000 0002',
Country_Code::NORWAY => '4000 0057 8000 0007',
Country_Code::POLAND => '4000 0061 6000 0005',
Country_Code::PORTUGAL => '4000 0062 0000 0007',
Country_Code::ROMANIA => '4000 0064 2000 0001',
Country_Code::SAUDI_ARABIA => '4000 0068 2000 0007',
Country_Code::SLOVENIA => '4000 0070 5000 0006',
Country_Code::SLOVAKIA => '4000 0070 3000 0001',
Country_Code::SPAIN => '4000 0072 4000 0007',
Country_Code::SWEDEN => '4000 0075 2000 0008',
Country_Code::SWITZERLAND => '4000 0075 6000 0009',
Country_Code::UNITED_KINGDOM => '4000 0082 6000 0000',
Country_Code::AUSTRALIA => '4000 0003 6000 0006',
Country_Code::CHINA => '4000 0015 6000 0002',
Country_Code::HONG_KONG => '4000 0034 4000 0004',
Country_Code::INDIA => '4000 0035 6000 0008',
Country_Code::JAPAN => '4000 0039 2000 0003',
Country_Code::MALAYSIA => '4000 0045 8000 0002',
Country_Code::NEW_ZEALAND => '4000 0055 4000 0008',
Country_Code::SINGAPORE => '4000 0070 2000 0003',
Country_Code::TAIWAN => '4000 0015 8000 0008',
Country_Code::THAILAND => '4000 0076 4000 0003',
];
/**
* Get test card number for a specific country.
*
* @param string $country_code Two-letter country code.
* @return string Test card number
*/
public static function get_test_card_for_country( string $country_code ) {
return self::$country_test_cards[ $country_code ] ?? self::$country_test_cards[ Country_Code::UNITED_STATES ];
}
}
@@ -0,0 +1,157 @@
<?php
/**
* Class Currency_Code
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Currency Code constants
*
* @psalm-immutale
*/
class Currency_Code extends Base_Constant {
const UNITED_STATES_DOLLAR = 'USD'; // United States Dollar.
const UNITED_ARAB_EMIRATES_DIRHAM = 'AED'; // United Arab Emirates dirham.
const AFGHAN_AFGHANI = 'AFN'; // Afghan afghani.
const ALBANIAN_LEK = 'ALL'; // Albanian lek.
const ARMENIAN_DRAM = 'AMD'; // Armenian dram.
const NETHERLANDS_ANTILLEAN_GUILDER = 'ANG'; // Netherlands Antillean guilder.
const ANGOLAN_KWANZA = 'AOA'; // Angolan kwanza.
const ARGENTINE_PESO = 'ARS'; // Argentine peso.
const AUSTRALIAN_DOLLAR = 'AUD'; // Australian dollar.
const ARUBAN_FLORIN = 'AWG'; // Aruban florin.
const AZERBAIJANI_MANAT = 'AZN'; // Azerbaijani manat.
const BOSNIA_AND_HERZEGOVINA_CONVERTIBLE_MARK = 'BAM'; // Bosnia and Herzegovina convertible mark.
const BARBADOS_DOLLAR = 'BBD'; // Barbados dollar.
const BANGLADESHI_TAKA = 'BDT'; // Bangladeshi taka.
const BULGARIAN_LEV = 'BGN'; // Bulgarian lev.
const BURUNDIAN_FRANC = 'BIF'; // Burundian franc.
const BERMUDIAN_DOLLAR = 'BMD'; // Bermudian dollar.
const BRUNEI_DOLLAR = 'BND'; // Brunei dollar.
const BOLIVIANO = 'BOB'; // Boliviano.
const BRAZILIAN_REAL = 'BRL'; // Brazilian real.
const BAHAMIAN_DOLLAR = 'BSD'; // Bahamian dollar.
const BOTSWANA_PULA = 'BWP'; // Botswana pula.
const NEW_BELARUSIAN_RUBLE = 'BYN'; // New Belarusian ruble.
const BELIZE_DOLLAR = 'BZD'; // Belize dollar.
const CANADIAN_DOLLAR = 'CAD'; // Canadian dollar.
const CONGOLESE_FRANC = 'CDF'; // Congolese franc.
const SWISS_FRANC = 'CHF'; // Swiss franc.
const CHILEAN_PESO = 'CLP'; // Chilean peso.
const CHINESE_YUAN = 'CNY'; // Renminbi (Chinese yuan).
const COLOMBIAN_PESO = 'COP'; // Colombian peso.
const COSTA_RICAN_COLON = 'CRC'; // Costa Rican colon.
const CAPE_VERDE_ESCUDO = 'CVE'; // Cape Verde escudo.
const CZECH_KORUNA = 'CZK'; // Czech koruna.
const DJIBOUTIAN_FRANC = 'DJF'; // Djiboutian franc.
const DANISH_KRONE = 'DKK'; // Danish krone.
const DOMINICAN_PESO = 'DOP'; // Dominican peso.
const ALGERIAN_DINAR = 'DZD'; // Algerian dinar.
const EGYPTIAN_POUND = 'EGP'; // Egyptian pound.
const ETHIOPIAN_BIRR = 'ETB'; // Ethiopian birr.
const EURO = 'EUR'; // Euro.
const FIJI_DOLLAR = 'FJD'; // Fiji dollar.
const FALKLAND_ISLANDS_POUND = 'FKP'; // Falkland Islands pound.
const POUND_STERLING = 'GBP'; // Pound sterling.
const GEORGIAN_LARI = 'GEL'; // Georgian lari.
const GIBRALTAR_POUND = 'GIP'; // Gibraltar pound.
const GAMBIAN_DALASI = 'GMD'; // Gambian dalasi.
const GUINEAN_FRANC = 'GNF'; // Guinean franc.
const GUATEMALAN_QUETZAL = 'GTQ'; // Guatemalan quetzal.
const GUYANESE_DOLLAR = 'GYD'; // Guyanese dollar.
const HONG_KONG_DOLLAR = 'HKD'; // Hong Kong dollar.
const HONDURAN_LEMPIRA = 'HNL'; // Honduran lempira.
const HAITIAN_GOURDE = 'HTG'; // Haitian gourde.
const HUNGARIAN_FORINT = 'HUF'; // Hungarian forint.
const INDONESIAN_RUPIAH = 'IDR'; // Indonesian rupiah.
const ISRAELI_NEW_SHEKEL = 'ILS'; // Israeli new shekel.
const INDIAN_RUPEE = 'INR'; // Indian rupee.
const ICELANDIC_KRONA = 'ISK'; // Icelandic króna.
const JAMAICAN_DOLLAR = 'JMD'; // Jamaican dollar.
const JAPANESE_YEN = 'JPY'; // Japanese yen.
const KENYAN_SHILLING = 'KES'; // Kenyan shilling.
const KYRGYZSTANI_SOM = 'KGS'; // Kyrgyzstani som.
const CAMBODIAN_RIEL = 'KHR'; // Cambodian riel.
const COMORIAN_FRANC = 'KMF'; // Comorian franc.
const SOUTH_KOREAN_WON = 'KRW'; // South Korean won.
const CAYMAN_ISLANDS_DOLLAR = 'KYD'; // Cayman Islands dollar.
const KAZAKHSTANI_TENGE = 'KZT'; // Kazakhstani tenge.
const LAO_KIP = 'LAK'; // Lao kip.
const LEBANESE_POUND = 'LBP'; // Lebanese pound.
const SRI_LANKAN_RUPEE = 'LKR'; // Sri Lankan rupee.
const LIBERIAN_DOLLAR = 'LRD'; // Liberian dollar.
const LESOTHO_LOTI = 'LSL'; // Lesotho loti.
const MOROCCAN_DIRHAM = 'MAD'; // Moroccan dirham.
const MOLDOVAN_LEU = 'MDL'; // Moldovan leu.
const MALAGASY_ARIARY = 'MGA'; // Malagasy ariary.
const MACEDONIAN_DENAR = 'MKD'; // Macedonian denar.
const MYANMAR_KYAT = 'MMK'; // Myanmar kyat.
const MONGOLIAN_TOGROG = 'MNT'; // Mongolian tögrög.
const MACANESE_PATACA = 'MOP'; // Macanese pataca.
const MAURITIAN_RUPEE = 'MUR'; // Mauritian rupee.
const MALDIVIAN_RUFIYAA = 'MVR'; // Maldivian rufiyaa.
const MALAWIAN_KWACHA = 'MWK'; // Malawian kwacha.
const MEXICAN_PESO = 'MXN'; // Mexican peso.
const MALAYSIAN_RINGGIT = 'MYR'; // Malaysian ringgit.
const MOZAMBICAN_METICAL = 'MZN'; // Mozambican metical.
const NAMIBIAN_DOLLAR = 'NAD'; // Namibian dollar.
const NIGERIAN_NAIRA = 'NGN'; // Nigerian naira.
const NICARAGUAN_CORDOBA = 'NIO'; // Nicaraguan córdoba.
const NORWEGIAN_KRONE = 'NOK'; // Norwegian krone.
const NEPALESE_RUPEE = 'NPR'; // Nepalese rupee.
const NEW_ZEALAND_DOLLAR = 'NZD'; // New Zealand dollar.
const PANAMANIAN_BALBOA = 'PAB'; // Panamanian balboa.
const PERUVIAN_SOL = 'PEN'; // Peruvian sol.
const PAPUA_NEW_GUINEAN_KINA = 'PGK'; // Papua New Guinean kina.
const PHILIPPINE_PESO = 'PHP'; // Philippine peso.
const PAKISTANI_RUPEE = 'PKR'; // Pakistani rupee.
const POLISH_ZLOTY = 'PLN'; // Polish złoty.
const PARAGUAYAN_GUARANI = 'PYG'; // Paraguayan guaraní.
const QATARI_RIYAL = 'QAR'; // Qatari riyal.
const ROMANIAN_LEU = 'RON'; // Romanian leu.
const SERBIAN_DINAR = 'RSD'; // Serbian dinar.
const RUSSIAN_RUBLE = 'RUB'; // Russian ruble.
const RWANDAN_FRANC = 'RWF'; // Rwandan franc.
const SAUDI_RIYAL = 'SAR'; // Saudi riyal.
const SOLOMON_ISLANDS_DOLLAR = 'SBD'; // Solomon Islands dollar.
const SEYCHELLOIS_RUPEE = 'SCR'; // Seychellois rupee.
const SWEDISH_KRONA = 'SEK'; // Swedish krona.
const SINGAPORE_DOLLAR = 'SGD'; // Singapore dollar.
const SAINT_HELENA_POUND = 'SHP'; // Saint Helena pound.
const SIERRA_LEONEAN_LEONE = 'SLE'; // Sierra Leonean leone.
const SOMALI_SHILLING = 'SOS'; // Somali shilling.
const SURINAMESE_DOLLAR = 'SRD'; // Surinamese dollar.
const SAO_TOME_AND_PRINCIPE_DOBRA = 'STD'; // São Tomé and Príncipe dobra.
const SWAZI_LILANGENI = 'SZL'; // Swazi lilangeni.
const THAI_BAHT = 'THB'; // Thai baht.
const TAJIKISTANI_SOMONI = 'TJS'; // Tajikistani somoni.
const TONGAN_PAANGA = 'TOP'; // Tongan paʻanga.
const TURKISH_LIRA = 'TRY'; // Turkish lira.
const TRINIDAD_AND_TOBAGO_DOLLAR = 'TTD'; // Trinidad and Tobago dollar.
const NEW_TAIWAN_DOLLAR = 'TWD'; // New Taiwan dollar.
const TANZANIAN_SHILLING = 'TZS'; // Tanzanian shilling.
const UKRAINIAN_HRYVNIA = 'UAH'; // Ukrainian hryvnia.
const UGANDAN_SHILLING = 'UGX'; // Ugandan shilling.
const URUGUAYAN_PESO = 'UYU'; // Uruguayan peso.
const UZBEKISTANI_SOM = 'UZS'; // Uzbekistani som.
const VIETNAMESE_DONG = 'VND'; // Vietnamese đồng.
const VANUATU_VATU = 'VUV'; // Vanuatu vatu.
const SAMOAN_TALA = 'WST'; // Samoan tala.
const CENTRAL_AFRICAN_CFA_FRANC = 'XAF'; // Central African CFA franc.
const EAST_CARIBBEAN_DOLLAR = 'XCD'; // East Caribbean dollar.
const WEST_AFRICAN_CFA_FRANC = 'XOF'; // West African CFA franc.
const CFP_FRANC = 'XPF'; // CFP franc.
const YEMENI_RIAL = 'YER'; // Yemeni rial.
const SOUTH_AFRICAN_RAND = 'ZAR'; // South African rand.
const ZAMBIAN_KWACHA = 'ZMW'; // Zambian kwacha.
// ... add more currencies as needed.
// crypto currencies.
const BITCOIN = 'BTC'; // Bitcoin.
}
@@ -0,0 +1,360 @@
<?php
/**
* Class Express_Checkout_Hong_Kong_States
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Express_Checkout_Hong_Kong_States
*
* Contains a list of districts (equivalent to WC states) in Hong Kong used for normalization and validation purposes.
* This is necessary due to a bug in Apple Pay that's currently being worked on. Until
* that bug is fixed this workaround will be necessary.
*
* More info in pc4etw-bY-p2.
*
* Note: The following code has been kindly sourced from the WooCommerce Stripe Payment Gateway implementation,
* which addresses the same issue in that plugin. See https://github.com/woocommerce/woocommerce-gateway-stripe/pull/1593.
*
* @since x.x.x
*/
class Express_Checkout_Hong_Kong_States {
// Source: https://www.rvd.gov.hk/doc/tc/hkpr13/06.pdf.
const STATES = [
'hong kong',
'hong kong island',
'港島',
'central and western',
'中西區',
'kennedy town',
'shek tong tsui',
'sai ying pun',
'sheung wan',
'central',
'admiralty',
'mid-levels',
'peak',
'堅尼地城',
'石塘咀',
'西營盤',
'上環',
'中環',
'金鐘',
'半山區',
'山頂',
'wan chai',
'灣仔',
'causeway bay',
'happy valley',
'tai hang',
'so kon po',
"jardine's lookout",
'灣仔',
'銅鑼灣',
'跑馬地',
'大坑',
'掃桿埔',
'渣甸山',
'eastern',
'東區',
'tin hau',
'braemar hill',
'north point',
'quarry bay',
'sai wan ho',
'shau kei wan',
'chai wan',
'siu sai wan',
'天后',
'寶馬山',
'北角',
'鰂魚涌',
'西灣河',
'筲箕灣',
'柴灣',
'小西灣',
'southern',
'南區',
'pok fu lam',
'aberdeen',
'ap lei chau',
'wong chuk hang',
'shouson hill',
'repulse bay',
'chung hom kok',
'stanley',
'tai tam',
'shek o',
'薄扶林',
'香港仔',
'鴨脷洲',
'黃竹坑',
'壽臣山',
'淺水灣',
'舂磡角',
'赤柱',
'大潭',
'石澳',
'kowloon',
'九龍',
'yau tsim mong',
'油尖旺',
'tsim sha tsui',
'yau ma tei',
'west kowloon reclamation',
"king's park, mong kok",
'tai kok tsui',
'尖沙咀',
'油麻地',
'西九龍填海區',
'京士柏',
'旺角',
'大角咀',
'sham shui po',
'深水埗',
'mei foo',
'lai chi kok',
'cheung sha wan',
'shek kip mei',
'yau yat tsuen',
'tai wo ping',
'stonecutters island',
'美孚',
'荔枝角',
'長沙灣',
'石硤尾',
'又一村',
'大窩坪',
'昂船洲',
'kowloon city',
'九龍城',
'hung hom',
'to kwa wan',
'ma tau kok',
'ma tau wai',
'kai tak',
'ho man tin',
'kowloon tong',
'beacon hill',
'紅磡',
'土瓜灣',
'馬頭角',
'馬頭圍',
'啟德',
'何文田',
'九龍塘',
'筆架山',
'wong tai sin',
'黃大仙',
'san po kong',
'tung tau',
'wang tau hom',
'lok fu',
'diamond hill',
'tsz wan shan',
'ngau chi wan',
'新蒲崗',
'東頭',
'橫頭磡',
'樂富',
'鑽石山',
'慈雲山',
'牛池灣',
'kwun tong',
'觀塘',
'ping shek',
'kowloon bay',
'ngau tau kok',
'jordan valley',
'kwun tong',
'sau mau ping',
'lam tin',
'yau tong',
'lei yue mun',
'坪石',
'九龍灣',
'牛頭角',
'佐敦谷',
'觀塘',
'秀茂坪',
'藍田',
'油塘',
'鯉魚門',
'new territories',
'新界',
'kwai tsing',
'葵青',
'kwai chung',
'tsing yi',
'葵涌',
'青衣',
'tsuen wan',
'荃灣',
'lei muk shue',
'ting kau',
'sham tseng',
'tsing lung tau',
'ma wan',
'sunny bay',
'梨木樹',
'汀九',
'深井',
'青龍頭',
'馬灣',
'欣澳',
'tuen mun',
'屯門',
'tai lam chung',
'so kwun wat',
'tuen mun',
'lam tei',
'大欖涌',
'掃管笏',
'屯門',
'藍地',
'yuen long',
'元朗',
'hung shui kiu',
'ha tsuen',
'lau fau shan',
'tin shui wai',
'yuen long',
'san tin',
'lok ma chau',
'kam tin',
'shek kong',
'pat heung',
'洪水橋',
'廈村',
'流浮山',
'天水圍',
'元朗',
'新田',
'落馬洲',
'錦田',
'石崗',
'八鄉',
'north',
'北區',
'fanling',
'luen wo hui',
'sheung shui',
'shek wu hui',
'sha tau kok',
'luk keng',
'wu kau tang',
'粉嶺',
'聯和墟',
'上水',
'石湖墟',
'沙頭角',
'鹿頸',
'烏蛟騰',
'tai po',
'大埔',
'tai po market',
'tai po kau',
'tai mei tuk',
'shuen wan',
'cheung muk tau',
'kei ling ha',
'大埔墟',
'大埔',
'大埔滘',
'大尾篤',
'船灣',
'樟木頭',
'企嶺下',
'sha tin',
'沙田',
'tai wai',
'fo tan',
'ma liu shui',
'wu kai sha',
'ma on shan',
'大圍',
'火炭',
'馬料水',
'烏溪沙',
'馬鞍山',
'sai kung',
'西貢',
'clear water bay',
'tai mong tsai',
'tseung kwan o',
'hang hau',
'tiu keng leng',
'ma yau tong',
'清水灣',
'大網仔',
'將軍澳',
'坑口',
'調景嶺',
'馬游塘',
'islands',
'離島',
'cheung chau',
'peng chau',
'lantau island (including tung chung)',
'lamma island',
'長洲',
'坪洲',
'大嶼山(包括東涌)',
'南丫島',
];
/**
* Checks if the given state is a valid region (equivalent to WC state) in Hong Kong.
*
* @param string $state The state to be evaluated.
* @return bool True if the provided state is valid, false otherwise.
*/
public static function is_valid_state( $state ) {
return in_array( $state, self::STATES, true );
}
}
@@ -0,0 +1,31 @@
<?php
/**
* Class Fraud_Meta_Box_Type
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* This class gives a list of all the possible fraud meta box type constants.
*
* @psalm-immutable
*/
class Fraud_Meta_Box_Type extends Base_Constant {
const ALLOW = 'allow';
const BLOCK = 'block';
const NOT_CARD = 'not_card';
const NOT_WCPAY = 'not_wcpay';
const PAYMENT_STARTED = 'payment_started';
const REVIEW = 'review';
const REVIEW_ALLOWED = 'review_allowed';
const REVIEW_BLOCKED = 'review_blocked';
const REVIEW_EXPIRED = 'review_expired';
const REVIEW_FAILED = 'review_failed';
const TERMINAL_PAYMENT = 'terminal_payment';
}
@@ -0,0 +1,40 @@
<?php
/**
* Class Intent_Status
*
* @package WooCommerce\Payments
*/
namespace WCPay\Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* This class gives a list of all Payment and Setup Intent status name constants.
* - https://stripe.com/docs/api/payment_intents/object#payment_intent_object-status
* - https://stripe.com/docs/api/setup_intents/object#setup_intent_object-status
*
* @psalm-immutable
*/
class Intent_Status extends Base_Constant {
const REQUIRES_PAYMENT_METHOD = 'requires_payment_method';
const REQUIRES_CONFIRMATION = 'requires_confirmation';
const REQUIRES_ACTION = 'requires_action';
const PROCESSING = 'processing';
const REQUIRES_CAPTURE = 'requires_capture';
const CANCELED = 'canceled';
const SUCCEEDED = 'succeeded';
/**
* Stripe intents that are treated as successfully created, i.e. authorized.
*
* @type array
*/
const AUTHORIZED_STATUSES = [
self::SUCCEEDED,
self::REQUIRES_CAPTURE,
self::PROCESSING,
];
}

Some files were not shown because too many files have changed in this diff Show More