<?php
/**
 * Stripe webhook handler.
 */

use Stripe\Invoice as Stripe_Invoice;
use Stripe\Subscription as Stripe_Subscription;
use Stripe\Charge as Stripe_Charge;
use Stripe\Event as Stripe_Event;
use Stripe\PaymentMethod as Stripe_PaymentMethod;
use Stripe\Customer as Stripe_Customer;
use Stripe\Checkout\Session as Stripe_Checkout_Session;

class PMPro_Stripe_Webhook_Handler {

	/**
	 * Execute the webhook lifecycle.
	 *
	 * @param array $args {
	 *     Optional. Webhook runtime args.
	 *
	 *     @type string      $event_id          Stripe event ID.
	 *     @type bool        $livemode          Whether this should use live mode keys.
	 *     @type bool        $scheduled_request Whether this run is from Action Scheduler.
	 *     @type object|null $post_event        Parsed event payload fallback.
	 *     @type bool|null   $echo_output       Whether to echo output. Defaults to inverse of scheduled_request.
	 * }
	 * @return void
	 */
	public static function run( $args = array() ) {
		global $logstr;
		$logstr = '';
		$args = wp_parse_args(
			$args,
			array(
				'event_id'          => null,
				'livemode'          => false,
				'scheduled_request' => false,
				'post_event'        => null,
				'echo_output'       => null,
			)
		);
		$event_id = $args['event_id'];
		$livemode = $args['livemode'];
		$scheduled_request = $args['scheduled_request'];
		$post_event = $args['post_event'];
		$echo_output = $args['echo_output'];
		$event_id = sanitize_text_field( $event_id );
		$livemode = ! empty( $livemode );
		$scheduled_request = ! empty( $scheduled_request );
		$post_event = ( is_object( $post_event ) ) ? $post_event : null;
		if ( null === $echo_output ) {
			$echo_output = ! $scheduled_request;
		}
		$echo_output = ! empty( $echo_output );
		
		// Make sure that Stripe is loaded and is the correct API version.
		$stripe = new PMProGateway_stripe();
		
		// Sets the PMPRO_DOING_WEBHOOK constant and fires the pmpro_doing_webhook action.
		pmpro_doing_webhook( 'stripe', true );
		
		try {
			if ( PMProGateway_stripe::using_api_keys() ) {
				$secret_key = get_option( "pmpro_stripe_secretkey" );
			} elseif ( $livemode ) {
				$secret_key = get_option( 'pmpro_live_stripe_connect_secretkey' );
			} else {
				$secret_key = get_option( 'pmpro_sandbox_stripe_connect_secretkey' );
			}
			Stripe\Stripe::setApiKey( $secret_key );
		} catch ( Exception $e ) {
			$logstr .= "Unable to set API key for Stripe gateway: " . $e->getMessage();
			self::result( $logstr, $echo_output );
			return;
		}
		
		/**
		 * Allow adding other content after the Order Settings table.
		 *
		 * @since 3.0.3
		 */
		do_action( 'pmpro_stripe_before_retrieve_webhook_event' );

		global $pmpro_stripe_event;
		$pmpro_stripe_event = null;
		
		// Get the event through the API now.
		if ( ! empty( $event_id ) ) {
			try {
				$pmpro_stripe_event = Stripe_Event::retrieve( $event_id );
			} catch ( Exception $e ) {
				$logstr .= 'Could not find an event with ID #' . $event_id . '. ' . $e->getMessage();

				/**
				 * Filter whether the Stripe webhook handler can trust a posted event payload
				 * when retrieving the event from Stripe fails.
				 *
				 * @since 3.6.5
				 *
				 * @param bool        $allow_unverified_post_event Whether to trust the posted event payload fallback.
				 * @param object|null $post_event                  Parsed event payload from the request body.
				 * @param string      $event_id                    Stripe event ID being retrieved.
				 */
				$allow_unverified_post_event = (bool) apply_filters( 'pmpro_stripe_webhook_allow_unverified_post_event', false, $post_event, $event_id );

				// This fallback is intended for controlled testing/debug scenarios only.
				if ( $allow_unverified_post_event && ! empty( $post_event ) && ! empty( $post_event->id ) && sanitize_text_field( $post_event->id ) === $event_id ) {
					$pmpro_stripe_event = $post_event;
					$logstr .= ' Falling back to the unverified posted event payload.';
				}
			}
		}

		// Fail fast if we could not resolve the webhook event.
		if ( empty( $pmpro_stripe_event ) || empty( $pmpro_stripe_event->id ) ) {
			if ( ! empty( $event_id ) ) {
				$logstr .= 'Could not find an event with ID #' . $event_id;
			} else {
				$logstr .= 'No event ID given.';
			}

			pmpro_unhandled_webhook( 'stripe' );
			self::result( $logstr, $echo_output );
			return;
		}

		// Send a 200 HTTP response to Stripe to avoid timeout.
		if ( ! $scheduled_request ) {
			pmpro_send_200_http_response();
		}

		// Log that we have successfully received a webhook from Stripe.
		$last_received_option = 'pmpro_stripe_webhook_last_received_' . ( $livemode ? 'live' : 'sandbox' ) . '_' . $pmpro_stripe_event->type;
		$old_last_received = get_option( $last_received_option );
		$old_last_received = is_numeric( $old_last_received ) ? (int) $old_last_received : 0;
		$new_last_received = is_numeric( $pmpro_stripe_event->created ) ? (int) $pmpro_stripe_event->created : 0;
		if ( $new_last_received > $old_last_received ) {
			update_option( $last_received_option, $new_last_received );
		}

		/**
		 * Allow code to run when a Stripe webhook is received.
		 *
		 * @since 2.11
		 */
		do_action( 'pmpro_stripe_webhook_event_received', $pmpro_stripe_event );

		$deferred_event_types = array(
			'charge.failed',
			'invoice.payment_succeeded',
			'invoice.payment_action_required',
			'charge.refunded',
			'checkout.session.async_payment_succeeded',
			'checkout.session.async_payment_failed',
		);

		/**
		 * Filter whether Stripe webhook events should be processed asynchronously.
		 *
		 * @since 3.7
		 *
		 * @param bool   $async_processing_enabled Whether deferred Stripe events should be queued for Action Scheduler processing.
		 * @param object $pmpro_stripe_event       The Stripe event object currently being processed.
		 */
		$async_processing_enabled = (bool) apply_filters( 'pmpro_stripe_webhook_enable_async_processing', false, $pmpro_stripe_event );
		if ( ! $scheduled_request && $async_processing_enabled && ! empty( $pmpro_stripe_event->type ) && in_array( $pmpro_stripe_event->type, $deferred_event_types, true ) ) {
			if ( class_exists( 'PMPro_Action_Scheduler' ) ) {
				$action_id = PMPro_Action_Scheduler::instance()->maybe_add_task(
					'pmpro_stripe_webhook_process_scheduled_event',
					array( $pmpro_stripe_event->id, $livemode ),
					'pmpro_async_tasks'
				);

				if ( empty( $action_id ) ) {
					$logstr .= 'Webhook event already queued for scheduled processing. Event ID #' . $pmpro_stripe_event->id . '.';
				} else {
					$logstr .= 'Queued webhook event for scheduled processing. Event ID #' . $pmpro_stripe_event->id . '.';
				}
				do_action( 'pmpro_stripe_webhook_event_queued', $pmpro_stripe_event, $action_id );
				self::result( $logstr, $echo_output );
				return;
			}

			$logstr .= 'Action Scheduler unavailable. Processing webhook immediately for event ID #' . $pmpro_stripe_event->id . '.';
		}

		if ( ! self::process_event( $pmpro_stripe_event, $livemode, $stripe, $logstr ) ) {
			$logstr .= "Not handled event type = " . $pmpro_stripe_event->type;
			pmpro_unhandled_webhook( 'stripe' );
		}

		self::result( $logstr, $echo_output );
		return;
	}

	/**
	 * Route a Stripe event type to its dedicated handler.
	 *
	 * @param object              $pmpro_stripe_event Stripe event object.
	 * @param bool                $livemode           Whether this run is in live mode.
	 * @param PMProGateway_stripe $stripe             Stripe gateway object.
	 * @param string              $logstr             Log output.
	 * @return bool True if the event type is handled here.
	 */
	private static function process_event( $pmpro_stripe_event, $livemode, $stripe, &$logstr ) {
		switch ( $pmpro_stripe_event->type ) {
			case 'invoice.payment_succeeded':
				self::handle_invoice_payment_succeeded( $pmpro_stripe_event, $logstr );
				return true;
			case 'invoice.payment_action_required':
				self::handle_invoice_payment_action_required( $pmpro_stripe_event, $livemode, $logstr );
				return true;
			case 'charge.failed':
				self::handle_charge_failed( $pmpro_stripe_event, $livemode, $logstr );
				return true;
			case 'customer.subscription.deleted':
				self::handle_customer_subscription_deleted( $pmpro_stripe_event, $livemode, $logstr );
				return true;
			case 'charge.refunded':
				self::handle_charge_refunded( $pmpro_stripe_event, $logstr );
				return true;
			case 'checkout.session.completed':
			case 'checkout.session.async_payment_succeeded': // Shares a handler: order data is derived from the Checkout Session, so completion is safe regardless of event order.
				self::handle_checkout_session_completed( $pmpro_stripe_event, $logstr );
				return true;
			case 'checkout.session.async_payment_failed':
				self::handle_checkout_session_async_payment_failed( $pmpro_stripe_event, $logstr );
				return true;
			case 'invoice.created':
			case 'invoice.upcoming':
				self::handle_invoice_created_or_upcoming( $pmpro_stripe_event, $livemode, $stripe, $logstr );
				return true;
			default:
				return false;
		}
	}

	/**
	 * Handle invoice.payment_succeeded events.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_invoice_payment_succeeded( $pmpro_stripe_event, &$logstr ) {
		// Make sure we have the invoice in the desired API version.
		$invoice = Stripe_Invoice::retrieve(
			array(
				'id'     => $pmpro_stripe_event->data->object->id,
				'expand' => array(
					'payments',
				),
			)
		);

		if ( $invoice->amount_due <= 0 ) {
			$logstr .= 'Ignoring an invoice for $0. Probably for a new subscription just created. Invoice ID #' . $invoice->id . '.';
			return;
		}

		if ( empty( $invoice->parent->subscription_details->subscription ) ) {
			$logstr .= 'No subscription associated with invoice ' . $invoice->id . '.';
			return;
		}

		$logstr .= pmpro_handle_recurring_payment_succeeded_at_gateway( self::get_order_data_from_invoice( $invoice ) );
	}

	/**
	 * Handle invoice.payment_action_required events.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param bool   $livemode           Whether this run is in live mode.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_invoice_payment_action_required( $pmpro_stripe_event, $livemode, &$logstr ) {
		// Make sure we have the invoice in the desired API version.
		$invoice = Stripe_Invoice::retrieve( $pmpro_stripe_event->data->object->id );

		// Get the subscription ID for this invoice.
		if ( ! empty( $invoice->parent->subscription_details->subscription ) ) {
			$subscription_id = $invoice->parent->subscription_details->subscription;
		} else {
			$logstr .= 'Could not find subscription ID for invoice ' . $invoice->id . '.';
			return;
		}

		// Get the subscription from the invoice.
		$subscription = PMPro_Subscription::get_subscription_from_subscription_transaction_id( $subscription_id, 'stripe', $livemode ? 'live' : 'sandbox' );
		if ( ! empty( $subscription ) ) {
			$user_id = $subscription->get_user_id();
			$user = get_userdata( $user_id );
			if ( empty( $user ) ) {
				$logstr .= "Couldn't find the subscription's user. Subscription ID = " . $subscription->get_id() . '.';
				return;
			}

			// Prep order for emails.
			$morder = new MemberOrder();
			$morder->user_id = $user_id;

			// Add invoice link to the order.
			$morder->invoice_url = $invoice->hosted_invoice_url;

			// Email the user and ask them to authenticate their payment.
			$pmproemail = new PMProEmail();
			$pmproemail->sendPaymentActionRequiredEmail( $user, $morder );

			// Email admin so they are aware.
			// TODO: Remove?
			$pmproemail = new PMProEmail();
			$pmproemail->sendPaymentActionRequiredAdminEmail( $user, $morder );

			$logstr .= 'Payment for subscription ID #' . $subscription->get_id() . ' requires customer authentication. Sent email to the member and site admin.';
			return;
		}

		$logstr .= 'Could not find the related subscription for event with ID #' . $pmpro_stripe_event->id . '.';
		if ( ! empty( $invoice->customer ) ) {
			$logstr .= ' Customer ID #' . $invoice->customer . '.';
		}
	}

	/**
	 * Handle charge.failed events.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param bool   $livemode           Whether this run is in live mode.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_charge_failed( $pmpro_stripe_event, $livemode, &$logstr ) {
		$invoice = null;

		// Make sure we have the charge in the desired API version.
		$charge = Stripe_Charge::retrieve( $pmpro_stripe_event->data->object->id );

		// Get the invoice for this charge if it exists.
		if ( ! empty( $charge->payment_intent ) ) {
			$invoice_payment = \Stripe\InvoicePayment::all(
				array(
					'payment' => array(
						'type'           => 'payment_intent',
						'payment_intent' => $charge->payment_intent,
					),
					'expand'  => array(
						'data.invoice',
					),
				)
			);
			// Using data[0] as only one invoice payment should match a search passing a specific payment intent ID.
			$invoice = empty( $invoice_payment->data[0]->invoice ) ? null : $invoice_payment->data[0]->invoice;
		}

		// If we don't have an invoice, bail.
		if ( empty( $invoice ) ) {
			$logstr .= 'Could not find an invoice for failed charge ' . $charge->id . '.';
			return;
		}

		// If we don't have a subscription on the invoice, bail.
		if ( empty( $invoice->parent->subscription_details->subscription ) ) {
			$logstr .= 'No subscription associated with invoice ' . $invoice->id . ' with failed payment.';
			return;
		}

		// If the subscription is no longer active in Stripe, bail.
		// We will handle this case with the customer.subscription.deleted event.
		try {
			$subscription = Stripe_Subscription::retrieve( $invoice->parent->subscription_details->subscription );
		} catch ( Exception $e ) {
			$logstr .= 'Error retrieving subscription ' . $invoice->parent->subscription_details->subscription . ' from Stripe: ' . $e->getMessage() . '. No action taken for failed payment.';
			return;
		}
		if ( empty( $subscription ) || ! in_array( $subscription->status, array( 'trialing', 'active', 'past_due' ), true ) ) {
			$logstr .= 'Subscription ' . ( ! empty( $subscription->id ) ? $subscription->id : $invoice->parent->subscription_details->subscription ) . ' is no longer active in Stripe. Status = ' . ( ! empty( $subscription->status ) ? $subscription->status : 'unknown' ) . '. No action taken for failed payment.';
			return;
		}

		$logstr .= pmpro_handle_recurring_payment_failure_at_gateway( self::get_order_data_from_invoice( $invoice, $charge->payment_method ) );
	}

	/**
	 * Handle customer.subscription.deleted events.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param bool   $livemode           Whether this run is in live mode.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_customer_subscription_deleted( $pmpro_stripe_event, $livemode, &$logstr ) {
		$logstr .= pmpro_handle_subscription_cancellation_at_gateway( $pmpro_stripe_event->data->object->id, 'stripe', $livemode ? 'live' : 'sandbox' );
	}

	/**
	 * Handle charge.refunded events.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_charge_refunded( $pmpro_stripe_event, &$logstr ) {
		// Make sure we have the charge in the desired API version.
		$charge = Stripe_Charge::retrieve( $pmpro_stripe_event->data->object->id );

		$payment_transaction_id = $charge->id;
		$morder = new MemberOrder();
		$morder->getMemberOrderByPaymentTransactionID( $payment_transaction_id );

		// Initial payment orders are stored using the invoice ID, so check that value too.
		if ( empty( $morder->id ) && ! empty( $charge->payment_intent ) ) {
			// Get the invoice for this charge if it exists.
			$invoice_payment = \Stripe\InvoicePayment::all(
				array(
					'payment' => array(
						'type'           => 'payment_intent',
						'payment_intent' => $charge->payment_intent,
					),
				)
			);
			// Using data[0] as only one invoice payment should match a search passing a specific payment intent ID.
			$payment_transaction_id = empty( $invoice_payment->data[0]->invoice ) ? null : $invoice_payment->data[0]->invoice;
			$morder->getMemberOrderByPaymentTransactionID( $payment_transaction_id );
		}

		// We've got the right order.
		if ( ! empty( $morder->id ) ) {
			// Ignore orders already in refund status.
			if ( 'refunded' === $morder->status ) {
				$logstr .= sprintf( 'Webhook: Order ID %1$s with transaction ID %2$s was already in refund status.', $morder->id, $payment_transaction_id );
				return;
			}

			// Handle partial refunds. Only updating the log and notes for now.
			if ( $pmpro_stripe_event->data->object->amount_refunded < $pmpro_stripe_event->data->object->amount ) {
				$logstr .= sprintf( 'Webhook: Order ID %1$s with transaction ID %2$s was partially refunded. The order will need to be updated in the WP dashboard.', $morder->id, $payment_transaction_id );

				$morder->add_order_note( sprintf( 'Webhook: Order ID %1$s was partially refunded for transaction ID %2$s at the gateway.', $morder->id, $payment_transaction_id ) );
				$morder->SaveOrder();
				return;
			}

			// Full refund.
			$morder->status = 'refunded';

			$logstr .= sprintf( 'Webhook: Order ID %1$s successfully refunded on %2$s for transaction ID %3$s at the gateway.', $morder->id, date_i18n( 'Y-m-d H:i:s' ), $payment_transaction_id );

			// Add to order notes.
			$morder->add_order_note( sprintf( 'Webhook: Order ID %1$s successfully refunded for transaction ID %2$s at the gateway.', $morder->id, $payment_transaction_id ) );

			$morder->SaveOrder();

			$user = get_userdata( $morder->user_id );
			if ( empty( $user ) ) {
				$logstr .= "Couldn't find the old order's user. Order ID = " . $morder->id . '.';
				return;
			}

			// Send an email to the member.
			$myemail = new PMProEmail();
			$myemail->sendRefundedEmail( $user, $morder );

			// Send an email to the admin.
			$myemail = new PMProEmail();
			$myemail->sendRefundedAdminEmail( $user, $morder );

			return;
		}

		// We can't find that order.
		$logstr .= sprintf( 'Webhook: Transaction ID %1$s was refunded at the gateway on %2$s, but we could not find a matching order.', $payment_transaction_id, date_i18n( 'Y-m-d H:i:s' ) );
	}

	/**
	 * Handle checkout.session.completed and checkout.session.async_payment_succeeded events.
	 *
	 * Both events run the same logic: the Checkout Session is re-retrieved from
	 * Stripe and all order data (transaction IDs, payment method, amounts) is
	 * derived from it, so completion does not depend on which event is being
	 * processed or on the order in which the events were delivered. If the
	 * session is not yet paid (delayed notification payment methods), the order
	 * is held at pending until a later event completes it.
	 *
	 * @since 3.8.4 Also handles checkout.session.async_payment_succeeded.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_checkout_session_completed( $pmpro_stripe_event, &$logstr ) {
		global $wpdb;
		$subscription = null;

		// Make sure we have the checkout session in the desired API version.
		$checkout_session = Stripe_Checkout_Session::retrieve( $pmpro_stripe_event->data->object->id );

		// Find the PMPro order for the checkout session.
		$order_id = $wpdb->get_var( $wpdb->prepare( "SELECT pmpro_membership_order_id FROM $wpdb->pmpro_membership_ordermeta WHERE meta_key = 'stripe_checkout_session_id' AND meta_value = %s LIMIT 1", $checkout_session->id ) );
		if ( empty( $order_id ) ) {
			$logstr .= 'Could not find an order for Checkout Session ' . $checkout_session->id;
			return;
		}
		$order = new MemberOrder( $order_id );
		if ( empty( $order ) ) {
			$logstr .= 'Order ID ' . $order_id . ' for Checkout Session ' . $checkout_session->id . ' could not be found.';
			return;
		}

		// Get the payment method object for this checkout and set transaction/subscription IDs.
		$payment_method = null;
		if ( 'payment' === $checkout_session->mode ) {
			// User purchased a one-time payment level. Assign the charge ID to the order.
			try {
				$payment_intent_args = array(
					'id'     => $checkout_session->payment_intent,
					'expand' => array(
						'payment_method',
						'latest_charge',
					),
				);
				$payment_intent = \Stripe\PaymentIntent::retrieve( $payment_intent_args );
				if ( ! empty( $checkout_session->invoice ) ) {
					$order->payment_transaction_id = $checkout_session->invoice;
				} elseif ( ! empty( $payment_intent->latest_charge ) ) {
					$order->payment_transaction_id = $payment_intent->latest_charge->id;
				}
				if ( ! empty( $payment_intent->payment_method ) ) {
					$payment_method = $payment_intent->payment_method;
				}
			} catch ( \Stripe\Error\Base $e ) {
				// Could not get payment intent. We just won't set a payment transaction ID.
			}
		} elseif ( 'subscription' === $checkout_session->mode ) {
			// User purchased a subscription. Assign the subscription ID and invoice ID to the order.
			$order->subscription_transaction_id = $checkout_session->subscription;
			try {
				$subscription_args = array(
					'id'     => $checkout_session->subscription,
					'expand' => array(
						'latest_invoice',
						'default_payment_method',
					),
				);
				$subscription = \Stripe\Subscription::retrieve( $subscription_args );
				if ( ! empty( $subscription->latest_invoice->id ) ) {
					$order->payment_transaction_id = $subscription->latest_invoice->id;
				}
				if ( ! empty( $subscription->default_payment_method ) ) {
					$payment_method = $subscription->default_payment_method;
				}
			} catch ( \Stripe\Error\Base $e ) {
				// Could not get invoices. We just won't set a payment transaction ID.
			}

			// Remove any application fee on the subscription. We will add it to individual invoices when they're created.
			try {
				Stripe_Subscription::update(
					$checkout_session->subscription,
					array(
						'application_fee_percent' => 0,
					)
				);
				$logstr .= 'Updated application fee for subscription ' . $checkout_session->subscription . ' to 0%.';
			} catch ( Exception $e ) {
				$logstr .= 'Could not update application fee for subscription ' . $checkout_session->subscription . '. ' . $e->getMessage();
			}
		}

		// Update payment method and billing address on order.
		if ( empty( $payment_method ) ) {
			$logstr .= 'Could not find payment method for Checkout Session ' . $checkout_session->id . '.';
		}
		self::populate_order_from_payment( $order, $payment_method, empty( $subscription ) ? null : $subscription->customer );

		// Update the amounts paid.
		$currency = pmpro_get_currency();
		$currency_unit_multiplier = pow( 10, intval( $currency['decimals'] ) );

		$order->total = (float) $checkout_session->amount_total / $currency_unit_multiplier;
		if ( in_array( get_option( 'pmpro_stripe_tax' ), array( 'inclusive', 'exclusive' ), true ) ) {
			// If Stripe calculated tax, use that. Otherwise, keep the tax calculated by PMPro.
			$order->subtotal = (float) $checkout_session->amount_subtotal / $currency_unit_multiplier;
			$order->tax = (float) $checkout_session->total_details->amount_tax / $currency_unit_multiplier;
		}

		// Was the checkout session successful?
		if ( 'paid' === $checkout_session->payment_status || 'no_payment_required' === $checkout_session->payment_status ) {
			self::process_checkout_session_completion( $order, $checkout_session, $logstr );
		} else {
			// No. The user is probably using a delayed notification payment method.
			// Set to pending in the meantime and wait for the next webhook.
			// Re-check the current status in the database first: a concurrent
			// checkout.session.async_payment_succeeded webhook may have completed
			// this order while we were making Stripe API calls above, and saving
			// our stale in-memory copy would clobber the completed order.
			$current_status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM $wpdb->pmpro_membership_orders WHERE id = %d", $order->id ) );
			if ( in_array( $current_status, array( 'token', 'pending' ), true ) ) {
				$order->status = 'pending';
				$order->saveOrder();
				$logstr .= 'Checkout Session ' . $checkout_session->id . ' has not yet been processed for PMPro order ID ' . $order->id . '.';
			} else {
				$logstr .= 'Order #' . $order->id . ' for Checkout Session ' . $checkout_session->id . ' was completed by a concurrent webhook. Not marking as pending.';
			}
		}
	}

	/**
	 * Handle checkout.session.async_payment_failed events.
	 *
	 * @param object $pmpro_stripe_event Stripe event object.
	 * @param string $logstr             Log output.
	 * @return void
	 */
	private static function handle_checkout_session_async_payment_failed( $pmpro_stripe_event, &$logstr ) {
		global $wpdb;

		// Make sure we have the checkout session in the desired API version.
		$checkout_session = Stripe_Checkout_Session::retrieve( $pmpro_stripe_event->data->object->id );

		// Find the PMPro order for the checkout session.
		$order_id = $wpdb->get_var( $wpdb->prepare( "SELECT pmpro_membership_order_id FROM $wpdb->pmpro_membership_ordermeta WHERE meta_key = 'stripe_checkout_session_id' AND meta_value = %s LIMIT 1", $checkout_session->id ) );
		if ( empty( $order_id ) ) {
			$logstr .= 'Could not find an order for Checkout Session ' . $checkout_session->id;
			return;
		}
		$order = new MemberOrder( $order_id );
		if ( empty( $order ) ) {
			$logstr .= 'Order ID ' . $order_id . ' for Checkout Session ' . $checkout_session->id . ' could not be found.';
			return;
		}

		// Mark the order as failed.
		$order->status = 'error';
		$order->saveOrder();

		// Email the user to notify them of failed payment.
		$pmproemail = new PMProEmail();
		$pmproemail->sendBillingFailureEmail( get_userdata( $order->user_id ), $order );

		// Email admin so they are aware of the failure.
		$pmproemail = new PMProEmail();
		$pmproemail->sendBillingFailureAdminEmail( get_bloginfo( 'admin_email' ), $order );

		$logstr .= 'Order #' . $order->id . ' for Checkout Session ' . $checkout_session->id . ' could not be processed.';
	}

	/**
	 * Handle invoice.created and invoice.upcoming events.
	 *
	 * @param object              $pmpro_stripe_event Stripe event object.
	 * @param bool                $livemode           Whether this run is in live mode.
	 * @param PMProGateway_stripe $stripe             Stripe gateway object.
	 * @param string              $logstr             Log output.
	 * @return void
	 */
	private static function handle_invoice_created_or_upcoming( $pmpro_stripe_event, $livemode, $stripe, &$logstr ) {
		// Make sure we have the invoice in the desired API version.
		if ( ! empty( $pmpro_stripe_event->data->object->id ) ) {
			$invoice = Stripe_Invoice::retrieve( $pmpro_stripe_event->data->object->id );
		} else {
			// For invoice.upcoming, use the object payload as-is.
			$invoice = $pmpro_stripe_event->data->object;
		}

		// Check if a subscription ID exists on the invoice.
		$subscription_id = empty( $invoice->parent->subscription_details->subscription ) ? null : $invoice->parent->subscription_details->subscription;
		if ( empty( $subscription_id ) ) {
			$invoice_id = empty( $invoice->id ) ? '[upcoming]' : $invoice->id;
			$logstr .= 'Invoice ' . $invoice_id . ' is not for a subscription and is therefore not a PMPro recurring payment. No action taken.';
			return;
		}

		// Make sure this is a PMPro subscription.
		$subscription = PMPro_Subscription::get_subscription_from_subscription_transaction_id( $subscription_id, 'stripe', $livemode ? 'live' : 'sandbox' );
		if ( empty( $subscription ) ) {
			$logstr .= 'Could not find a PMPro subscription with transaction ID ' . $subscription_id . '. No action taken.';
			return;
		}

		// Remove the application fee on the subscription. We will add it to individual invoices when they're created.
		try {
			Stripe_Subscription::update(
				$subscription_id,
				array(
					'application_fee_percent' => 0,
				)
			);
			$logstr .= 'Updated application fee for subscription ' . $subscription_id . ' to 0%.';
		} catch ( Exception $e ) {
			$logstr .= 'Could not update application fee for subscription ' . $subscription_id . '. ' . $e->getMessage();
		}

		// invoice.upcoming only needs the subscription-level application fee update.
		if ( 'invoice.upcoming' === $pmpro_stripe_event->type ) {
			return;
		}

		// If the invoice is not in draft status, we don't need to do anything.
		if ( 'draft' !== $invoice->status ) {
			$logstr .= 'Invoice ' . $invoice->id . ' is not in draft status. No action taken.';
			return;
		}

		// If the site is using API keys, we don't need to update the application fee.
		if ( $stripe->using_api_keys() ) {
			$logstr .= 'Using API keys, so not updating application fee for invoice ' . $invoice->id . '.';
			return;
		}

		// Update the application fee on the invoice.
		$application_fee = $stripe->get_application_fee_percentage();
		try {
			Stripe_Invoice::update(
				$invoice->id,
				array(
					'application_fee_amount' => floor( $invoice->amount_due * ( $application_fee / 100 ) ),
				)
			);
			$logstr .= 'Updated application fee for invoice ' . $invoice->id . ' to ' . $application_fee . '%.';
		} catch ( Exception $e ) {
			$logstr .= 'Could not update application fee for invoice ' . $invoice->id . '. ' . $e->getMessage();
		}
	}

	/**
	 * Finalize webhook logging/output.
	 *
	 * @param string $logstr      Log output.
	 * @param bool   $echo_output Whether to echo log output.
	 * @return void
	 */
	private static function result( $logstr, $echo_output = true ) {
		$logstr = (string) $logstr;

		// Use the global log buffer so callbacks can append context before final output.
		$GLOBALS['logstr'] = $logstr;

		/**
		 * Allow custom code to run before webhook response is finalized.
		 *
		 * @since 2.11
		 */
		do_action( 'pmpro_stripe_webhook_before_exit' );

		$logstr = empty( $GLOBALS['logstr'] ) ? $logstr : (string) $GLOBALS['logstr'];

		if ( ! empty( $logstr ) ) {
			$logstr = 'Logged On: ' . date_i18n( 'm/d/Y H:i:s' ) . "\n" . $logstr . "\n-------------\n";

			if ( $echo_output ) {
				echo esc_html( $logstr );
			}

			if ( defined( 'PMPRO_STRIPE_WEBHOOK_DEBUG' ) && PMPRO_STRIPE_WEBHOOK_DEBUG === 'log' ) {
				$logfile = apply_filters( 'pmpro_stripe_webhook_logfile', pmpro_get_restricted_file_path( 'logs', 'stripe-webhook.txt' ) );
				$loghandle = fopen( $logfile, 'a+' );
				fwrite( $loghandle, $logstr );
				fclose( $loghandle );
			} elseif ( defined( 'PMPRO_STRIPE_WEBHOOK_DEBUG' ) && false !== PMPRO_STRIPE_WEBHOOK_DEBUG ) {
				if ( strpos( PMPRO_STRIPE_WEBHOOK_DEBUG, '@' ) ) {
					$log_email = PMPRO_STRIPE_WEBHOOK_DEBUG;
				} else {
					$log_email = get_option( 'admin_email' );
				}

				wp_mail( $log_email, get_option( 'blogname' ) . ' Stripe Webhook Log', nl2br( esc_html( $logstr ) ) );
			}
		}

	}

	/**
	 * Process a successful Stripe Checkout Session for a PMPro order.
	 *
	 * Dedupes against orders that have already reached a terminal status,
	 * guards against concurrent webhook deliveries via a MySQL advisory lock,
	 * then completes the checkout and assigns the membership level.
	 *
	 * Steps:
	 * 1. Skip if the order has already been processed.
	 * 2. Acquire an advisory lock on the order; skip if another webhook holds it.
	 * 3. Pull checkout data from order meta.
	 * 4. Complete the checkout (change membership level, mark order successful,
	 *    record discount code use, save user meta, run pmpro_after_checkout,
	 *    send checkout emails).
	 * 5. Mark the order as errored if the checkout could not be completed.
	 * 6. Release the advisory lock.
	 *
	 * @since 2.8
	 * @since 3.7.3 Renamed from change_membership_level. Added concurrent-webhook
	 *            advisory lock and moved status/log handling inside.
	 *
	 * @param MemberOrder $morder           The order for the checkout being completed.
	 * @param object      $checkout_session The Stripe Checkout Session object.
	 * @param string      $logstr           Log output.
	 * @return void
	 */
	private static function process_checkout_session_completion( $morder, $checkout_session, &$logstr ) {
		global $wpdb;

		// Have we already processed this order?
		if ( ! in_array( $morder->status, array( 'token', 'pending' ), true ) ) {
			$logstr .= 'Order #' . $morder->id . ' for Checkout Session ' . $checkout_session->id . ' has already been processed. Ignoring.';
			return;
		}

		// Acquire a MySQL advisory lock scoped to this order. 0-second timeout:
		// try immediately, don't wait. Returns '1' if acquired, '0' if another
		// worker holds it, NULL on error (including hosts that disable GET_LOCK).
		// Lock names are scoped to the MySQL server (not the database), so the
		// lock name is namespaced with a hash of the database name + table prefix
		// to avoid cross-site collisions on shared MySQL instances where two
		// PMPro sites could otherwise share a lock when their order IDs happen
		// to match. Uses $wpdb->dbname rather than DB_NAME so hosts where the
		// constant isn't defined (per core's wp_set_wpdb_vars defensive default)
		// don't fatal here.
		$lock_name = 'pmpro_stripe_order_' . substr( md5( $wpdb->dbname . $wpdb->prefix ), 0, 12 ) . '_' . $morder->id;

		/**
		 * Filter whether to acquire MySQL advisory locks around critical
		 * sections that need protection from concurrent execution. Defaults
		 * to true. Site owners can return false as a system-wide escape
		 * hatch on hosts where persistent MySQL sessions or other
		 * environment quirks cause stuck advisory locks.
		 *
		 * @since 3.7.3
		 *
		 * @param bool $use_lock Whether to acquire the lock.
		 */
		if ( apply_filters( 'pmpro_use_advisory_locks', true ) ) {
			$got_lock = $wpdb->get_var( $wpdb->prepare( "SELECT GET_LOCK(%s, 0)", $lock_name ) );
		} else {
			// Advisory locks disabled site-wide via filter. Take the same
			// no-protection path as hosts where GET_LOCK is unavailable.
			$got_lock = null;
		}

		// $wpdb->get_var() returns numeric results as strings, hence the '0'/'1'
		// comparisons. Any other value (typically null) means GET_LOCK errored,
		// is not supported on this host, or was disabled via filter.
		if ( '0' === $got_lock ) {
			$logstr .= 'Order #' . $morder->id . ' for Checkout Session ' . $checkout_session->id . ' is already being processed by a concurrent webhook. Ignoring.';
			return;
		}

		if ( '1' !== $got_lock ) {
			// GET_LOCK errored, is unavailable on this host, or was disabled
			// via the pmpro_use_advisory_locks filter. Proceed without
			// concurrency protection so webhooks still work, but log a warning
			// so the admin can correlate if race-related anomalies appear.
			$logstr .= 'WARNING: MySQL advisory lock unavailable. Processing order #' . $morder->id . ' without concurrency protection. ';
		}

		try {
			// Re-read order status now that we hold the lock. The $morder in memory
			// may have been loaded before a prior concurrent worker persisted a
			// terminal status, so the pre-lock fast-path check above can't be
			// trusted as the correctness guard. This post-lock check is.
			$current_status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM $wpdb->pmpro_membership_orders WHERE id = %d", $morder->id ) );
			if ( ! in_array( $current_status, array( 'token', 'pending' ), true ) ) {
				$logstr .= 'Order #' . $morder->id . ' for Checkout Session ' . $checkout_session->id . ' was processed by a concurrent webhook while we waited for the lock. Ignoring.';
				return;
			}

			// For one-time payments, the Charge may not have existed yet at the original
			// checkout.session.completed event (e.g. async-cleared methods like bank transfers).
			// Fill in the transaction ID now while we hold the lock so it gets saved with the
			// rest of the order in pmpro_complete_async_checkout().
			if ( 'payment' === $checkout_session->mode
				&& empty( $morder->payment_transaction_id )
				&& ! empty( $checkout_session->payment_intent )
			) {
				try {
					$payment_intent = \Stripe\PaymentIntent::retrieve(
						array(
							'id'     => $checkout_session->payment_intent,
							'expand' => array( 'latest_charge' ),
						)
					);
					if ( ! empty( $payment_intent->latest_charge ) ) {
						$morder->payment_transaction_id = $payment_intent->latest_charge->id;
					}
				} catch ( \Stripe\Error\Base $e ) {
					// Could not get payment intent. We just won't set a payment transaction ID.
				}
			}

			pmpro_pull_checkout_data_from_order( $morder );
			if ( pmpro_complete_async_checkout( $morder ) ) {
				$logstr .= 'Order #' . $morder->id . ' for Checkout Session ' . $checkout_session->id . ' was processed successfully.';
			} else {
				$logstr .= 'Order #' . $morder->id . ' for Checkout Session ' . $checkout_session->id . ' could not be processed.';
				$morder->status = 'error';
				$morder->saveOrder();
			}
		} finally {
			// Release the advisory lock on every exit path: normal completion,
			// early return after the post-lock status re-check, or an exception
			// thrown by the checkout pipeline. Belt-and-suspenders alongside the
			// connection-close auto-release MySQL provides for advisory locks.
			if ( '1' === $got_lock ) {
				$wpdb->get_var( $wpdb->prepare( "SELECT RELEASE_LOCK(%s)", $lock_name ) );
			}
		}
	}

	/**
	 * Update order information from a Stripe payment method.
	 *
	 * @since 2.8
	 *
	 * @param MemberOrder          $order          The order to update.
	 * @param Stripe_PaymentMethod $payment_method The payment method object.
	 * @param string               $customer_id    The Stripe customer to try to pull a billing address from if not on the payment method.
	 */
	private static function populate_order_from_payment( $order, $payment_method, $customer_id = null ) {
		$customer = null;

		// Fill the "Payment Type" and credit card fields.
		if ( ! empty( $payment_method ) && ! empty( $payment_method->type ) ) {
			$order->payment_type = 'Stripe - ' . $payment_method->type;
			if ( ! empty( $payment_method->card ) ) {
				// Paid with a card, let's update order and user meta with the card info.
				$order->cardtype = $payment_method->card->brand;
				$order->accountnumber = hideCardNumber( $payment_method->card->last4 );
				$order->expirationmonth = $payment_method->card->exp_month;
				$order->expirationyear = $payment_method->card->exp_year;
			} else {
				$order->cardtype = '';
				$order->accountnumber = '';
				$order->expirationmonth = '';
				$order->expirationyear = '';
			}
		} else {
			// Some defaults.
			$order->payment_type = 'Stripe';
			$order->cardtype = '';
			$order->accountnumber = '';
			$order->expirationmonth = '';
			$order->expirationyear = '';
		}

		// Check if we have a billing address in the payment method.
		if ( ! empty( $payment_method ) && ! empty( $payment_method->billing_details ) && ! empty( $payment_method->billing_details->address ) && ! empty( $payment_method->billing_details->address->line1 ) ) {
			$order->billing = new stdClass();
			$order->billing->name = empty( $payment_method->billing_details->name ) ? '' : $payment_method->billing_details->name;
			$order->billing->street = empty( $payment_method->billing_details->address->line1 ) ? '' : $payment_method->billing_details->address->line1;
			$order->billing->street2 = empty( $payment_method->billing_details->address->line2 ) ? '' : $payment_method->billing_details->address->line2;
			$order->billing->city = empty( $payment_method->billing_details->address->city ) ? '' : $payment_method->billing_details->address->city;
			$order->billing->state = empty( $payment_method->billing_details->address->state ) ? '' : $payment_method->billing_details->address->state;
			$order->billing->zip = empty( $payment_method->billing_details->address->postal_code ) ? '' : $payment_method->billing_details->address->postal_code;
			$order->billing->country = empty( $payment_method->billing_details->address->country ) ? '' : $payment_method->billing_details->address->country;
			$order->billing->phone = empty( $payment_method->billing_details->phone ) ? '' : $payment_method->billing_details->phone;
		} else {
			// No billing address in the payment method, let's try to get it from the customer.
			if ( ! empty( $customer_id ) ) {
				$customer = Stripe_Customer::retrieve( $customer_id );
			}
			if ( ! empty( $customer ) && ! empty( $customer->address ) && ! empty( $customer->address->line1 ) ) {
				$order->billing = new stdClass();
				$order->billing->name = empty( $customer->name ) ? '' : $customer->name;
				$order->billing->street = empty( $customer->address->line1 ) ? '' : $customer->address->line1;
				$order->billing->street2 = empty( $customer->address->line2 ) ? '' : $customer->address->line2;
				$order->billing->city = empty( $customer->address->city ) ? '' : $customer->address->city;
				$order->billing->state = empty( $customer->address->state ) ? '' : $customer->address->state;
				$order->billing->zip = empty( $customer->address->postal_code ) ? '' : $customer->address->postal_code;
				$order->billing->country = empty( $customer->address->country ) ? '' : $customer->address->country;
				$order->billing->phone = empty( $customer->phone ) ? '' : $customer->phone;
			} else {
				// No billing address in the customer, let's try to get it from the old order or from user meta.
				$order->find_billing_address();
			}
		}
	}

	/**
	 * Build "extra order data" array from invoice to be passed to gateway request handler functions.
	 *
	 * @since 3.6
	 *
	 * @param Stripe_Invoice $invoice           The invoice object from Stripe.
	 * @param string         $payment_method_id Optional payment method ID to retrieve from Stripe.
	 *                                          Useful for failed payments where the invoice has no payment data.
	 * @return array The order data array.
	 */
	private static function get_order_data_from_invoice( $invoice, $payment_method_id = '' ) {
		global $pmpro_currency, $pmpro_currencies;

		$payment_method = null;
		$customer = null;

		// Build the order data array.
		// Note that we will not set id, user_id, membership_id, or status here.
		// Those will be set in the abstract gateway request handler functions.
		$order_data = array();

		// Set order data that is already formatted correctly.
		$order_data['gateway'] = 'stripe';
		$order_data['gateway_environment'] = ( ! empty( $invoice->livemode ) && $invoice->livemode ) ? 'live' : 'sandbox';
		$order_data['timestamp'] = $invoice->created;
		$order_data['subscription_transaction_id'] = $invoice->parent->subscription_details->subscription;
		$order_data['payment_transaction_id'] = $invoice->id;

		// Set order pricing data.
		$currency_unit_multiplier = 100; // Default to 100 cents / USD.
		if ( is_array( $pmpro_currencies[ $pmpro_currency ] ) && isset( $pmpro_currencies[ $pmpro_currency ]['decimals'] ) ) {
			$currency_unit_multiplier = pow( 10, intval( $pmpro_currencies[ $pmpro_currency ]['decimals'] ) );
		}
		$order_data['total']    = ( ! empty( $invoice->total ) ? $invoice->total / $currency_unit_multiplier : 0 );
		$order_data['subtotal'] = ( ! empty( $invoice->subtotal ) ? $invoice->subtotal / $currency_unit_multiplier : 0 );
		// invoice->tax was removed in Stripe API 2025-03-31.basil. Derive tax from total_excluding_tax instead.
		if ( null !== $invoice->total_excluding_tax ) {
			$order_data['tax'] = pmpro_round_price( $order_data['total'] - ( $invoice->total_excluding_tax / $currency_unit_multiplier ) );
		} else {
			$order_data['tax'] = 0;
		}

		// Set payment information data.
		// Find the payment method.
		// If we have a payment method ID, retrieve it directly from Stripe.
		// This is needed for failed payments where the invoice has no payment data.
		if ( ! empty( $payment_method_id ) ) {
			$payment_method = Stripe_PaymentMethod::retrieve( $payment_method_id );
		}

		// If we don't have a payment method yet, try to get it from the invoice's payment intent.
		if ( empty( $payment_method ) && ! empty( $invoice->payments->data[0]->payment->payment_intent ) ) {
			$payment_intent_args = array(
				'id'     => $invoice->payments->data[0]->payment->payment_intent,
				'expand' => array(
					'payment_method',
					'latest_charge',
				),
			);
			$payment_intent = \Stripe\PaymentIntent::retrieve( $payment_intent_args );

			// Find the payment method.
			if ( ! empty( $payment_intent->payment_method ) ) {
				$payment_method = $payment_intent->payment_method;
			} elseif ( ! empty( $payment_intent->latest_charge ) ) {
				// If we didn't get a payment method, check the charge.
				$payment_method = $payment_intent->latest_charge->payment_method_details;
			}
		}

		// Set the payment type and card info if we have a payment method.
		if ( ! empty( $payment_method ) ) {
			$order_data['payment_type'] = 'Stripe - ' . $payment_method->type;
			if ( ! empty( $payment_method->card ) ) {
				// Paid with a card, let's update order and user meta with the card info.
				$order_data['cardtype'] = $payment_method->card->brand;
				$order_data['accountnumber'] = hideCardNumber( $payment_method->card->last4 );
				$order_data['expirationmonth'] = $payment_method->card->exp_month;
				$order_data['expirationyear'] = $payment_method->card->exp_year;
			} else {
				// Not a card. Clear card fields in case the order being updated has card info from a previous payment attempt.
				$order_data['cardtype'] = '';
				$order_data['accountnumber'] = '';
				$order_data['expirationmonth'] = '';
				$order_data['expirationyear'] = '';
			}
		} else {
			$order_data['payment_type'] = 'Stripe';
			$order_data['cardtype'] = '';
			$order_data['accountnumber'] = '';
			$order_data['expirationmonth'] = '';
			$order_data['expirationyear'] = '';
		}

		// Set the billing address.
		if ( ! empty( $payment_method ) && ! empty( $payment_method->billing_details ) && ! empty( $payment_method->billing_details->address ) && ! empty( $payment_method->billing_details->address->line1 ) ) {
			$order_data['billing'] = new stdClass();
			$order_data['billing']->name = empty( $payment_method->billing_details->name ) ? '' : $payment_method->billing_details->name;
			$order_data['billing']->street = empty( $payment_method->billing_details->address->line1 ) ? '' : $payment_method->billing_details->address->line1;
			$order_data['billing']->street2 = empty( $payment_method->billing_details->address->line2 ) ? '' : $payment_method->billing_details->address->line2;
			$order_data['billing']->city = empty( $payment_method->billing_details->address->city ) ? '' : $payment_method->billing_details->address->city;
			$order_data['billing']->state = empty( $payment_method->billing_details->address->state ) ? '' : $payment_method->billing_details->address->state;
			$order_data['billing']->zip = empty( $payment_method->billing_details->address->postal_code ) ? '' : $payment_method->billing_details->address->postal_code;
			$order_data['billing']->country = empty( $payment_method->billing_details->address->country ) ? '' : $payment_method->billing_details->address->country;
			$order_data['billing']->phone = empty( $payment_method->billing_details->phone ) ? '' : $payment_method->billing_details->phone;
		} else {
			// No billing address in the payment method, let's try to get it from the customer.
			if ( ! empty( $invoice->customer ) ) {
				$customer = Stripe_Customer::retrieve( $invoice->customer );
			}
			if ( ! empty( $customer ) && ! empty( $customer->address ) && ! empty( $customer->address->line1 ) ) {
				$order_data['billing'] = new stdClass();
				$order_data['billing']->name = empty( $customer->name ) ? '' : $customer->name;
				$order_data['billing']->street = empty( $customer->address->line1 ) ? '' : $customer->address->line1;
				$order_data['billing']->street2 = empty( $customer->address->line2 ) ? '' : $customer->address->line2;
				$order_data['billing']->city = empty( $customer->address->city ) ? '' : $customer->address->city;
				$order_data['billing']->state = empty( $customer->address->state ) ? '' : $customer->address->state;
				$order_data['billing']->zip = empty( $customer->address->postal_code ) ? '' : $customer->address->postal_code;
				$order_data['billing']->country = empty( $customer->address->country ) ? '' : $customer->address->country;
				$order_data['billing']->phone = empty( $customer->phone ) ? '' : $customer->phone;
			} else {
				// No billing address in the customer, so try to pull it from the most recent subscription order.
				$last_order = MemberOrder::get_order(
					array(
						'gateway'                     => $order_data['gateway'],
						'gateway_environment'         => $order_data['gateway_environment'],
						'subscription_transaction_id' => $order_data['subscription_transaction_id'],
						'status'                      => 'success',
					)
				);
				if ( ! empty( $last_order ) && ! empty( $last_order->billing ) ) {
					$order_data['billing'] = new stdClass();
					$order_data['billing']->name = empty( $last_order->billing->name ) ? '' : $last_order->billing->name;
					$order_data['billing']->street = empty( $last_order->billing->street ) ? '' : $last_order->billing->street;
					$order_data['billing']->street2 = empty( $last_order->billing->street2 ) ? '' : $last_order->billing->street2;
					$order_data['billing']->city = empty( $last_order->billing->city ) ? '' : $last_order->billing->city;
					$order_data['billing']->state = empty( $last_order->billing->state ) ? '' : $last_order->billing->state;
					$order_data['billing']->zip = empty( $last_order->billing->zip ) ? '' : $last_order->billing->zip;
					$order_data['billing']->country = empty( $last_order->billing->country ) ? '' : $last_order->billing->country;
					$order_data['billing']->phone = empty( $last_order->billing->phone ) ? '' : $last_order->billing->phone;
				}
			}
		}

		return $order_data;
	}
}
