Skip to content

Conversation

@superdav42
Copy link
Collaborator

@superdav42 superdav42 commented Dec 23, 2025

Summary

Fixes the issue where PayPal checkout would silently loop back to the /register page instead of showing an error message when PayPal API calls fail (e.g., error 10002 "Security header is not valid" due to invalid credentials).

Root Causes Fixed

  1. get_checkout_details() not checking ACK=Failure - The function only checked HTTP status code (200 OK) but not the PayPal ACK field. When API credentials were invalid, PayPal would return HTTP 200 with ACK=Failure, which was being treated as valid data.

  2. confirmation_form() returned errors instead of displaying them - When an error occurred, the function returned an error string, but the calling code used output buffering and ignored return values, so errors were never shown to users.

  3. Missing validation for required data - No validation for pending_payment, customer, or membership objects before trying to render the confirmation form.

Changes

  • get_checkout_details(): Added ACK=Failure check to detect PayPal API errors, returns WP_Error on failures instead of partial data, added comprehensive logging
  • confirmation_form(): Changed return type from string to void, uses wp_die() to display errors, added validation for required objects
  • process_confirmation(): Added proper WP_Error handling, improved error messages with better context, added logging for all error conditions
  • create_recurring_profile() & complete_single_payment(): Added logging for API call lifecycle, added ACK=FailureWithWarning check, improved error messages to include PayPal error code and message

Result

Users now see clear error messages instead of being silently redirected back to the checkout page. All PayPal API errors are logged for debugging.

Test plan

  • Test PayPal checkout with valid credentials - should complete successfully
  • Test PayPal checkout with invalid credentials - should show error message instead of looping
  • Test expired PayPal token scenario - should show clear error message
  • Check PayPal logs (/wp-admin/network/admin.php?page=wp-ultimo-logs&tab=paypal) for proper logging
  • Test recurring payment profile creation
  • Test single payment completion

Fixes #193

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added PayPal REST integration with OAuth connect/disconnect, webhook support, and REST-based checkout/subscription flows
    • Settings UI: connection status, sandbox toggle, connect/disconnect and webhook controls
    • “View on PayPal” links for PayPal-backed memberships
  • Bug Fixes

    • Improved payment/confirmation error handling with clearer user messages and consistent responses
    • Better detection of invalid/expired tokens, refunds and subscription cancellations handling
  • Chores

    • Consolidated PayPal helpers and logging for easier troubleshooting

✏️ Tip: You can customize this high-level summary in your review settings.

## Problem
When PayPal returns an error (e.g., error 10002 "Security header is not valid"
due to invalid API credentials), the checkout would silently loop back to the
/register page instead of showing an error message to the user.

## Root Causes Fixed

1. **get_checkout_details() not checking ACK=Failure**: The function only checked
   HTTP status code (200 OK) but not the PayPal ACK field. When API credentials
   were invalid, PayPal would return HTTP 200 with ACK=Failure, which was being
   treated as a valid response.

2. **confirmation_form() returned errors instead of displaying them**: When an
   error occurred, the function returned an error string but the calling code
   used output buffering and ignored return values, so errors were never shown.

3. **Missing validation for required data**: No validation for pending_payment,
   customer, or membership objects before trying to render the confirmation form.

## Changes

### get_checkout_details()
- Added ACK=Failure check to detect PayPal API errors
- Returns WP_Error on API failures instead of partial data
- Added comprehensive logging for all error conditions

### confirmation_form()
- Changed return type from string to void
- Uses wp_die() to display errors instead of returning strings
- Added validation for pending_payment, customer, and membership
- Added error logging

### process_confirmation()
- Added proper WP_Error handling for get_checkout_details() response
- Improved all error messages with better context
- Added logging for all error conditions
- Uses proper wp_die() calls with back_link option

### create_recurring_profile() & complete_single_payment()
- Added logging for API call start, success, and failure
- Added ACK=FailureWithWarning check (not just Failure)
- Improved error messages to include PayPal error code and message
- Fixed HTTP response code from 401 to 200 for proper back links

## Result
Users now see clear error messages instead of being silently redirected.
All PayPal API errors are logged for debugging.

Fixes #193

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 23, 2025

📝 Walkthrough

Walkthrough

Adds a PayPal REST gateway with OAuth and webhook handlers, introduces a shared Base_PayPal_Gateway, updates the legacy NVP PayPal gateway to extend it with improved error/logging, and tightens URL helper return types in Base_Gateway; also updates gateway registration and webhook hooks in the gateway manager.

Changes

Cohort / File(s) Summary
Core gateway signature updates
inc/gateways/class-base-gateway.php
Added explicit string return types and fixed empty-string returns for gateway URL helpers: get_payment_url_on_gateway, get_subscription_url_on_gateway, get_customer_url_on_gateway.
New PayPal base class
inc/gateways/class-base-paypal-gateway.php
New abstract Base_PayPal_Gateway providing PayPal-specific helpers (API/base URLs, partner attribution header, logging, site action link), capability flags, and abstract is_configured/get_connection_status.
Legacy NVP PayPal gateway updates
inc/gateways/class-paypal-gateway.php
Switched inheritance to Base_PayPal_Gateway; added logging and robust WP_Error handling across checkout/confirmation/recurring/single-payment flows; added is_configured() and get_connection_status(); standardized user-facing error responses.
New REST gateway implementation
inc/gateways/class-paypal-rest-gateway.php
New PayPal_REST_Gateway implementing OAuth/token caching, REST API request wrapper, create order/subscription flows, confirmation handling, refunds/cancellations, webhook installation and settings UI, and many helpers for plans/orders/subscriptions.
OAuth onboarding & token manager
inc/gateways/class-paypal-oauth-handler.php
New PayPal_OAuth_Handler handling partner referral onboarding flow, partner token caching, merchant credential storage, OAuth return handling, webhook install/uninstall, AJAX connect/disconnect endpoints, and admin notices.
Webhook processing
inc/gateways/class-paypal-webhook-handler.php
New PayPal_Webhook_Handler to verify PayPal webhook signatures, dispatch subscription and payment events, and update membership/payment state; includes signature verification and event-specific handlers.
Gateway registration & manager changes
inc/managers/class-gateway-manager.php
Registers paypal-rest gateway and legacy paypal label, imports REST/webhook classes, initializes webhook handler, changes remote URL hooks to filters, and adjusts gateway fields rendering call.

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: API request to create Order/Subscription (redirect URL)
  PayPal-->>User: Approval page (redirect to PayPal)
  User->>PayPal: Approve payment/subscription
  PayPal-->>Site: Redirect callback (token / subscription_id)
  Site->>PayPal: Confirm order/subscription via API
  PayPal-->>Site: Confirmation response (status, transaction ids)
  Site->>DB: Persist payment/membership updates
  PayPal->>Webhook: Send webhook events (captures, refunds, subscription updates)
  Webhook->>Site: Verify and dispatch event to update DB/state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

"I'm a rabbit in a coding glen,
I hopped through logs and found a pen,
New gateways stitched with OAuth thread,
Webhooks listening where bytes are fed 🐰,
I nibble code and bounce again."

🚥 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 title accurately reflects the main changes: fixing a PayPal checkout loop issue and improving error handling throughout the legacy PayPal gateway implementation.
Linked Issues check ✅ Passed Changes comprehensively address issue #193: error handling now surfaces PayPal failures instead of silent loops, ACK=Failure detection and WP_Error returns are implemented, logging is added throughout, and both legacy NVP and new REST gateways are provided.
Out of Scope Changes check ✅ Passed All changes are in scope: legacy PayPal gateway refactoring, new REST gateway/OAuth/webhook handlers, and base gateway updates directly support fixing the checkout loop and error handling per issue #193; no unrelated changes 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 docstrings

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.

- 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 <noreply@anthropic.com>
@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

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.

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 +724 to +754
protected function delete_webhooks_on_disconnect(): void {

try {
$gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance();
$gateway = $gateway_manager->get_gateway('paypal-rest');

if (! $gateway instanceof PayPal_REST_Gateway) {
return;
}

// Try to delete sandbox webhook
$gateway->set_test_mode(true);
$result = $gateway->delete_webhook();
if (is_wp_error($result)) {
wu_log_add('paypal', sprintf('Failed to delete sandbox webhook: %s', $result->get_error_message()), LogLevel::WARNING);
} else {
wu_log_add('paypal', 'Sandbox webhook deleted during disconnect');
}

// Try to delete live webhook
$gateway->set_test_mode(false);
$result = $gateway->delete_webhook();
if (is_wp_error($result)) {
wu_log_add('paypal', sprintf('Failed to delete live webhook: %s', $result->get_error_message()), LogLevel::WARNING);
} else {
wu_log_add('paypal', 'Live webhook deleted during disconnect');
}
} catch (\Exception $e) {
wu_log_add('paypal', sprintf('Exception deleting webhooks during disconnect: %s', $e->getMessage()), LogLevel::WARNING);
}
}
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

Same issue with gateway instance retrieval.

This method has the same issue as install_webhook_after_oauth - $gateway_manager->get_gateway() returns gateway metadata, not a gateway instance.

🐛 Proposed fix
 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) {
+		if (! $gateway) {
 			return;
 		}
🤖 Prompt for AI Agents
In @inc/gateways/class-paypal-oauth-handler.php around lines 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.

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.

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.

2 participants