Skip to content

Add modern PayPal REST API gateway with subscription support#305

Open
superdav42 wants to merge 11 commits intomainfrom
fix/paypal-checkout-loop-193
Open

Add modern PayPal REST API gateway with subscription support#305
superdav42 wants to merge 11 commits intomainfrom
fix/paypal-checkout-loop-193

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Dec 23, 2025

Summary

Adds a modern PayPal REST API gateway alongside the existing legacy NVP gateway. The new gateway uses PayPal's v2 Orders and Subscriptions APIs with proper webhook handling.

New Files

  • class-paypal-rest-gateway.php — Full gateway implementation (one-time + recurring payments via REST API v2)
  • class-paypal-webhook-handler.php — Webhook processing for subscription events, payment completions, refunds
  • class-paypal-oauth-handler.php — OAuth Partner Referrals flow for merchant onboarding ("Connect with PayPal")
  • class-base-paypal-gateway.php — Shared base class for both PayPal gateways (URLs, logging, BN code)

Key Features

  • One-time payments via PayPal Orders API v2
  • Recurring subscriptions via PayPal Subscriptions API
  • Webhook handling: subscription activated/cancelled/suspended, payment completed, refunds
  • Duplicate payment prevention (checks gateway_payment_id before creating)
  • OAuth "Connect with PayPal" flow for easy merchant onboarding
  • Sandbox/live mode switching
  • "View on PayPal" links in membership admin
  • Partner credentials via constants (WU_PAYPAL_PARTNER_*), settings, or wu_paypal_partner_credentials filter

Security Fixes

  • Removed WP_DEBUG-based webhook signature bypass — replaced with explicit wu_paypal_skip_webhook_verification filter
  • Fixed fallback returns that could bypass verification in test mode
  • Partner credentials can be stored outside the database via constants

Tests

  • 20 unit tests for PayPal_REST_Gateway (configuration, URLs, connection status, mode switching)
  • 14 unit tests for PayPal_Webhook_Handler (signature verification, event handling, duplicate prevention, refunds)
  • Updated Gateway_Manager_Test to verify both paypal-rest and paypal registration

Test plan

  • All unit tests pass (2917 tests, 34 new)
  • PHPCS passes with zero errors
  • PHPStan passes with zero errors
  • Enable PayPal REST gateway in settings with sandbox credentials
  • Test one-time payment checkout flow
  • Test recurring subscription checkout flow
  • Verify webhook handling (subscription activated, payment completed)
  • Verify legacy PayPal gateway still works

Fixes #193

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Full PayPal REST support: OAuth connect/disconnect flow, sandbox/live modes, webhook handling, REST checkout and subscription flows, connection status and admin notices; legacy PayPal (NVP) remains available.
  • Bug Fixes

    • Improved error handling and reporting for confirmations, token issues, refunds, and subscription cancellations.
  • Chores

    • Consolidated PayPal gateway plumbing, unified gateway URL helpers, logging improvements, and gateway registration/initialization updates.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 23, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a PayPal REST gateway (OAuth, token caching, API wrapper, orders/subscriptions, webhooks), a reusable Base_PayPal_Gateway, a PayPal OAuth onboarding handler, a webhook processor, and updates the legacy NVP PayPal gateway and Base_Gateway URL helper return types and gateway manager registration/hooks.

Changes

Cohort / File(s) Summary
Base gateway helpers
inc/gateways/class-base-gateway.php
Tightened return types: get_payment_url_on_gateway, get_subscription_url_on_gateway, get_customer_url_on_gateway now return string and return '' by default.
New PayPal common base
inc/gateways/class-base-paypal-gateway.php
New abstract Base_PayPal_Gateway added with PayPal base/API URL helpers, partner attribution header, logging, capability flags (recurring/amount update), subscription/URL helpers, site action integration, and abstract is_configured() / get_connection_status().
Legacy NVP PayPal gateway
inc/gateways/class-paypal-gateway.php
Now extends Base_PayPal_Gateway; adds is_configured() and get_connection_status(), updates docblocks/signatures (confirmation_form(): void), and removes some legacy helper methods.
PayPal REST gateway
inc/gateways/class-paypal-rest-gateway.php
New PayPal_REST_Gateway implementing OAuth/manual credentials, token caching, api_request(), create order/subscription flows, confirmation handlers, refunds/cancellations, webhook install/delete, admin settings/UI, and many helpers.
OAuth onboarding handler
inc/gateways/class-paypal-oauth-handler.php
New PayPal_OAuth_Handler to initiate/verify OAuth via proxy, handle return, store merchant credentials/status, install/delete webhooks, provide AJAX connect/disconnect endpoints, and surface admin notices.
Webhook processing
inc/gateways/class-paypal-webhook-handler.php
New PayPal_Webhook_Handler to verify webhook signatures, dispatch subscription/payment events, update memberships/payments, ensure idempotency, and extensive logging.
Gateway manager & registration
inc/managers/class-gateway-manager.php
Registers paypal-rest and legacy paypal, imports REST/webhook classes, initializes webhook handler, switches some remote URL hooks to filters, and updates gateway fields rendering.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Site as WP Site (PayPal_REST_Gateway)
  participant PayPal as PayPal API
  participant Webhook as PayPal_Webhook_Handler
  participant DB as WP Ultimo Storage

  User->>Site: Start checkout (create order/subscription)
  Site->>PayPal: Create Order/Subscription -> returns approval URL
  PayPal-->>User: Approval page (redirect)
  User->>PayPal: Approve payment/subscription
  PayPal-->>Site: Redirect callback (token / subscription_id)
  Site->>PayPal: Confirm order/subscription (API)
  PayPal-->>Site: Confirmation (status, transaction ids)
  Site->>DB: Persist payment/membership updates
  PayPal->>Webhook: Send webhook events (captures, refunds, subs updates)
  Webhook->>Site: Verify & dispatch -> Site updates DB/state
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Enhancements #346 — Edits PayPal gateway files and URL helper signatures; likely overlaps with legacy PayPal gateway and base gateway changes in this PR.

"I hopped through code with whiskers bright,
OAuth threads and webhooks in sight,
New gateways stitched with eager paws,
I nibble logs and celebrate the cause 🐇✨"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding a modern PayPal REST API gateway with subscription support, which aligns with the core objective of implementing PayPal v2 Orders and Subscriptions APIs.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #193 objectives: provides REST API alternative to NVP (fixing 10002 error), implements webhook handling, OAuth onboarding, sandbox/live mode support, verbose logging, and clear error surfacing instead of silent loops.
Out of Scope Changes check ✅ Passed All changes are directly scoped to PayPal gateway implementation: new REST gateway, webhook handler, OAuth handler, shared PayPal base class, and gateway manager updates to register new gateways. No unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/paypal-checkout-loop-193

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
inc/gateways/class-paypal-gateway.php (1)

848-852: Consider using integer for HTTP response code.

The response parameter in wp_die() expects an integer. While the string '200' works (WordPress casts it), using an integer 200 would be more conventional and explicit.

This pattern appears in multiple wp_die() calls throughout the file (lines 848, 862, 885, 906, 927, 1351, 1381, 1477, 1558, 1580, 1658, 1687, 1705, 1724).

🔎 Example fix (apply to all occurrences)
-					'response'  => '200',
+					'response'  => 200,
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 068753e and 134ca8b.

📒 Files selected for processing (1)
  • inc/gateways/class-paypal-gateway.php
🧰 Additional context used
🪛 PHPMD (2.15.0)
inc/gateways/class-paypal-gateway.php

1802-1802: Avoid unused local variables such as '$custom'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: cypress (8.2, chrome)
  • GitHub Check: cypress (8.1, chrome)
  • GitHub Check: PHP 8.4
  • GitHub Check: Build Plugin for Testing
  • GitHub Check: PHP 8.1
🔇 Additional comments (7)
inc/gateways/class-paypal-gateway.php (7)

833-866: LGTM!

Good addition of WP_Error handling. The error detection for both WP_Error objects and empty/invalid responses covers the main failure scenarios. Logging with LogLevel::ERROR and using wp_die() with back_link provides clear user feedback and debugging capability.


878-931: LGTM!

Consistent error handling for missing required objects (payment, cart, membership, customer). Each failure path now logs the error and provides a user-friendly message with a back link, addressing the original issue where errors were silently swallowed.


1333-1397: LGTM!

Comprehensive error handling for CreateRecurringPaymentsProfile. The changes properly:

  • Log before initiating the request
  • Handle WP_Error from wp_remote_post
  • Check for both failure and failurewithwarning ACK values
  • Extract and display PayPal error codes/messages
  • Log success with transaction details for debugging

1469-1480: LGTM!

Good addition of logging for unexpected HTTP responses before the wp_die() call. This will help diagnose edge cases where PayPal returns non-200 status codes.


1532-1662: LGTM!

The complete_single_payment method now has consistent error handling that mirrors create_recurring_profile. The addition of logging at entry, for failures, and on success provides good observability for debugging payment issues.


1668-1738: LGTM!

The confirmation_form method now properly validates all required objects before rendering the template:

  1. Validates checkout details response
  2. Checks for pending payment existence
  3. Verifies customer and membership data

The return type change to void is appropriate since the method now uses wp_die() for errors instead of returning error strings.


1779-1790: LGTM - Core fix for the checkout loop issue.

This is the critical fix addressing the root cause from Issue #193. The method now properly checks for ACK=Failure and ACK=FailureWithWarning responses and returns a WP_Error instead of treating the response as valid. This prevents the silent loop back to /register.

$body['pending_payment'] = $pending_payment;

$custom = explode('|', (string) $body['PAYMENTREQUEST_0_CUSTOM']);
$custom = explode('|', (string) wu_get_isset($body, 'PAYMENTREQUEST_0_CUSTOM', ''));
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove unused variable.

The $custom variable is assigned but never used. This appears to be dead code.

🔎 Proposed fix
-			$custom = explode('|', (string) wu_get_isset($body, 'PAYMENTREQUEST_0_CUSTOM', ''));
-
 			return $body;

Based on static analysis hint from PHPMD.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$custom = explode('|', (string) wu_get_isset($body, 'PAYMENTREQUEST_0_CUSTOM', ''));
return $body;
🧰 Tools
🪛 PHPMD (2.15.0)

1802-1802: Avoid unused local variables such as '$custom'. (undefined)

(UnusedLocalVariable)

🤖 Prompt for AI Agents
In inc/gateways/class-paypal-gateway.php around line 1802, the variable $custom
is assigned from explode(...) but never used; remove that unused assignment line
to eliminate dead code and satisfy PHPMD static analysis.

@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In @inc/gateways/class-base-paypal-gateway.php:
- Around line 164-177: Replace usages of the PHP 8-only function str_starts_with
(e.g., the check in class-base-paypal-gateway.php that tests
$gateway_subscription_id) with a PHP 7.4-compatible check: use
strpos($gateway_subscription_id, 'I-') === 0 (or an equivalent substr/strncmp)
to detect the prefix; apply the same replacement to the other ~28 occurrences
across the codebase or alternatively add a simple polyfill for str_starts_with
if you prefer to keep the original calls, but do not change composer PHP
requirement unless intentionally bumping minimum PHP to 8.0.

In @inc/gateways/class-paypal-oauth-handler.php:
- Around line 724-754: The delete_webhooks_on_disconnect method is calling
$gateway_manager->get_gateway('paypal-rest') which returns gateway metadata, not
an instance; replace that call with the Gateway_Manager method that returns an
actual gateway object (for example use get_gateway_instance('paypal-rest') or
the manager's equivalent instance factory), then verify the returned value is an
instance of PayPal_REST_Gateway before calling set_test_mode() and
delete_webhook(); update the variable name if needed (e.g., $gateway_instance)
and keep the existing error logging and try/catch logic unchanged.
- Around line 688-714: The instanceof check in install_webhook_after_oauth is
wrong because $gateway_manager->get_gateway('paypal-rest') returns metadata, so
replace that call with wu_get_gateway('paypal-rest') to obtain an actual
PayPal_REST_Gateway instance; update the code path that currently assigns
$gateway from Gateway_Manager::get_instance()->get_gateway(...) to call
wu_get_gateway('paypal-rest'), keep the subsequent set_test_mode/install_webhook
logic and the same logging branches, and ensure the instanceof
PayPal_REST_Gateway check now succeeds and the webhook installation is
attempted.

In @inc/gateways/class-paypal-webhook-handler.php:
- Around line 183-187: The current check in class PayPal_Webhook_Handler (when
$webhook_id is empty) returns $this->test_mode which accepts any webhook in test
mode; change this to never auto-accept missing webhook IDs. Replace the line
"return $this->test_mode" so the method logs a clear error and returns false (do
not accept the request), e.g., keep the warning log or escalate to error and
then return false; if you need an explicit opt-in for development, add a boolean
property like $allow_unverified_webhooks_in_test (default false) and only return
true when that property is explicitly true.
- Around line 172-176: The current unconditional bypass in the signature-check
block (when $this->test_mode and WP_DEBUG) is unsafe; change it to only skip
cryptographic verification while still requiring the webhook headers to exist
and be well-formed: keep the $this->test_mode and WP_DEBUG branch but replace
the immediate return true with a header presence check for the PayPal webhook
headers (e.g., 'Paypal-Transmission-Id', 'Paypal-Transmission-Sig',
'Paypal-Transmission-Time', 'Paypal-Cert-Url') and return true only if those
headers are present (otherwise return false and log an error); update the log
call ($this->log('Skipping signature verification in debug mode')) to include
which headers were missing so the verification path in the class (the method
using $this->test_mode and the log call) still defends against spoofed requests
in debug/test environments.
🧹 Nitpick comments (7)
inc/gateways/class-paypal-oauth-handler.php (1)

393-394: Remove unused variables or add TODO for future use.

$consent_status and $risk_status are captured from PayPal's OAuth callback but never used. If these are intended for future validation logic, add a TODO comment; otherwise, remove them to reduce dead code.

♻️ Suggested fix
-		$consent_status      = isset($_GET['consentStatus']) && 'true' === $_GET['consentStatus'];
-		$risk_status         = isset($_GET['isEmailConfirmed']) ? sanitize_text_field(wp_unslash($_GET['isEmailConfirmed'])) : '';
+		// Note: consentStatus and isEmailConfirmed are available in the callback but not currently used
+		// They could be used for additional validation if needed in the future
inc/gateways/class-paypal-gateway.php (1)

1793-1793: Remove unused $custom variable.

The $custom variable is parsed from the response but never used.

♻️ Suggested fix
-		$custom = explode('|', (string) wu_get_isset($body, 'PAYMENTREQUEST_0_CUSTOM', ''));
inc/gateways/class-paypal-webhook-handler.php (1)

419-481: Handle potential duplicate payment creation race condition.

The check for existing payment on line 449-454 and subsequent payment creation could have a race condition if two webhook deliveries arrive simultaneously. Consider using a database lock or unique constraint.

💡 Consideration

The current flow:

  1. Check if payment exists by gateway_payment_id
  2. If not, create payment

If two webhooks arrive simultaneously, both could pass the existence check before either creates the payment. Consider wrapping this in a transaction or using a unique constraint on gateway_payment_id in the database.

inc/gateways/class-paypal-rest-gateway.php (4)

376-395: Remove unused $description variable.

The $description variable is created on line 379 but never used in the subscription creation flow.

♻️ Suggested fix
 protected function create_subscription($payment, $membership, $customer, $cart, $type): void {
 
 	$currency    = strtoupper($payment->get_currency());
-	$description = $this->get_subscription_description($cart);
 
 	// First, create or get the billing plan
 	$plan_id = $this->get_or_create_plan($cart, $currency);

788-788: Unused $customer_id from destructuring.

The $customer_id is extracted but not used. If it's not needed, use an underscore or remove it from destructuring.

♻️ Suggested fix
-		[$payment_id, $membership_id, $customer_id] = $custom_parts;
+		[$payment_id, $membership_id] = $custom_parts;

Apply the same fix on line 880.

Also applies to: 880-880


1262-1313: Inline JavaScript in settings should be extracted.

The inline JavaScript for the connect/disconnect buttons works but could be extracted to a separate JS file for better maintainability and to enable proper linting.


993-1009: process_membership_update is incomplete.

The method logs the update request but doesn't actually implement the update logic. PayPal subscription updates are limited, but the method should either attempt what's possible or return an error explaining limitations.

💡 Suggested improvement
 public function process_membership_update(&$membership, $customer) {
 
 	$subscription_id = $membership->get_gateway_subscription_id();
 
 	if (empty($subscription_id)) {
 		return new \WP_Error(
 			'wu_paypal_no_subscription',
 			__('No subscription ID found for this membership.', 'ultimate-multisite')
 		);
 	}
 
-	// Note: PayPal subscription updates are limited
-	// For significant changes, may need to cancel and recreate
-	$this->log(sprintf('Membership update requested for subscription: %s', $subscription_id));
+	// PayPal REST API has limited subscription update capabilities
+	// For amount/duration changes, cancel current subscription and create new one
+	$this->log(sprintf('Membership update requested for subscription: %s - cancelling and requiring new subscription', $subscription_id));
 
-	return true;
+	// Cancel the current subscription
+	$this->process_cancellation($membership, $customer);
+	
+	// Reset gateway info so a new subscription will be created
+	$membership->set_gateway_subscription_id('');
+	$membership->set_auto_renew(false);
+	
+	return true;
 }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 134ca8b and 9ad43be.

📒 Files selected for processing (7)
  • inc/gateways/class-base-gateway.php
  • inc/gateways/class-base-paypal-gateway.php
  • inc/gateways/class-paypal-gateway.php
  • inc/gateways/class-paypal-oauth-handler.php
  • inc/gateways/class-paypal-rest-gateway.php
  • inc/gateways/class-paypal-webhook-handler.php
  • inc/managers/class-gateway-manager.php
🧰 Additional context used
🧬 Code graph analysis (4)
inc/gateways/class-paypal-oauth-handler.php (5)
inc/gateways/class-paypal-rest-gateway.php (2)
  • init (85-90)
  • is_configured (155-162)
inc/gateways/class-paypal-webhook-handler.php (2)
  • init (45-51)
  • get_api_base_url (59-62)
inc/functions/settings.php (2)
  • wu_get_setting (37-40)
  • wu_save_setting (51-54)
inc/gateways/class-base-paypal-gateway.php (2)
  • get_api_base_url (98-101)
  • is_configured (291-291)
inc/functions/helper.php (1)
  • wu_log_add (208-211)
inc/gateways/class-base-gateway.php (2)
inc/gateways/class-base-paypal-gateway.php (2)
  • get_payment_url_on_gateway (130-143)
  • get_subscription_url_on_gateway (155-177)
inc/gateways/class-base-stripe-gateway.php (3)
  • get_payment_url_on_gateway (2939-2950)
  • get_subscription_url_on_gateway (2962-2967)
  • get_customer_url_on_gateway (2979-2984)
inc/gateways/class-paypal-gateway.php (2)
inc/gateways/class-base-paypal-gateway.php (2)
  • is_configured (291-291)
  • get_connection_status (299-299)
inc/functions/helper.php (1)
  • wu_log_add (208-211)
inc/gateways/class-paypal-rest-gateway.php (3)
inc/gateways/class-base-paypal-gateway.php (4)
  • get_api_base_url (98-101)
  • log (221-224)
  • add_partner_attribution_header (205-210)
  • get_subscription_description (111-118)
inc/gateways/class-paypal-oauth-handler.php (2)
  • init (79-91)
  • get_api_base_url (119-122)
inc/gateways/class-paypal-webhook-handler.php (3)
  • init (45-51)
  • get_api_base_url (59-62)
  • log (574-577)
🪛 GitHub Check: Code Quality Checks
inc/gateways/class-base-gateway.php

[warning] 536-536:
The method parameter $gateway_payment_id is never used


[warning] 551-551:
The method parameter $gateway_subscription_id is never used


[warning] 566-566:
The method parameter $gateway_customer_id is never used

inc/gateways/class-paypal-webhook-handler.php

[warning] 367-367:
It is recommended not to use reserved keyword "resource" as function parameter name. Found: $resource


[warning] 341-341:
It is recommended not to use reserved keyword "resource" as function parameter name. Found: $resource


[warning] 325-325:
It is recommended not to use reserved keyword "resource" as function parameter name. Found: $resource


[warning] 297-297:
It is recommended not to use reserved keyword "resource" as function parameter name. Found: $resource


[warning] 280-280:
It is recommended not to use reserved keyword "resource" as function parameter name. Found: $resource


[warning] 221-221:
base64_encode() can be used to obfuscate code which is strongly discouraged. Please verify that the function is used for benign reasons.

🪛 PHPMD (2.15.0)
inc/gateways/class-paypal-oauth-handler.php

393-393: Avoid unused local variables such as '$consent_status'. (undefined)

(UnusedLocalVariable)


394-394: Avoid unused local variables such as '$risk_status'. (undefined)

(UnusedLocalVariable)

inc/gateways/class-base-gateway.php

536-536: Avoid unused parameters such as '$gateway_payment_id'. (undefined)

(UnusedFormalParameter)


551-551: Avoid unused parameters such as '$gateway_subscription_id'. (undefined)

(UnusedFormalParameter)


566-566: Avoid unused parameters such as '$gateway_customer_id'. (undefined)

(UnusedFormalParameter)

inc/gateways/class-paypal-gateway.php

1793-1793: Avoid unused local variables such as '$custom'. (undefined)

(UnusedLocalVariable)

inc/gateways/class-base-paypal-gateway.php

253-253: Avoid unused parameters such as '$atts'. (undefined)

(UnusedFormalParameter)


253-253: Avoid unused parameters such as '$site'. (undefined)

(UnusedFormalParameter)

inc/gateways/class-paypal-rest-gateway.php

376-376: Avoid unused parameters such as '$type'. (undefined)

(UnusedFormalParameter)


379-379: Avoid unused local variables such as '$description'. (undefined)

(UnusedLocalVariable)


610-610: Avoid unused parameters such as '$type'. (undefined)

(UnusedFormalParameter)


788-788: Avoid unused local variables such as '$customer_id'. (undefined)

(UnusedLocalVariable)


880-880: Avoid unused local variables such as '$customer_id'. (undefined)

(UnusedLocalVariable)


924-924: Avoid unused parameters such as '$customer'. (undefined)

(UnusedFormalParameter)


957-957: Avoid unused parameters such as '$membership'. (undefined)

(UnusedFormalParameter)


957-957: Avoid unused parameters such as '$customer'. (undefined)

(UnusedFormalParameter)


993-993: Avoid unused parameters such as '$customer'. (undefined)

(UnusedFormalParameter)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: cypress (8.2, chrome)
  • GitHub Check: Build Plugin for Testing
  • GitHub Check: cypress (8.1, chrome)
  • GitHub Check: PHP 8.4
🔇 Additional comments (16)
inc/gateways/class-base-gateway.php (1)

534-569: LGTM - Return type declarations align with gateway pattern.

These base class stub methods correctly return empty strings and declare explicit return types, matching the established pattern in Base_Stripe_Gateway. The unused parameters are intentional as these methods are designed to be overridden by child classes.

inc/managers/class-gateway-manager.php (3)

417-431: LGTM - PayPal gateway registration and webhook initialization.

The new PayPal REST gateway registration with OAuth-based descriptions, the legacy PayPal gateway re-labeling, and the webhook handler initialization are well-structured.


543-547: Appropriate change from actions to filters for URL retrieval.

Using add_filter instead of add_action is semantically correct here since these hooks are meant to return URL data that can be used by other components. This aligns with the filter-based data retrieval pattern.


556-556: Direct method call is cleaner.

Replacing call_user_func([$gateway, 'fields']) with $gateway->fields() improves readability without changing behavior.

inc/gateways/class-paypal-oauth-handler.php (1)

79-91: OAuth handler initialization looks correct.

The handler properly loads test mode setting, partner credentials, registers AJAX handlers with nonce verification, and hooks into admin_init for OAuth return handling.

inc/gateways/class-base-paypal-gateway.php (2)

253-283: Filter callback signature is correct.

The $atts and $site parameters are required by the wu_element_get_site_actions filter signature even though they're not used in this implementation. This is standard practice for WordPress filter callbacks.


23-79: Well-structured base class for PayPal gateways.

The abstract class provides good shared functionality including:

  • Cross-gateway hook compatibility via $other_ids
  • Partner attribution tracking via $bn_code
  • Common capability declarations for recurring payments
inc/gateways/class-paypal-gateway.php (4)

841-874: Excellent error handling for PayPal API failures.

This change directly addresses the PR objective. When get_checkout_details() returns a WP_Error, the code now properly displays the error via wp_die() instead of silently failing and looping back. The logging and contextual error messages will help with debugging.


1770-1781: Key fix: get_checkout_details now returns WP_Error on API failures.

This is the core fix for issue #193. Previously, ACK=Failure responses were not properly detected, causing the checkout to loop. Now the method:

  1. Checks for ACK=Failure or FailureWithWarning
  2. Logs the error with code and message
  3. Returns a WP_Error that callers can handle

1378-1392: Good addition of FailureWithWarning handling.

PayPal can return FailureWithWarning in addition to Failure. Handling both ensures edge cases are properly caught and displayed to users with the PayPal error code and message.

Also applies to: 1560-1574


1661-1729: confirmation_form() now properly validates and handles errors.

The method now:

  1. Returns void (breaking change noted in AI summary)
  2. Validates pending_payment, customer, and membership exist
  3. Uses wp_die() to display errors with back links
  4. Logs errors for debugging

This prevents silent failures and provides clear user feedback.

inc/gateways/class-paypal-webhook-handler.php (2)

280-287: Parameter name $resource is acceptable.

The static analysis warning about $resource being a reserved keyword is a false positive. While resource is a type in PHP, it's not a reserved keyword and can be used as a parameter name.

Also applies to: 297-315, 325-331, 341-357, 367-382, 392-407


70-147: Well-structured webhook processor with comprehensive event handling.

The webhook handler properly:

  1. Validates incoming JSON
  2. Verifies webhook signatures via PayPal API
  3. Routes events to appropriate handlers
  4. Returns appropriate HTTP status codes
inc/gateways/class-paypal-rest-gateway.php (3)

218-273: Good token caching implementation.

The access token caching with a 5-minute safety buffer before expiration is a solid pattern. This prevents token expiration mid-request while minimizing unnecessary token refreshes.


506-596: Billing plan caching is efficient.

Caching plan IDs via update_site_option with an MD5 hash key based on plan parameters avoids recreating identical plans on PayPal. The verification check for existing plans handles the case where a plan was deleted on PayPal's side.


85-90: Gateway initialization and hooks are well-structured.

The init method properly loads test mode and credentials. The hooks method initializes the OAuth handler, registers webhook installation handlers, and sets up admin notices - providing a complete gateway lifecycle.

Also applies to: 132-147

Comment on lines +164 to +177
if (str_starts_with($gateway_subscription_id, 'I-')) {
// REST API subscription
return sprintf(
'https://www.%spaypal.com/billing/subscriptions/%s',
$sandbox_prefix,
$gateway_subscription_id
);
}

// Legacy NVP recurring payment profile
$base_url = 'https://www.%spaypal.com/us/cgi-bin/webscr?cmd=_profile-recurring-payments&encrypted_profile_id=%s';

return sprintf($base_url, $sandbox_prefix, $gateway_subscription_id);
}
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 12, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check composer.json for PHP version requirement
echo "=== composer.json PHP requirement ==="
if [ -f composer.json ]; then
  cat composer.json | jq '.require.php // .config."platform"."php" // empty' 2>/dev/null || grep -i '"php"' composer.json
else
  echo "No composer.json found"
fi

echo ""
echo "=== Checking README or plugin header for version info ==="
head -50 README.md 2>/dev/null | grep -i "php\|version" || echo "No README found"

echo ""
echo "=== Checking main plugin file for version requirements ==="
fd "*.php" -x grep -l "Requires PHP\|requires_php" {} \; 2>/dev/null | head -5

echo ""
echo "=== Checking if str_starts_with polyfill exists ==="
rg "function str_starts_with|if.*str_starts_with" -A 2 -B 2

echo ""
echo "=== Checking all uses of str_starts_with in codebase ==="
rg "str_starts_with" --type php -n

Repository: Multisite-Ultimate/ultimate-multisite

Length of output: 22048


str_starts_with() is incompatible with PHP 7.4.1 (the minimum supported version per composer.json).

The function str_starts_with() (used in lines 164 and 191) requires PHP 8.0+. Since the plugin supports PHP 7.4.1 and no polyfill exists in the codebase, this will cause a fatal error on any PHP 7.4 installation. This affects not only the PayPal gateway but also 28 other files throughout the codebase using the same function.

Either update the minimum PHP version requirement to 8.0+, add a polyfill for str_starts_with(), or replace the function call with PHP 7.4-compatible alternatives (e.g., strpos() === 0).

🤖 Prompt for AI Agents
In @inc/gateways/class-base-paypal-gateway.php around lines 164 - 177, Replace
usages of the PHP 8-only function str_starts_with (e.g., the check in
class-base-paypal-gateway.php that tests $gateway_subscription_id) with a PHP
7.4-compatible check: use strpos($gateway_subscription_id, 'I-') === 0 (or an
equivalent substr/strncmp) to detect the prefix; apply the same replacement to
the other ~28 occurrences across the codebase or alternatively add a simple
polyfill for str_starts_with if you prefer to keep the original calls, but do
not change composer PHP requirement unless intentionally bumping minimum PHP to
8.0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We have a polyfill.

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for str_starts_with polyfill definition
echo "=== Searching for str_starts_with polyfill ==="
rg "function str_starts_with" --type php -n -C 5

echo ""
echo "=== Checking for polyfill files ==="
fd -e php polyfill -x cat {}

echo ""
echo "=== Searching for function_exists check for str_starts_with ==="
rg "function_exists.*str_starts_with" --type php -n -C 3

Length of output: 148


🏁 Script executed:

#!/bin/bash
# Check composer.json for polyfill dependencies
echo "=== Checking composer.json for polyfill packages ==="
if [ -f composer.json ]; then
  cat composer.json | jq '.require, .["require-dev"]' 2>/dev/null | grep -i polyfill || echo "No polyfill packages found in composer.json"
else
  echo "No composer.json found"
fi

echo ""
echo "=== Checking for vendor/symfony polyfills ==="
if [ -d vendor/symfony ]; then
  ls -la vendor/symfony/ | grep polyfill
else
  echo "No vendor/symfony directory found"
fi

echo ""
echo "=== Checking for autoload/bootstrap files ==="
fd -e php "bootstrap|autoload|init|loader" -x echo "File: {}" \; -x head -30 {} \; | head -100

echo ""
echo "=== Checking main plugin file for polyfill loading ==="
rg "require.*polyfill|include.*polyfill|polyfill" --type php -n -C 2 | head -50

Length of output: 5546


@superdav42, you're absolutely right—I apologize for the incorrect flag! 🙏

I can confirm that your composer.json includes symfony/polyfill-php80 (v1.31.0), which provides str_starts_with() and other PHP 8.0 functions for PHP 7.4 environments. The polyfill is loaded via Composer's autoloader, so the usage of str_starts_with() throughout the codebase is completely safe and compatible with PHP 7.4.1+.

Thank you for the correction—the code is good to go as-is!

(\_/)
(o.o) learned something!
(> <)

Comment on lines 688 to 714
protected function install_webhook_after_oauth(string $mode_prefix): void {

try {
// Get the PayPal REST gateway instance
$gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance();
$gateway = $gateway_manager->get_gateway('paypal-rest');

if (! $gateway instanceof PayPal_REST_Gateway) {
wu_log_add('paypal', 'Could not get PayPal REST gateway instance for webhook installation', LogLevel::WARNING);
return;
}

// Ensure the gateway is in the correct mode
$gateway->set_test_mode('sandbox' === $mode_prefix);

// Install the webhook
$result = $gateway->install_webhook();

if (true === $result) {
wu_log_add('paypal', sprintf('Webhook installed successfully for %s mode after OAuth', $mode_prefix));
} elseif (is_wp_error($result)) {
wu_log_add('paypal', sprintf('Failed to install webhook after OAuth: %s', $result->get_error_message()), LogLevel::ERROR);
}
} catch (\Exception $e) {
wu_log_add('paypal', sprintf('Exception installing webhook after OAuth: %s', $e->getMessage()), LogLevel::ERROR);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "function get_gateway|function wu_get_gateway" --type=php -A 15

Repository: Multisite-Ultimate/ultimate-multisite

Length of output: 9592


🏁 Script executed:

rg -n "registered_gateways\[" --type=php -B 2 -A 2 | head -50

Repository: Multisite-Ultimate/ultimate-multisite

Length of output: 1583


🏁 Script executed:

rg -n "PayPal_REST_Gateway|register_gateway.*paypal" --type=php -A 5

Repository: Multisite-Ultimate/ultimate-multisite

Length of output: 3367


Type check should use wu_get_gateway() to get a gateway instance instead of using get_gateway() which returns metadata array.

The instanceof check on line 695 will always fail because $gateway_manager->get_gateway() returns an associative array with gateway metadata, not a PayPal_REST_Gateway instance. Use wu_get_gateway('paypal-rest') instead, which properly instantiates and returns the gateway object. This prevents webhook installation from being silently skipped.

🐛 Proposed fix
 protected function install_webhook_after_oauth(string $mode_prefix): void {
 
 	try {
-		// Get the PayPal REST gateway instance
-		$gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance();
-		$gateway         = $gateway_manager->get_gateway('paypal-rest');
+		// Get a fresh PayPal REST gateway instance
+		$gateway = wu_get_gateway('paypal-rest');
 
-		if (! $gateway instanceof PayPal_REST_Gateway) {
+		if (! $gateway) {
 			wu_log_add('paypal', 'Could not get PayPal REST gateway instance for webhook installation', LogLevel::WARNING);
 			return;
 		}
🤖 Prompt for AI Agents
In @inc/gateways/class-paypal-oauth-handler.php around lines 688 - 714, The
instanceof check in install_webhook_after_oauth is wrong because
$gateway_manager->get_gateway('paypal-rest') returns metadata, so replace that
call with wu_get_gateway('paypal-rest') to obtain an actual PayPal_REST_Gateway
instance; update the code path that currently assigns $gateway from
Gateway_Manager::get_instance()->get_gateway(...) to call
wu_get_gateway('paypal-rest'), keep the subsequent set_test_mode/install_webhook
logic and the same logging branches, and ensure the instanceof
PayPal_REST_Gateway check now succeeds and the webhook installation is
attempted.

Comment on lines 172 to 176
if ($this->test_mode && defined('WP_DEBUG') && WP_DEBUG) {
$this->log('Skipping signature verification in debug mode');
return true;
}
return false;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Security concern: Debug mode bypasses signature verification entirely.

Skipping signature verification when WP_DEBUG is true and in test mode could allow spoofed webhooks in development environments that might still have real data. Consider requiring at least the webhook headers to be present, even if verification is skipped.

🔒 Suggested improvement
 		// If headers are missing, we can't verify
 		if (empty($auth_algo) || empty($cert_url) || empty($transmission_id) || empty($transmission_sig) || empty($transmission_time)) {
 			$this->log('Missing webhook signature headers', LogLevel::WARNING);
-			// In development/testing, you might want to skip verification
-			if ($this->test_mode && defined('WP_DEBUG') && WP_DEBUG) {
-				$this->log('Skipping signature verification in debug mode');
-				return true;
-			}
+			// Even in debug mode, require headers to be present to prevent accidental acceptance of malformed requests
 			return false;
 		}
🤖 Prompt for AI Agents
In @inc/gateways/class-paypal-webhook-handler.php around lines 172 - 176, The
current unconditional bypass in the signature-check block (when $this->test_mode
and WP_DEBUG) is unsafe; change it to only skip cryptographic verification while
still requiring the webhook headers to exist and be well-formed: keep the
$this->test_mode and WP_DEBUG branch but replace the immediate return true with
a header presence check for the PayPal webhook headers (e.g.,
'Paypal-Transmission-Id', 'Paypal-Transmission-Sig', 'Paypal-Transmission-Time',
'Paypal-Cert-Url') and return true only if those headers are present (otherwise
return false and log an error); update the log call ($this->log('Skipping
signature verification in debug mode')) to include which headers were missing so
the verification path in the class (the method using $this->test_mode and the
log call) still defends against spoofed requests in debug/test environments.

Comment on lines 183 to 187
if (empty($webhook_id)) {
$this->log('Webhook ID not configured, skipping verification', LogLevel::WARNING);
// Allow in test mode without webhook ID
return $this->test_mode;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Allowing webhooks without webhook ID in test mode is risky.

Returning true when webhook ID is not configured in test mode means any request to the webhook endpoint will be accepted. This could mask configuration issues and process illegitimate requests.

🔒 Suggested improvement
 		if (empty($webhook_id)) {
 			$this->log('Webhook ID not configured, skipping verification', LogLevel::WARNING);
-			// Allow in test mode without webhook ID
-			return $this->test_mode;
+			// Require webhook ID to be configured for security
+			return false;
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (empty($webhook_id)) {
$this->log('Webhook ID not configured, skipping verification', LogLevel::WARNING);
// Allow in test mode without webhook ID
return $this->test_mode;
}
if (empty($webhook_id)) {
$this->log('Webhook ID not configured, skipping verification', LogLevel::WARNING);
// Require webhook ID to be configured for security
return false;
}
🤖 Prompt for AI Agents
In @inc/gateways/class-paypal-webhook-handler.php around lines 183 - 187, The
current check in class PayPal_Webhook_Handler (when $webhook_id is empty)
returns $this->test_mode which accepts any webhook in test mode; change this to
never auto-accept missing webhook IDs. Replace the line "return
$this->test_mode" so the method logs a clear error and returns false (do not
accept the request), e.g., keep the warning log or escalate to error and then
return false; if you need an explicit opt-in for development, add a boolean
property like $allow_unverified_webhooks_in_test (default false) and only return
true when that property is explicitly true.

superdav42 and others added 2 commits February 27, 2026 15:15
- Add Base_PayPal_Gateway abstract class for shared PayPal functionality
- Add PayPal_REST_Gateway with modern PayPal REST API implementation
- Add PayPal_OAuth_Handler for OAuth 2.0 authentication flow
- Add PayPal_Webhook_Handler for webhook event processing
- Update Gateway_Manager to register new PayPal REST gateway and use filters
- Fix Base_Gateway methods to properly return strings for filter callbacks
- Maintain backwards compatibility with legacy PayPal gateway

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Remove WP_DEBUG-based webhook signature bypass, use wu_paypal_skip_webhook_verification filter
- Fix empty webhook_id and client_credentials fallbacks to return false instead of test_mode
- Add WU_PAYPAL_PARTNER_* constants and wu_paypal_partner_credentials filter support
- Hide OAuth Connect button when partner credentials not configured
- Add phpcs:ignore for base64_encode (required for PayPal Basic auth)
- Rename $resource parameter to $event_data to avoid reserved keyword warning
- Fix PHPCS formatting errors in oauth handler
- Add 20 unit tests for PayPal REST Gateway (config, URLs, credentials)
- Add 14 unit tests for PayPal Webhook Handler (signature, subscriptions, refunds)
- Add paypal-rest and paypal assertions to Gateway Manager tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@superdav42 superdav42 force-pushed the fix/paypal-checkout-loop-193 branch from 9ad43be to bc0bed6 Compare February 27, 2026 23:24
@superdav42 superdav42 changed the title Fix PayPal checkout loop and improve error handling Add modern PayPal REST API gateway with subscription support Feb 27, 2026
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
inc/gateways/class-paypal-oauth-handler.php (1)

733-737: ⚠️ Potential issue | 🟠 Major

Use wu_get_gateway('paypal-rest') for instance retrieval.

Line 734 and Line 769 call Gateway_Manager::get_gateway(), which returns metadata arrays, so the instanceof PayPal_REST_Gateway checks always fail and webhook install/delete is skipped.

🐛 Proposed fix
 protected function install_webhook_after_oauth(string $mode_prefix): void {
 
 	try {
-		// Get the PayPal REST gateway instance
-		$gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance();
-		$gateway         = $gateway_manager->get_gateway('paypal-rest');
+		$gateway = wu_get_gateway('paypal-rest');
 
 		if (! $gateway instanceof PayPal_REST_Gateway) {
 			wu_log_add('paypal', 'Could not get PayPal REST gateway instance for webhook installation', LogLevel::WARNING);
 			return;
 		}
@@
 protected function delete_webhooks_on_disconnect(): void {
 
 	try {
-		$gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance();
-		$gateway         = $gateway_manager->get_gateway('paypal-rest');
+		$gateway = wu_get_gateway('paypal-rest');
 
 		if (! $gateway instanceof PayPal_REST_Gateway) {
 			return;
 		}

Also applies to: 768-772

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-paypal-oauth-handler.php` around lines 733 - 737, The code
is using \WP_Ultimo\Managers\Gateway_Manager::get_gateway(...) which returns
gateway metadata arrays so the instanceof PayPal_REST_Gateway checks fail;
replace calls to Gateway_Manager::get_gateway('paypal-rest') with the helper
wu_get_gateway('paypal-rest') wherever used (the blocks performing the
instanceof PayPal_REST_Gateway check for webhook install/delete), so you get the
actual gateway object and the instanceof check will work; update both
occurrences that currently call get_gateway(...) to use wu_get_gateway(...)
before proceeding with webhook installation/deletion.
inc/gateways/class-base-paypal-gateway.php (1)

164-165: ⚠️ Potential issue | 🔴 Critical

Replace str_starts_with() or require PHP 8+ explicitly.

Line 164 and Line 191 use a PHP 8-only function. On PHP 7.4 environments this is a fatal runtime error.

🐛 PHP 7.4-compatible fix
-		if (str_starts_with($gateway_subscription_id, 'I-')) {
+		if (0 === strpos((string) $gateway_subscription_id, 'I-')) {
@@
-		return str_starts_with($subscription_id, 'I-');
+		return 0 === strpos($subscription_id, 'I-');
#!/bin/bash
# Verify declared PHP support and locate PHP 8-only prefix checks.
# Expected: either PHP >=8.0 declared, or no unsupported usages remain.

if [ -f composer.json ]; then
  rg -n '"php"\s*:' composer.json
fi

rg -n --type=php '\bstr_starts_with\s*\('

Also applies to: 191-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-base-paypal-gateway.php` around lines 164 - 165, The code
uses the PHP 8-only function str_starts_with on $gateway_subscription_id (and
again later near the other check) which breaks on PHP 7.4; replace those calls
with a PHP 7-compatible check (e.g. use strpos($gateway_subscription_id, 'I-')
=== 0) or add a small polyfill that defines str_starts_with when not already
present, and ensure both occurrences (the check around $gateway_subscription_id
and the duplicate at the later check) are updated consistently; alternatively,
if you intend to require PHP 8+, update composer.json's "php" platform
requirement to >=8.0 and run tests to verify no remaining str_starts_with
usages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/gateways/class-paypal-rest-gateway.php`:
- Around line 155-162: is_configured() currently returns true for OAuth-only
setups (checks merchant_id) but the token acquisition path requires client_id +
client_secret, causing a mismatch; update the logic so configured status matches
what the gateway actually needs: either (A) change is_configured() to require
both client_id and client_secret (remove merchant_id-only success) or (B)
implement the OAuth token path used by token acquisition (e.g., in the method
that retrieves tokens) so merchant_id-only setups can obtain tokens. Refer to
is_configured, merchant_id, client_id, client_secret and the token acquisition
logic (the method that requests/returns access tokens) and make the chosen
approach consistent across both places.

---

Duplicate comments:
In `@inc/gateways/class-base-paypal-gateway.php`:
- Around line 164-165: The code uses the PHP 8-only function str_starts_with on
$gateway_subscription_id (and again later near the other check) which breaks on
PHP 7.4; replace those calls with a PHP 7-compatible check (e.g. use
strpos($gateway_subscription_id, 'I-') === 0) or add a small polyfill that
defines str_starts_with when not already present, and ensure both occurrences
(the check around $gateway_subscription_id and the duplicate at the later check)
are updated consistently; alternatively, if you intend to require PHP 8+, update
composer.json's "php" platform requirement to >=8.0 and run tests to verify no
remaining str_starts_with usages.

In `@inc/gateways/class-paypal-oauth-handler.php`:
- Around line 733-737: The code is using
\WP_Ultimo\Managers\Gateway_Manager::get_gateway(...) which returns gateway
metadata arrays so the instanceof PayPal_REST_Gateway checks fail; replace calls
to Gateway_Manager::get_gateway('paypal-rest') with the helper
wu_get_gateway('paypal-rest') wherever used (the blocks performing the
instanceof PayPal_REST_Gateway check for webhook install/delete), so you get the
actual gateway object and the instanceof check will work; update both
occurrences that currently call get_gateway(...) to use wu_get_gateway(...)
before proceeding with webhook installation/deletion.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ad43be and bc0bed6.

📒 Files selected for processing (7)
  • inc/gateways/class-base-gateway.php
  • inc/gateways/class-base-paypal-gateway.php
  • inc/gateways/class-paypal-gateway.php
  • inc/gateways/class-paypal-oauth-handler.php
  • inc/gateways/class-paypal-rest-gateway.php
  • inc/gateways/class-paypal-webhook-handler.php
  • inc/managers/class-gateway-manager.php

Comment on lines +155 to +162
public function is_configured(): bool {

// Either OAuth connected OR manual credentials
$has_oauth = ! empty($this->merchant_id);
$has_manual = ! empty($this->client_id) && ! empty($this->client_secret);

return $has_oauth || $has_manual;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

is_configured() and token acquisition criteria are inconsistent.

Line 158–161 marks OAuth-only setups as configured, but Line 233 requires client_id + client_secret for every API call. This can present “connected” status while checkout/webhook API operations fail.

💡 Minimal safety fix (until OAuth token path is implemented here)
 public function is_configured(): bool {
 
-	// Either OAuth connected OR manual credentials
-	$has_oauth  = ! empty($this->merchant_id);
-	$has_manual = ! empty($this->client_id) && ! empty($this->client_secret);
-
-	return $has_oauth || $has_manual;
+	return ! empty($this->client_id) && ! empty($this->client_secret);
 }

Also applies to: 233-238

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-paypal-rest-gateway.php` around lines 155 - 162,
is_configured() currently returns true for OAuth-only setups (checks
merchant_id) but the token acquisition path requires client_id + client_secret,
causing a mismatch; update the logic so configured status matches what the
gateway actually needs: either (A) change is_configured() to require both
client_id and client_secret (remove merchant_id-only success) or (B) implement
the OAuth token path used by token acquisition (e.g., in the method that
retrieves tokens) so merchant_id-only setups can obtain tokens. Refer to
is_configured, merchant_id, client_id, client_secret and the token acquisition
logic (the method that requests/returns access tokens) and make the chosen
approach consistent across both places.

Move partner credential handling to a proxy server at
ultimatemultisite.com/wp-json/paypal-connect/v1, matching the
existing Stripe Connect proxy pattern. This keeps partner
credentials out of the open source codebase.

The OAuth handler now:
- Calls POST /oauth/init on the proxy to create referral URLs
- Calls POST /oauth/verify to verify merchant status
- Calls POST /deauthorize on disconnect (non-blocking)
- No longer loads or stores partner credentials locally

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/gateways/class-paypal-oauth-handler.php`:
- Around line 219-220: Remove the unused local variables $consent_status and
$risk_status from the method in class-paypal-oauth-handler.php: delete their
assignments (the lines that set $consent_status = ... and $risk_status = ...)
since they are never read; if these values are intended for future use, replace
the assignments with a clear TODO comment or add a short doc comment explaining
planned usage instead of leaving unused variables in the method.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bc0bed6 and ce610f9.

📒 Files selected for processing (1)
  • inc/gateways/class-paypal-oauth-handler.php

Comment on lines 219 to 220
$consent_status = isset($_GET['consentStatus']) && 'true' === $_GET['consentStatus'];
$risk_status = isset($_GET['isEmailConfirmed']) ? sanitize_text_field(wp_unslash($_GET['isEmailConfirmed'])) : '';
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove unused variables $consent_status and $risk_status.

These variables are assigned but never used in the method. If they're intended for future use, consider documenting that; otherwise, remove them to avoid confusion.

🧹 Proposed fix
 		$permissions_granted = isset($_GET['permissionsGranted']) && 'true' === $_GET['permissionsGranted'];
-		$consent_status      = isset($_GET['consentStatus']) && 'true' === $_GET['consentStatus'];
-		$risk_status         = isset($_GET['isEmailConfirmed']) ? sanitize_text_field(wp_unslash($_GET['isEmailConfirmed'])) : '';
 		$tracking_id         = isset($_GET['tracking_id']) ? sanitize_text_field(wp_unslash($_GET['tracking_id'])) : '';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$consent_status = isset($_GET['consentStatus']) && 'true' === $_GET['consentStatus'];
$risk_status = isset($_GET['isEmailConfirmed']) ? sanitize_text_field(wp_unslash($_GET['isEmailConfirmed'])) : '';
$permissions_granted = isset($_GET['permissionsGranted']) && 'true' === $_GET['permissionsGranted'];
$tracking_id = isset($_GET['tracking_id']) ? sanitize_text_field(wp_unslash($_GET['tracking_id'])) : '';
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 219-219: Avoid unused local variables such as '$consent_status'. (undefined)

(UnusedLocalVariable)


[warning] 220-220: Avoid unused local variables such as '$risk_status'. (undefined)

(UnusedLocalVariable)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-paypal-oauth-handler.php` around lines 219 - 220, Remove
the unused local variables $consent_status and $risk_status from the method in
class-paypal-oauth-handler.php: delete their assignments (the lines that set
$consent_status = ... and $risk_status = ...) since they are never read; if
these values are intended for future use, replace the assignments with a clear
TODO comment or add a short doc comment explaining planned usage instead of
leaving unused variables in the method.

The settings renderer was escaping <script> tags in note-type field
desc callbacks, causing JavaScript to appear as visible text instead
of executing. Move connect button and webhook button handlers to
enqueue_connect_scripts() and enqueue_webhook_scripts() methods that
use add_action('admin_footer') to output JS safely.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
inc/gateways/class-paypal-rest-gateway.php (1)

155-162: ⚠️ Potential issue | 🟠 Major

Align configured-state with token requirements.

Line [157]-[161] marks OAuth-only setups as configured, but Line [233] still hard-fails without client_id + client_secret. This can show “connected” while checkout/webhook calls fail.

💡 Minimal consistency fix
 public function is_configured(): bool {

-	// Either OAuth connected OR manual credentials
-	$has_oauth  = ! empty($this->merchant_id);
-	$has_manual = ! empty($this->client_id) && ! empty($this->client_secret);
-
-	return $has_oauth || $has_manual;
+	return ! empty($this->client_id) && ! empty($this->client_secret);
 }

Also applies to: 233-238

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-paypal-rest-gateway.php` around lines 155 - 162, The
configured-state check in is_configured currently treats having only merchant_id
(OAuth) as configured while other code still requires client_id and
client_secret and will hard-fail; update is_configured (the method
is_configured) to reflect the actual requirements used elsewhere by returning
true only when the gateway has either a full OAuth token pair that the code
accepts or when client_id and client_secret are both present (i.e., require
client_id + client_secret for non-OAuth flows), or alternatively make the later
credential-checking logic accept OAuth-only by branching on merchant_id vs
client_id/client_secret so both sides agree (reference merchant_id, client_id,
client_secret and the method is_configured).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/gateways/class-paypal-rest-gateway.php`:
- Around line 673-679: The loop that extracts the approval URL currently only
checks for link rel 'approve' and should also accept 'payer-action' as a
defensive edge case; update the foreach over $result['links'] in
class-paypal-rest-gateway.php (the block using $approval_url and $link['rel'])
to treat either 'approve' or 'payer-action' as valid (e.g., check if
$link['rel'] is in ['approve','payer-action']) and set $approval_url =
$link['href'] when matched, keeping the existing break/fallback logic intact.
- Around line 868-873: The capture response parsing fails because the PayPal
capture call that sets $capture returns a minimal representation lacking
purchase_units and captures; update the code that makes the PayPal capture
request (the call that assigns $capture) to include the HTTP header "Prefer:
return=representation" so PayPal returns the full order payload, or
alternatively, after a successful capture call perform a GET
/v2/checkout/orders/{token} to fetch the full order and use that response for
parsing; then update the logic that reads $purchase_unit and uses
$purchase_unit['payments']['captures'][0]['custom_id'] and id to use the
full-order response (or the GET result) and remove/invalidate the incomplete
fallback to purchase_units[0].custom_id when the minimal response is returned.

---

Duplicate comments:
In `@inc/gateways/class-paypal-rest-gateway.php`:
- Around line 155-162: The configured-state check in is_configured currently
treats having only merchant_id (OAuth) as configured while other code still
requires client_id and client_secret and will hard-fail; update is_configured
(the method is_configured) to reflect the actual requirements used elsewhere by
returning true only when the gateway has either a full OAuth token pair that the
code accepts or when client_id and client_secret are both present (i.e., require
client_id + client_secret for non-OAuth flows), or alternatively make the later
credential-checking logic accept OAuth-only by branching on merchant_id vs
client_id/client_secret so both sides agree (reference merchant_id, client_id,
client_secret and the method is_configured).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ce610f9 and 87373bc.

📒 Files selected for processing (1)
  • inc/gateways/class-paypal-rest-gateway.php

Comment on lines 673 to 679
// Find approval URL
$approval_url = '';
foreach ($result['links'] ?? [] as $link) {
if ('approve' === $link['rel']) {
$approval_url = $link['href'];
break;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

PayPal Orders v2 API create-order response link rel values approve payer-action

💡 Result:

In the Orders v2 create-order response, the links[].rel values tell you the next buyer/merchant step (HATEOAS):

  • rel: "approve"Redirect the buyer to this URL to approve the order in the PayPal checkout flow. You typically do this right after creating an order (status like CREATED) and before you authorize/capture. [1][2]

  • rel: "payer-action"Redirect the buyer to this URL when the order status is PAYER_ACTION_REQUIRED, meaning the payer must take additional action in the PayPal flow (commonly: finish/confirm the checkout experience, re-approve, etc.) before you can authorize/capture successfully. [3][4]

(For completeness, you’ll also commonly see rel: "self" as the API URL to fetch the order.) [4]

Sources: [1] (docs.paypal.ai) [2] (developer.paypal.com) [3] (docs.paypal.ai) [4] (developer.paypal.com)

Citations:


🏁 Script executed:

# Check if 'payer-action' is mentioned anywhere in the codebase
rg "payer-action" --type php

# Also check for any references to Orders v2 or approval links in the same file
rg "payer-action|approval_url" inc/gateways/class-paypal-rest-gateway.php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1039


Handle Orders v2 payer-action approval link relation as defensive edge case handling.

Line 676 only checks rel === 'approve', which correctly handles the normal Orders v2 flow where a newly created order returns the approve link. However, when an order status transitions to PAYER_ACTION_REQUIRED (requiring additional buyer action in PayPal), the response contains a payer-action link instead. While less common in typical checkout flows, adding support for this relation provides defensive coverage for edge cases.

🔧 Suggested fix
 		$approval_url = '';
 		foreach ($result['links'] ?? [] as $link) {
-			if ('approve' === $link['rel']) {
+			$rel = $link['rel'] ?? '';
+			if (in_array($rel, ['approve', 'payer-action'], true)) {
 				$approval_url = $link['href'];
 				break;
 			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Find approval URL
$approval_url = '';
foreach ($result['links'] ?? [] as $link) {
if ('approve' === $link['rel']) {
$approval_url = $link['href'];
break;
}
// Find approval URL
$approval_url = '';
foreach ($result['links'] ?? [] as $link) {
$rel = $link['rel'] ?? '';
if (in_array($rel, ['approve', 'payer-action'], true)) {
$approval_url = $link['href'];
break;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-paypal-rest-gateway.php` around lines 673 - 679, The loop
that extracts the approval URL currently only checks for link rel 'approve' and
should also accept 'payer-action' as a defensive edge case; update the foreach
over $result['links'] in class-paypal-rest-gateway.php (the block using
$approval_url and $link['rel']) to treat either 'approve' or 'payer-action' as
valid (e.g., check if $link['rel'] is in ['approve','payer-action']) and set
$approval_url = $link['href'] when matched, keeping the existing break/fallback
logic intact.

Comment on lines +868 to +873
// Parse custom_id
$purchase_unit = $capture['purchase_units'][0] ?? [];
$custom_parts = explode('|', $purchase_unit['payments']['captures'][0]['custom_id'] ?? '');

if (count($custom_parts) !== 3) {
wp_die(
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

PayPal Orders v2 API create order capture response custom_id location

💡 Result:

Where custom_id shows up (JSON “location”)

1) You set it on Create Order

2) You get it back on Capture (if you ask for the full representation)

  • Call: POST /v2/checkout/orders/{id}/capture with header Prefer: return=representation (otherwise the response can be minimal: just id, status, links). (developer.paypal.com)
  • In the full capture response, the capture object includes custom_id at:

3) You can also read it later from the capture resource

  • Use the capture id from purchase_units[].payments.captures[].id, then GET /v2/payments/captures/{capture_id} and read:

“Location” of the created/captured resource (URL)

PayPal returns HATEOAS links; the resource URL is in the link with rel: "self" (e.g., .../v2/checkout/orders/{id}) in create/capture responses. (developer.paypal.com)

Note

custom_id is for your reconciliation/metadata; you can’t query Orders/Captures by custom_id (you must query by order id or capture id). (stackoverflow.com)

Citations:


🏁 Script executed:

find . -type f -name "class-paypal-rest-gateway.php" | head -5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 125


🏁 Script executed:

cd inc/gateways && wc -l class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 115


🏁 Script executed:

cd inc/gateways && sed -n '850,890p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1291


🏁 Script executed:

cd inc/gateways && sed -n '1,100p' class-paypal-rest-gateway.php | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1103


🏁 Script executed:

cd inc/gateways && rg -n "capture\['purchase_units" class-paypal-rest-gateway.php -B 5 -A 5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 441


🏁 Script executed:

cd inc/gateways && rg -n "Prefer.*return" class-paypal-rest-gateway.php -B 3 -A 3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

cd inc/gateways && rg -n "/capture" class-paypal-rest-gateway.php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 396


🏁 Script executed:

cd inc/gateways && sed -n '800,868p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2119


🏁 Script executed:

cd inc/gateways && rg -n "api_request" class-paypal-rest-gateway.php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1272


🏁 Script executed:

cd inc/gateways && sed -n '1,50p' class-paypal-rest-gateway.php && rg -n "function api_request" class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1202


🏁 Script executed:

cd inc/gateways && rg -n "function api_request" class-paypal-rest-gateway.php -A 30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 962


🏁 Script executed:

cd inc/gateways && rg -n "Prefer" class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

cd inc && find . -name "*.php" -exec rg -l "Prefer.*representation" {} \;

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

cd inc/gateways && sed -n '285,330p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1411


🏁 Script executed:

cd inc/gateways && rg -n "purchase_units" class-paypal-rest-gateway.php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 435


🏁 Script executed:

cd inc/gateways && rg -n "custom_id" class-paypal-rest-gateway.php -B 2 -A 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 995


🏁 Script executed:

cd inc/gateways && sed -n '660,680p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 547


🏁 Script executed:

cd inc/gateways && sed -n '615,640p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1076


🏁 Script executed:

cd inc/gateways && grep -n "Prefer" class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🌐 Web query:

PayPal v2 capture endpoint minimal response without Prefer header JSON structure

💡 Result:

If you omit the Prefer header on POST /v2/checkout/orders/{id}/capture, PayPal’s default is Prefer: return=minimal, meaning the response body is a minimal JSON containing only:

Minimal response structure:

{
  "id": "ORDER_ID",
  "status": "COMPLETED",
  "links": [
    {
      "href": "https://api-m.paypal.com/v2/checkout/orders/ORDER_ID",
      "rel": "self",
      "method": "GET"
    }
  ]
}

Citations:


🏁 Script executed:

cd inc/gateways && rg -n "'/v2/checkout/orders/'" class-paypal-rest-gateway.php -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 239


🏁 Script executed:

cd inc/gateways && sed -n '845,920p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2295


🏁 Script executed:

cd inc/gateways && sed -n '869,910p' class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1434


🏁 Script executed:

cd inc/gateways && rg -n "payments\['captures" class-paypal-rest-gateway.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🌐 Web query:

PayPal Orders v2 API how to get custom_id after capture complete purchase_units

💡 Result:

To read back a purchase_units[].custom_id after you capture an order, you must request (or fetch) an order representation that includes purchase_units.

Option A (recommended): Capture with full representation

POST /v2/checkout/orders/{id}/capture returns minimal fields by default unless you send Prefer: return=representation (then it returns the full order, including purchase_units). (developer.paypal.com)

POST /v2/checkout/orders/{order_id}/capture
Prefer: return=representation

Then read:

resp.purchase_units[i].custom_id

Option B: GET the order after capture

You can call Show order details at any stage (including after it’s COMPLETED) and read the same field from the returned purchase_units. (developer.paypal.com)

GET /v2/checkout/orders/{order_id}

Then:

order.purchase_units[i].custom_id

If you only have a capture_id

From the capture response (or order details) you can store purchase_units[].payments.captures[].id (developer.paypal.com) and then GET /v2/payments/captures/{capture_id} to retrieve capture details (the capture resource includes “custom ID” as part of its capture identification fields). (developer.paypal.com)

Citations:


Request Prefer header for full representation in capture response.

Line 849 calls the capture endpoint without the Prefer: return=representation header, causing PayPal to return a minimal response containing only id, status, and links. The code then attempts to read purchase_units[0].payments.captures[0].custom_id and purchases_units[0].payments.captures[0].id at lines 870 and 903, which do not exist in the minimal response. This causes all order confirmations to fail with "Invalid order data".

The suggested fallback to purchase_units[0].custom_id is incomplete—the minimal response does not include purchase_units at all. To fix this properly, either:

  1. Add Prefer: return=representation header to the capture request to receive the full order representation, or
  2. Call GET /v2/checkout/orders/{token} after capture to retrieve the full order details including purchase_units.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/gateways/class-paypal-rest-gateway.php` around lines 868 - 873, The
capture response parsing fails because the PayPal capture call that sets
$capture returns a minimal representation lacking purchase_units and captures;
update the code that makes the PayPal capture request (the call that assigns
$capture) to include the HTTP header "Prefer: return=representation" so PayPal
returns the full order payload, or alternatively, after a successful capture
call perform a GET /v2/checkout/orders/{token} to fetch the full order and use
that response for parsing; then update the logic that reads $purchase_unit and
uses $purchase_unit['payments']['captures'][0]['custom_id'] and id to use the
full-order response (or the GET result) and remove/invalidate the incomplete
fallback to purchase_units[0].custom_id when the minimal response is returned.

The settings framework runs description HTML through wp_kses() which
strips data-nonce and data-sandbox attributes from buttons. Move nonce
and sandbox values into the admin_footer script as JS variables instead.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Platform fees apply when merchants connect via OAuth (Partner Referral)
and have not purchased an addon. Manual credential users pay no fee.

Implementation:
- Proxy: new /partner-token endpoint returns short-lived partner token
- Plugin: creates orders with partner token + PayPal-Auth-Assertion
  header and payment_instruction.platform_fees
- Falls back gracefully to standard order if proxy is unavailable
- Fee notice displayed in settings (matching Stripe's notice)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

- Accept both 'approve' and 'payer-action' link rel in approval URL loops
- Add Prefer: return=representation header to capture API for full response
- Remove unused $consent_status and $risk_status variables from OAuth handler
- Suppress unused parameter warnings in Base_Gateway URL methods

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

- Combine connection status and connect button into single OAuth widget
- Use same dashicons-admin-links icon as Stripe Connect button
- Hide manual API key fields behind "Use Direct API Keys (Advanced)" toggle
- Remove separate webhook configure button (auto-created on connect/save)
- Add blue info box explaining automatic webhook configuration
- Match Stripe's green connected/blue disconnected visual styling

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

superdav42 and others added 2 commits February 27, 2026 21:11
Only register the PayPal (Legacy) NVP gateway if it's already active
or has credentials configured. New installs will only see the modern
PayPal REST gateway.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- PayPal_REST_Gateway_Test: 9 new tests for settings registration,
  render_oauth_connection output, webhook listener URL, and
  maybe_install_webhook skip logic
- PayPal_OAuth_Handler_Test: 10 tests for singleton, is_configured,
  is_merchant_connected (sandbox/live), get_merchant_details, and
  init hook registration
- Gateway_Manager_Test: 4 new tests for legacy PayPal conditional
  registration (hidden without config, shown with credentials,
  shown when active, paypal-rest always registered)
- E2E Cypress spec: 035-paypal-checkout-flow.spec.js tests PayPal
  gateway setup, checkout form rendering, and form submission with
  PayPal selected
- PHP fixtures for gateway setup and result verification

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

OAuth Connect button is now hidden by default and only shown when
the PayPal proxy plugin at ultimatemultisite.com reports that partner
credentials are configured (GET /status endpoint).

When OAuth is disabled (current state):
- Manual API key fields shown directly without advanced toggle
- No Connect with PayPal button visible

When OAuth is enabled (after partnership setup):
- Full Connect UI with connect/disconnect buttons
- Manual keys behind "Use Direct API Keys (Advanced)" toggle

Feature flag checks (in priority order):
1. WU_PAYPAL_OAUTH_ENABLED constant
2. wu_paypal_oauth_enabled filter
3. Cached proxy /status response (12h transient)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions
Copy link

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PayPal checkout loops back to /register (sandbox). NVP capture fails with error 10002.

1 participant