PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.25
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.25
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Hooks / CLI / Commands.php

Commands.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.25, at app/Hooks/CLI/Commands.php

1,571 lines 55.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Hooks\CLI;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Models\AppliedCoupon;
7 use FluentCart\App\Models\Customer;
8 use FluentCart\App\Models\Order;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Services\Email\EmailNotificationMailer;
11 use FluentCart\App\Services\Email\EmailNotifications;
12 use FluentCart\App\Services\Email\Mailer;
13 use FluentCart\Database\DBMigrator;
14 use FluentCart\Database\DBSeeder;
15 use FluentCart\Framework\Support\Arr;
16 use FluentCartPro\App\Modules\Licensing\Models\License;
17 use FluentCartPro\App\Modules\Licensing\Models\LicenseSite;
18
19 class Commands
20 {
21 public function migrate_wc_products($args, $assoc_args)
22 {
23 if (!class_exists('WooCommerce')) {
24 \WP_CLI::error('WooCommerce is not installed or activated.');
25 return;
26 }
27
28 $tableCheck = \FluentCart\App\Modules\WooCommerceMigrator\WooCommerceMigratorHelper::checkRequiredTables();
29 if (is_wp_error($tableCheck)) {
30 \WP_CLI::error($tableCheck->get_error_message());
31 return;
32 }
33
34 $wcMigrator = new \FluentCart\App\Modules\WooCommerceMigrator\WooCommerceMigratorCli();
35
36 try {
37 // Count total products for progress bar
38 global $wpdb;
39 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
40 $totalProducts = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'product'");
41
42 if (!$totalProducts) {
43 \WP_CLI::error('No WooCommerce products found to migrate.');
44 return;
45 }
46
47 \WP_CLI::line('Starting attachment migration...');
48 $attachmentResult = $wcMigrator->migrateAttachments();
49 if (is_wp_error($attachmentResult)) {
50 \WP_CLI::error('Attachment migration failed: ' . $attachmentResult->get_error_message());
51 return;
52 }
53 \WP_CLI::success('Attachments migrated successfully.');
54
55 \WP_CLI::line('Starting product migration...');
56 $progress = \WP_CLI\Utils\make_progress_bar('Migrating products', $totalProducts);
57
58 $result = $wcMigrator->migrate_products(Arr::get($assoc_args, 'update', false));
59
60 if (is_wp_error($result)) {
61 $progress->finish();
62 \WP_CLI::error($result->get_error_message());
63 return;
64 }
65
66 $progress->finish();
67
68 \WP_CLI::success(sprintf(
69 'Migration completed. Successfully migrated %d products. Failed: %d products.',
70 $result['success'],
71 $result['failed']
72 ));
73
74 if ($result['failed'] > 0) {
75 \WP_CLI::warning('Failed products:');
76 foreach ($result['failed_ids'] as $productId => $error) {
77 \WP_CLI::line(sprintf('Product ID %d: %s', $productId, $error));
78 }
79 \WP_CLI::warning('Check the migration logs for more details (_fluent_wc_failed_migration_logs option).');
80 }
81
82 // Verify migration
83 $verificationErrors = $this->verifyMigration($result);
84 if (!empty($verificationErrors)) {
85 \WP_CLI::warning('Migration verification found issues:');
86 foreach ($verificationErrors as $error) {
87 \WP_CLI::line('- ' . $error);
88 }
89 } else {
90 \WP_CLI::success('Migration verification passed successfully.');
91 }
92
93 } catch (\Exception $e) {
94 \WP_CLI::error('Migration failed: ' . $e->getMessage());
95 }
96 }
97
98 private function verifyMigration($result)
99 {
100 $errors = [];
101 global $wpdb;
102
103 // Check if all products have details
104 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
105 $productsWithoutDetails = $wpdb->get_var(
106 "SELECT COUNT(*) FROM {$wpdb->posts} p
107 LEFT JOIN {$wpdb->prefix}fct_product_details pd ON p.ID = pd.post_id
108 WHERE p.post_type = 'fluent-products' AND pd.id IS NULL"
109 );
110
111 if ($productsWithoutDetails > 0) {
112 $errors[] = sprintf('%d products are missing product details.', $productsWithoutDetails);
113 }
114
115 // Check if all variable products have variations
116 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
117 $variableProductsWithoutVariations = $wpdb->get_var(
118 "SELECT COUNT(*) FROM {$wpdb->prefix}fct_product_details pd
119 LEFT JOIN {$wpdb->prefix}fct_product_variations pv ON pd.post_id = pv.post_id
120 WHERE pd.variation_type = 'advance_variation' AND pv.id IS NULL"
121 );
122
123 if ($variableProductsWithoutVariations > 0) {
124 $errors[] = sprintf('%d variable products are missing variations.', $variableProductsWithoutVariations);
125 }
126
127 // Check if all downloadable products have download records
128 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
129 $downloadableProductsWithoutFiles = $wpdb->get_var(
130 "SELECT COUNT(*) FROM {$wpdb->prefix}fct_product_details pd
131 LEFT JOIN {$wpdb->prefix}fct_product_downloads dl ON pd.post_id = dl.post_id
132 WHERE pd.fulfillment_type = 'digital' AND dl.id IS NULL"
133 );
134
135 if ($downloadableProductsWithoutFiles > 0) {
136 $errors[] = sprintf('%d downloadable products are missing download files.', $downloadableProductsWithoutFiles);
137 }
138
139 return $errors;
140 }
141
142 public function anonymize_customers()
143 {
144 $total = Customer::count();
145 $progress = \WP_CLI\Utils\make_progress_bar('Anonymize Customers: (' . number_format($total) . ')', $total);
146 $page = 1;
147 $completed = false;
148 while (!$completed) {
149 $customers = Customer::orderBy('id', 'ASC')
150 ->limit(500)
151 ->offset(($page - 1) * 500)
152 ->get();
153 if ($customers->isEmpty()) {
154 $completed = true;
155 break;
156 }
157
158 foreach ($customers as $customer) {
159 $progress->tick();
160 if (!$customer->orders || $customer->orders->isEmpty()) {
161 $customer->delete();
162 continue;
163 }
164 $customer->email = 'customer_' . $customer->id . '@example.com';
165 $customer->user_id = NULL;
166 $customer->save();
167 }
168 $page++;
169 }
170 $progress->finish();
171
172 \WP_CLI::line('Anonymized ' . $total . ' Customers');
173
174 // let's annonymize the license keys as well
175 $total = fluentCart('db')->table('fct_licenses')->count();
176 $progress = \WP_CLI\Utils\make_progress_bar('Anonymize License keys: (' . number_format($total) . ')', $total);
177
178 $page = 1;
179 $completed = false;
180 while (!$completed) {
181 $licesnses = License::orderBy('id', 'ASC')
182 ->limit(500)
183 ->offset(($page - 1) * 500)
184 ->get();
185
186 if ($licesnses->isEmpty()) {
187 $completed = true;
188 break;
189 }
190 foreach ($licesnses as $license) {
191 $progress->tick();
192 $license->license_key = md5($license->license_key . time());
193 $license->save();
194 }
195 $page++;
196 }
197
198 $progress->finish();
199 \WP_CLI::line('Anonymized ' . $total . ' License Keys');
200
201 $progress->finish();
202 \WP_CLI::line('Anonymized ' . $total . ' Sites');
203
204 }
205
206 public function sync_product_names()
207 {
208
209 $productTitles = fluentCart('db')->table('posts')
210 ->where('post_type', 'fluent-products')
211 ->get()
212 ->keyBy('ID');
213
214 foreach ($productTitles as $productId => $product) {
215 continue;
216 fluentCart('db')->table('fct_order_items')
217 ->where('post_id', $productId)
218 ->update([
219 'post_title' => $product->post_title
220 ]);
221 }
222
223 $subscriptions = fluentCart('db')->table('fct_subscriptions')
224 ->get();
225
226 foreach ($subscriptions as $subscription) {
227 $title = $productTitles[$subscription->product_id] ? $productTitles[$subscription->product_id]->post_title : '';
228 if (!$title) {
229 continue;
230 }
231
232 $variation = fluentCart('db')->table('fct_product_variations')
233 ->where('id', $subscription->variation_id)
234 ->first();
235
236 if (!$variation) {
237 continue;
238 }
239
240 $fullTitle = $title . ' - ' . $variation->variation_title;
241
242 fluentCart('db')->table('fct_subscriptions')
243 ->where('id', $subscription->id)
244 ->update([
245 'item_name' => $fullTitle
246 ]);
247 }
248
249
250 dd($productTitles);
251
252
253 }
254
255 public function recount_stats($args, $assoc_args)
256 {
257 $type = Arr::get($assoc_args, 'type');
258 $types = ['customers', 'subscriptions', 'coupons'];
259
260 if (!in_array($type, $types)) {
261 \WP_CLI::line('Invalid Type. Please provide any of the following types:');
262 foreach ($types as $type) {
263 \WP_CLI::line($type);
264 }
265 return;
266 }
267
268 if ($type == 'customers') {
269 $this->recountCustomersStat();
270 } else if ($type == 'subscriptions') {
271 $this->recountSubscriptions();
272 } else if ($type == 'coupons') {
273 $this->recountCoupons();
274 }
275 }
276
277 public function recountCustomersStat()
278 {
279 $completed = false;
280 $page = 1;
281 $perPage = 100;
282 $totalCustomers = Customer::count();
283
284 $progress = \WP_CLI\Utils\make_progress_bar('Recounting Customer stats: (' . number_format($totalCustomers) . ')', $totalCustomers);
285 while (!$completed) {
286 $customers = Customer::orderBy('id', 'ASC')
287 ->limit($perPage)
288 ->offset(($page - 1) * $perPage)
289 ->get();
290
291 if ($customers->isEmpty()) {
292 $completed = true;
293 break;
294 }
295
296 foreach ($customers as $customer) {
297 $orders = \FluentCart\App\Models\Order::query()->where('customer_id', $customer->id)
298 ->with('transactions')
299 ->get();
300
301 $totalPayments = [];
302 $ltv = 0;
303
304 foreach ($orders as $order) {
305 $netPaid = $order->total_paid - $order->total_refund;
306 if ($netPaid <= 0) {
307 continue;
308 }
309
310 $ltv += $netPaid;
311
312
313 foreach ($order->transactions as $transaction) {
314 if ($transaction->status == 'paid') {
315 if (empty($totalPayments[$order['currency']])) {
316 $totalPayments[$order['currency']] = $transaction['total'];
317 } else {
318 $totalPayments[$order['currency']] += $transaction['total'];
319 }
320 }
321 }
322
323 $totalPayments = array_map(function ($value) {
324 return (int)$value;
325 }, $totalPayments);
326 }
327
328 $updateData = [
329 'user_id' => $customer->getWpUserId(true),
330 'purchase_value' => $totalPayments,
331 'ltv' => $ltv,
332 'purchase_count' => $orders->count(),
333 'first_purchase_date' => $orders->min('created_at') . '',
334 'last_purchase_date' => $orders->max('created_at') . '',
335 ];
336
337 App::db()->table('fct_customers')->where('id', $customer->id)->update($updateData);
338 $progress->tick();
339 }
340
341 $page++;
342 }
343
344 $progress->finish();
345 }
346
347 public function recountSubscriptions()
348 {
349 $completed = false;
350 $page = 1;
351 $perPage = 100;
352 $total = Subscription::count();
353
354 $progress = \WP_CLI\Utils\make_progress_bar('Recounting Subscriptions Bills count: (' . number_format($total) . ')', $total);
355 while (!$completed) {
356 $subscriptions = Subscription::orderBy('id', 'ASC')
357 ->limit($perPage)
358 ->offset(($page - 1) * $perPage)
359 ->get();
360
361 if ($subscriptions->isEmpty()) {
362 $completed = true;
363 break;
364 }
365
366 $keyedSubscriptions = [];
367 $parentOrderIds = [];
368
369 foreach ($subscriptions as $subscription) {
370 $progress->tick();
371 if (isset($keyedSubscriptions[$subscription->parent_order_id])) {
372 // dd('Invalid Subscription Parent ID: '. $subscription->parent_order_id);
373 }
374
375 $keyedSubscriptions[$subscription->parent_order_id] = $subscription;
376 $parentOrderIds[] = $subscription->parent_order_id;
377 }
378
379 $renewals = \FluentCart\App\Models\Order::query()
380 ->where(function ($query) use ($parentOrderIds) {
381 $query->whereIn('id', $parentOrderIds)
382 ->orWhereIn('parent_id', $parentOrderIds);
383 })
384 ->whereIn('payment_status', ['paid', 'partially_refunded'])
385 ->get();
386
387 $counts = [];
388
389 foreach ($renewals as $renewal) {
390 if ($renewal->parent_id) {
391 if (!isset($counts[$renewal->parent_id])) {
392 $counts[$renewal->parent_id] = 0;
393 }
394 $counts[$renewal->parent_id]++;
395 } else {
396 if (!isset($counts[$renewal->id])) {
397 $counts[$renewal->id] = 0;
398 }
399 $counts[$renewal->id]++;
400 }
401 }
402
403 foreach ($counts as $orderId => $count) {
404 if (!isset($keyedSubscriptions[$orderId])) {
405 \WP_CLI::line('Invalid Subscription. orderID: ' . $orderId);
406 continue;
407 }
408
409 $subscription = $keyedSubscriptions[$orderId];
410 if ($subscription->bill_count != $count) {
411 unset($subscription->preventsLazyLoading);
412 $subscription->bill_count = $count;
413 $subscription->save();
414 }
415 }
416
417 $page++;
418 }
419
420 $progress->finish();
421 }
422
423 private function recountCoupons()
424 {
425 $appliedCoupons = AppliedCoupon::whereHas('order', function ($query) {
426 $query->whereIn('payment_status', ['paid', 'partially_refunded', 'require_capture']);
427 })
428 ->selectRaw('coupon_id, code, COUNT(*) as count')
429 ->groupBy('coupon_id')
430 ->whereNotNull('coupon_id')
431 ->get();
432
433 foreach ($appliedCoupons as $appliedCoupon) {
434 fluentCart('db')->table('fct_coupons')->where('id', $appliedCoupon->coupon_id)
435 ->update(['use_count' => $appliedCoupon->count]);
436 }
437
438 \WP_CLI::line('Recounted ' . $appliedCoupons->count() . ' Coupons');
439 }
440
441 private function getFakeCustomer()
442 {
443 $faker = \Faker\Factory::create();
444
445 $gender = $faker->randomElement(['male', 'female']);
446
447 $firstName = $faker->firstName($gender);
448 $lastName = $faker->firstName($gender);
449
450 return [
451 'email' => $faker->email,
452 'first_name' => $firstName,
453 'last_name' => $lastName,
454 'billing_first_name' => $firstName,
455 'shipping_last_name' => $lastName,
456 'billing_address_1' => $faker->streetName(),
457 'billing_address_2' => '',
458 'billing_city' => $faker->city(),
459 'billing_state' => $faker->state,
460 'billing_zip' => $faker->postcode,
461 'billing_country' => $faker->countryCode(),
462 'ip_address' => $faker->ipv4,
463 'phone' => $faker->phoneNumber,
464 'date_of_birth' => $faker->date('Y-m-d', '-35 years')
465 ];
466
467 }
468
469 private function getRandomCart($products, $maxAmount = 5)
470 {
471 $maxItem = random_int(1, $maxAmount);
472
473 $cartItems = [];
474 for ($itemCount = 1; $itemCount <= $maxItem; $itemCount++) {
475 $randomProduct = $products[array_rand($products)];
476 $quantity = random_int(1, 3);
477 $itemPrice = $randomProduct['detail']['item_price'];
478 $itemData = [
479 'product_id' => $randomProduct['ID'],
480 'variation_id' => $randomProduct['detail']['id'],
481 'quantity' => $quantity,
482 'item_price' => $itemPrice,
483 'line_total' => $quantity * $itemPrice,
484 'fallback_title' => $randomProduct['post_title']
485 ];
486
487 if ($randomProduct['detail']['manage_cost'] == 'yes' && $randomProduct['detail']['item_cost']) {
488 $profitPerItem = $itemPrice - $randomProduct['detail']['item_cost'];
489 $itemData['net_profit'] = $profitPerItem * $quantity;
490 }
491
492 $cartItems[$randomProduct['ID']] = $itemData;
493 }
494
495 return $cartItems;
496 }
497
498 public function migrate(): void
499 {
500 DBMigrator::migrate();
501 }
502
503 public function migrate_fresh_2($args, $assoc_args)
504 {
505 delete_option('fluent_cart_plugin_once_activated');
506 }
507
508 public function migrate_fresh($args, $assoc_args, $checkDev = true)
509 {
510
511 if ($checkDev && App::config()->get('using_faker') === false) {
512 if (class_exists('WP_CLI')) {
513 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
514 echo \WP_CLI::colorize('%yYou Are Not In Dev Mode');
515 } else {
516 echo esc_html__("You Are Not In Dev Mode", "fluent-cart");
517 }
518 return;
519 }
520
521 delete_option('fluent_cart_plugin_once_activated');
522 delete_option('fluent_cart_store_settings');
523
524 delete_option('__fluent_cart_edd2_migration_steps');
525 delete_option('_fluent_edd_failed_payment_logs');
526
527 global $wpdb;
528 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
529 $wpdb->query("SET GLOBAL FOREIGN_KEY_CHECKS=0;");
530
531 try {
532 DBMigrator::refresh();
533 } catch (\Exception $e) {
534
535 }
536
537 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
538 $wpdb->query("SET GLOBAL FOREIGN_KEY_CHECKS=1;");
539
540 // Delete the post metas
541 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
542 $wpdb->query("DELETE pm FROM {$wpdb->prefix}postmeta pm INNER JOIN {$wpdb->prefix}posts p ON pm.post_id = p.ID WHERE p.post_type = 'fluent-products'");
543 // Delete the posts
544 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
545 $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_type = 'fluent-products'");
546
547 // Delete the post metas
548 $postmetas = ['_edd_migrated_from', '_fcart_migrated_id', '__edd_migrated_variation_maps'];
549 foreach ($postmetas as $postMeta) {
550 // delete from wp_postmeta table where meta_key = $postMeta
551 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
552 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}postmeta WHERE meta_key = %s", $postMeta));
553 }
554
555 if (isset($assoc_args['seed'])) {
556 $this->seed_all($args, $assoc_args, 1000, false);
557 }
558
559 if (class_exists('WP_CLI')) {
560 \WP_CLI::line('All Data has been reseted!');
561 } else {
562 echo "All Done!";
563 }
564
565 }
566
567 public function seed($args, $assoc_args, $default = 1000, $checkDev = true)
568 {
569 if ($checkDev) {
570 $this->authorize();
571 }
572
573 $entities = ['product', 'customer', 'order', 'coupon', 'tax'];
574 $count = isset($assoc_args['count']) ? absint($assoc_args['count']) : $default;
575
576
577 if (class_exists('WP_CLI')) {
578 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
579 echo \WP_CLI::colorize('%yInserting ' . esc_html($count) . ' records. Please wait...%n');
580 }
581
582 foreach ($entities as $entity) {
583 if (isset($assoc_args[$entity])) {
584 DBSeeder::run($count, $entity, true, $assoc_args);
585 }
586 }
587
588 if (class_exists('WP_CLI')) {
589 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
590 echo \WP_CLI::colorize( '%GSuccess: ' . esc_html( $count ) . ' records inserted into the database.%n' );
591
592 }
593 }
594
595 public function authorize($checkDev = true)
596 {
597 if ($checkDev && App::config()->get('using_faker') === false) {
598 if (class_exists('WP_CLI')) {
599 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
600 echo \WP_CLI::colorize('%yYou Are Not In Dev Mode');
601 } else {
602 echo('You Are Not In Dev Mode');
603 }
604
605 die();
606 }
607 }
608
609 public function seed_all($args, $assoc_args, $default = 1000, $checkDev = true)
610 {
611
612 if ($checkDev) {
613 $this->authorize();
614 }
615
616 $count = isset($assoc_args['count']) ? absint($assoc_args['count']) : $default;
617 if (class_exists('WP_CLI')) {
618 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
619 echo \WP_CLI::colorize('%yInserting ' . esc_html($count) . ' records. Please wait...%n');
620 }
621 DBSeeder::run($count);
622 if (class_exists('WP_CLI')) {
623 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
624 echo \WP_CLI::colorize('%GSuccess: ' . esc_html($count) . ' records inserted into the database.%n');
625 }
626 }
627
628
629 public function generate_billing_address_from_ip()
630 {
631 if (!function_exists('fluent_geo_location')) {
632 \WP_CLI::line('Skipping Billing Address Generation. fluent_geo_location function not found.');
633 return;
634 }
635
636 $counts = Order::whereDoesntHave('billing_address')->count();
637 if (!$counts) {
638 \WP_CLI::line('All Done!');
639 return;
640 }
641
642 $progress = \WP_CLI\Utils\make_progress_bar('Generating Billing Address: (' . number_format($counts) . ')', $counts);
643 $completed = false;
644 $perPage = 100;
645 $page = 1;
646
647 while (!$completed) {
648 $orders = Order::whereDoesntHave('billing_address')
649 ->orderBy('id', 'ASC')
650 ->limit($perPage)
651 ->offset(($page - 1) * $perPage)
652 ->get();
653
654 if ($orders->isEmpty()) {
655 $completed = true;
656 break;
657 }
658
659 $allAddresses = [];
660 foreach ($orders as $order) {
661 $progress->tick();
662 $ip = $order->ip_address;
663 if (!$ip) {
664 continue;
665 }
666
667 $geoInfo = fluent_geo_location($ip);
668
669 if (is_wp_error($geoInfo)) {
670 continue;
671 }
672
673 if (empty($geoInfo['country'])) {
674 continue;
675 }
676
677 $allAddresses[] = array_filter([
678 'type' => 'billing',
679 'order_id' => $order->id,
680 'city' => $geoInfo['city'],
681 'state' => $geoInfo['state'],
682 'country' => $geoInfo['country'],
683 'postcode' => $geoInfo['postal_code'],
684 'created_at' => $order->created_at,
685 'updated_at' => $order->updated_at,
686 ]);
687
688 $customer = $order->customer;
689 if (!$customer->country && $geoInfo['country']) {
690 $customer->country = $geoInfo['country'] ?? '';
691 $customer->city = $geoInfo['city'] ?? '';
692 $customer->state = $geoInfo['state'] ?? '';
693 $customer->postcode = $geoInfo['postal_code'] ?? '';
694 $customer->save();
695 }
696 }
697
698 if ($allAddresses) {
699 foreach ($allAddresses as $address) {
700 fluentCart('db')->table('fct_order_addresses')->insert($address);
701 }
702 }
703
704 $page++;
705 }
706
707 $progress->finish();
708 }
709
710
711 /**
712 * Migrate WooCommerce customers to FluentCart
713 *
714 * ## OPTIONS
715 *
716 * [--force]
717 * : Force migration even if customers already exist
718 *
719 * [--debug]
720 * : Show debug information about customers found
721 *
722 * ## EXAMPLES
723 *
724 * wp fluent_cart migrate_customers
725 * wp fluent_cart migrate_customers --force
726 * wp fluent_cart migrate_customers --debug
727 */
728 public function migrate_customers($args, $assoc_args)
729 {
730 \WP_CLI::line('Starting WooCommerce to FluentCart customer migration...');
731
732 // Debug mode to check what customers are found
733 if (isset($assoc_args['debug'])) {
734 global $wpdb;
735
736 \WP_CLI::line('=== DEBUG MODE ===');
737
738 // Check WooCommerce
739 if (!class_exists('WooCommerce')) {
740 \WP_CLI::error('WooCommerce is not active');
741 return;
742 }
743 \WP_CLI::line('✓ WooCommerce is active');
744
745 // Check customers query
746 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
747 $customers = $wpdb->get_results("
748 SELECT DISTINCT u.ID, u.user_email, u.user_registered
749 FROM {$wpdb->users} u
750 WHERE u.ID IN (
751 SELECT DISTINCT pm.meta_value
752 FROM {$wpdb->posts} p
753 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
754 WHERE p.post_type = 'shop_order'
755 AND pm.meta_key = '_customer_user'
756 AND pm.meta_value > 0
757 )
758 OR EXISTS (
759 SELECT 1 FROM {$wpdb->usermeta} um
760 WHERE um.user_id = u.ID
761 AND um.meta_key = 'paying_customer'
762 AND um.meta_value = '1'
763 )
764 ORDER BY u.user_registered ASC
765 LIMIT 10
766 ");
767
768 \WP_CLI::line(sprintf('Found %d customers:', count($customers)));
769 foreach ($customers as $customer) {
770 \WP_CLI::line(sprintf('- ID: %d, Email: %s, Registered: %s',
771 $customer->ID, $customer->user_email, $customer->user_registered));
772 }
773
774 // Check existing FluentCart customers
775 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
776 $fluentCustomers = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}fct_customers");
777 \WP_CLI::line(sprintf('Existing FluentCart customers: %d', $fluentCustomers));
778
779 return;
780 }
781
782 $service = new \FluentCart\App\Modules\WooCommerceMigrator\Services\CustomerMigrationService();
783
784 if (!$service->checkDependencies()) {
785 \WP_CLI::error('Migration dependencies not met. Check error logs for details.');
786 return;
787 }
788
789 $options = [
790 'force' => isset($assoc_args['force']) && $assoc_args['force']
791 ];
792
793 $stats = $service->migrate($options);
794
795 $this->displayMigrationStats('Customers', $stats);
796 }
797
798
799 /**
800 * Migrate WooCommerce orders to FluentCart
801 *
802 * ## OPTIONS
803 *
804 * [--batch-size=<size>]
805 * : Number of orders to process at once
806 * ---
807 * default: 50
808 * ---
809 *
810 * [--start-date=<date>]
811 * : Start date for order migration (YYYY-MM-DD)
812 *
813 * [--end-date=<date>]
814 * : End date for order migration (YYYY-MM-DD)
815 *
816 * [--debug]
817 * : Show debug information about orders found
818 *
819 * ## EXAMPLES
820 *
821 * wp fluent_cart migrate_orders
822 * wp fluent_cart migrate_orders --batch-size=25
823 * wp fluent_cart migrate_orders --start-date=2024-01-01 --end-date=2024-12-31
824 * wp fluent_cart migrate_orders --debug
825 */
826 public function migrate_orders($args, $assoc_args)
827 {
828 \WP_CLI::line('Starting WooCommerce to FluentCart order migration...');
829
830 $service = new \FluentCart\App\Modules\WooCommerceMigrator\Services\OrderMigrationService();
831
832 if (!$service->canMigrate()) {
833 $errors = $service->getErrors();
834 foreach ($errors as $error) {
835 \WP_CLI::error($error);
836 }
837 return;
838 }
839
840 // Debug mode to check what orders are found
841 if (isset($assoc_args['debug'])) {
842 global $wpdb;
843
844 \WP_CLI::line('=== DEBUG MODE ===');
845
846 // Check HPOS
847 $hposEnabled = get_option('woocommerce_custom_orders_table_enabled') === 'yes';
848 \WP_CLI::line($hposEnabled ? '✓ HPOS is enabled' : '✗ HPOS is not enabled');
849
850 // Check orders
851 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
852 $orderCount = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}wc_orders WHERE type = 'shop_order'");
853 \WP_CLI::line(sprintf('Total WooCommerce orders: %d', $orderCount));
854
855 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
856 $migratedCount = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}fct_orders WHERE invoice_no LIKE 'WC-%'");
857 \WP_CLI::line(sprintf('Already migrated orders: %d', $migratedCount));
858
859 // Sample orders
860 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
861 $sampleOrders = $wpdb->get_results("
862 SELECT id, status, currency, total_amount, customer_id, date_created_gmt
863 FROM {$wpdb->prefix}wc_orders
864 WHERE type = 'shop_order'
865 ORDER BY id DESC
866 LIMIT 5
867 ");
868
869 \WP_CLI::line('Sample orders:');
870 foreach ($sampleOrders as $order) {
871 \WP_CLI::line(sprintf('- ID: %d, Status: %s, Total: %s %s, Customer: %s, Date: %s',
872 $order->id, $order->status, $order->currency, $order->total_amount,
873 $order->customer_id ?: 'Guest', $order->date_created_gmt));
874 }
875
876 return;
877 }
878
879 $options = [];
880
881 // Set batch size
882 if (isset($assoc_args['batch-size'])) {
883 $service->setBatchSize((int)$assoc_args['batch-size']);
884 }
885
886 // Set date filters (if needed in future)
887 if (isset($assoc_args['start-date']) || isset($assoc_args['end-date'])) {
888 \WP_CLI::warning('Date filtering not yet implemented. Processing all orders.');
889 }
890
891 $stats = $service->migrate($options);
892
893 $this->displayMigrationStats('Orders', $stats);
894 }
895
896 /**
897 * Update customer purchase statistics after order migration
898 *
899 * This command recalculates purchase counts, values, and dates for customers
900 * who have migrated orders. Useful if orders were migrated but customer stats
901 * weren't properly updated.
902 *
903 * ## EXAMPLES
904 *
905 * wp fluent_cart update_customer_stats
906 *
907 * @when after_wp_load
908 */
909 public function update_customer_stats($args, $assoc_args)
910 {
911 \WP_CLI::line('Updating customer purchase statistics...');
912
913 try {
914 $orderService = new \FluentCart\App\Modules\WooCommerceMigrator\Services\OrderMigrationService();
915 $result = $orderService->updateAllCustomerStats();
916
917 \WP_CLI::success($result['message']);
918
919 if (!empty($orderService->getErrors())) {
920 \WP_CLI::warning('Some errors occurred:');
921 foreach ($orderService->getErrors() as $error) {
922 \WP_CLI::line('- ' . $error);
923 }
924 }
925
926 } catch (\Exception $e) {
927 \WP_CLI::error('Failed to update customer statistics: ' . $e->getMessage());
928 }
929 }
930
931 /**
932 * Run complete migration (products, customers, orders)
933 *
934 * ## OPTIONS
935 *
936 * [--skip-products]
937 * : Skip product migration
938 *
939 * [--skip-customers]
940 * : Skip customer migration
941 *
942 * [--skip-orders]
943 * : Skip order migration
944 *
945 * ## EXAMPLES
946 *
947 * wp fluent_cart migrate_all
948 * wp fluent_cart migrate_all --skip-products
949 * wp fluent_cart migrate_all --skip-customers
950 * wp fluent_cart migrate_all --skip-orders
951 */
952 public function migrate_all($args, $assoc_args)
953 {
954 \WP_CLI::line('Starting complete WooCommerce to FluentCart migration...');
955 \WP_CLI::line('Migration order: Products → Customers → Orders (dependencies respected)');
956
957 $totalStats = [
958 'products' => null,
959 'customers' => null,
960 'orders' => null
961 ];
962
963 // Migrate Products (foundation requirement)
964 if (!isset($assoc_args['skip-products'])) {
965 \WP_CLI::line('');
966 \WP_CLI::line('=== MIGRATING PRODUCTS ===');
967 \WP_CLI::line('Products must be migrated first (required for order items)');
968 try {
969 $this->migrate_wc_products($args, $assoc_args);
970 \WP_CLI::success('Product migration completed');
971 } catch (\Exception $e) {
972 \WP_CLI::error('Product migration failed: ' . $e->getMessage());
973 \WP_CLI::line('Cannot proceed with orders without products. Stopping migration.');
974 return;
975 }
976 }
977
978 // Migrate Customers (required for orders)
979 if (!isset($assoc_args['skip-customers'])) {
980 \WP_CLI::line('');
981 \WP_CLI::line('=== MIGRATING CUSTOMERS ===');
982 \WP_CLI::line('Customers must be migrated before orders (required for order ownership)');
983 $service = new \FluentCart\App\Modules\WooCommerceMigrator\Services\CustomerMigrationService();
984 $totalStats['customers'] = $service->migrate();
985 $this->displayMigrationStats('Customers', $totalStats['customers']);
986
987 if (($totalStats['customers']['success'] ?? 0) === 0) {
988 \WP_CLI::warning('No customers were migrated. Orders migration may have limited functionality.');
989 }
990 }
991
992 // Migrate Orders (depends on products and customers)
993 if (!isset($assoc_args['skip-orders'])) {
994 \WP_CLI::line('');
995 \WP_CLI::line('=== MIGRATING ORDERS ===');
996 \WP_CLI::line('Orders migration includes: line items, addresses, coupons, fees, and metadata');
997
998 $orderService = new \FluentCart\App\Modules\WooCommerceMigrator\Services\OrderMigrationService();
999
1000 if (!$orderService->canMigrate()) {
1001 $errors = $orderService->getErrors();
1002 \WP_CLI::warning('Order migration cannot proceed:');
1003 foreach ($errors as $error) {
1004 \WP_CLI::line('- ' . $error);
1005 }
1006 \WP_CLI::line('Skipping order migration. Run individual migrations first.');
1007 } else {
1008 $totalStats['orders'] = $orderService->migrate();
1009 $this->displayMigrationStats('Orders', $totalStats['orders']);
1010 }
1011 }
1012
1013 // Summary
1014 \WP_CLI::line('');
1015 \WP_CLI::line('=== MIGRATION SUMMARY ===');
1016 $overallSuccess = true;
1017
1018 foreach ($totalStats as $type => $stats) {
1019 if ($stats === null) {
1020 \WP_CLI::line(sprintf('%s: Skipped', ucfirst($type)));
1021 } else {
1022 $success = $stats['success'] ?? 0;
1023 $failed = $stats['failed'] ?? 0;
1024 $total = $success + $failed;
1025 \WP_CLI::line(sprintf('%s: %d/%d successful', ucfirst($type), $success, $total));
1026 if ($failed > 0) {
1027 $overallSuccess = false;
1028 }
1029 }
1030 }
1031
1032 if ($overallSuccess) {
1033 \WP_CLI::success('Migration completed successfully! All data has been migrated.');
1034 } else {
1035 \WP_CLI::warning('Migration completed with some failures. Check individual stats above for details.');
1036 }
1037 }
1038
1039
1040 private function displayMigrationStats($type, $stats)
1041 {
1042 if (!$stats) {
1043 \WP_CLI::line("No {$type} migration stats available.");
1044 return;
1045 }
1046
1047 \WP_CLI::line('');
1048 \WP_CLI::line("=== {$type} Migration Stats ===");
1049 \WP_CLI::line(sprintf('Success: %d', $stats['success'] ?? 0));
1050 \WP_CLI::line(sprintf('Failed: %d', $stats['failed'] ?? 0));
1051 \WP_CLI::line(sprintf('Skipped: %d', $stats['skipped'] ?? 0));
1052 \WP_CLI::line(sprintf('Total processed: %d', ($stats['success'] ?? 0) + ($stats['failed'] ?? 0) + ($stats['skipped'] ?? 0)));
1053
1054 if (!empty($stats['errors'])) {
1055 \WP_CLI::line('');
1056 \WP_CLI::line('Errors:');
1057 foreach ($stats['errors'] as $error) {
1058 \WP_CLI::line('- ' . $error);
1059 }
1060 }
1061
1062 if (!empty($stats['warnings'])) {
1063 \WP_CLI::line('');
1064 \WP_CLI::line('Warnings:');
1065 foreach ($stats['warnings'] as $warning) {
1066 \WP_CLI::line('- ' . $warning);
1067 }
1068 }
1069 }
1070
1071 /**
1072 * Clone existing orders with random dates
1073 *
1074 * ## OPTIONS
1075 *
1076 * [--count=<number>]
1077 * : Number of orders to clone
1078 * ---
1079 * default: 10
1080 * ---
1081 *
1082 * [--start-date=<date>]
1083 * : Start date for cloned orders (YYYY-MM-DD)
1084 * ---
1085 * default: 30 days ago
1086 * ---
1087 *
1088 * [--end-date=<date>]
1089 * : End date for cloned orders (YYYY-MM-DD)
1090 * ---
1091 * default: today
1092 * ---
1093 *
1094 * [--source-order-id=<id>]
1095 * : Specific order ID to clone
1096 *
1097 * ## EXAMPLES
1098 *
1099 * wp fluent_cart clone_orders --count=50
1100 * wp fluent_cart clone_orders --count=25 --start-date=2024-01-01 --end-date=2024-12-31
1101 * wp fluent_cart clone_orders --source-order-id=123 --count=5
1102 */
1103 public function clone_orders($args, $assoc_args)
1104 {
1105 $cloner = new OrderCloneCommand();
1106 $cloner->clone_orders($args, $assoc_args);
1107 }
1108
1109 /**
1110 * Generate retention snapshots for cohort analysis
1111 *
1112 * This command processes all subscriptions and generates monthly retention
1113 * snapshots that track true customer retention (not subscription retention).
1114 * Customers who "recycle" (cancel and re-subscribe) are correctly tracked
1115 * as retained, not churned.
1116 *
1117 * ## OPTIONS
1118 *
1119 * [--product_id=<id>]
1120 * : Only process a specific product (optional)
1121 *
1122 * ## EXAMPLES
1123 *
1124 * wp fluent_cart generate_retention_snapshots --product_id=123
1125 *
1126 * @when after_wp_load
1127 */
1128 public function generate_retention_snapshots($args, $assoc_args)
1129 {
1130 $command = new RetentionSnapshotCommand();
1131 $command->generate($args, $assoc_args);
1132 }
1133
1134
1135 /**
1136 * Send a test email for any notification type
1137 *
1138 * ## OPTIONS
1139 *
1140 * [<notification-name>]
1141 * : The notification name to send (e.g. order_paid_customer)
1142 *
1143 * [--order_id=<id>]
1144 * : Use a real order for email data
1145 *
1146 * [--to=<email>]
1147 * : Override recipient email address
1148 *
1149 * [--list]
1150 * : List all available notification names
1151 *
1152 * [--all]
1153 * : Send all notifications at once (requires --to)
1154 *
1155 * [--render-only]
1156 * : Skip sending, just render and save the HTML file to .debug-emails/
1157 *
1158 * ## EXAMPLES
1159 *
1160 * wp fluent_cart test_email --list
1161 * wp fluent_cart test_email order_paid_customer --order_id=42
1162 * wp fluent_cart test_email order_paid_customer --order_id=42 --to=dev@example.com
1163 * wp fluent_cart test_email order_paid_customer --to=dev@example.com
1164 * wp fluent_cart test_email order_paid_customer --render-only
1165 * wp fluent_cart test_email order_paid_customer --render-only --order_id=42
1166 * wp fluent_cart test_email --all --to=dev@example.com
1167 * wp fluent_cart test_email --all --to=dev@example.com --order_id=42
1168 */
1169 public function test_email($args, $assoc_args)
1170 {
1171 $notifications = EmailNotifications::getNotifications();
1172
1173 // --list flag: show all available notifications
1174 if (isset($assoc_args['list'])) {
1175 $rows = [];
1176 foreach ($notifications as $name => $notification) {
1177 $settings = Arr::get($notification, 'settings', []);
1178 $rows[] = [
1179 'Name' => $name,
1180 'Title' => Arr::get($notification, 'title', ''),
1181 'Recipient' => Arr::get($notification, 'recipient', ''),
1182 'Active' => Arr::get($settings, 'active', 'no'),
1183 ];
1184 }
1185 \WP_CLI\Utils\format_items('table', $rows, ['Name', 'Title', 'Recipient', 'Active']);
1186 return;
1187 }
1188
1189 // --all flag: send every notification
1190 if (isset($assoc_args['all'])) {
1191 $to = Arr::get($assoc_args, 'to');
1192 if (empty($to)) {
1193 \WP_CLI::error('--all requires --to=<email> to specify the recipient.');
1194 return;
1195 }
1196
1197 $data = $this->resolveEmailData($assoc_args);
1198 $mailer = new EmailNotificationMailer();
1199 $sent = 0;
1200 $failed = 0;
1201
1202 foreach ($notifications as $name => $notification) {
1203 try {
1204 $formatted = EmailNotifications::formatNotification($notification, $data);
1205 list($body, $subject, $_to) = $mailer->parseEmailContent($formatted, $data);
1206
1207 $result = Mailer::make()->to(sanitize_email($to))->subject($subject)->body($body)->send(true);
1208
1209 if ($result) {
1210 \WP_CLI::line(sprintf('[OK] %s — %s', $name, $subject));
1211 $sent++;
1212 } else {
1213 \WP_CLI::warning(sprintf('[FAIL] %s — wp_mail returned false', $name));
1214 $failed++;
1215 }
1216 } catch (\Throwable $e) {
1217 \WP_CLI::warning(sprintf('[ERROR] %s — %s', $name, $e->getMessage()));
1218 $failed++;
1219 }
1220 }
1221
1222 \WP_CLI::success(sprintf('Done. Sent: %d, Failed: %d, Total: %d', $sent, $failed, $sent + $failed));
1223 return;
1224 }
1225
1226 // Validate notification name
1227 if (empty($args[0])) {
1228 \WP_CLI::error('Please provide a notification name. Use --list to see available names, or --all to send all.');
1229 return;
1230 }
1231
1232 $emailName = $args[0];
1233 if (!isset($notifications[$emailName])) {
1234 \WP_CLI::error(sprintf(
1235 "Unknown notification: '%s'. Available: %s",
1236 $emailName,
1237 implode(', ', array_keys($notifications))
1238 ));
1239 return;
1240 }
1241
1242 // Build email data
1243 $data = $this->resolveEmailData($assoc_args);
1244
1245 // Format the notification and render email
1246 $mailer = new EmailNotificationMailer();
1247
1248 $notification = EmailNotifications::getNotification($emailName);
1249 $notification = EmailNotifications::formatNotification($notification, $data);
1250
1251 list($body, $subject, $to) = $mailer->parseEmailContent($notification, $data);
1252
1253 // Save debug HTML file
1254 $debugDir = FLUENTCART_PLUGIN_PATH . '.debug-emails';
1255 if (!is_dir($debugDir)) {
1256 mkdir($debugDir, 0755, true);
1257 }
1258 $debugFile = $debugDir . '/' . $emailName . '.html';
1259 file_put_contents($debugFile, $body);
1260
1261 \WP_CLI::line('Subject: ' . $subject);
1262 \WP_CLI::line('Debug HTML: ' . $debugFile);
1263
1264 // --render-only: skip sending, just output the rendered HTML file
1265 if (isset($assoc_args['render-only'])) {
1266 \WP_CLI::success('Rendered to ' . $debugFile);
1267 return;
1268 }
1269
1270 // Override recipient if --to provided
1271 $toOverride = Arr::get($assoc_args, 'to');
1272 if ($toOverride) {
1273 $to = sanitize_email($toOverride);
1274 }
1275
1276 if (empty($to)) {
1277 \WP_CLI::error('No recipient email resolved. Use --to=<email> to specify one.');
1278 return;
1279 }
1280
1281 // Send
1282 $result = Mailer::make()->to($to)->subject($subject)->body($body)->send(true);
1283
1284 \WP_CLI::line('To: ' . $to);
1285
1286 if ($result) {
1287 \WP_CLI::success('Email sent successfully.');
1288 } else {
1289 \WP_CLI::warning('wp_mail() returned false. Check your mail configuration.');
1290 }
1291 }
1292
1293 /**
1294 * Render raw block markup through FluentBlockParser and save as HTML.
1295 *
1296 * Useful for testing individual block renderers without a full notification.
1297 *
1298 * <file>
1299 * : Path to a file containing block markup
1300 *
1301 * [--order_id=<id>]
1302 * : Use a real order for shortcode data
1303 *
1304 * [--out=<path>]
1305 * : Output file path (default: .debug-emails/render_blocks.html)
1306 *
1307 * [--wrapper]
1308 * : Wrap in email template (default: yes). Use --no-wrapper to skip.
1309 *
1310 * ## EXAMPLES
1311 *
1312 * wp fluent_cart render_blocks blocks.html
1313 * wp fluent_cart render_blocks blocks.html --order_id=42
1314 * wp fluent_cart render_blocks blocks.html --out=test-output.html
1315 * wp fluent_cart render_blocks blocks.html --no-wrapper
1316 */
1317 public function render_blocks($args, $assoc_args)
1318 {
1319 if (empty($args[0])) {
1320 \WP_CLI::error('Please provide a file path containing block markup.');
1321 return;
1322 }
1323
1324 $file = $args[0];
1325 if (!file_exists($file)) {
1326 \WP_CLI::error("File not found: {$file}");
1327 return;
1328 }
1329
1330 $blockMarkup = file_get_contents($file);
1331 if (empty(trim($blockMarkup))) {
1332 \WP_CLI::error("File is empty: {$file}");
1333 return;
1334 }
1335
1336 // Resolve data for shortcode replacement
1337 $data = $this->resolveEmailData($assoc_args);
1338
1339 // Parse blocks through the pro block parser filter
1340 $rendered = apply_filters('fluent_cart/parse_email_block_content', '', $blockMarkup, $data);
1341
1342 if (empty($rendered)) {
1343 \WP_CLI::error('Block parsing requires FluentCart Pro. Please ensure the pro plugin is active.');
1344 return;
1345 }
1346
1347 // Optionally wrap in the email template (--no-wrapper to skip)
1348 $useWrapper = Arr::get($assoc_args, 'wrapper', true);
1349 if ($useWrapper) {
1350 $mailer = new EmailNotificationMailer();
1351 $rendered = apply_filters('fluent_cart/render_block_email_template', $rendered, [
1352 'emailBody' => $rendered,
1353 'preheader' => '',
1354 'emailFooter' => $mailer->getEmailFooter(),
1355 ]);
1356 }
1357
1358 // Replace shortcodes
1359 $rendered = \FluentCart\App\Services\ShortCodeParser\ShortcodeTemplateBuilder::make($rendered, $data);
1360
1361 // Determine output path
1362 $outPath = Arr::get($assoc_args, 'out');
1363 if (!$outPath) {
1364 $debugDir = FLUENTCART_PLUGIN_PATH . '.debug-emails';
1365 if (!is_dir($debugDir)) {
1366 mkdir($debugDir, 0755, true);
1367 }
1368 $outPath = $debugDir . '/render_blocks.html';
1369 }
1370
1371 file_put_contents($outPath, $rendered);
1372 \WP_CLI::success('Rendered to ' . $outPath);
1373 }
1374
1375 /**
1376 * Resolve order/mock data for test emails.
1377 *
1378 * @param array $assoc_args
1379 * @return array
1380 */
1381 private function resolveEmailData($assoc_args)
1382 {
1383 $order = null;
1384 $orderId = Arr::get($assoc_args, 'order_id');
1385
1386 if ($orderId) {
1387 $order = Order::query()
1388 ->with(['customer', 'shipping_address', 'billing_address', 'transactions', 'order_items'])
1389 ->find(absint($orderId));
1390
1391 if (!$order) {
1392 \WP_CLI::warning("Order #{$orderId} not found. Using mock data.");
1393 }
1394 }
1395
1396 if (!$order) {
1397 $order = Order::query()
1398 ->with(['customer', 'shipping_address', 'billing_address', 'transactions', 'order_items'])
1399 ->latest()
1400 ->first();
1401 }
1402
1403 if ($order) {
1404 $transaction = [];
1405 if (!empty($order->transactions)) {
1406 $transaction = $order->transactions->first();
1407 }
1408
1409 // Load subscription if one exists for this order
1410 $subscription = Subscription::where('parent_order_id', $order->id)->first();
1411
1412 \WP_CLI::line(sprintf('Using order #%d (%s)', $order->id, $order->invoice_no));
1413 $data = [
1414 'order' => $order,
1415 'customer' => $order->customer !== null ? $order->customer : [],
1416 'transaction' => $transaction ? $transaction : [],
1417 ];
1418
1419 if ($subscription) {
1420 $data['subscription'] = $subscription;
1421 \WP_CLI::line(sprintf('Using subscription #%d (%s)', $subscription->id, $subscription->status));
1422 }
1423
1424 return $data;
1425 }
1426
1427 \WP_CLI::line('No orders found. Using mock data.');
1428 return $this->getMockEmailData();
1429 }
1430
1431 private function getMockEmailData()
1432 {
1433 $faker = \Faker\Factory::create();
1434 $now = current_time('mysql');
1435
1436 $customer = new Customer();
1437 $customer->id = 999;
1438 $customer->first_name = $faker->firstName;
1439 $customer->last_name = $faker->lastName;
1440 $customer->email = $faker->safeEmail;
1441 $customer->phone = $faker->phoneNumber;
1442 $customer->city = $faker->city;
1443 $customer->state = $faker->state;
1444 $customer->postcode = $faker->postcode;
1445 $customer->country = $faker->countryCode;
1446
1447 $item1 = new \FluentCart\App\Models\OrderItem();
1448 $item1->id = 1;
1449 $item1->post_title = 'Sample Product One';
1450 $item1->item_price = 4900;
1451 $item1->quantity = 1;
1452 $item1->line_total = 4900;
1453
1454 $item2 = new \FluentCart\App\Models\OrderItem();
1455 $item2->id = 2;
1456 $item2->post_title = 'Sample Product Two';
1457 $item2->item_price = 2500;
1458 $item2->quantity = 2;
1459 $item2->line_total = 5000;
1460
1461 $orderItems = new \FluentCart\Framework\Support\Collection([$item1, $item2]);
1462
1463 $order = new Order();
1464 $order->id = 9999;
1465 $order->invoice_no = 'FC-MOCK-9999';
1466 $order->total = 9900;
1467 $order->subtotal = 9900;
1468 $order->discount_total = 0;
1469 $order->tax_total = 0;
1470 $order->total_paid = 9900;
1471 $order->total_refund = 0;
1472 $order->payment_status = 'paid';
1473 $order->currency = 'USD';
1474 $order->customer_id = 999;
1475 $order->created_at = $now;
1476 $order->updated_at = $now;
1477 $order->setRelation('customer', $customer);
1478 $order->setRelation('order_items', $orderItems);
1479 $order->setRelation('orderTaxRates', new \FluentCart\Framework\Support\Collection());
1480 $order->setRelation('transactions', new \FluentCart\Framework\Support\Collection());
1481 $order->setRelation('shipping_address', null);
1482 $order->setRelation('billing_address', null);
1483
1484 $transaction = new \FluentCart\App\Models\OrderTransaction();
1485 $transaction->id = 1;
1486 $transaction->total = 9900;
1487 $transaction->payment_method = 'stripe';
1488 $transaction->status = 'paid';
1489 $transaction->created_at = $now;
1490
1491 return [
1492 'order' => $order,
1493 'customer' => $customer,
1494 'transaction' => $transaction,
1495 ];
1496 }
1497
1498 public function sync_stripe_renwals($args, $assoc_args)
1499 {
1500 $days = isset($assoc_args['days']) ? absint($assoc_args['days']) : 30;
1501
1502 if ($days > 365) {
1503 \WP_CLI::line('Days cannot be more than 365');
1504 return;
1505 }
1506
1507 // Base query parameters (applied to every page)
1508 $body_params = array(
1509 'expand' => ['data.payment_intent'],
1510 'status' => 'paid',
1511 'limit' => 10, // Max per page
1512 'created[gt]' => strtotime(-$days . ' days'), // Invoices created after ~30 days ago
1513 );
1514
1515 $starting_after = null;
1516 $has_more = true;
1517
1518 while ($has_more) {
1519 if ($starting_after) {
1520 $body_params['starting_after'] = $starting_after;
1521 }
1522 $data = (new \FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API())->getStripeObject('invoices', $body_params);
1523
1524 if (is_wp_error($data)) {
1525 \WP_CLI::line('Error fetching invoices: ' . $data->get_error_message());
1526 return;
1527 }
1528
1529 foreach ($data['data'] as $invoice) {
1530 if (isset($invoice['billing_reason']) && $invoice['billing_reason'] === 'subscription_cycle') {
1531 $paymentIntent = isset($invoice['payment_intent']) ? $invoice['payment_intent'] : null;
1532 if(!$paymentIntent) {
1533 continue;
1534 }
1535
1536 $transactionExists = \FluentCart\App\Models\OrderTransaction::where('vendor_charge_id', $paymentIntent['id'])
1537 ->where('payment_method', 'stripe')
1538 ->exists();
1539
1540 if ($transactionExists) {
1541 \WP_CLI::line('Skipping already processed Intent ID: ' . $paymentIntent['id']);
1542 continue;
1543 }
1544
1545 $order = (new \FluentCart\App\Modules\PaymentMethods\StripeGateway\Webhook\Webhook())->processSubscriptionRenewal($invoice);
1546
1547 if (is_wp_error($order)) {
1548 \WP_CLI::line('Error processing invoice ID ' . $invoice['id'] . ': ' . $order->get_error_message());
1549 continue;
1550 }
1551
1552 if(!$order) {
1553 \WP_CLI::line('No order created for invoice ID: ' . $invoice['id']);
1554 continue;
1555 }
1556
1557 \WP_CLI::line('Created Order ID: ' . $order->id . ' for Invoice ID: ' . $invoice['id']);
1558 }
1559 }
1560
1561 $has_more = $data['has_more'] ?? false;
1562 if ($has_more && !empty($data['data'])) {
1563 $last_invoice = end($data['data']);
1564 $starting_after = $last_invoice['id'];
1565 }
1566 }
1567
1568 \WP_CLI::line('Completed syncing Stripe renewals.');
1569 }
1570 }
1571