wp-update-server-plugin/inc/class-paypal-connect.php
David Stone e8193bf3ea
feat(paypal): add comprehensive logging to PayPal Connect proxy (#29)
* chore: add wp-cli.yml and local dev environment docs

Point wp-cli at shared WordPress 7.0-RC2 multisite dev install at
../wordpress (wordpress.local:8080). Documents reset workflow and
WP-CLI usage in AGENTS.md.

* feat(paypal): add comprehensive logging to PayPal Connect proxy

Previously only the deauthorize endpoint logged anything. All other
endpoints (oauth/init, oauth/verify, partner-token) were silent, making
it impossible to debug failed PayPal API calls in production.

This commit adds:
- Protected log() helper with consistent '[PayPal Connect]' prefix and
  JSON-encoded context data
- get_debug_id() helper to extract the PayPal-Debug-Id response header
  from every PayPal API call (the key value PayPal support and the
  integration review team ask for by name)
- Request-received and outcome log entries on all handlers
- Error logging with PayPal-Debug-Id on all failed PayPal API calls:
  - /v1/oauth2/token (partner access token)
  - /v2/customer/partner-referrals
  - /v1/customer/partners/{partner_id}/merchant-integrations/{merchant_id}
- Success logging with debug ID and key response fields

Sensitive values (access tokens, client secrets) are never logged.

Context: the PayPal integration review requires debug IDs from test
API calls. Without server-side logging, the only way to get them was
to instrument the plugin side, which misses proxy-side failures entirely.
2026-04-08 14:50:54 -06:00

709 lines
20 KiB
PHP

<?php
/**
* PayPal Connect Proxy.
*
* Handles PayPal Partner Referrals API on behalf of customer sites,
* keeping partner credentials secure on the ultimatemultisite.com server.
*
* Mirrors the Stripe Connect proxy pattern at /wp-json/stripe-connect/v1.
*
* REST API namespace: paypal-connect/v1
*
* Endpoints:
* POST /oauth/init - Create partner referral URL for merchant onboarding
* POST /oauth/verify - Verify merchant integration status after onboarding
* POST /partner-token - Get a short-lived partner access token for platform fees
* POST /deauthorize - Notify proxy that a site has disconnected
*
* @package WP_Update_Server_Plugin
* @since 1.0.0
*/
namespace WP_Update_Server_Plugin;
defined('ABSPATH') || exit;
/**
* PayPal Connect proxy class.
*/
class PayPal_Connect {
/**
* REST API namespace.
*
* @var string
*/
const API_NAMESPACE = 'paypal-connect/v1';
/**
* Partner Attribution ID (BN Code).
*
* @var string
*/
const BN_CODE = 'ULTIMATE_SP_PPCP';
/**
* Constructor.
*/
public function __construct() {
add_action('rest_api_init', [$this, 'register_routes']);
}
/**
* Log a PayPal Connect proxy event.
*
* Writes to the PHP error log with a consistent prefix so entries are easy
* to grep. Sensitive values (access tokens, client secrets, credentials)
* must NEVER be passed to this method — only request metadata, response
* codes, PayPal debug IDs, and error messages.
*
* @param string $message The message to log.
* @param array $context Optional context data (will be JSON-encoded).
* @return void
*/
protected function log(string $message, array $context = []): void {
$line = '[PayPal Connect] ' . $message;
if (! empty($context)) {
$line .= ' ' . wp_json_encode($context);
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log($line);
}
/**
* Extract the PayPal-Debug-Id header from a response.
*
* PayPal returns this header on every API response. It is the single most
* important value for debugging failed calls — PayPal support and the
* integration review team ask for it by name.
*
* @param array|\WP_Error $response The wp_remote_* response.
* @return string The debug ID, or empty string if not present.
*/
protected function get_debug_id($response): string {
if (is_wp_error($response)) {
return '';
}
$debug_id = wp_remote_retrieve_header($response, 'paypal-debug-id');
return is_string($debug_id) ? $debug_id : '';
}
/**
* Register REST API routes.
*
* @return void
*/
public function register_routes(): void {
register_rest_route(
self::API_NAMESPACE,
'/oauth/init',
[
'methods' => 'POST',
'callback' => [$this, 'handle_oauth_init'],
'permission_callback' => '__return_true',
]
);
register_rest_route(
self::API_NAMESPACE,
'/oauth/verify',
[
'methods' => 'POST',
'callback' => [$this, 'handle_oauth_verify'],
'permission_callback' => '__return_true',
]
);
register_rest_route(
self::API_NAMESPACE,
'/partner-token',
[
'methods' => 'POST',
'callback' => [$this, 'handle_partner_token'],
'permission_callback' => '__return_true',
]
);
register_rest_route(
self::API_NAMESPACE,
'/deauthorize',
[
'methods' => 'POST',
'callback' => [$this, 'handle_deauthorize'],
'permission_callback' => '__return_true',
]
);
register_rest_route(
self::API_NAMESPACE,
'/status',
[
'methods' => 'GET',
'callback' => [$this, 'handle_status'],
'permission_callback' => '__return_true',
]
);
}
/**
* Get PayPal API base URL.
*
* @param bool $test_mode Whether to use sandbox.
* @return string
*/
public function get_api_base_url(bool $test_mode): string {
return $test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
}
/**
* Get partner credentials for a given mode.
*
* Reads from constants defined in wp-config.php on the proxy server.
*
* @param bool $test_mode Whether to use sandbox credentials.
* @return array{client_id: string, client_secret: string, merchant_id: string}
*/
public function get_partner_credentials(bool $test_mode): array {
if ($test_mode) {
return [
'client_id' => defined('WU_PAYPAL_SANDBOX_PARTNER_CLIENT_ID') ? WU_PAYPAL_SANDBOX_PARTNER_CLIENT_ID : '',
'client_secret' => defined('WU_PAYPAL_SANDBOX_PARTNER_CLIENT_SECRET') ? WU_PAYPAL_SANDBOX_PARTNER_CLIENT_SECRET : '',
'merchant_id' => defined('WU_PAYPAL_SANDBOX_PARTNER_MERCHANT_ID') ? WU_PAYPAL_SANDBOX_PARTNER_MERCHANT_ID : '',
];
}
return [
'client_id' => defined('WU_PAYPAL_PARTNER_CLIENT_ID') ? WU_PAYPAL_PARTNER_CLIENT_ID : '',
'client_secret' => defined('WU_PAYPAL_PARTNER_CLIENT_SECRET') ? WU_PAYPAL_PARTNER_CLIENT_SECRET : '',
'merchant_id' => defined('WU_PAYPAL_PARTNER_MERCHANT_ID') ? WU_PAYPAL_PARTNER_MERCHANT_ID : '',
];
}
/**
* Get a partner access token from PayPal.
*
* @param bool $test_mode Whether to use sandbox.
* @return string|\WP_Error
*/
public function get_partner_access_token(bool $test_mode) {
$cache_key = 'wu_pp_proxy_token_' . ($test_mode ? 'sandbox' : 'live');
$cached_token = get_transient($cache_key);
if ($cached_token) {
return $cached_token;
}
$credentials = $this->get_partner_credentials($test_mode);
if (empty($credentials['client_id']) || empty($credentials['client_secret'])) {
return new \WP_Error(
'missing_credentials',
'PayPal partner credentials are not configured on the proxy server.'
);
}
$response = wp_remote_post(
$this->get_api_base_url($test_mode) . '/v1/oauth2/token',
[
'headers' => [
'Authorization' => 'Basic ' . base64_encode($credentials['client_id'] . ':' . $credentials['client_secret']), // phpcs:ignore
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => 'grant_type=client_credentials',
'timeout' => 30,
]
);
if (is_wp_error($response)) {
$this->log('Partner access token request failed (transport error)', [
'mode' => $test_mode ? 'sandbox' : 'live',
'error' => $response->get_error_message(),
]);
return $response;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$code = wp_remote_retrieve_response_code($response);
$debug_id = $this->get_debug_id($response);
if (200 !== $code || empty($body['access_token'])) {
$error_msg = $body['error_description'] ?? 'Failed to obtain PayPal access token';
$this->log('Partner access token request failed', [
'mode' => $test_mode ? 'sandbox' : 'live',
'status' => $code,
'debug_id' => $debug_id,
'error' => $error_msg,
]);
return new \WP_Error('token_error', $error_msg);
}
$this->log('Partner access token obtained', [
'mode' => $test_mode ? 'sandbox' : 'live',
'debug_id' => $debug_id,
]);
$expires_in = isset($body['expires_in']) ? (int) $body['expires_in'] - 300 : 3300;
set_transient($cache_key, $body['access_token'], $expires_in);
return $body['access_token'];
}
/**
* Handle POST /oauth/init
*
* Creates a PayPal Partner Referral URL for merchant onboarding.
* The customer site sends its return URL; the proxy calls PayPal
* with the partner credentials and returns the onboarding link.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_oauth_init(\WP_REST_Request $request): \WP_REST_Response {
$body = $request->get_json_params();
$return_url = $body['returnUrl'] ?? '';
$test_mode = (bool) ($body['testMode'] ?? true);
$this->log('oauth/init received', [
'mode' => $test_mode ? 'sandbox' : 'live',
'return_url' => $return_url,
]);
if (empty($return_url)) {
$this->log('oauth/init rejected: missing returnUrl');
return new \WP_REST_Response(
['error' => 'returnUrl is required'],
400
);
}
$access_token = $this->get_partner_access_token($test_mode);
if (is_wp_error($access_token)) {
return new \WP_REST_Response(
['error' => $access_token->get_error_message()],
500
);
}
$credentials = $this->get_partner_credentials($test_mode);
// Generate a tracking ID for this onboarding
$tracking_id = 'wu_' . wp_generate_uuid4();
// Store tracking ID for later verification (24 hours)
set_transient(
'wu_pp_onboarding_' . $tracking_id,
[
'started' => time(),
'test_mode' => $test_mode,
'return_url' => $return_url,
],
DAY_IN_SECONDS
);
// Append tracking_id to the return URL so the customer site can verify
$return_url_with_tracking = add_query_arg('tracking_id', $tracking_id, $return_url);
// Build the partner referral request
$referral_data = [
'tracking_id' => $tracking_id,
'partner_config_override' => [
'return_url' => $return_url_with_tracking,
],
'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',
'ACCESS_MERCHANT_INFORMATION',
],
],
],
],
],
],
'products' => ['PPCP'],
'legal_consents' => [
[
'type' => 'SHARE_DATA_CONSENT',
'granted' => true,
],
],
];
$response = wp_remote_post(
$this->get_api_base_url($test_mode) . '/v2/customer/partner-referrals',
[
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
'PayPal-Partner-Attribution-Id' => self::BN_CODE,
],
'body' => wp_json_encode($referral_data),
'timeout' => 30,
]
);
if (is_wp_error($response)) {
$this->log('partner-referrals request failed (transport error)', [
'tracking_id' => $tracking_id,
'error' => $response->get_error_message(),
]);
return new \WP_REST_Response(
['error' => 'Failed to create partner referral: ' . $response->get_error_message()],
500
);
}
$resp_body = json_decode(wp_remote_retrieve_body($response), true);
$resp_code = wp_remote_retrieve_response_code($response);
$debug_id = $this->get_debug_id($response);
if (201 !== $resp_code || empty($resp_body['links'])) {
$error_msg = $resp_body['message'] ?? 'Failed to create partner referral';
$this->log('partner-referrals returned non-201', [
'tracking_id' => $tracking_id,
'status' => $resp_code,
'debug_id' => $debug_id,
'error' => $error_msg,
'details' => $resp_body['details'] ?? null,
]);
return new \WP_REST_Response(
['error' => $error_msg],
500
);
}
// Find the action_url link
$action_url = '';
foreach ($resp_body['links'] as $link) {
if ('action_url' === $link['rel']) {
$action_url = $link['href'];
break;
}
}
if (empty($action_url)) {
$this->log('partner-referrals missing action_url link', [
'tracking_id' => $tracking_id,
'debug_id' => $debug_id,
]);
return new \WP_REST_Response(
['error' => 'No action URL returned from PayPal'],
500
);
}
$this->log('partner-referrals succeeded', [
'tracking_id' => $tracking_id,
'debug_id' => $debug_id,
'mode' => $test_mode ? 'sandbox' : 'live',
]);
return new \WP_REST_Response(
[
'actionUrl' => $action_url,
'trackingId' => $tracking_id,
],
200
);
}
/**
* Handle POST /oauth/verify
*
* After merchant completes onboarding and returns to the customer site,
* the customer site calls this endpoint to verify the merchant's status
* using the partner credentials (which the customer site doesn't have).
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_oauth_verify(\WP_REST_Request $request): \WP_REST_Response {
$body = $request->get_json_params();
$merchant_id = $body['merchantId'] ?? '';
$tracking_id = $body['trackingId'] ?? '';
$test_mode = (bool) ($body['testMode'] ?? true);
$this->log('oauth/verify received', [
'merchant_id' => $merchant_id,
'tracking_id' => $tracking_id,
'mode' => $test_mode ? 'sandbox' : 'live',
]);
if (empty($merchant_id) || empty($tracking_id)) {
$this->log('oauth/verify rejected: missing merchantId or trackingId');
return new \WP_REST_Response(
['error' => 'merchantId and trackingId are required'],
400
);
}
// Verify the tracking ID was created by us
$onboarding_data = get_transient('wu_pp_onboarding_' . $tracking_id);
if (! $onboarding_data) {
$this->log('oauth/verify rejected: invalid or expired tracking ID', [
'tracking_id' => $tracking_id,
]);
return new \WP_REST_Response(
['error' => 'Invalid or expired tracking ID'],
400
);
}
// Clean up the tracking transient
delete_transient('wu_pp_onboarding_' . $tracking_id);
$access_token = $this->get_partner_access_token($test_mode);
if (is_wp_error($access_token)) {
return new \WP_REST_Response(
['error' => $access_token->get_error_message()],
500
);
}
$credentials = $this->get_partner_credentials($test_mode);
if (empty($credentials['merchant_id'])) {
// Without partner merchant ID, record the onboarding event and return basic success.
$this->log('oauth/verify: partner merchant_id not configured, skipping merchant-integrations call', [
'merchant_id' => $merchant_id,
'mode' => $test_mode ? 'sandbox' : 'live',
]);
PayPal_Merchants_Table::upsert_merchant($merchant_id, $tracking_id, $test_mode);
return new \WP_REST_Response(
[
'merchantId' => $merchant_id,
'paymentsReceivable' => true,
'emailConfirmed' => true,
],
200
);
}
$response = wp_remote_get(
$this->get_api_base_url($test_mode) . '/v1/customer/partners/' . $credentials['merchant_id'] . '/merchant-integrations/' . $merchant_id,
[
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
'PayPal-Partner-Attribution-Id' => self::BN_CODE,
],
'timeout' => 30,
]
);
if (is_wp_error($response)) {
$this->log('merchant-integrations request failed (transport error)', [
'merchant_id' => $merchant_id,
'error' => $response->get_error_message(),
]);
return new \WP_REST_Response(
['error' => 'Failed to verify merchant: ' . $response->get_error_message()],
500
);
}
$resp_body = json_decode(wp_remote_retrieve_body($response), true);
$resp_code = wp_remote_retrieve_response_code($response);
$debug_id = $this->get_debug_id($response);
if (200 !== $resp_code) {
$error_msg = $resp_body['message'] ?? 'Failed to verify merchant status';
$this->log('merchant-integrations returned non-200', [
'merchant_id' => $merchant_id,
'status' => $resp_code,
'debug_id' => $debug_id,
'error' => $error_msg,
'details' => $resp_body['details'] ?? null,
]);
return new \WP_REST_Response(
['error' => $error_msg],
$resp_code
);
}
// Record the successful onboarding event.
$verified_merchant_id = $resp_body['merchant_id'] ?? $merchant_id;
$verified_tracking_id = $resp_body['tracking_id'] ?? $tracking_id;
$this->log('merchant-integrations succeeded', [
'merchant_id' => $verified_merchant_id,
'debug_id' => $debug_id,
'payments_receivable' => $resp_body['payments_receivable'] ?? false,
'email_confirmed' => $resp_body['primary_email_confirmed'] ?? false,
]);
PayPal_Merchants_Table::upsert_merchant($verified_merchant_id, $verified_tracking_id, $test_mode);
return new \WP_REST_Response(
[
'merchantId' => $verified_merchant_id,
'trackingId' => $verified_tracking_id,
'paymentsReceivable' => $resp_body['payments_receivable'] ?? false,
'emailConfirmed' => $resp_body['primary_email_confirmed'] ?? false,
],
200
);
}
/**
* Handle POST /partner-token
*
* Returns a short-lived partner access token and partner client ID.
* Used by the plugin to create PayPal orders with platform fees
* via the PayPal-Auth-Assertion header.
*
* The partner access token alone cannot make payments — it requires
* a PayPal-Auth-Assertion header with a merchant's payer_id that was
* onboarded through our partner referral.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_partner_token(\WP_REST_Request $request): \WP_REST_Response {
$body = $request->get_json_params();
$test_mode = (bool) ($body['testMode'] ?? true);
$this->log('partner-token received', [
'mode' => $test_mode ? 'sandbox' : 'live',
]);
$access_token = $this->get_partner_access_token($test_mode);
if (is_wp_error($access_token)) {
return new \WP_REST_Response(
['error' => $access_token->get_error_message()],
500
);
}
$credentials = $this->get_partner_credentials($test_mode);
$this->log('partner-token issued', [
'mode' => $test_mode ? 'sandbox' : 'live',
]);
return new \WP_REST_Response(
[
'access_token' => $access_token,
'partner_client_id' => $credentials['client_id'],
'expires_in' => 3300, // ~55 minutes (conservative)
],
200
);
}
/**
* Handle GET /status
*
* Returns whether PayPal OAuth Connect is available.
* OAuth is enabled only when partner credentials are configured
* on the proxy server. Customer sites cache this response.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_status(\WP_REST_Request $request): \WP_REST_Response {
$sandbox_creds = $this->get_partner_credentials(true);
$live_creds = $this->get_partner_credentials(false);
$sandbox_ready = ! empty($sandbox_creds['client_id']) && ! empty($sandbox_creds['client_secret']);
$live_ready = ! empty($live_creds['client_id']) && ! empty($live_creds['client_secret']);
return new \WP_REST_Response(
[
'oauth_enabled' => $sandbox_ready || $live_ready,
'sandbox_ready' => $sandbox_ready,
'live_ready' => $live_ready,
],
200
);
}
/**
* Handle POST /deauthorize
*
* Notification that a customer site has disconnected.
* Records the disconnect event in the analytics table and logs for auditing.
*
* @param \WP_REST_Request $request The request.
* @return \WP_REST_Response
*/
public function handle_deauthorize(\WP_REST_Request $request): \WP_REST_Response {
$body = $request->get_json_params();
$site_url = $body['siteUrl'] ?? 'unknown';
$merchant_id = $body['merchantId'] ?? '';
$test_mode = (bool) ($body['testMode'] ?? true);
$mode = $test_mode ? 'sandbox' : 'live';
$this->log('deauthorize received', [
'site_url' => $site_url,
'merchant_id' => $merchant_id ?: 'unknown',
'mode' => $mode,
]);
// Record the disconnect event in the analytics table when a merchant ID is provided.
if ( ! empty($merchant_id)) {
PayPal_Merchants_Table::mark_disconnected($merchant_id, $test_mode);
}
// Log the disconnect for auditing.
$this->log('Site disconnected', [
'site_url' => $site_url,
'merchant_id' => $merchant_id ?: 'unknown',
'mode' => $mode,
]);
return new \WP_REST_Response(
['success' => true],
200
);
}
}