<?php
/**
 * WC_Payments_Webhook_Processing_Service class
 *
 * @package WooCommerce\Payments
 */

use WCPay\Constants\Order_Status;
use WCPay\Constants\Payment_Method;
use WCPay\Core\Server\Request\Get_Intention;
use WCPay\Database_Cache;
use WCPay\Exceptions\Invalid_Payment_Method_Exception;
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
use WCPay\Exceptions\Order_Not_Found_Exception;
use WCPay\Exceptions\Rest_Request_Exception;
use WCPay\Logger;
use WCPay\Constants\Refund_Status;
use WCPay\Constants\Refund_Failure_Reason;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Service to process webhook data.
 */
class WC_Payments_Webhook_Processing_Service {
	/**
	 * Client for making requests to the WooCommerce Payments API
	 *
	 * @var WC_Payments_API_Client
	 */
	protected $api_client;

	/**
	 * DB wrapper.
	 *
	 * @var WC_Payments_DB
	 */
	private $wcpay_db;

	/**
	 * WC Payments Account.
	 *
	 * @var WC_Payments_Account
	 */
	private $account;

	/**
	 * WC Payments Remote Note Service.
	 *
	 * @var WC_Payments_Remote_Note_Service
	 */
	private $remote_note_service;

	/**
	 * WC_Payments_Order_Service instance
	 *
	 * @var WC_Payments_Order_Service
	 */
	protected $order_service;

	/**
	 * WC_Payments_In_Person_Payments_Receipts_Service
	 *
	 * @var WC_Payments_In_Person_Payments_Receipts_Service
	 */
	private $receipt_service;

	/**
	 * WC_Payment_Gateway_WCPay
	 *
	 * @var WC_Payment_Gateway_WCPay
	 */
	private $wcpay_gateway;

	/**
	 * Database_Cache instance.
	 *
	 * @var Database_Cache
	 */
	private $database_cache;

	/**
	 * WC_Payments_Onboarding_Service instance.
	 *
	 * @var WC_Payments_Onboarding_Service
	 */
	private $onboarding_service;

	/**
	 * WC_Payments_Token_Service instance.
	 *
	 * @var WC_Payments_Token_Service
	 */
	private $token_service;

	/**
	 * WC_Payments_Webhook_Processing_Service constructor.
	 *
	 * @param WC_Payments_API_Client                          $api_client          WooCommerce Payments API client.
	 * @param WC_Payments_DB                                  $wcpay_db            WC_Payments_DB instance.
	 * @param WC_Payments_Account                             $account             WC_Payments_Account instance.
	 * @param WC_Payments_Remote_Note_Service                 $remote_note_service WC_Payments_Remote_Note_Service instance.
	 * @param WC_Payments_Order_Service                       $order_service       WC_Payments_Order_Service instance.
	 * @param WC_Payments_In_Person_Payments_Receipts_Service $receipt_service     WC_Payments_In_Person_Payments_Receipts_Service instance.
	 * @param WC_Payment_Gateway_WCPay                        $wcpay_gateway       WC_Payment_Gateway_WCPay instance.
	 * @param Database_Cache                                  $database_cache      Database_Cache instance.
	 * @param WC_Payments_Onboarding_Service                  $onboarding_service  WC_Payments_Onboarding_Service instance.
	 * @param WC_Payments_Token_Service                       $token_service       WC_Payments_Token_Service instance.
	 */
	public function __construct(
		WC_Payments_API_Client $api_client,
		WC_Payments_DB $wcpay_db,
		WC_Payments_Account $account,
		WC_Payments_Remote_Note_Service $remote_note_service,
		WC_Payments_Order_Service $order_service,
		WC_Payments_In_Person_Payments_Receipts_Service $receipt_service,
		WC_Payment_Gateway_WCPay $wcpay_gateway,
		Database_Cache $database_cache,
		WC_Payments_Onboarding_Service $onboarding_service,
		WC_Payments_Token_Service $token_service
	) {
		$this->wcpay_db            = $wcpay_db;
		$this->account             = $account;
		$this->remote_note_service = $remote_note_service;
		$this->order_service       = $order_service;
		$this->api_client          = $api_client;
		$this->receipt_service     = $receipt_service;
		$this->wcpay_gateway       = $wcpay_gateway;
		$this->database_cache      = $database_cache;
		$this->onboarding_service  = $onboarding_service;
		$this->token_service       = $token_service;
	}

	/**
	 * Process webhook event data.
	 *
	 * @param  array $event_body Body data of webhook request.
	 *
	 * @return void
	 *
	 * @throws Invalid_Webhook_Data_Exception
	 */
	public function process( array $event_body ) {
		// Extract information about the webhook event.
		$event_type = $this->read_webhook_property( $event_body, 'type' );
		$event_id   = '';
		try {
			$event_id = $this->read_webhook_property( $event_body, 'id' );
		} catch ( Invalid_Webhook_Data_Exception $e ) {
			Logger::error( 'Webhook event ID not found' );
		}

		Logger::debug(
			'WEBHOOK RECEIVED: ' . $event_type . ' ' . $event_id,
			[
				'body' => WC_Payments_Utils::redact_array( $event_body, WC_Payments_API_Client::API_KEYS_TO_REDACT ),
			]
		);

		if ( $this->is_webhook_mode_mismatch( $event_body ) ) {
			return;
		}

		try {
			/**
			 * Fires before a WooPayments webhook event is processed.
			 *
			 * @since 1.8.0
			 *
			 * @param string $event_type The webhook event type.
			 * @param array  $event_body The webhook event body.
			 */
			do_action( 'woocommerce_payments_before_webhook_delivery', $event_type, $event_body );
		} catch ( Exception $e ) {
			Logger::error( $e );
		}

		switch ( $event_type ) {
			case 'charge.refunded':
				$this->process_webhook_refund_triggered_externally( $event_body );
				break;
			case 'charge.refund.updated':
				$this->process_webhook_refund_updated( $event_body );
				break;
			case 'charge.dispute.created':
				$this->process_webhook_dispute_created( $event_body );
				break;
			case 'charge.dispute.closed':
				$this->process_webhook_dispute_closed( $event_body );
				break;
			case 'charge.dispute.funds_reinstated':
			case 'charge.dispute.funds_withdrawn':
			case 'charge.dispute.updated':
				$this->process_webhook_dispute_updated( $event_body );
				break;
			case 'charge.expired':
				$this->process_webhook_expired_authorization( $event_body );
				break;
			case 'account.updated':
				$this->account->refresh_account_data();
				$this->token_service->clear_all_cached_payment_methods();
				break;
			case 'account.deleted':
				$this->onboarding_service->cleanup_on_account_reset();
				// Reset the WooCommerce NOX data, if it is not already.
				delete_option( WC_Payments_Account::NOX_PROFILE_OPTION_KEY );
				// NOX onboarding should be unlocked by the time we receive this event,
				// but unlock it just in case, to maintain sanity.
				delete_option( WC_Payments_Account::NOX_ONBOARDING_LOCKED_KEY );

				// Refetch the account data to allow the platform to drive the available next steps.
				$this->account->refresh_account_data();

				// Use the opportunity to clear cached payment methods.
				$this->token_service->clear_all_cached_payment_methods();
				break;
			case 'wcpay.notification':
				$this->process_wcpay_notification( $event_body );
				break;
			case 'payment_intent.payment_failed':
				$this->process_webhook_payment_intent_failed( $event_body );
				break;
			case 'payment_intent.succeeded':
				$this->process_webhook_payment_intent_succeeded( $event_body );
				break;
			case 'payment_intent.canceled':
				$this->process_webhook_payment_intent_canceled( $event_body );
				break;
			case 'payment_intent.amount_capturable_updated':
				$this->process_webhook_payment_intent_amount_capturable_updated( $event_body );
				break;
			case 'invoice.upcoming':
			case 'invoice.paid':
			case 'invoice.payment_failed':
				$this->process_webhook_stripe_billing_invoice( $event_type, $event_body );
				break;
		}

		try {
			/**
			 * Fires after a WooPayments webhook event has been processed.
			 *
			 * @since 1.8.0
			 *
			 * @param string $event_type The webhook event type.
			 * @param array  $event_body The webhook event body.
			 */
			do_action( 'woocommerce_payments_after_webhook_delivery', $event_type, $event_body );
		} catch ( Exception $e ) {
			Logger::error( $e );
		}
	}

	/**
	 * Check webhook mode against the gateway mode.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @return bool Indicates whether the event's mode is different from the gateway's mode
	 * @throws Invalid_Webhook_Data_Exception Event mode does not match the gateway mode.
	 */
	private function is_webhook_mode_mismatch( array $event_body ): bool {
		if ( ! $this->has_webhook_property( $event_body, 'livemode' ) ) {
			return false;
		}

		$is_gateway_live_mode = WC_Payments::mode()->is_live();
		$is_event_live_mode   = $this->read_webhook_property( $event_body, 'livemode' );

		if ( $is_gateway_live_mode !== $is_event_live_mode ) {
			$event_id = $this->read_webhook_property( $event_body, 'id' );

			Logger::error(
				sprintf(
					'Webhook event mode did not match the gateway mode (event ID: %s)',
					$event_id
				)
			);

			return true;
		}

		return false;
	}

	/**
	 * Process webhook refund updated.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception           Required parameters not found.
	 * @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
	 */
	private function process_webhook_refund_updated( $event_body ) {
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );

		// Fetch the details of the failed refund so that we can find the associated order and write a note.
		$charge_id           = $this->read_webhook_property( $event_object, 'charge' );
		$refund_id           = $this->read_webhook_property( $event_object, 'id' );
		$amount              = $this->read_webhook_property( $event_object, 'amount' );
		$currency            = $this->read_webhook_property( $event_object, 'currency' );
		$status              = $this->read_webhook_property( $event_object, 'status' );
		$balance_transaction = $this->has_webhook_property( $event_object, 'balance_transaction' )
			? $this->read_webhook_property( $event_object, 'balance_transaction' )
			: null;

		// Look up the order related to this charge.
		$order = $this->wcpay_db->order_from_charge_id( $charge_id );
		if ( ! $order ) {
			throw new Invalid_Payment_Method_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				),
				'order_not_found'
			);
		}

		$matched_wc_refund = null;
		/**
		 * Get the WC_Refund from the WCPay refund ID.
		 *
		 * @var WC_Order_Refund[] $wc_refunds
		 * */
		$wc_refunds = $order->get_refunds();
		if ( ! empty( $wc_refunds ) ) {
			foreach ( $wc_refunds as $wc_refund ) {
				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
				if ( $refund_id === $wcpay_refund_id ) {
					$matched_wc_refund = $wc_refund;
					break;
				}
			}
		}

		// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
		switch ( $status ) {
			case Refund_Status::FAILED:
				$failure_reason = $this->has_webhook_property( $event_object, 'failure_reason' )
					? $this->read_webhook_property( $event_object, 'failure_reason' )
					: null;
				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, false, $failure_reason );
				break;
			case Refund_Status::CANCELED:
				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, true );
				break;
			case Refund_Status::SUCCEEDED:
				if ( $matched_wc_refund ) {
					$this->order_service->add_note_and_metadata_for_created_refund( $order, $matched_wc_refund, $refund_id, $balance_transaction ?? null );
				}
				break;
			default:
				throw new Invalid_Webhook_Data_Exception( 'Invalid refund update status: ' . $status );
		}
	}

	/**
	 * Process webhook for an expired uncaptured payment.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception           Required parameters not found.
	 * @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
	 */
	private function process_webhook_expired_authorization( $event_body ) {
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );

		// Fetch the details of the expired auth so that we can find the associated order.
		$charge_id = $this->read_webhook_property( $event_object, 'id' );

		// Look up the order related to this charge.
		$order = $this->wcpay_db->order_from_charge_id( $charge_id );
		if ( ! $order ) {
			throw new Invalid_Payment_Method_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				),
				'order_not_found'
			);
		}

		// Get the intent_id and then its status.
		$intent_id = $event_object['payment_intent'] ?? $order->get_meta( '_intent_id' );

		$request = Get_Intention::create( $intent_id );
		$request->set_hook_args( $order );
		$intent = $request->send();

		$intent_status = $intent->get_status();

		// TODO: Revisit this logic once we support partial captures or multiple charges for order. We'll need to handle the "payment_intent.canceled" event too.
		$this->order_service->mark_payment_capture_expired( $order, $intent_id, $intent_status, $charge_id );

		// Clear the authorization summary cache to trigger a fetch of new data.
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
	}

	/**
	 * Process webhook for a payment intent canceled event.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @return void
	 */
	private function process_webhook_payment_intent_canceled( $event_body ) {
		// Clear the authorization summary cache to trigger a fetch of new data.
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
	}

	/**
	 * Process webhook for a payment intent amount capturable updated event.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @return void
	 */
	private function process_webhook_payment_intent_amount_capturable_updated( $event_body ) {
		// Clear the authorization summary cache to trigger a fetch of new data.
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
	}

	/**
	 * Process webhook for a failed payment intent.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception   Required parameters not found.
	 * @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
	 */
	private function process_webhook_payment_intent_failed( $event_body ) {
		// Check to make sure we should process this according to the payment method.
		$charge_id           = $event_body['data']['object']['charges']['data'][0]['id'] ?? '';
		$last_payment_error  = $event_body['data']['object']['last_payment_error'] ?? null;
		$payment_method      = $last_payment_error['payment_method'] ?? null;
		$payment_method_type = $payment_method['type'] ?? null;

		$actionable_methods = [
			Payment_Method::CARD,
			Payment_Method::CARD_PRESENT,
			Payment_Method::US_BANK_ACCOUNT,
			Payment_Method::BECS,
			Payment_Method::WECHAT_PAY,
		];

		if ( empty( $payment_method_type ) || ! in_array( $payment_method_type, $actionable_methods, true ) ) {
			return;
		}

		// Get the order and make sure it is an order and the payment methods match.
		$order             = $this->get_order_from_event_body( $event_body );
		$payment_method_id = $payment_method['id'] ?? null;

		if ( ! $order || empty( $payment_method_id ) ) {
			return;
		}

		if ( Payment_Method::CARD_PRESENT !== $payment_method_type && $payment_method_id !== $order->get_meta( '_payment_method_id' ) ) {
			return;
		}

		$event_data    = $this->read_webhook_property( $event_body, 'data' );
		$event_object  = $this->read_webhook_property( $event_data, 'object' );
		$intent_id     = $this->read_webhook_property( $event_object, 'id' );
		$intent_status = $this->read_webhook_property( $event_object, 'status' );
		if ( Payment_Method::CARD_PRESENT === $payment_method_type ) {
			$this->order_service->mark_terminal_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) );
		} else {
			$this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) );
		}
	}

	/**
	 * Process webhook for a successful payment intent.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception   Required parameters not found.
	 * @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order.
	 */
	private function process_webhook_payment_intent_succeeded( $event_body ) {
		$event_data    = $this->read_webhook_property( $event_body, 'data' );
		$event_object  = $this->read_webhook_property( $event_data, 'object' );
		$intent_id     = $this->read_webhook_property( $event_object, 'id' );
		$currency      = $this->read_webhook_property( $event_object, 'currency' );
		$order         = $this->get_order_from_event_body( $event_body );
		$event_charges = $this->read_webhook_property( $event_object, 'charges' );
		$charges_data  = $this->read_webhook_property( $event_charges, 'data' );
		$charge_id     = $this->read_webhook_property( $charges_data[0], 'id' );
		$charge_amount = $this->read_webhook_property( $event_object, 'amount' );

		$payment_method_id = $charges_data[0]['payment_method'] ?? null;
		if ( ! $order ) {
			return;
		}

		// Update missing intents because webhook can be delivered before order is processed on the client.
		$meta_data_to_update = [
			'_intent_id'         => $intent_id,
			'_charge_id'         => $charge_id,
			'_payment_method_id' => $payment_method_id,
			WC_Payments_Utils::ORDER_INTENT_CURRENCY_META_KEY => $currency,
		];

		// Save mandate id, necessary for some subscription renewals.
		$mandate_id = $event_data['object']['charges']['data'][0]['payment_method_details']['card']['mandate'] ?? null;
		if ( $mandate_id ) {
			$meta_data_to_update['_stripe_mandate_id'] = $mandate_id;
		}

		// FEE_BREAKDOWN_FORK_PATCH: remove when envelope is the only path.
		// Prefer the server-driven fee_breakdown_v1 envelope carried on the
		// forwarded webhook body when present. Its totals are authoritative
		// (post-refund, tax-adjusted) so we can always write correct meta
		// without the prior truthy-check skipping $0 values.
		$fee_breakdown_v1 = $charges_data[0]['fee_breakdown_v1'] ?? null;
		if ( is_array( $fee_breakdown_v1 ) && isset( $fee_breakdown_v1['totals']['fee']['amount'], $fee_breakdown_v1['totals']['fee']['currency'] ) ) {
			$meta_data_to_update['_wcpay_transaction_fee'] = WC_Payments_Utils::interpret_stripe_amount(
				(int) $fee_breakdown_v1['totals']['fee']['amount'],
				$fee_breakdown_v1['totals']['fee']['currency']
			);
			if ( isset( $fee_breakdown_v1['totals']['net']['amount'], $fee_breakdown_v1['totals']['net']['currency'] ) ) {
				$meta_data_to_update['_wcpay_net'] = WC_Payments_Utils::interpret_stripe_amount(
					(int) $fee_breakdown_v1['totals']['net']['amount'],
					$fee_breakdown_v1['totals']['net']['currency']
				);
			}
		} else {
			$application_fee_amount = $charges_data[0]['application_fee_amount'] ?? null;

			if ( $application_fee_amount ) {
				$fee = WC_Payments_Utils::interpret_stripe_amount( $application_fee_amount, $currency );
				$meta_data_to_update['_wcpay_transaction_fee'] = $fee;

				$charge_amount                     = WC_Payments_Utils::interpret_stripe_amount( $charge_amount, $currency );
				$meta_data_to_update['_wcpay_net'] = $charge_amount - $fee;
			}
		}

		// Keys we always write when present in the envelope — including
		// legitimate $0 values — because their truthiness is semantically
		// meaningful for the order page "Transaction Fee" row and net.
		$authoritative_keys = [ '_wcpay_transaction_fee', '_wcpay_net' ];
		foreach ( $meta_data_to_update as $key => $value ) {
			if ( in_array( $key, $authoritative_keys, true ) ) {
				if ( null !== $value ) {
					$order->update_meta_data( $key, $value );
				}
				continue;
			}
			if ( $value ) {
				$order->update_meta_data( $key, $value );
			}
		}
		// Save the order after updating the meta data values.
		$order->save();

		// This is an incoming request from WCPay server rather than an outgoing request to WCPay server.
		// However, the shape of the payment intent object are the same.
		// Using this extraction method will reduce the code duplication.
		$ipp_channel      = $event_object['metadata']['ipp_channel'] ?? '';
		$allowed_channels = [ 'mobile_pos', 'mobile_store_management' ];
		if ( in_array( $ipp_channel, $allowed_channels, true ) ) {
			$this->order_service->set_ipp_channel_for_order( $order, $ipp_channel );
		}

		$payment_intent = $this->api_client->deserialize_payment_intention_object_from_array( $event_object );
		$this->order_service->update_order_status_from_intent( $order, $payment_intent );

		$payment_method = $charges_data[0]['payment_method_details']['type'] ?? null;
		// Send the customer a card reader receipt if it's an in person payment type.
		if ( Payment_Method::CARD_PRESENT === $payment_method || Payment_Method::INTERAC_PRESENT === $payment_method ) {
			$merchant_settings = [
				'business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ),
				'support_info'  => [
					'address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ),
					'phone'   => $this->wcpay_gateway->get_option( 'account_business_support_phone' ),
					'email'   => $this->wcpay_gateway->get_option( 'account_business_support_email' ),
				],
			];
			$this->receipt_service->send_customer_ipp_receipt_email( $order, $merchant_settings, $charges_data[0] );
		}

		// Clear the authorization summary cache to trigger a fetch of new data.
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
		$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
	}

	/**
	 * Process webhook dispute created.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception Required parameters not found.
	 */
	private function process_webhook_dispute_created( $event_body ) {
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );
		$charge_id    = $this->read_webhook_property( $event_object, 'charge' );
		$reason       = $this->read_webhook_property( $event_object, 'reason' );
		$amount_raw   = $this->read_webhook_property( $event_object, 'amount' );
		$evidence     = $this->read_webhook_property( $event_object, 'evidence_details' );
		$status       = $this->read_webhook_property( $event_object, 'status' );
		$due_by       = $this->read_webhook_property( $evidence, 'due_by' );

		$order = $this->wcpay_db->order_from_charge_id( $charge_id );

		$currency      = $order->get_currency();
		$amount_string = wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount_raw, $currency ), [ 'currency' => strtoupper( $currency ) ] );

		// Explicitly add currency info if needed (multi-currency stores).
		$amount = WC_Payments_Explicit_Price_Formatter::get_explicit_price_with_currency( $amount_string, $currency );

		// Convert due_by to a date string in the store timezone.
		$due_by = date_i18n( wc_date_format(), $due_by );

		if ( ! $order ) {
			throw new Invalid_Webhook_Data_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				)
			);
		}

		$this->order_service->mark_payment_dispute_created( $order, $charge_id, $amount, $reason, $due_by, $status );

		// Clear dispute caches to trigger a fetch of new data.
		$this->database_cache->delete_dispute_caches();
	}

	/**
	 * Process webhook dispute closed.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception Required parameters not found.
	 */
	private function process_webhook_dispute_closed( $event_body ) {
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );
		$charge_id    = $this->read_webhook_property( $event_object, 'charge' );
		$status       = $this->read_webhook_property( $event_object, 'status' );
		$dispute_id   = $this->read_webhook_property( $event_object, 'id' );
		$order        = $this->wcpay_db->order_from_charge_id( $charge_id );

		if ( ! $order ) {
			throw new Invalid_Webhook_Data_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				)
			);
		}

		// Fetch dispute summary data.
		$dispute_summary = [];
		try {
			$dispute_summary = $this->api_client->get_dispute_summary( $dispute_id );
		} catch ( Exception $e ) {
			Logger::error(
				sprintf(
					'Failed to fetch dispute summary for dispute %1$s (charge %2$s): %3$s',
					$dispute_id,
					$charge_id,
					$e->getMessage()
				)
			);
		}

		$this->order_service->mark_payment_dispute_closed( $order, $charge_id, $status, $dispute_summary );

		// Clear dispute caches to trigger a fetch of new data.
		$this->database_cache->delete_dispute_caches();
	}

	/**
	 * Process webhook dispute updated.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception Required parameters not found.
	 */
	private function process_webhook_dispute_updated( $event_body ) {
		$event_type   = $this->read_webhook_property( $event_body, 'type' );
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );
		$charge_id    = $this->read_webhook_property( $event_object, 'charge' );
		$order        = $this->wcpay_db->order_from_charge_id( $charge_id );

		if ( ! $order ) {
			throw new Invalid_Webhook_Data_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				)
			);
		}

		switch ( $event_type ) {
			case 'charge.dispute.funds_withdrawn':
				$message = __( 'Payment dispute and fees have been deducted from your next payout', 'woocommerce-payments' );
				break;
			case 'charge.dispute.funds_reinstated':
				$message = __( 'Payment dispute funds have been reinstated', 'woocommerce-payments' );
				break;
			default:
				$message = __( 'Payment dispute has been updated', 'woocommerce-payments' );
		}

		$note = sprintf(
		/* translators: %1: the dispute message, %2: the dispute details URL */
			__( '%1$s. See <a href="%2$s">dispute overview</a> for more details.', 'woocommerce-payments' ),
			$message,
			add_query_arg(
				[ 'id' => $charge_id ],
				admin_url( 'admin.php?page=wc-admin&path=/payments/transactions/details' )
			)
		);

		if ( $this->order_service->order_note_exists( $order, $note ) ) {
			return;
		}

		$order->add_order_note( $note );

		// Clear dispute caches to trigger a fetch of new data.
		$this->database_cache->delete_dispute_caches();
	}

	/**
	 * Process notification data.
	 *
	 * @param  array $event_body The event that triggered the webhook.
	 *
	 * @return void
	 *
	 * @throws Invalid_Webhook_Data_Exception When data is not valid.
	 */
	private function process_wcpay_notification( array $event_body ) {
		$note = $this->read_webhook_property( $event_body, 'data' );

		// Convert exception Rest_Request_Exception to Invalid_Webhook_Data_Exception
		// to be compatible with the expected exception in process().
		try {
			$this->remote_note_service->put_note( $note );
		} catch ( Rest_Request_Exception $e ) {
			throw new Invalid_Webhook_Data_Exception( $e->getMessage() );
		}
	}

	/**
	 * Safely get a value from the webhook event body array.
	 *
	 * @param array  $items Array to read from.
	 * @param string $key   ID to fetch on.
	 *
	 * @return string|array|int|bool
	 * @throws Invalid_Webhook_Data_Exception Thrown if ID not set.
	 */
	private function read_webhook_property( $items, $key ) {
		if ( ! isset( $items[ $key ] ) ) {
			throw new Invalid_Webhook_Data_Exception(
				sprintf(
				/* translators: %1: ID being fetched */
					__( '%1$s not found in array', 'woocommerce-payments' ),
					$key
				)
			);
		}
		return $items[ $key ];
	}

	/**
	 * Safely check whether a webhook contains a property.
	 *
	 * @param array  $items Array to read from.
	 * @param string $key   ID to fetch on.
	 *
	 * @return bool
	 */
	private function has_webhook_property( $items, $key ) {
		return isset( $items[ $key ] );
	}

	/**
	 * Gets the order related to the event.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception   Required parameters not found.
	 * @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order.
	 *
	 * @return null|WC_Order
	 */
	private function get_order_from_event_body( $event_body ) {
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );
		$intent_id    = $this->read_webhook_property( $event_object, 'id' );
		$order_key    = $this->read_webhook_property( $event_object, 'metadata' )['order_key'] ?? null;

		// Look up the order related to this intent.
		$order = $this->wcpay_db->order_from_intent_id( $intent_id );

		if ( ! $order instanceof \WC_Order ) {
			// Retrieving order with order_id in case intent_id was not properly set.
			Logger::debug( 'intent_id not found, using order_id to retrieve order' );
			$metadata = $this->read_webhook_property( $event_object, 'metadata' );
			$order_id = $metadata['order_id'] ?? null;
			// If metadata order id is null, try to read from the charges metadata.
			if ( null === $order_id ) {
				$charges  = $this->read_webhook_property( $event_object, 'charges' );
				$charge   = $charges[0] ?? [];
				$order_id = $charge['metadata']['order_id'] ?? null;
			}

			if ( $order_id ) {
				$order = $this->wcpay_db->order_from_order_id( $order_id );
			} elseif ( ! empty( $event_object['invoice'] ) ) {
				// If the payment intent contains an invoice it is a WCPay Subscription-related intent and will be handled by the `invoice.paid` event.
				return null;
			}
		}

		/**
		 * If the order has been found, but there is an order key mismatch, it
		 * could be caused by another site creating orders with the same IDs
		 * while this site remains the primary webhook receiver.
		 */
		if ( null !== $order_key && $order instanceof WC_Order && $order->get_order_key() !== $order_key ) {
			Logger::debug(
				'Mismatching order key found while retrieving an order for webhook processing',
				[
					'intent_id'         => $intent_id,
					'order_id'          => $order->get_id(),
					'webhook_order_key' => $order_key,
					'local_order_key'   => $order->get_order_key(),
				]
			);
			return null;
		}

		if ( ! $order instanceof \WC_Order ) {
			throw new Invalid_Payment_Method_Exception(
				sprintf(
				/* translators: %1: intent ID */
					__( 'Could not find order via intent ID: %1$s', 'woocommerce-payments' ),
					$intent_id
				),
				'order_not_found'
			);
		}

		return $order;
	}

	/**
	 * Gets the proper failure message from the code in the error.
	 * Error codes from https://stripe.com/docs/error-codes.
	 *
	 * @param array $error The last payment error from the payment failed event.
	 *
	 * @return string The failure message.
	 */
	private function get_failure_message_from_error( $error ): string {
		$code         = $error['code'] ?? '';
		$decline_code = $error['decline_code'] ?? '';
		$message      = $error['message'] ?? '';

		switch ( $code ) {
			case 'account_closed':
				return __( "The customer's bank account has been closed.", 'woocommerce-payments' );
			case 'debit_not_authorized':
				return __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' );
			case 'insufficient_funds':
				return __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' );
			case 'no_account':
				return __( "The customer's bank account could not be located.", 'woocommerce-payments' );
			case 'payment_method_microdeposit_failed':
				return __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' );
			case 'payment_method_microdeposit_verification_attempts_exceeded':
				return __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' );
			case 'payment_intent_mandate_invalid':
				return __( 'The mandate used for this renewal payment is invalid. You may need to bring the customer back to your store and ask them to resubmit their payment information.', 'woocommerce-payments' );
			case 'card_declined':
				switch ( $decline_code ) {
					case 'debit_notification_undelivered':
						return __( "The customer's bank could not send pre-debit notification for the payment.", 'woocommerce-payments' );
					case 'transaction_not_approved':
						return __( 'For recurring payment greater than mandate amount or INR 15000, payment was not approved by the card holder.', 'woocommerce-payments' );
				}
		}

		// translators: %s Stripe error message.
		return sprintf( __( 'With the following message: <code>%s</code>', 'woocommerce-payments' ), $message );
	}

	/**
	 * Process webhook refund for events triggered externally.
	 *
	 * @param array $event_body The event that triggered the webhook.
	 *
	 * @throws Invalid_Webhook_Data_Exception           Required parameters not found.
	 * @throws Invalid_Webhook_Data_Exception           When the refund amount is not valid.
	 * @throws Order_Not_Found_Exception                When unable to resolve charge ID to order.
	 */
	private function process_webhook_refund_triggered_externally( array $event_body ): void {
		$event_data   = $this->read_webhook_property( $event_body, 'data' );
		$event_object = $this->read_webhook_property( $event_data, 'object' );

		$is_refunded_event = isset( $event_body['type'] ) && 'charge.refunded' === $event_body['type'];
		$status            = $this->read_webhook_property( $event_object, 'status' );
		if ( 'succeeded' !== $status || ! $is_refunded_event ) {
			return;
		}

		// Check if the charge was actually captured before processing the refund.
		// Stripe sends charge.refunded webhooks for cancelled authorizations even though no payment was captured.
		// We should not create WooCommerce refund objects for these cases as they cause negative values in analytics.
		$captured = $event_object['captured'] ?? false;
		if ( ! $captured ) {
			return;
		}

		// Fetch the details of the refund so that we can find the associated order and write a note.
		$charge_id                     = $this->read_webhook_property( $event_object, 'id' );
		$refund                        = $this->read_webhook_property( $event_object, 'refunds' )['data'][0]; // Most recent refund.
		$refund_id                     = $refund['id'] ?? '';
		$refund_reason                 = $refund['reason'] ?? '';
		$refund_balance_transaction_id = $refund['balance_transaction'] ?? '';
		$charge_amount                 = $this->read_webhook_property( $event_object, 'amount' );
		$currency                      = $this->read_webhook_property( $event_object, 'currency' );
		$refunded_amount               = WC_Payments_Utils::interpret_stripe_amount( $refund['amount'], $currency );
		$is_partial_refund             = $refund['amount'] < $charge_amount;

		// Look up the order related to this charge.
		$order = $this->wcpay_db->order_from_charge_id( $charge_id );
		if ( ! $order ) {
			throw new Order_Not_Found_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				),
				'order_not_found'
			);
		}
		// Only care about refunds that are triggered externally, i.e. outside WP Admin.
		// Refunds triggered in WP Admin are handled by WC_Payment_Gateway_WCPay::process_refund.
		$wc_refunds = $order->get_refunds();
		if ( ! empty( $wc_refunds ) ) {
			foreach ( $wc_refunds as $wc_refund ) {
				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
				if ( $refund_id === $wcpay_refund_id ) {
					return;
				}
			}
		}
		if ( $charge_amount < 0 || $refunded_amount > $order->get_total() ) {
			throw new Invalid_Webhook_Data_Exception(
				sprintf(
				/* translators: %1: charge ID */
					__( 'The refund amount is not valid for charge ID: %1$s', 'woocommerce-payments' ),
					$charge_id
				)
			);
		}

		$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, ( ! $is_partial_refund ? $order->get_items() : [] ) );
		// Process the refund in the order service.
		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, Refund_Status::PENDING === $refund['status'] );
	}

	/**
	 * Process webhook for Stripe Billing invoice events.
	 *
	 * @param string $event_type The type of event that triggered the webhook.
	 * @param array  $event_body The event that triggered the webhook.
	 *
	 * @return void
	 *
	 * @throws Invalid_Webhook_Data_Exception When the linked subscription is not found.
	 */
	private function process_webhook_stripe_billing_invoice( $event_type, $event_body ) {
		if ( ! class_exists( 'WC_Payments_Subscriptions' ) ) {
			return;
		}

		switch ( $event_type ) {
			case 'invoice.upcoming':
				WC_Payments_Subscriptions::get_event_handler()->handle_invoice_upcoming( $event_body );
				break;
			case 'invoice.paid':
				WC_Payments_Subscriptions::get_event_handler()->handle_invoice_paid( $event_body );
				break;
			case 'invoice.payment_failed':
				WC_Payments_Subscriptions::get_event_handler()->handle_invoice_payment_failed( $event_body );
				break;
		}
	}
}
