Skip to content

Commit af1315a

Browse files
committed
fix(amount-factory): derive total from breakdown components to prevent PayPal PATCH rejection
WC_Cart::get_total() can diverge from the arithmetic sum of its component getters (get_subtotal, get_shipping_total, get_total_tax) by ±$0.01 due to per-item tax rounding. PayPal's PATCH order API requires amount.value to exactly equal item_total + shipping + tax_total - discount, so when the two values differ the PATCH is rejected and the buyer cannot change their shipping address in the one-time pay (Fastlane guest) flow. Fix from_wc_cart(): compute total in integer cents from the same component floats used to build the breakdown, so the formatted values are always internally consistent. Fix from_store_api_cart(): use integer minor-unit arithmetic on Store API values (already in cents) and include total_fees in item_total to match from_wc_cart() and cover carts with WooCommerce fees.
1 parent a96febb commit af1315a

1 file changed

Lines changed: 46 additions & 22 deletions

File tree

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

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -74,28 +74,30 @@ public function __construct(
7474
* @return Amount
7575
*/
7676
public function from_wc_cart( \WC_Cart $cart ): Amount {
77-
$total = new Money( (float) $cart->get_total( 'numeric' ), $this->currency->get() );
77+
$item_total_val = (float) $cart->get_subtotal() + (float) $cart->get_fee_total();
78+
$shipping_val = (float) $cart->get_shipping_total();
79+
$taxes_val = (float) $cart->get_total_tax();
80+
$discount_val = (float) $cart->get_discount_total();
7881

79-
$item_total = (float) $cart->get_subtotal() + (float) $cart->get_fee_total();
80-
$item_total = new Money( $item_total, $this->currency->get() );
81-
$shipping = new Money(
82-
(float) $cart->get_shipping_total(),
83-
$this->currency->get()
84-
);
85-
86-
$taxes = new Money(
87-
(float) $cart->get_total_tax(),
88-
$this->currency->get()
89-
);
82+
$item_total = new Money( $item_total_val, $this->currency->get() );
83+
$shipping = new Money( $shipping_val, $this->currency->get() );
84+
$taxes = new Money( $taxes_val, $this->currency->get() );
9085

9186
$discount = null;
92-
if ( $cart->get_discount_total() ) {
93-
$discount = new Money(
94-
(float) $cart->get_discount_total(),
95-
$this->currency->get()
96-
);
87+
if ( $discount_val ) {
88+
$discount = new Money( $discount_val, $this->currency->get() );
9789
}
9890

91+
// Derive the total from breakdown components in integer cents rather than
92+
// using get_total(), which can diverge from the component sum by ±$0.01
93+
// due to WooCommerce per-item tax rounding. PayPal requires amount.value to
94+
// exactly equal the sum of its breakdown fields or it rejects the PATCH.
95+
$total_cents = (int) round( $item_total_val * 100 )
96+
+ (int) round( $shipping_val * 100 )
97+
+ (int) round( $taxes_val * 100 )
98+
- (int) round( $discount_val * 100 );
99+
$total = new Money( $total_cents / 100, $this->currency->get() );
100+
99101
$breakdown = new AmountBreakdown(
100102
$item_total,
101103
$shipping,
@@ -116,16 +118,38 @@ public function from_wc_cart( \WC_Cart $cart ): Amount {
116118
* Returns an Amount object based off a WooCommerce cart object from the Store API.
117119
*/
118120
public function from_store_api_cart( CartTotals $cart_totals ): Amount {
121+
// Store API values are in integer minor units (e.g. cents), so integer
122+
// arithmetic here is exact. Fees are included in items to match
123+
// from_wc_cart() and to avoid a breakdown mismatch when fees are present.
124+
// Total is derived from the breakdown sum rather than total_price() so
125+
// PayPal's amount.value === sum(breakdown) invariant always holds.
126+
$items_minor = (int) $cart_totals->total_items()->value()
127+
+ (int) $cart_totals->total_fees()->value();
128+
$shipping_minor = (int) $cart_totals->total_shipping()->value();
129+
$tax_minor = (int) $cart_totals->total_tax()->value();
130+
$discount_minor = (int) $cart_totals->total_discount()->value();
131+
$total_minor = $items_minor + $shipping_minor + $tax_minor - $discount_minor;
132+
133+
$currency = $cart_totals->total_price()->currency_code();
134+
$minor_unit = $cart_totals->total_price()->currency_minor_unit();
135+
$make = static function ( int $minor ) use ( $currency, $minor_unit ): Money {
136+
return ( new \WooCommerce\PayPalCommerce\WcGateway\StoreApi\Entity\Money(
137+
(string) $minor,
138+
$currency,
139+
$minor_unit
140+
) )->to_paypal();
141+
};
142+
119143
return new Amount(
120-
$cart_totals->total_price()->to_paypal(),
144+
$make( $total_minor ),
121145
new AmountBreakdown(
122-
$cart_totals->total_items()->to_paypal(),
123-
$cart_totals->total_shipping()->to_paypal(),
124-
$cart_totals->total_tax()->to_paypal(),
146+
$make( $items_minor ),
147+
$make( $shipping_minor ),
148+
$make( $tax_minor ),
125149
null,
126150
null,
127151
null,
128-
$cart_totals->total_discount()->to_paypal(),
152+
$make( $discount_minor ),
129153
)
130154
);
131155
}

0 commit comments

Comments
 (0)