From 07733d9c204592537677e2beff43e346e3e58787 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 1 Apr 2026 11:45:15 -0600 Subject: [PATCH] fix(paypal): add payee.merchant_id to purchase_units in create_order() PayPal partner model requires payee.merchant_id in each purchase_unit to ensure payments route to the correct merchant account when using OAuth (PPCP) mode. Previously only the PayPal-Auth-Assertion header was set; this adds the explicit payee field as PayPal requires. The field is only added when $this->merchant_id is non-empty (OAuth mode). Manual-credentials mode is unaffected. Closes #730. Part of the t523 PayPal PPCP compliance series. --- inc/gateways/class-paypal-rest-gateway.php | 52 +++--- .../Gateways/PayPal_REST_Gateway_Test.php | 160 ++++++++++++++++++ 2 files changed, 190 insertions(+), 22 deletions(-) diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php index e9c482e8f..5fb6e2c3c 100644 --- a/inc/gateways/class-paypal-rest-gateway.php +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -1010,6 +1010,35 @@ protected function create_order($payment, $membership, $customer, $cart, $type): $currency = $this->get_payment_currency_code($payment); $description = $this->get_subscription_description($cart); + $purchase_unit = [ + 'reference_id' => $payment->get_hash(), + 'description' => substr($description, 0, 127), + 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), + 'amount' => [ + 'currency_code' => $currency, + 'value' => $this->format_amount($payment->get_total(), $currency), + 'breakdown' => [ + 'item_total' => [ + 'currency_code' => $currency, + 'value' => $this->format_amount($payment->get_subtotal(), $currency), + ], + 'tax_total' => [ + 'currency_code' => $currency, + 'value' => $this->format_amount($payment->get_tax_total(), $currency), + ], + ], + ], + 'items' => $this->build_order_items($cart, $currency), + ]; + + // PayPal partner model requires payee.merchant_id in each purchase_unit + // to ensure payments route to the correct merchant account. + if (! empty($this->merchant_id)) { + $purchase_unit['payee'] = [ + 'merchant_id' => $this->merchant_id, + ]; + } + $order_data = [ 'intent' => 'CAPTURE', 'payment_source' => [ @@ -1025,28 +1054,7 @@ protected function create_order($payment, $membership, $customer, $cart, $type): 'email_address' => $customer->get_email_address(), ], ], - 'purchase_units' => [ - [ - 'reference_id' => $payment->get_hash(), - 'description' => substr($description, 0, 127), - 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), - 'amount' => [ - 'currency_code' => $currency, - 'value' => $this->format_amount($payment->get_total(), $currency), - 'breakdown' => [ - 'item_total' => [ - 'currency_code' => $currency, - 'value' => $this->format_amount($payment->get_subtotal(), $currency), - ], - 'tax_total' => [ - 'currency_code' => $currency, - 'value' => $this->format_amount($payment->get_tax_total(), $currency), - ], - ], - ], - 'items' => $this->build_order_items($cart, $currency), - ], - ], + 'purchase_units' => [ $purchase_unit ], ]; /** diff --git a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php index 279da14a2..9c6c87f4c 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php @@ -1647,6 +1647,166 @@ public function test_build_order_items_empty_cart(): void { $this->assertEmpty($result); } + // ------------------------------------------------------------------------- + // create_order() — payee.merchant_id + // ------------------------------------------------------------------------- + + /** + * Build a minimal customer + membership + payment for create_order() tests. + * + * @return array{customer: \WP_Ultimo\Models\Customer, membership: \WP_Ultimo\Models\Membership, payment: \WP_Ultimo\Models\Payment} + */ + private function create_order_test_fixtures(): array { + + $user_id = self::factory()->user->create( + [ + 'user_email' => 'paypal-rest-test-' . wp_rand() . '@example.com', + ] + ); + + $customer = wu_create_customer( + [ + 'user_id' => $user_id, + 'email' => get_userdata($user_id)->user_email, + 'username' => get_userdata($user_id)->user_login, + ] + ); + + if (is_wp_error($customer)) { + return []; + } + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'status' => 'active', + 'gateway' => 'paypal-rest', + 'product_id' => 0, + ] + ); + + if (is_wp_error($membership)) { + return []; + } + + $payment = new \WP_Ultimo\Models\Payment(); + $payment->set_currency('USD'); + $payment->set_total(50.00); + $payment->set_subtotal(50.00); + $payment->set_tax_total(0.00); + $payment->set_gateway('paypal-rest'); + $payment->set_membership_id($membership->get_id()); + $payment->set_customer_id($customer->get_id()); + $payment->save(); + + return [ + 'customer' => $customer, + 'membership' => $membership, + 'payment' => $payment, + ]; + } + + /** + * Invoke create_order() and capture the order_data via filter before the API call. + * + * Sets $this->payment on the gateway (required by get_confirm_url()) and hooks + * wu_paypal_rest_order_data to capture the data then abort before wp_redirect(). + * + * @param PayPal_REST_Gateway $gateway The gateway instance. + * @param array $fixtures Fixtures from create_order_test_fixtures(). + * @return array|null The captured order_data, or null if filter was not triggered. + */ + private function invoke_create_order_and_capture(PayPal_REST_Gateway $gateway, array $fixtures): ?array { + + // Set $this->payment on the gateway so get_confirm_url() can call get_hash(). + $reflection = new \ReflectionClass($gateway); + $payment_prop = $reflection->getParentClass()->getProperty('payment'); + $payment_prop->setAccessible(true); + $payment_prop->setValue($gateway, $fixtures['payment']); + + $captured_order_data = null; + + add_filter( + 'wu_paypal_rest_order_data', + function ($order_data) use (&$captured_order_data) { + $captured_order_data = $order_data; + throw new \RuntimeException('abort-before-api'); + } + ); + + $cart = $this->createMock(\WP_Ultimo\Checkout\Cart::class); + $cart->method('get_line_items')->willReturn([]); + $cart->method('get_cart_descriptor')->willReturn('Test Plan'); + + $method = $reflection->getMethod('create_order'); + + try { + $method->invoke($gateway, $fixtures['payment'], $fixtures['membership'], $fixtures['customer'], $cart, 'new'); + } catch (\RuntimeException $e) { + // Expected — thrown by the filter to abort before wp_redirect(). + } + + remove_all_filters('wu_paypal_rest_order_data'); + + return $captured_order_data; + } + + /** + * Test create_order includes payee.merchant_id when merchant_id is set (OAuth mode). + * + * Uses the wu_paypal_rest_order_data filter to capture order_data before the + * API call, then throws to abort execution before wp_redirect(). + */ + public function test_create_order_includes_payee_merchant_id_when_set(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'MERCHANT_ABC123'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + $fixtures = $this->create_order_test_fixtures(); + + if (empty($fixtures)) { + $this->markTestSkipped('Could not create test fixtures.'); + return; + } + + $order_data = $this->invoke_create_order_and_capture($gateway, $fixtures); + + $this->assertNotNull($order_data, 'wu_paypal_rest_order_data filter was not triggered'); + $this->assertArrayHasKey('purchase_units', $order_data); + $this->assertArrayHasKey('payee', $order_data['purchase_units'][0]); + $this->assertEquals('MERCHANT_ABC123', $order_data['purchase_units'][0]['payee']['merchant_id']); + } + + /** + * Test create_order omits payee when merchant_id is empty (manual credentials mode). + */ + public function test_create_order_omits_payee_when_merchant_id_empty(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'manual_client_id'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'manual_client_secret'); + wu_save_setting('paypal_rest_sandbox_merchant_id', ''); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + $fixtures = $this->create_order_test_fixtures(); + + if (empty($fixtures)) { + $this->markTestSkipped('Could not create test fixtures.'); + return; + } + + $order_data = $this->invoke_create_order_and_capture($gateway, $fixtures); + + $this->assertNotNull($order_data, 'wu_paypal_rest_order_data filter was not triggered'); + $this->assertArrayHasKey('purchase_units', $order_data); + $this->assertArrayNotHasKey('payee', $order_data['purchase_units'][0]); + } + // ------------------------------------------------------------------------- // Supported currencies list // -------------------------------------------------------------------------