<?php
/**
 * ConvesioPay Subscription Renewal Trait
 *
 * Shared WooCommerce Subscriptions renewal logic for the ConvesioPay gateway family.
 * Used by: Card, Apple Pay, and Google Pay gateways.
 *
 * -----------------------------------------------------------------------------
 * WHY A TRAIT?
 * -----------------------------------------------------------------------------
 * All three gateways use the identical /payments/stored-card endpoint and carry
 * the same renewal routing logic. Duplicating ~350 lines into each gateway class
 * creates drift risk. A PHP trait is inlined at compile-time into the using class,
 * so $this->id, $this->get_return_url(), and $this->get_line_items() all resolve
 * correctly against the actual gateway instance with no interface requirement.
 *
 * Each using class gets its own copy of the static $renewal_hook_registered flag
 * (PHP trait static semantics), so Card, Apple Pay, and Google Pay each guard
 * their own hook independently.
 *
 * -----------------------------------------------------------------------------
 * META KEYS REFERENCE
 * -----------------------------------------------------------------------------
 * The renewal logic reads and writes the following order/subscription meta keys:
 *
 * READ from parent order:
 *   _convesiopay_customer_id          — ConvesioPay canonical customer ID.
 *                                       Written by PeachPay on first native renewal
 *                                       or by the migrator. May be absent on freshly
 *                                       migrated subscriptions.
 *   _convesiopay_stored_payment_method_id
 *                                     — Currently active renewal token. On migrated
 *                                       subs the migrator writes a ConvesioPay-format
 *                                       token (e.g. FXXW5G8JLKGK9S99) here from the
 *                                       API payment data — NOT the Adyen numeric ref.
 *   _pp_cpay_user_renewal_card        — Explicit flag ('1') that the customer
 *                                       intentionally selected a native ConvesioPay
 *                                       card via the "Card for renewals" selector or
 *                                       had one auto-assigned by a webhook delete
 *                                       event. Absent on fresh migrations.
 *   _adn_shopper_reference            — Legacy Adyen customer identity. Present on
 *                                       subscriptions migrated from the legacy
 *                                       ConvesioPay (Adyen) plugin.
 *   _adn_recurringDetailReference     — Legacy Adyen stored-card reference (e.g. 47702).
 *                                       Different format from ConvesioPay tokens.
 *                                       May be blank if the migrator did not capture it.
 *
 * READ from subscription (fallback when absent on parent order):
 *   _pp_migration_source              — Set to 'cpay' by the migrator on the
 *                                       subscription object to mark it as legacy.
 *   _adn_shopper_reference            — Same as above; subscription-level fallback.
 *   _adn_recurringDetailReference     — Same as above; subscription-level fallback.
 *
 * WRITTEN to parent order (when applicable):
 *   _convesiopay_customer_id          — Persisted during Combination 2 edge renewal
 *                                       when derived via MD5(email) fallback, so
 *                                       future renewals resolve it without re-deriving.
 *
 * WRITTEN to renewal order on success:
 *   _convesiopay_payment_id           — ConvesioPay payment ID from the API response.
 *   _convesiopay_customer_id          — Customer ID used for this renewal.
 *   _convesiopay_stored_payment_method_id
 *                                     — Token used for this renewal (when non-empty).
 *
 * -----------------------------------------------------------------------------
 * RENEWAL PATH COMBINATIONS (see process_subscription_renewal() for full logic)
 * -----------------------------------------------------------------------------
 *
 *  Combination 1 — Legacy subscription, original ADN card still active
 *  Condition : _pp_migration_source = cpay, _pp_cpay_user_renewal_card NOT set
 *  customer.id              : _adn_shopper_reference
 *  storedPaymentMethodId    : _adn_recurringDetailReference  (migrator token if blank)
 *  legacyStoredPaymentMethod: true
 *
 *  Combination 1 edge — ADN recurring ref is blank
 *  Condition : same as Combination 1 but _adn_recurringDetailReference = ''
 *  customer.id              : _adn_shopper_reference
 *  storedPaymentMethodId    : _convesiopay_stored_payment_method_id  (migrator value)
 *  legacyStoredPaymentMethod: true
 *
 *  Combination 2 — Legacy subscription, customer switched to a native card
 *  Condition : _pp_migration_source = cpay, _pp_cpay_user_renewal_card = '1',
 *              _convesiopay_customer_id present
 *  customer.id              : _convesiopay_customer_id
 *  storedPaymentMethodId    : _convesiopay_stored_payment_method_id  (selected card)
 *  legacyStoredPaymentMethod: not sent
 *
 *  Combination 2 edge — Native card selected but _convesiopay_customer_id missing
 *  Condition : same as Combination 2 but _convesiopay_customer_id = ''
 *  customer.id              : md5( strtolower( billing_email ) )
 *                             (persisted to parent order for future renewals)
 *  storedPaymentMethodId    : _convesiopay_stored_payment_method_id  (selected card)
 *  legacyStoredPaymentMethod: not sent
 *  NOTE: _adn_shopper_reference is NEVER used as fallback on the native path —
 *        it is an Adyen identity and would address the wrong customer context.
 *
 *  Combination 3 — Native PeachPay subscription (not migrated)
 *  Condition : _pp_migration_source absent / !== 'cpay'
 *  customer.id              : _convesiopay_customer_id
 *  storedPaymentMethodId    : _convesiopay_stored_payment_method_id
 *  legacyStoredPaymentMethod: not sent
 *
 * KEY RULE: legacyStoredPaymentMethod: true is sent if and only if
 *           _adn_shopper_reference is used as customer.id.
 *           It is never sent for native-path renewals, even on migrated subscriptions.
 *
 * -----------------------------------------------------------------------------
 * WHO WRITES _pp_cpay_user_renewal_card?
 * -----------------------------------------------------------------------------
 *  1. peachpay_convesiopay_save_subscription_card() in hooks.php
 *     — When the customer explicitly selects a card via the "Card for renewals" UI.
 *  2. PeachPay_ConvesioPay_Webhook_Payment_Methods delete handler
 *     — When a stored card is deleted and a replacement is auto-assigned by webhook.
 *
 * Without this flag, a migrated subscription ALWAYS uses the legacy path,
 * regardless of what token the migrator wrote to _convesiopay_stored_payment_method_id.
 * This is intentional: the migrator writes a ConvesioPay-format token (FXXW5G8JLKGK9S99)
 * while the ADN ref is a different format (47702). String comparison between the two
 * always differs even though no card change occurred, which previously caused every
 * migrated renewal to wrongly skip legacyStoredPaymentMethod: true and fail.
 *
 * @package PeachPay
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Provides WooCommerce Subscriptions renewal handling for ConvesioPay gateways.
 *
 * Used by PeachPay_ConvesioPay_Card_Gateway, PeachPay_ConvesioPay_ApplePay_Gateway,
 * and PeachPay_ConvesioPay_GooglePay_Gateway.
 *
 * Requirements on the using class:
 *   - $this->id must be the WooCommerce gateway ID string.
 *   - $this->get_return_url( WC_Order ) must be accessible (inherited from WC_Payment_Gateway).
 *   - $this->get_line_items( WC_Order ) must exist as a private/protected method.
 */
trait PeachPay_ConvesioPay_Subscription_Renewal_Trait {

	/**
	 * Tracks whether the scheduled-payment hook has been registered for this gateway
	 * class during the current request.
	 *
	 * PHP trait static properties are per-class (each using class gets its own copy),
	 * so Card, Apple Pay, and Google Pay each maintain an independent guard. This
	 * prevents duplicate callback registration when gateway objects are instantiated
	 * more than once during the WooCommerce bootstrap.
	 *
	 * @var bool
	 */
	private static $renewal_hook_registered = false;

	/**
	 * Register the WooCommerce Subscriptions scheduled-payment action hook.
	 *
	 * Called from each gateway constructor. The static guard ensures the hook is
	 * added at most once per request per gateway class, even if the constructor
	 * runs multiple times during WooCommerce's gateway initialization sequence.
	 *
	 * Hook registered: woocommerce_scheduled_subscription_payment_{gateway_id}
	 */
	private function register_subscription_renewal_hook() {
		if ( self::$renewal_hook_registered ) {
			return;
		}
		add_action(
			'woocommerce_scheduled_subscription_payment_' . $this->id,
			array( $this, 'handle_subscription_renewal_payment' ),
			10,
			2
		);
		self::$renewal_hook_registered = true;
	}

	/**
	 * Entry point called by WooCommerce Subscriptions when a scheduled renewal fires.
	 *
	 * Resolves the subscription and its parent order from the renewal order, logs
	 * the attempt, and delegates to process_subscription_renewal().
	 *
	 * Fails gracefully (logs + returns) when:
	 *   - WooCommerce Subscriptions is not active (wcs_get_subscriptions_for_renewal_order absent).
	 *   - No subscription is found for the renewal order.
	 *   - No parent order is found for the subscription.
	 *
	 * @param float    $renewal_total Amount to charge for this renewal.
	 * @param WC_Order $renewal_order The renewal order created by WC Subscriptions.
	 */
	public function handle_subscription_renewal_payment( $renewal_total, $renewal_order ) {
		if ( ! function_exists( 'wcs_get_subscriptions_for_renewal_order' ) ) {
			peachpay_log(
				'error',
				'ConvesioPay Subscription renewal failed - wcs_get_subscriptions_for_renewal_order not found',
				array(
					'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
					'component' => 'payments',
					'feature'   => 'convesiopay_gateway',
					'operation' => 'subscription.renewal',
					'outcome'   => 'failed',
					'line'      => __LINE__,
					'function'  => __FUNCTION__,
					'file'      => __FILE__,
					'class'     => __CLASS__,
					'method'    => __METHOD__,
					'data'      => array(
						'gateway_id'    => $this->id,
						'renewal_total' => $renewal_total,
					),
				)
			);
			return;
		}

		$subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order );
		$subscription  = array_pop( $subscriptions );

		if ( ! $subscription ) {
			peachpay_log(
				'error',
				'ConvesioPay Subscription renewal failed - no subscription found for renewal order',
				array(
					'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
					'component' => 'payments',
					'feature'   => 'convesiopay_gateway',
					'operation' => 'subscription.renewal',
					'outcome'   => 'failed',
					'line'      => __LINE__,
					'function'  => __FUNCTION__,
					'file'      => __FILE__,
					'class'     => __CLASS__,
					'method'    => __METHOD__,
					'data'      => array(
						'gateway_id'       => $this->id,
						'renewal_total'    => $renewal_total,
						'renewal_order_id' => $renewal_order->get_id(),
					),
				)
			);
			return;
		}

		$parent_order = wc_get_order( $subscription->get_parent_id() );

		if ( ! $parent_order ) {
			peachpay_log(
				'error',
				'ConvesioPay Subscription renewal failed - no parent order found for subscription',
				array(
					'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
					'component' => 'payments',
					'feature'   => 'convesiopay_gateway',
					'operation' => 'subscription.renewal',
					'outcome'   => 'failed',
					'line'      => __LINE__,
					'function'  => __FUNCTION__,
					'file'      => __FILE__,
					'class'     => __CLASS__,
					'method'    => __METHOD__,
					'data'      => array(
						'gateway_id'       => $this->id,
						'renewal_total'    => $renewal_total,
						'renewal_order_id' => $renewal_order->get_id(),
						'parent_order_id'  => $subscription->get_parent_id(),
					),
				)
			);
			return;
		}

		peachpay_log(
			'debug',
			'ConvesioPay Subscription renewal debug for subscription id - ' . $subscription->get_id(),
			array(
				'event'     => 'payments.convesiopay_gateway.subscription.renewal.debug',
				'component' => 'payments',
				'feature'   => 'convesiopay_gateway',
				'operation' => 'subscription.renewal',
				'outcome'   => 'debug',
				'line'      => __LINE__,
				'function'  => __FUNCTION__,
				'file'      => __FILE__,
				'class'     => __CLASS__,
				'method'    => __METHOD__,
				'data'      => array(
					'gateway_id'    => $this->id,
					'renewal_total' => $renewal_total,
				),
			)
		);

		$this->process_subscription_renewal( $parent_order, $renewal_order, $renewal_total, $subscription );

		peachpay_log(
			'debug',
			'ConvesioPay Subscription renewal debug for subscription id - ' . $subscription->get_id() . ' completed',
			array(
				'event'     => 'payments.convesiopay_gateway.subscription.renewal.debug',
				'component' => 'payments',
				'feature'   => 'convesiopay_gateway',
				'operation' => 'subscription.renewal',
				'outcome'   => 'debug',
				'line'      => __LINE__,
				'function'  => __FUNCTION__,
				'file'      => __FILE__,
				'class'     => __CLASS__,
				'method'    => __METHOD__,
			)
		);
	}

	/**
	 * Charge a subscription renewal via the ConvesioPay /payments/stored-card endpoint.
	 *
	 * -------------------------------------------------------------------------
	 * RENEWAL PATH SELECTION
	 * -------------------------------------------------------------------------
	 * The correct customer identity and token depend on whether the subscription
	 * was migrated from the legacy ConvesioPay (Adyen) plugin and, if so, whether
	 * the customer has since switched to a native ConvesioPay card.
	 *
	 * Three explicit combinations are evaluated in order:
	 *
	 * --- COMBINATION 1: Migrated subscription, original Adyen card still active ---
	 * Triggered when: _pp_migration_source = 'cpay' AND _pp_cpay_user_renewal_card NOT set.
	 * Payload:
	 *   customer.id              = _adn_shopper_reference
	 *   storedPaymentMethodId    = _adn_recurringDetailReference
	 *                              (falls back to _convesiopay_stored_payment_method_id
	 *                               if _adn_recurringDetailReference is blank — this is
	 *                               the migrator-written ConvesioPay-format token)
	 *   legacyStoredPaymentMethod = true
	 *
	 * IMPORTANT — why _pp_cpay_user_renewal_card is required to leave this path:
	 *   The subscription migrator writes a ConvesioPay-format token (e.g. FXXW5G8JLKGK9S99)
	 *   to _convesiopay_stored_payment_method_id. The original Adyen reference stored in
	 *   _adn_recurringDetailReference uses a different format (e.g. 47702 or KLDLD8LV53KWCN75).
	 *   These values always differ even though no card change has occurred. A string
	 *   comparison between them would incorrectly conclude the customer selected a native
	 *   card and route the renewal through the native path without legacyStoredPaymentMethod,
	 *   causing the ConvesioPay API to reject the request.
	 *   The explicit user-action flag is the only reliable gate.
	 *   (Production failure confirmed on transformationprayer.org sub #47702 — PP-6162.)
	 *
	 * --- COMBINATION 2: Migrated subscription, customer switched to a native card ---
	 * Triggered when: _pp_migration_source = 'cpay' AND _pp_cpay_user_renewal_card = '1'.
	 * The flag is written by:
	 *   a) peachpay_convesiopay_save_subscription_card() when the customer selects a
	 *      card via the "Card for renewals" selector in My Account.
	 *   b) PeachPay_ConvesioPay_Webhook_Payment_Methods delete handler when a card is
	 *      deleted and a replacement is auto-assigned.
	 * Payload:
	 *   customer.id              = _convesiopay_customer_id
	 *   storedPaymentMethodId    = _convesiopay_stored_payment_method_id (selected card)
	 *   legacyStoredPaymentMethod = not sent
	 *
	 * --- COMBINATION 2 EDGE: Same as Combination 2, but _convesiopay_customer_id missing ---
	 * This can occur if Card Manager used the MD5 fallback and it was not yet persisted,
	 * or if the canonical ID was manually removed from the parent order.
	 * Payload:
	 *   customer.id              = md5( strtolower( billing_email ) )
	 *                              The card was stored under this identity by Card Manager.
	 *                              The derived ID is persisted to _convesiopay_customer_id
	 *                              on the parent order so future renewals resolve it
	 *                              directly without re-deriving.
	 *   storedPaymentMethodId    = _convesiopay_stored_payment_method_id (selected card)
	 *   legacyStoredPaymentMethod = not sent
	 * NOTE: _adn_shopper_reference must NEVER be used as a fallback here. It is an Adyen
	 *       identity. Pairing it with a native ConvesioPay token addresses the wrong
	 *       customer context at the ConvesioPay API and would incorrectly trigger
	 *       legacyStoredPaymentMethod: true.
	 *
	 * --- COMBINATION 3: Native PeachPay subscription (not migrated) ---
	 * Triggered when: _pp_migration_source is absent or !== 'cpay'.
	 * The entire migrated-subscription block is skipped.
	 * Payload:
	 *   customer.id              = _convesiopay_customer_id
	 *   storedPaymentMethodId    = _convesiopay_stored_payment_method_id
	 *   legacyStoredPaymentMethod = not sent
	 *
	 * KEY RULE: legacyStoredPaymentMethod: true is sent if and only if
	 *           _adn_shopper_reference is used as customer.id.
	 *
	 * -------------------------------------------------------------------------
	 * IDEMPOTENCY
	 * -------------------------------------------------------------------------
	 * Two guards prevent double-charging:
	 *   1. Order status check: if the renewal order is already 'processing' or
	 *      'completed', return immediately. Covers the race between the WC Subscriptions
	 *      scheduled action and a concurrent webhook that completes the order first.
	 *   2. Payment ID check: if _convesiopay_payment_id is already set on the renewal
	 *      order, return immediately. Covers duplicate hook invocations within the
	 *      same request.
	 *
	 * -------------------------------------------------------------------------
	 * TEST SCENARIOS (QA reference)
	 * -------------------------------------------------------------------------
	 * T-C1   : Migrated sub, ADN ref present, no flag → legacy path, legacyFlag=true  ✅ tested
	 * T-C1E  : Migrated sub, ADN ref blank, no flag   → legacy path, migrator token,
	 *           legacyFlag=true                                                         ✅ tested
	 * T-C2   : Migrated sub, flag set, canonical ID present → native path, no legacyFlag ✅ tested
	 * T-C2E  : Migrated sub, flag set, canonical ID missing → MD5 fallback, native path  ⚠ not yet reproduced in QA
	 * T-C3   : Native PeachPay sub → native path, no legacyFlag                        ✅ tested
	 * T-IDEM : Renewal order already processing/completed → returns early, no charge    ✅ tested
	 * T-PROD : transformationprayer.org sub #47702 → legacy path after PP-6162 fix      ✅ tested
	 *
	 * @param WC_Order             $parent_order  The original subscription order that holds
	 *                                             the stored payment token and customer identity.
	 * @param WC_Order             $renewal_order The new renewal order to be charged.
	 * @param float                $renewal_total The amount to charge in the order's currency.
	 * @param WC_Subscription|null $subscription  The subscription object. Optional for backward
	 *                                             compatibility — when provided, it is used as a
	 *                                             fallback source for _adn_* and migration meta.
	 */
	public function process_subscription_renewal( $parent_order, $renewal_order, $renewal_total, $subscription = null ) {
		try {
			// -----------------------------------------------------------------
			// IDEMPOTENCY GUARD 1: Order status
			// Prevents double-charging when the WC Subscriptions scheduled action
			// fires while a concurrent webhook has already completed the order.
			// -----------------------------------------------------------------
			if ( $renewal_order->has_status( array( 'processing', 'completed' ) ) ) {
				peachpay_log(
					'warning',
					'ConvesioPay Subscription renewal warning - renewal order id - ' . $renewal_order->get_id() . ' already paid (status: ' . $renewal_order->get_status() . ')',
					array(
						'event'     => 'payments.convesiopay_gateway.subscription.renewal.warning',
						'component' => 'payments',
						'feature'   => 'convesiopay_gateway',
						'operation' => 'subscription.renewal',
						'outcome'   => 'warning',
						'line'      => __LINE__,
						'function'  => __FUNCTION__,
						'file'      => __FILE__,
						'class'     => __CLASS__,
						'method'    => __METHOD__,
						'data'      => array(
							'gateway_id'    => $this->id,
							'order_id'      => $renewal_order->get_id(),
							'order_status'  => $renewal_order->get_status(),
							'order_number'  => $renewal_order->get_order_number(),
							'renewal_total' => $renewal_total,
						),
					)
				);
				return;
			}

			// -----------------------------------------------------------------
			// IDEMPOTENCY GUARD 2: Payment ID already set
			// Prevents double-charging from duplicate hook invocations within
			// the same request lifecycle.
			// -----------------------------------------------------------------
			if ( ! empty( $renewal_order->get_meta( '_convesiopay_payment_id' ) ) ) {
				peachpay_log(
					'warning',
					'ConvesioPay Subscription renewal warning - renewal order id - ' . $renewal_order->get_id() . ' already has a payment id',
					array(
						'event'     => 'payments.convesiopay_gateway.subscription.renewal.warning',
						'component' => 'payments',
						'feature'   => 'convesiopay_gateway',
						'operation' => 'subscription.renewal',
						'outcome'   => 'warning',
						'line'      => __LINE__,
						'function'  => __FUNCTION__,
						'file'      => __FILE__,
						'class'     => __CLASS__,
						'method'    => __METHOD__,
						'data'      => array(
							'gateway_id'             => $this->id,
							'convesiopay_payment_id' => $renewal_order->get_meta( '_convesiopay_payment_id' ),
							'order_id'               => $renewal_order->get_id(),
							'order_status'           => $renewal_order->get_status(),
							'order_number'           => $renewal_order->get_order_number(),
							'renewal_total'          => $renewal_total,
						),
					)
				);
				return;
			}

			// -----------------------------------------------------------------
			// RESOLVE INITIAL TOKEN SOURCES (Combination 3 default)
			// For native PeachPay subscriptions these values are used directly.
			// For legacy-migrated subscriptions the block below may override them.
			// -----------------------------------------------------------------
			$customer_id                  = $parent_order->get_meta( '_convesiopay_customer_id' );
			$stored_payment_method_id     = $parent_order->get_meta( '_convesiopay_stored_payment_method_id' );
			$legacy_stored_payment_method = false; // Only set to true on the legacy Adyen path (Combinations 1 / 1-edge).

			// -----------------------------------------------------------------
			// DETECT LEGACY-MIGRATED SUBSCRIPTION
			// The migration marker is written on the subscription object by the
			// migrator. A fallback to the parent order handles older flows where
			// the marker was written there instead.
			// -----------------------------------------------------------------
			$is_legacy_cpay_migrated = false;
			if ( $subscription && is_callable( array( $subscription, 'get_meta' ) ) ) {
				// Primary: check the subscription object (set by the migrator).
				$is_legacy_cpay_migrated = ( 'cpay' === (string) $subscription->get_meta( '_pp_migration_source' ) );
			} elseif ( $parent_order ) {
				// Backward-compatible fallback: older/custom flows may have written
				// the marker to the parent order instead of the subscription.
				$is_legacy_cpay_migrated = ( 'cpay' === (string) $parent_order->get_meta( '_pp_migration_source' ) );
			}

			if ( $is_legacy_cpay_migrated ) {
				// -------------------------------------------------------------
				// RESOLVE LEGACY ADYEN REFERENCES
				// Parent order takes priority; subscription is the fallback.
				// Both _adn_shopper_reference and _adn_recurringDetailReference
				// may exist on either object depending on migration flow.
				// -------------------------------------------------------------
				$legacy_shopper_reference = trim( (string) $parent_order->get_meta( '_adn_shopper_reference' ) );
				if ( '' === $legacy_shopper_reference && $subscription && is_callable( array( $subscription, 'get_meta' ) ) ) {
					$legacy_shopper_reference = trim( (string) $subscription->get_meta( '_adn_shopper_reference' ) );
				}

				$legacy_recurring_ref = trim( (string) $parent_order->get_meta( '_adn_recurringDetailReference' ) );
				if ( '' === $legacy_recurring_ref && $subscription && is_callable( array( $subscription, 'get_meta' ) ) ) {
					$legacy_recurring_ref = trim( (string) $subscription->get_meta( '_adn_recurringDetailReference' ) );
				}

				// -------------------------------------------------------------
				// DETERMINE RENEWAL PATH: NATIVE vs LEGACY
				//
				// We CANNOT use string comparison between $stored_payment_method_id
				// and $legacy_recurring_ref to detect whether the customer changed
				// their card. The subscription migrator writes a ConvesioPay-format
				// token (e.g. FXXW5G8JLKGK9S99) to _convesiopay_stored_payment_method_id
				// from the API payment data, while _adn_recurringDetailReference holds
				// the original Adyen reference in a different format (e.g. 47702).
				// These two values ALWAYS differ even when no card change has occurred.
				// Using them for path detection caused every migrated renewal to skip
				// legacyStoredPaymentMethod: true and fail at the API.
				// (Root cause of production failure on transformationprayer.org — PP-6162)
				//
				// The ONLY reliable indicator that the customer has switched to a native
				// ConvesioPay card is the explicit flag _pp_cpay_user_renewal_card = '1'
				// written by the card selector save or the webhook delete handler.
				// Without this flag, a migrated subscription always takes the legacy path.
				// -------------------------------------------------------------
				$user_explicitly_changed_card = ( '1' === (string) $parent_order->get_meta( '_pp_cpay_user_renewal_card' ) );
				$selected_token_is_native     = (
					$user_explicitly_changed_card        // Explicit user action required.
					&& '' !== $stored_payment_method_id  // A token must actually be set.
					&& (
						'' === $legacy_recurring_ref                          // ADN ref is blank — only native tokens available.
						|| $stored_payment_method_id !== $legacy_recurring_ref // Token differs from ADN ref (user selected a new card).
					)
				);

				if ( $selected_token_is_native ) {
					// ---------------------------------------------------------
					// COMBINATION 2 / 2-EDGE: Native card selected on migrated sub
					//
					// Use the ConvesioPay canonical customer ID. If it is missing
					// (e.g. Card Manager used the MD5 fallback and the resolved ID
					// was not yet written back to the parent order), derive it from
					// the billing email using the same MD5 convention used by Card
					// Manager — this is the identity under which the selected card
					// was stored at ConvesioPay.
					//
					// IMPORTANT: Never fall back to _adn_shopper_reference here.
					// That is an Adyen identity. Pairing it with a native ConvesioPay
					// token would address the wrong customer at the API and would
					// incorrectly set legacyStoredPaymentMethod: true.
					// ---------------------------------------------------------
					if ( empty( $customer_id ) ) {
						// Combination 2 edge: derive MD5 customer ID from billing email.
						$billing_email = '';
						if ( $subscription && is_callable( array( $subscription, 'get_billing_email' ) ) ) {
							$billing_email = sanitize_email( (string) $subscription->get_billing_email() );
						}
						if ( '' === $billing_email ) {
							// Fallback to renewal order billing email if subscription does not expose it.
							$billing_email = sanitize_email( (string) $renewal_order->get_billing_email() );
						}
						if ( '' !== $billing_email ) {
							$customer_id = md5( strtolower( $billing_email ) );
							// Persist to parent order so future renewals find it directly
							// without re-deriving, and so the Card Manager UI can use it.
							$parent_order->update_meta_data( '_convesiopay_customer_id', $customer_id );
							$parent_order->save_meta_data();
							peachpay_log( 'info', 'ConvesioPay renewal (migrated sub) – native card path: derived customer_id from billing email md5=' . $customer_id );
						}
					}
					// $stored_payment_method_id already holds the selected native token.
					// $legacy_stored_payment_method stays false — never sent on native path.
					peachpay_log( 'info', 'ConvesioPay renewal (migrated sub) – native card path: customer_id=' . $customer_id . ' stored_payment_method_id=' . $stored_payment_method_id . ' legacy_flag=false' );

				} else {
					// ---------------------------------------------------------
					// COMBINATION 1 / 1-EDGE: Legacy path — charge via Adyen identity
					//
					// Use _adn_shopper_reference as customer.id and override the
					// stored payment method ID with _adn_recurringDetailReference
					// when it is non-blank.
					//
					// legacyStoredPaymentMethod: true is sent if and only if
					// _adn_shopper_reference is used as customer.id. This is the
					// flag ConvesioPay needs to route through the Adyen legacy path.
					//
					// Combination 1 edge: if _adn_recurringDetailReference is blank,
					// $stored_payment_method_id retains the migrator-written
					// ConvesioPay token (FXXW5G8JLKGK9S99 style). legacyStoredPaymentMethod
					// is still sent because we are using the Adyen shopper reference as
					// customer.id — the API uses that to look up the card.
					// ---------------------------------------------------------
					if ( '' !== $legacy_shopper_reference ) {
						$customer_id                  = $legacy_shopper_reference;
						$legacy_stored_payment_method = true; // Tied to using _adn_shopper_reference as customer.id.
					}
					if ( '' !== $legacy_recurring_ref ) {
						// Override with the actual Adyen numeric/alphanumeric reference
						// when available. If blank, the migrator token is used as fallback.
						$stored_payment_method_id = $legacy_recurring_ref;
					}
					peachpay_log( 'info', 'ConvesioPay renewal (migrated sub) – legacy path: customer_id=' . $customer_id . ' stored_payment_method_id=' . $stored_payment_method_id . ' legacy_recurring_ref=' . $legacy_recurring_ref );
				}
			}
			// For Combination 3 (native PeachPay sub), the if ($is_legacy_cpay_migrated)
			// block above is skipped entirely and the initial $customer_id /
			// $stored_payment_method_id values read from the parent order are used as-is.

			// -----------------------------------------------------------------
			// GUARD: customer_id must be resolvable before proceeding.
			// This can only be hit if both _convesiopay_customer_id and
			// _adn_shopper_reference are absent/blank on a migrated subscription,
			// or if _convesiopay_customer_id is absent on a native subscription.
			// -----------------------------------------------------------------
			if ( empty( $customer_id ) ) {
				peachpay_log(
					'error',
					'ConvesioPay Subscription renewal failed - missing customer id',
					array(
						'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
						'component' => 'payments',
						'feature'   => 'convesiopay_gateway',
						'operation' => 'subscription.renewal',
						'outcome'   => 'failed',
						'line'      => __LINE__,
						'function'  => __FUNCTION__,
						'file'      => __FILE__,
						'class'     => __CLASS__,
						'method'    => __METHOD__,
						'data'      => array(
							'gateway_id'               => $this->id,
							'parent_order_id'          => $parent_order->get_id(),
							'renewal_order_id'         => $renewal_order->get_id(),
							'customer_id'              => $customer_id,
							'stored_payment_method_id' => $stored_payment_method_id,
						),
					)
				);
				throw new Exception( __( 'Missing customer id for subscription renewal.', 'peachpay-for-woocommerce' ) );
			}

			// Use the static integration config directly. The trait cannot assume
			// that get_convesiopay_config() exists on every using class (Apple Pay
			// and Google Pay define it as private; Card defines it as protected).
			$config = PeachPay_ConvesioPay_Integration::config();
			if ( empty( $config['secret_key'] ) ) {
				throw new Exception( __( 'ConvesioPay is not properly configured. Missing secret key.', 'peachpay-for-woocommerce' ) );
			}

			// ConvesioPay requires a complete address object when one is sent.
			// Parse WooCommerce address line 1 into house number/name + street.
			$billing_address = peachpay_convesiopay_build_address(
				$renewal_order->get_billing_address_1(),
				$renewal_order->get_billing_address_2(),
				$renewal_order->get_billing_city(),
				$renewal_order->get_billing_state(),
				$renewal_order->get_billing_postcode(),
				$renewal_order->get_billing_country()
			);

			$shipping_address = peachpay_convesiopay_build_address(
				$renewal_order->get_shipping_address_1(),
				$renewal_order->get_shipping_address_2(),
				$renewal_order->get_shipping_city(),
				$renewal_order->get_shipping_state(),
				$renewal_order->get_shipping_postcode(),
				$renewal_order->get_shipping_country()
			);

			// Build the stored-card renewal request payload.
			// storedPaymentMethodId is added below only when non-empty.
			// legacyStoredPaymentMethod is added only when the legacy Adyen path is used.
			$request_data = array(
				'integration' => $config['integration_name'] ?? 'PeachPay',
				'returnUrl'   => $this->get_return_url( $renewal_order ),
				'orderNumber' => strval( $renewal_order->get_id() ),
				'amount'      => (int) round( $renewal_total * 100 ),
				'currency'    => $renewal_order->get_currency(),
				'customer'    => array(
					'id' => $customer_id,
				),
				'lineItems'   => $this->get_line_items( $renewal_order ),
				'name'        => $renewal_order->get_customer_first_name() . ' ' . $renewal_order->get_customer_last_name(),
				'email'       => $renewal_order->get_billing_email(),
			);

			// Only include storedPaymentMethodId when a token is available.
			// On Combination 1 edge where both ADN ref and migrator token are blank,
			// the field is omitted and ConvesioPay uses the shopper reference alone.
			if ( ! empty( $stored_payment_method_id ) ) {
				$request_data['customer']['storedPaymentMethodId'] = $stored_payment_method_id;
			}

			// legacyStoredPaymentMethod is ONLY sent when using _adn_shopper_reference
			// as customer.id (Combinations 1 and 1-edge). It must never be sent on the
			// native path, even for migrated subscriptions that switched cards.
			if ( $legacy_stored_payment_method ) {
				$request_data['legacyStoredPaymentMethod'] = true;
				peachpay_log( 'info', 'ConvesioPay renewal – legacyStoredPaymentMethod=true' );
			}

			// Addresses are optional per ConvesioPay API, but if provided all required
			// fields must be present. stateOrProvince is only required for US/CA.
			if ( ! empty( $billing_address ) && peachpay_convesiopay_is_complete_address( $billing_address ) ) {
				$request_data['billingAddress'] = $billing_address;
			}
			if ( ! empty( $shipping_address ) && peachpay_convesiopay_is_complete_address( $shipping_address ) ) {
				$request_data['shippingAddress'] = $shipping_address;
			}

			peachpay_log(
				'debug',
				'ConvesioPay Subscription renewal api request debug - renewal order id - ' . $renewal_order->get_id(),
				array(
					'event'     => 'payments.convesiopay_gateway.subscription.renewal.debug',
					'component' => 'payments',
					'feature'   => 'convesiopay_gateway',
					'operation' => 'subscription.renewal',
					'outcome'   => 'debug',
					'line'      => __LINE__,
					'function'  => __FUNCTION__,
					'file'      => __FILE__,
					'class'     => __CLASS__,
					'method'    => __METHOD__,
					'data'      => array(
						'gateway_id'                   => $this->id,
						'api_url'                      => $config['api_url'] . '/payments/stored-card',
						'request_data'                 => $request_data,
						'is_legacy_cpay_migrated'      => $is_legacy_cpay_migrated,
						'legacy_stored_payment_method' => $legacy_stored_payment_method,
					),
				)
			);

			$response = wp_remote_post(
				$config['api_url'] . '/payments/stored-card',
				array(
					'headers' => array(
						'Authorization' => $config['secret_key'],
						'Content-Type'  => 'application/json',
					),
					'body'    => wp_json_encode( $request_data ),
					'timeout' => 30,
				)
			);

			if ( is_wp_error( $response ) ) {
				peachpay_log(
					'error',
					'ConvesioPay Subscription renewal failed - network error for renewal order id - ' . $renewal_order->get_id(),
					array(
						'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
						'component' => 'payments',
						'feature'   => 'convesiopay_gateway',
						'operation' => 'subscription.renewal',
						'outcome'   => 'failed',
						'line'      => __LINE__,
						'function'  => __FUNCTION__,
						'file'      => __FILE__,
						'class'     => __CLASS__,
						'method'    => __METHOD__,
						'data'      => array(
							'gateway_id'    => $this->id,
							'request_data'  => $request_data,
							'error_message' => $response->get_error_message(),
						),
					)
				);
				throw new Exception( 'Network error: ' . $response->get_error_message() );
			}

			$response_code = wp_remote_retrieve_response_code( $response );
			$response_body = wp_remote_retrieve_body( $response );
			$response_data = json_decode( $response_body, true );

			$success_statuses = array( 'Succeeded', 'Pending', 'Authorized', 'Authorised' );
			$status           = $response_data['status'] ?? '';
			$payment_id       = $response_data['id'] ?? '';

			if ( 200 === (int) $response_code && in_array( $status, $success_statuses, true ) ) {
				peachpay_log(
					'info',
					'ConvesioPay Subscription renewal success - renewal order id - ' . $renewal_order->get_id(),
					array(
						'event'     => 'payments.convesiopay_gateway.subscription.renewal.success',
						'component' => 'payments',
						'feature'   => 'convesiopay_gateway',
						'operation' => 'subscription.renewal',
						'outcome'   => 'success',
						'line'      => __LINE__,
						'method'    => __METHOD__,
						'data'      => array(
							'gateway_id' => $this->id,
							'response'   => $response_data,
						),
					)
				);

				// Write renewal result to the renewal order.
				$renewal_order->update_meta_data( '_convesiopay_payment_id', $payment_id );
				$renewal_order->update_meta_data( '_convesiopay_customer_id', $customer_id );
				if ( ! empty( $stored_payment_method_id ) ) {
					// Propagate the token used to the renewal order so subsequent renewals
					// (if this renewal order is ever used as the parent) carry it forward.
					$renewal_order->update_meta_data( '_convesiopay_stored_payment_method_id', $stored_payment_method_id );
				}
				$renewal_order->set_transaction_id( $payment_id );

				if ( 'Succeeded' === $status ) {
					// Payment captured — mark order complete immediately.
					$renewal_order->payment_complete( $payment_id );
					$renewal_order->add_order_note(
						sprintf(
							/* translators: %s: ConvesioPay payment ID */
							__( 'ConvesioPay subscription renewal successful. Payment ID: %s', 'peachpay-for-woocommerce' ),
							$payment_id
						)
					);
				} else {
					// Authorized or Pending — payment is in flight, awaiting capture/confirmation.
					$renewal_order->add_order_note(
						sprintf(
							/* translators: %s: ConvesioPay payment ID */
							__( 'ConvesioPay subscription renewal payment authorized and awaiting confirmation. Payment ID: %s', 'peachpay-for-woocommerce' ),
							$payment_id
						)
					);
					$renewal_order->save();
				}
			} else {
				$error_message = $response_data['message'] ?? $response_data['body']['message'] ?? 'Unknown error';
				peachpay_log(
					'error',
					'ConvesioPay Subscription renewal failed - renewal order id - ' . $renewal_order->get_id(),
					array(
						'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
						'component' => 'payments',
						'feature'   => 'convesiopay_gateway',
						'operation' => 'subscription.renewal',
						'outcome'   => 'failed',
						'line'      => __LINE__,
						'function'  => __FUNCTION__,
						'file'      => __FILE__,
						'class'     => __CLASS__,
						'method'    => __METHOD__,
						'data'      => array(
							'gateway_id'    => $this->id,
							'request_data'  => $request_data,
							'response_data' => $response_data,
						),
					)
				);
				throw new Exception( 'Renewal payment failed: ' . $error_message );
			}
		} catch ( Exception $e ) {
			peachpay_log(
				'error',
				'ConvesioPay Subscription renewal failed - exception',
				array(
					'event'     => 'payments.convesiopay_gateway.subscription.renewal.failed',
					'component' => 'payments',
					'feature'   => 'convesiopay_gateway',
					'operation' => 'subscription.renewal',
					'outcome'   => 'failed',
					'line'      => __LINE__,
					'function'  => __FUNCTION__,
					'file'      => __FILE__,
					'class'     => __CLASS__,
					'method'    => __METHOD__,
					'data'      => array(
						'gateway_id' => $this->id,
						'message'    => $e->getMessage(),
						'code'       => $e->getCode(),
						'file'       => $e->getFile(),
						'line'       => $e->getLine(),
						'trace'      => $e->getTraceAsString(),
					),
				)
			);

			// Set renewal order to 'failed' so WC Subscriptions can retry and the
			// merchant can see the failure in the order list. Guard against overwriting
			// a successful status that another concurrent process may have set.
			if ( $renewal_order instanceof WC_Order && ! $renewal_order->has_status( array( 'processing', 'completed' ) ) ) {
				$failure_note = sprintf(
					/* translators: %s: failure message */
					__( 'ConvesioPay subscription renewal failed: %s', 'peachpay-for-woocommerce' ),
					$e->getMessage()
				);
				$renewal_order->update_status( 'failed', $failure_note );
			}
		}
	}
}
