Skip to content

Commit 4d72990

Browse files
authored
Merge pull request #4530 from woocommerce/dev/PCP-6699-fractional-woo-commerce-quantities-are-truncated-to-0-causing-invalid-pay-pal-line-items-and-checkout-failures
fix(api-client): fractional WooCommerce quantities truncated to 0, breaking PayPal checkout (6699)
2 parents 23f81b8 + c161b1d commit 4d72990

2 files changed

Lines changed: 154 additions & 10 deletions

File tree

modules/ppcp-api-client/src/Factory/ItemFactory.php

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,22 @@ function ( array $item ): Item {
5757
$product = $item['data'];
5858
$cart_item_key = $item['key'] ?? null;
5959

60-
$quantity = (int) $item['quantity'];
61-
$image = wp_get_attachment_image_src( (int) $product->get_image_id(), 'full' );
60+
$wc_quantity = (float) $item['quantity'];
61+
/**
62+
* PayPal requires an integer quantity of at least 1. WooCommerce allows
63+
* fractional quantities (e.g. 0.3, via plugins like Measurement Price
64+
* Calculator), which would otherwise truncate to 0 and be rejected by
65+
* the PayPal API. Normalize such lines to a single unit priced at the
66+
* full line subtotal, instead of truncating the quantity to 0.
67+
*/
68+
$is_fractional_unit = $wc_quantity > 0 && $wc_quantity < 1;
69+
$quantity = $is_fractional_unit ? 1 : (int) $wc_quantity;
70+
$image = wp_get_attachment_image_src( (int) $product->get_image_id(), 'full' );
6271

63-
$price = (float) $item['line_subtotal'] / (float) $item['quantity'];
64-
$line_tax = isset( $item['line_tax'] ) ? (float) $item['line_tax'] : 0.0;
65-
$unit_tax = $quantity > 0 ? $line_tax / (float) $quantity : 0.0;
72+
$line_subtotal = (float) $item['line_subtotal'];
73+
$price = $is_fractional_unit ? $line_subtotal : $line_subtotal / $wc_quantity;
74+
$line_tax = isset( $item['line_tax'] ) ? (float) $item['line_tax'] : 0.0;
75+
$unit_tax = $is_fractional_unit ? $line_tax : ( $quantity > 0 ? $line_tax / (float) $quantity : 0.0 );
6676

6777
return new Item(
6878
$this->prepare_item_string( $product->get_name() ),
@@ -137,14 +147,23 @@ function ( \WC_Order_Item_Fee $item ) use ( $order ): Item {
137147
* @return Item
138148
*/
139149
private function from_wc_order_line_item( \WC_Order_Item_Product $item, \WC_Order $order ): Item {
140-
$product = $item->get_product();
141-
$currency = $order->get_currency();
142-
$quantity = (int) $item->get_quantity();
143-
$price_without_tax = (float) $order->get_item_subtotal( $item, false );
150+
$product = $item->get_product();
151+
$currency = $order->get_currency();
152+
$wc_quantity = (float) $item->get_quantity();
153+
/**
154+
* PayPal requires an integer quantity of at least 1. WooCommerce allows
155+
* fractional quantities (e.g. 0.3, via plugins like Measurement Price
156+
* Calculator), which would otherwise truncate to 0 and be rejected by
157+
* the PayPal API. Normalize such lines to a single unit priced at the
158+
* full line subtotal, instead of truncating the quantity to 0.
159+
*/
160+
$is_fractional_unit = $wc_quantity > 0 && $wc_quantity < 1;
161+
$quantity = $is_fractional_unit ? 1 : (int) $wc_quantity;
162+
$price_without_tax = $is_fractional_unit ? (float) $item->get_subtotal() : (float) $order->get_item_subtotal( $item, false );
144163
$price_without_tax_rounded = round( $price_without_tax, 2 );
145164
$image = $product instanceof WC_Product ? wp_get_attachment_image_src( (int) $product->get_image_id(), 'full' ) : '';
146165
$line_tax = (float) $item->get_total_tax();
147-
$unit_tax = $quantity > 0 ? $line_tax / (float) $quantity : 0.0;
166+
$unit_tax = $is_fractional_unit ? $line_tax : ( $quantity > 0 ? $line_tax / (float) $quantity : 0.0 );
148167

149168
return new Item(
150169
$this->prepare_item_string( $item->get_name() ),

tests/PHPUnit/ApiClient/Factory/ItemFactoryTest.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,66 @@ public function testFromCartDefault()
8888
$this->assertEquals(42, $item->unit_amount()->value());
8989
}
9090

91+
public function testFromCartFractionalQuantityIsNormalizedToOne()
92+
{
93+
$testee = new ItemFactory($this->currency);
94+
95+
$product = Mockery::mock(\WC_Product_Simple::class);
96+
$product
97+
->expects('get_name')
98+
->andReturn('name');
99+
$product
100+
->expects('get_description')
101+
->andReturn('description');
102+
$product
103+
->expects('get_sku')
104+
->andReturn('sku');
105+
$product
106+
->expects('is_virtual')
107+
->andReturn(false);
108+
$items = [
109+
[
110+
'data' => $product,
111+
'quantity' => 0.3,
112+
'line_subtotal' => 30.00,
113+
'line_total' => 30.00
114+
],
115+
];
116+
$cart = Mockery::mock(\WC_Cart::class);
117+
$cart
118+
->expects('get_cart_contents')
119+
->andReturn($items);
120+
121+
when('get_woocommerce_currency')->justReturn('USD');
122+
123+
expect('wp_strip_all_tags')->andReturnFirstArg();
124+
expect('strip_shortcodes')->andReturnFirstArg();
125+
126+
$woocommerce = Mockery::mock(\WooCommerce::class);
127+
$session = Mockery::mock(\WC_Session::class);
128+
when('WC')->justReturn($woocommerce);
129+
$woocommerce->session = $session;
130+
$session->shouldReceive('get')->andReturn([]);
131+
132+
when('wp_get_attachment_image_src')->justReturn('image_url');
133+
$product
134+
->expects('get_image_id')
135+
->andReturn(1);
136+
$product
137+
->expects('get_permalink')
138+
->andReturn('url');
139+
$product->shouldReceive('get_id')->andReturn(null);
140+
141+
$result = $testee->from_wc_cart($cart);
142+
143+
$item = current($result);
144+
/**
145+
* @var Item $item
146+
*/
147+
$this->assertEquals(1, $item->quantity());
148+
$this->assertEquals(30.00, $item->unit_amount()->value());
149+
}
150+
91151
public function testFromCartDigitalGood()
92152
{
93153
$testee = new ItemFactory($this->currency);
@@ -216,6 +276,71 @@ public function testFromWcOrderDefault()
216276
$this->assertEquals(1, $item->unit_amount()->value());
217277
}
218278

279+
public function testFromWcOrderFractionalQuantityIsNormalizedToOne()
280+
{
281+
$testee = new ItemFactory($this->currency);
282+
283+
$product = Mockery::mock(\WC_Product::class);
284+
$product
285+
->expects('get_description')
286+
->andReturn('description');
287+
$product
288+
->expects('get_sku')
289+
->andReturn('sku');
290+
$product
291+
->expects('is_virtual')
292+
->andReturn(false);
293+
294+
expect('wp_strip_all_tags')->andReturnFirstArg();
295+
expect('strip_shortcodes')->andReturnFirstArg();
296+
297+
when('wp_get_attachment_image_src')->justReturn('image_url');
298+
$product
299+
->expects('get_image_id')
300+
->andReturn(1);
301+
$product
302+
->expects('get_permalink')
303+
->andReturn('url');
304+
305+
$item = Mockery::mock(\WC_Order_Item_Product::class);
306+
$item
307+
->expects('get_product')
308+
->andReturn($product);
309+
$item
310+
->expects('get_name')
311+
->andReturn('name');
312+
$item
313+
->expects('get_quantity')
314+
->andReturn(0.3);
315+
316+
$order = Mockery::mock(\WC_Order::class);
317+
$order
318+
->shouldReceive('get_currency')
319+
->once()
320+
->andReturn($this->currency->get());
321+
$order
322+
->expects('get_items')
323+
->andReturn([$item]);
324+
$order
325+
->expects('get_fees')
326+
->andReturn([]);
327+
$order
328+
->shouldNotReceive('get_item_subtotal');
329+
330+
$product->shouldReceive('get_id')->andReturn(123);
331+
$item->shouldReceive('get_subtotal')->andReturn(30.00);
332+
$item->shouldReceive('get_total')->andReturn(30.00);
333+
$item->shouldReceive('get_total_tax')->andReturn(0.00);
334+
335+
$result = $testee->from_wc_order($order);
336+
$item = current($result);
337+
/**
338+
* @var Item $item
339+
*/
340+
$this->assertEquals(1, $item->quantity());
341+
$this->assertEquals(30.00, $item->unit_amount()->value());
342+
}
343+
219344
public function testFromWcOrderDigitalGood()
220345
{
221346
$testee = new ItemFactory($this->currency);

0 commit comments

Comments
 (0)