flush_response(); $unprocessed_event_ids = FrmPayPalLiteConnectHelper::get_unprocessed_event_ids(); if ( $unprocessed_event_ids ) { $this->process_event_ids( $unprocessed_event_ids ); } wp_send_json_success(); } /** * @since 6.31 * * @param array $event_ids * * @return void */ private function process_event_ids( $event_ids ) { foreach ( $event_ids as $event_id ) { if ( $this->should_skip_event( $event_id ) ) { continue; } set_transient( 'frm_paypal_last_process_' . $event_id, time(), 60 ); $this->event = FrmPayPalLiteConnectHelper::get_event( $event_id ); if ( ! is_object( $this->event ) ) { $this->count_failed_event( $event_id ); continue; } $this->handle_event(); $this->track_handled_event( $event_id ); FrmPayPalLiteConnectHelper::process_event( $event_id ); } } /** * @since 6.31 * * @param string $event_id * * @return bool True if the event should be skipped. */ private function should_skip_event( $event_id ) { if ( $this->last_attempt_to_process_event_is_too_recent( $event_id ) ) { return true; } $option = get_option( self::$events_to_skip_option_name ); return is_array( $option ) && in_array( $event_id, $option, true ); } /** * @param string $event_id * * @return bool */ private function last_attempt_to_process_event_is_too_recent( $event_id ) { $last_process_attempt = get_transient( 'frm_paypal_last_process_' . $event_id ); return is_numeric( $last_process_attempt ) && $last_process_attempt > time() - 60; } /** * @since 6.31 * * @param string $event_id * * @return void */ private function count_failed_event( $event_id ) { $transient_name = 'frm_paypal_failed_event_' . $event_id; $transient = get_transient( $transient_name ); $failed_count = is_int( $transient ) ? $transient + 1 : 1; $maximum_retries = 3; if ( $failed_count >= $maximum_retries ) { $this->track_handled_event( $event_id ); } else { set_transient( $transient_name, $failed_count, 4 * DAY_IN_SECONDS ); } } /** * Track an event to no longer process. * This is called for successful events, and also for failed events after a number of retries. * * @since 6.31 * * @param string $event_id * * @return void */ private function track_handled_event( $event_id ) { $option = get_option( self::$events_to_skip_option_name ); if ( is_array( $option ) ) { if ( count( $option ) > 1000 ) { // Prevent the option from getting too big by removing the front item before adding the next. array_shift( $option ); } } else { $option = array(); } $option[] = $event_id; update_option( self::$events_to_skip_option_name, $option, false ); } /** * @return void */ private function handle_event() { $this->resource = $this->event->resource ?? null; if ( ! is_object( $this->resource ) ) { FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No resource object found in event' ); return; } $payment_events = array( 'PAYMENT.CAPTURE.COMPLETED' => 'complete', 'PAYMENT.CAPTURE.DECLINED' => 'failed', 'PAYMENT.CAPTURE.DENIED' => 'failed', 'PAYMENT.CAPTURE.REFUNDED' => 'refunded', 'PAYMENT.CAPTURE.REVERSED' => 'refunded', 'PAYMENT.SALE.COMPLETED' => 'complete', 'PAYMENT.SALE.DENIED' => 'failed', 'PAYMENT.SALE.REFUNDED' => 'refunded', 'PAYMENT.SALE.REVERSED' => 'refunded', ); if ( isset( $payment_events[ $this->event->event_type ] ) ) { $this->status = $payment_events[ $this->event->event_type ]; $this->handle_payment_event(); return; } switch ( $this->event->event_type ) { case 'BILLING.SUBSCRIPTION.ACTIVATED': case 'BILLING.SUBSCRIPTION.RE-ACTIVATED': $this->handle_subscription_activated(); break; case 'BILLING.SUBSCRIPTION.CANCELLED': case 'BILLING.SUBSCRIPTION.EXPIRED': case 'BILLING.SUBSCRIPTION.SUSPENDED': $this->handle_subscription_canceled(); break; case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED': $this->handle_subscription_payment_failed(); break; case 'BILLING.SUBSCRIPTION.UPDATED': $this->handle_subscription_updated(); break; } } /** * Handle a payment capture or sale event by syncing the payment record. * * @since 6.31 * * @return void */ private function handle_payment_event() { $receipt_id = $this->get_receipt_id_for_event(); if ( ! $receipt_id ) { FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No resource ID found in payment event' ); return; } $frm_payment = new FrmTransLitePayment(); $payment = $frm_payment->get_one_by( $receipt_id, 'receipt_id' ); // If no payment was found by capture ID, check for a pending payment // stored with the order ID as receipt_id. Update it to the capture ID. if ( ! $payment ) { $payment = $this->maybe_resolve_pending_payment( $frm_payment, $receipt_id ); } if ( ! $payment && $this->status === 'refunded' ) { FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No action taken. The refunded payment does not exist for ' . $receipt_id ); return; } $run_triggers = false; if ( ! $payment ) { $payment = $this->maybe_create_subscription_payment(); if ( $payment ) { $run_triggers = true; } } elseif ( $payment->status !== $this->status ) { $payment_values = (array) $payment; $is_partial_refund = $this->is_partial_refund(); if ( $is_partial_refund ) { $this->set_partial_refund( $payment_values ); // translators: %s: The amount of money that was refunded. $note = sprintf( __( 'Payment partially refunded %s', 'formidable' ), $this->get_refunded_amount() ); } else { $payment_values['status'] = $this->status; $payment->status = $this->status; // translators: %s: The status of the payment. $note = sprintf( __( 'Payment %s', 'formidable' ), $payment_values['status'] ); } FrmTransLiteAppHelper::add_note_to_payment( $payment_values, $note ); $frm_payment->update( $payment->id, $payment_values ); if ( ! $is_partial_refund ) { $run_triggers = true; } }//end if if ( $run_triggers && $payment && $payment->action_id ) { FrmTransLiteActionsController::trigger_payment_status_change( array( 'status' => $this->status, 'payment' => $payment, ) ); } } /** * Try to create a new payment record from a subscription payment event. * This handles recurring payments after the first one. * * @since 6.31 * * @return false|object The new payment object or false if not a subscription payment. */ private function maybe_create_subscription_payment() { $subscription_id = $this->get_subscription_id_from_resource(); if ( ! $subscription_id ) { return false; } $sub = $this->get_subscription( $subscription_id ); if ( ! $sub ) { return false; } $receipt_id = $this->resource->id ?? ''; $amount = $this->get_amount_from_resource(); $payment_values = array( 'paysys' => 'paypal', 'amount' => $amount, 'status' => $this->status, 'item_id' => $sub->item_id, 'action_id' => $sub->action_id, 'receipt_id' => $receipt_id, 'sub_id' => $sub->id, 'begin_date' => gmdate( 'Y-m-d' ), 'expire_date' => '0000-00-00', 'meta_value' => array(), 'created_at' => current_time( 'mysql', 1 ), 'test' => 'test' === FrmPayPalLiteAppHelper::active_mode() ? 1 : 0, ); FrmTransLiteAppHelper::add_note_to_payment( $payment_values ); $frm_payment = new FrmTransLitePayment(); $existing_payment = $frm_payment->get_one_by( $receipt_id, 'receipt_id' ); if ( $existing_payment ) { return false; } $existing_sub_payment = $frm_payment->get_one_by( $sub->id, 'sub_id' ); if ( $existing_sub_payment && str_starts_with( $existing_sub_payment->receipt_id, 'I-' ) ) { $frm_payment->update( $existing_sub_payment->id, array( 'receipt_id' => $receipt_id ) ); $this->update_next_bill_date( $sub ); $this->maybe_cancel_subscription_at_limit( $sub ); return $frm_payment->get_one( $existing_sub_payment->id ); } $payment_id = $frm_payment->create( $payment_values ); $this->update_next_bill_date( $sub ); $this->maybe_cancel_subscription_at_limit( $sub ); return $frm_payment->get_one( $payment_id ); } /** * Get the PayPal subscription ID from the resource object. * * @since 6.31 * * @return string */ private function get_subscription_id_from_resource() { if ( ! empty( $this->resource->billing_agreement_id ) ) { return $this->resource->billing_agreement_id; } if ( ! empty( $this->resource->supplementary_data->related_ids->subscription_id ) ) { return $this->resource->supplementary_data->related_ids->subscription_id; } return ''; } /** * Get the payment amount from the resource object. * * @since 6.31 * * @return string */ private function get_amount_from_resource() { if ( ! empty( $this->resource->amount->total ) ) { return $this->resource->amount->total; } if ( ! empty( $this->resource->amount->value ) ) { return $this->resource->amount->value; } return '0.00'; } /** * Get the refunded amount from the resource object. * * @since 6.31 * * @return string */ private function get_refunded_amount() { if ( ! empty( $this->resource->amount->total ) ) { return $this->resource->amount->total; } if ( ! empty( $this->resource->amount->value ) ) { return $this->resource->amount->value; } return '0.00'; } /** * Check if a refund is partial by comparing the refunded amount to the original payment amount. * * @since 6.31 * * @return bool */ private function is_partial_refund() { if ( $this->status !== 'refunded' ) { return false; } $receipt_id = $this->get_receipt_id_for_event(); if ( ! $receipt_id ) { return false; } $frm_payment = new FrmTransLitePayment(); $original_payment = $frm_payment->get_one_by( $receipt_id, 'receipt_id' ); $refunded_amount = (float) $this->get_refunded_amount(); if ( $original_payment ) { return $refunded_amount < (float) $original_payment->amount; } return false; } /** * Set the partial refund amount on a payment. * * @since 6.31 * * @param array $payment_values The payment values to update. * * @return void */ private function set_partial_refund( &$payment_values ) { $refunded = (float) $this->get_refunded_amount(); $original = (float) $payment_values['amount']; $payment_values['amount'] = number_format( $original - $refunded, 2, '.', '' ); } /** * Try to find a pending payment stored with the order ID as receipt_id. * * When a capture is pending, the payment record is created with the order * ID as receipt_id. When the capture completes, the webhook provides a * capture ID. This method extracts the order ID from the resource's HATEOAS * 'up' link, looks up the pending payment, and updates its receipt_id to * the capture ID. * * @since 6.31 * * @param FrmTransLitePayment $frm_payment The payment model instance. * @param string $capture_id The capture ID from the webhook resource. * * @return object|null The payment if found and updated, or null. */ private function maybe_resolve_pending_payment( $frm_payment, $capture_id ) { $order_id = $this->get_order_id_from_resource_links(); if ( ! $order_id ) { return null; } $payment = $frm_payment->get_one_by( $order_id, 'receipt_id' ); if ( ! $payment || 'pending' !== $payment->status ) { return null; } $frm_payment->update( $payment->id, array( 'receipt_id' => $capture_id ) ); $payment->receipt_id = $capture_id; return $payment; } /** * Extract the order ID from the webhook resource's HATEOAS 'up' link. * * For PAYMENT.CAPTURE.COMPLETED events, the 'up' link points to the * order: /v2/checkout/orders/{order_id}. * * @since 6.31 * * @return string The order ID, or empty string if not found. */ private function get_order_id_from_resource_links() { if ( empty( $this->resource->links ) || ! is_array( $this->resource->links ) ) { return ''; } foreach ( $this->resource->links as $link ) { if ( ! isset( $link->rel ) || 'up' !== $link->rel ) { continue; } if ( empty( $link->href ) ) { continue; } $path = wp_parse_url( $link->href, PHP_URL_PATH ); if ( ! $path ) { continue; } $segments = explode( '/', rtrim( $path, '/' ) ); $last_segment = end( $segments ); if ( $last_segment ) { return $last_segment; } }//end foreach return ''; } /** * Get the receipt ID to use for looking up a payment record. * * For most events, this is the resource ID. For refund/reversal events, * the resource is the refund object, so we need to extract the original * capture or sale ID from the resource's sale_id property or HATEOAS links. * * @since 6.31 * * @return string The receipt ID of the original payment. */ private function get_receipt_id_for_event() { if ( $this->status !== 'refunded' ) { return $this->resource->id ?? ''; } $refund_id = $this->resource->id ?? ''; // For sale refunds, the sale_id property references the original sale. if ( ! empty( $this->resource->sale_id ) ) { return $this->resource->sale_id; } // For capture refunds, extract the capture ID from the HATEOAS 'up' link. // The 'up' link points to the original capture: /v2/payments/captures/{capture_id} if ( empty( $this->resource->links ) || ! is_array( $this->resource->links ) ) { return $refund_id; } foreach ( $this->resource->links as $link ) { if ( ! isset( $link->rel ) || 'up' !== $link->rel ) { continue; } if ( empty( $link->href ) ) { continue; } // Extract the ID from the URL path: .../captures/{id} or .../sale/{id} $path = wp_parse_url( $link->href, PHP_URL_PATH ); if ( ! $path ) { continue; } $segments = explode( '/', rtrim( $path, '/' ) ); $last_segment = end( $segments ); if ( $last_segment ) { return $last_segment; } }//end foreach // Fallback to the resource ID (the refund ID itself). return $refund_id; } /** * Get a subscription record by its PayPal subscription ID. * * @since 6.31 * * @param string $sub_id The PayPal subscription ID. * * @return object|null */ private function get_subscription( $sub_id ) { $frm_sub = new FrmTransLiteSubscription(); $sub = $frm_sub->get_one_by( $sub_id, 'sub_id' ); if ( ! $sub ) { FrmTransLiteLog::log_message( 'PayPal Webhook Message', 'No action taken since there is not a matching subscription for ' . $sub_id ); } return $sub; } /** * Update the next bill date for a subscription using the PayPal API. * * @since 6.31 * * @param object $sub The local subscription record. * * @return void */ private function update_next_bill_date( $sub ) { $subscription = FrmPayPalLiteConnectHelper::get_subscription( $sub->sub_id ); if ( ! is_object( $subscription ) ) { return; } $next_bill_date = ''; if ( ! empty( $subscription->billing_info->next_billing_time ) ) { $next_bill_date = gmdate( 'Y-m-d', strtotime( $subscription->billing_info->next_billing_time ) ); } if ( ! $next_bill_date ) { return; } $frm_sub = new FrmTransLiteSubscription(); $frm_sub->update( $sub->id, array( 'next_bill_date' => $next_bill_date ) ); } /** * Check if a subscription has reached its payment limit. * If it has, cancel the subscription. * * @since 6.31 * * @param object $sub The local subscription record. * * @return void */ private function maybe_cancel_subscription_at_limit( $sub ) { $action = FrmFormAction::get_single_action_type( $sub->action_id, 'payment' ); if ( ! is_object( $action ) || empty( $action->post_content['payment_limit'] ) ) { return; } $frm_payment = new FrmTransLitePayment(); $all_payments = $frm_payment->get_all_by( $sub->id, 'sub_id' ); $count = FrmTransLiteAppHelper::count_completed_payments( $all_payments ); if ( $count < (int) $action->post_content['payment_limit'] ) { return; } $cancelled = FrmPayPalLiteConnectHelper::cancel_subscription( $sub->sub_id ); if ( $cancelled ) { FrmTransLiteSubscriptionsController::change_subscription_status( array( 'status' => 'future_cancel', 'sub' => $sub, ) ); } } /** * Handle a subscription activated or re-activated event. * * @since 6.31 * * @return void */ private function handle_subscription_activated() { $subscription_id = $this->resource->id ?? ''; if ( ! $subscription_id ) { return; } $sub = $this->get_subscription( $subscription_id ); if ( ! $sub ) { return; } if ( $sub->status === 'active' ) { return; } FrmTransLiteSubscriptionsController::change_subscription_status( array( 'status' => 'active', 'sub' => $sub, ) ); $this->update_next_bill_date( $sub ); } /** * Handle a subscription cancelled, expired, or suspended event. * * @since 6.31 * * @return void */ private function handle_subscription_canceled() { $subscription_id = $this->resource->id ?? ''; if ( ! $subscription_id ) { FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: No subscription ID found in resource' ); return; } FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Looking for subscription ID: ' . $subscription_id ); $sub = $this->get_subscription( $subscription_id ); if ( ! $sub ) { FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Subscription not found in database for ID: ' . $subscription_id ); return; } FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Found subscription, current status: ' . $sub->status ); if ( $sub->status === 'canceled' ) { FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Subscription already canceled, no action needed' ); return; } FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Updating subscription status to canceled' ); FrmTransLiteSubscriptionsController::change_subscription_status( array( 'status' => 'canceled', 'sub' => $sub, ) ); FrmTransLiteLog::log_message( 'PayPal Webhook Debug', 'BILLING.SUBSCRIPTION.CANCELLED: Status update completed' ); } /** * Handle a subscription payment failed event. * Increments the fail count and cancels after too many failures. * * @since 6.31 * * @return void */ private function handle_subscription_payment_failed() { $subscription_id = $this->resource->id ?? ''; if ( ! $subscription_id ) { return; } $sub = $this->get_subscription( $subscription_id ); if ( ! $sub ) { return; } $frm_sub = new FrmTransLiteSubscription(); $fail_count = (int) $sub->fail_count + 1; $frm_sub->update( $sub->id, array( 'fail_count' => $fail_count ) ); if ( $fail_count > 3 ) { FrmTransLiteSubscriptionsController::change_subscription_status( array( 'status' => 'canceled', 'sub' => $sub, ) ); } } /** * Handle a subscription updated event. * Syncs subscription data like amount and next bill date. * * @since 6.31 * * @return void */ private function handle_subscription_updated() { $subscription_id = $this->resource->id ?? ''; if ( ! $subscription_id ) { return; } $sub = $this->get_subscription( $subscription_id ); if ( ! $sub ) { return; } $new_values = array(); // Sync the subscription status. $paypal_status = $this->resource->status ?? ''; $status_map = array( 'ACTIVE' => 'active', 'SUSPENDED' => 'canceled', 'CANCELLED' => 'canceled', 'EXPIRED' => 'canceled', ); if ( $paypal_status && isset( $status_map[ $paypal_status ] ) && $sub->status !== $status_map[ $paypal_status ] ) { FrmTransLiteSubscriptionsController::change_subscription_status( array( 'status' => $status_map[ $paypal_status ], 'sub' => $sub, ) ); } // Sync next billing date and amount from the PayPal API. $subscription = FrmPayPalLiteConnectHelper::get_subscription( $subscription_id ); if ( ! is_object( $subscription ) ) { // If the subscription doesn't exist in PayPal's API (404 error), it's likely cancelled. // Update the local status to canceled if it's not already. if ( $sub->status !== 'canceled' ) { FrmTransLiteSubscriptionsController::change_subscription_status( array( 'status' => 'canceled', 'sub' => $sub, ) ); } return; } if ( ! empty( $subscription->billing_info->next_billing_time ) ) { $new_values['next_bill_date'] = gmdate( 'Y-m-d', strtotime( $subscription->billing_info->next_billing_time ) ); } if ( ! empty( $subscription->plan->billing_cycles[0]->pricing_scheme->fixed_price->value ) ) { $new_values['amount'] = $subscription->plan->billing_cycles[0]->pricing_scheme->fixed_price->value; } if ( ! $new_values ) { return; } $frm_sub = new FrmTransLiteSubscription(); $frm_sub->update( $sub->id, $new_values ); } }