1010use WooCommerce \PayPalCommerce \TestCase ;
1111use Mockery ;
1212use WooCommerce \PayPalCommerce \WcGateway \Gateway \PayPalGateway ;
13+ use WooCommerce \PayPalCommerce \WcGateway \StoreApi \Entity \CartTotals ;
14+ use WooCommerce \PayPalCommerce \WcGateway \StoreApi \Entity \Money as StoreApiMoney ;
1315use function Brain \Monkey \Functions \expect ;
1416use function Brain \Monkey \Functions \when ;
1517
@@ -35,10 +37,6 @@ public function setUp(): void
3537 public function testFromWcCartDefault ()
3638 {
3739 $ cart = Mockery::mock (\WC_Cart::class);
38- $ cart
39- ->shouldReceive ('get_total ' )
40- ->withAnyArgs ()
41- ->andReturn (13 );
4240 $ cart
4341 ->shouldReceive ('get_subtotal ' )
4442 ->andReturn (5 );
@@ -78,12 +76,8 @@ public function testFromWcCartNoDiscount()
7876 {
7977 $ cart = Mockery::mock (\WC_Cart::class);
8078 $ cart
81- ->shouldReceive ('get_total ' )
82- ->withAnyArgs ()
83- ->andReturn (11 );
84- $ cart
85- ->shouldReceive ('get_subtotal ' )
86- ->andReturn (5 );
79+ ->shouldReceive ('get_subtotal ' )
80+ ->andReturn (5 );
8781 $ cart
8882 ->shouldReceive ('get_fee_total ' )
8983 ->andReturn (0 );
@@ -110,6 +104,165 @@ public function testFromWcCartNoDiscount()
110104 $ this ->assertEquals ((float ) 4 , $ result ->breakdown ()->tax_total ()->value ());
111105 }
112106
107+ /**
108+ * The critical invariant: amount.value must always equal the formatted sum of
109+ * its breakdown parts. This verifies the fix for the FCI PATCH rejection that
110+ * occurred when WC per-item tax rounding caused get_total() to diverge by ±$0.01.
111+ *
112+ * @dataProvider dataBreakdownRoundingCases
113+ */
114+ public function testFromWcCartTotalAlwaysEqualsBreakdownSum (
115+ float $ subtotal ,
116+ float $ fees ,
117+ float $ shipping ,
118+ float $ tax ,
119+ float $ discount
120+ ): void {
121+ $ cart = Mockery::mock ( \WC_Cart::class );
122+ $ cart ->shouldReceive ( 'get_subtotal ' )->andReturn ( $ subtotal );
123+ $ cart ->shouldReceive ( 'get_fee_total ' )->andReturn ( $ fees );
124+ $ cart ->shouldReceive ( 'get_shipping_total ' )->andReturn ( $ shipping );
125+ $ cart ->shouldReceive ( 'get_total_tax ' )->andReturn ( $ tax );
126+ $ cart ->shouldReceive ( 'get_discount_total ' )->andReturn ( $ discount );
127+
128+ $ woocommerce = Mockery::mock ( \WooCommerce::class );
129+ $ session = Mockery::mock ( \WC_Session::class );
130+ $ woocommerce ->session = $ session ;
131+ when ( 'WC ' )->justReturn ( $ woocommerce );
132+ $ session ->shouldReceive ( 'get ' )->andReturn ( [] );
133+
134+ $ result = $ this ->testee ->from_wc_cart ( $ cart );
135+ $ breakdown = $ result ->breakdown ();
136+
137+ $ sum = (float ) $ breakdown ->item_total ()->value_str ()
138+ + (float ) $ breakdown ->shipping ()->value_str ()
139+ + (float ) $ breakdown ->tax_total ()->value_str ();
140+ if ( $ breakdown ->discount () ) {
141+ $ sum -= (float ) $ breakdown ->discount ()->value_str ();
142+ }
143+ $ expected_total_str = number_format ( $ sum , 2 , '. ' , '' );
144+
145+ $ this ->assertSame (
146+ $ expected_total_str ,
147+ $ result ->value_str (),
148+ "amount.value_str() must equal the formatted breakdown sum (PayPal PATCH invariant) "
149+ );
150+ }
151+
152+ public function dataBreakdownRoundingCases (): array
153+ {
154+ return [
155+ // Classic WC tax-rounding edge case: 8% tax on $13.33 = $1.0664.
156+ // WC rounds to $1.07 for tax but get_total() may accumulate differently.
157+ 'tax_rounding_edge_case ' => [ 13.33 , 0.0 , 5.00 , 1.07 , 0.0 ],
158+ // Three items at $3.336 each: item total rounds differently at cart vs item level.
159+ 'multi_item_tax_rounding ' => [ 10.01 , 0.0 , 4.99 , 1.50 , 0.0 ],
160+ // Discount present; all components interact.
161+ 'with_discount ' => [ 20.00 , 2.00 , 3.50 , 2.25 , 1.75 ],
162+ // Fees and no discount.
163+ 'with_fees_no_discount ' => [ 15.50 , 4.50 , 6.00 , 2.60 , 0.0 ],
164+ // Large order with all components.
165+ 'large_order ' => [ 199.99 , 10.00 , 12.95 , 22.30 , 15.00 ],
166+ // Zero-value shipping (digital goods).
167+ 'digital_no_shipping ' => [ 49.99 , 0.0 , 0.0 , 4.50 , 0.0 ],
168+ ];
169+ }
170+
171+ /**
172+ * Proves that the total is derived from breakdown components, not get_total().
173+ * When WC's get_total() is off by 1 cent, the PATCH total must still match
174+ * the breakdown so PayPal accepts it.
175+ */
176+ public function testFromWcCartTotalDerivesFromComponentsNotWcGetTotal (): void
177+ {
178+ $ cart = Mockery::mock ( \WC_Cart::class );
179+ // Components sum to $19.40 (1333 + 500 + 107 = 1940 cents).
180+ $ cart ->shouldReceive ( 'get_subtotal ' )->andReturn ( 13.33 );
181+ $ cart ->shouldReceive ( 'get_fee_total ' )->andReturn ( 0.0 );
182+ $ cart ->shouldReceive ( 'get_shipping_total ' )->andReturn ( 5.00 );
183+ $ cart ->shouldReceive ( 'get_total_tax ' )->andReturn ( 1.07 );
184+ $ cart ->shouldReceive ( 'get_discount_total ' )->andReturn ( 0.0 );
185+ // get_total() deliberately returns a mismatched value to prove it is not used.
186+ $ cart ->shouldReceive ( 'get_total ' )->withAnyArgs ()->andReturn ( 19.39 );
187+
188+ $ woocommerce = Mockery::mock ( \WooCommerce::class );
189+ $ session = Mockery::mock ( \WC_Session::class );
190+ $ woocommerce ->session = $ session ;
191+ when ( 'WC ' )->justReturn ( $ woocommerce );
192+ $ session ->shouldReceive ( 'get ' )->andReturn ( [] );
193+
194+ $ result = $ this ->testee ->from_wc_cart ( $ cart );
195+
196+ // Total must be $19.40 (from breakdown), not $19.39 (from get_total()).
197+ $ this ->assertSame ( '19.40 ' , $ result ->value_str () );
198+ $ this ->assertSame ( '13.33 ' , $ result ->breakdown ()->item_total ()->value_str () );
199+ $ this ->assertSame ( '5.00 ' , $ result ->breakdown ()->shipping ()->value_str () );
200+ $ this ->assertSame ( '1.07 ' , $ result ->breakdown ()->tax_total ()->value_str () );
201+ }
202+
203+ /**
204+ * @dataProvider dataFromStoreApiCart
205+ */
206+ public function testFromStoreApiCart (
207+ int $ items ,
208+ int $ fees ,
209+ int $ shipping ,
210+ int $ tax ,
211+ int $ discount ,
212+ string $ expected_total ,
213+ string $ expected_items ,
214+ string $ expected_shipping ,
215+ string $ expected_tax ,
216+ ?string $ expected_discount
217+ ): void {
218+ $ currency = 'USD ' ;
219+ $ minor_unit = 2 ;
220+ $ make = fn ( int $ v ) => new StoreApiMoney ( (string ) $ v , $ currency , $ minor_unit );
221+
222+ $ totals = Mockery::mock ( CartTotals::class );
223+ $ totals ->shouldReceive ( 'total_items ' )->andReturn ( $ make ( $ items ) );
224+ $ totals ->shouldReceive ( 'total_fees ' )->andReturn ( $ make ( $ fees ) );
225+ $ totals ->shouldReceive ( 'total_shipping ' )->andReturn ( $ make ( $ shipping ) );
226+ $ totals ->shouldReceive ( 'total_tax ' )->andReturn ( $ make ( $ tax ) );
227+ $ totals ->shouldReceive ( 'total_discount ' )->andReturn ( $ make ( $ discount ) );
228+ $ totals ->shouldReceive ( 'total_price ' )->andReturn ( $ make ( 0 ) ); // never used for value
229+
230+ $ result = $ this ->testee ->from_store_api_cart ( $ totals );
231+ $ breakdown = $ result ->breakdown ();
232+
233+ $ this ->assertSame ( $ expected_total , $ result ->value_str (), 'amount.value_str ' );
234+ $ this ->assertSame ( $ expected_items , $ breakdown ->item_total ()->value_str (), 'item_total includes fees ' );
235+ $ this ->assertSame ( $ expected_shipping , $ breakdown ->shipping ()->value_str (), 'shipping ' );
236+ $ this ->assertSame ( $ expected_tax , $ breakdown ->tax_total ()->value_str (), 'tax_total ' );
237+ if ( null === $ expected_discount ) {
238+ $ this ->assertNull ( $ breakdown ->discount (), 'discount should be null ' );
239+ } else {
240+ $ this ->assertSame ( $ expected_discount , $ breakdown ->discount ()->value_str (), 'discount ' );
241+ }
242+ // Core invariant: value must equal breakdown sum.
243+ $ sum = (float ) $ breakdown ->item_total ()->value_str ()
244+ + (float ) $ breakdown ->shipping ()->value_str ()
245+ + (float ) $ breakdown ->tax_total ()->value_str ();
246+ if ( $ breakdown ->discount () ) {
247+ $ sum -= (float ) $ breakdown ->discount ()->value_str ();
248+ }
249+ $ this ->assertSame ( $ result ->value_str (), number_format ( $ sum , 2 , '. ' , '' ), 'breakdown invariant ' );
250+ }
251+
252+ public function dataFromStoreApiCart (): array
253+ {
254+ return [
255+ // items=1000¢, fees=0, shipping=500¢, tax=180¢, discount=0 → total=1680¢=$16.80
256+ 'no_fees_no_discount ' => [ 1000 , 0 , 500 , 180 , 0 , '16.80 ' , '10.00 ' , '5.00 ' , '1.80 ' , null ],
257+ // Fees included in item_total: items=1000¢ + fees=200¢ = 1200¢
258+ 'with_fees ' => [ 1000 , 200 , 500 , 180 , 0 , '18.80 ' , '12.00 ' , '5.00 ' , '1.80 ' , null ],
259+ // Discount reduces total: items=2000¢ + fees=0, shipping=500¢, tax=225¢, discount=150¢
260+ 'with_discount ' => [ 2000 , 0 , 500 , 225 , 150 , '25.75 ' , '20.00 ' , '5.00 ' , '2.25 ' , '1.50 ' ],
261+ // Fees + discount together.
262+ 'fees_and_discount ' => [ 1500 , 300 , 700 , 250 , 100 , '26.50 ' , '18.00 ' , '7.00 ' , '2.50 ' , '1.00 ' ],
263+ ];
264+ }
265+
113266 public function testFromWcOrderDefault ()
114267 {
115268 $ order = Mockery::mock (\WC_Order::class);
@@ -134,9 +287,6 @@ public function testFromWcOrderDefault()
134287 ->shouldReceive ('get_payment_method ' )
135288 ->andReturn (PayPalGateway::ID );
136289
137- $ order
138- ->shouldReceive ('get_total ' )
139- ->andReturn (10 );
140290 $ order
141291 ->shouldReceive ('get_subtotal ' )
142292 ->andReturn (6 );
@@ -195,9 +345,6 @@ public function testFromWcOrderDiscountIsNull()
195345 ->shouldReceive ('get_payment_method ' )
196346 ->andReturn (PayPalGateway::ID );
197347
198- $ order
199- ->shouldReceive ('get_total ' )
200- ->andReturn (100 );
201348 $ order
202349 ->shouldReceive ('get_subtotal ' )
203350 ->andReturn (6 );
0 commit comments