1%
@@ -102,7 +102,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base
- Foreign exchange fee
+ Currency conversion fee
1%
diff --git a/dev/phpcs/WCPay/ruleset.xml b/dev/phpcs/WCPay/ruleset.xml
index 9806ccfe9e7..7c8cefbd0e3 100644
--- a/dev/phpcs/WCPay/ruleset.xml
+++ b/dev/phpcs/WCPay/ruleset.xml
@@ -17,10 +17,6 @@
*/includes/class-wc-payments-order-success-page.php
-
- */includes/class-wc-payments-customer-service.php
- */includes/class-wc-payments-token-service.php
-
*/includes/class-wc-payments-webhook-reliability-service.php
diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php
index e7ad01fe210..d78671d1298 100644
--- a/includes/admin/class-wc-payments-admin.php
+++ b/includes/admin/class-wc-payments-admin.php
@@ -6,6 +6,7 @@
*/
use Automattic\Jetpack\Identity_Crisis as Jetpack_Identity_Crisis;
+use WCPay\Constants\Intent_Status;
use WCPay\Core\Server\Request;
use WCPay\Database_Cache;
use WCPay\Logger;
@@ -1253,7 +1254,7 @@ public function show_woopay_payment_method_name_admin( $order_id ) {
*/
public function display_wcpay_transaction_fee( $order_id ) {
$order = wc_get_order( $order_id );
- if ( ! $order || ! $order->get_meta( '_wcpay_transaction_fee' ) ) {
+ if ( ! $order || ! $order->get_meta( '_wcpay_transaction_fee' ) || Intent_Status::REQUIRES_CAPTURE === $order->get_meta( WC_Payments_Order_Service::INTENTION_STATUS_META_KEY ) ) {
return;
}
?>
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index 4f579caf2b1..d1be21241b9 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -20,7 +20,19 @@
use WCPay\Constants\Intent_Status;
use WCPay\Constants\Payment_Type;
use WCPay\Constants\Payment_Method;
-use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception, Order_ID_Mismatch_Exception, Order_Not_Found_Exception, New_Process_Payment_Exception };
+use WCPay\Exceptions\{Add_Payment_Method_Exception,
+ Amount_Too_Small_Exception,
+ API_Merchant_Exception,
+ Process_Payment_Exception,
+ Intent_Authentication_Exception,
+ API_Exception,
+ Invalid_Address_Exception,
+ Fraud_Prevention_Enabled_Exception,
+ Invalid_Phone_Number_Exception,
+ Rate_Limiter_Enabled_Exception,
+ Order_ID_Mismatch_Exception,
+ Order_Not_Found_Exception,
+ New_Process_Payment_Exception};
use WCPay\Core\Server\Request\Cancel_Intention;
use WCPay\Core\Server\Request\Capture_Intention;
use WCPay\Core\Server\Request\Create_And_Confirm_Intention;
@@ -1270,6 +1282,9 @@ public function process_payment( $order_id ) {
);
$error_details = esc_html( rtrim( $e->getMessage(), '.' ) );
+ if ( $e instanceof API_Merchant_Exception ) {
+ $error_details = $error_details . '. ' . esc_html( rtrim( $e->get_merchant_message(), '.' ) );
+ }
if ( $e instanceof API_Exception && 'card_error' === $e->get_error_type() ) {
// If the payment failed with a 'card_error' API exception, initialize the fraud meta box
diff --git a/includes/class-wc-payments-captured-event-note.php b/includes/class-wc-payments-captured-event-note.php
index 10c48567952..07e902d8632 100644
--- a/includes/class-wc-payments-captured-event-note.php
+++ b/includes/class-wc-payments-captured-event-note.php
@@ -327,9 +327,9 @@ private function fee_label_mapping( int $fixed_rate, bool $is_capped ) {
$res['additional-fx'] = 0 !== $fixed_rate
/* translators: %1$s% is the fee percentage and %2$s is the fixed rate */
- ? __( 'Foreign exchange fee: %1$s%% + %2$s', 'woocommerce-payments' )
+ ? __( 'Currency conversion fee: %1$s%% + %2$s', 'woocommerce-payments' )
/* translators: %1$s% is the fee percentage */
- : __( 'Foreign exchange fee: %1$s%%', 'woocommerce-payments' );
+ : __( '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 */
diff --git a/includes/class-wc-payments-customer-service.php b/includes/class-wc-payments-customer-service.php
index 05f95c32d31..d0f97e061c0 100644
--- a/includes/class-wc-payments-customer-service.php
+++ b/includes/class-wc-payments-customer-service.php
@@ -99,7 +99,12 @@ public function __construct(
$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.
diff --git a/includes/class-wc-payments-token-service.php b/includes/class-wc-payments-token-service.php
index 7bfdc482e18..283a0d7851a 100644
--- a/includes/class-wc-payments-token-service.php
+++ b/includes/class-wc-payments-token-service.php
@@ -47,7 +47,12 @@ class WC_Payments_Token_Service {
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 );
diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php
index 17300478794..4ad2d32625e 100644
--- a/includes/class-wc-payments.php
+++ b/includes/class-wc-payments.php
@@ -354,6 +354,7 @@ public static function init() {
include_once __DIR__ . '/exceptions/class-base-exception.php';
include_once __DIR__ . '/exceptions/class-api-exception.php';
+ include_once __DIR__ . '/exceptions/class-api-merchant-exception.php';
include_once __DIR__ . '/exceptions/class-connection-exception.php';
include_once __DIR__ . '/core/class-mode.php';
@@ -554,6 +555,8 @@ public static function init() {
self::$onboarding_service->init_hooks();
self::$incentives_service->init_hooks();
self::$compatibility_service->init_hooks();
+ self::$customer_service->init_hooks();
+ self::$token_service->init_hooks();
$payment_method_classes = [
CC_Payment_Method::class,
diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php
index 31ec70bedf8..d2584f9b824 100644
--- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php
+++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php
@@ -11,6 +11,7 @@
use WCPay\Core\Server\Request\Get_Intention;
use WCPay\Exceptions\API_Exception;
+use WCPay\Exceptions\API_Merchant_Exception;
use WCPay\Exceptions\Invalid_Payment_Method_Exception;
use WCPay\Exceptions\Add_Payment_Method_Exception;
use WCPay\Exceptions\Order_Not_Found_Exception;
@@ -342,6 +343,11 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) {
$renewal_order->update_status( 'failed' );
if ( ! empty( $payment_information ) ) {
+ $error_details = esc_html( rtrim( $e->getMessage(), '.' ) );
+ if ( $e instanceof API_Merchant_Exception ) {
+ $error_details = $error_details . '. ' . esc_html( rtrim( $e->get_merchant_message(), '.' ) );
+ }
+
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the failed payment amount, %2: error message */
@@ -358,7 +364,7 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) {
wc_price( $amount, [ 'currency' => WC_Payments_Utils::get_order_intent_currency( $renewal_order ) ] ),
$renewal_order
),
- esc_html( rtrim( $e->getMessage(), '.' ) )
+ $error_details
);
$renewal_order->add_order_note( $note );
}
diff --git a/includes/exceptions/class-api-merchant-exception.php b/includes/exceptions/class-api-merchant-exception.php
new file mode 100644
index 00000000000..ac10bd271bc
--- /dev/null
+++ b/includes/exceptions/class-api-merchant-exception.php
@@ -0,0 +1,49 @@
+merchant_message = $merchant_message;
+
+ parent::__construct( $message, $error_code, $http_code, $error_type, $decline_code, $code, $previous );
+ }
+
+ /**
+ * Returns the merchant message.
+ *
+ * @return string Merchant message.
+ */
+ public function get_merchant_message(): string {
+ return $this->merchant_message;
+ }
+}
diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php
index b3adf5bf7eb..e90094d57de 100644
--- a/includes/wc-payment-api/class-wc-payments-api-client.php
+++ b/includes/wc-payment-api/class-wc-payments-api-client.php
@@ -9,6 +9,7 @@
use WCPay\Constants\Intent_Status;
use WCPay\Exceptions\API_Exception;
+use WCPay\Exceptions\API_Merchant_Exception;
use WCPay\Exceptions\Amount_Too_Small_Exception;
use WCPay\Exceptions\Amount_Too_Large_Exception;
use WCPay\Exceptions\Connection_Exception;
@@ -2419,6 +2420,13 @@ protected function check_response_for_errors( $response ) {
);
Logger::error( "$error_message ($error_code)" );
+
+ if ( 'card_declined' === $error_code && isset( $response_body['error']['payment_intent']['charges']['data'][0]['outcome']['seller_message'] ) ) {
+ $merchant_message = $response_body['error']['payment_intent']['charges']['data'][0]['outcome']['seller_message'];
+
+ throw new API_Merchant_Exception( $message, $error_code, $response_code, $merchant_message, $error_type, $decline_code );
+ }
+
throw new API_Exception( $message, $error_code, $response_code, $error_type, $decline_code );
}
}
diff --git a/src/Internal/Service/Level3Service.php b/src/Internal/Service/Level3Service.php
index 67db748debe..b75f3dd1271 100644
--- a/src/Internal/Service/Level3Service.php
+++ b/src/Internal/Service/Level3Service.php
@@ -80,10 +80,10 @@ public function get_data_from_order( int $order_id ): array {
$order_items = array_values( $order->get_items( [ 'line_item', 'fee' ] ) );
$currency = $order->get_currency();
- $process_item = function ( $item ) use ( $currency ) {
- return $this->process_item( $item, $currency );
- };
- $items_to_send = array_map( $process_item, $order_items );
+ $items_to_send = [];
+ foreach ( $order_items as $item ) {
+ $items_to_send = array_merge( $items_to_send, $this->process_item( $item, $currency ) );
+ }
$level3_data = [
'merchant_reference' => (string) $order->get_id(), // An alphanumeric string of up to characters in length. This unique value is assigned by the merchant to identify the order. Also known as an “Order ID”.
@@ -137,9 +137,9 @@ public function get_data_from_order( int $order_id ): array {
*
* @param WC_Order_Item_Product|WC_Order_Item_Fee $item Item to process.
* @param string $currency Currency to use.
- * @return \stdClass
+ * @return \stdClass[]
*/
- private function process_item( WC_Order_Item $item, string $currency ): stdClass {
+ private function process_item( WC_Order_Item $item, string $currency ): array {
// Check to see if it is a WC_Order_Item_Product or a WC_Order_Item_Fee.
if ( $item instanceof WC_Order_Item_Product ) {
$subtotal = $item->get_subtotal();
@@ -164,7 +164,7 @@ private function process_item( WC_Order_Item $item, string $currency ): stdClass
$unit_cost = 0;
}
- return (object) [
+ $line_item = (object) [
'product_code' => (string) $product_code, // Up to 12 characters that uniquely identify the product.
'product_description' => $description, // Up to 26 characters long describing the product.
'unit_cost' => $unit_cost, // Cost of the product, in cents, as a non-negative integer.
@@ -172,6 +172,29 @@ private function process_item( WC_Order_Item $item, string $currency ): stdClass
'tax_amount' => $tax_amount, // The amount of tax this item had added to it, in cents, as a non-negative integer.
'discount_amount' => $discount_amount, // The amount an item was discounted—if there was a sale,for example, as a non-negative integer.
];
+ $line_items = [ $line_item ];
+
+ /**
+ * In edge cases, rounding after division might lead to a slight inconsistency.
+ *
+ * For example: 10/3 with 2 decimal places = 3.33, but 3.33*3 = 9.99.
+ */
+ if ( $subtotal > 0 ) {
+ $prepared_subtotal = $this->prepare_amount( $subtotal, $currency );
+ $difference = $prepared_subtotal - ( $unit_cost * $quantity );
+ if ( $difference > 0 ) {
+ $line_items[] = (object) [
+ 'product_code' => 'rounding-fix',
+ 'product_description' => __( 'Rounding fix', 'woocommerce-payments' ),
+ 'unit_cost' => $difference,
+ 'quantity' => 1,
+ 'tax_amount' => 0,
+ 'discount_amount' => 0,
+ ];
+ }
+ }
+
+ return $line_items;
}
/**
diff --git a/tests/fixtures/captured-payments/discount.json b/tests/fixtures/captured-payments/discount.json
index 2fa6a911d74..5bf6f936c45 100644
--- a/tests/fixtures/captured-payments/discount.json
+++ b/tests/fixtures/captured-payments/discount.json
@@ -60,7 +60,7 @@
"feeBreakdown": {
"base": "Base fee: 2.9% + $0.30",
"additional-international": "International card fee: 1%",
- "additional-fx": "Foreign exchange fee: 1%",
+ "additional-fx": "Currency conversion fee: 1%",
"discount": {
"label": "Discount",
"variable": "Variable fee: -4.9%",
diff --git a/tests/fixtures/captured-payments/foreign-card.json b/tests/fixtures/captured-payments/foreign-card.json
index 234878b2372..df45c326d62 100644
--- a/tests/fixtures/captured-payments/foreign-card.json
+++ b/tests/fixtures/captured-payments/foreign-card.json
@@ -53,7 +53,7 @@
"feeBreakdown": {
"base": "Base fee: 2.9% + $0.30",
"additional-international": "International card fee: 1%",
- "additional-fx": "Foreign exchange fee: 1%"
+ "additional-fx": "Currency conversion fee: 1%"
},
"netString": "Net payout: $95.47 USD"
}
diff --git a/tests/fixtures/captured-payments/fx-decimal.json b/tests/fixtures/captured-payments/fx-decimal.json
index b95e9318c84..2f065036122 100644
--- a/tests/fixtures/captured-payments/fx-decimal.json
+++ b/tests/fixtures/captured-payments/fx-decimal.json
@@ -45,7 +45,7 @@
"feeString": "Fee (3.9% + $0.30): -$4.39",
"feeBreakdown": {
"base": "Base fee: 2.9% + $0.30",
- "additional-fx": "Foreign exchange fee: 1%"
+ "additional-fx": "Currency conversion fee: 1%"
},
"netString": "Net payout: $100.65 USD"
}
diff --git a/tests/fixtures/captured-payments/fx-partial-capture.json b/tests/fixtures/captured-payments/fx-partial-capture.json
index f10ff7aa9e9..691390d4852 100644
--- a/tests/fixtures/captured-payments/fx-partial-capture.json
+++ b/tests/fixtures/captured-payments/fx-partial-capture.json
@@ -57,7 +57,7 @@
"feeString": "Fee (3.51% + £0.21): -$0.88",
"feeBreakdown": {
"base": "Base fee: 2.9% + $0.30",
- "additional-fx": "Foreign exchange fee: 1%",
+ "additional-fx": "Currency conversion fee: 1%",
"discount": {
"label": "Discount",
"variable": "Variable fee: -0.39%",
diff --git a/tests/fixtures/captured-payments/fx-with-capped-fee.json b/tests/fixtures/captured-payments/fx-with-capped-fee.json
index 8c1b602a3eb..4c31a8435d7 100644
--- a/tests/fixtures/captured-payments/fx-with-capped-fee.json
+++ b/tests/fixtures/captured-payments/fx-with-capped-fee.json
@@ -55,7 +55,7 @@
"feeBreakdown": {
"base": "Base fee: capped at $6.00",
"additional-international": "International card fee: 1.5%",
- "additional-fx": "Foreign exchange fee: 1%"
+ "additional-fx": "Currency conversion fee: 1%"
},
"netString": "Net payout: $971.04 USD"
}
diff --git a/tests/fixtures/captured-payments/fx.json b/tests/fixtures/captured-payments/fx.json
index 8ceee7b7438..f18ca9297ab 100644
--- a/tests/fixtures/captured-payments/fx.json
+++ b/tests/fixtures/captured-payments/fx.json
@@ -46,7 +46,7 @@
"feeString": "Fee (3.9% + $0.30): -$4.20",
"feeBreakdown": {
"base": "Base fee: 2.9% + $0.30",
- "additional-fx": "Foreign exchange fee: 1%"
+ "additional-fx": "Currency conversion fee: 1%"
},
"netString": "Net payout: $95.84 USD"
}
diff --git a/tests/fixtures/captured-payments/jpy-payment.json b/tests/fixtures/captured-payments/jpy-payment.json
index 6c7a6b3ee05..4b4c6c152c9 100644
--- a/tests/fixtures/captured-payments/jpy-payment.json
+++ b/tests/fixtures/captured-payments/jpy-payment.json
@@ -57,7 +57,7 @@
"feeBreakdown": {
"base": "Base fee: 3.6%",
"additional-international": "International card fee: 2%",
- "additional-fx": "Foreign exchange fee: 2%"
+ "additional-fx": "Currency conversion fee: 2%"
},
"netString": "Net payout: ¥4,507 JPY"
}
diff --git a/tests/fixtures/captured-payments/subscription.json b/tests/fixtures/captured-payments/subscription.json
index b7312ea0c02..d0e1fe705e4 100644
--- a/tests/fixtures/captured-payments/subscription.json
+++ b/tests/fixtures/captured-payments/subscription.json
@@ -53,7 +53,7 @@
"feeString": "Fee (4.9% + $0.30): -$3.04",
"feeBreakdown": {
"base": "Base fee: 2.9% + $0.30",
- "additional-fx": "Foreign exchange fee: 1%",
+ "additional-fx": "Currency conversion fee: 1%",
"additional-wcpay-subscription": "Subscription transaction fee: 1%"
},
"netString": "Net payout: $52.87 USD"
diff --git a/tests/unit/src/Internal/Service/Level3ServiceTest.php b/tests/unit/src/Internal/Service/Level3ServiceTest.php
index fe3eee573c3..dba9766386d 100644
--- a/tests/unit/src/Internal/Service/Level3ServiceTest.php
+++ b/tests/unit/src/Internal/Service/Level3ServiceTest.php
@@ -167,6 +167,10 @@ protected function mock_level_3_order(
$mock_items = array_merge( $mock_items, array_fill( 0, $basket_size - count( $mock_items ), $mock_items[0] ) );
}
+ $this->mock_order( $mock_items, $shipping_postcode );
+ }
+
+ protected function mock_order( array $mock_items, string $shipping_postcode ) {
// Setup the order.
$mock_order = $this
->getMockBuilder( WC_Order::class )
@@ -434,6 +438,25 @@ public function test_full_level3_data_with_float_quantity() {
$this->assertEquals( $expected_data, $level_3_data );
}
+ public function test_rounding_in_edge_cases() {
+ $this->mock_account->method( 'get_account_country' )->willReturn( Country_Code::UNITED_STATES );
+
+ $mock_items = [];
+ $mock_items[] = $this->create_mock_item( 'Beanie with Addon', 3, 73, 0, 30 );
+ $this->mock_order( $mock_items, '98012' );
+
+ $level_3_data = $this->sut->get_data_from_order( $this->order_id );
+
+ $this->assertCount( 2, $level_3_data['line_items'] );
+ $this->assertEquals( 2433, $level_3_data['line_items'][0]->unit_cost );
+ $this->assertEquals( 'rounding-fix', $level_3_data['line_items'][1]->product_code );
+ $this->assertEquals( 'Rounding fix', $level_3_data['line_items'][1]->product_description );
+ $this->assertEquals( 1, $level_3_data['line_items'][1]->unit_cost );
+ $this->assertEquals( 1, $level_3_data['line_items'][1]->quantity );
+ $this->assertEquals( 0, $level_3_data['line_items'][1]->tax_amount );
+ $this->assertEquals( 0, $level_3_data['line_items'][1]->discount_amount );
+ }
+
public function test_full_level3_data_with_float_quantity_zero() {
$expected_data = [
'merchant_reference' => '210',
diff --git a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php
index 3fc4a56c8f6..fb95bcf1591 100644
--- a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php
+++ b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php
@@ -7,7 +7,9 @@
use WCPay\Constants\Country_Code;
use WCPay\Constants\Intent_Status;
+use WCPay\Core\Server\Request\Create_And_Confirm_Intention;
use WCPay\Exceptions\API_Exception;
+use WCPay\Exceptions\API_Merchant_Exception;
use WCPay\Internal\Logger;
use WCPay\Exceptions\Connection_Exception;
use WCPay\Fraud_Prevention\Fraud_Prevention_Service;
@@ -1195,6 +1197,24 @@ public function test_get_tracking_info() {
$this->assertEquals( $expect, $result );
}
+ public function test_throws_api_merchant_exception() {
+ $mock_response = [];
+ $mock_response['error']['code'] = 'card_declined';
+ $mock_response['error']['payment_intent']['charges']['data'][0]['outcome']['seller_message'] = 'Bank declined';
+ $this->set_http_mock_response(
+ 401,
+ $mock_response
+ );
+
+ try {
+ // This is a dummy call to trigger the response so that our test can validate the exception.
+ $this->payments_api_client->create_subscription();
+ } catch ( API_Merchant_Exception $e ) {
+ $this->assertSame( 'card_declined', $e->get_error_code() );
+ $this->assertSame( 'Bank declined', $e->get_merchant_message() );
+ }
+ }
+
/**
* Set up http mock response.
*