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): ?> + + + + + + + + 0): ?> + + + + + + + +
+ + label()); ?> + + + is_failure()): ?> + + +
+ + + +
+
+
+ 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): + ?> +
+

+

+ +

+
    +
  1. +
  2. +
  3. +
  4. +
+
+ + +
+

+ +
+
+ __('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; + } + } +}