diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..978107bfe
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,78 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+PayPal for WooCommerce (by Angell EYE) — a WordPress/WooCommerce payment gateway plugin supporting multiple PayPal products. Version 4.6.5, requires PHP 5.4+, WordPress 5.8+, WooCommerce 3.0+.
+
+## Commands
+
+```bash
+# Install dev dependencies
+composer install
+
+# Lint (PHPCS with WordPress standards)
+composer lint # Full lint check
+composer lint:errors # Errors only (uses phpcs-errors.xml.dist)
+composer lint:fix # Auto-fix with phpcbf
+
+# Lint a specific file
+vendor/bin/phpcs --standard=phpcs.xml.dist path/to/file.php
+vendor/bin/phpcbf --standard=phpcs.xml.dist path/to/file.php
+```
+
+No build step — JS/CSS are maintained directly (no Webpack/Gulp). No automated test suite exists.
+
+## Architecture
+
+### Entry Point & Bootstrap
+
+`paypal-for-woocommerce.php` — main plugin file. Loads shared includes from `angelleye-includes/`, then instantiates `AngellEYE_Gateway_Paypal` (the central controller class defined in the same file). This class registers all payment gateways via the `woocommerce_payment_gateways` filter at priority 1000.
+
+### Gateway Structure
+
+Two generations of gateways coexist:
+
+**Modern (PPCP) — `ppcp-gateway/`:**
+- `WC_Gateway_PPCP_AngellEYE` — main gateway class (`angelleye_ppcp`)
+- `AngellEYE_PayPal_PPCP_Payment` — payment processing (largest file, ~310KB)
+- `AngellEYE_PayPal_PPCP_Smart_Button` — smart button orchestration
+- Child gateways: `WC_Gateway_CC_AngellEYE` (cards), `WC_Gateway_Apple_Pay_AngellEYE`, `WC_Gateway_Google_Pay_AngellEYE`
+- Traits for shared behavior: `WC_Gateway_Base_AngellEYE`, `AngellEye_PPCP_Core`, `WC_PPCP_Pre_Orders_Trait`, `WC_Gateway_PPCP_Angelleye_Subscriptions_Base`
+
+**Legacy — `classes/`:**
+- PayPal Express Checkout (v1 & v2), Pro (DoDirectPayment), Pro PayFlow, Advanced, REST Credit Cards, Braintree
+- Each has a corresponding subscriptions subclass in `classes/subscriptions/`
+- PayPal/Braintree SDKs bundled in `classes/lib/`
+
+### Key Directories
+
+| Directory | Purpose |
+|-----------|---------|
+| `ppcp-gateway/` | Modern PayPal Commerce Platform gateway (active development focus) |
+| `ppcp-gateway/subscriptions/` | WooCommerce Subscriptions support for PPCP |
+| `ppcp-gateway/checkout-block/` | WooCommerce Blocks integration |
+| `ppcp-gateway/funnelkit/` | FunnelKit (Aero Checkout, Upsells) integration |
+| `ppcp-gateway/ppcp-payment-token/` | Payment tokenization/vaulting |
+| `classes/` | Legacy gateway implementations |
+| `angelleye-includes/` | Shared utilities, functions, session management |
+| `template/` | Admin, email, and customer-facing templates |
+| `assets/` | Legacy JS/CSS/images |
+| `ppcp-gateway/js/`, `ppcp-gateway/css/` | PPCP-specific frontend assets |
+
+### Integrations
+
+The plugin integrates with: CartFlows (`ppcp-gateway/cartflow/`, `angelleye-includes/cartflows-pro/`), FunnelKit (`ppcp-gateway/funnelkit/`), WooCommerce Subscriptions, WooCommerce Pre-Orders, and WooCommerce Blocks.
+
+## Coding Conventions
+
+- **Class naming**: `WC_Gateway_*_AngellEYE` or `AngellEYE_*` prefix
+- **Function naming**: `angelleye_*` or `angelleye_ppcp_*`, wrapped in `if (!function_exists())` checks
+- **File naming**: `class-wc-gateway-*.php` for classes, `angelleye-*.php` for function files
+- **Constants**: `PAYPAL_*` or `AE_*` prefix, guarded with `if (!defined())`
+- **Singleton pattern**: `protected static $_instance` with `instance()` method
+- **Code reuse**: Traits over inheritance for cross-cutting concerns (subscriptions, pre-orders, base gateway)
+- **Standards**: WordPress Core/Extra/Docs via PHPCS; short array syntax `[]` is allowed
+- **Namespace usage**: Minimal — only bundled PayPal/Braintree SDKs use namespaces; plugin code uses class prefixes
+- **Logging**: `AngellEYE_PFW_Payment_Logger` singleton; log path via `angelleye_get_log_path()`
diff --git a/composer.json b/composer.json
index 1f8aae889..bdba91e3b 100644
--- a/composer.json
+++ b/composer.json
@@ -10,20 +10,22 @@
}
],
"require": {
- "php": ">=5.4.0",
+ "php": ">=8.1.0",
"composer/installers": "^2.2"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"phpcompatibility/php-compatibility": "^9.3",
"phpcompatibility/phpcompatibility-wp": "^2.1",
+ "phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.10",
"wp-coding-standards/wpcs": "^3.0"
},
"scripts": {
"lint:errors": "phpcs --standard=phpcs-errors.xml.dist",
"lint": "phpcs --standard=phpcs.xml.dist",
- "lint:fix": "phpcbf --standard=phpcs.xml.dist"
+ "lint:fix": "phpcbf --standard=phpcs.xml.dist",
+ "test": "phpunit -c tests/Migration/phpunit.xml"
},
"config": {
"sort-packages": true,
diff --git a/ppcp-gateway/includes/trait-angelleye-ppcp-core.php b/ppcp-gateway/includes/trait-angelleye-ppcp-core.php
index f78797061..d35a9b7fb 100644
--- a/ppcp-gateway/includes/trait-angelleye-ppcp-core.php
+++ b/ppcp-gateway/includes/trait-angelleye-ppcp-core.php
@@ -42,6 +42,12 @@ public function angelleye_ppcp_load_class($loadSettingsFields = false) {
include_once ( PAYPAL_FOR_WOOCOMMERCE_PLUGIN_DIR . '/ppcp-gateway/class-angelleye-paypal-ppcp-migration.php');
}
AngellEYE_PayPal_PPCP_Migration::instance();
+ // Load refactored migration system (subscription migration with state tracking)
+ $migration_autoload = PAYPAL_FOR_WOOCOMMERCE_PLUGIN_DIR . '/src/Migration/autoload.php';
+ if (file_exists($migration_autoload) && !function_exists('angelleye_ppcp_migration_init')) {
+ include_once $migration_autoload;
+ angelleye_ppcp_migration_init();
+ }
$this->setting_obj = WC_Gateway_PPCP_AngellEYE_Settings::instance();
$this->api_log = AngellEYE_PayPal_PPCP_Log::instance();
$this->api_request = AngellEYE_PayPal_PPCP_Request::instance();
diff --git a/src/Migration/Admin/Migration_Admin_Page.php b/src/Migration/Admin/Migration_Admin_Page.php
new file mode 100644
index 000000000..29bbb5011
--- /dev/null
+++ b/src/Migration/Admin/Migration_Admin_Page.php
@@ -0,0 +1,667 @@
+ admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('angelleye_ppcp_migration_nonce'),
+ 'strings' => [
+ 'confirmStart' => __('Are you sure you want to start the migration? This process cannot be undone.', 'paypal-for-woocommerce'),
+ 'confirmStop' => __('Are you sure you want to stop the migration?', 'paypal-for-woocommerce'),
+ 'confirmReset' => __('WARNING: This will reset ALL migration data. Are you sure?', 'paypal-for-woocommerce'),
+ 'starting' => __('Starting migration...', 'paypal-for-woocommerce'),
+ 'stopping' => __('Stopping migration...', 'paypal-for-woocommerce'),
+ 'retrying' => __('Retrying failed subscriptions...', 'paypal-for-woocommerce'),
+ 'error' => __('An error occurred. Please try again.', 'paypal-for-woocommerce'),
+ 'success' => __('Operation completed successfully.', 'paypal-for-woocommerce'),
+ ],
+ ]);
+ }
+
+ /**
+ * Render the admin page.
+ */
+ public static function render_page(): void {
+ if (!current_user_can(self::CAPABILITY)) {
+ wp_die(__('You do not have permission to access this page.', 'paypal-for-woocommerce'));
+ }
+
+ $controller = Migration_Controller::instance();
+ $stats = $controller->get_stats('paypal_express');
+ $is_running = !$controller->can_start_migration();
+
+ ?>
+
+ 0 || $failed > 0) {
+ $class = $failed > 0 ? 'notice-warning' : 'notice-success';
+ $message = sprintf(
+ /* translators: %1$d: completed count, %2$d: failed count */
+ __('Migration completed. %1$d successful, %2$d failed.', 'paypal-for-woocommerce'),
+ $completed,
+ $failed
+ );
+ } else {
+ $class = 'notice-info';
+ $message = __('Ready to start migration.', 'paypal-for-woocommerce');
+ }
+ ?>
+
+ value] ?? 0) +
+ ($by_status[Migration_Status::FAILED_API_ERROR->value] ?? 0) +
+ ($by_status[Migration_Status::FAILED_DATA_ERROR->value] ?? 0);
+ $skipped = ($by_status[Migration_Status::SKIPPED_EXCLUDED->value] ?? 0) +
+ ($by_status[Migration_Status::SKIPPED_MANUAL->value] ?? 0);
+ $not_started = $by_status['not_started'] ?? 0;
+ $in_progress = $by_status[Migration_Status::IN_PROGRESS->value] ?? 0;
+
+ $progress_percent = $total > 0 ? round((($completed + $failed + $skipped) / $total) * 100, 1) : 0;
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ value] ?? 0; ?>
+ 0): ?>
+
+ |
+
+ label()); ?>
+
+ |
+ |
+
+ is_failure()): ?>
+
+
+ |
+
+
+
+ 0): ?>
+
+ |
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+
+ get_failed_subscriptions('paypal_express');
+
+ if (empty($failed)) {
+ return;
+ }
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+ #
+
+
+ |
+
+ get_customer_id();
+ $customer = new \WC_Customer($customer_id);
+ echo esc_html($customer->get_display_name() . ' (' . $customer->get_email() . ')');
+ } else {
+ echo '—';
+ }
+ ?>
+ |
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ 50): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ value] ?? 0;
+ ?>
+
+
+
+
+
+
+
+ -
+
+ 0): ?>
+
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
+ value] ?? 0;
+ if ($failed_no_token > 0):
+ ?>
+
+
+
+
+
+ __('Permission denied.', 'paypal-for-woocommerce')]);
+ }
+
+ $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : 'paypal_express';
+ $controller = Migration_Controller::instance();
+
+ wp_send_json_success([
+ 'stats' => $controller->get_stats($payment_method),
+ 'is_running' => !$controller->can_start_migration($payment_method),
+ ]);
+ }
+
+ /**
+ * AJAX: Start migration.
+ */
+ public static function ajax_start_migration(): void {
+ check_ajax_referer('angelleye_ppcp_migration_nonce', 'nonce');
+
+ if (!current_user_can(self::CAPABILITY)) {
+ wp_send_json_error(['message' => __('Permission denied.', 'paypal-for-woocommerce')]);
+ }
+
+ $controller = Migration_Controller::instance();
+
+ if (!$controller->can_start_migration()) {
+ wp_send_json_error(['message' => __('Migration is already running.', 'paypal-for-woocommerce')]);
+ }
+
+ $batch_size = isset($_POST['batch_size']) ? intval($_POST['batch_size']) : 50;
+ $batch_size = max(10, min(500, $batch_size));
+
+ $result = $controller->start_migration('paypal_express', 'angelleye_ppcp', $batch_size);
+
+ if ($result) {
+ wp_send_json_success([
+ 'message' => __('Migration started successfully.', 'paypal-for-woocommerce'),
+ 'stats' => $controller->get_stats('paypal_express'),
+ ]);
+ } else {
+ wp_send_json_error(['message' => __('Failed to start migration.', 'paypal-for-woocommerce')]);
+ }
+ }
+
+ /**
+ * AJAX: Stop migration.
+ */
+ public static function ajax_stop_migration(): void {
+ check_ajax_referer('angelleye_ppcp_migration_nonce', 'nonce');
+
+ if (!current_user_can(self::CAPABILITY)) {
+ wp_send_json_error(['message' => __('Permission denied.', 'paypal-for-woocommerce')]);
+ }
+
+ $controller = Migration_Controller::instance();
+ $controller->stop_migration();
+
+ wp_send_json_success([
+ 'message' => __('Migration stopped.', 'paypal-for-woocommerce'),
+ 'stats' => $controller->get_stats('paypal_express'),
+ ]);
+ }
+
+ /**
+ * AJAX: Retry failed subscriptions.
+ */
+ public static function ajax_retry_failed(): void {
+ check_ajax_referer('angelleye_ppcp_migration_nonce', 'nonce');
+
+ if (!current_user_can(self::CAPABILITY)) {
+ wp_send_json_error(['message' => __('Permission denied.', 'paypal-for-woocommerce')]);
+ }
+
+ $controller = Migration_Controller::instance();
+
+ // Retry specific error type or all failed
+ $error_code = isset($_POST['error_code']) ? sanitize_text_field($_POST['error_code']) : null;
+ $subscription_id = isset($_POST['subscription_id']) ? intval($_POST['subscription_id']) : null;
+
+ if ($subscription_id) {
+ // Retry single subscription
+ $result = $controller->retry_subscription($subscription_id);
+ if ($result && $result->is_success()) {
+ wp_send_json_success([
+ 'message' => __('Subscription retried successfully.', 'paypal-for-woocommerce'),
+ 'stats' => $controller->get_stats('paypal_express'),
+ ]);
+ } else {
+ wp_send_json_error([
+ 'message' => $result ? $result->error_message : __('Retry failed.', 'paypal-for-woocommerce'),
+ ]);
+ }
+ } else {
+ // Retry by error code or all
+ $retried = $controller->retry_failed($error_code, 'paypal_express', 'angelleye_ppcp');
+ wp_send_json_success([
+ 'message' => sprintf(
+ /* translators: %d: number of subscriptions */
+ __('%d subscriptions queued for retry.', 'paypal-for-woocommerce'),
+ $retried
+ ),
+ 'stats' => $controller->get_stats('paypal_express'),
+ 'retried_count' => $retried,
+ ]);
+ }
+ }
+
+ /**
+ * AJAX: Reset all migration data.
+ */
+ public static function ajax_reset_all(): void {
+ check_ajax_referer('angelleye_ppcp_migration_nonce', 'nonce');
+
+ if (!current_user_can(self::CAPABILITY)) {
+ wp_send_json_error(['message' => __('Permission denied.', 'paypal-for-woocommerce')]);
+ }
+
+ $payment_method = isset($_POST['payment_method']) ? sanitize_text_field($_POST['payment_method']) : 'paypal_express';
+ $controller = Migration_Controller::instance();
+ $controller->reset_all($payment_method);
+
+ wp_send_json_success([
+ 'message' => __('All migration data has been reset.', 'paypal-for-woocommerce'),
+ 'stats' => $controller->get_stats($payment_method),
+ ]);
+ }
+
+ /**
+ * AJAX: Export failed subscriptions as CSV.
+ */
+ public static function ajax_export_failed(): void {
+ check_ajax_referer('angelleye_ppcp_migration_nonce', 'nonce');
+
+ if (!current_user_can(self::CAPABILITY)) {
+ wp_send_json_error(['message' => __('Permission denied.', 'paypal-for-woocommerce')]);
+ }
+
+ $controller = Migration_Controller::instance();
+ $error_code = isset($_POST['error_code']) ? sanitize_text_field($_POST['error_code']) : null;
+
+ $failed = $controller->get_failed_subscriptions('paypal_express', $error_code ? [$error_code] : null, 1000);
+
+ $csv_data = [];
+ $csv_data[] = ['Subscription ID', 'Customer ID', 'Customer Email', 'Customer Name', 'Error Code', 'Error Message', 'Failed At'];
+
+ foreach ($failed as $item) {
+ $subscription = wcs_get_subscription($item['subscription_id']);
+ $customer_id = $subscription ? $subscription->get_customer_id() : 0;
+ $customer = $customer_id ? new \WC_Customer($customer_id) : null;
+ $completed_at = get_post_meta($item['subscription_id'], '_angelleye_ppcp_migration_completed_at', true);
+
+ $csv_data[] = [
+ $item['subscription_id'],
+ $customer_id,
+ $customer ? $customer->get_email() : 'N/A',
+ $customer ? $customer->get_display_name() : 'N/A',
+ $item['error_code'] ?? 'unknown',
+ $item['error_message'] ?? '',
+ $completed_at ? date('Y-m-d H:i:s', $completed_at) : 'N/A',
+ ];
+ }
+
+ $filename = 'failed-subscriptions-' . date('Y-m-d-His') . '.csv';
+
+ wp_send_json_success([
+ 'filename' => $filename,
+ 'data' => $csv_data,
+ 'count' => count($failed),
+ ]);
+ }
+
+ /**
+ * Show admin notices on other pages.
+ */
+ public static function maybe_show_notices(): void {
+ $screen = get_current_screen();
+ if (!$screen || $screen->id !== 'woocommerce_page_' . self::PAGE_SLUG) {
+ return;
+ }
+
+ // Any additional notices can be added here
+ }
+}
diff --git a/src/Migration/Contracts/Batch_Processor_Interface.php b/src/Migration/Contracts/Batch_Processor_Interface.php
new file mode 100644
index 000000000..1673edd36
--- /dev/null
+++ b/src/Migration/Contracts/Batch_Processor_Interface.php
@@ -0,0 +1,61 @@
+ Array of subscription IDs.
+ */
+ public function get_pending_subscriptions(string $payment_method, int $limit = 100): array;
+
+ /**
+ * Get migration statistics.
+ *
+ * @param string $payment_method The payment method to query.
+ * @return array Status counts keyed by status value.
+ */
+ public function get_stats(string $payment_method): array;
+}
diff --git a/src/Migration/Contracts/Migration_Step_Interface.php b/src/Migration/Contracts/Migration_Step_Interface.php
new file mode 100644
index 000000000..64fcd5c98
--- /dev/null
+++ b/src/Migration/Contracts/Migration_Step_Interface.php
@@ -0,0 +1,46 @@
+results = $results;
+ $this->total = count($results);
+ $this->successful = count(array_filter($results, fn($r) => $r->is_success()));
+ $this->failed = count(array_filter($results, fn($r) => $r->is_failure()));
+ $this->skipped = count(array_filter($results, fn($r) => $r->is_skipped()));
+ $this->has_more = $has_more;
+ $this->next_batch_token = $next_batch_token;
+ }
+
+ /**
+ * Get failed results.
+ *
+ * @return Migration_Result[]
+ */
+ public function get_failures(): array {
+ return array_filter($this->results, fn($r) => $r->is_failure());
+ }
+
+ /**
+ * Get successful results.
+ *
+ * @return Migration_Result[]
+ */
+ public function get_successes(): array {
+ return array_filter($this->results, fn($r) => $r->is_success());
+ }
+
+ /**
+ * Get skipped results.
+ *
+ * @return Migration_Result[]
+ */
+ public function get_skipped(): array {
+ return array_filter($this->results, fn($r) => $r->is_skipped());
+ }
+
+ /**
+ * Check if batch had any failures.
+ *
+ * @return bool
+ */
+ public function has_failures(): bool {
+ return $this->failed > 0;
+ }
+
+ /**
+ * Get success rate as percentage.
+ *
+ * @return float
+ */
+ public function success_rate(): float {
+ if ($this->total === 0) {
+ return 0.0;
+ }
+ return round(($this->successful / $this->total) * 100, 2);
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ public function to_array(): array {
+ return [
+ 'total' => $this->total,
+ 'successful' => $this->successful,
+ 'failed' => $this->failed,
+ 'skipped' => $this->skipped,
+ 'success_rate' => $this->success_rate(),
+ 'has_more' => $this->has_more,
+ 'next_batch_token' => $this->next_batch_token,
+ 'results' => array_map(fn($r) => $r->to_array(), $this->results),
+ ];
+ }
+}
diff --git a/src/Migration/DTOs/Migration_Result.php b/src/Migration/DTOs/Migration_Result.php
new file mode 100644
index 000000000..a993caecc
--- /dev/null
+++ b/src/Migration/DTOs/Migration_Result.php
@@ -0,0 +1,159 @@
+processed_at = $processed_at ?? new DateTimeImmutable();
+ }
+
+ /**
+ * Create a success result.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param array $context Additional context.
+ * @return self
+ */
+ public static function success(int $subscription_id, array $context = []): self {
+ return new self(
+ Migration_Status::COMPLETED,
+ $subscription_id,
+ null,
+ null,
+ $context
+ );
+ }
+
+ /**
+ * Create a failure result.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param Migration_Status $status Failure status.
+ * @param string $error_code Error code.
+ * @param string $error_message Error message.
+ * @param array $context Additional context.
+ * @return self
+ * @throws InvalidArgumentException If status is not a failure status.
+ */
+ public static function failed(
+ int $subscription_id,
+ Migration_Status $status,
+ string $error_code,
+ string $error_message,
+ array $context = []
+ ): self {
+ if (!$status->is_failure()) {
+ throw new InvalidArgumentException('Status must be a failure status');
+ }
+
+ return new self(
+ $status,
+ $subscription_id,
+ $error_code,
+ $error_message,
+ $context
+ );
+ }
+
+ /**
+ * Create a skipped result.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param Migration_Status $status Skip status.
+ * @param string $reason Skip reason.
+ * @param array $context Additional context.
+ * @return self
+ */
+ public static function skipped(
+ int $subscription_id,
+ Migration_Status $status,
+ string $reason,
+ array $context = []
+ ): self {
+ return new self(
+ $status,
+ $subscription_id,
+ null,
+ $reason,
+ $context
+ );
+ }
+
+ /**
+ * Check if result is successful.
+ *
+ * @return bool
+ */
+ public function is_success(): bool {
+ return $this->status === Migration_Status::COMPLETED;
+ }
+
+ /**
+ * Check if result is failure.
+ *
+ * @return bool
+ */
+ public function is_failure(): bool {
+ return $this->status->is_failure();
+ }
+
+ /**
+ * Check if result is skipped.
+ *
+ * @return bool
+ */
+ public function is_skipped(): bool {
+ return in_array($this->status, [
+ Migration_Status::SKIPPED_EXCLUDED,
+ Migration_Status::SKIPPED_MANUAL,
+ ], true);
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ public function to_array(): array {
+ return [
+ 'status' => $this->status->value,
+ 'status_label' => $this->status->label(),
+ 'subscription_id' => $this->subscription_id,
+ 'error_code' => $this->error_code,
+ 'error_message' => $this->error_message,
+ 'context' => $this->context,
+ 'processed_at' => $this->processed_at?->format('Y-m-d H:i:s'),
+ ];
+ }
+}
diff --git a/src/Migration/DTOs/Migration_Stats.php b/src/Migration/DTOs/Migration_Stats.php
new file mode 100644
index 000000000..eca3eef23
--- /dev/null
+++ b/src/Migration/DTOs/Migration_Stats.php
@@ -0,0 +1,111 @@
+total === 0) {
+ return 0.0;
+ }
+ $processed = $this->completed + $this->failed + $this->skipped;
+ return round(($processed / $this->total) * 100, 2);
+ }
+
+ /**
+ * Calculate success rate among completed.
+ *
+ * @return float
+ */
+ public function success_rate(): float {
+ $processed = $this->completed + $this->failed;
+ if ($processed === 0) {
+ return 0.0;
+ }
+ return round(($this->completed / $processed) * 100, 2);
+ }
+
+ /**
+ * Check if migration is complete.
+ *
+ * @return bool
+ */
+ public function is_complete(): bool {
+ return $this->pending === 0 && $this->in_progress === 0;
+ }
+
+ /**
+ * Get estimated remaining time in seconds.
+ *
+ * @param float $processing_rate Subscriptions per second.
+ * @return int|null
+ */
+ public function estimated_remaining_seconds(float $processing_rate = 0.5): ?int {
+ if ($processing_rate <= 0) {
+ return null;
+ }
+ $remaining = $this->pending + $this->in_progress;
+ return (int) ($remaining / $processing_rate);
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ public function to_array(): array {
+ return [
+ 'total' => $this->total,
+ 'completed' => $this->completed,
+ 'failed' => $this->failed,
+ 'skipped' => $this->skipped,
+ 'pending' => $this->pending,
+ 'in_progress' => $this->in_progress,
+ 'completion_percentage' => $this->completion_percentage(),
+ 'success_rate' => $this->success_rate(),
+ 'is_complete' => $this->is_complete(),
+ 'failures_by_reason' => $this->failures_by_reason,
+ 'started_at' => $this->started_at?->format('Y-m-d H:i:s'),
+ 'completed_at' => $this->completed_at?->format('Y-m-d H:i:s'),
+ ];
+ }
+}
diff --git a/src/Migration/Enums/Migration_Status.php b/src/Migration/Enums/Migration_Status.php
new file mode 100644
index 000000000..fd711d0e3
--- /dev/null
+++ b/src/Migration/Enums/Migration_Status.php
@@ -0,0 +1,85 @@
+ true,
+ default => false,
+ };
+ }
+
+ /**
+ * Check if status represents failure.
+ *
+ * @return bool True if failed status.
+ */
+ public function is_failure(): bool {
+ return match($this) {
+ self::FAILED_NO_TOKEN,
+ self::FAILED_API_ERROR,
+ self::FAILED_DATA_ERROR => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Get human-readable label.
+ *
+ * @return string Label for display.
+ */
+ public function label(): string {
+ return match($this) {
+ self::NOT_STARTED => __('Not Started', 'paypal-for-woocommerce'),
+ self::IN_PROGRESS => __('In Progress', 'paypal-for-woocommerce'),
+ self::COMPLETED => __('Completed', 'paypal-for-woocommerce'),
+ self::FAILED_NO_TOKEN => __('Failed - No Payment Token', 'paypal-for-woocommerce'),
+ self::FAILED_API_ERROR => __('Failed - API Error', 'paypal-for-woocommerce'),
+ self::FAILED_DATA_ERROR => __('Failed - Data Error', 'paypal-for-woocommerce'),
+ self::SKIPPED_EXCLUDED => __('Skipped - Excluded', 'paypal-for-woocommerce'),
+ self::SKIPPED_MANUAL => __('Skipped - Manual Review', 'paypal-for-woocommerce'),
+ };
+ }
+
+ /**
+ * Get CSS class for status display.
+ *
+ * @return string CSS class name.
+ */
+ public function css_class(): string {
+ return match($this) {
+ self::COMPLETED => 'status-completed',
+ self::FAILED_NO_TOKEN, self::FAILED_API_ERROR, self::FAILED_DATA_ERROR => 'status-failed',
+ self::SKIPPED_EXCLUDED, self::SKIPPED_MANUAL => 'status-skipped',
+ self::IN_PROGRESS => 'status-in-progress',
+ default => 'status-pending',
+ };
+ }
+}
diff --git a/src/Migration/Migration_Controller.php b/src/Migration/Migration_Controller.php
new file mode 100644
index 000000000..4f80b5f08
--- /dev/null
+++ b/src/Migration/Migration_Controller.php
@@ -0,0 +1,380 @@
+initialize_services();
+ }
+
+ /**
+ * Initialize all services.
+ *
+ * @return void
+ */
+ private function initialize_services(): void {
+ $this->state_storage = new HPOS_Migration_State_Storage();
+
+ $settings = WC_Gateway_PPCP_AngellEYE_Settings::instance();
+ $token_validator = new Payment_Token_Validator();
+ $payment_method_updater = new Payment_Method_Updater($settings);
+
+ $this->migration_service = new Subscription_Migration_Service(
+ $this->state_storage,
+ $token_validator,
+ $payment_method_updater
+ );
+
+ $this->batch_processor = new Action_Scheduler_Batch_Processor($this->migration_service);
+ }
+
+ /**
+ * Start migration for a payment method.
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @param int $batch_size Number of subscriptions per batch.
+ * @return bool True if started successfully.
+ */
+ public function start_migration(
+ string $from_payment_method,
+ string $to_payment_method = 'angelleye_ppcp',
+ int $batch_size = 100
+ ): bool {
+ // Check if already running
+ if ($this->batch_processor->is_running($from_payment_method, $to_payment_method)) {
+ return false;
+ }
+
+ $this->batch_processor->start($from_payment_method, $to_payment_method, $batch_size);
+ return true;
+ }
+
+ /**
+ * Process a single subscription immediately.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $to_payment_method Target payment method.
+ * @return array Result data.
+ */
+ public function migrate_subscription(int $subscription_id, string $to_payment_method = 'angelleye_ppcp'): array {
+ $result = $this->migration_service->retry($subscription_id, $to_payment_method);
+ return $result->to_array();
+ }
+
+ /**
+ * Get migration statistics.
+ *
+ * @param string $payment_method Payment method.
+ * @return array Statistics array.
+ */
+ public function get_stats(string $payment_method): array {
+ $status_counts = $this->state_storage->get_stats($payment_method);
+
+ $total = array_sum($status_counts);
+ $completed = $status_counts[Migration_Status::COMPLETED->value] ?? 0;
+ $failed = ($status_counts[Migration_Status::FAILED_NO_TOKEN->value] ?? 0)
+ + ($status_counts[Migration_Status::FAILED_API_ERROR->value] ?? 0)
+ + ($status_counts[Migration_Status::FAILED_DATA_ERROR->value] ?? 0);
+ $pending = $status_counts['not_started'] ?? 0;
+ $in_progress = $status_counts[Migration_Status::IN_PROGRESS->value] ?? 0;
+
+ $processed = $completed + $failed;
+ $completion_percentage = $total > 0 ? round(($processed / $total) * 100, 2) : 0;
+ $success_rate = $processed > 0 ? round(($completed / $processed) * 100, 2) : 0;
+
+ return [
+ 'total' => $total,
+ 'completed' => $completed,
+ 'failed' => $failed,
+ 'pending' => $pending,
+ 'in_progress' => $in_progress,
+ 'completion_percentage' => $completion_percentage,
+ 'success_rate' => $success_rate,
+ 'by_status' => $status_counts,
+ ];
+ }
+
+ /**
+ * Cancel running migration.
+ *
+ * @param string|null $from_payment_method Optional specific migration to cancel.
+ * @return void
+ */
+ public function cancel_migration(?string $from_payment_method = null): void {
+ $this->batch_processor->cancel($from_payment_method);
+ }
+
+ /**
+ * Check if migration is running.
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @return bool True if migration is running.
+ */
+ public function is_migration_running(
+ string $from_payment_method,
+ string $to_payment_method = 'angelleye_ppcp'
+ ): bool {
+ return $this->batch_processor->is_running($from_payment_method, $to_payment_method);
+ }
+
+ /**
+ * Process batch immediately (for testing/debugging).
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @param int $batch_size Batch size.
+ * @return array Batch result.
+ */
+ public function process_batch(
+ string $from_payment_method,
+ string $to_payment_method = 'angelleye_ppcp',
+ int $batch_size = 10
+ ): array {
+ $result = $this->migration_service->process_batch(
+ $from_payment_method,
+ $to_payment_method,
+ $batch_size
+ );
+
+ return $result->to_array();
+ }
+
+ /**
+ * Reset migration state for a subscription.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @return void
+ */
+ public function reset_subscription(int $subscription_id): void {
+ $meta_keys = [
+ HPOS_Migration_State_Storage::META_STATUS,
+ HPOS_Migration_State_Storage::META_ATTEMPTS,
+ HPOS_Migration_State_Storage::META_ERROR_CODE,
+ HPOS_Migration_State_Storage::META_ERROR_MESSAGE,
+ HPOS_Migration_State_Storage::META_STARTED_AT,
+ HPOS_Migration_State_Storage::META_COMPLETED_AT,
+ ];
+
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ return;
+ }
+
+ foreach ($meta_keys as $key) {
+ $subscription->delete_meta_data($key);
+ }
+ $subscription->save();
+ }
+
+ /**
+ * Check if migration can be started (not already running).
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @return bool True if can start.
+ */
+ public function can_start_migration(
+ string $from_payment_method = 'paypal_express',
+ string $to_payment_method = 'angelleye_ppcp'
+ ): bool {
+ return !$this->batch_processor->is_running($from_payment_method, $to_payment_method);
+ }
+
+ /**
+ * Stop/cancel running migration.
+ *
+ * @return void
+ */
+ public function stop_migration(): void {
+ $this->batch_processor->cancel();
+ }
+
+ /**
+ * Retry a single subscription.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $to_payment_method Target payment method.
+ * @return \AngellEYE\PayPal\Migration\DTOs\Migration_Result|null
+ */
+ public function retry_subscription(int $subscription_id, string $to_payment_method = 'angelleye_ppcp'): ?\AngellEYE\PayPal\Migration\DTOs\Migration_Result {
+ return $this->migration_service->retry($subscription_id, $to_payment_method);
+ }
+
+ /**
+ * Retry failed subscriptions.
+ *
+ * @param string|null $error_code Optional error code filter.
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @return int Number of subscriptions queued for retry.
+ */
+ public function retry_failed(
+ ?string $error_code = null,
+ string $from_payment_method = 'paypal_express',
+ string $to_payment_method = 'angelleye_ppcp'
+ ): int {
+ $failed = $this->get_failed_subscriptions($from_payment_method, $error_code ? [$error_code] : null, 1000);
+
+ $count = 0;
+ foreach ($failed as $item) {
+ // Reset status to allow retry
+ $this->reset_subscription($item['subscription_id']);
+ $count++;
+ }
+
+ // Restart migration if not running
+ if ($count > 0 && $this->can_start_migration($from_payment_method, $to_payment_method)) {
+ $this->start_migration($from_payment_method, $to_payment_method, 50);
+ }
+
+ return $count;
+ }
+
+ /**
+ * Get failed subscriptions with details.
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param array|null $error_codes Optional error codes filter.
+ * @param int $limit Maximum results.
+ * @return array Array of failed subscription details.
+ */
+ public function get_failed_subscriptions(
+ string $from_payment_method = 'paypal_express',
+ ?array $error_codes = null,
+ int $limit = 100
+ ): array {
+ $failed_statuses = [
+ Migration_Status::FAILED_NO_TOKEN->value,
+ Migration_Status::FAILED_API_ERROR->value,
+ Migration_Status::FAILED_DATA_ERROR->value,
+ ];
+
+ $args = [
+ 'type' => 'shop_subscription',
+ 'status' => ['wc-active', 'wc-on-hold', 'wc-pending-cancel'],
+ 'payment_method' => $from_payment_method,
+ 'limit' => $limit,
+ 'return' => 'ids',
+ 'meta_query' => [
+ [
+ 'key' => HPOS_Migration_State_Storage::META_STATUS,
+ 'value' => $failed_statuses,
+ 'compare' => 'IN',
+ ],
+ ],
+ ];
+
+ if ($error_codes) {
+ $args['meta_query'][] = [
+ 'key' => HPOS_Migration_State_Storage::META_ERROR_CODE,
+ 'value' => $error_codes,
+ 'compare' => 'IN',
+ ];
+ }
+
+ $subscription_ids = wc_get_orders($args);
+ $results = [];
+
+ foreach ($subscription_ids as $subscription_id) {
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ continue;
+ }
+
+ $results[] = [
+ 'subscription_id' => $subscription_id,
+ 'error_code' => $subscription->get_meta(HPOS_Migration_State_Storage::META_ERROR_CODE),
+ 'error_message' => $subscription->get_meta(HPOS_Migration_State_Storage::META_ERROR_MESSAGE),
+ ];
+ }
+
+ return $results;
+ }
+
+ /**
+ * Reset all migration data.
+ *
+ * @param string $payment_method Payment method to reset.
+ * @return int Number of subscriptions reset.
+ */
+ public function reset_all(string $payment_method = 'paypal_express'): int {
+ // Get all subscriptions with migration data
+ $args = [
+ 'type' => 'shop_subscription',
+ 'status' => 'any',
+ 'payment_method' => $payment_method,
+ 'limit' => -1,
+ 'return' => 'ids',
+ 'meta_query' => [
+ [
+ 'key' => HPOS_Migration_State_Storage::META_STATUS,
+ 'compare' => 'EXISTS',
+ ],
+ ],
+ ];
+
+ $subscription_ids = wc_get_orders($args);
+
+ foreach ($subscription_ids as $subscription_id) {
+ $this->reset_subscription($subscription_id);
+ }
+
+ // Cancel any running migration
+ $this->stop_migration();
+
+ return count($subscription_ids);
+ }
+
+ /**
+ * Get the batch processor instance.
+ *
+ * @return Action_Scheduler_Batch_Processor
+ */
+ public function get_batch_processor(): Action_Scheduler_Batch_Processor {
+ return $this->batch_processor;
+ }
+}
diff --git a/src/Migration/Queue/Action_Scheduler_Batch_Processor.php b/src/Migration/Queue/Action_Scheduler_Batch_Processor.php
new file mode 100644
index 000000000..10db4fdf3
--- /dev/null
+++ b/src/Migration/Queue/Action_Scheduler_Batch_Processor.php
@@ -0,0 +1,218 @@
+migration_service = $migration_service;
+
+ // Register hook
+ add_action($this->hook_name, [$this, 'process_scheduled_batch'], 10, 3);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function start(string $from_payment_method, string $to_payment_method, int $batch_size = 100): void {
+ // Cancel any existing scheduled actions
+ $this->cancel($from_payment_method, $to_payment_method);
+
+ // Schedule first batch
+ $this->schedule_next($from_payment_method, $to_payment_method, $batch_size, time());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function schedule_next(
+ string $from_payment_method,
+ string $to_payment_method,
+ int $batch_size,
+ ?int $timestamp = null
+ ): void {
+ if ($timestamp === null) {
+ $timestamp = time() + 30; // 30 second delay between batches
+ }
+
+ as_schedule_single_action(
+ $timestamp,
+ $this->hook_name,
+ [
+ 'from' => $from_payment_method,
+ 'to' => $to_payment_method,
+ 'batch_size' => $batch_size,
+ ],
+ $this->group
+ );
+ }
+
+ /**
+ * Process a scheduled batch.
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @param int $batch_size Batch size.
+ * @return void
+ */
+ public function process_scheduled_batch(
+ string $from_payment_method,
+ string $to_payment_method,
+ int $batch_size
+ ): void {
+ $result = $this->migration_service->process_batch(
+ $from_payment_method,
+ $to_payment_method,
+ $batch_size
+ );
+
+ // Log results
+ $this->log_batch_results($result, $from_payment_method);
+
+ // Schedule next batch if there are more
+ if ($result->has_more) {
+ $this->schedule_next($from_payment_method, $to_payment_method, $batch_size);
+ } else {
+ $this->log_migration_complete($from_payment_method, $to_payment_method);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function pause(string $from_payment_method, string $to_payment_method): void {
+ as_unschedule_action(
+ $this->hook_name,
+ [
+ 'from' => $from_payment_method,
+ 'to' => $to_payment_method,
+ ],
+ $this->group
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function cancel(?string $from_payment_method = null, ?string $to_payment_method = null): void {
+ if ($from_payment_method && $to_payment_method) {
+ as_unschedule_all_actions(
+ $this->hook_name,
+ [
+ 'from' => $from_payment_method,
+ 'to' => $to_payment_method,
+ ],
+ $this->group
+ );
+ } else {
+ // Cancel all migration actions
+ as_unschedule_all_actions($this->hook_name, [], $this->group);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function is_running(string $from_payment_method, string $to_payment_method): bool {
+ $pending_actions = as_get_scheduled_actions([
+ 'hook' => $this->hook_name,
+ 'args' => [
+ 'from' => $from_payment_method,
+ 'to' => $to_payment_method,
+ ],
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ 'group' => $this->group,
+ ]);
+
+ return !empty($pending_actions);
+ }
+
+ /**
+ * Get pending batch count.
+ *
+ * @return int Number of pending batches.
+ */
+ public function get_pending_count(): int {
+ return as_get_scheduled_action_count([
+ 'hook' => $this->hook_name,
+ 'group' => $this->group,
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ ]);
+ }
+
+ /**
+ * Log batch results.
+ *
+ * @param Batch_Result $result Batch result.
+ * @param string $payment_method Payment method.
+ * @return void
+ */
+ private function log_batch_results(Batch_Result $result, string $payment_method): void {
+ if (!function_exists('wc_get_logger')) {
+ return;
+ }
+
+ $logger = wc_get_logger();
+ $context = ['source' => 'angelleye-migration'];
+
+ $message = sprintf(
+ 'Batch completed for %s: %d processed, %d successful, %d failed, %d skipped (%.1f%% success)',
+ $payment_method,
+ $result->total,
+ $result->successful,
+ $result->failed,
+ $result->skipped,
+ $result->success_rate()
+ );
+
+ $logger->info($message, $context);
+
+ // Log failures in detail
+ foreach ($result->get_failures() as $failure) {
+ $logger->error(sprintf(
+ 'Failed - Subscription %d: [%s] %s',
+ $failure->subscription_id,
+ $failure->error_code,
+ $failure->error_message
+ ), $context);
+ }
+ }
+
+ /**
+ * Log migration completion.
+ *
+ * @param string $from Source payment method.
+ * @param string $to Target payment method.
+ * @return void
+ */
+ private function log_migration_complete(string $from, string $to): void {
+ if (!function_exists('wc_get_logger')) {
+ return;
+ }
+
+ wc_get_logger()->info(
+ sprintf('Migration from %s to %s completed', $from, $to),
+ ['source' => 'angelleye-migration']
+ );
+ }
+}
diff --git a/src/Migration/Services/Payment_Method_Updater.php b/src/Migration/Services/Payment_Method_Updater.php
new file mode 100644
index 000000000..c3b70cccb
--- /dev/null
+++ b/src/Migration/Services/Payment_Method_Updater.php
@@ -0,0 +1,116 @@
+settings = $settings;
+ }
+
+ /**
+ * Update subscription payment method.
+ *
+ * @param WC_Subscription $subscription Subscription to update.
+ * @param string $new_payment_method New payment method ID.
+ * @return array Result data.
+ * @throws Exception If update fails.
+ */
+ public function update(WC_Subscription $subscription, string $new_payment_method): array {
+ $old_payment_method = $subscription->get_payment_method();
+ $old_payment_method_title = $subscription->get_payment_method_title();
+
+ $new_payment_method_title = $this->get_payment_method_title($new_payment_method);
+
+ do_action(
+ 'woocommerce_subscriptions_pre_update_payment_method',
+ $subscription,
+ $new_payment_method,
+ $old_payment_method
+ );
+
+ try {
+ // Update payment method
+ $subscription->set_payment_method($new_payment_method);
+ $subscription->set_payment_method_title($new_payment_method_title);
+
+ // Store old method for reference
+ $subscription->update_meta_data('_old_payment_method', $old_payment_method);
+ $subscription->update_meta_data('_angelleye_ppcp_old_payment_method', $old_payment_method);
+ $subscription->update_meta_data('_old_payment_method_title', $old_payment_method_title);
+
+ // Add order note
+ $note = sprintf(
+ /* translators: %1$s: old payment method, %2$s: new payment method */
+ __('Payment method changed from "%1$s" to "%2$s" by Angelleye Migration.', 'paypal-for-woocommerce'),
+ $old_payment_method_title ?: $old_payment_method,
+ $new_payment_method_title
+ );
+ $subscription->add_order_note($note);
+
+ $subscription->save();
+
+ // Trigger actions
+ do_action('woocommerce_subscription_payment_method_updated', $subscription, $new_payment_method, $old_payment_method);
+ do_action("woocommerce_subscription_payment_method_updated_to_{$new_payment_method}", $subscription, $old_payment_method);
+
+ if ($old_payment_method) {
+ do_action("woocommerce_subscription_payment_method_updated_from_{$old_payment_method}", $subscription, $new_payment_method);
+ }
+
+ return [
+ 'success' => true,
+ 'old_method' => $old_payment_method,
+ 'new_method' => $new_payment_method,
+ 'old_title' => $old_payment_method_title,
+ 'new_title' => $new_payment_method_title,
+ ];
+
+ } catch (Exception $e) {
+ // Add error note
+ $error_note = sprintf(
+ /* translators: %1$s: error message */
+ __('Migration error: %1$s', 'paypal-for-woocommerce'),
+ $e->getMessage()
+ );
+ $subscription->add_order_note($error_note);
+ $subscription->save();
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Get payment method title.
+ *
+ * @param string $payment_method Payment method ID.
+ * @return string Payment method title.
+ */
+ private function get_payment_method_title(string $payment_method): string {
+ return match($payment_method) {
+ 'angelleye_ppcp_cc' => $this->settings->get('advanced_card_payments_title', __('Credit Card', 'paypal-for-woocommerce')),
+ 'angelleye_ppcp' => $this->settings->get('title', __('PayPal', 'paypal-for-woocommerce')),
+ 'angelleye_ppcp_google_pay' => $this->settings->get('google_pay_payments_title', __('Google Pay', 'paypal-for-woocommerce')),
+ 'angelleye_ppcp_apple_pay' => $this->settings->get('apple_pay_payments_title', __('Apple Pay', 'paypal-for-woocommerce')),
+ default => $this->settings->get('title', __('PayPal', 'paypal-for-woocommerce')),
+ };
+ }
+}
diff --git a/src/Migration/Services/Payment_Token_Validator.php b/src/Migration/Services/Payment_Token_Validator.php
new file mode 100644
index 000000000..0f86a519c
--- /dev/null
+++ b/src/Migration/Services/Payment_Token_Validator.php
@@ -0,0 +1,155 @@
+
+ */
+ private array $meta_keys = [
+ '_payment_tokens_id',
+ 'payment_token_id',
+ '_ppec_billing_agreement_id',
+ '_paypal_subscription_id',
+ ];
+
+ /**
+ * Check if subscription has a valid payment token.
+ *
+ * @param WC_Subscription $subscription The subscription to check.
+ * @return bool True if valid token exists.
+ */
+ public function has_valid_token(WC_Subscription $subscription): bool {
+ foreach ($this->meta_keys as $key) {
+ $token = $this->get_token_value($subscription, $key);
+
+ if (!empty($token) && $this->validate_token_format($key, $token)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get detailed token information.
+ *
+ * @param WC_Subscription $subscription The subscription to check.
+ * @return array|null Token details or null if not found.
+ */
+ public function get_token_details(WC_Subscription $subscription): ?array {
+ foreach ($this->meta_keys as $key) {
+ $token = $this->get_token_value($subscription, $key);
+
+ if (!empty($token) && $this->validate_token_format($key, $token)) {
+ return [
+ 'meta_key' => $key,
+ 'token_value' => $this->mask_token($token),
+ 'token_type' => $this->get_token_type($key),
+ 'is_valid' => true,
+ ];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get all token attempts for debugging.
+ *
+ * @param WC_Subscription $subscription The subscription to check.
+ * @return array Array of token check results.
+ */
+ public function get_all_token_attempts(WC_Subscription $subscription): array {
+ $attempts = [];
+
+ foreach ($this->meta_keys as $key) {
+ $token = $this->get_token_value($subscription, $key);
+ $attempts[] = [
+ 'meta_key' => $key,
+ 'found' => !empty($token),
+ 'valid' => !empty($token) && $this->validate_token_format($key, $token),
+ 'token_preview' => $token ? $this->mask_token($token) : null,
+ ];
+ }
+
+ return $attempts;
+ }
+
+ /**
+ * Get token value from subscription meta.
+ *
+ * @param WC_Subscription $subscription The subscription.
+ * @param string $key Meta key.
+ * @return string|null Token value or null.
+ */
+ private function get_token_value(WC_Subscription $subscription, string $key): ?string {
+ // Try subscription meta first (HPOS compatible)
+ $value = $subscription->get_meta($key, true);
+
+ if (!empty($value)) {
+ return $value;
+ }
+
+ // Fallback to direct postmeta for legacy data
+ $value = get_post_meta($subscription->get_id(), $key, true);
+
+ return !empty($value) ? $value : null;
+ }
+
+ /**
+ * Validate token format based on type.
+ *
+ * @param string $key Meta key.
+ * @param string $token Token value.
+ * @return bool True if valid format.
+ */
+ private function validate_token_format(string $key, string $token): bool {
+ return match($key) {
+ '_paypal_subscription_id' => str_starts_with($token, 'B-'),
+ default => strlen($token) >= 10,
+ };
+ }
+
+ /**
+ * Get human-readable token type.
+ *
+ * @param string $key Meta key.
+ * @return string Token type label.
+ */
+ private function get_token_type(string $key): string {
+ return match($key) {
+ '_payment_tokens_id' => __('Payment Token', 'paypal-for-woocommerce'),
+ 'payment_token_id' => __('Legacy Payment Token', 'paypal-for-woocommerce'),
+ '_ppec_billing_agreement_id' => __('PayPal Express Billing Agreement', 'paypal-for-woocommerce'),
+ '_paypal_subscription_id' => __('PayPal Subscription Profile', 'paypal-for-woocommerce'),
+ default => __('Unknown', 'paypal-for-woocommerce'),
+ };
+ }
+
+ /**
+ * Mask token for safe logging.
+ *
+ * @param string $token Token value.
+ * @return string Masked token.
+ */
+ private function mask_token(string $token): string {
+ $length = strlen($token);
+ if ($length <= 8) {
+ return str_repeat('*', $length);
+ }
+ return substr($token, 0, 4) . str_repeat('*', $length - 8) . substr($token, -4);
+ }
+}
diff --git a/src/Migration/Services/Subscription_Migration_Service.php b/src/Migration/Services/Subscription_Migration_Service.php
new file mode 100644
index 000000000..3110be631
--- /dev/null
+++ b/src/Migration/Services/Subscription_Migration_Service.php
@@ -0,0 +1,218 @@
+state_storage = $state_storage;
+ $this->token_validator = $token_validator;
+ $this->payment_method_updater = $payment_method_updater;
+ }
+
+ /**
+ * Process a batch of subscriptions.
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @param int $batch_size Number of subscriptions to process.
+ * @return Batch_Result
+ */
+ public function process_batch(
+ string $from_payment_method,
+ string $to_payment_method,
+ int $batch_size = 100
+ ): Batch_Result {
+ $subscription_ids = $this->state_storage->get_pending_subscriptions(
+ $from_payment_method,
+ $batch_size
+ );
+
+ if (empty($subscription_ids)) {
+ return new Batch_Result([], false);
+ }
+
+ $results = [];
+ foreach ($subscription_ids as $subscription_id) {
+ $results[] = $this->process_single(
+ $subscription_id,
+ $from_payment_method,
+ $to_payment_method
+ );
+ }
+
+ // Check if there are more pending subscriptions
+ $remaining = $this->state_storage->get_pending_subscriptions($from_payment_method, 1);
+ $has_more = !empty($remaining);
+
+ return new Batch_Result($results, $has_more);
+ }
+
+ /**
+ * Process a single subscription.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @return Migration_Result
+ */
+ public function process_single(
+ int $subscription_id,
+ string $from_payment_method,
+ string $to_payment_method
+ ): Migration_Result {
+
+ // Check if already processed
+ if ($this->state_storage->is_processed($subscription_id)) {
+ $current_status = $this->state_storage->get_status($subscription_id);
+
+ return Migration_Result::skipped(
+ $subscription_id,
+ Migration_Status::SKIPPED_EXCLUDED,
+ __('Already processed', 'paypal-for-woocommerce'),
+ ['previous_status' => $current_status?->value]
+ );
+ }
+
+ // Mark as started
+ $this->state_storage->mark_started($subscription_id);
+
+ // Get subscription
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ $this->state_storage->mark_failed(
+ $subscription_id,
+ 'data_error',
+ __('Subscription not found', 'paypal-for-woocommerce')
+ );
+
+ return Migration_Result::failed(
+ $subscription_id,
+ Migration_Status::FAILED_DATA_ERROR,
+ 'SUBSCRIPTION_NOT_FOUND',
+ __('Subscription could not be loaded', 'paypal-for-woocommerce'),
+ ['subscription_id' => $subscription_id]
+ );
+ }
+
+ // Validate token
+ if (!$this->token_validator->has_valid_token($subscription)) {
+ $this->state_storage->mark_failed($subscription_id, 'no_token');
+
+ // Add order note only on first attempt
+ if ($this->state_storage->get_attempts($subscription_id) === 1) {
+ $subscription->add_order_note(
+ __('Migration failed: No valid payment token found for subscription.', 'paypal-for-woocommerce')
+ );
+ $subscription->save();
+ }
+
+ return Migration_Result::failed(
+ $subscription_id,
+ Migration_Status::FAILED_NO_TOKEN,
+ 'NO_VALID_TOKEN',
+ __('No valid payment token found in subscription meta', 'paypal-for-woocommerce'),
+ [
+ 'subscription_id' => $subscription_id,
+ 'token_attempts' => $this->token_validator->get_all_token_attempts($subscription),
+ ]
+ );
+ }
+
+ // Update payment method
+ try {
+ $update_result = $this->payment_method_updater->update($subscription, $to_payment_method);
+ $this->state_storage->mark_completed($subscription_id);
+
+ return Migration_Result::success(
+ $subscription_id,
+ [
+ 'old_payment_method' => $from_payment_method,
+ 'new_payment_method' => $to_payment_method,
+ 'update_result' => $update_result,
+ 'token_details' => $this->token_validator->get_token_details($subscription),
+ ]
+ );
+
+ } catch (Exception $e) {
+ $this->state_storage->mark_failed(
+ $subscription_id,
+ 'api_error',
+ $e->getMessage()
+ );
+
+ return Migration_Result::failed(
+ $subscription_id,
+ Migration_Status::FAILED_API_ERROR,
+ 'UPDATE_ERROR',
+ $e->getMessage(),
+ [
+ 'subscription_id' => $subscription_id,
+ 'old_payment_method' => $from_payment_method,
+ 'new_payment_method' => $to_payment_method,
+ ]
+ );
+ }
+ }
+
+ /**
+ * Retry a failed migration.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $to_payment_method Target payment method.
+ * @return Migration_Result
+ * @throws \InvalidArgumentException If subscription not found.
+ */
+ public function retry(int $subscription_id, string $to_payment_method): Migration_Result {
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ throw new \InvalidArgumentException(
+ sprintf(__('Subscription %d not found', 'paypal-for-woocommerce'), $subscription_id)
+ );
+ }
+
+ $from_payment_method = $subscription->get_meta('_angelleye_ppcp_old_payment_method')
+ ?: $subscription->get_payment_method();
+
+ return $this->process_single($subscription_id, $from_payment_method, $to_payment_method);
+ }
+
+ /**
+ * Get migration statistics.
+ *
+ * @param string $payment_method Payment method.
+ * @return array
+ */
+ public function get_stats(string $payment_method): array {
+ return $this->state_storage->get_stats($payment_method);
+ }
+}
diff --git a/src/Migration/State/HPOS_Migration_State_Storage.php b/src/Migration/State/HPOS_Migration_State_Storage.php
new file mode 100644
index 000000000..019a19a51
--- /dev/null
+++ b/src/Migration/State/HPOS_Migration_State_Storage.php
@@ -0,0 +1,263 @@
+is_hpos_enabled = OrderUtil::custom_orders_table_usage_is_enabled();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function mark_started(int $subscription_id): void {
+ $this->update_meta($subscription_id, self::META_STATUS, Migration_Status::IN_PROGRESS->value);
+
+ $attempts = (int) $this->get_meta($subscription_id, self::META_ATTEMPTS, 0);
+ $this->update_meta($subscription_id, self::META_ATTEMPTS, $attempts + 1);
+
+ if ($attempts === 0) {
+ $this->update_meta($subscription_id, self::META_STARTED_AT, time());
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function mark_completed(int $subscription_id): void {
+ $this->update_meta($subscription_id, self::META_STATUS, Migration_Status::COMPLETED->value);
+ $this->update_meta($subscription_id, self::META_COMPLETED_AT, time());
+ $this->delete_meta($subscription_id, self::META_ERROR_CODE);
+ $this->delete_meta($subscription_id, self::META_ERROR_MESSAGE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function mark_failed(int $subscription_id, string $reason_code, ?string $message = null): void {
+ $status = match($reason_code) {
+ 'no_token' => Migration_Status::FAILED_NO_TOKEN,
+ 'api_error' => Migration_Status::FAILED_API_ERROR,
+ 'data_error' => Migration_Status::FAILED_DATA_ERROR,
+ default => Migration_Status::FAILED_DATA_ERROR,
+ };
+
+ $this->update_meta($subscription_id, self::META_STATUS, $status->value);
+ $this->update_meta($subscription_id, self::META_ERROR_CODE, $reason_code);
+ if ($message) {
+ $this->update_meta($subscription_id, self::META_ERROR_MESSAGE, $message);
+ }
+ $this->update_meta($subscription_id, self::META_COMPLETED_AT, time());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function mark_skipped(int $subscription_id, string $reason): void {
+ $status = match($reason) {
+ 'excluded' => Migration_Status::SKIPPED_EXCLUDED,
+ 'manual' => Migration_Status::SKIPPED_MANUAL,
+ default => Migration_Status::SKIPPED_MANUAL,
+ };
+
+ $this->update_meta($subscription_id, self::META_STATUS, $status->value);
+ $this->update_meta($subscription_id, self::META_ERROR_MESSAGE, $reason);
+ $this->update_meta($subscription_id, self::META_COMPLETED_AT, time());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function is_processed(int $subscription_id): bool {
+ $status = $this->get_status($subscription_id);
+ return $status?->is_terminal() ?? false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get_status(int $subscription_id): ?Migration_Status {
+ $status_value = $this->get_meta($subscription_id, self::META_STATUS);
+ if (!$status_value) {
+ return null;
+ }
+ return Migration_Status::tryFrom($status_value);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get_failed_reason(int $subscription_id): ?string {
+ return $this->get_meta($subscription_id, self::META_ERROR_MESSAGE) ?: null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get_attempts(int $subscription_id): int {
+ return (int) $this->get_meta($subscription_id, self::META_ATTEMPTS, 0);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get_pending_subscriptions(string $payment_method, int $limit = 100): array {
+ $args = [
+ 'type' => 'shop_subscription',
+ 'limit' => $limit,
+ 'return' => 'ids',
+ 'status' => ['wc-active', 'wc-on-hold'],
+ 'payment_method' => $payment_method,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ ];
+
+ // Exclude already processed subscriptions
+ $args['meta_query'] = [
+ 'relation' => 'OR',
+ [
+ 'key' => self::META_STATUS,
+ 'compare' => 'NOT EXISTS',
+ ],
+ [
+ 'key' => self::META_STATUS,
+ 'value' => [
+ Migration_Status::NOT_STARTED->value,
+ Migration_Status::IN_PROGRESS->value,
+ ],
+ 'compare' => 'IN',
+ ],
+ ];
+
+ return wc_get_orders($args);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function get_stats(string $payment_method): array {
+ $status_counts = [];
+
+ foreach (Migration_Status::cases() as $status) {
+ $args = [
+ 'type' => 'shop_subscription',
+ 'status' => ['wc-active', 'wc-on-hold', 'wc-pending-cancel'],
+ 'payment_method' => $payment_method,
+ 'limit' => -1,
+ 'return' => 'ids',
+ 'meta_query' => [
+ [
+ 'key' => self::META_STATUS,
+ 'value' => $status->value,
+ 'compare' => '=',
+ ],
+ ],
+ ];
+
+ $status_counts[$status->value] = count(wc_get_orders($args));
+ }
+
+ // Count not started
+ $args = [
+ 'type' => 'shop_subscription',
+ 'status' => ['wc-active', 'wc-on-hold', 'wc-pending-cancel'],
+ 'payment_method' => $payment_method,
+ 'limit' => -1,
+ 'return' => 'ids',
+ 'meta_query' => [
+ [
+ 'key' => self::META_STATUS,
+ 'compare' => 'NOT EXISTS',
+ ],
+ ],
+ ];
+ $status_counts['not_started'] = count(wc_get_orders($args));
+
+ return $status_counts;
+ }
+
+ /**
+ * HPOS-compatible meta update.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $key Meta key.
+ * @param mixed $value Meta value.
+ * @return void
+ */
+ private function update_meta(int $subscription_id, string $key, mixed $value): void {
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ return;
+ }
+
+ if ($this->is_hpos_enabled) {
+ $subscription->update_meta_data($key, $value);
+ $subscription->save();
+ } else {
+ update_post_meta($subscription_id, $key, $value);
+ }
+ }
+
+ /**
+ * HPOS-compatible meta retrieval.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $key Meta key.
+ * @param mixed $default Default value.
+ * @return mixed
+ */
+ private function get_meta(int $subscription_id, string $key, mixed $default = null): mixed {
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ return $default;
+ }
+
+ $value = $subscription->get_meta($key, true);
+
+ if ($value === '' || $value === null) {
+ if (!$this->is_hpos_enabled) {
+ $value = get_post_meta($subscription_id, $key, true);
+ }
+ }
+
+ return $value !== '' && $value !== null ? $value : $default;
+ }
+
+ /**
+ * HPOS-compatible meta deletion.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @param string $key Meta key.
+ * @return void
+ */
+ private function delete_meta(int $subscription_id, string $key): void {
+ $subscription = wcs_get_subscription($subscription_id);
+ if (!$subscription) {
+ return;
+ }
+
+ if ($this->is_hpos_enabled) {
+ $subscription->delete_meta_data($key);
+ $subscription->save();
+ } else {
+ delete_post_meta($subscription_id, $key);
+ }
+ }
+}
diff --git a/src/Migration/autoload.php b/src/Migration/autoload.php
new file mode 100644
index 000000000..f3a44bbdf
--- /dev/null
+++ b/src/Migration/autoload.php
@@ -0,0 +1,100 @@
+ $base_dir . 'Admin/',
+ 'Contracts/' => $base_dir . 'Contracts/',
+ 'Controller/' => $base_dir,
+ 'DTOs/' => $base_dir . 'DTOs/',
+ 'Enums/' => $base_dir . 'Enums/',
+ 'Queue/' => $base_dir . 'Queue/',
+ 'Reporting/' => $base_dir . 'Reporting/',
+ 'Services/' => $base_dir . 'Services/',
+ 'State/' => $base_dir . 'State/',
+ ];
+
+ foreach ($file_mappings as $prefix_dir => $dir) {
+ if (str_starts_with($file, $prefix_dir)) {
+ $file_path = $dir . substr($file, strlen($prefix_dir));
+
+ if (file_exists($file_path)) {
+ require_once $file_path;
+ return;
+ }
+ }
+ }
+
+ // Check for Migration_Controller in root
+ if ($relative_class === 'Migration_Controller') {
+ $controller_path = $base_dir . 'Migration_Controller.php';
+ if (file_exists($controller_path)) {
+ require_once $controller_path;
+ return;
+ }
+ }
+});
+
+/**
+ * Initialize migration admin functionality.
+ *
+ * Call this function during plugin initialization to set up admin pages.
+ */
+function angelleye_ppcp_migration_init_admin(): void {
+ if (!is_admin()) {
+ return;
+ }
+
+ \AngellEYE\PayPal\Migration\Admin\Migration_Admin_Page::init();
+}
+
+/**
+ * Initialize migration system.
+ *
+ * Call this function during plugin initialization.
+ */
+function angelleye_ppcp_migration_init(): void {
+ // Initialize admin functionality if in admin
+ add_action('init', 'angelleye_ppcp_migration_init_admin', 10);
+
+ // Register Action Scheduler hooks
+ add_action('angelleye_ppcp_migration_process_batch', 'angelleye_ppcp_migration_process_batch', 10, 3);
+}
+
+/**
+ * Process batch callback for Action Scheduler.
+ *
+ * @param string $from_payment_method Source payment method.
+ * @param string $to_payment_method Target payment method.
+ * @param int $batch_size Batch size.
+ * @return void
+ */
+function angelleye_ppcp_migration_process_batch(
+ string $from_payment_method,
+ string $to_payment_method,
+ int $batch_size
+): void {
+ $controller = \AngellEYE\PayPal\Migration\Migration_Controller::instance();
+ $controller->process_batch($from_payment_method, $to_payment_method, $batch_size);
+}
diff --git a/tests/Migration/.phpunit.cache/test-results b/tests/Migration/.phpunit.cache/test-results
new file mode 100644
index 000000000..e675e4a38
--- /dev/null
+++ b/tests/Migration/.phpunit.cache/test-results
@@ -0,0 +1 @@
+{"version":2,"defects":[],"times":{"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_calculates_totals_correctly":0.003,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_success_rate_calculation":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_success_rate_returns_zero_for_empty_batch":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_get_failures_returns_only_failed_results":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_get_successes_returns_only_successful_results":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_get_skipped_returns_only_skipped_results":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_has_failures_returns_true_when_failures_exist":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_has_failures_returns_false_when_no_failures":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_has_more_flag_is_stored":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Batch_Result_DTO::test_to_array_contains_all_fields":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_success_creates_completed_result":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_failed_creates_failure_result":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_failed_throws_exception_for_non_failure_status":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_skipped_creates_skipped_result":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_to_array_contains_all_fields":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_processed_at_is_set_automatically":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_is_skipped_returns_true_for_skipped_statuses":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Result_DTO::test_is_skipped_returns_false_for_non_skipped":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_is_terminal_returns_true_for_completed":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_is_terminal_returns_true_for_failed_no_token":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_is_terminal_returns_false_for_in_progress":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_is_terminal_returns_false_for_not_started":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_is_failure_returns_true_for_failed_statuses":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_is_failure_returns_false_for_non_failed_statuses":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_label_returns_expected_strings":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_css_class_returns_expected_values":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_all_statuses_can_be_instantiated_from_string":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Migration_Status_Enum::test_tryFrom_returns_null_for_invalid_value":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_has_valid_token_returns_true_for_payment_tokens_id":0.005,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_has_valid_token_returns_true_for_ppec_billing_agreement":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_has_valid_token_returns_true_for_paypal_subscription_id":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_has_valid_token_returns_false_for_invalid_subscription_id":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_has_valid_token_returns_false_when_no_token":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_has_valid_token_returns_false_for_short_token":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_get_token_details_returns_correct_info":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_get_token_details_masks_token_value":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Payment_Token_Validator::test_get_all_token_attempts_returns_all_checks":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_single_skips_already_processed":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_single_fails_for_missing_subscription":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_single_fails_when_no_token":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_single_succeeds_with_valid_token":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_single_catches_update_exception":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_batch_returns_correct_counts":0.001,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_batch_empty_returns_zero_results":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_process_batch_sets_has_more_when_remaining":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_retry_calls_process_single_and_returns_result":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_retry_throws_for_nonexistent_subscription":0,"AngellEYE\\PayPal\\Migration\\Tests\\Unit\\Test_Subscription_Migration_Service::test_retry_uses_old_payment_method_meta_when_available":0}}
\ No newline at end of file
diff --git a/tests/Migration/Unit/Test_Batch_Result_DTO.php b/tests/Migration/Unit/Test_Batch_Result_DTO.php
new file mode 100644
index 000000000..aede3889b
--- /dev/null
+++ b/tests/Migration/Unit/Test_Batch_Result_DTO.php
@@ -0,0 +1,119 @@
+create_results());
+
+ $this->assertEquals(5, $result->total);
+ $this->assertEquals(2, $result->successful);
+ $this->assertEquals(2, $result->failed);
+ $this->assertEquals(1, $result->skipped);
+ }
+
+ public function test_success_rate_calculation(): void {
+ $result = new Batch_Result($this->create_results());
+
+ $this->assertEquals(40.0, $result->success_rate()); // 2/5 = 40%
+ }
+
+ public function test_success_rate_returns_zero_for_empty_batch(): void {
+ $result = new Batch_Result([]);
+
+ $this->assertEquals(0.0, $result->success_rate());
+ }
+
+ public function test_get_failures_returns_only_failed_results(): void {
+ $result = new Batch_Result($this->create_results());
+ $failures = $result->get_failures();
+
+ $this->assertCount(2, $failures);
+ foreach ($failures as $failure) {
+ $this->assertTrue($failure->is_failure());
+ }
+ }
+
+ public function test_get_successes_returns_only_successful_results(): void {
+ $result = new Batch_Result($this->create_results());
+ $successes = $result->get_successes();
+
+ $this->assertCount(2, $successes);
+ foreach ($successes as $success) {
+ $this->assertTrue($success->is_success());
+ }
+ }
+
+ public function test_get_skipped_returns_only_skipped_results(): void {
+ $result = new Batch_Result($this->create_results());
+ $skipped = $result->get_skipped();
+
+ $this->assertCount(1, $skipped);
+ foreach ($skipped as $skip) {
+ $this->assertTrue($skip->is_skipped());
+ }
+ }
+
+ public function test_has_failures_returns_true_when_failures_exist(): void {
+ $result = new Batch_Result($this->create_results());
+
+ $this->assertTrue($result->has_failures());
+ }
+
+ public function test_has_failures_returns_false_when_no_failures(): void {
+ $result = new Batch_Result([
+ Migration_Result::success(1),
+ Migration_Result::success(2),
+ ]);
+
+ $this->assertFalse($result->has_failures());
+ }
+
+ public function test_has_more_flag_is_stored(): void {
+ $result = new Batch_Result([], true, 'next_token');
+
+ $this->assertTrue($result->has_more);
+ $this->assertEquals('next_token', $result->next_batch_token);
+ }
+
+ public function test_to_array_contains_all_fields(): void {
+ $result = new Batch_Result($this->create_results(), true);
+ $array = $result->to_array();
+
+ $this->assertArrayHasKey('total', $array);
+ $this->assertArrayHasKey('successful', $array);
+ $this->assertArrayHasKey('failed', $array);
+ $this->assertArrayHasKey('skipped', $array);
+ $this->assertArrayHasKey('success_rate', $array);
+ $this->assertArrayHasKey('has_more', $array);
+ $this->assertArrayHasKey('next_batch_token', $array);
+ $this->assertArrayHasKey('results', $array);
+
+ $this->assertEquals(5, $array['total']);
+ $this->assertEquals(40.0, $array['success_rate']);
+ $this->assertTrue($array['has_more']);
+ }
+}
diff --git a/tests/Migration/Unit/Test_Migration_Result_DTO.php b/tests/Migration/Unit/Test_Migration_Result_DTO.php
new file mode 100644
index 000000000..f8200b747
--- /dev/null
+++ b/tests/Migration/Unit/Test_Migration_Result_DTO.php
@@ -0,0 +1,115 @@
+ 'value']);
+
+ $this->assertTrue($result->is_success());
+ $this->assertEquals(Migration_Status::COMPLETED, $result->status);
+ $this->assertEquals(123, $result->subscription_id);
+ $this->assertEquals(['key' => 'value'], $result->context);
+ $this->assertNull($result->error_code);
+ $this->assertNull($result->error_message);
+ }
+
+ public function test_failed_creates_failure_result(): void {
+ $result = Migration_Result::failed(
+ 456,
+ Migration_Status::FAILED_NO_TOKEN,
+ 'NO_TOKEN',
+ 'No token found',
+ ['attempt' => 1]
+ );
+
+ $this->assertTrue($result->is_failure());
+ $this->assertFalse($result->is_success());
+ $this->assertEquals(Migration_Status::FAILED_NO_TOKEN, $result->status);
+ $this->assertEquals(456, $result->subscription_id);
+ $this->assertEquals('NO_TOKEN', $result->error_code);
+ $this->assertEquals('No token found', $result->error_message);
+ }
+
+ public function test_failed_throws_exception_for_non_failure_status(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Status must be a failure status');
+
+ Migration_Result::failed(
+ 123,
+ Migration_Status::COMPLETED, // Not a failure status
+ 'ERROR',
+ 'Error message'
+ );
+ }
+
+ public function test_skipped_creates_skipped_result(): void {
+ $result = Migration_Result::skipped(
+ 789,
+ Migration_Status::SKIPPED_EXCLUDED,
+ 'Already processed'
+ );
+
+ $this->assertTrue($result->is_skipped());
+ $this->assertFalse($result->is_success());
+ $this->assertFalse($result->is_failure());
+ $this->assertEquals(Migration_Status::SKIPPED_EXCLUDED, $result->status);
+ $this->assertEquals('Already processed', $result->error_message);
+ }
+
+ public function test_to_array_contains_all_fields(): void {
+ $result = Migration_Result::success(123, ['extra' => 'data']);
+ $array = $result->to_array();
+
+ $this->assertArrayHasKey('status', $array);
+ $this->assertArrayHasKey('status_label', $array);
+ $this->assertArrayHasKey('subscription_id', $array);
+ $this->assertArrayHasKey('error_code', $array);
+ $this->assertArrayHasKey('error_message', $array);
+ $this->assertArrayHasKey('context', $array);
+ $this->assertArrayHasKey('processed_at', $array);
+
+ $this->assertEquals('completed', $array['status']);
+ $this->assertEquals('Completed', $array['status_label']);
+ $this->assertEquals(['extra' => 'data'], $array['context']);
+ }
+
+ public function test_processed_at_is_set_automatically(): void {
+ $before = new \DateTimeImmutable();
+ $result = Migration_Result::success(123);
+ $after = new \DateTimeImmutable();
+
+ $this->assertNotNull($result->processed_at);
+ $this->assertGreaterThanOrEqual($before, $result->processed_at);
+ $this->assertLessThanOrEqual($after, $result->processed_at);
+ }
+
+ public function test_is_skipped_returns_true_for_skipped_statuses(): void {
+ $skipped_excluded = Migration_Result::skipped(1, Migration_Status::SKIPPED_EXCLUDED, 'test');
+ $skipped_manual = Migration_Result::skipped(2, Migration_Status::SKIPPED_MANUAL, 'test');
+
+ $this->assertTrue($skipped_excluded->is_skipped());
+ $this->assertTrue($skipped_manual->is_skipped());
+ }
+
+ public function test_is_skipped_returns_false_for_non_skipped(): void {
+ $success = Migration_Result::success(1);
+ $failed = Migration_Result::failed(2, Migration_Status::FAILED_NO_TOKEN, 'ERR', 'msg');
+
+ $this->assertFalse($success->is_skipped());
+ $this->assertFalse($failed->is_skipped());
+ }
+}
diff --git a/tests/Migration/Unit/Test_Migration_Status_Enum.php b/tests/Migration/Unit/Test_Migration_Status_Enum.php
new file mode 100644
index 000000000..922263454
--- /dev/null
+++ b/tests/Migration/Unit/Test_Migration_Status_Enum.php
@@ -0,0 +1,67 @@
+assertTrue(Migration_Status::COMPLETED->is_terminal());
+ }
+
+ public function test_is_terminal_returns_true_for_failed_no_token(): void {
+ $this->assertTrue(Migration_Status::FAILED_NO_TOKEN->is_terminal());
+ }
+
+ public function test_is_terminal_returns_false_for_in_progress(): void {
+ $this->assertFalse(Migration_Status::IN_PROGRESS->is_terminal());
+ }
+
+ public function test_is_terminal_returns_false_for_not_started(): void {
+ $this->assertFalse(Migration_Status::NOT_STARTED->is_terminal());
+ }
+
+ public function test_is_failure_returns_true_for_failed_statuses(): void {
+ $this->assertTrue(Migration_Status::FAILED_NO_TOKEN->is_failure());
+ $this->assertTrue(Migration_Status::FAILED_API_ERROR->is_failure());
+ $this->assertTrue(Migration_Status::FAILED_DATA_ERROR->is_failure());
+ }
+
+ public function test_is_failure_returns_false_for_non_failed_statuses(): void {
+ $this->assertFalse(Migration_Status::COMPLETED->is_failure());
+ $this->assertFalse(Migration_Status::IN_PROGRESS->is_failure());
+ $this->assertFalse(Migration_Status::SKIPPED_EXCLUDED->is_failure());
+ }
+
+ public function test_label_returns_expected_strings(): void {
+ $this->assertEquals('Completed', Migration_Status::COMPLETED->label());
+ $this->assertEquals('Failed - No Payment Token', Migration_Status::FAILED_NO_TOKEN->label());
+ $this->assertEquals('In Progress', Migration_Status::IN_PROGRESS->label());
+ }
+
+ public function test_css_class_returns_expected_values(): void {
+ $this->assertEquals('status-completed', Migration_Status::COMPLETED->css_class());
+ $this->assertEquals('status-failed', Migration_Status::FAILED_NO_TOKEN->css_class());
+ $this->assertEquals('status-in-progress', Migration_Status::IN_PROGRESS->css_class());
+ }
+
+ public function test_all_statuses_can_be_instantiated_from_string(): void {
+ foreach (Migration_Status::cases() as $status) {
+ $from_string = Migration_Status::tryFrom($status->value);
+ $this->assertSame($status, $from_string);
+ }
+ }
+
+ public function test_tryFrom_returns_null_for_invalid_value(): void {
+ $this->assertNull(Migration_Status::tryFrom('invalid_status'));
+ }
+}
diff --git a/tests/Migration/Unit/Test_Payment_Token_Validator.php b/tests/Migration/Unit/Test_Payment_Token_Validator.php
new file mode 100644
index 000000000..d672d6070
--- /dev/null
+++ b/tests/Migration/Unit/Test_Payment_Token_Validator.php
@@ -0,0 +1,128 @@
+validator = new Payment_Token_Validator();
+ }
+
+ /**
+ * Create a mock subscription with meta values.
+ */
+ private function create_mock_subscription(array $meta_values): WC_Subscription {
+ $subscription = $this->createMock(WC_Subscription::class);
+
+ $subscription->method('get_meta')->willReturnCallback(
+ function($key) use ($meta_values) {
+ return $meta_values[$key] ?? '';
+ }
+ );
+
+ $subscription->method('get_id')->willReturn(123);
+
+ return $subscription;
+ }
+
+ public function test_has_valid_token_returns_true_for_payment_tokens_id(): void {
+ $subscription = $this->create_mock_subscription([
+ '_payment_tokens_id' => 'tok_valid_token_123',
+ ]);
+
+ $this->assertTrue($this->validator->has_valid_token($subscription));
+ }
+
+ public function test_has_valid_token_returns_true_for_ppec_billing_agreement(): void {
+ $subscription = $this->create_mock_subscription([
+ '_ppec_billing_agreement_id' => 'B-1234567890',
+ ]);
+
+ $this->assertTrue($this->validator->has_valid_token($subscription));
+ }
+
+ public function test_has_valid_token_returns_true_for_paypal_subscription_id(): void {
+ $subscription = $this->create_mock_subscription([
+ '_paypal_subscription_id' => 'B-9876543210',
+ ]);
+
+ $this->assertTrue($this->validator->has_valid_token($subscription));
+ }
+
+ public function test_has_valid_token_returns_false_for_invalid_subscription_id(): void {
+ // PayPal subscription IDs must start with 'B-'
+ $subscription = $this->create_mock_subscription([
+ '_paypal_subscription_id' => 'I-9876543210', // Invalid - starts with I-
+ ]);
+
+ $this->assertFalse($this->validator->has_valid_token($subscription));
+ }
+
+ public function test_has_valid_token_returns_false_when_no_token(): void {
+ $subscription = $this->create_mock_subscription([]);
+
+ $this->assertFalse($this->validator->has_valid_token($subscription));
+ }
+
+ public function test_has_valid_token_returns_false_for_short_token(): void {
+ $subscription = $this->create_mock_subscription([
+ '_payment_tokens_id' => 'short', // Less than 10 chars
+ ]);
+
+ $this->assertFalse($this->validator->has_valid_token($subscription));
+ }
+
+ public function test_get_token_details_returns_correct_info(): void {
+ $subscription = $this->create_mock_subscription([
+ '_payment_tokens_id' => 'tok_valid_token_12345',
+ ]);
+
+ $details = $this->validator->get_token_details($subscription);
+
+ $this->assertNotNull($details);
+ $this->assertEquals('_payment_tokens_id', $details['meta_key']);
+ $this->assertEquals('Payment Token', $details['token_type']);
+ $this->assertTrue($details['is_valid']);
+ }
+
+ public function test_get_token_details_masks_token_value(): void {
+ $subscription = $this->create_mock_subscription([
+ '_payment_tokens_id' => 'tok_1234567890abcdef',
+ ]);
+
+ $details = $this->validator->get_token_details($subscription);
+
+ $this->assertStringContainsString('****', $details['token_value']);
+ $this->assertStringStartsWith('tok_', $details['token_value']); // First 4 chars visible
+ $this->assertStringEndsWith('cdef', $details['token_value']); // Last 4 chars visible
+ }
+
+ public function test_get_all_token_attempts_returns_all_checks(): void {
+ $subscription = $this->create_mock_subscription([
+ '_payment_tokens_id' => '',
+ '_ppec_billing_agreement_id' => 'B-12345',
+ ]);
+
+ $attempts = $this->validator->get_all_token_attempts($subscription);
+
+ $this->assertCount(4, $attempts); // 4 meta keys checked
+
+ // Find the found one
+ $found_attempt = array_filter($attempts, fn($a) => $a['found']);
+ $this->assertCount(1, $found_attempt);
+ }
+}
diff --git a/tests/Migration/Unit/Test_Subscription_Migration_Service.php b/tests/Migration/Unit/Test_Subscription_Migration_Service.php
new file mode 100644
index 000000000..ca10e448a
--- /dev/null
+++ b/tests/Migration/Unit/Test_Subscription_Migration_Service.php
@@ -0,0 +1,300 @@
+state_storage = $this->createMock(Migration_State_Storage_Interface::class);
+ $this->token_validator = $this->createMock(Payment_Token_Validator::class);
+ $this->payment_method_updater = $this->createMock(Payment_Method_Updater::class);
+
+ $this->service = new Subscription_Migration_Service(
+ $this->state_storage,
+ $this->token_validator,
+ $this->payment_method_updater
+ );
+
+ // Clear the global test subscriptions registry
+ global $_test_subscriptions;
+ $_test_subscriptions = [];
+ }
+
+ protected function tearDown(): void {
+ global $_test_subscriptions;
+ $_test_subscriptions = [];
+ parent::tearDown();
+ }
+
+ /**
+ * Register a mock subscription so wcs_get_subscription() returns it.
+ */
+ private function register_subscription(int $id, ?WC_Subscription $subscription = null): WC_Subscription {
+ global $_test_subscriptions;
+
+ if ($subscription === null) {
+ $subscription = $this->createMock(WC_Subscription::class);
+ $subscription->method('get_id')->willReturn($id);
+ }
+
+ $_test_subscriptions[$id] = $subscription;
+ return $subscription;
+ }
+
+ // ── process_single ──────────────────────────────────────────────────
+
+ public function test_process_single_skips_already_processed(): void {
+ $this->state_storage->expects($this->once())
+ ->method('is_processed')
+ ->with(123)
+ ->willReturn(true);
+
+ $this->state_storage->method('get_status')
+ ->willReturn(Migration_Status::COMPLETED);
+
+ $result = $this->service->process_single(123, 'paypal_express', 'angelleye_ppcp');
+
+ $this->assertTrue($result->is_skipped());
+ $this->assertEquals(Migration_Status::SKIPPED_EXCLUDED, $result->status);
+ }
+
+ public function test_process_single_fails_for_missing_subscription(): void {
+ $this->state_storage->method('is_processed')->willReturn(false);
+ // No subscription registered → wcs_get_subscription returns null
+
+ $this->state_storage->expects($this->once())
+ ->method('mark_failed')
+ ->with(999, 'data_error', $this->isType('string'));
+
+ $result = $this->service->process_single(999, 'paypal_express', 'angelleye_ppcp');
+
+ $this->assertTrue($result->is_failure());
+ $this->assertEquals(Migration_Status::FAILED_DATA_ERROR, $result->status);
+ $this->assertEquals('SUBSCRIPTION_NOT_FOUND', $result->error_code);
+ }
+
+ public function test_process_single_fails_when_no_token(): void {
+ $subscription = $this->register_subscription(456);
+
+ $this->state_storage->method('is_processed')->willReturn(false);
+ $this->state_storage->method('get_attempts')->willReturn(1);
+
+ $this->token_validator->expects($this->once())
+ ->method('has_valid_token')
+ ->with($subscription)
+ ->willReturn(false);
+
+ $this->token_validator->method('get_all_token_attempts')->willReturn([]);
+
+ $this->state_storage->expects($this->once())
+ ->method('mark_failed')
+ ->with(456, 'no_token');
+
+ $result = $this->service->process_single(456, 'paypal_express', 'angelleye_ppcp');
+
+ $this->assertTrue($result->is_failure());
+ $this->assertEquals(Migration_Status::FAILED_NO_TOKEN, $result->status);
+ $this->assertEquals('NO_VALID_TOKEN', $result->error_code);
+ }
+
+ public function test_process_single_succeeds_with_valid_token(): void {
+ $subscription = $this->register_subscription(789);
+
+ $this->state_storage->method('is_processed')->willReturn(false);
+
+ $this->token_validator->method('has_valid_token')->willReturn(true);
+ $this->token_validator->method('get_token_details')->willReturn([
+ 'meta_key' => '_payment_tokens_id',
+ 'token_value' => 'tok_****cdef',
+ 'token_type' => 'Payment Token',
+ 'is_valid' => true,
+ ]);
+
+ $this->payment_method_updater->expects($this->once())
+ ->method('update')
+ ->with($subscription, 'angelleye_ppcp')
+ ->willReturn(['success' => true]);
+
+ $this->state_storage->expects($this->once())
+ ->method('mark_completed')
+ ->with(789);
+
+ $result = $this->service->process_single(789, 'paypal_express', 'angelleye_ppcp');
+
+ $this->assertTrue($result->is_success());
+ $this->assertEquals(Migration_Status::COMPLETED, $result->status);
+ $this->assertArrayHasKey('old_payment_method', $result->context);
+ $this->assertEquals('paypal_express', $result->context['old_payment_method']);
+ }
+
+ public function test_process_single_catches_update_exception(): void {
+ $subscription = $this->register_subscription(321);
+
+ $this->state_storage->method('is_processed')->willReturn(false);
+ $this->token_validator->method('has_valid_token')->willReturn(true);
+
+ $this->payment_method_updater->method('update')
+ ->willThrowException(new \Exception('PayPal API timeout'));
+
+ $this->state_storage->expects($this->once())
+ ->method('mark_failed')
+ ->with(321, 'api_error', 'PayPal API timeout');
+
+ $result = $this->service->process_single(321, 'paypal_express', 'angelleye_ppcp');
+
+ $this->assertTrue($result->is_failure());
+ $this->assertEquals(Migration_Status::FAILED_API_ERROR, $result->status);
+ $this->assertEquals('UPDATE_ERROR', $result->error_code);
+ $this->assertEquals('PayPal API timeout', $result->error_message);
+ }
+
+ // ── process_batch ───────────────────────────────────────────────────
+
+ public function test_process_batch_returns_correct_counts(): void {
+ // Register mock subscriptions for all IDs
+ foreach ([1, 2, 3, 4, 5] as $id) {
+ $this->register_subscription($id);
+ }
+
+ // First call: return batch IDs. Second call: check remaining → empty.
+ $this->state_storage->method('get_pending_subscriptions')
+ ->willReturnOnConsecutiveCalls([1, 2, 3, 4, 5], []);
+
+ $this->state_storage->method('is_processed')->willReturn(false);
+ $this->state_storage->method('get_attempts')->willReturn(1);
+
+ // First 2 have valid tokens, last 3 don't
+ $call_count = 0;
+ $this->token_validator->method('has_valid_token')
+ ->willReturnCallback(function () use (&$call_count) {
+ $call_count++;
+ return $call_count <= 2;
+ });
+
+ $this->token_validator->method('get_token_details')->willReturn([
+ 'meta_key' => '_payment_tokens_id',
+ 'token_value' => '****',
+ ]);
+ $this->token_validator->method('get_all_token_attempts')->willReturn([]);
+
+ $this->payment_method_updater->method('update')
+ ->willReturn(['success' => true]);
+
+ $result = $this->service->process_batch('paypal_express', 'angelleye_ppcp', 5);
+
+ $this->assertEquals(5, $result->total);
+ $this->assertEquals(2, $result->successful);
+ $this->assertEquals(3, $result->failed);
+ $this->assertFalse($result->has_more);
+ }
+
+ public function test_process_batch_empty_returns_zero_results(): void {
+ $this->state_storage->method('get_pending_subscriptions')
+ ->willReturn([]);
+
+ $result = $this->service->process_batch('paypal_express', 'angelleye_ppcp', 10);
+
+ $this->assertEquals(0, $result->total);
+ $this->assertFalse($result->has_more);
+ }
+
+ public function test_process_batch_sets_has_more_when_remaining(): void {
+ $this->register_subscription(1);
+ $this->register_subscription(2);
+
+ // First call: return 2 IDs. Second call (remaining check): return 1 ID.
+ $this->state_storage->method('get_pending_subscriptions')
+ ->willReturnOnConsecutiveCalls([1, 2], [3]);
+
+ $this->state_storage->method('is_processed')->willReturn(false);
+ $this->state_storage->method('get_attempts')->willReturn(1);
+ $this->token_validator->method('has_valid_token')->willReturn(false);
+ $this->token_validator->method('get_all_token_attempts')->willReturn([]);
+
+ $result = $this->service->process_batch('paypal_express', 'angelleye_ppcp', 2);
+
+ $this->assertTrue($result->has_more);
+ }
+
+ // ── retry ───────────────────────────────────────────────────────────
+
+ public function test_retry_calls_process_single_and_returns_result(): void {
+ $subscription = $this->createMock(WC_Subscription::class);
+ $subscription->method('get_meta')->willReturn('');
+ $subscription->method('get_payment_method')->willReturn('paypal_express');
+ $subscription->method('get_id')->willReturn(500);
+ $this->register_subscription(500, $subscription);
+
+ $this->state_storage->method('is_processed')->willReturn(false);
+ $this->state_storage->method('get_attempts')->willReturn(1);
+ $this->token_validator->method('has_valid_token')->willReturn(true);
+ $this->token_validator->method('get_token_details')->willReturn([
+ 'meta_key' => '_payment_tokens_id',
+ 'token_value' => '****',
+ ]);
+ $this->payment_method_updater->method('update')
+ ->willReturn(['success' => true]);
+
+ $result = $this->service->retry(500, 'angelleye_ppcp');
+
+ $this->assertInstanceOf(Migration_Result::class, $result);
+ $this->assertTrue($result->is_success());
+ }
+
+ public function test_retry_throws_for_nonexistent_subscription(): void {
+ // No subscription registered → wcs_get_subscription returns null
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Subscription 999 not found');
+
+ $this->service->retry(999, 'angelleye_ppcp');
+ }
+
+ public function test_retry_uses_old_payment_method_meta_when_available(): void {
+ $subscription = $this->createMock(WC_Subscription::class);
+ $subscription->method('get_meta')
+ ->willReturnCallback(function ($key) {
+ if ($key === '_angelleye_ppcp_old_payment_method') {
+ return 'paypal_express';
+ }
+ return '';
+ });
+ $subscription->method('get_payment_method')->willReturn('angelleye_ppcp');
+ $subscription->method('get_id')->willReturn(600);
+ $this->register_subscription(600, $subscription);
+
+ // process_single will be called with 'paypal_express' (from meta), not 'angelleye_ppcp' (current)
+ $this->state_storage->method('is_processed')->willReturn(false);
+ $this->state_storage->method('get_attempts')->willReturn(1);
+ $this->token_validator->method('has_valid_token')->willReturn(false);
+ $this->token_validator->method('get_all_token_attempts')->willReturn([]);
+
+ $result = $this->service->retry(600, 'angelleye_ppcp');
+
+ // It should have used 'paypal_express' as from_payment_method
+ $this->assertTrue($result->is_failure());
+ $this->assertEquals(600, $result->subscription_id);
+ }
+}
diff --git a/tests/Migration/bootstrap.php b/tests/Migration/bootstrap.php
new file mode 100644
index 000000000..e342a900a
--- /dev/null
+++ b/tests/Migration/bootstrap.php
@@ -0,0 +1,13 @@
+
+
+
+
+ ./Unit
+
+
+
+
+
+ ../../src/Migration
+
+
+
+
+
+
+
+
+
diff --git a/tests/Migration/stubs.php b/tests/Migration/stubs.php
new file mode 100644
index 000000000..6463b1246
--- /dev/null
+++ b/tests/Migration/stubs.php
@@ -0,0 +1,118 @@
+
+ */
+$_test_subscriptions = [];
+
+if (!function_exists('wcs_get_subscription')) {
+ function wcs_get_subscription($subscription_id) {
+ global $_test_subscriptions;
+ return $_test_subscriptions[$subscription_id] ?? null;
+ }
+}
+
+// ── WooCommerce class stubs ─────────────────────────────────────────────────
+// Minimal stubs so PHPUnit can create mocks via createMock().
+
+if (!class_exists('WC_Subscription')) {
+ class WC_Subscription {
+ public function get_id(): int { return 0; }
+ public function get_meta($key, $single = true) { return ''; }
+ public function update_meta_data($key, $value) {}
+ public function delete_meta_data($key) {}
+ public function get_payment_method(): string { return ''; }
+ public function set_payment_method($method) {}
+ public function get_payment_method_title(): string { return ''; }
+ public function set_payment_method_title($title) {}
+ public function get_customer_id(): int { return 0; }
+ public function add_order_note($note) {}
+ public function save() {}
+ }
+}
+
+if (!class_exists('WC_Gateway_PPCP_AngellEYE_Settings')) {
+ class WC_Gateway_PPCP_AngellEYE_Settings {
+ private static $instance;
+ public static function instance() {
+ if (!self::$instance) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+ public function get($key, $default = '') {
+ return $default;
+ }
+ }
+}