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( + '

%2$s

', + 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();