Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated WooPayments MultiCurrency integration with Product Add-Ons #9070

Merged
merged 14 commits into from
Jul 29, 2024
4 changes: 4 additions & 0 deletions changelog/fix-issue-8911
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: update

Updated the integration between WooPayments Multi-Currency and Product Add-Ons.
139 changes: 94 additions & 45 deletions includes/multi-currency/Compatibility/WooCommerceProductAddOns.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,37 +110,64 @@ public function product_addons_params( array $params ): array {
*/
public function get_item_data( $addon_data, $addon, $cart_item ): array {
Copy link
Contributor

@peterfabian peterfabian Jul 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this method is very similar to \WC_Product_Addons_Cart::get_item_data, would it be worth it to add a comment to PAO to reflect changes in \WC_Product_Addons_Cart::get_item_data here in Woo Payments, too, so we can avoid things being out of sync in the future? (and similarly with other methods here that have counterparts in PAO)

Alternatively, we could refactor \WC_Product_Addons_Cart::get_item_data (or extract the calculation part out to a separate method) and call it from WooPayments, it's essentially a static method anyway, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some comments here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

$price = isset( $cart_item['addons_price_before_calc'] ) ? $cart_item['addons_price_before_calc'] : $addon['price'];
$name = $addon['name'];

if ( 0.0 === $addon['price'] ) {
$name .= '';
} elseif ( 'percentage_based' === $addon['price_type'] && 0.0 === $price ) {
$name .= '';
} elseif ( 'custom_price' === $addon['field_type'] ) {
$name .= ' (' . wc_price( $addon['price'] ) . ')';
} elseif ( 'percentage_based' !== $addon['price_type'] && $addon['price'] && apply_filters( 'woocommerce_addons_add_price_to_name', '__return_true' ) ) {
// Get our converted and tax adjusted price to put in the add on name.
$price = $this->multi_currency->get_price( $addon['price'], 'product' );
if ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
$value = $addon['value'];

/*
* 'woocommerce_addons_add_cart_price_to_value'
*
* Use this filter to display the price next to each selected add-on option.
* By default, add-on prices show up only next to flat fee add-ons.
*
* @param boolean
*/
$add_price_to_value = apply_filters( 'woocommerce_addons_add_cart_price_to_value', false, $cart_item );
jimjasson marked this conversation as resolved.
Show resolved Hide resolved

if ( 0.0 === (float) $addon['price'] ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be good enough here, but comparing floats for equality might cause subtle problems. Perhaps 0 is a special case here, but I think price can often be filtered and calculated, so maybe not totally safe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$value .= '';
} elseif ( 'percentage_based' === $addon['price_type'] && 0.0 === (float) $price ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here with comparing floats for equality.

$value .= '';
} elseif ( 'custom_price' === $addon['field_type'] && $addon['price'] ) {
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$addon_price = wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon['price'], $cart_item['data'] ) );
/* translators: %1$s custom addon price in cart */
$value .= sprintf( _x( ' (%1$s)', 'custom price addon price in cart', 'woocommerce-payments' ), $addon_price );
$addon['display'] = $value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Should we add a comment indicating that if display is set, it overrides value? I'm not certain it does but from my testing, it looks like it does.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also 1-1 identical to how Product Add-Ons handles the display key -- in general, the display key is not actively used, but is there for backwards compatibility.

}
} elseif ( 'flat_fee' === $addon['price_type'] && $addon['price'] ) {
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
if ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$addon_price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
}
$addon_price = wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $cart_item['data'] ) );
/* translators: %1$s flat fee addon price in order */
$value .= sprintf( _x( ' (+ %1$s)', 'flat fee addon price in cart', 'woocommerce-payments' ), $addon_price );
}
if ( class_exists( '\WC_Product_Addons_Helper' ) ) {
$price = \WC_Product_Addons_Helper::get_product_addon_price_for_display( $price, $cart_item['data'] );
$name .= ' (' . wc_price( $price ) . ')';
} elseif ( 'quantity_based' === $addon['price_type'] && $addon['price'] && $add_price_to_value ) {
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
if ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$addon_price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
}
$addon_price = wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $cart_item['data'] ) );
/* translators: %1$s addon price in order */
$value .= sprintf( _x( ' (%1$s)', 'quantity based addon price in cart', 'woocommerce-payments' ), $addon_price );
}
} else {
} elseif ( 'percentage_based' === $addon['price_type'] && $addon['price'] && $add_price_to_value ) {
// Get the percentage cost in the currency in use, and set the meta data on the product that the value was converted.
$_product = wc_get_product( $cart_item['product_id'] );
$price = $this->multi_currency->get_price( $price, 'product' );
$_product->set_price( $price * ( $addon['price'] / 100 ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope for this PR, but interesting that we update the product price with percentage-based add-ons, but don't update it for quantity-based add-ons.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this happens because the percentage amount needs to be calculated on top of the converted product price. Flat fees and quantity based add-ons are added on top of the product price and they don't need the product price to make any calculation about the actual add-on value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation.

$_product->update_meta_data( self::ADDONS_CONVERTED_META_KEY, 1 );
$name .= ' (' . WC()->cart->get_product_price( $_product ) . ')';
/* translators: %1$s addon price in order */
$value .= sprintf( _x( ' (%1$s)', 'percentage based addon price in cart', 'woocommerce-payments' ), WC()->cart->get_product_price( $_product ) );
}

return [
'name' => $name,
'value' => $addon['value'],
'name' => $addon['name'],
'value' => $value,
'display' => isset( $addon['display'] ) ? $addon['display'] : '',
];
}
Expand All @@ -155,10 +182,14 @@ public function get_item_data( $addon_data, $addon, $cart_item ): array {
* @return array
*/
public function update_product_price( $updated_prices, $cart_item, $prices ): array {
$price = $this->multi_currency->get_price( $prices['price'], 'product' );
$regular_price = $this->multi_currency->get_price( $prices['regular_price'], 'product' );
$sale_price = $this->multi_currency->get_price( $prices['sale_price'], 'product' );
$quantity = $cart_item['quantity'];
$price = $this->multi_currency->get_price( $prices['price'], 'product' );
$regular_price = $this->multi_currency->get_price( $prices['regular_price'], 'product' );
$sale_price = $this->multi_currency->get_price( $prices['sale_price'], 'product' );
$flat_fees = 0;
$quantity = $cart_item['quantity'];
$price_before_addons = $price;
$regular_price_before_addons = $regular_price;
$sale_price_before_addons = $sale_price;

// TODO: Check compat with Smart Coupons.
// Compatibility with Smart Coupons self declared gift amount purchase.
Expand Down Expand Up @@ -191,14 +222,16 @@ public function update_product_price( $updated_prices, $cart_item, $prices ): ar

switch ( $addon['price_type'] ) {
case 'percentage_based':
$price += (float) ( $cart_item['data']->get_price( 'view' ) * ( $addon_price / 100 ) );
$regular_price += (float) ( $regular_price * ( $addon_price / 100 ) );
$sale_price += (float) ( $sale_price * ( $addon_price / 100 ) );
$price += (float) ( $price_before_addons * ( $addon_price / 100 ) );
$regular_price += (float) ( $regular_price_before_addons * ( $addon_price / 100 ) );
$sale_price += (float) ( $sale_price_before_addons * ( $addon_price / 100 ) );
break;
case 'flat_fee':
$price += (float) ( $addon_price / $quantity );
$regular_price += (float) ( $addon_price / $quantity );
$sale_price += (float) ( $addon_price / $quantity );
$flat_fee = $quantity > 0 ? (float) ( $addon_price / $quantity ) : 0;
$price += $flat_fee;
$regular_price += $flat_fee;
$sale_price += $flat_fee;
$flat_fees += $flat_fee;
break;
default:
$price += (float) $addon_price;
Expand All @@ -212,9 +245,10 @@ public function update_product_price( $updated_prices, $cart_item, $prices ): ar
$cart_item['data']->update_meta_data( self::ADDONS_CONVERTED_META_KEY, 1 );

return [
'price' => $price,
'regular_price' => $regular_price,
'sale_price' => $sale_price,
'price' => $price,
'regular_price' => $regular_price,
'sale_price' => $sale_price,
'addons_flat_fees_sum' => $flat_fees,
];
}

Expand All @@ -230,8 +264,17 @@ public function update_product_price( $updated_prices, $cart_item, $prices ): ar
*/
public function order_line_item_meta( array $meta_data, array $addon, \WC_Order_Item_Product $item, array $values ): array {

$add_price_to_value = apply_filters( 'woocommerce_addons_add_order_price_to_value', false, $item );

$value = $addon['value'];

// Pass the timestamp as the add-on value in order to save the timestamp to the DB.
if ( isset( $addon['timestamp'] ) ) {
$value = $addon['timestamp'];
jimjasson marked this conversation as resolved.
Show resolved Hide resolved
}

// If there is an add-on price, add the price of the add-on to the label name.
if ( $addon['price'] && apply_filters( 'woocommerce_addons_add_price_to_name', true ) ) {
if ( $addon['price'] && $add_price_to_value ) {
$product = $item->get_product();

if ( 'percentage_based' === $addon['price_type'] && 0.0 !== (float) $product->get_price() ) {
Expand All @@ -247,24 +290,30 @@ public function order_line_item_meta( array $meta_data, array $addon, \WC_Order_
// Convert all others.
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
}
if ( class_exists( '\WC_Product_Addons_Helper' ) ) {
$price = html_entity_decode(
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$price = html_entity_decode(
wp_strip_all_tags( wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $values['data'] ) ) ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
$addon['name'] .= ' (' . $price . ')';
}
}

if ( 'custom_price' === $addon['field_type'] ) {
$addon['value'] = $addon['price'];
if ( 'flat_fee' === $addon['price_type'] && $addon['price'] && $add_price_to_value ) {
/* translators: %1$s flat fee addon price in order */
$value .= sprintf( _x( ' (+ %1$s)', 'flat fee addon price in order', 'woocommerce-payments' ), $price );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to consider handling things more gracefully when $price is not defined. Which can happen if class_exists( '\WC_Product_Addons_Helper' ) resolves to false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this, but couldn't find a case where the WC_Product_Addons_Helper wouldn't be defined -- do you have a specific case in mind?

Right now, if this class is not defined, the the add-on value will be the same as the one set by Product Add-Ons, i.e. not converted by the selected currency. Would you prefer to have a different fallback here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it's unlikely, this is one scenario where this may happen:

  • We remove WC_Product_Addons_Helper in a future version of woocommerce-product-addons.
  • The merchant installs the future version of woocommerce-product-addons.
  • The merchant installs this version of WooPayments.

Although this is possible, it's unlikely, so we can proceed without making modifications.

} elseif ( ( 'quantity_based' === $addon['price_type'] || 'percentage_based' === $addon['price_type'] ) && $addon['price'] && $add_price_to_value ) {
/* translators: %1$s addon price in order */
$value .= sprintf( _x( ' (%1$s)', 'addon price in order', 'woocommerce-payments' ), $price );
} elseif ( 'custom_price' === $addon['field_type'] ) {
/* translators: %1$s custom addon price in order */
$value = sprintf( _x( ' (%1$s)', 'custom addon price in order', 'woocommerce-payments' ), $price );
}

$meta_data['raw_price'] = $this->multi_currency->get_price( $addon['price'], 'product' );
}

return [
'key' => $addon['name'],
'value' => $addon['value'],
];
$meta_data['value'] = $value;
return $meta_data;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public function test_order_line_item_meta_returns_flat_fee_data_correctly() {
$this->mock_multi_currency->method( 'get_price' )->with( $price, 'product' )->willReturn( (float) $price * 2 );
$addon = [
'name' => 'checkboxes',
'value' => 'flat fee',
'value' => 'flat fee (+ $84.00)',
'price' => (float) $price,
'field_type' => 'checkbox',
'price_type' => 'flat_fee',
Expand All @@ -149,10 +149,10 @@ public function test_order_line_item_meta_returns_flat_fee_data_correctly() {
$item->save();

$expected = [
'key' => 'checkboxes ($84.00)',
'value' => 'flat fee',
'key' => 'checkboxes',
'value' => 'flat fee (+ $84.00)',
];
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [], $addon, $item, [ 'data' => '' ] ) );
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [ 'key' => 'checkboxes' ], $addon, $item, [ 'data' => '' ] ) );
}

public function test_order_line_item_meta_returns_percentage_data_correctly() {
Expand All @@ -171,10 +171,10 @@ public function test_order_line_item_meta_returns_percentage_data_correctly() {
$item->save();

$expected = [
'key' => 'checkboxes ($5.00)',
'key' => 'checkboxes',
'value' => 'percentage based',
];
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [], $addon, $item, [ 'data' => '' ] ) );
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [ 'key' => 'checkboxes' ], $addon, $item, [ 'data' => '' ] ) );
}

public function test_order_line_item_meta_returns_input_multiplier_data_correctly() {
Expand All @@ -195,18 +195,18 @@ public function test_order_line_item_meta_returns_input_multiplier_data_correctl
$item->save();

$expected = [
'key' => 'quantity ($42.00)',
'key' => 'quantity',
'value' => 2,
];
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [], $addon, $item, [ 'data' => '' ] ) );
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [ 'key' => 'quantity' ], $addon, $item, [ 'data' => '' ] ) );
}

public function test_order_line_item_meta_returns_custom_price_data_correctly() {
$price = 42;
$this->mock_multi_currency->method( 'get_price' )->with( $price, 'product' )->willReturn( (float) $price * 2 );
$addon = [
'name' => 'checkboxes',
'value' => 'custom price',
'name' => 'custom price',
'value' => (float) $price,
'price' => (float) $price,
'field_type' => 'custom_price',
'price_type' => '',
Expand All @@ -218,10 +218,10 @@ public function test_order_line_item_meta_returns_custom_price_data_correctly()
$item->save();

$expected = [
'key' => 'checkboxes ($42.00)',
'key' => 'checkboxes',
'value' => 42.0,
];
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [], $addon, $item, [ 'data' => '' ] ) );
$this->assertSame( $expected, $this->woocommerce_product_add_ons->order_line_item_meta( [ 'key' => 'checkboxes' ], $addon, $item, [ 'data' => '' ] ) );
}

public function test_update_product_price_returns_flat_fee_data_correctly() {
Expand All @@ -243,9 +243,10 @@ public function test_update_product_price_returns_flat_fee_data_correctly() {
'sale_price' => 0,
];
$expected = [
'price' => 78.0, // (10 * 1.5) + (42 * 1.5)
'regular_price' => 78.0,
'sale_price' => 63.0, // (0 * 1.5) + (42 * 1.5)
'price' => 78.0, // (10 * 1.5) + (42 * 1.5)
'regular_price' => 78.0,
'sale_price' => 63.0, // (0 * 1.5) + (42 * 1.5)
'addons_flat_fees_sum' => 63.0,
];

$this->mock_multi_currency
Expand Down Expand Up @@ -282,9 +283,10 @@ public function test_update_product_price_returns_percentage_data_correctly() {
'sale_price' => 0,
];
$expected = [
'price' => 22.5, // 10 * 1.5 * 1.5
'regular_price' => 22.5,
'sale_price' => 0.0,
'price' => 22.5, // 10 * 1.5 * 1.5
'regular_price' => 22.5,
'sale_price' => 0.0,
'addons_flat_fees_sum' => 0,
];

// Product is created with a price of 10, and update_product_price calls get_price, which is already converted.
Expand Down Expand Up @@ -322,9 +324,10 @@ public function test_update_product_price_returns_custom_price_data_correctly()
'sale_price' => 0,
];
$expected = [
'price' => 57.0, // (10 * 1.5) + 42
'regular_price' => 57.0,
'sale_price' => 42.0,
'price' => 57.0, // (10 * 1.5) + 42
'regular_price' => 57.0,
'sale_price' => 42.0,
'addons_flat_fees_sum' => 0,
];

$this->mock_multi_currency
Expand Down Expand Up @@ -359,9 +362,10 @@ public function test_update_product_price_returns_multiplier_data_correctly() {
'sale_price' => 0,
];
$expected = [
'price' => 141.0, // (10 * 1.5) + ((42 * 1.5) * 2)
'regular_price' => 141.0,
'sale_price' => 126.0, // (0 * 1.5) + ((42 * 1.5) * 2)
'price' => 141.0, // (10 * 1.5) + ((42 * 1.5) * 2)
'regular_price' => 141.0,
'sale_price' => 126.0, // (0 * 1.5) + ((42 * 1.5) * 2)
'addons_flat_fees_sum' => 126.0,
];

$this->mock_multi_currency
Expand Down Expand Up @@ -440,9 +444,9 @@ public function test_get_item_data_returns_custom_price_data_correctly() {
'addons_price_before_calc' => 10,
];
$expected = [
'name' => 'Customer defined price (<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
'value' => '',
'display' => '',
'name' => 'Customer defined price',
'value' => ' (<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
'display' => ' (<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
];

$this->assertSame( $expected, $this->woocommerce_product_add_ons->get_item_data( [], $addon, $cart_item ) );
Expand All @@ -464,8 +468,8 @@ public function test_get_item_data_returns_multiplier_price_data_correctly() {
'quantity' => 1,
];
$expected = [
'name' => 'Multiplier (<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
'value' => 2,
'name' => 'Multiplier',
'value' => '2 (+ <span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
'display' => '',
];

Expand Down Expand Up @@ -500,8 +504,8 @@ public function test_get_item_data_returns_price_data_correctly() {
'quantity' => 1,
];
$expected = [
'name' => 'Checkbox (<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
'value' => 'Flat fee',
'name' => 'Checkbox',
'value' => 'Flat fee (+ <span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>42.00</bdi></span>)',
'display' => '',
];

Expand All @@ -526,7 +530,7 @@ public function test_get_item_data_returns_percentage_price_data_correctly() {
'addons_price_before_calc' => 10,
];
$expected = [
'name' => 'Checkbox (<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&#36;</span>5.00</bdi></span>)',
'name' => 'Checkbox',
'value' => 'Percentage',
'display' => '',
];
Expand Down
Loading