diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php
index 66da5d2c..8d5524e4 100644
--- a/inc/gateways/class-base-gateway.php
+++ b/inc/gateways/class-base-gateway.php
@@ -531,9 +531,12 @@ public function process_confirmation() {}
* @since 2.0.0
*
* @param string $gateway_payment_id The gateway payment id.
- * @return void|string
+ * @return string
*/
- public function get_payment_url_on_gateway($gateway_payment_id) {}
+ public function get_payment_url_on_gateway($gateway_payment_id): string {
+
+ return '';
+ }
/**
* Returns the external link to view the membership on the membership gateway.
@@ -543,9 +546,12 @@ public function get_payment_url_on_gateway($gateway_payment_id) {}
* @since 2.0.0
*
* @param string $gateway_subscription_id The gateway subscription id.
- * @return void|string.
+ * @return string
*/
- public function get_subscription_url_on_gateway($gateway_subscription_id) {}
+ public function get_subscription_url_on_gateway($gateway_subscription_id): string {
+
+ return '';
+ }
/**
* Returns the external link to view the membership on the membership gateway.
@@ -555,9 +561,12 @@ public function get_subscription_url_on_gateway($gateway_subscription_id) {}
* @since 2.0.0
*
* @param string $gateway_customer_id The gateway customer id.
- * @return void|string.
+ * @return string
*/
- public function get_customer_url_on_gateway($gateway_customer_id) {}
+ public function get_customer_url_on_gateway($gateway_customer_id): string {
+
+ return '';
+ }
/**
* Reflects membership changes on the gateway.
diff --git a/inc/gateways/class-base-paypal-gateway.php b/inc/gateways/class-base-paypal-gateway.php
new file mode 100644
index 00000000..33e02893
--- /dev/null
+++ b/inc/gateways/class-base-paypal-gateway.php
@@ -0,0 +1,300 @@
+test_mode ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com';
+ }
+
+ /**
+ * Returns the PayPal API base URL based on test mode.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ protected function get_api_base_url(): string {
+
+ return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ }
+
+ /**
+ * Get the subscription description.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
+ * @return string
+ */
+ protected function get_subscription_description($cart): string {
+
+ $descriptor = $cart->get_cart_descriptor();
+
+ $desc = html_entity_decode(substr($descriptor, 0, 127), ENT_COMPAT, 'UTF-8');
+
+ return $desc;
+ }
+
+ /**
+ * Returns the external link to view the payment on the payment gateway.
+ *
+ * Return an empty string to hide the link element.
+ *
+ * @since 2.0.0
+ *
+ * @param string $gateway_payment_id The gateway payment id.
+ * @return string
+ */
+ public function get_payment_url_on_gateway($gateway_payment_id): string {
+
+ if (empty($gateway_payment_id)) {
+ return '';
+ }
+
+ $sandbox_prefix = $this->test_mode ? 'sandbox.' : '';
+
+ return sprintf(
+ 'https://www.%spaypal.com/activity/payment/%s',
+ $sandbox_prefix,
+ $gateway_payment_id
+ );
+ }
+
+ /**
+ * Returns the external link to view the subscription on PayPal.
+ *
+ * Return an empty string to hide the link element.
+ *
+ * @since 2.0.0
+ *
+ * @param string $gateway_subscription_id The gateway subscription id.
+ * @return string
+ */
+ public function get_subscription_url_on_gateway($gateway_subscription_id): string {
+
+ if (empty($gateway_subscription_id)) {
+ return '';
+ }
+
+ $sandbox_prefix = $this->test_mode ? 'sandbox.' : '';
+
+ // Check if this is a REST API subscription ID (starts with I-) or legacy NVP profile ID
+ 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);
+ }
+
+ /**
+ * Returns whether a gateway subscription ID is from the REST API.
+ *
+ * REST API subscription IDs start with "I-" prefix.
+ *
+ * @since 2.0.0
+ *
+ * @param string $subscription_id The subscription ID to check.
+ * @return bool
+ */
+ protected function is_rest_subscription_id(string $subscription_id): bool {
+
+ return str_starts_with($subscription_id, 'I-');
+ }
+
+ /**
+ * Adds partner attribution to API request headers.
+ *
+ * This should be called when making REST API requests to PayPal
+ * to ensure partner tracking and revenue sharing.
+ *
+ * @since 2.0.0
+ *
+ * @param array $headers Existing headers array.
+ * @return array Headers with partner attribution added.
+ */
+ protected function add_partner_attribution_header(array $headers): array {
+
+ $headers['PayPal-Partner-Attribution-Id'] = $this->bn_code;
+
+ return $headers;
+ }
+
+ /**
+ * Log a PayPal-related message.
+ *
+ * @since 2.0.0
+ *
+ * @param string $message The message to log.
+ * @param string $level Log level (default: 'info').
+ * @return void
+ */
+ protected function log(string $message, string $level = 'info'): void {
+
+ wu_log_add('paypal', $message, $level);
+ }
+
+ /**
+ * Adds the necessary hooks for PayPal gateways.
+ *
+ * Child classes should call parent::hooks() and add their own hooks.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function hooks(): void {
+
+ // Add admin links to PayPal for membership management
+ add_filter('wu_element_get_site_actions', [$this, 'add_site_actions'], 10, 4);
+ }
+
+ /**
+ * Adds PayPal-related actions to the site actions.
+ *
+ * Allows viewing subscription on PayPal for connected memberships.
+ *
+ * @since 2.0.0
+ *
+ * @param array $actions The site actions.
+ * @param array $atts The widget attributes.
+ * @param \WP_Ultimo\Models\Site $site The current site object.
+ * @param \WP_Ultimo\Models\Membership $membership The current membership object.
+ * @return array
+ */
+ public function add_site_actions($actions, $atts, $site, $membership) {
+
+ if (! $membership) {
+ return $actions;
+ }
+
+ $payment_gateway = $membership->get_gateway();
+
+ if (! in_array($payment_gateway, $this->other_ids, true)) {
+ return $actions;
+ }
+
+ $subscription_id = $membership->get_gateway_subscription_id();
+
+ if (empty($subscription_id)) {
+ return $actions;
+ }
+
+ $subscription_url = $this->get_subscription_url_on_gateway($subscription_id);
+
+ if (! empty($subscription_url)) {
+ $actions['view_on_paypal'] = [
+ 'label' => __('View on PayPal', 'ultimate-multisite'),
+ 'icon_classes' => 'dashicons-wu-paypal wu-align-middle',
+ 'href' => $subscription_url,
+ 'target' => '_blank',
+ ];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Checks if PayPal is properly configured.
+ *
+ * @since 2.0.0
+ * @return bool
+ */
+ abstract public function is_configured(): bool;
+
+ /**
+ * Returns the connection status for display in settings.
+ *
+ * @since 2.0.0
+ * @return array{connected: bool, message: string, details: array}
+ */
+ abstract public function get_connection_status(): array;
+}
diff --git a/inc/gateways/class-paypal-gateway.php b/inc/gateways/class-paypal-gateway.php
index 3953ab01..974b5ede 100644
--- a/inc/gateways/class-paypal-gateway.php
+++ b/inc/gateways/class-paypal-gateway.php
@@ -1,6 +1,13 @@
username) && ! empty($this->password) && ! empty($this->signature);
}
/**
- * Declares support to subscription amount updates.
- *
- * @since 2.1.2
- * @return true
- */
- public function supports_amount_update(): bool {
-
- return true;
- }
-
- /**
- * Adds the necessary hooks for the manual gateway.
+ * Returns the connection status for display in settings.
*
* @since 2.0.0
- * @return void
+ * @return array{connected: bool, message: string, details: array}
*/
- public function hooks() {}
+ public function get_connection_status(): array {
+
+ $configured = $this->is_configured();
+
+ return [
+ 'connected' => $configured,
+ 'message' => $configured
+ ? __('PayPal credentials configured', 'ultimate-multisite')
+ : __('PayPal credentials not configured', 'ultimate-multisite'),
+ 'details' => [
+ 'mode' => $this->test_mode ? 'sandbox' : 'live',
+ 'username' => ! empty($this->username) ? substr($this->username, 0, 10) . '...' : '',
+ ],
+ ];
+ }
/**
* Initialization code.
@@ -419,6 +426,7 @@ public function process_membership_update(&$membership, $customer) {
* @param \WP_Ultimo\Models\Customer $customer The customer checking out.
* @param \WP_Ultimo\Checkout\Cart $cart The cart object.
* @param string $type The checkout type. Can be 'new', 'retry', 'upgrade', 'downgrade', 'addon'.
+ * @throws \Exception When PayPal API call fails or returns an error.
* @return void
*/
public function process_checkout($payment, $membership, $customer, $cart, $type): void {
@@ -649,7 +657,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type)
*
* Redirect to the PayPal checkout URL.
*/
- wp_redirect($this->checkout_url . $body['TOKEN']);
+ wp_redirect($this->checkout_url . $body['TOKEN']); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- PayPal external URL
exit;
}
@@ -830,8 +838,39 @@ public function process_confirmation(): void {
*/
$details = $this->get_checkout_details(wu_request('token'));
- if (empty($details)) {
- wp_die(esc_html__('PayPal token no longer valid.', 'ultimate-multisite'));
+ /*
+ * Check if we got a WP_Error back from PayPal (e.g., invalid credentials, expired token).
+ */
+ if (is_wp_error($details)) {
+ wu_log_add('paypal', 'PayPal confirmation failed: ' . $details->get_error_message(), LogLevel::ERROR);
+
+ wp_die(
+ esc_html(
+ sprintf(
+ // translators: %s is the PayPal error message.
+ __('PayPal Error: %s', 'ultimate-multisite'),
+ $details->get_error_message()
+ )
+ ),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ if (empty($details) || ! is_array($details)) {
+ wu_log_add('paypal', 'PayPal confirmation failed: token no longer valid or empty response', LogLevel::ERROR);
+
+ wp_die(
+ esc_html__('PayPal token no longer valid. Please try again.', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
/*
@@ -845,7 +884,16 @@ public function process_confirmation(): void {
* Bail.
*/
if (empty($payment)) {
- wp_die(esc_html__('Pending payment does not exist.', 'ultimate-multisite'));
+ wu_log_add('paypal', 'PayPal confirmation failed: pending payment not found', LogLevel::ERROR);
+
+ wp_die(
+ esc_html__('Pending payment does not exist. Please try again or contact support.', 'ultimate-multisite'),
+ esc_html__('Payment Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
/*
@@ -857,7 +905,16 @@ public function process_confirmation(): void {
$original_cart = $payment->get_meta('wu_original_cart');
if (empty($original_cart)) {
- wp_die(esc_html__('Original cart does not exist.', 'ultimate-multisite'));
+ wu_log_add('paypal', 'PayPal confirmation failed: original cart not found for payment ' . $payment->get_id(), LogLevel::ERROR);
+
+ wp_die(
+ esc_html__('Original cart does not exist. Please try again or contact support.', 'ultimate-multisite'),
+ esc_html__('Cart Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
/*
@@ -869,9 +926,16 @@ public function process_confirmation(): void {
$is_recurring = $original_cart->has_recurring();
if (empty($membership) || empty($customer)) {
- $error = new \WP_Error('no-membership', esc_html__('Missing membership or customer data.', 'ultimate-multisite'));
-
- wp_die($error); // phpcs:ignore WordPress.Security.EscapeOutput
+ wu_log_add('paypal', 'PayPal confirmation failed: missing membership or customer for payment ' . $payment->get_id(), LogLevel::ERROR);
+
+ wp_die(
+ esc_html__('Missing membership or customer data. Please try again or contact support.', 'ultimate-multisite'),
+ esc_html__('Data Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
if ($should_auto_renew && $is_recurring) {
@@ -1274,6 +1338,8 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh
$args['TOTALBILLINGCYCLES'] = $membership->get_billing_cycles() - $membership->get_times_billed();
}
+ wu_log_add('paypal', sprintf('Creating recurring payment profile for payment #%d', $payment->get_id()));
+
$request = wp_remote_post(
$this->api_endpoint,
[
@@ -1284,7 +1350,16 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh
);
if (is_wp_error($request)) {
- wp_die(esc_html($request->get_error_message()));
+ wu_log_add('paypal', 'CreateRecurringPaymentsProfile request failed: ' . $request->get_error_message(), LogLevel::ERROR);
+
+ wp_die(
+ esc_html($request->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
$body = wp_remote_retrieve_body($request);
@@ -1300,8 +1375,21 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh
wp_parse_str($body, $body);
}
- if ('failure' === strtolower((string) $body['ACK'])) {
- wp_die(esc_html($body['L_LONGMESSAGE0']), esc_html($body['L_ERRORCODE0']));
+ if ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK'])) {
+ $error_code = $body['L_ERRORCODE0'] ?? 'unknown';
+ $error_message = $body['L_LONGMESSAGE0'] ?? __('Unknown PayPal error', 'ultimate-multisite');
+
+ wu_log_add('paypal', sprintf('CreateRecurringPaymentsProfile failed with error %s: %s', $error_code, $error_message), LogLevel::ERROR);
+
+ wp_die(
+ // translators: %1$s is the PayPal error code, %2$s is the error message.
+ esc_html(sprintf(__('PayPal Error (%1$s): %2$s', 'ultimate-multisite'), $error_code, $error_message)),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
} else {
/*
* We were successful, let's update
@@ -1313,6 +1401,8 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh
$transaction_id = $body['TRANSACTIONID'] ?? '';
$profile_status = $body['PROFILESTATUS'] ?? '';
+ wu_log_add('paypal', sprintf('CreateRecurringPaymentsProfile successful. Profile ID: %s, Transaction ID: %s, Status: %s', $body['PROFILEID'] ?? 'N/A', $transaction_id ?: 'N/A', $profile_status ?: 'N/A'));
+
// If TRANSACTIONID is not passed we need to wait for webhook
$payment_status = Payment_Status::PENDING;
@@ -1385,34 +1475,19 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh
exit;
}
} else {
+ wu_log_add('paypal', sprintf('CreateRecurringPaymentsProfile received unexpected response: HTTP %d %s', $code, $message), LogLevel::ERROR);
+
wp_die(
esc_html__('Something has gone wrong, please try again', 'ultimate-multisite'),
esc_html__('Error', 'ultimate-multisite'),
[
'back_link' => true,
- 'response' => '401',
+ 'response' => '200',
]
);
}
}
- /**
- * Get the subscription description.
- *
- * @since 2.0.0
- *
- * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
- * @return string
- */
- protected function get_subscription_description($cart) {
-
- $descriptor = $cart->get_cart_descriptor();
-
- $desc = html_entity_decode(substr($descriptor, 0, 127), ENT_COMPAT, 'UTF-8');
-
- return $desc;
- }
-
/**
* Create a single payment on PayPal.
*
@@ -1445,6 +1520,8 @@ protected function complete_single_payment($details, $cart, $payment, $membershi
'BUTTONSOURCE' => 'WP_Ultimo',
];
+ wu_log_add('paypal', sprintf('Processing single payment for payment #%d', $payment->get_id()));
+
$request = wp_remote_post(
$this->api_endpoint,
[
@@ -1463,7 +1540,16 @@ protected function complete_single_payment($details, $cart, $payment, $membershi
$message = wp_remote_retrieve_response_message($request);
if (is_wp_error($request)) {
- wp_die(esc_html($request->get_error_message()));
+ wu_log_add('paypal', 'DoExpressCheckoutPayment request failed: ' . $request->get_error_message(), LogLevel::ERROR);
+
+ wp_die(
+ esc_html($request->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
if (200 === absint($code) && 'OK' === $message) {
@@ -1471,8 +1557,21 @@ protected function complete_single_payment($details, $cart, $payment, $membershi
wp_parse_str($body, $body);
}
- if ('failure' === strtolower((string) $body['ACK'])) {
- wp_die(esc_html($body['L_LONGMESSAGE0']), esc_html($body['L_ERRORCODE0']));
+ if ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK'])) {
+ $error_code = $body['L_ERRORCODE0'] ?? 'unknown';
+ $error_message = $body['L_LONGMESSAGE0'] ?? __('Unknown PayPal error', 'ultimate-multisite');
+
+ wu_log_add('paypal', sprintf('DoExpressCheckoutPayment failed with error %s: %s', $error_code, $error_message), LogLevel::ERROR);
+
+ wp_die(
+ // translators: %1$s is the PayPal error code, %2$s is the error message.
+ esc_html(sprintf(__('PayPal Error (%1$s): %2$s', 'ultimate-multisite'), $error_code, $error_message)),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
} else {
/*
* We were successful, let's update
@@ -1530,6 +1629,8 @@ protected function complete_single_payment($details, $cart, $payment, $membershi
$membership->save();
}
+ wu_log_add('paypal', sprintf('DoExpressCheckoutPayment successful. Transaction ID: %s', $transaction_id));
+
$this->payment = $payment;
$redirect_url = $this->get_return_url();
@@ -1538,12 +1639,14 @@ protected function complete_single_payment($details, $cart, $payment, $membershi
exit;
}
} else {
+ wu_log_add('paypal', sprintf('DoExpressCheckoutPayment received unexpected response: HTTP %d %s', $code, $message), LogLevel::ERROR);
+
wp_die(
esc_html__('Something has gone wrong, please try again', 'ultimate-multisite'),
esc_html__('Error', 'ultimate-multisite'),
[
'back_link' => true,
- 'response' => '401',
+ 'response' => '200',
]
);
}
@@ -1553,9 +1656,9 @@ protected function complete_single_payment($details, $cart, $payment, $membershi
* Display the confirmation form.
*
* @since 2.1
- * @return string
+ * @return void
*/
- public function confirmation_form() {
+ public function confirmation_form(): void {
$token = sanitize_text_field(wu_request('token'));
@@ -1565,21 +1668,62 @@ public function confirmation_form() {
$error = is_wp_error($checkout_details) ? $checkout_details->get_error_message() : __('Invalid response code from PayPal', 'ultimate-multisite');
// translators: %s is the paypal error message.
- return '
' . sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'ultimate-multisite'), $error) . '
';
+ $error_message = sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'ultimate-multisite'), esc_html($error));
+
+ wp_die(
+ esc_html($error_message),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ /*
+ * Validate that the pending payment exists.
+ */
+ $pending_payment = $checkout_details['pending_payment'] ?? null;
+
+ if (empty($pending_payment)) {
+ wu_log_add('paypal', 'PayPal confirmation failed: pending payment not found', LogLevel::ERROR);
+
+ wp_die(
+ esc_html__('Unable to locate the pending payment. Please try again or contact support.', 'ultimate-multisite'),
+ esc_html__('Payment Not Found', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
}
/*
* Compiles the necessary elements.
*/
- $customer = $checkout_details['pending_payment']->get_customer(); // current customer
+ $customer = $pending_payment->get_customer();
+ $membership = $pending_payment->get_membership();
+
+ if (empty($customer) || empty($membership)) {
+ wu_log_add('paypal', 'PayPal confirmation failed: customer or membership not found', LogLevel::ERROR);
+
+ wp_die(
+ esc_html__('Unable to locate customer or membership data. Please try again or contact support.', 'ultimate-multisite'),
+ esc_html__('Data Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
wu_get_template(
'checkout/paypal/confirm',
[
'checkout_details' => $checkout_details,
'customer' => $customer,
- 'payment' => $checkout_details['pending_payment'],
- 'membership' => $checkout_details['pending_payment']->get_membership(),
+ 'payment' => $pending_payment,
+ 'membership' => $membership,
]
);
}
@@ -1615,12 +1759,27 @@ public function get_checkout_details($token = '') {
$message = wp_remote_retrieve_response_message($request);
if (is_wp_error($request)) {
+ wu_log_add('paypal', 'GetExpressCheckoutDetails request failed: ' . $request->get_error_message(), LogLevel::ERROR);
+
return $request;
} elseif (200 === absint($code) && 'OK' === $message) {
if (is_string($body)) {
wp_parse_str($body, $body);
}
+ /*
+ * Check for PayPal API errors.
+ * ACK=Failure means the API call failed (e.g., invalid credentials, expired token).
+ */
+ if (isset($body['ACK']) && ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK']))) {
+ $error_code = $body['L_ERRORCODE0'] ?? 'unknown';
+ $error_message = $body['L_LONGMESSAGE0'] ?? __('Unknown PayPal error', 'ultimate-multisite');
+
+ wu_log_add('paypal', sprintf('GetExpressCheckoutDetails failed with error %s: %s', $error_code, $error_message), LogLevel::ERROR);
+
+ return new \WP_Error($error_code, $error_message);
+ }
+
$payment_id = absint(wu_request('payment-id'));
$pending_payment = $payment_id ? wu_get_payment($payment_id) : wu_get_payment_by_hash(wu_request('payment'));
@@ -1631,48 +1790,32 @@ public function get_checkout_details($token = '') {
$body['pending_payment'] = $pending_payment;
- $custom = explode('|', (string) $body['PAYMENTREQUEST_0_CUSTOM']);
+ $custom = explode('|', (string) wu_get_isset($body, 'PAYMENTREQUEST_0_CUSTOM', ''));
return $body;
}
+ wu_log_add('paypal', sprintf('GetExpressCheckoutDetails received unexpected response: HTTP %d %s', $code, $message), LogLevel::ERROR);
+
return false;
}
/**
* Returns the external link to view the payment on the payment gateway.
*
- * Return an empty string to hide the link element.
+ * For the legacy NVP API, there's no reliable payment link, so we return empty.
+ * The base class provides a URL that may work for some transactions.
*
* @since 2.0.0
*
* @param string $gateway_payment_id The gateway payment id.
- * @return string.
+ * @return string
*/
public function get_payment_url_on_gateway($gateway_payment_id): string {
return '';
}
- /**
- * Returns the external link to view the membership on the membership gateway.
- *
- * Return an empty string to hide the link element.
- *
- * @since 2.0.0
- *
- * @param string $gateway_subscription_id The gateway subscription id.
- * @return string.
- */
- public function get_subscription_url_on_gateway($gateway_subscription_id): string {
-
- $sandbox_prefix = $this->test_mode ? 'sandbox.' : '';
-
- $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);
- }
-
/**
* Verifies that the IPN notification actually came from PayPal.
*
diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php
new file mode 100644
index 00000000..171e7f23
--- /dev/null
+++ b/inc/gateways/class-paypal-oauth-handler.php
@@ -0,0 +1,755 @@
+test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true);
+
+ $this->load_partner_credentials();
+
+ // Register AJAX handlers
+ add_action('wp_ajax_wu_paypal_connect', [$this, 'ajax_initiate_oauth']);
+ add_action('wp_ajax_wu_paypal_disconnect', [$this, 'ajax_disconnect']);
+
+ // Handle OAuth return callback
+ add_action('admin_init', [$this, 'handle_oauth_return']);
+ }
+
+ /**
+ * Load partner credentials from settings.
+ *
+ * Partner credentials are used to authenticate with PayPal's Partner API
+ * to initiate the OAuth flow for merchants.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ protected function load_partner_credentials(): void {
+
+ // In production, these would be Ultimate Multisite's partner credentials
+ // For now, merchants can use their own REST app credentials for testing
+ $mode_prefix = $this->test_mode ? 'sandbox_' : 'live_';
+
+ $this->partner_client_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_id", '');
+ $this->partner_client_secret = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_secret", '');
+ $this->partner_merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_merchant_id", '');
+ }
+
+ /**
+ * Returns the PayPal API base URL based on test mode.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ protected function get_api_base_url(): string {
+
+ return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ }
+
+ /**
+ * Returns the PayPal web base URL based on test mode.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ protected function get_paypal_web_url(): string {
+
+ return $this->test_mode ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com';
+ }
+
+ /**
+ * Get an access token for the partner application.
+ *
+ * @since 2.0.0
+ * @return string|\WP_Error Access token or error.
+ */
+ protected function get_partner_access_token() {
+
+ // Check for cached token
+ $cache_key = 'wu_paypal_partner_token_' . ($this->test_mode ? 'sandbox' : 'live');
+ $cached_token = get_site_transient($cache_key);
+
+ if ($cached_token) {
+ return $cached_token;
+ }
+
+ if (empty($this->partner_client_id) || empty($this->partner_client_secret)) {
+ return new \WP_Error(
+ 'wu_paypal_missing_partner_credentials',
+ __('Partner credentials not configured. Please configure the partner client ID and secret.', 'ultimate-multisite')
+ );
+ }
+
+ $response = wp_remote_post(
+ $this->get_api_base_url() . '/v1/oauth2/token',
+ [
+ 'headers' => [
+ 'Authorization' => 'Basic ' . base64_encode($this->partner_client_id . ':' . $this->partner_client_secret),
+ 'Content-Type' => 'application/x-www-form-urlencoded',
+ ],
+ 'body' => 'grant_type=client_credentials',
+ 'timeout' => 30,
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ wu_log_add('paypal', 'Failed to get partner access token: ' . $response->get_error_message(), LogLevel::ERROR);
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $code = wp_remote_retrieve_response_code($response);
+
+ if (200 !== $code || empty($body['access_token'])) {
+ $error_msg = $body['error_description'] ?? __('Failed to obtain access token', 'ultimate-multisite');
+ wu_log_add('paypal', 'Failed to get partner access token: ' . $error_msg, LogLevel::ERROR);
+ return new \WP_Error('wu_paypal_token_error', $error_msg);
+ }
+
+ // Cache the token (expires_in is in seconds, subtract 5 minutes for safety)
+ $expires_in = isset($body['expires_in']) ? (int) $body['expires_in'] - 300 : 3300;
+ set_site_transient($cache_key, $body['access_token'], $expires_in);
+
+ return $body['access_token'];
+ }
+
+ /**
+ * Generate a partner referral URL for merchant onboarding.
+ *
+ * Uses PayPal Partner Referrals API v2 to create an onboarding link.
+ *
+ * @since 2.0.0
+ * @return array|\WP_Error Array with action_url and tracking_id, or error.
+ */
+ public function generate_referral_url() {
+
+ $access_token = $this->get_partner_access_token();
+
+ if (is_wp_error($access_token)) {
+ return $access_token;
+ }
+
+ // Generate a unique tracking ID for this onboarding attempt
+ $tracking_id = 'wu_' . wp_generate_uuid4();
+
+ // Store tracking ID for verification when merchant returns
+ set_site_transient(
+ 'wu_paypal_onboarding_' . $tracking_id,
+ [
+ 'started' => time(),
+ 'test_mode' => $this->test_mode,
+ ],
+ DAY_IN_SECONDS
+ );
+
+ // Build the return URL
+ $return_url = add_query_arg(
+ [
+ 'page' => 'wp-ultimo-settings',
+ 'tab' => 'payment-gateways',
+ 'wu_paypal_onboarding' => 'complete',
+ 'tracking_id' => $tracking_id,
+ ],
+ network_admin_url('admin.php')
+ );
+
+ // Build the partner referral request
+ $referral_data = [
+ 'tracking_id' => $tracking_id,
+ 'partner_config_override' => [
+ 'return_url' => $return_url,
+ ],
+ 'operations' => [
+ [
+ 'operation' => 'API_INTEGRATION',
+ 'api_integration_preference' => [
+ 'rest_api_integration' => [
+ 'integration_method' => 'PAYPAL',
+ 'integration_type' => 'THIRD_PARTY',
+ 'third_party_details' => [
+ 'features' => [
+ 'PAYMENT',
+ 'REFUND',
+ 'PARTNER_FEE',
+ 'DELAY_FUNDS_DISBURSEMENT',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'products' => ['EXPRESS_CHECKOUT'],
+ 'legal_consents' => [
+ [
+ 'type' => 'SHARE_DATA_CONSENT',
+ 'granted' => true,
+ ],
+ ],
+ ];
+
+ /**
+ * Filters the partner referral data before sending to PayPal.
+ *
+ * @since 2.0.0
+ *
+ * @param array $referral_data The referral request data.
+ * @param string $tracking_id The tracking ID for this onboarding.
+ */
+ $referral_data = apply_filters('wu_paypal_partner_referral_data', $referral_data, $tracking_id);
+
+ $response = wp_remote_post(
+ $this->get_api_base_url() . '/v2/customer/partner-referrals',
+ [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ 'PayPal-Partner-Attribution-Id' => $this->bn_code,
+ ],
+ 'body' => wp_json_encode($referral_data),
+ 'timeout' => 30,
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ wu_log_add('paypal', 'Failed to create partner referral: ' . $response->get_error_message(), LogLevel::ERROR);
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $code = wp_remote_retrieve_response_code($response);
+
+ if (201 !== $code || empty($body['links'])) {
+ $error_msg = $body['message'] ?? __('Failed to create partner referral', 'ultimate-multisite');
+ wu_log_add('paypal', 'Failed to create partner referral: ' . wp_json_encode($body), LogLevel::ERROR);
+ return new \WP_Error('wu_paypal_referral_error', $error_msg);
+ }
+
+ // Find the action_url link
+ $action_url = '';
+ foreach ($body['links'] as $link) {
+ if ('action_url' === $link['rel']) {
+ $action_url = $link['href'];
+ break;
+ }
+ }
+
+ if (empty($action_url)) {
+ return new \WP_Error('wu_paypal_no_action_url', __('No action URL returned from PayPal', 'ultimate-multisite'));
+ }
+
+ wu_log_add('paypal', sprintf('Partner referral created. Tracking ID: %s', $tracking_id));
+
+ return [
+ 'action_url' => $action_url,
+ 'tracking_id' => $tracking_id,
+ ];
+ }
+
+ /**
+ * AJAX handler to initiate OAuth flow.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function ajax_initiate_oauth(): void {
+
+ check_ajax_referer('wu_paypal_oauth', 'nonce');
+
+ if (! current_user_can('manage_network_options')) {
+ wp_send_json_error(
+ [
+ 'message' => __('You do not have permission to do this.', 'ultimate-multisite'),
+ ]
+ );
+ }
+
+ // Update test mode from request if provided
+ if (isset($_POST['sandbox_mode'])) {
+ $this->test_mode = (bool) (int) $_POST['sandbox_mode'];
+ $this->load_partner_credentials();
+ }
+
+ $result = $this->generate_referral_url();
+
+ if (is_wp_error($result)) {
+ wp_send_json_error(
+ [
+ 'message' => $result->get_error_message(),
+ ]
+ );
+ }
+
+ wp_send_json_success(
+ [
+ 'redirect_url' => $result['action_url'],
+ 'tracking_id' => $result['tracking_id'],
+ ]
+ );
+ }
+
+ /**
+ * Handle the OAuth return callback.
+ *
+ * When the merchant completes the PayPal onboarding flow, they are redirected
+ * back to WordPress with parameters indicating success/failure.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function handle_oauth_return(): void {
+
+ // Check if this is an OAuth return - nonce verification not possible for external OAuth callback
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback from PayPal
+ if (! isset($_GET['wu_paypal_onboarding']) || 'complete' !== $_GET['wu_paypal_onboarding']) {
+ return;
+ }
+
+ // Verify we're on the settings page
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback from PayPal
+ if (! isset($_GET['page']) || 'wp-ultimo-settings' !== $_GET['page']) {
+ return;
+ }
+
+ // Get parameters from PayPal
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth callback from PayPal
+ $merchant_id = isset($_GET['merchantIdInPayPal']) ? sanitize_text_field(wp_unslash($_GET['merchantIdInPayPal'])) : '';
+ $merchant_email = isset($_GET['merchantId']) ? sanitize_email(wp_unslash($_GET['merchantId'])) : '';
+ $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'])) : '';
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ // Verify tracking ID
+ $onboarding_data = get_site_transient('wu_paypal_onboarding_' . $tracking_id);
+
+ if (! $onboarding_data) {
+ wu_log_add('paypal', 'OAuth return with invalid tracking ID: ' . $tracking_id, LogLevel::WARNING);
+ $this->add_oauth_notice('error', __('Invalid onboarding session. Please try again.', 'ultimate-multisite'));
+ return;
+ }
+
+ // Update test mode to match the onboarding session
+ $this->test_mode = $onboarding_data['test_mode'];
+
+ // Check if permissions were granted
+ if (! $permissions_granted) {
+ wu_log_add('paypal', 'OAuth: Merchant did not grant permissions', LogLevel::WARNING);
+ $this->add_oauth_notice('warning', __('PayPal permissions were not granted. Please try again and approve the required permissions.', 'ultimate-multisite'));
+ return;
+ }
+
+ // Verify the merchant status with PayPal
+ $merchant_status = $this->verify_merchant_status($merchant_id, $tracking_id);
+
+ if (is_wp_error($merchant_status)) {
+ wu_log_add('paypal', 'Failed to verify merchant status: ' . $merchant_status->get_error_message(), LogLevel::ERROR);
+ $this->add_oauth_notice('error', __('Failed to verify your PayPal account status. Please try again.', 'ultimate-multisite'));
+ return;
+ }
+
+ // Store the merchant credentials
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+
+ wu_save_setting("paypal_rest_{$mode_prefix}_merchant_id", $merchant_id);
+ wu_save_setting("paypal_rest_{$mode_prefix}_merchant_email", $merchant_email);
+ wu_save_setting('paypal_rest_connected', true);
+ wu_save_setting('paypal_rest_connection_date', current_time('mysql'));
+ wu_save_setting('paypal_rest_connection_mode', $mode_prefix);
+
+ // Store additional status info if available
+ if (! empty($merchant_status['payments_receivable'])) {
+ wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $merchant_status['payments_receivable']);
+ }
+ if (! empty($merchant_status['primary_email_confirmed'])) {
+ wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $merchant_status['primary_email_confirmed']);
+ }
+
+ // Clean up the tracking transient
+ delete_site_transient('wu_paypal_onboarding_' . $tracking_id);
+
+ wu_log_add('paypal', sprintf('PayPal OAuth completed. Merchant ID: %s, Mode: %s', $merchant_id, $mode_prefix));
+
+ // Automatically install webhooks for the connected account
+ $this->install_webhook_after_oauth($mode_prefix);
+
+ $this->add_oauth_notice('success', __('PayPal account connected successfully!', 'ultimate-multisite'));
+
+ // Redirect to remove query parameters
+ wp_safe_redirect(
+ add_query_arg(
+ [
+ 'page' => 'wp-ultimo-settings',
+ 'tab' => 'payment-gateways',
+ 'paypal_connected' => '1',
+ ],
+ network_admin_url('admin.php')
+ )
+ );
+ exit;
+ }
+
+ /**
+ * Verify merchant status after OAuth completion.
+ *
+ * Calls PayPal to verify the merchant's integration status and capabilities.
+ *
+ * @since 2.0.0
+ *
+ * @param string $merchant_id The merchant's PayPal ID.
+ * @param string $tracking_id The tracking ID from onboarding.
+ * @return array|\WP_Error Merchant status data or error.
+ */
+ protected function verify_merchant_status(string $merchant_id, string $tracking_id) {
+
+ $access_token = $this->get_partner_access_token();
+
+ if (is_wp_error($access_token)) {
+ return $access_token;
+ }
+
+ if (empty($this->partner_merchant_id)) {
+ // If no partner merchant ID, we can't verify status via partner API
+ // Return basic success
+ return [
+ 'merchant_id' => $merchant_id,
+ 'payments_receivable' => true,
+ ];
+ }
+
+ $response = wp_remote_get(
+ $this->get_api_base_url() . '/v1/customer/partners/' . $this->partner_merchant_id . '/merchant-integrations/' . $merchant_id,
+ [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ 'PayPal-Partner-Attribution-Id' => $this->bn_code,
+ ],
+ 'timeout' => 30,
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $code = wp_remote_retrieve_response_code($response);
+
+ if (200 !== $code) {
+ $error_msg = $body['message'] ?? __('Failed to verify merchant status', 'ultimate-multisite');
+ return new \WP_Error('wu_paypal_verify_error', $error_msg);
+ }
+
+ return [
+ 'merchant_id' => $body['merchant_id'] ?? $merchant_id,
+ 'tracking_id' => $body['tracking_id'] ?? $tracking_id,
+ 'payments_receivable' => $body['payments_receivable'] ?? false,
+ 'primary_email_confirmed' => $body['primary_email_confirmed'] ?? false,
+ 'oauth_integrations' => $body['oauth_integrations'] ?? [],
+ ];
+ }
+
+ /**
+ * AJAX handler to disconnect PayPal.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function ajax_disconnect(): void {
+
+ check_ajax_referer('wu_paypal_oauth', 'nonce');
+
+ if (! current_user_can('manage_network_options')) {
+ wp_send_json_error(
+ [
+ 'message' => __('You do not have permission to do this.', 'ultimate-multisite'),
+ ]
+ );
+ }
+
+ // Delete webhooks before clearing credentials
+ $this->delete_webhooks_on_disconnect();
+
+ // Clear all connection data
+ $settings_to_clear = [
+ 'paypal_rest_connected',
+ 'paypal_rest_connection_date',
+ 'paypal_rest_connection_mode',
+ 'paypal_rest_sandbox_merchant_id',
+ 'paypal_rest_sandbox_merchant_email',
+ 'paypal_rest_sandbox_payments_receivable',
+ 'paypal_rest_sandbox_email_confirmed',
+ 'paypal_rest_live_merchant_id',
+ 'paypal_rest_live_merchant_email',
+ 'paypal_rest_live_payments_receivable',
+ 'paypal_rest_live_email_confirmed',
+ 'paypal_rest_sandbox_webhook_id',
+ 'paypal_rest_live_webhook_id',
+ ];
+
+ foreach ($settings_to_clear as $setting) {
+ wu_save_setting($setting, '');
+ }
+
+ // Clear cached access tokens
+ delete_site_transient('wu_paypal_partner_token_sandbox');
+ delete_site_transient('wu_paypal_partner_token_live');
+ delete_site_transient('wu_paypal_rest_access_token_sandbox');
+ delete_site_transient('wu_paypal_rest_access_token_live');
+
+ wu_log_add('paypal', 'PayPal account disconnected');
+
+ wp_send_json_success(
+ [
+ 'message' => __('PayPal account disconnected successfully.', 'ultimate-multisite'),
+ ]
+ );
+ }
+
+ /**
+ * Add an admin notice for OAuth status.
+ *
+ * @since 2.0.0
+ *
+ * @param string $type Notice type: 'success', 'error', 'warning', 'info'.
+ * @param string $message The notice message.
+ * @return void
+ */
+ protected function add_oauth_notice(string $type, string $message): void {
+
+ set_site_transient(
+ 'wu_paypal_oauth_notice',
+ [
+ 'type' => $type,
+ 'message' => $message,
+ ],
+ 60
+ );
+ }
+
+ /**
+ * Display OAuth notices.
+ *
+ * Should be called on admin_notices hook.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function display_oauth_notices(): void {
+
+ $notice = get_site_transient('wu_paypal_oauth_notice');
+
+ if ($notice) {
+ delete_site_transient('wu_paypal_oauth_notice');
+
+ $class = 'notice notice-' . esc_attr($notice['type']) . ' is-dismissible';
+ printf(
+ '',
+ esc_attr($class),
+ esc_html($notice['message'])
+ );
+ }
+ }
+
+ /**
+ * Check if OAuth is fully configured.
+ *
+ * @since 2.0.0
+ * @return bool
+ */
+ public function is_configured(): bool {
+
+ return ! empty($this->partner_client_id) && ! empty($this->partner_client_secret);
+ }
+
+ /**
+ * Check if a merchant is connected via OAuth.
+ *
+ * @since 2.0.0
+ *
+ * @param bool $sandbox Whether to check sandbox mode.
+ * @return bool
+ */
+ public function is_merchant_connected(bool $sandbox = true): bool {
+
+ $mode_prefix = $sandbox ? 'sandbox' : 'live';
+ $merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}_merchant_id", '');
+
+ return ! empty($merchant_id);
+ }
+
+ /**
+ * Get connected merchant details.
+ *
+ * @since 2.0.0
+ *
+ * @param bool $sandbox Whether to get sandbox mode details.
+ * @return array
+ */
+ public function get_merchant_details(bool $sandbox = true): array {
+
+ $mode_prefix = $sandbox ? 'sandbox' : 'live';
+
+ return [
+ 'merchant_id' => wu_get_setting("paypal_rest_{$mode_prefix}_merchant_id", ''),
+ 'merchant_email' => wu_get_setting("paypal_rest_{$mode_prefix}_merchant_email", ''),
+ 'payments_receivable' => wu_get_setting("paypal_rest_{$mode_prefix}_payments_receivable", false),
+ 'email_confirmed' => wu_get_setting("paypal_rest_{$mode_prefix}_email_confirmed", false),
+ 'connection_date' => wu_get_setting('paypal_rest_connection_date', ''),
+ ];
+ }
+
+ /**
+ * Install webhooks after successful OAuth connection.
+ *
+ * Creates the webhook endpoint in PayPal to receive subscription and payment events.
+ *
+ * @since 2.0.0
+ *
+ * @param string $mode_prefix The mode prefix ('sandbox' or 'live').
+ * @return void
+ */
+ 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);
+ }
+ }
+
+ /**
+ * Delete webhooks when disconnecting from PayPal.
+ *
+ * Attempts to delete webhooks from both sandbox and live modes.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ 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);
+ }
+ }
+}
diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php
new file mode 100644
index 00000000..086fee2b
--- /dev/null
+++ b/inc/gateways/class-paypal-rest-gateway.php
@@ -0,0 +1,1624 @@
+test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true);
+
+ $this->load_credentials();
+ }
+
+ /**
+ * Load credentials from settings.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ protected function load_credentials(): void {
+
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+
+ // First check for OAuth-connected merchant
+ $this->merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}_merchant_id", '');
+
+ // Load client credentials (either from OAuth or manual entry)
+ $this->client_id = wu_get_setting("paypal_rest_{$mode_prefix}_client_id", '');
+ $this->client_secret = wu_get_setting("paypal_rest_{$mode_prefix}_client_secret", '');
+ }
+
+ /**
+ * Set the test mode and reload credentials.
+ *
+ * @since 2.0.0
+ *
+ * @param bool $test_mode Whether to use sandbox mode.
+ * @return void
+ */
+ public function set_test_mode(bool $test_mode): void {
+
+ $this->test_mode = $test_mode;
+ $this->access_token = ''; // Clear cached token
+
+ $this->load_credentials();
+ }
+
+ /**
+ * Adds the necessary hooks for the REST PayPal gateway.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function hooks(): void {
+
+ parent::hooks();
+
+ // Initialize OAuth handler
+ PayPal_OAuth_Handler::get_instance()->init();
+
+ // Handle webhook installation after settings save
+ add_action('wu_after_save_settings', [$this, 'maybe_install_webhook'], 10, 3);
+
+ // AJAX handler for manual webhook installation
+ add_action('wp_ajax_wu_paypal_install_webhook', [$this, 'ajax_install_webhook']);
+
+ // Display OAuth notices
+ add_action('admin_notices', [PayPal_OAuth_Handler::get_instance(), 'display_oauth_notices']);
+ }
+
+ /**
+ * Checks if PayPal REST is properly configured.
+ *
+ * @since 2.0.0
+ * @return bool
+ */
+ 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;
+ }
+
+ /**
+ * Returns the connection status for display in settings.
+ *
+ * @since 2.0.0
+ * @return array{connected: bool, message: string, details: array}
+ */
+ public function get_connection_status(): array {
+
+ $oauth_handler = PayPal_OAuth_Handler::get_instance();
+ $is_sandbox = $this->test_mode;
+
+ if ($oauth_handler->is_merchant_connected($is_sandbox)) {
+ $merchant_details = $oauth_handler->get_merchant_details($is_sandbox);
+
+ return [
+ 'connected' => true,
+ 'message' => __('Connected via PayPal', 'ultimate-multisite'),
+ 'details' => [
+ 'mode' => $is_sandbox ? 'sandbox' : 'live',
+ 'merchant_id' => $merchant_details['merchant_id'],
+ 'email' => $merchant_details['merchant_email'],
+ 'connected_at' => $merchant_details['connection_date'],
+ 'method' => 'oauth',
+ ],
+ ];
+ }
+
+ if (! empty($this->client_id) && ! empty($this->client_secret)) {
+ return [
+ 'connected' => true,
+ 'message' => __('Connected via API credentials', 'ultimate-multisite'),
+ 'details' => [
+ 'mode' => $is_sandbox ? 'sandbox' : 'live',
+ 'client_id' => substr($this->client_id, 0, 20) . '...',
+ 'method' => 'manual',
+ ],
+ ];
+ }
+
+ return [
+ 'connected' => false,
+ 'message' => __('Not connected', 'ultimate-multisite'),
+ 'details' => [
+ 'mode' => $is_sandbox ? 'sandbox' : 'live',
+ ],
+ ];
+ }
+
+ /**
+ * Get an access token for API requests.
+ *
+ * @since 2.0.0
+ * @return string|\WP_Error Access token or error.
+ */
+ protected function get_access_token() {
+
+ if (! empty($this->access_token)) {
+ return $this->access_token;
+ }
+
+ // Check for cached token
+ $cache_key = 'wu_paypal_rest_access_token_' . ($this->test_mode ? 'sandbox' : 'live');
+ $cached_token = get_site_transient($cache_key);
+
+ if ($cached_token) {
+ $this->access_token = $cached_token;
+ return $this->access_token;
+ }
+
+ if (empty($this->client_id) || empty($this->client_secret)) {
+ return new \WP_Error(
+ 'wu_paypal_missing_credentials',
+ __('PayPal API credentials not configured.', 'ultimate-multisite')
+ );
+ }
+
+ $response = wp_remote_post(
+ $this->get_api_base_url() . '/v1/oauth2/token',
+ [
+ 'headers' => [
+ 'Authorization' => 'Basic ' . base64_encode($this->client_id . ':' . $this->client_secret),
+ 'Content-Type' => 'application/x-www-form-urlencoded',
+ ],
+ 'body' => 'grant_type=client_credentials',
+ 'timeout' => 30,
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ $this->log('Failed to get access token: ' . $response->get_error_message(), LogLevel::ERROR);
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $code = wp_remote_retrieve_response_code($response);
+
+ if (200 !== $code || empty($body['access_token'])) {
+ $error_msg = $body['error_description'] ?? __('Failed to obtain access token', 'ultimate-multisite');
+ $this->log('Failed to get access token: ' . $error_msg, LogLevel::ERROR);
+ return new \WP_Error('wu_paypal_token_error', $error_msg);
+ }
+
+ // Cache the token (expires_in is in seconds, subtract 5 minutes for safety)
+ $expires_in = isset($body['expires_in']) ? (int) $body['expires_in'] - 300 : 3300;
+ set_site_transient($cache_key, $body['access_token'], $expires_in);
+
+ $this->access_token = $body['access_token'];
+
+ return $this->access_token;
+ }
+
+ /**
+ * Make an API request to PayPal REST API.
+ *
+ * @since 2.0.0
+ *
+ * @param string $endpoint API endpoint (relative to base URL).
+ * @param array $data Request data.
+ * @param string $method HTTP method.
+ * @return array|\WP_Error Response data or error.
+ */
+ protected function api_request(string $endpoint, array $data = [], string $method = 'POST') {
+
+ $access_token = $this->get_access_token();
+
+ if (is_wp_error($access_token)) {
+ return $access_token;
+ }
+
+ $headers = [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ ];
+
+ $headers = $this->add_partner_attribution_header($headers);
+
+ $args = [
+ 'headers' => $headers,
+ 'method' => $method,
+ 'timeout' => 45,
+ ];
+
+ if (! empty($data) && in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
+ $args['body'] = wp_json_encode($data);
+ }
+
+ $url = $this->get_api_base_url() . $endpoint;
+
+ $this->log(sprintf('API Request: %s %s', $method, $endpoint));
+
+ $response = wp_remote_request($url, $args);
+
+ if (is_wp_error($response)) {
+ $this->log('API Request failed: ' . $response->get_error_message(), LogLevel::ERROR);
+ return $response;
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $code = wp_remote_retrieve_response_code($response);
+
+ if ($code >= 400) {
+ $error_msg = $body['message'] ?? ($body['error_description'] ?? __('API request failed', 'ultimate-multisite'));
+ $this->log(sprintf('API Error (%d): %s', $code, wp_json_encode($body)), LogLevel::ERROR);
+ return new \WP_Error(
+ 'wu_paypal_api_error',
+ $error_msg,
+ [
+ 'status' => $code,
+ 'response' => $body,
+ ]
+ );
+ }
+
+ return $body ?? [];
+ }
+
+ /**
+ * Process a checkout.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout.
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Models\Customer $customer The customer checking out.
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
+ * @param string $type The checkout type.
+ * @return void
+ */
+ public function process_checkout($payment, $membership, $customer, $cart, $type): void {
+
+ $should_auto_renew = $cart->should_auto_renew();
+ $is_recurring = $cart->has_recurring();
+
+ if ($should_auto_renew && $is_recurring) {
+ $this->create_subscription($payment, $membership, $customer, $cart, $type);
+ } else {
+ $this->create_order($payment, $membership, $customer, $cart, $type);
+ }
+ }
+
+ /**
+ * Create a PayPal subscription for recurring payments.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Models\Payment $payment The payment.
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Models\Customer $customer The customer.
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
+ * @param string $type The checkout type.
+ * @return void
+ */
+ 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);
+
+ if (is_wp_error($plan_id)) {
+ wp_die(
+ esc_html($plan_id->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ // Create the subscription
+ $subscription_data = [
+ 'plan_id' => $plan_id,
+ 'subscriber' => [
+ 'name' => [
+ 'given_name' => $customer->get_display_name(),
+ ],
+ 'email_address' => $customer->get_email_address(),
+ ],
+ 'application_context' => [
+ 'brand_name' => wu_get_setting('company_name', get_network_option(null, 'site_name')),
+ 'locale' => str_replace('_', '-', get_locale()),
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'SUBSCRIBE_NOW',
+ 'return_url' => $this->get_confirm_url(),
+ 'cancel_url' => $this->get_cancel_url(),
+ ],
+ 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()),
+ ];
+
+ // Handle initial payment if different from recurring
+ $initial_amount = $payment->get_total();
+ $recurring_amount = $cart->get_recurring_total();
+
+ if ($initial_amount > 0 && abs($initial_amount - $recurring_amount) > 0.01) {
+ // Add setup fee for the difference
+ $setup_fee = $initial_amount - $recurring_amount;
+ if ($setup_fee > 0) {
+ $subscription_data['plan'] = [
+ 'payment_preferences' => [
+ 'setup_fee' => [
+ 'value' => number_format($setup_fee, 2, '.', ''),
+ 'currency_code' => $currency,
+ ],
+ ],
+ ];
+ }
+ }
+
+ // Handle trial periods
+ if ($membership->is_trialing()) {
+ $trial_end = $membership->get_date_trial_end();
+ if ($trial_end) {
+ $subscription_data['start_time'] = gmdate('Y-m-d\TH:i:s\Z', strtotime($trial_end));
+ }
+ }
+
+ /**
+ * Filter subscription data before creating.
+ *
+ * @since 2.0.0
+ *
+ * @param array $subscription_data The subscription data.
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart.
+ */
+ $subscription_data = apply_filters('wu_paypal_rest_subscription_data', $subscription_data, $membership, $cart);
+
+ $result = $this->api_request('/v1/billing/subscriptions', $subscription_data);
+
+ if (is_wp_error($result)) {
+ wp_die(
+ esc_html($result->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ // Find approval URL
+ $approval_url = '';
+ foreach ($result['links'] ?? [] as $link) {
+ if ('approve' === $link['rel']) {
+ $approval_url = $link['href'];
+ break;
+ }
+ }
+
+ if (empty($approval_url)) {
+ wp_die(
+ esc_html__('Failed to get PayPal approval URL', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ // Store subscription ID for confirmation
+ $membership->set_gateway_subscription_id($result['id']);
+ $membership->save();
+
+ $this->log(sprintf('Subscription created: %s. Redirecting to approval.', $result['id']));
+
+ // Redirect to PayPal for approval
+ wp_redirect($approval_url); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- PayPal external URL
+ exit;
+ }
+
+ /**
+ * Get or create a billing plan for the subscription.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
+ * @param string $currency The currency code.
+ * @return string|\WP_Error Plan ID or error.
+ */
+ protected function get_or_create_plan($cart, string $currency) {
+
+ // Generate a unique plan key based on cart contents
+ $plan_key = 'wu_paypal_plan_' . md5(
+ wp_json_encode(
+ [
+ 'amount' => $cart->get_recurring_total(),
+ 'currency' => $currency,
+ 'duration' => $cart->get_duration(),
+ 'duration_unit' => $cart->get_duration_unit(),
+ ]
+ )
+ );
+
+ // Check if we already have this plan
+ $existing_plan_id = get_site_option($plan_key);
+ if ($existing_plan_id) {
+ // Verify the plan still exists
+ $plan = $this->api_request('/v1/billing/plans/' . $existing_plan_id, [], 'GET');
+ if (! is_wp_error($plan) && isset($plan['id'])) {
+ return $plan['id'];
+ }
+ }
+
+ // First create a product
+ $product_name = wu_get_setting('company_name', get_network_option(null, 'site_name')) . ' - ' . $cart->get_cart_descriptor();
+
+ $product_data = [
+ 'name' => substr($product_name, 0, 127),
+ 'description' => substr($this->get_subscription_description($cart), 0, 256),
+ 'type' => 'SERVICE',
+ 'category' => 'SOFTWARE',
+ ];
+
+ $product = $this->api_request('/v1/catalogs/products', $product_data);
+
+ if (is_wp_error($product)) {
+ return $product;
+ }
+
+ // Convert duration unit to PayPal format
+ $interval_unit = strtoupper($cart->get_duration_unit());
+ $interval_map = [
+ 'DAY' => 'DAY',
+ 'WEEK' => 'WEEK',
+ 'MONTH' => 'MONTH',
+ 'YEAR' => 'YEAR',
+ ];
+ $paypal_interval = $interval_map[ $interval_unit ] ?? 'MONTH';
+
+ // Create the billing plan
+ $plan_data = [
+ 'product_id' => $product['id'],
+ 'name' => substr($product_name, 0, 127),
+ 'description' => substr($this->get_subscription_description($cart), 0, 127),
+ 'billing_cycles' => [
+ [
+ 'frequency' => [
+ 'interval_unit' => $paypal_interval,
+ 'interval_count' => $cart->get_duration(),
+ ],
+ 'tenure_type' => 'REGULAR',
+ 'sequence' => 1,
+ 'total_cycles' => 0, // 0 = unlimited
+ 'pricing_scheme' => [
+ 'fixed_price' => [
+ 'value' => number_format($cart->get_recurring_total(), 2, '.', ''),
+ 'currency_code' => $currency,
+ ],
+ ],
+ ],
+ ],
+ 'payment_preferences' => [
+ 'auto_bill_outstanding' => true,
+ 'payment_failure_threshold' => 3,
+ ],
+ ];
+
+ $plan = $this->api_request('/v1/billing/plans', $plan_data);
+
+ if (is_wp_error($plan)) {
+ return $plan;
+ }
+
+ // Cache the plan ID
+ update_site_option($plan_key, $plan['id']);
+
+ $this->log(sprintf('Billing plan created: %s', $plan['id']));
+
+ return $plan['id'];
+ }
+
+ /**
+ * Create a PayPal order for one-time payments.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Models\Payment $payment The payment.
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Models\Customer $customer The customer.
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
+ * @param string $type The checkout type.
+ * @return void
+ */
+ protected function create_order($payment, $membership, $customer, $cart, $type): void {
+
+ $currency = strtoupper($payment->get_currency());
+ $description = $this->get_subscription_description($cart);
+
+ $order_data = [
+ 'intent' => 'CAPTURE',
+ 'purchase_units' => [
+ [
+ 'reference_id' => $payment->get_hash(),
+ 'description' => substr($description, 0, 127),
+ 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()),
+ 'amount' => [
+ 'currency_code' => $currency,
+ 'value' => number_format($payment->get_total(), 2, '.', ''),
+ 'breakdown' => [
+ 'item_total' => [
+ 'currency_code' => $currency,
+ 'value' => number_format($payment->get_subtotal(), 2, '.', ''),
+ ],
+ 'tax_total' => [
+ 'currency_code' => $currency,
+ 'value' => number_format($payment->get_tax_total(), 2, '.', ''),
+ ],
+ ],
+ ],
+ 'items' => $this->build_order_items($cart, $currency),
+ ],
+ ],
+ 'application_context' => [
+ 'brand_name' => wu_get_setting('company_name', get_network_option(null, 'site_name')),
+ 'locale' => str_replace('_', '-', get_locale()),
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'PAY_NOW',
+ 'return_url' => $this->get_confirm_url(),
+ 'cancel_url' => $this->get_cancel_url(),
+ ],
+ ];
+
+ /**
+ * Filter order data before creating.
+ *
+ * @since 2.0.0
+ *
+ * @param array $order_data The order data.
+ * @param \WP_Ultimo\Models\Payment $payment The payment.
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart.
+ */
+ $order_data = apply_filters('wu_paypal_rest_order_data', $order_data, $payment, $cart);
+
+ $result = $this->api_request('/v2/checkout/orders', $order_data);
+
+ if (is_wp_error($result)) {
+ wp_die(
+ esc_html($result->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ // Find approval URL
+ $approval_url = '';
+ foreach ($result['links'] ?? [] as $link) {
+ if ('approve' === $link['rel']) {
+ $approval_url = $link['href'];
+ break;
+ }
+ }
+
+ if (empty($approval_url)) {
+ wp_die(
+ esc_html__('Failed to get PayPal approval URL', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ [
+ 'back_link' => true,
+ 'response' => '200',
+ ]
+ );
+ }
+
+ // Store order ID for confirmation
+ $payment->set_gateway_payment_id($result['id']);
+ $payment->save();
+
+ $this->log(sprintf('Order created: %s. Redirecting to approval.', $result['id']));
+
+ // Redirect to PayPal for approval
+ wp_redirect($approval_url); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- PayPal external URL
+ exit;
+ }
+
+ /**
+ * Build order items array for PayPal.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Checkout\Cart $cart The cart object.
+ * @param string $currency The currency code.
+ * @return array
+ */
+ protected function build_order_items($cart, string $currency): array {
+
+ $items = [];
+
+ foreach ($cart->get_line_items() as $line_item) {
+ $items[] = [
+ 'name' => substr($line_item->get_title(), 0, 127),
+ 'description' => substr($line_item->get_description(), 0, 127) ?: null,
+ 'unit_amount' => [
+ 'currency_code' => $currency,
+ 'value' => number_format($line_item->get_unit_price(), 2, '.', ''),
+ ],
+ 'quantity' => (string) $line_item->get_quantity(),
+ 'category' => 'DIGITAL_GOODS',
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * Process confirmation after PayPal approval.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function process_confirmation(): void {
+
+ $token = sanitize_text_field(wu_request('token', ''));
+ $subscription_id = sanitize_text_field(wu_request('subscription_id', ''));
+
+ if (! empty($subscription_id)) {
+ $this->confirm_subscription($subscription_id);
+ } elseif (! empty($token)) {
+ $this->confirm_order($token);
+ } else {
+ wp_die(
+ esc_html__('Invalid PayPal confirmation', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+ }
+
+ /**
+ * Confirm a subscription after PayPal approval.
+ *
+ * @since 2.0.0
+ *
+ * @param string $subscription_id The PayPal subscription ID.
+ * @return void
+ */
+ protected function confirm_subscription(string $subscription_id): void {
+
+ // Get subscription details
+ $subscription = $this->api_request('/v1/billing/subscriptions/' . $subscription_id, [], 'GET');
+
+ if (is_wp_error($subscription)) {
+ wp_die(
+ esc_html($subscription->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ // Parse custom_id to get our IDs
+ $custom_parts = explode('|', $subscription['custom_id'] ?? '');
+ if (count($custom_parts) !== 3) {
+ wp_die(
+ esc_html__('Invalid subscription data', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ [$payment_id, $membership_id, $customer_id] = $custom_parts;
+
+ $payment = wu_get_payment($payment_id);
+ $membership = wu_get_membership($membership_id);
+
+ if (! $payment || ! $membership) {
+ wp_die(
+ esc_html__('Payment or membership not found', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ // Check subscription status
+ if ('ACTIVE' === $subscription['status'] || 'APPROVED' === $subscription['status']) {
+ // Update membership
+ $membership->set_gateway('paypal-rest');
+ $membership->set_gateway_subscription_id($subscription_id);
+ $membership->set_gateway_customer_id($subscription['subscriber']['payer_id'] ?? '');
+ $membership->set_auto_renew(true);
+
+ // Handle based on status
+ if ('ACTIVE' === $subscription['status']) {
+ // Payment already processed
+ $payment->set_status(Payment_Status::COMPLETED);
+ $membership->renew(false);
+ } else {
+ // Will be activated on first payment webhook
+ $payment->set_status(Payment_Status::PENDING);
+ }
+
+ $payment->set_gateway('paypal-rest');
+ $payment->save();
+ $membership->save();
+
+ $this->log(sprintf('Subscription confirmed: %s, Status: %s', $subscription_id, $subscription['status']));
+
+ $this->payment = $payment;
+ wp_safe_redirect($this->get_return_url());
+ exit;
+ }
+
+ wp_die(
+ // translators: %s is the subscription status
+ esc_html(sprintf(__('Subscription not approved. Status: %s', 'ultimate-multisite'), $subscription['status'])),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ /**
+ * Confirm an order after PayPal approval.
+ *
+ * @since 2.0.0
+ *
+ * @param string $token The PayPal order token.
+ * @return void
+ */
+ protected function confirm_order(string $token): void {
+
+ // Capture the order
+ $capture = $this->api_request('/v2/checkout/orders/' . $token . '/capture', []);
+
+ if (is_wp_error($capture)) {
+ wp_die(
+ esc_html($capture->get_error_message()),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ if ('COMPLETED' !== $capture['status']) {
+ wp_die(
+ // translators: %s is the order status
+ esc_html(sprintf(__('Order not completed. Status: %s', 'ultimate-multisite'), $capture['status'])),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ // 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(
+ esc_html__('Invalid order data', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ [$payment_id, $membership_id, $customer_id] = $custom_parts;
+
+ $payment = wu_get_payment($payment_id);
+ $membership = wu_get_membership($membership_id);
+
+ if (! $payment || ! $membership) {
+ wp_die(
+ esc_html__('Payment or membership not found', 'ultimate-multisite'),
+ esc_html__('PayPal Error', 'ultimate-multisite'),
+ ['back_link' => true]
+ );
+ }
+
+ // Get transaction ID from capture
+ $transaction_id = $purchase_unit['payments']['captures'][0]['id'] ?? $token;
+
+ // Update payment
+ $payment->set_gateway('paypal-rest');
+ $payment->set_gateway_payment_id($transaction_id);
+ $payment->set_status(Payment_Status::COMPLETED);
+ $payment->save();
+
+ // Update membership
+ $membership->set_gateway('paypal-rest');
+ $membership->set_gateway_customer_id($capture['payer']['payer_id'] ?? '');
+ $membership->add_to_times_billed(1);
+ $membership->renew(false);
+
+ $this->log(sprintf('Order captured: %s, Transaction: %s', $token, $transaction_id));
+
+ $this->payment = $payment;
+ wp_safe_redirect($this->get_return_url());
+ exit;
+ }
+
+ /**
+ * Process cancellation of a subscription.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Models\Customer $customer The customer.
+ * @return void
+ */
+ public function process_cancellation($membership, $customer): void {
+
+ $subscription_id = $membership->get_gateway_subscription_id();
+
+ if (empty($subscription_id)) {
+ return;
+ }
+
+ $result = $this->api_request(
+ '/v1/billing/subscriptions/' . $subscription_id . '/cancel',
+ ['reason' => __('Cancelled by user', 'ultimate-multisite')]
+ );
+
+ if (is_wp_error($result)) {
+ $this->log('Failed to cancel subscription: ' . $result->get_error_message(), LogLevel::ERROR);
+ return;
+ }
+
+ $this->log(sprintf('Subscription cancelled: %s', $subscription_id));
+ }
+
+ /**
+ * Process refund.
+ *
+ * @since 2.0.0
+ *
+ * @param float $amount The amount to refund.
+ * @param \WP_Ultimo\Models\Payment $payment The payment.
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Models\Customer $customer The customer.
+ * @return void
+ * @throws \Exception When refund fails.
+ */
+ public function process_refund($amount, $payment, $membership, $customer): void {
+
+ $capture_id = $payment->get_gateway_payment_id();
+
+ if (empty($capture_id)) {
+ throw new \Exception(esc_html__('No capture ID found for this payment.', 'ultimate-multisite'));
+ }
+
+ $refund_data = [];
+
+ // Only include amount for partial refunds
+ if ($amount < $payment->get_total()) {
+ $refund_data['amount'] = [
+ 'value' => number_format($amount, 2, '.', ''),
+ 'currency_code' => strtoupper($payment->get_currency()),
+ ];
+ }
+
+ $result = $this->api_request('/v2/payments/captures/' . $capture_id . '/refund', $refund_data);
+
+ if (is_wp_error($result)) {
+ throw new \Exception(esc_html($result->get_error_message()));
+ }
+
+ $this->log(sprintf('Refund processed: %s for capture %s', $result['id'] ?? 'unknown', $capture_id));
+ }
+
+ /**
+ * Reflects membership changes on the gateway.
+ *
+ * @since 2.0.0
+ *
+ * @param \WP_Ultimo\Models\Membership $membership The membership.
+ * @param \WP_Ultimo\Models\Customer $customer The customer.
+ * @return bool|\WP_Error
+ */
+ 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));
+
+ return true;
+ }
+
+ /**
+ * Adds the PayPal REST Gateway settings to the settings screen.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function settings(): void {
+
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_header',
+ [
+ 'title' => __('PayPal', 'ultimate-multisite'),
+ 'desc' => __('Modern PayPal integration with Connect with PayPal onboarding.', 'ultimate-multisite'),
+ 'type' => 'header',
+ 'show_as_submenu' => true,
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+
+ // Connection status display
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_connection_status',
+ [
+ 'title' => __('Connection Status', 'ultimate-multisite'),
+ 'type' => 'note',
+ 'desc' => [$this, 'render_connection_status'],
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+
+ // Sandbox mode toggle
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_sandbox_mode',
+ [
+ 'title' => __('PayPal Sandbox Mode', 'ultimate-multisite'),
+ 'desc' => __('Enable sandbox mode for testing.', 'ultimate-multisite'),
+ 'type' => 'toggle',
+ 'default' => 1,
+ 'html_attr' => [
+ 'v-model' => 'paypal_rest_sandbox_mode',
+ ],
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+
+ // Connect with PayPal button
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_connect_button',
+ [
+ 'title' => __('Connect with PayPal', 'ultimate-multisite'),
+ 'type' => 'note',
+ 'desc' => [$this, 'render_connect_button'],
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+
+ // Advanced/Manual credentials header
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_manual_header',
+ [
+ 'title' => __('Manual Configuration', 'ultimate-multisite'),
+ 'desc' => __('Advanced: Enter API credentials manually if Connect with PayPal is not available.', 'ultimate-multisite'),
+ 'type' => 'header',
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+
+ // Sandbox Client ID
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_sandbox_client_id',
+ [
+ 'title' => __('Sandbox Client ID', 'ultimate-multisite'),
+ 'placeholder' => __('e.g. AX7MV...', 'ultimate-multisite'),
+ 'type' => 'text',
+ 'default' => '',
+ 'capability' => 'manage_api_keys',
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ 'paypal_rest_sandbox_mode' => 1,
+ ],
+ ]
+ );
+
+ // Sandbox Client Secret
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_sandbox_client_secret',
+ [
+ 'title' => __('Sandbox Client Secret', 'ultimate-multisite'),
+ 'placeholder' => __('e.g. EK4jT...', 'ultimate-multisite'),
+ 'type' => 'text',
+ 'default' => '',
+ 'capability' => 'manage_api_keys',
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ 'paypal_rest_sandbox_mode' => 1,
+ ],
+ ]
+ );
+
+ // Live Client ID
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_live_client_id',
+ [
+ 'title' => __('Live Client ID', 'ultimate-multisite'),
+ 'placeholder' => __('e.g. AX7MV...', 'ultimate-multisite'),
+ 'type' => 'text',
+ 'default' => '',
+ 'capability' => 'manage_api_keys',
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ 'paypal_rest_sandbox_mode' => 0,
+ ],
+ ]
+ );
+
+ // Live Client Secret
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_live_client_secret',
+ [
+ 'title' => __('Live Client Secret', 'ultimate-multisite'),
+ 'placeholder' => __('e.g. EK4jT...', 'ultimate-multisite'),
+ 'type' => 'text',
+ 'default' => '',
+ 'capability' => 'manage_api_keys',
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ 'paypal_rest_sandbox_mode' => 0,
+ ],
+ ]
+ );
+
+ // Webhook URL display
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_webhook_url',
+ [
+ 'title' => __('Webhook URL', 'ultimate-multisite'),
+ 'desc' => __('Webhooks are automatically configured when you connect your PayPal account.', 'ultimate-multisite'),
+ 'type' => 'text-display',
+ 'copy' => true,
+ 'value' => $this->get_webhook_listener_url(),
+ 'capability' => 'manage_api_keys',
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+
+ // Webhook status
+ wu_register_settings_field(
+ 'payment-gateways',
+ 'paypal_rest_webhook_status',
+ [
+ 'title' => __('Webhook Status', 'ultimate-multisite'),
+ 'type' => 'note',
+ 'desc' => [$this, 'render_webhook_status'],
+ 'require' => [
+ 'active_gateways' => 'paypal-rest',
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Render connection status HTML.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ public function render_connection_status(): string {
+
+ $status = $this->get_connection_status();
+
+ if ($status['connected']) {
+ $mode_label = 'sandbox' === $status['details']['mode']
+ ? __('Sandbox', 'ultimate-multisite')
+ : __('Live', 'ultimate-multisite');
+
+ $email = $status['details']['email'] ?? ($status['details']['client_id'] ?? '');
+
+ return sprintf(
+ '
+
+ %s (%s)
+ %s
+
',
+ esc_html($status['message']),
+ esc_html($mode_label),
+ esc_html($email)
+ );
+ }
+
+ return sprintf(
+ '
+
+ %s
+
',
+ esc_html__('Not connected. Use Connect with PayPal below or enter credentials manually.', 'ultimate-multisite')
+ );
+ }
+
+ /**
+ * Render the Connect with PayPal button.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ public function render_connect_button(): string {
+
+ $nonce = wp_create_nonce('wu_paypal_oauth');
+ $is_sandbox = $this->test_mode;
+ $oauth = PayPal_OAuth_Handler::get_instance();
+ $is_connected = $oauth->is_merchant_connected($is_sandbox);
+
+ if ($is_connected) {
+ return sprintf(
+ '
+ %s
',
+ esc_attr($nonce),
+ esc_html__('Disconnect PayPal', 'ultimate-multisite'),
+ esc_html__('This will remove the PayPal connection. Existing subscriptions will continue to work.', 'ultimate-multisite')
+ );
+ }
+
+ return sprintf(
+ '
+ %s
+ ',
+ esc_attr($nonce),
+ $is_sandbox ? 1 : 0,
+ esc_html__('Connect with PayPal', 'ultimate-multisite'),
+ esc_html__('Click to securely connect your PayPal account.', 'ultimate-multisite'),
+ esc_js(__('Connecting...', 'ultimate-multisite')),
+ esc_js(__('Connection failed. Please try again.', 'ultimate-multisite')),
+ esc_js(__('Connect with PayPal', 'ultimate-multisite')),
+ esc_js(__('Connection failed. Please try again.', 'ultimate-multisite')),
+ esc_js(__('Connect with PayPal', 'ultimate-multisite')),
+ esc_js(__('Are you sure you want to disconnect PayPal?', 'ultimate-multisite'))
+ );
+ }
+
+ /**
+ * Render webhook status HTML.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ public function render_webhook_status(): string {
+
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+ $mode_label = $this->test_mode
+ ? __('Sandbox', 'ultimate-multisite')
+ : __('Live', 'ultimate-multisite');
+
+ $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+
+ if (! empty($webhook_id)) {
+ return sprintf(
+ '
+
+ %s
+ %s: %s
+
',
+ esc_html__('Webhook configured', 'ultimate-multisite'),
+ esc_html($mode_label),
+ esc_html($webhook_id)
+ );
+ }
+
+ // Check if we have credentials but no webhook
+ if ($this->is_configured()) {
+ return sprintf(
+ '
+
+ %s
+
+
+ ',
+ esc_html__('Webhook not configured. Click below to configure automatically.', 'ultimate-multisite'),
+ esc_attr(wp_create_nonce('wu_paypal_webhook')),
+ esc_html__('Configure Webhook', 'ultimate-multisite'),
+ esc_js(__('Configuring...', 'ultimate-multisite')),
+ esc_js(__('Failed to configure webhook. Please try again.', 'ultimate-multisite')),
+ esc_js(__('Configure Webhook', 'ultimate-multisite')),
+ esc_js(__('Failed to configure webhook. Please try again.', 'ultimate-multisite')),
+ esc_js(__('Configure Webhook', 'ultimate-multisite'))
+ );
+ }
+
+ return sprintf(
+ '
+
+ %s
+
',
+ esc_html__('Connect with PayPal to automatically configure webhooks.', 'ultimate-multisite')
+ );
+ }
+
+ /**
+ * Maybe install webhook after settings save.
+ *
+ * Automatically creates a webhook in PayPal when credentials are configured.
+ *
+ * @since 2.0.0
+ *
+ * @param array $settings The final settings array.
+ * @param array $settings_to_save Settings being updated.
+ * @param array $saved_settings Original settings.
+ * @return void
+ */
+ public function maybe_install_webhook($settings, $settings_to_save, $saved_settings): void {
+
+ $active_gateways = (array) wu_get_isset($settings_to_save, 'active_gateways', []);
+
+ if (! in_array('paypal-rest', $active_gateways, true)) {
+ return;
+ }
+
+ // Check if settings changed
+ $changed_settings = [
+ $settings['paypal_rest_sandbox_mode'] ?? '',
+ $settings['paypal_rest_sandbox_client_id'] ?? '',
+ $settings['paypal_rest_sandbox_client_secret'] ?? '',
+ $settings['paypal_rest_live_client_id'] ?? '',
+ $settings['paypal_rest_live_client_secret'] ?? '',
+ ];
+
+ $original_settings = [
+ $saved_settings['paypal_rest_sandbox_mode'] ?? '',
+ $saved_settings['paypal_rest_sandbox_client_id'] ?? '',
+ $saved_settings['paypal_rest_sandbox_client_secret'] ?? '',
+ $saved_settings['paypal_rest_live_client_id'] ?? '',
+ $saved_settings['paypal_rest_live_client_secret'] ?? '',
+ ];
+
+ // Only install if settings changed
+ if ($changed_settings === $original_settings) {
+ return;
+ }
+
+ // Reload credentials with new settings
+ $this->test_mode = (bool) (int) ($settings['paypal_rest_sandbox_mode'] ?? true);
+ $this->load_credentials();
+
+ // Check if we have credentials
+ if (! $this->is_configured()) {
+ return;
+ }
+
+ $this->install_webhook();
+ }
+
+ /**
+ * Install webhook in PayPal.
+ *
+ * @since 2.0.0
+ * @return bool|\WP_Error True on success, WP_Error on failure.
+ */
+ public function install_webhook() {
+
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+
+ // Check if we already have a webhook installed
+ $existing_webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+
+ if (! empty($existing_webhook_id)) {
+ // Verify it still exists
+ $existing = $this->api_request('/v1/notifications/webhooks/' . $existing_webhook_id, [], 'GET');
+
+ if (! is_wp_error($existing) && ! empty($existing['id'])) {
+ $this->log(sprintf('Webhook already exists: %s', $existing_webhook_id));
+ return true;
+ }
+
+ // Webhook was deleted, clear the setting
+ wu_save_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+ }
+
+ $webhook_url = $this->get_webhook_listener_url();
+
+ // Define the events we want to receive
+ $event_types = [
+ ['name' => 'BILLING.SUBSCRIPTION.CREATED'],
+ ['name' => 'BILLING.SUBSCRIPTION.ACTIVATED'],
+ ['name' => 'BILLING.SUBSCRIPTION.UPDATED'],
+ ['name' => 'BILLING.SUBSCRIPTION.CANCELLED'],
+ ['name' => 'BILLING.SUBSCRIPTION.SUSPENDED'],
+ ['name' => 'BILLING.SUBSCRIPTION.PAYMENT.FAILED'],
+ ['name' => 'PAYMENT.SALE.COMPLETED'],
+ ['name' => 'PAYMENT.CAPTURE.COMPLETED'],
+ ['name' => 'PAYMENT.CAPTURE.REFUNDED'],
+ ];
+
+ $webhook_data = [
+ 'url' => $webhook_url,
+ 'event_types' => $event_types,
+ ];
+
+ $this->log(sprintf('Creating webhook for URL: %s', $webhook_url));
+
+ $result = $this->api_request('/v1/notifications/webhooks', $webhook_data);
+
+ if (is_wp_error($result)) {
+ $this->log(sprintf('Failed to create webhook: %s', $result->get_error_message()), LogLevel::ERROR);
+ return $result;
+ }
+
+ if (empty($result['id'])) {
+ $this->log('Webhook created but no ID returned', LogLevel::ERROR);
+ return new \WP_Error('wu_paypal_webhook_no_id', __('Webhook created but no ID returned', 'ultimate-multisite'));
+ }
+
+ // Save the webhook ID
+ wu_save_setting("paypal_rest_{$mode_prefix}_webhook_id", $result['id']);
+
+ $this->log(sprintf('Webhook created successfully: %s', $result['id']));
+
+ return true;
+ }
+
+ /**
+ * Check if webhook is installed.
+ *
+ * @since 2.0.0
+ * @return bool|array False if not installed, webhook data if installed.
+ */
+ public function has_webhook_installed() {
+
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+ $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+
+ if (empty($webhook_id)) {
+ return false;
+ }
+
+ $webhook = $this->api_request('/v1/notifications/webhooks/' . $webhook_id, [], 'GET');
+
+ if (is_wp_error($webhook)) {
+ return false;
+ }
+
+ return $webhook;
+ }
+
+ /**
+ * Delete webhook from PayPal.
+ *
+ * @since 2.0.0
+ * @return bool True on success.
+ */
+ public function delete_webhook(): bool {
+
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+ $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+
+ if (empty($webhook_id)) {
+ return true;
+ }
+
+ $result = $this->api_request('/v1/notifications/webhooks/' . $webhook_id, [], 'DELETE');
+
+ // Clear the stored webhook ID regardless of result
+ wu_save_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+
+ if (is_wp_error($result)) {
+ $this->log(sprintf('Failed to delete webhook: %s', $result->get_error_message()), LogLevel::WARNING);
+ return false;
+ }
+
+ $this->log(sprintf('Webhook deleted: %s', $webhook_id));
+
+ return true;
+ }
+
+ /**
+ * AJAX handler to manually install webhook.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function ajax_install_webhook(): void {
+
+ check_ajax_referer('wu_paypal_webhook', 'nonce');
+
+ if (! current_user_can('manage_network_options')) {
+ wp_send_json_error(
+ [
+ 'message' => __('You do not have permission to do this.', 'ultimate-multisite'),
+ ]
+ );
+ }
+
+ // Reload credentials to ensure we have the latest
+ $this->load_credentials();
+
+ if (! $this->is_configured()) {
+ wp_send_json_error(
+ [
+ 'message' => __('PayPal credentials are not configured.', 'ultimate-multisite'),
+ ]
+ );
+ }
+
+ $result = $this->install_webhook();
+
+ if (true === $result) {
+ wp_send_json_success(
+ [
+ 'message' => __('Webhook configured successfully.', 'ultimate-multisite'),
+ ]
+ );
+ } elseif (is_wp_error($result)) {
+ wp_send_json_error(
+ [
+ 'message' => $result->get_error_message(),
+ ]
+ );
+ } else {
+ wp_send_json_error(
+ [
+ 'message' => __('Failed to configure webhook. Please try again.', 'ultimate-multisite'),
+ ]
+ );
+ }
+ }
+}
diff --git a/inc/gateways/class-paypal-webhook-handler.php b/inc/gateways/class-paypal-webhook-handler.php
new file mode 100644
index 00000000..9bbfd06c
--- /dev/null
+++ b/inc/gateways/class-paypal-webhook-handler.php
@@ -0,0 +1,578 @@
+test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true);
+
+ // Register webhook listener
+ add_action('wu_paypal-rest_process_webhooks', [$this, 'process_webhook']);
+ }
+
+ /**
+ * Returns the PayPal API base URL based on test mode.
+ *
+ * @since 2.0.0
+ * @return string
+ */
+ protected function get_api_base_url(): string {
+
+ return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ }
+
+ /**
+ * Process incoming webhook.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public function process_webhook(): void {
+
+ $raw_body = file_get_contents('php://input');
+
+ if (empty($raw_body)) {
+ $this->log('Webhook received with empty body', LogLevel::WARNING);
+ status_header(400);
+ exit;
+ }
+
+ $event = json_decode($raw_body, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE || empty($event['event_type'])) {
+ $this->log('Webhook received with invalid JSON', LogLevel::WARNING);
+ status_header(400);
+ exit;
+ }
+
+ $this->log(sprintf('Webhook received: %s, ID: %s', $event['event_type'], $event['id'] ?? 'unknown'));
+
+ // Verify webhook signature
+ if (! $this->verify_webhook_signature($raw_body)) {
+ $this->log('Webhook signature verification failed', LogLevel::ERROR);
+ status_header(401);
+ exit;
+ }
+
+ // Process based on event type
+ $event_type = $event['event_type'];
+ $resource = $event['resource'] ?? [];
+
+ switch ($event_type) {
+ // Subscription events
+ case 'BILLING.SUBSCRIPTION.CREATED':
+ $this->handle_subscription_created($resource);
+ break;
+
+ case 'BILLING.SUBSCRIPTION.ACTIVATED':
+ $this->handle_subscription_activated($resource);
+ break;
+
+ case 'BILLING.SUBSCRIPTION.UPDATED':
+ $this->handle_subscription_updated($resource);
+ break;
+
+ case 'BILLING.SUBSCRIPTION.CANCELLED':
+ $this->handle_subscription_cancelled($resource);
+ break;
+
+ case 'BILLING.SUBSCRIPTION.SUSPENDED':
+ $this->handle_subscription_suspended($resource);
+ break;
+
+ case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
+ $this->handle_subscription_payment_failed($resource);
+ break;
+
+ // Payment events
+ case 'PAYMENT.SALE.COMPLETED':
+ $this->handle_payment_completed($resource);
+ break;
+
+ case 'PAYMENT.CAPTURE.COMPLETED':
+ $this->handle_capture_completed($resource);
+ break;
+
+ case 'PAYMENT.CAPTURE.REFUNDED':
+ $this->handle_capture_refunded($resource);
+ break;
+
+ default:
+ $this->log(sprintf('Unhandled webhook event: %s', $event_type));
+ break;
+ }
+
+ status_header(200);
+ exit;
+ }
+
+ /**
+ * Verify the webhook signature.
+ *
+ * PayPal REST webhooks use RSA-SHA256 signatures for verification.
+ *
+ * @since 2.0.0
+ *
+ * @param string $raw_body The raw request body.
+ * @return bool
+ */
+ protected function verify_webhook_signature(string $raw_body): bool {
+
+ // Get webhook headers - these come from PayPal's webhook signature
+ $auth_algo = isset($_SERVER['HTTP_PAYPAL_AUTH_ALGO']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_AUTH_ALGO'])) : '';
+ $cert_url = isset($_SERVER['HTTP_PAYPAL_CERT_URL']) ? sanitize_url(wp_unslash($_SERVER['HTTP_PAYPAL_CERT_URL'])) : '';
+ $transmission_id = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_ID']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'])) : '';
+ $transmission_sig = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'])) : '';
+ $transmission_time = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'])) : '';
+
+ // 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;
+ }
+ return false;
+ }
+
+ // Get webhook ID from settings
+ $mode_prefix = $this->test_mode ? 'sandbox' : 'live';
+ $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", '');
+
+ 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;
+ }
+
+ // Get access token for verification API call
+ $gateway = wu_get_gateway('paypal-rest');
+ if (! $gateway) {
+ $this->log('PayPal REST gateway not available', LogLevel::ERROR);
+ return false;
+ }
+
+ // Build verification request
+ $verify_data = [
+ 'auth_algo' => $auth_algo,
+ 'cert_url' => $cert_url,
+ 'transmission_id' => $transmission_id,
+ 'transmission_sig' => $transmission_sig,
+ 'transmission_time' => $transmission_time,
+ 'webhook_id' => $webhook_id,
+ 'webhook_event' => json_decode($raw_body, true),
+ ];
+
+ // Get client credentials
+ $client_id = wu_get_setting("paypal_rest_{$mode_prefix}_client_id", '');
+ $client_secret = wu_get_setting("paypal_rest_{$mode_prefix}_client_secret", '');
+
+ if (empty($client_id) || empty($client_secret)) {
+ $this->log('Client credentials not configured for webhook verification', LogLevel::WARNING);
+ return $this->test_mode;
+ }
+
+ // Get access token
+ $token_response = wp_remote_post(
+ $this->get_api_base_url() . '/v1/oauth2/token',
+ [
+ 'headers' => [
+ 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret),
+ 'Content-Type' => 'application/x-www-form-urlencoded',
+ ],
+ 'body' => 'grant_type=client_credentials',
+ 'timeout' => 30,
+ ]
+ );
+
+ if (is_wp_error($token_response)) {
+ $this->log('Failed to get token for webhook verification: ' . $token_response->get_error_message(), LogLevel::ERROR);
+ return false;
+ }
+
+ $token_body = json_decode(wp_remote_retrieve_body($token_response), true);
+ $access_token = $token_body['access_token'] ?? '';
+
+ if (empty($access_token)) {
+ $this->log('Failed to get access token for webhook verification', LogLevel::ERROR);
+ return false;
+ }
+
+ // Call verification endpoint
+ $verify_response = wp_remote_post(
+ $this->get_api_base_url() . '/v1/notifications/verify-webhook-signature',
+ [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json',
+ ],
+ 'body' => wp_json_encode($verify_data),
+ 'timeout' => 30,
+ ]
+ );
+
+ if (is_wp_error($verify_response)) {
+ $this->log('Webhook verification request failed: ' . $verify_response->get_error_message(), LogLevel::ERROR);
+ return false;
+ }
+
+ $verify_body = json_decode(wp_remote_retrieve_body($verify_response), true);
+
+ if (($verify_body['verification_status'] ?? '') === 'SUCCESS') {
+ $this->log('Webhook signature verified successfully');
+ return true;
+ }
+
+ $this->log(sprintf('Webhook signature verification returned: %s', $verify_body['verification_status'] ?? 'unknown'), LogLevel::WARNING);
+
+ return false;
+ }
+
+ /**
+ * Handle subscription created event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The subscription resource data.
+ * @return void
+ */
+ protected function handle_subscription_created(array $resource): void {
+
+ $subscription_id = $resource['id'] ?? '';
+ $this->log(sprintf('Subscription created: %s', $subscription_id));
+
+ // Subscription created is usually handled during checkout flow
+ // This webhook is mainly for logging/verification
+ }
+
+ /**
+ * Handle subscription activated event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The subscription resource data.
+ * @return void
+ */
+ protected function handle_subscription_activated(array $resource): void {
+
+ $subscription_id = $resource['id'] ?? '';
+
+ $membership = $this->get_membership_by_subscription($subscription_id);
+
+ if (! $membership) {
+ $this->log(sprintf('Subscription activated but no membership found: %s', $subscription_id), LogLevel::WARNING);
+ return;
+ }
+
+ // Update membership status if needed
+ if ($membership->get_status() !== Membership_Status::ACTIVE) {
+ $membership->set_status(Membership_Status::ACTIVE);
+ $membership->save();
+
+ $this->log(sprintf('Membership %d activated via webhook', $membership->get_id()));
+ }
+ }
+
+ /**
+ * Handle subscription updated event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The subscription resource data.
+ * @return void
+ */
+ protected function handle_subscription_updated(array $resource): void {
+
+ $subscription_id = $resource['id'] ?? '';
+ $this->log(sprintf('Subscription updated: %s', $subscription_id));
+
+ // Handle any subscription updates as needed
+ }
+
+ /**
+ * Handle subscription cancelled event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The subscription resource data.
+ * @return void
+ */
+ protected function handle_subscription_cancelled(array $resource): void {
+
+ $subscription_id = $resource['id'] ?? '';
+
+ $membership = $this->get_membership_by_subscription($subscription_id);
+
+ if (! $membership) {
+ $this->log(sprintf('Subscription cancelled but no membership found: %s', $subscription_id), LogLevel::WARNING);
+ return;
+ }
+
+ // Cancel at end of period
+ $membership->set_auto_renew(false);
+ $membership->save();
+
+ $this->log(sprintf('Membership %d set to not auto-renew after cancellation', $membership->get_id()));
+ }
+
+ /**
+ * Handle subscription suspended event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The subscription resource data.
+ * @return void
+ */
+ protected function handle_subscription_suspended(array $resource): void {
+
+ $subscription_id = $resource['id'] ?? '';
+
+ $membership = $this->get_membership_by_subscription($subscription_id);
+
+ if (! $membership) {
+ $this->log(sprintf('Subscription suspended but no membership found: %s', $subscription_id), LogLevel::WARNING);
+ return;
+ }
+
+ $membership->set_status(Membership_Status::ON_HOLD);
+ $membership->save();
+
+ $this->log(sprintf('Membership %d suspended via webhook', $membership->get_id()));
+ }
+
+ /**
+ * Handle subscription payment failed event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The subscription resource data.
+ * @return void
+ */
+ protected function handle_subscription_payment_failed(array $resource): void {
+
+ $subscription_id = $resource['id'] ?? '';
+
+ $membership = $this->get_membership_by_subscription($subscription_id);
+
+ if (! $membership) {
+ $this->log(sprintf('Subscription payment failed but no membership found: %s', $subscription_id), LogLevel::WARNING);
+ return;
+ }
+
+ $this->log(sprintf('Payment failed for membership %d, subscription %s', $membership->get_id(), $subscription_id));
+
+ // Optionally record a failed payment
+ // The membership status might be updated by PayPal's retry logic
+ }
+
+ /**
+ * Handle payment sale completed event.
+ *
+ * This is triggered for subscription payments.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The sale resource data.
+ * @return void
+ */
+ protected function handle_payment_completed(array $resource): void {
+
+ $sale_id = $resource['id'] ?? '';
+ $billing_id = $resource['billing_agreement_id'] ?? '';
+ $custom_id = $resource['custom'] ?? ($resource['custom_id'] ?? '');
+ $amount = $resource['amount']['total'] ?? ($resource['amount']['value'] ?? 0);
+ $currency = $resource['amount']['currency'] ?? ($resource['amount']['currency_code'] ?? 'USD');
+
+ $this->log(sprintf('Payment completed: %s, Amount: %s %s', $sale_id, $amount, $currency));
+
+ // Try to find membership by billing agreement (subscription ID)
+ $membership = null;
+ if (! empty($billing_id)) {
+ $membership = $this->get_membership_by_subscription($billing_id);
+ }
+
+ // Fallback to custom_id parsing
+ if (! $membership && ! empty($custom_id)) {
+ $custom_parts = explode('|', $custom_id);
+ if (count($custom_parts) >= 2) {
+ $membership = wu_get_membership((int) $custom_parts[1]);
+ }
+ }
+
+ if (! $membership) {
+ $this->log(sprintf('Payment completed but no membership found for sale: %s', $sale_id), LogLevel::WARNING);
+ return;
+ }
+
+ // Check if this is a renewal payment (not initial)
+ $existing_payment = wu_get_payment_by('gateway_payment_id', $sale_id);
+
+ if ($existing_payment) {
+ $this->log(sprintf('Payment %s already recorded', $sale_id));
+ return;
+ }
+
+ // Create renewal payment
+ $payment_data = [
+ 'customer_id' => $membership->get_customer_id(),
+ 'membership_id' => $membership->get_id(),
+ 'gateway' => 'paypal-rest',
+ 'gateway_payment_id' => $sale_id,
+ 'currency' => $currency,
+ 'subtotal' => (float) $amount,
+ 'total' => (float) $amount,
+ 'status' => Payment_Status::COMPLETED,
+ 'product_id' => $membership->get_plan_id(),
+ ];
+
+ $payment = wu_create_payment($payment_data);
+
+ if (is_wp_error($payment)) {
+ $this->log(sprintf('Failed to create renewal payment: %s', $payment->get_error_message()), LogLevel::ERROR);
+ return;
+ }
+
+ // Update membership
+ $membership->add_to_times_billed(1);
+ $membership->renew(false);
+
+ $this->log(sprintf('Renewal payment created: %d for membership %d', $payment->get_id(), $membership->get_id()));
+ }
+
+ /**
+ * Handle capture completed event.
+ *
+ * This is triggered for one-time payments.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The capture resource data.
+ * @return void
+ */
+ protected function handle_capture_completed(array $resource): void {
+
+ $capture_id = $resource['id'] ?? '';
+ $this->log(sprintf('Capture completed: %s', $capture_id));
+
+ // Capture completed is usually handled during the confirmation flow
+ // This webhook is for verification/edge cases
+ }
+
+ /**
+ * Handle capture refunded event.
+ *
+ * @since 2.0.0
+ *
+ * @param array $resource The refund resource data.
+ * @return void
+ */
+ protected function handle_capture_refunded(array $resource): void {
+
+ $refund_id = $resource['id'] ?? '';
+ $capture_id = '';
+ $amount = $resource['amount']['value'] ?? 0;
+
+ // Find the original capture ID from links
+ foreach ($resource['links'] ?? [] as $link) {
+ if ('up' === $link['rel']) {
+ // Extract capture ID from the link
+ preg_match('/captures\/([A-Z0-9]+)/', $link['href'], $matches);
+ $capture_id = $matches[1] ?? '';
+ break;
+ }
+ }
+
+ $this->log(sprintf('Refund processed: %s for capture %s, Amount: %s', $refund_id, $capture_id, $amount));
+
+ if (empty($capture_id)) {
+ return;
+ }
+
+ // Find the payment
+ $payment = wu_get_payment_by('gateway_payment_id', $capture_id);
+
+ if (! $payment) {
+ $this->log(sprintf('Refund webhook: payment not found for capture %s', $capture_id), LogLevel::WARNING);
+ return;
+ }
+
+ // Update payment status if fully refunded
+ if ($amount >= $payment->get_total()) {
+ $payment->set_status(Payment_Status::REFUND);
+ $payment->save();
+ $this->log(sprintf('Payment %d marked as refunded', $payment->get_id()));
+ }
+ }
+
+ /**
+ * Get membership by PayPal subscription ID.
+ *
+ * @since 2.0.0
+ *
+ * @param string $subscription_id The PayPal subscription ID.
+ * @return \WP_Ultimo\Models\Membership|null
+ */
+ protected function get_membership_by_subscription(string $subscription_id) {
+
+ if (empty($subscription_id)) {
+ return null;
+ }
+
+ return wu_get_membership_by('gateway_subscription_id', $subscription_id);
+ }
+
+ /**
+ * Log a message.
+ *
+ * @since 2.0.0
+ *
+ * @param string $message The message to log.
+ * @param string $level Log level.
+ * @return void
+ */
+ protected function log(string $message, string $level = 'info'): void {
+
+ wu_log_add('paypal', '[Webhook] ' . $message, $level);
+ }
+}
diff --git a/inc/managers/class-gateway-manager.php b/inc/managers/class-gateway-manager.php
index ab199a5d..36e4ea9f 100644
--- a/inc/managers/class-gateway-manager.php
+++ b/inc/managers/class-gateway-manager.php
@@ -19,6 +19,8 @@
use WP_Ultimo\Gateways\Stripe_Gateway;
use WP_Ultimo\Gateways\Stripe_Checkout_Gateway;
use WP_Ultimo\Gateways\PayPal_Gateway;
+use WP_Ultimo\Gateways\PayPal_REST_Gateway;
+use WP_Ultimo\Gateways\PayPal_Webhook_Handler;
use WP_Ultimo\Gateways\Manual_Gateway;
// Exit if accessed directly
@@ -412,10 +414,21 @@ public function add_default_gateways(): void {
wu_register_gateway('stripe-checkout', __('Stripe Checkout', 'ultimate-multisite'), $stripe_checkout_desc, Stripe_Checkout_Gateway::class);
/*
- * PayPal Payments
+ * PayPal Payments (REST API - Modern)
*/
- $paypal_desc = __('PayPal is the leading provider in checkout solutions and it is the easier way to get your network subscriptions going.', 'ultimate-multisite');
- wu_register_gateway('paypal', __('PayPal', 'ultimate-multisite'), $paypal_desc, PayPal_Gateway::class);
+ $paypal_rest_desc = __('Modern PayPal integration with Connect with PayPal onboarding. Recommended for new setups.', 'ultimate-multisite');
+ wu_register_gateway('paypal-rest', __('PayPal', 'ultimate-multisite'), $paypal_rest_desc, PayPal_REST_Gateway::class);
+
+ /*
+ * PayPal Payments (Legacy NVP API)
+ */
+ $paypal_desc = __('PayPal Express Checkout (Legacy). Uses username/password/signature authentication. For existing integrations only.', 'ultimate-multisite');
+ wu_register_gateway('paypal', __('PayPal (Legacy)', 'ultimate-multisite'), $paypal_desc, PayPal_Gateway::class);
+
+ /*
+ * Initialize PayPal REST webhook handler
+ */
+ PayPal_Webhook_Handler::get_instance()->init();
/*
* Manual Payments
@@ -527,11 +540,11 @@ public function install_hooks($class_name): void {
add_action("wu_{$gateway_id}_process_webhooks", [$gateway, 'process_webhooks']);
- add_action("wu_{$gateway_id}_remote_payment_url", [$gateway, 'get_payment_url_on_gateway']);
+ add_filter("wu_{$gateway_id}_remote_payment_url", [$gateway, 'get_payment_url_on_gateway']);
- add_action("wu_{$gateway_id}_remote_subscription_url", [$gateway, 'get_subscription_url_on_gateway']);
+ add_filter("wu_{$gateway_id}_remote_subscription_url", [$gateway, 'get_subscription_url_on_gateway']);
- add_action("wu_{$gateway_id}_remote_customer_url", [$gateway, 'get_customer_url_on_gateway']);
+ add_filter("wu_{$gateway_id}_remote_customer_url", [$gateway, 'get_customer_url_on_gateway']);
/*
* Renders the gateway fields.
@@ -540,7 +553,7 @@ public function install_hooks($class_name): void {
'wu_checkout_gateway_fields',
function () use ($gateway) {
- $field_content = call_user_func([$gateway, 'fields']);
+ $field_content = $gateway->fields();
ob_start();