method_title = esc_html__( 'PayPal', 'learnpress' ); $this->method_description = esc_html__( 'Make a payment via Paypal.', 'learnpress' ); $this->icon = LP_PLUGIN_URL . 'assets/images/paypal-logo-preview.png'; $this->title = esc_html__( 'PayPal', 'learnpress' ); $this->description = esc_html__( 'Pay with PayPal', 'learnpress' ); parent::__construct(); $this->init(); } /** * Init. * @throws Exception */ public function init() { if ( $this->is_enabled() ) { if ( $this->settings->get( 'paypal_sandbox', 'no' ) === 'no' ) { $this->paypal_url = $this->paypal_live_url; $this->paypal_payment_url = $this->paypal_payment_live_url; $this->paypal_email = $this->settings->get( 'paypal_email' ); $this->api_url = $this->api_live_url; // use for PayPal rest api } else { $this->paypal_url = $this->paypal_sandbox_url; $this->paypal_payment_url = $this->paypal_payment_sandbox_url; $this->paypal_email = $this->settings->get( 'paypal_sandbox_email' ); $this->api_url = $this->api_sandbox_url; // use for PayPal rest api } // Use PayPal rest api $this->client_id = $this->settings->get( 'app_client_id' ); $this->client_secret = $this->settings->get( 'app_client_secret' ); $this->subscription_webhook_id = (string) $this->settings->get( 'subscription_webhook_id', '' ); } else { return; } $this->check_webhook_callback(); } /** * Check whether PayPal subscription mode is enabled in gateway settings. * * @return bool */ public function is_subscription_enabled(): bool { return $this->settings->get( 'enable_subscriptions', 'no' ) === 'yes'; } /** * Listen callback, webhook payment from PayPal. */ public function check_webhook_callback() { if ( ! isset( $_GET['paypay_express_checkout'] ) ) { return; } $paypal_order_id = LP_Request::get_param( 'token' ); if ( empty( $paypal_order_id ) ) { return; } try { $this->capture_payment_for_order( $paypal_order_id ); } catch ( Throwable $e ) { LP_Debug::error_log( $e ); } } /** * https://developer.paypal.com/api/nvp-soap/ipn/IPNImplementation/#link-ipnlistenerrequestresponseflow * Check validate IPN. * * @return bool * @deprecated 4.3.8 */ /*public function validate_ipn(): bool { $validate_ipn = array( 'cmd' => '_notify-validate' ); $validate_ipn += wp_unslash( $_POST ); $params = array( 'body' => $validate_ipn, 'timeout' => 60, ); // Post back to get a response $response = wp_remote_post( $this->paypal_payment_url, $params ); if ( ! is_wp_error( $response ) && $response['response']['code'] >= 200 && $response['response']['code'] < 300 ) { $body = wp_remote_retrieve_body( $response ); if ( 'VERIFIED' === $body ) { return true; } } else { error_log( 'Error code paypal validate ipn: ' . $response['response']['code'] ); error_log( 'Error code paypal validate ipn: ' . $response->get_error_message() ); } return false; }*/ /** * Handle payment. * * @param int $order_id * * @return array * @throws Exception */ public function process_payment( $order_id = 0 ): array { $order = new LP_Order( $order_id ); //$subscription_data = $this->resolve_subscription_payment_data( $order ); $subscription_data = $this->is_data_for_payment_subscription( $order ); if ( ! empty( $subscription_data ) ) { $subscription_res = $this->pay_via_subscription( $order, $subscription_data ); $paypal_payment_url = $subscription_res['redirect_url'] ?? ''; } else { $paypal_payment_url = $this->create_payment_url( $order ); } return [ 'result' => 'success', 'redirect' => $paypal_payment_url, ]; } /** * Prepare args to send to PayPal * * @param LP_Order $order * * @return array * @since 3.0.0 * @version 1.0.1 * @deprecated 4.3.8 */ /*public function get_paypal_args( LP_Order $order ): array { $checkout = LearnPress::instance()->checkout(); $custom = array( 'order_id' => $order->get_id(), 'order_key' => $order->get_order_key(), 'checkout_email' => $checkout->get_checkout_email(), ); $lp_cart = LearnPress::instance()->get_cart(); $cart_total = $lp_cart->calculate_totals(); $item_arg = array( 'item_name_1' => $order->get_order_number(), 'quantity_1' => 1, 'amount_1' => $cart_total->total, ); $args = array_merge( array( 'cmd' => '_cart', 'business' => $this->paypal_email, 'no_note' => 1, 'currency_code' => learn_press_get_currency(), 'charset' => 'utf-8', 'rm' => is_ssl() ? 2 : 1, 'upload' => 1, 'return' => esc_url_raw( $this->get_return_url( $order ) ), 'cancel_return' => esc_url_raw( learn_press_is_enable_cart() ? learn_press_get_page_link( 'cart' ) : get_home_url() ), 'bn' => 'LearnPress_Cart', 'custom' => json_encode( $custom ), 'notify_url' => get_home_url() . '/?paypal_notify=1', ), $item_arg ); return apply_filters( 'learn-press/paypal/args', $args ); }*/ /** * Get access token from PayPal * * Check token expire time before get new token * * @throws Exception * @since 4.2.4 * @version 1.0.1 */ public function get_access_token() { $client_id = $this->client_id; $client_secret = $this->client_secret; if ( empty( $client_id ) ) { throw new Exception( __( 'Paypal Client id is required.', 'learnpress' ) ); } if ( ! $client_secret ) { throw new Exception( __( 'Paypal Client secret is required', 'learnpress' ) ); } // Check token expire $token_exist = LP_Settings::get_option( self::PAYPAL_TOKEN ); if ( ! empty( $token_exist ) ) { $token_exist = LP_Helper::json_decode( $token_exist ); $five_minutes_ago = time() - 5 * 60; if ( ! empty( $token_exist->lp_time_end ) && $token_exist->lp_time_end > $five_minutes_ago ) { return $token_exist; } } $params = array( 'grant_type' => 'client_credentials' ); $response = wp_remote_post( $this->api_url . 'v1/oauth2/token', array( 'body' => $params, 'headers' => array( 'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ), ), 'timeout' => 60, ) ); $data_token_str = wp_remote_retrieve_body( $response ); $data_token = LP_Helper::json_decode( $data_token_str ); $data_token->lp_time_end = time() + $data_token->expires_in; $data_token_str = json_encode( $data_token ); if ( isset( $data_token->error ) ) { throw new Exception( $data_token->error_description ); } if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) { throw new Exception( __( 'Invalid PayPal access token', 'learnpress' ) ); } LP_Settings::update_option( self::PAYPAL_TOKEN, $data_token_str ); return $data_token; } /** * create args to create PayPal order * * @param LP_Order $order * * @return array * @since 4.2.4 * @version 1.0.2 */ public function get_order_args( LP_Order $order ): array { $lp_cart = LearnPress::instance()->get_cart(); $cart_total = $lp_cart->calculate_totals(); $order_id = $order->get_id(); $return_url = esc_url_raw( add_query_arg( 'paypay_express_checkout', 1, $this->get_return_url( $order ) ) ); $cancel_url = esc_url_raw( learn_press_is_enable_cart() ? learn_press_get_page_link( 'cart' ) : get_home_url() ); $brand_name = ! empty( get_bloginfo() ) ? get_bloginfo() : 'LearnPress'; $data = array( 'intent' => 'CAPTURE', 'purchase_units' => array( array( 'amount' => array( 'currency_code' => learn_press_get_currency(), 'value' => strval( round( $cart_total->total, 2 ) ), ), 'custom_id' => $order_id, ), ), 'payment_source' => array( 'paypal' => array( 'experience_context' => array( 'payment_method_preference' => 'UNRESTRICTED', 'brand_name' => $brand_name, 'landing_page' => 'LOGIN', 'user_action' => 'PAY_NOW', 'return_url' => $return_url, 'cancel_url' => $cancel_url, ), ), ), ); return apply_filters( 'learn-press/paypal-rest/args', $data ); } /** * Create Order PayPal and get checkout url * * @param LP_Order $order * * @return string * @throws Exception * @since 4.2.4 * @version 1.0.1 */ public function create_payment_url( LP_Order $order ): string { $checkout_url = ''; $params = $this->get_order_args( $order ); $data_token = $this->get_access_token(); $response = wp_remote_post( $this->api_url . 'v2/checkout/orders', array( 'body' => json_encode( $params ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); $result = LP_Helper::json_decode( wp_remote_retrieve_body( $response ) ); if ( isset( $result->error ) ) { throw new Exception( $result->error_description ); } /** * Error response * * https://developer.paypal.com/api/rest/reference/orders/v2/errors/ */ if ( isset( $result->name ) && isset( $result->details[0] ) ) { throw new Exception( $result->details[0]->description ); } if ( empty( $result->id ) ) { throw new Exception( __( 'Invalid Paypal checkout', 'learnpress' ) ); } foreach ( $result->links as $link ) { if ( $link->rel === 'payer-action' ) { $checkout_url = $link->href; break; } } if ( empty( $checkout_url ) ) { throw new Exception( __( 'Invalid Paypal checkout url', 'learnpress' ) ); } return $checkout_url; } /** * Capture payment for order * * @param string $paypal_order_id * https://developer.paypal.com/docs/api/orders/v2/#orders_capture * * @return bool True when capture is completed and order status is updated. * @throws Exception * @version 1.0.2 * @since 4.2.4 */ public function capture_payment_for_order( string $paypal_order_id ): bool { $data_token = $this->get_access_token(); $response = wp_remote_post( $this->api_url . 'v2/checkout/orders/' . $paypal_order_id . '/capture', array( 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, ), 'timeout' => 60, ) ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message() ); } $body = wp_remote_retrieve_body( $response ); $response_data = LP_Helper::json_decode( $body ); if ( isset( $response_data->debug_id ) ) { throw new Exception( $response_data->details[0]->description ); } $capture = $response_data->purchase_units[0]->payments->captures[0] ?? null; if ( empty( $capture ) ) { return false; } $order_id = absint( $capture->custom_id ?? 0 ); if ( $order_id <= 0 ) { return false; } $capture_id = $capture->id ?? ''; if ( empty( $capture_id ) ) { return false; } $lp_order = learn_press_get_order( $order_id ); if ( $lp_order instanceof LP_Order && $capture->status === 'COMPLETED' ) { $transaction_id = $lp_order->get_transaction_id(); if ( $transaction_id === $capture_id ) { return true; } $lp_order->payment_complete( $capture_id ); $lp_order->add_note( sprintf( __( 'PayPal payment %1$s completed at %2$s %3$s', 'learnpress' ), $capture_id, wp_date( 'Y-m-d H:i:s' ), wp_timezone_string() ) ); return true; } return false; } /** * Validate subscription payload for PayPal checkout. * * This override keeps gateway-specific assumptions close to PayPal flow * while still reusing the shared contract validation from abstract gateway. * * @param array $data * * @return array * @throws Exception * @deprecated 4.3.8 */ /*protected function validate_subscription_payload( array $data ): array { $data = parent::validate_subscription_payload( $data ); // PayPal subscription API expects a plan id string and positive quantity. $data['price_id'] = (string) $data['price_id']; $data['quantity'] = max( 1, absint( $data['quantity'] ) ); return $data; }*/ /** * Convert generic interval to PayPal interval unit. * * @param string $interval * * @return string * @deprecated 4.3.8 */ /*protected function map_paypal_interval_unit( string $interval ): string { $map = array( 'day' => 'DAY', 'week' => 'WEEK', 'month' => 'MONTH', 'year' => 'YEAR', ); return $map[ $interval ] ?? 'MONTH'; }*/ /** * Convert PayPal interval unit to LearnPress interval slug. * * @param string $interval_unit * * @return string * @deprecated 4.3.8 */ /*protected function map_paypal_interval_slug( string $interval_unit ): string { $map = array( 'DAY' => 'day', 'WEEK' => 'week', 'MONTH' => 'month', 'YEAR' => 'year', ); $interval_unit = strtoupper( sanitize_text_field( $interval_unit ) ); return $map[ $interval_unit ] ?? 'month'; }*/ /** * Build normalized summary from PayPal plan payload. * * @param array $plan_data * * @return array * @since 4.3.7 * @version 1.0.1 */ protected function build_paypal_plan_summary( array $plan_data ): array { $summary = array( 'plan_id' => $plan_data['id'] ?? '', 'status' => strtolower( (string) ( $plan_data['status'] ?? '' ) ), 'amount' => 0.0, 'currency' => '', 'interval' => '', 'interval_count' => 1, 'setup_fee' => 0.0, 'product_id' => (string) ( $plan_data['product_id'] ?? '' ), ); if ( ! empty( $plan_data['payment_preferences']['setup_fee']['value'] ) ) { $summary['setup_fee'] = (float) $plan_data['payment_preferences']['setup_fee']['value']; } if ( ! empty( $plan_data['billing_cycles'] ) && is_array( $plan_data['billing_cycles'] ) ) { foreach ( $plan_data['billing_cycles'] as $billing_cycle ) { if ( ! is_array( $billing_cycle ) ) { continue; } if ( ( $billing_cycle['tenure_type'] ?? '' ) !== 'REGULAR' ) { continue; } $summary['amount'] = (float) ( $billing_cycle['pricing_scheme']['fixed_price']['value'] ?? 0 ); $summary['currency'] = strtolower( (string) ( $billing_cycle['pricing_scheme']['fixed_price']['currency_code'] ?? '' ) ); $summary['interval'] = strtolower( (string) ( $billing_cycle['frequency']['interval_unit'] ?? '' ) ); $summary['interval_count'] = max( 1, absint( $billing_cycle['frequency']['interval_count'] ?? 1 ) ); break; } } return $summary; } /** * Create a PayPal subscription plan (creates product first if needed). * * - If `product_id` is empty, creates a new PayPal product via v1/catalogs/products API, * then creates a billing plan via v1/billing/plans API. * - Supports trial period and setup fee. * - Returns full PayPal product and plan data on success. * * @param array $data Required: name, amount, currency, interval, interval_count. * Optional: description, trial_days, setup_fee, product_id, metadata. * * @return array With keys: status, product, plan, message. * @throws Exception On API errors or validation failures. Includes full PayPal error details if available. * @since 4.3.7 * @version 1.0.1 */ public function create_plan( array $data ): array { if ( ! $this->is_subscription_enabled() ) { throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) ); } $data = $this->validate_data_plan_payload( $data ); $data_token = $this->get_access_token(); // PayPal-specific optional keys are normalized at gateway level. $description = LP_Helper::sanitize_params_submitted( $data['description'] ?? '', 'html' ); $trial_days = absint( $data['trial_days'] ?? 0 ); $setup_fee = (float) ( $data['setup_fee'] ?? 0 ); // Create product before create plan $product_id = $data['product_id'] ?? ''; $product_data = [ 'id' => $product_id ]; if ( empty( $product_id ) ) { $product_payload = array( 'name' => $data['name'] ?? '', 'type' => 'SERVICE', ); // Add description if not empty if ( ! empty( $description ) ) { $product_payload['description'] = $description; } // Call API create product $product_response = wp_remote_post( $this->api_url . 'v1/catalogs/products', array( 'body' => wp_json_encode( $product_payload ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $product_response ) ) { throw new Exception( $product_response->get_error_message() ); } $product_body = wp_remote_retrieve_body( $product_response ); $product_data = LP_Helper::json_decode( $product_body, true ); // Error create product return from PayPal if ( ! empty( $product_data['debug_id'] ) ) { throw new Exception( __( 'Create Product: ' ) . $product_data['details'][0]['description'], (int) $product_data['debug_id'] ); } if ( empty( $product_data['id'] ) ) { throw new Exception( __( 'Invalid PayPal product response.', 'learnpress' ) ); } $product_id = $product_data['id']; } $currency_code = strtoupper( (string) $data['currency'] ); $billing_cycles = array(); $sequence = 1; // Set trial period if ( $trial_days > 0 ) { $billing_cycles[] = array( 'frequency' => array( 'interval_unit' => 'DAY', 'interval_count' => $trial_days, ), 'tenure_type' => 'TRIAL', 'sequence' => 1, 'total_cycles' => 1, 'pricing_scheme' => array( 'fixed_price' => array( 'value' => '0', 'currency_code' => $currency_code, ), ), ); $sequence = 2; } // Set regular price $billing_cycles[] = array( 'frequency' => array( 'interval_unit' => strtoupper( (string) $data['interval'] ?? 'MONTH' ), 'interval_count' => max( 1, absint( $data['interval_count'] ?? 0 ) ), ), 'tenure_type' => 'REGULAR', 'sequence' => $sequence, 'total_cycles' => 0, 'pricing_scheme' => array( 'fixed_price' => array( 'value' => number_format( (float) $data['amount'], 2, '.', '' ), 'currency_code' => $currency_code, ), ), ); $plan_payload = array( 'product_id' => $product_id, 'name' => $data['name'] ?? '', 'status' => 'ACTIVE', 'billing_cycles' => $billing_cycles, 'payment_preferences' => array( 'auto_bill_outstanding' => true, 'setup_fee' => array( 'value' => number_format( $setup_fee, 2, '.', '' ), 'currency_code' => $currency_code, ), 'setup_fee_failure_action' => 'CONTINUE', 'payment_failure_threshold' => 3, ), ); // Add description if not empty if ( ! empty( $description ) ) { $plan_payload['description'] = $description; } // Call API create plan $plan_response = wp_remote_post( $this->api_url . 'v1/billing/plans', array( 'body' => wp_json_encode( $plan_payload ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $plan_response ) ) { throw new Exception( $plan_response->get_error_message() ); } $body = wp_remote_retrieve_body( $plan_response ); $response_data = LP_Helper::json_decode( $body, true ); // Error return from PayPal if ( ! empty( $response_data['debug_id'] ) ) { throw new Exception( __( 'Create Plan: ' ) . $response_data['details'][0]['description'], (int) $response_data['debug_id'] ); } if ( empty( $response_data['id'] ) ) { throw new Exception( __( 'Invalid PayPal plan response.', 'learnpress' ) ); } return array( 'status' => 'success', 'product' => $product_data, 'plan' => $response_data, 'message' => __( 'PayPal subscription plan created.', 'learnpress' ), ); } /** * List PayPal billing plans. * * @param array $args Supported: page, page_size, total_required, product_id. * * @return array * @throws Exception * @deprecated 4.3.8 */ /*public function list_plans( array $args = array() ): array { if ( ! $this->is_subscription_enabled() ) { throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) ); } $args = wp_parse_args( $args, array( 'page' => 1, 'page_size' => 20, 'total_required' => false, 'product_id' => '', ) ); $query_args = array( 'page' => max( 1, absint( $args['page'] ) ), 'page_size' => max( 1, min( 20, absint( $args['page_size'] ) ) ), 'total_required' => ! empty( $args['total_required'] ) ? 'true' : 'false', ); if ( ! empty( $args['product_id'] ) ) { $query_args['product_id'] = sanitize_text_field( wp_unslash( (string) $args['product_id'] ) ); } $data_token = $this->get_access_token(); if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) { throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) ); } $plans_response = wp_remote_get( $this->api_url . 'v1/billing/plans?' . http_build_query( $query_args ), array( 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $plans_response ) ) { throw new Exception( $plans_response->get_error_message() ); } $plans_body = LP_Helper::json_decode( wp_remote_retrieve_body( $plans_response ) ); if ( ! is_object( $plans_body ) ) { throw new Exception( __( 'Invalid PayPal plans response.', 'learnpress' ) ); } $plans = array(); $summaries = array(); if ( ! empty( $plans_body->plans ) && is_array( $plans_body->plans ) ) { foreach ( $plans_body->plans as $plan ) { if ( ! is_object( $plan ) ) { continue; } $plans[] = $plan; $summaries[] = $this->build_paypal_plan_summary( $plan ); } } return array( 'status' => 'success', 'plans' => $plans, 'summaries' => $summaries, 'total_items' => absint( $plans_body->total_items ?? count( $plans ) ), 'total_pages' => absint( $plans_body->total_pages ?? 1 ), 'message' => __( 'PayPal subscription plans fetched.', 'learnpress' ), ); }*/ /** * Get PayPal billing plan details by plan id. * * Returned `summary` is normalized for integration checks when external * code compares local subscription config with remote provider plan values. * * @param string $plan_id * * @return array * @throws Exception */ public function get_plan( string $plan_id ): array { if ( ! $this->is_subscription_enabled() ) { throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) ); } $plan_id = LP_Helper::sanitize_params_submitted( $plan_id ); if ( empty( $plan_id ) ) { throw new Exception( __( 'Missing subscription plan id.', 'learnpress' ) ); } $data_token = $this->get_access_token(); $plan_response = wp_remote_get( $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ), array( 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $plan_response ) ) { throw new Exception( $plan_response->get_error_message() ); } $plan_data = LP_Helper::json_decode( wp_remote_retrieve_body( $plan_response ), true ); if ( isset( $plan_data['debug_id'] ) ) { throw new Exception( __( 'Get plan: ', 'learnpress' ) . $plan_data['details'][0]['description'], (int) $plan_data['debug_id'] ); } $summary = $this->build_paypal_plan_summary( $plan_data ); return array( 'status' => 'success', 'plan' => $plan_data, 'summary' => $summary, 'message' => __( 'PayPal subscription plan fetched.', 'learnpress' ), ); } /** * Update PayPal billing plan values. * * Supported update fields: * - name, description, status, setup_fee * - amount (uses update-pricing-schemes endpoint for REGULAR cycle) * * @param string $plan_id * @param array $data * * @return array * @throws Exception * @since 4.3.7 * @version 1.0.1 */ public function update_plan( string $plan_id, array $data ): array { if ( ! $this->is_subscription_enabled() ) { throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) ); } $plan_id = LP_Helper::sanitize_params_submitted( $plan_id ); if ( empty( $plan_id ) ) { throw new Exception( __( 'Missing subscription plan id.', 'learnpress' ) ); } $current = $this->get_plan( $plan_id ); $current_plan = $current['plan']; $current_sum = $current['summary']; $data = $this->validate_data_plan_payload( $data ); $patches = array(); if ( ! empty( $data['name'] ?? '' ) ) { $patches[] = array( 'op' => 'replace', 'path' => '/name', 'value' => sanitize_text_field( wp_unslash( (string) $data['name'] ) ), ); } if ( ! empty( $data['description'] ?? '' ) ) { $patches[] = array( 'op' => 'replace', 'path' => '/description', 'value' => sanitize_text_field( wp_unslash( (string) $data['description'] ) ), ); } if ( ! empty( $data['status'] ?? '' ) ) { $status = strtoupper( sanitize_text_field( wp_unslash( (string) $data['status'] ) ) ); if ( ! in_array( $status, array( 'ACTIVE', 'INACTIVE' ), true ) ) { throw new Exception( __( 'Invalid PayPal subscription plan status.', 'learnpress' ) ); } $patches[] = array( 'op' => 'replace', 'path' => '/status', 'value' => $status, ); } if ( isset( $data['setup_fee'] ) && '' !== $data['setup_fee'] ) { $setup_fee = (float) $data['setup_fee']; if ( $setup_fee < 0 ) { throw new Exception( __( 'Invalid subscription setup fee.', 'learnpress' ) ); } $patches[] = array( 'op' => 'replace', 'path' => '/payment_preferences/setup_fee', 'value' => array( 'value' => number_format( $setup_fee, 2, '.', '' ), 'currency_code' => strtoupper( $current_sum['currency'] ?? '' ), ), ); } if ( ! empty( $patches ) ) { $data_token = $this->get_access_token(); $patch_response = wp_remote_request( $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ), array( 'method' => 'PATCH', 'body' => wp_json_encode( $patches ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $patch_response ) ) { throw new Exception( $patch_response->get_error_message() ); } // Empty is for success, not empty is for error $patch_response_body = wp_remote_retrieve_body( $patch_response ); if ( ! empty( $patch_response_body ) ) { $patched_plan = LP_Helper::json_decode( $patch_response_body, true ); if ( isset( $patched_plan['debug_id'] ) ) { throw new Exception( __( 'Update plan: ', 'learnpress' ) . $patched_plan['details'][0]['description'], (int) $patched_plan['debug_id'] ); } } } // Check if interval is changed will not allow to change if ( ! empty( $data['interval'] ?? '' ) ) { if ( $data['interval'] !== $current_sum['interval'] ) { throw new Exception( __( 'PayPal plan interval cannot be changed. Create a new plan instead.', 'learnpress' ) ); } } // Check if interval count is changed will not allow to change if ( ! empty( $data['interval_count'] ?? '' ) ) { if ( $data['interval_count'] !== $current_sum['interval_count'] ) { throw new Exception( __( 'PayPal plan interval count cannot be changed. Create a new plan instead.', 'learnpress' ) ); } } if ( ! empty( $data['amount'] ?? '' ) ) { $new_amount = (float) $data['amount']; if ( $new_amount <= 0 ) { throw new Exception( __( 'Invalid subscription amount.', 'learnpress' ) ); } $currency_code = strtoupper( $data['currency'] ?? '' ); if ( empty( $currency_code ) ) { throw new Exception( __( 'Missing subscription currency.', 'learnpress' ) ); } $regular_sequence = 1; if ( ! empty( $current_plan['billing_cycles'] ) && is_array( $current_plan['billing_cycles'] ) ) { foreach ( $current_plan['billing_cycles'] as $billing_cycle ) { if ( ! is_array( $billing_cycle ) ) { continue; } if ( ( $billing_cycle['tenure_type'] ?? '' ) === 'REGULAR' ) { $regular_sequence = max( 1, absint( $billing_cycle['sequence'] ?? 1 ) ); break; } } } $pricing_response = wp_remote_post( $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ) . '/update-pricing-schemes', array( 'body' => wp_json_encode( array( 'pricing_schemes' => array( array( 'billing_cycle_sequence' => $regular_sequence, 'pricing_scheme' => array( 'fixed_price' => array( 'value' => number_format( $new_amount, 2, '.', '' ), 'currency_code' => $currency_code, ), ), ), ), ) ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $pricing_response ) ) { throw new Exception( $pricing_response->get_error_message() ); } if ( isset( $pricing_response['debug_id'] ) ) { throw new Exception( __( 'Update plan pricing: ', 'learnpress' ) . $pricing_response['details'][0]['description'], (int) $pricing_response['debug_id'] ); } } $updated = $this->get_plan( $plan_id ); $updated['message'] = __( 'PayPal subscription plan updated.', 'learnpress' ); return $updated; } /** * Deactivate PayPal billing plan. * * PayPal does not support hard-delete for plans; this operation marks * plan status as INACTIVE. * * @param string $plan_id * * @return array * @throws Exception */ public function delete_plan( string $plan_id ): array { $deleted = $this->update_plan( $plan_id, array( 'status' => 'INACTIVE', ) ); $deleted['message'] = __( 'PayPal subscription plan deactivated.', 'learnpress' ); return $deleted; } /** * Create PayPal subscription checkout and return redirect payload. * * Request is sent to PayPal Billing Subscriptions API using: * - plan_id = subscription price/plan id configured in PayPal. * - custom_id = LearnPress parent order id for reconciliation. * - return/cancel URLs = checkout callbacks. * * @param array $data Normalized payload from get_subscription_context(). * * @return array{ * status:string, * redirect_url:string, * provider_reference:string, * subscription_id:string, * message:string * } * @throws Exception * @deprecated 4.3.8 Use pay_via_subscription() instead. */ /*public function pay_subscription( array $data ): array { if ( ! $this->is_subscription_enabled() ) { throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) ); } $data = $this->validate_subscription_payload( $data ); $data_token = $this->get_access_token(); if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) { throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) ); } $metadata = array_map( function ( $value ) { if ( is_scalar( $value ) || is_null( $value ) ) { return (string) $value; } return wp_json_encode( $value ); }, (array) $data['metadata'] ); $order_subscription_id = absint( $metadata['lp_order_id'] ?? 0 ); $request_body = array( 'plan_id' => (string) $data['price_id'], 'quantity' => (string) max( 1, absint( $data['quantity'] ) ), 'custom_id' => ! empty( $order_subscription_id ) ? (string) $order_subscription_id : '', 'application_context' => array( 'brand_name' => ! empty( get_bloginfo() ) ? get_bloginfo() : 'LearnPress', 'return_url' => esc_url_raw( (string) $data['success_url'] ), 'cancel_url' => esc_url_raw( (string) $data['cancel_url'] ), ), ); $response = wp_remote_post( $this->api_url . 'v1/billing/subscriptions', array( 'body' => wp_json_encode( $request_body ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message() ); } $body = wp_remote_retrieve_body( $response ); $data = LP_Helper::json_decode( $body ); if ( empty( $data->id ) ) { $error_message = __( 'Invalid PayPal subscription response.', 'learnpress' ); if ( ! empty( $data->message ) ) { $error_message = $data->message; } throw new Exception( $error_message ); } $approve_url = ''; if ( ! empty( $data->links ) && is_array( $data->links ) ) { foreach ( $data->links as $link ) { if ( ! empty( $link->rel ) && 'approve' === $link->rel ) { $approve_url = $link->href; break; } } } if ( empty( $approve_url ) ) { throw new Exception( __( 'Invalid PayPal subscription approve URL.', 'learnpress' ) ); } return array( 'status' => 'success', 'redirect_url' => esc_url_raw( $approve_url ), 'provider_reference' => (string) $data->id, 'subscription_id' => (string) $data->id, 'message' => __( 'Redirecting to PayPal subscription checkout.', 'learnpress' ), ); }*/ /** * Create PayPal subscription checkout and return redirect payload. * * Request is sent to PayPal Billing Subscriptions API using: * - plan_id = subscription price/plan id configured in PayPal. * - custom_id = LearnPress parent order id for reconciliation. * - return/cancel URLs = checkout callbacks. * * @param array $data [ 'plan_id' => string, 'quantity' => int, 'success_url' => string, 'cancel_url' => string ] * Required: plan_id * * @return array [ 'redirect_url' => string, and data from v1/billing/subscriptions ] * @throws Exception * @since 4.3.7 * @version 1.0.1 */ public function pay_via_subscription( LP_Order $lp_order, array $data ): array { if ( ! $this->is_subscription_enabled() ) { throw new Exception( __( 'PayPal subscriptions are disabled.', 'learnpress' ) ); } $plan_id = $data['plan_id'] ?? ''; if ( empty( $plan_id ) ) { throw new Exception( __( 'PayPal subscription plan ID is invalid.', 'learnpress' ) ); } $lp_order_id = $lp_order->get_id(); $data_token = $this->get_access_token(); $request_body = array( 'plan_id' => $plan_id, 'quantity' => max( 1, absint( $data['quantity'] ?? 0 ) ), 'custom_id' => $lp_order_id, 'application_context' => array( 'brand_name' => ! empty( get_bloginfo() ) ? get_bloginfo() : 'LearnPress', 'return_url' => esc_url_raw( $data['success_url'] ?? '' ), 'cancel_url' => esc_url_raw( $data['cancel_url'] ?? '' ), ), ); // If the user already completed a trial for this plan on a previous order. $user_has_trial_done = get_user_meta( $lp_order->get_user_id(), 'user_plan_trial', true ); $user_has_trial_done = apply_filters( 'learn-press/subscription/user-has-trial', $user_has_trial_done, $lp_order, $plan_id, $this ); if ( $user_has_trial_done && $user_has_trial_done === $plan_id ) { LP_Debug::log_to_comment( 'Pay renew for user trial done: ' . $plan_id ); // Fetch plan details to find the REGULAR billing cycle and its pricing. $plan_response = wp_remote_get( $this->api_url . 'v1/billing/plans/' . rawurlencode( $plan_id ), array( 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( ! is_wp_error( $plan_response ) ) { $plan_body = LP_Helper::json_decode( wp_remote_retrieve_body( $plan_response ), true ); $plan_billing_cycles = $plan_body['billing_cycles'] ?? array(); LP_Debug::log_to_comment( 'Plan: ' . json_encode( $plan_body, JSON_UNESCAPED_UNICODE ) ); $regular_cycle = null; foreach ( $plan_billing_cycles as $cycle ) { if ( ( $cycle['tenure_type'] ?? '' ) === 'REGULAR' ) { $regular_cycle = $cycle; break; } } if ( $regular_cycle ) { // Override billing_cycles with only the REGULAR cycle at sequence=1, // effectively skipping any trial cycle for this subscription. $override_cycle = array( 'sequence' => 1, ); if ( ! empty( $regular_cycle['pricing_scheme'] ) ) { $override_cycle['pricing_scheme'] = $regular_cycle['pricing_scheme']; } $request_body['plan']['billing_cycles'] = array( $override_cycle ); //$request_body['plan']['payment_preferences'] = $plan_body['payment_preferences'] ?? ''; } } } LP_Debug::log_to_comment( 'Payment param send: ' . json_encode( $request_body, JSON_UNESCAPED_UNICODE ) ); $response = wp_remote_post( $this->api_url . 'v1/billing/subscriptions', array( 'body' => wp_json_encode( $request_body ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message() ); } $body = wp_remote_retrieve_body( $response ); $response_data = LP_Helper::json_decode( $body, true ); LP_Debug::log_to_comment( 'Pay subscription: ' . $body ); // Error return from PayPal if ( ! empty( $response_data['debug_id'] ) ) { throw new Exception( __( 'Pay PayPal subscription: ', 'learnpress' ) . $response_data['details'][0]['description'], (int) $response_data['debug_id'] ); } if ( empty( $response_data['id'] ) ) { throw new Exception( __( 'Invalid PayPal subscription response.', 'learnpress' ) ); } // Update info for LP Order update_post_meta( $lp_order_id, self::META_SUBSCRIPTION_ID, $response_data['id'] ); update_post_meta( $lp_order_id, self::META_SUBSCRIPTION_PLAN_ID, $plan_id ); $response_data['redirect_url'] = $response_data['links'][0]['href']; return $response_data; } /** * Reverse verify PayPal subscription webhook before processing. * * Verification uses PayPal /verify-webhook-signature endpoint and requires: * - configured webhook id from merchant settings, * - required PayPal transmission headers, * - raw JSON payload as webhook_event. * * @param array $webhook_data Webhook payload + headers extracted by listener. * * @return array Verified webhook payload array on success. * @throws Exception * @deprecated 4.3.8 */ /*public function verify_subscription_webhook( array $webhook_data ): array { if ( empty( $this->subscription_webhook_id ) ) { throw new Exception( __( 'PayPal subscription webhook id is missing.', 'learnpress' ), 500 ); } $required_headers = array( 'paypal-auth-algo', 'paypal-cert-url', 'paypal-transmission-id', 'paypal-transmission-sig', 'paypal-transmission-time', ); $this->validate_webhook_data_contract( $webhook_data, array( 'body', 'headers' ), $required_headers ); $payload = $webhook_data['body'] ?? null; if ( is_string( $payload ) ) { $payload = json_decode( $payload, true ); } if ( empty( $payload ) || ! is_array( $payload ) ) { throw new Exception( __( 'Invalid PayPal webhook payload.', 'learnpress' ), 400 ); } $headers_map = is_array( $webhook_data['headers'] ?? null ) ? $webhook_data['headers'] : array(); foreach ( $required_headers as $required_header ) { $header_value = sanitize_text_field( (string) ( $headers_map[ $required_header ] ?? '' ) ); if ( '' === $header_value ) { throw new Exception( __( 'Invalid PayPal webhook headers.', 'learnpress' ), 400 ); } $headers_map[ $required_header ] = $header_value; } $cert_url = esc_url_raw( $headers_map['paypal-cert-url'] ); $cert_host = wp_parse_url( $cert_url, PHP_URL_HOST ); if ( empty( $cert_url ) || stripos( $cert_url, 'https://' ) !== 0 || empty( $cert_host ) || ! preg_match( '/(^|\.)paypal\.com$/i', (string) $cert_host ) ) { throw new Exception( __( 'Invalid PayPal webhook certificate URL.', 'learnpress' ), 400 ); } $data_token = $this->get_access_token(); if ( empty( $data_token->access_token ) || empty( $data_token->token_type ) ) { throw new Exception( __( 'Invalid Paypal access token', 'learnpress' ) ); } $verify_payload = array( 'auth_algo' => $headers_map['paypal-auth-algo'], 'cert_url' => $cert_url, 'transmission_id' => $headers_map['paypal-transmission-id'], 'transmission_sig' => $headers_map['paypal-transmission-sig'], 'transmission_time' => $headers_map['paypal-transmission-time'], 'webhook_id' => (string) $this->subscription_webhook_id, 'webhook_event' => $payload, ); $response = wp_remote_post( $this->api_url . 'v1/notifications/verify-webhook-signature', array( 'body' => wp_json_encode( $verify_payload ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message(), 400 ); } $verify_result = LP_Helper::json_decode( wp_remote_retrieve_body( $response ), true ); $is_verified = ! empty( $verify_result['verification_status'] ) && 'SUCCESS' === strtoupper( $verify_result['verification_status'] ); if ( ! $is_verified ) { throw new Exception( __( 'PayPal webhook verification failed.', 'learnpress' ), 400 ); } return $payload; }*/ /** * Normalize PayPal webhook event into LearnPress subscription event schema. * * Maps PayPal event names into LP canonical event_type values and extracts * identifiers needed by Subscription Manager (event_id, subscription_id, * parent_order_id, renewal_key, amount/currency). * * @param array|object $provider_event * * @return array * @deprecated 4.3.8 Use normalize_subscription_data instead. */ /*public function normalize_subscription_event( $provider_event ): array { $event = parent::normalize_subscription_event( $provider_event ); if ( is_object( $provider_event ) ) { $provider_event = (array) $provider_event; } $event['event_id'] = (string) ( $provider_event['id'] ?? '' ); $paypal_event_type = (string) ( $provider_event['event_type'] ?? '' ); $resource = (array) ( $provider_event['resource'] ?? array() ); $event['metadata'] = array(); $order_subscription_id = ''; if ( ! empty( $resource['custom_id'] ) ) { $order_subscription_id = (string) $resource['custom_id']; } elseif ( ! empty( $resource['custom'] ) ) { // Some PayPal sale resources provide `custom` instead of `custom_id`. $order_subscription_id = (string) $resource['custom']; } if ( '' !== $order_subscription_id ) { $event['metadata']['lp_order_id'] = $order_subscription_id; $event['parent_order_id'] = absint( $order_subscription_id ); } switch ( $paypal_event_type ) { case 'BILLING.SUBSCRIPTION.ACTIVATED': $event['event_type'] = 'subscription_activated'; $event['subscription_id'] = (string) ( $resource['id'] ?? '' ); break; case 'BILLING.SUBSCRIPTION.UPDATED': // This callback does not add state transition value for LP flow. // Keep ignored intentionally to avoid ambiguous status handling. $event['event_type'] = 'ignored'; break; case 'BILLING.SUBSCRIPTION.CANCELLED': $event['event_type'] = 'subscription_cancelled'; $event['subscription_id'] = (string) ( $resource['id'] ?? '' ); break; case 'BILLING.SUBSCRIPTION.SUSPENDED': $event['event_type'] = 'subscription_suspended'; $event['subscription_id'] = (string) ( $resource['id'] ?? '' ); break; case 'BILLING.SUBSCRIPTION.EXPIRED': // EXPPIRED is usually for fixed-term plans (total_cycles > 0), // not the common auto-renew membership plan (total_cycles = 0). $event['event_type'] = 'subscription_expired'; $event['subscription_id'] = (string) ( $resource['id'] ?? '' ); break; case 'PAYMENT.SALE.COMPLETED': $event['event_type'] = 'renewal_payment_succeeded'; $event['subscription_id'] = (string) ( $resource['billing_agreement_id'] ?? '' ); $event['transaction_id'] = (string) ( $resource['id'] ?? '' ); if ( ! empty( $resource['id'] ) ) { $event['renewal_key'] = 'paypal_sale_' . sanitize_text_field( (string) $resource['id'] ); } if ( ! empty( $resource['amount'] ) ) { $amount = (array) $resource['amount']; $event['amount'] = (float) ( $amount['total'] ?? ( $amount['value'] ?? 0 ) ); $event['currency'] = strtoupper( (string) ( $amount['currency'] ?? ( $amount['currency_code'] ?? '' ) ) ); } break; case 'PAYMENT.SALE.DENIED': $event['event_type'] = 'renewal_payment_failed'; $event['subscription_id'] = (string) ( $resource['billing_agreement_id'] ?? '' ); $event['transaction_id'] = (string) ( $resource['id'] ?? '' ); if ( ! empty( $resource['id'] ) ) { $event['renewal_key'] = 'paypal_sale_' . sanitize_text_field( (string) $resource['id'] ); } break; case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED': $event['event_type'] = 'renewal_payment_failed'; $event['subscription_id'] = (string) ( $resource['id'] ?? ( $resource['billing_agreement_id'] ?? '' ) ); $event['transaction_id'] = (string) ( $resource['sale_id'] ?? ( $resource['transaction_id'] ?? '' ) ); break; default: $event['event_type'] = 'ignored'; break; } return $event; }*/ /** * Verify, normalize, and dispatch PayPal subscription webhook event. * * Returns ignored status for unsupported event types and delegates valid * mapped events to LP_Subscription_Manager for idempotent processing. * * @param WP_REST_Request $request * * @return array * @throws Exception * @deprecated 4.3.8 Use capture_subscription_webhook instead. */ /*public function listen_webhook_subscription( WP_REST_Request $request ): array { $required_headers = array( 'paypal-auth-algo', 'paypal-cert-url', 'paypal-transmission-id', 'paypal-transmission-sig', 'paypal-transmission-time', ); $webhook_data = $this->build_webhook_data_from_request( $request, $required_headers, true ); $verified_event = $this->verify_subscription_webhook( $webhook_data ); $event = $this->normalize_subscription_event( $verified_event ); if ( empty( $event['event_type'] ) || 'ignored' === $event['event_type'] ) { return array( 'status' => 'ignored', 'event_id' => $event['event_id'] ?? '', 'event_type' => $event['event_type'] ?? 'ignored', 'status_code' => 200, ); } if ( ! class_exists( 'LP_Subscription_Manager' ) ) { throw new Exception( __( 'Subscription manager is not available.', 'learnpress' ), 500 ); } return LP_Subscription_Manager::instance()->process_webhook_event( $this, $event ); }*/ /** * Receive data from webhook. * Verify, normalize, and dispatch PayPal subscription webhook event. * * Flow for a plan purchase lifecycle: * First purchase: BILLING.SUBSCRIPTION.CREATED -> BILLING.SUBSCRIPTION.ACTIVATED -> PAYMENT.SALE.COMPLETED * Renewal: PAYMENT.SALE.COMPLETED * * @param WP_REST_Request $request * * @throws Exception * @since 4.3.7 * @version 1.0.0 */ public function capture_subscription_webhook( WP_REST_Request $request ) { $webhook_data = LP_Helper::json_decode( $request->get_body(), true ); // Verify data from webhook $webhook_data_verify = [ 'auth_algo' => $request->get_header( 'PAYPAL-AUTH-ALGO' ) ?? '', 'cert_url' => $request->get_header( 'PAYPAL-CERT-URL' ) ?? '', 'transmission_id' => $request->get_header( 'PAYPAL-TRANSMISSION-ID' ) ?? '', 'transmission_sig' => $request->get_header( 'PAYPAL-TRANSMISSION-SIG' ) ?? '', 'transmission_time' => $request->get_header( 'PAYPAL-TRANSMISSION-TIME' ) ?? '', 'webhook_id' => $this->subscription_webhook_id, 'webhook_event' => $webhook_data, ]; $this->verify_data_from_webhook_subscription( $webhook_data_verify ); // Check lp order exists // SUBSCRIPTION return 'custom_id', PAYMENT.SALE return 'custom' $lp_order_id = $webhook_data['resource']['custom'] ?? $webhook_data['resource']['custom_id'] ?? ''; $lp_order = learn_press_get_order( $lp_order_id ); if ( ! $lp_order ) { error_log( 'LearnPress order is invalid: ' . json_encode( $webhook_data, JSON_UNESCAPED_UNICODE ) ); return; } $webhook_data['lp_order_id'] = $lp_order_id; // Webhook billing subscription create $is_billing_subscription_created = $this->capture_billing_subscription_create( $lp_order, $webhook_data ); if ( $is_billing_subscription_created ) { return; } // Capture payment setup fee or renewal $this->capture_payment_setup_fee_or_renewal( $lp_order, $webhook_data ); // Capture subscription data $this->capture_subscription_data( $webhook_data ); $lp_payment_success = $lp_order->get_meta( self::META_SUBSCRIPTION_DATA_PAYMENT_SUCCESS ); $lp_subscription_status_tmp = $lp_order->get_meta( self::META_SUBSCRIPTION_STATUS_TMP ); $lp_subscription_status = $lp_order->get_meta( self::META_SUBSCRIPTION_STATUS ); // Check lp order status is activated/trial will renew if ( $lp_order->is_completed() ) { if ( in_array( $lp_subscription_status, [ LP_Subscription_Manager::STATUS_ACTIVATED, LP_Subscription_Manager::STATUS_TRIAL ] ) ) { LP_Debug::log_to_comment( 'Activated/Trial to renew' ); update_post_meta( $lp_order_id, self::META_SUBSCRIPTION_STATUS, LP_Subscription_Manager::STATUS_ACTIVATED ); $lp_subscription_status_set_to_handle = LP_Subscription_Manager::STATUS_RENEWED; } } elseif ( ! empty( $lp_payment_success ) && $lp_subscription_status_tmp === LP_Subscription_Manager::STATUS_ACTIVATED ) { // If payment success and subscription status tmp is not empty LP_Debug::log_to_comment( 'Payment success and subscription status tmp is not empty' ); $lp_subscription_status_set_to_handle = LP_Subscription_Manager::STATUS_ACTIVATED; } elseif ( $webhook_data['lp_subscription_status'] !== LP_Subscription_Manager::STATUS_ACTIVATED ) { // If subscription status is not activated, use subscription status from webhook data switch $lp_subscription_status_set_to_handle = $webhook_data['lp_subscription_status'] ?? ''; LP_Debug::log_to_comment( 'Subscription status is not activated: ' . $lp_subscription_status_set_to_handle ); } if ( empty( $lp_subscription_status_set_to_handle ) ) { return; } // Dispatch webhook $this->process_subscription_by_status( $lp_order, $lp_subscription_status_set_to_handle, $webhook_data ); } /** * Reverse verify PayPal subscription webhook before processing. * Doc: https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post * * @param array $webhook_data * * @throws Exception */ public function verify_data_from_webhook_subscription( array $webhook_data ) { $data_token = $this->get_access_token(); $response = wp_remote_post( $this->api_url . 'v1/notifications/verify-webhook-signature', array( 'body' => wp_json_encode( $webhook_data ), 'headers' => array( 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ), 'timeout' => 60, ) ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message() ); } $verify_result = LP_Helper::json_decode( wp_remote_retrieve_body( $response ), true ); $is_verified = ! empty( $verify_result['verification_status'] ) && 'SUCCESS' === strtoupper( $verify_result['verification_status'] ); if ( ! $is_verified ) { throw new Exception( __( 'PayPal webhook verification failed.', 'learnpress' ), 400 ); } } /** * Capture PayPal subscription state from webhook data. * * This method only handles PayPal webhook payloads whose resource type is * `subscription`. Other webhook events can still be sent to the same endpoint, * so non-subscription resources, missing resources, and completed parent orders * are ignored instead of treated as errors. * * For `BILLING.SUBSCRIPTION.ACTIVATED`, the PayPal billing cycle data is used * to decide whether the LearnPress subscription status should be trial or * activated. Cancelled, suspended, and expired events are mapped to the matching * LearnPress subscription statuses. The resolved status is written back into * `$webhook_data['lp_subscription_status']` for later dispatch. * * @param array $webhook_data * * @return void * @throws Exception When the LearnPress order is invalid, the PayPal event type is missing, or no subscription status can be resolved. * @since 4.3.7 * @version 1.0.0 */ public function capture_subscription_data( array &$webhook_data = [] ) { parent::normalize_subscription_data( $webhook_data ); $lp_order_id = $webhook_data['lp_order_id'] ?? 0; $lp_order = learn_press_get_order( $lp_order_id ); if ( ! $lp_order ) { throw new Exception( __( 'LearnPress order is invalid.', 'learnpress' ), 400 ); } if ( empty( $webhook_data['resource'] ) || ! is_array( $webhook_data['resource'] ) ) { return; } $resource = $webhook_data['resource']; $resource_type = $webhook_data['resource_type'] ?? ''; if ( $resource_type !== 'subscription' ) { return; } /* * If order is completed, skip if is data webhook subscription * Because if order completed with subscription activated and payment success * The next for renewal only get via payment, not return subscription */ if ( $lp_order->is_completed() ) { throw new Exception( __( 'Ignore lp order completed with subscription activated.', 'learnpress' ), 400 ); } $event_type = $webhook_data['event_type'] ?? ''; if ( empty( $event_type ) ) { throw new Exception( 'PayPal subscription event type is invalid.', 400 ); } $lp_subscription_status = ''; switch ( $event_type ) { case 'BILLING.SUBSCRIPTION.ACTIVATED': // Check is trial or active $billing_info = $resource['billing_info'] ?? false; if ( ! empty( $billing_info ) ) { $cycle_executions = $billing_info['cycle_executions'] ?? []; if ( ! empty( $cycle_executions ) ) { $trialCycle = null; $regularCycle = null; foreach ( $cycle_executions as $cycle ) { if ( $cycle['tenure_type'] === 'TRIAL' ) { $trialCycle = $cycle; } elseif ( $cycle['tenure_type'] === 'REGULAR' ) { $regularCycle = $cycle; } } // 1. Check if it's the first time in Trial // If trial is running (remaining > 0) // Or the trial just completed its last charge (completed == total_cycles) but has not yet transitioned to the next REGULAR cycle if ( $trialCycle ) { $trialCycleRemaining = $trialCycle['cycles_remaining'] ?? 0; $trialCycleCompleted = $trialCycle['cycles_completed'] ?? 0; $trialCycleTotal = $trialCycle['total_cycles'] ?? 0; if ( $trialCycleRemaining > 0 || ( $trialCycleTotal && $trialCycleCompleted === $trialCycleTotal ) ) { $lp_subscription_status = LP_Subscription_Manager::STATUS_TRIAL; } } // 2. Check not trial if ( empty( $lp_subscription_status ) && $regularCycle ) { $lp_subscription_status = LP_Subscription_Manager::STATUS_ACTIVATED; // Key tmp for check if payment success and has key will set lp_subscription_status = value of subscription_status_tmp update_post_meta( $lp_order->get_id(), self::META_SUBSCRIPTION_STATUS_TMP, $lp_subscription_status ); } } } break; case 'BILLING.SUBSCRIPTION.CANCELLED': $lp_subscription_status = LP_Subscription_Manager::STATUS_CANCELLED; break; case 'BILLING.SUBSCRIPTION.SUSPENDED': $lp_subscription_status = LP_Subscription_Manager::STATUS_SUSPENDED; break; case 'BILLING.SUBSCRIPTION.EXPIRED': $lp_subscription_status = LP_Subscription_Manager::STATUS_EXPIRED; break; default: $lp_subscription_status = $event_type; break; } if ( empty( $lp_subscription_status ) ) { error_log( 'Empty subscription status: ' . json_encode( $webhook_data ) ); throw new Exception( __( 'LP PayPal get status from webhook is invalid.', 'learnpress' ), 400 ); } $webhook_data['lp_subscription_status'] = $lp_subscription_status; LP_Debug::log_to_comment( 'LP PayPal normalize subscription data' . json_encode( $webhook_data, JSON_UNESCAPED_UNICODE ) ); } /** * Capture PayPal setup-fee or renewal payment data. * * This method handles `PAYMENT.SALE.COMPLETED` webhook payloads whose resource * type is `sale`. It verifies that the PayPal billing agreement matches the * subscription id stored on the LearnPress parent order, stores the successful * payment payload for first-payment coordination when the parent order is not * completed yet, and appends normalized amount/currency values to `$webhook_data`. * * @param LP_Order $lp_order * @param array $webhook_data * * @return true|void * @throws Exception When the sale resource is invalid or does not match the stored subscription id. * @since 4.3.7 * @version 1.0.0 */ public function capture_payment_setup_fee_or_renewal( $lp_order, array &$webhook_data ) { $resource_type = $webhook_data['resource_type'] ?? ''; $event_type = $webhook_data['event_type'] ?? ''; if ( 'sale' !== $resource_type || 'PAYMENT.SALE.COMPLETED' !== $event_type ) { return; } if ( empty( $webhook_data['resource'] ) || ! is_array( $webhook_data['resource'] ) ) { throw new Exception( __( 'PayPal setup fee/renew payment resource is invalid.', 'learnpress' ), 400 ); } $resource = $webhook_data['resource']; $billing_agreement_id = $resource['billing_agreement_id'] ?? ''; if ( empty( $billing_agreement_id ) ) { throw new Exception( __( 'PayPal billing agreement ID is invalid.', 'learnpress' ), 400 ); } // Verify subscription id receiver sample with subscription id on LP Order $subscription_id = get_post_meta( $lp_order->get_id(), self::META_SUBSCRIPTION_ID, true ); if ( $billing_agreement_id !== $subscription_id ) { throw new Exception( __( 'PayPal billing subscription not same with subscription on LP Order.', 'learnpress' ), 400 ); } // Save payment data to combine with subscription status defined by LP later, not for renewal if ( ! $lp_order->is_completed() ) { update_post_meta( $lp_order->get_id(), self::META_SUBSCRIPTION_DATA_PAYMENT_SUCCESS, wp_json_encode( $webhook_data, JSON_UNESCAPED_UNICODE ) ); } $webhook_data['lp_subscription_amount'] = $resource['amount']['total'] ?? 0; $webhook_data['lp_subscription_currency'] = $resource['amount']['currency'] ?? ''; // Add note to order $lp_order->add_note( sprintf( 'LP Order: %s %s: %s. %s', sprintf( '%s', $lp_order->get_edit_link(), $lp_order->get_order_number() ), __( 'PayPal payment setup fee/renew success created at', 'learnpress' ), $webhook_data['create_time'] ?? '', sprintf( __( 'Subscription ID: %s', 'learnpress' ), $subscription_id ) ) ); return true; } /** * Capture PayPal billing subscription creation webhook. * * `BILLING.SUBSCRIPTION.CREATED` is acknowledged by adding an order note and * returning true so the caller can stop processing. The actual order completion * and subscription status update happen later when activation/payment webhooks * are received. * * @param LP_Order $lp_order * @param array $webhook_data * * @return bool * @since 4.3.7 * @version 1.0.0 */ public function capture_billing_subscription_create( $lp_order, array $webhook_data ): bool { // If billing created, create subscription $event_type = $webhook_data['event_type'] ?? ''; if ( $event_type === 'BILLING.SUBSCRIPTION.CREATED' ) { if ( $lp_order ) { // Add note to order $lp_order->add_note( sprintf( 'LP Order: %s %s: %s. %s', sprintf( '%s', $lp_order->get_edit_link(), $lp_order->get_order_number() ), __( 'PayPal subscription created at', 'learnpress' ), $webhook_data['create_time'] ?? '', sprintf( __( 'Link payment: %s', 'learnpress' ), $webhook_data['resource']['links'][0]['href'] ?? '' ) ) ); } return true; } return false; } /** * Build manage-subscription URL shown in LearnPress order/profile context. * * @param LP_Order $order * * @return string */ public function get_manage_subscription_url( LP_Order $order ): string { $subscription_id = get_post_meta( $order->get_id(), self::META_SUBSCRIPTION_ID, true ); if ( empty( $subscription_id ) ) { return parent::get_manage_subscription_url( $order ); } $manage_url = trailingslashit( $this->paypal_url ) . 'myaccount/autopay/'; return (string) apply_filters( 'learn-press/paypal/subscription/manage-url', $manage_url, $order, $subscription_id, $this ); } /** * Refund PayPal capture via REST API. * * @param LP_Order $lp_order * @param float $amount 0 or empty means full refund. * @param string $note * * @return array * @throws Exception * @since 4.4.0 * @version 1.0.0 */ public function refund( $lp_order, float $amount = 0, string $note = '' ): array { $order_id = $lp_order->get_id(); $capture_id = $lp_order->get_transaction_id(); if ( empty( $capture_id ) ) { throw new Exception( __( 'Missing PayPal capture id to refund.', 'learnpress' ) ); } $refund_args = []; if ( $amount > 0 ) { $refund_args['amount'] = [ 'currency_code' => $lp_order->get_currency(), 'value' => strval( round( $amount, 2 ) ), ]; } $note_to_payer = trim( $note ); if ( ! empty( $note_to_payer ) ) { $refund_args['note_to_payer'] = $note_to_payer; } $refund_args = apply_filters( 'learn-press/paypal-refund/args', $refund_args, $lp_order, $this ); $data_token = $this->get_access_token(); $response = wp_remote_post( $this->api_url . 'v2/payments/captures/' . rawurlencode( $capture_id ) . '/refund', [ 'body' => json_encode( $refund_args ), 'headers' => [ 'Authorization' => $data_token->token_type . ' ' . $data_token->access_token, 'Content-Type' => 'application/json', ], 'timeout' => 60, ] ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message() ); } $body = wp_remote_retrieve_body( $response ); $response_data = LP_Helper::json_decode( $body ); if ( isset( $response_data->debug_id ) ) { throw new Exception( $response_data->details[0]->description ); } if ( ! empty( $response_data->status ) && $response_data->status === 'COMPLETED' ) { update_post_meta( $order_id, '_paypal_refund_id', $response_data->id ); $lp_order->add_note( sprintf( __( 'PayPal refund completed at %1$s %2$s', 'learnpress' ), wp_date( 'Y-m-d H:i:s' ), wp_timezone_string() ) ); do_action( 'learn-press/paypal-refund/success', $lp_order, $response_data, $this ); } else { throw new Exception( __( 'PayPal refund something went wrong.', 'learnpress' ) ); } return [ 'result' => 'success', 'refund_id' => $response_data->id, 'status' => $result->status ?? '', 'response' => $response_data, ]; } /** * Settings form fields for this gateway * * @return array */ public function get_settings(): array { return Config::instance()->get( $this->id, 'settings/gateway' ); } } }