PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.0
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.5.0, at app/Hooks/CLI/Commands.php

1,600 lines 56.1 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 delete_option('fluent_cart_tax_configuration_settings');
524 delete_option('fluent_cart_has_tax_configure');
525
526 delete_option('__fluent_cart_edd2_migration_steps');
527 delete_option('_fluent_edd_failed_payment_logs');
528
529 global $wpdb;
530 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
531 $wpdb->query("SET GLOBAL FOREIGN_KEY_CHECKS=0;");
532
533 try {
534 DBMigrator::refresh();
535 } catch (\Exception $e) {
536
537 }
538
539 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
540 $wpdb->query("SET GLOBAL FOREIGN_KEY_CHECKS=1;");
541
542 // Delete the post metas
543 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
544 $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'");
545 // Delete the posts
546 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
547 $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_type = 'fluent-products'");
548
549 // Delete the post metas
550 $postmetas = ['_edd_migrated_from', '_fcart_migrated_id', '__edd_migrated_variation_maps'];
551 foreach ($postmetas as $postMeta) {
552 // delete from wp_postmeta table where meta_key = $postMeta
553 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
554 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}postmeta WHERE meta_key = %s", $postMeta));
555 }
556
557 if (isset($assoc_args['seed'])) {
558 $this->seed_all($args, $assoc_args, 1000, false);
559 }
560
561 if (class_exists('WP_CLI')) {
562 \WP_CLI::line('All Data has been reseted!');
563 } else {
564 echo "All Done!";
565 }
566
567 }
568
569 public function seed($args, $assoc_args, $default = 1000, $checkDev = true)
570 {
571 if ($checkDev) {
572 $this->authorize();
573 }
574
575 $entities = ['product', 'customer', 'order', 'coupon', 'tax'];
576 $count = isset($assoc_args['count']) ? absint($assoc_args['count']) : $default;
577
578
579 if (class_exists('WP_CLI')) {
580 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
581 echo \WP_CLI::colorize('%yInserting ' . esc_html($count) . ' records. Please wait...%n');
582 }
583
584 foreach ($entities as $entity) {
585 if (isset($assoc_args[$entity])) {
586 DBSeeder::run($count, $entity, true, $assoc_args);
587 }
588 }
589
590 if (class_exists('WP_CLI')) {
591 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
592 echo \WP_CLI::colorize( '%GSuccess: ' . esc_html( $count ) . ' records inserted into the database.%n' );
593
594 }
595 }
596
597 public function authorize($checkDev = true)
598 {
599 if ($checkDev && App::config()->get('using_faker') === false) {
600 if (class_exists('WP_CLI')) {
601 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
602 echo \WP_CLI::colorize('%yYou Are Not In Dev Mode');
603 } else {
604 echo('You Are Not In Dev Mode');
605 }
606
607 die();
608 }
609 }
610
611 public function seed_all($args, $assoc_args, $default = 1000, $checkDev = true)
612 {
613
614 if ($checkDev) {
615 $this->authorize();
616 }
617
618 $count = isset($assoc_args['count']) ? absint($assoc_args['count']) : $default;
619 if (class_exists('WP_CLI')) {
620 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
621 echo \WP_CLI::colorize('%yInserting ' . esc_html($count) . ' records. Please wait...%n');
622 }
623 DBSeeder::run($count);
624 if (class_exists('WP_CLI')) {
625 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
626 echo \WP_CLI::colorize('%GSuccess: ' . esc_html($count) . ' records inserted into the database.%n');
627 }
628 }
629
630
631 public function generate_billing_address_from_ip()
632 {
633 if (!function_exists('fluent_geo_location')) {
634 \WP_CLI::line('Skipping Billing Address Generation. fluent_geo_location function not found.');
635 return;
636 }
637
638 $counts = Order::whereDoesntHave('billing_address')->count();
639 if (!$counts) {
640 \WP_CLI::line('All Done!');
641 return;
642 }
643
644 $progress = \WP_CLI\Utils\make_progress_bar('Generating Billing Address: (' . number_format($counts) . ')', $counts);
645 $completed = false;
646 $perPage = 100;
647 $page = 1;
648
649 while (!$completed) {
650 $orders = Order::whereDoesntHave('billing_address')
651 ->orderBy('id', 'ASC')
652 ->limit($perPage)
653 ->offset(($page - 1) * $perPage)
654 ->get();
655
656 if ($orders->isEmpty()) {
657 $completed = true;
658 break;
659 }
660
661 $allAddresses = [];
662 foreach ($orders as $order) {
663 $progress->tick();
664 $ip = $order->ip_address;
665 if (!$ip) {
666 continue;
667 }
668
669 $geoInfo = fluent_geo_location($ip);
670
671 if (is_wp_error($geoInfo)) {
672 continue;
673 }
674
675 if (empty($geoInfo['country'])) {
676 continue;
677 }
678
679 $allAddresses[] = array_filter([
680 'type' => 'billing',
681 'order_id' => $order->id,
682 'city' => $geoInfo['city'],
683 'state' => $geoInfo['state'],
684 'country' => $geoInfo['country'],
685 'postcode' => $geoInfo['postal_code'],
686 'created_at' => $order->created_at,
687 'updated_at' => $order->updated_at,
688 ]);
689
690 $customer = $order->customer;
691 if (!$customer->country && $geoInfo['country']) {
692 $customer->country = $geoInfo['country'] ?? '';
693 $customer->city = $geoInfo['city'] ?? '';
694 $customer->state = $geoInfo['state'] ?? '';
695 $customer->postcode = $geoInfo['postal_code'] ?? '';
696 $customer->save();
697 }
698 }
699
700 if ($allAddresses) {
701 foreach ($allAddresses as $address) {
702 fluentCart('db')->table('fct_order_addresses')->insert($address);
703 }
704 }
705
706 $page++;
707 }
708
709 $progress->finish();
710 }
711
712
713 /**
714 * Migrate WooCommerce customers to FluentCart
715 *
716 * ## OPTIONS
717 *
718 * [--force]
719 * : Force migration even if customers already exist
720 *
721 * [--debug]
722 * : Show debug information about customers found
723 *
724 * ## EXAMPLES
725 *
726 * wp fluent_cart migrate_customers
727 * wp fluent_cart migrate_customers --force
728 * wp fluent_cart migrate_customers --debug
729 */
730 public function migrate_customers($args, $assoc_args)
731 {
732 \WP_CLI::line('Starting WooCommerce to FluentCart customer migration...');
733
734 // Debug mode to check what customers are found
735 if (isset($assoc_args['debug'])) {
736 global $wpdb;
737
738 \WP_CLI::line('=== DEBUG MODE ===');
739
740 // Check WooCommerce
741 if (!class_exists('WooCommerce')) {
742 \WP_CLI::error('WooCommerce is not active');
743 return;
744 }
745 \WP_CLI::line('✓ WooCommerce is active');
746
747 // Check customers query
748 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
749 $customers = $wpdb->get_results("
750 SELECT DISTINCT u.ID, u.user_email, u.user_registered
751 FROM {$wpdb->users} u
752 WHERE u.ID IN (
753 SELECT DISTINCT pm.meta_value
754 FROM {$wpdb->posts} p
755 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
756 WHERE p.post_type = 'shop_order'
757 AND pm.meta_key = '_customer_user'
758 AND pm.meta_value > 0
759 )
760 OR EXISTS (
761 SELECT 1 FROM {$wpdb->usermeta} um
762 WHERE um.user_id = u.ID
763 AND um.meta_key = 'paying_customer'
764 AND um.meta_value = '1'
765 )
766 ORDER BY u.user_registered ASC
767 LIMIT 10
768 ");
769
770 \WP_CLI::line(sprintf('Found %d customers:', count($customers)));
771 foreach ($customers as $customer) {
772 \WP_CLI::line(sprintf('- ID: %d, Email: %s, Registered: %s',
773 $customer->ID, $customer->user_email, $customer->user_registered));
774 }
775
776 // Check existing FluentCart customers
777 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
778 $fluentCustomers = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}fct_customers");
779 \WP_CLI::line(sprintf('Existing FluentCart customers: %d', $fluentCustomers));
780
781 return;
782 }
783
784 $service = new \FluentCart\App\Modules\WooCommerceMigrator\Services\CustomerMigrationService();
785
786 if (!$service->checkDependencies()) {
787 \WP_CLI::error('Migration dependencies not met. Check error logs for details.');
788 return;
789 }
790
791 $options = [
792 'force' => isset($assoc_args['force']) && $assoc_args['force']
793 ];
794
795 $stats = $service->migrate($options);
796
797 $this->displayMigrationStats('Customers', $stats);
798 }
799
800
801 /**
802 * Migrate WooCommerce orders to FluentCart
803 *
804 * ## OPTIONS
805 *
806 * [--batch-size=<size>]
807 * : Number of orders to process at once
808 * ---
809 * default: 50
810 * ---
811 *
812 * [--start-date=<date>]
813 * : Start date for order migration (YYYY-MM-DD)
814 *
815 * [--end-date=<date>]
816 * : End date for order migration (YYYY-MM-DD)
817 *
818 * [--debug]
819 * : Show debug information about orders found
820 *
821 * ## EXAMPLES
822 *
823 * wp fluent_cart migrate_orders
824 * wp fluent_cart migrate_orders --batch-size=25
825 * wp fluent_cart migrate_orders --start-date=2024-01-01 --end-date=2024-12-31
826 * wp fluent_cart migrate_orders --debug
827 */
828 public function migrate_orders($args, $assoc_args)
829 {
830 \WP_CLI::line('Starting WooCommerce to FluentCart order migration...');
831
832 $service = new \FluentCart\App\Modules\WooCommerceMigrator\Services\OrderMigrationService();
833
834 if (!$service->canMigrate()) {
835 $errors = $service->getErrors();
836 foreach ($errors as $error) {
837 \WP_CLI::error($error);
838 }
839 return;
840 }
841
842 // Debug mode to check what orders are found
843 if (isset($assoc_args['debug'])) {
844 global $wpdb;
845
846 \WP_CLI::line('=== DEBUG MODE ===');
847
848 // Check HPOS
849 $hposEnabled = get_option('woocommerce_custom_orders_table_enabled') === 'yes';
850 \WP_CLI::line($hposEnabled ? '✓ HPOS is enabled' : '✗ HPOS is not enabled');
851
852 // Check orders
853 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
854 $orderCount = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}wc_orders WHERE type = 'shop_order'");
855 \WP_CLI::line(sprintf('Total WooCommerce orders: %d', $orderCount));
856
857 //phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
858 $migratedCount = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}fct_orders WHERE invoice_no LIKE 'WC-%'");
859 \WP_CLI::line(sprintf('Already migrated orders: %d', $migratedCount));
860
861 // Sample orders
862 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
863 $sampleOrders = $wpdb->get_results("
864 SELECT id, status, currency, total_amount, customer_id, date_created_gmt
865 FROM {$wpdb->prefix}wc_orders
866 WHERE type = 'shop_order'
867 ORDER BY id DESC
868 LIMIT 5
869 ");
870
871 \WP_CLI::line('Sample orders:');
872 foreach ($sampleOrders as $order) {
873 \WP_CLI::line(sprintf('- ID: %d, Status: %s, Total: %s %s, Customer: %s, Date: %s',
874 $order->id, $order->status, $order->currency, $order->total_amount,
875 $order->customer_id ?: 'Guest', $order->date_created_gmt));
876 }
877
878 return;
879 }
880
881 $options = [];
882
883 // Set batch size
884 if (isset($assoc_args['batch-size'])) {
885 $service->setBatchSize((int)$assoc_args['batch-size']);
886 }
887
888 // Set date filters (if needed in future)
889 if (isset($assoc_args['start-date']) || isset($assoc_args['end-date'])) {
890 \WP_CLI::warning('Date filtering not yet implemented. Processing all orders.');
891 }
892
893 $stats = $service->migrate($options);
894
895 $this->displayMigrationStats('Orders', $stats);
896 }
897
898 /**
899 * Update customer purchase statistics after order migration
900 *
901 * This command recalculates purchase counts, values, and dates for customers
902 * who have migrated orders. Useful if orders were migrated but customer stats
903 * weren't properly updated.
904 *
905 * ## EXAMPLES
906 *
907 * wp fluent_cart update_customer_stats
908 *
909 * @when after_wp_load
910 */
911 public function update_customer_stats($args, $assoc_args)
912 {
913 \WP_CLI::line('Updating customer purchase statistics...');
914
915 try {
916 $orderService = new \FluentCart\App\Modules\WooCommerceMigrator\Services\OrderMigrationService();
917 $result = $orderService->updateAllCustomerStats();
918
919 \WP_CLI::success($result['message']);
920
921 if (!empty($orderService->getErrors())) {
922 \WP_CLI::warning('Some errors occurred:');
923 foreach ($orderService->getErrors() as $error) {
924 \WP_CLI::line('- ' . $error);
925 }
926 }
927
928 } catch (\Exception $e) {
929 \WP_CLI::error('Failed to update customer statistics: ' . $e->getMessage());
930 }
931 }
932
933 /**
934 * Run complete migration (products, customers, orders)
935 *
936 * ## OPTIONS
937 *
938 * [--skip-products]
939 * : Skip product migration
940 *
941 * [--skip-customers]
942 * : Skip customer migration
943 *
944 * [--skip-orders]
945 * : Skip order migration
946 *
947 * ## EXAMPLES
948 *
949 * wp fluent_cart migrate_all
950 * wp fluent_cart migrate_all --skip-products
951 * wp fluent_cart migrate_all --skip-customers
952 * wp fluent_cart migrate_all --skip-orders
953 */
954 public function migrate_all($args, $assoc_args)
955 {
956 \WP_CLI::line('Starting complete WooCommerce to FluentCart migration...');
957 \WP_CLI::line('Migration order: Products → Customers → Orders (dependencies respected)');
958
959 $totalStats = [
960 'products' => null,
961 'customers' => null,
962 'orders' => null
963 ];
964
965 // Migrate Products (foundation requirement)
966 if (!isset($assoc_args['skip-products'])) {
967 \WP_CLI::line('');
968 \WP_CLI::line('=== MIGRATING PRODUCTS ===');
969 \WP_CLI::line('Products must be migrated first (required for order items)');
970 try {
971 $this->migrate_wc_products($args, $assoc_args);
972 \WP_CLI::success('Product migration completed');
973 } catch (\Exception $e) {
974 \WP_CLI::error('Product migration failed: ' . $e->getMessage());
975 \WP_CLI::line('Cannot proceed with orders without products. Stopping migration.');
976 return;
977 }
978 }
979
980 // Migrate Customers (required for orders)
981 if (!isset($assoc_args['skip-customers'])) {
982 \WP_CLI::line('');
983 \WP_CLI::line('=== MIGRATING CUSTOMERS ===');
984 \WP_CLI::line('Customers must be migrated before orders (required for order ownership)');
985 $service = new \FluentCart\App\Modules\WooCommerceMigrator\Services\CustomerMigrationService();
986 $totalStats['customers'] = $service->migrate();
987 $this->displayMigrationStats('Customers', $totalStats['customers']);
988
989 if (($totalStats['customers']['success'] ?? 0) === 0) {
990 \WP_CLI::warning('No customers were migrated. Orders migration may have limited functionality.');
991 }
992 }
993
994 // Migrate Orders (depends on products and customers)
995 if (!isset($assoc_args['skip-orders'])) {
996 \WP_CLI::line('');
997 \WP_CLI::line('=== MIGRATING ORDERS ===');
998 \WP_CLI::line('Orders migration includes: line items, addresses, coupons, fees, and metadata');
999
1000 $orderService = new \FluentCart\App\Modules\WooCommerceMigrator\Services\OrderMigrationService();
1001
1002 if (!$orderService->canMigrate()) {
1003 $errors = $orderService->getErrors();
1004 \WP_CLI::warning('Order migration cannot proceed:');
1005 foreach ($errors as $error) {
1006 \WP_CLI::line('- ' . $error);
1007 }
1008 \WP_CLI::line('Skipping order migration. Run individual migrations first.');
1009 } else {
1010 $totalStats['orders'] = $orderService->migrate();
1011 $this->displayMigrationStats('Orders', $totalStats['orders']);
1012 }
1013 }
1014
1015 // Summary
1016 \WP_CLI::line('');
1017 \WP_CLI::line('=== MIGRATION SUMMARY ===');
1018 $overallSuccess = true;
1019
1020 foreach ($totalStats as $type => $stats) {
1021 if ($stats === null) {
1022 \WP_CLI::line(sprintf('%s: Skipped', ucfirst($type)));
1023 } else {
1024 $success = $stats['success'] ?? 0;
1025 $failed = $stats['failed'] ?? 0;
1026 $total = $success + $failed;
1027 \WP_CLI::line(sprintf('%s: %d/%d successful', ucfirst($type), $success, $total));
1028 if ($failed > 0) {
1029 $overallSuccess = false;
1030 }
1031 }
1032 }
1033
1034 if ($overallSuccess) {
1035 \WP_CLI::success('Migration completed successfully! All data has been migrated.');
1036 } else {
1037 \WP_CLI::warning('Migration completed with some failures. Check individual stats above for details.');
1038 }
1039 }
1040
1041
1042 private function displayMigrationStats($type, $stats)
1043 {
1044 if (!$stats) {
1045 \WP_CLI::line("No {$type} migration stats available.");
1046 return;
1047 }
1048
1049 \WP_CLI::line('');
1050 \WP_CLI::line("=== {$type} Migration Stats ===");
1051 \WP_CLI::line(sprintf('Success: %d', $stats['success'] ?? 0));
1052 \WP_CLI::line(sprintf('Failed: %d', $stats['failed'] ?? 0));
1053 \WP_CLI::line(sprintf('Skipped: %d', $stats['skipped'] ?? 0));
1054 \WP_CLI::line(sprintf('Total processed: %d', ($stats['success'] ?? 0) + ($stats['failed'] ?? 0) + ($stats['skipped'] ?? 0)));
1055
1056 if (!empty($stats['errors'])) {
1057 \WP_CLI::line('');
1058 \WP_CLI::line('Errors:');
1059 foreach ($stats['errors'] as $error) {
1060 \WP_CLI::line('- ' . $error);
1061 }
1062 }
1063
1064 if (!empty($stats['warnings'])) {
1065 \WP_CLI::line('');
1066 \WP_CLI::line('Warnings:');
1067 foreach ($stats['warnings'] as $warning) {
1068 \WP_CLI::line('- ' . $warning);
1069 }
1070 }
1071 }
1072
1073 /**
1074 * Clone existing orders with random dates
1075 *
1076 * ## OPTIONS
1077 *
1078 * [--count=<number>]
1079 * : Number of orders to clone
1080 * ---
1081 * default: 10
1082 * ---
1083 *
1084 * [--start-date=<date>]
1085 * : Start date for cloned orders (YYYY-MM-DD)
1086 * ---
1087 * default: 30 days ago
1088 * ---
1089 *
1090 * [--end-date=<date>]
1091 * : End date for cloned orders (YYYY-MM-DD)
1092 * ---
1093 * default: today
1094 * ---
1095 *
1096 * [--source-order-id=<id>]
1097 * : Specific order ID to clone
1098 *
1099 * ## EXAMPLES
1100 *
1101 * wp fluent_cart clone_orders --count=50
1102 * wp fluent_cart clone_orders --count=25 --start-date=2024-01-01 --end-date=2024-12-31
1103 * wp fluent_cart clone_orders --source-order-id=123 --count=5
1104 */
1105 public function clone_orders($args, $assoc_args)
1106 {
1107 $cloner = new OrderCloneCommand();
1108 $cloner->clone_orders($args, $assoc_args);
1109 }
1110
1111 /**
1112 * Generate retention snapshots for cohort analysis
1113 *
1114 * This command processes all subscriptions and generates monthly retention
1115 * snapshots that track true customer retention (not subscription retention).
1116 * Customers who "recycle" (cancel and re-subscribe) are correctly tracked
1117 * as retained, not churned.
1118 *
1119 * ## OPTIONS
1120 *
1121 * [--product_id=<id>]
1122 * : Only process a specific product (optional)
1123 *
1124 * ## EXAMPLES
1125 *
1126 * wp fluent_cart generate_retention_snapshots --product_id=123
1127 *
1128 * @when after_wp_load
1129 */
1130 public function generate_retention_snapshots($args, $assoc_args)
1131 {
1132 $command = new RetentionSnapshotCommand();
1133 $command->generate($args, $assoc_args);
1134 }
1135
1136
1137 /**
1138 * Send a test email for any notification type
1139 *
1140 * ## OPTIONS
1141 *
1142 * [<notification-name>]
1143 * : The notification name to send (e.g. order_paid_customer)
1144 *
1145 * [--order_id=<id>]
1146 * : Use a real order for email data
1147 *
1148 * [--to=<email>]
1149 * : Override recipient email address
1150 *
1151 * [--list]
1152 * : List all available notification names
1153 *
1154 * [--all]
1155 * : Send all notifications at once (requires --to)
1156 *
1157 * [--render-only]
1158 * : Skip sending, just render and save the HTML file to .debug-emails/
1159 *
1160 * ## EXAMPLES
1161 *
1162 * wp fluent_cart test_email --list
1163 * wp fluent_cart test_email order_paid_customer --order_id=42
1164 * wp fluent_cart test_email order_paid_customer --order_id=42 --to=dev@example.com
1165 * wp fluent_cart test_email order_paid_customer --to=dev@example.com
1166 * wp fluent_cart test_email order_paid_customer --render-only
1167 * wp fluent_cart test_email order_paid_customer --render-only --order_id=42
1168 * wp fluent_cart test_email --all --to=dev@example.com
1169 * wp fluent_cart test_email --all --to=dev@example.com --order_id=42
1170 */
1171 public function test_email($args, $assoc_args)
1172 {
1173 $notifications = EmailNotifications::getNotifications();
1174
1175 // --list flag: show all available notifications
1176 if (isset($assoc_args['list'])) {
1177 $rows = [];
1178 foreach ($notifications as $name => $notification) {
1179 $settings = Arr::get($notification, 'settings', []);
1180 $rows[] = [
1181 'Name' => $name,
1182 'Title' => Arr::get($notification, 'title', ''),
1183 'Recipient' => Arr::get($notification, 'recipient', ''),
1184 'Active' => Arr::get($settings, 'active', 'no'),
1185 ];
1186 }
1187 \WP_CLI\Utils\format_items('table', $rows, ['Name', 'Title', 'Recipient', 'Active']);
1188 return;
1189 }
1190
1191 // --all flag: send every notification
1192 if (isset($assoc_args['all'])) {
1193 $to = Arr::get($assoc_args, 'to');
1194 if (empty($to)) {
1195 \WP_CLI::error('--all requires --to=<email> to specify the recipient.');
1196 return;
1197 }
1198
1199 $data = $this->resolveEmailData($assoc_args);
1200 $mailer = new EmailNotificationMailer();
1201 $sent = 0;
1202 $failed = 0;
1203
1204 foreach ($notifications as $name => $notification) {
1205 try {
1206 $formatted = EmailNotifications::formatNotification($notification, $data);
1207 list($body, $subject, $_to) = $mailer->parseEmailContent($formatted, $data);
1208
1209 $result = Mailer::make()->to(sanitize_email($to))->subject($subject)->body($body)->send(true);
1210
1211 if ($result) {
1212 \WP_CLI::line(sprintf('[OK] %s — %s', $name, $subject));
1213 $sent++;
1214 } else {
1215 \WP_CLI::warning(sprintf('[FAIL] %s — wp_mail returned false', $name));
1216 $failed++;
1217 }
1218 } catch (\Throwable $e) {
1219 \WP_CLI::warning(sprintf('[ERROR] %s — %s', $name, $e->getMessage()));
1220 $failed++;
1221 }
1222 }
1223
1224 \WP_CLI::success(sprintf('Done. Sent: %d, Failed: %d, Total: %d', $sent, $failed, $sent + $failed));
1225 return;
1226 }
1227
1228 // Validate notification name
1229 if (empty($args[0])) {
1230 \WP_CLI::error('Please provide a notification name. Use --list to see available names, or --all to send all.');
1231 return;
1232 }
1233
1234 $emailName = $args[0];
1235 if (!isset($notifications[$emailName])) {
1236 \WP_CLI::error(sprintf(
1237 "Unknown notification: '%s'. Available: %s",
1238 $emailName,
1239 implode(', ', array_keys($notifications))
1240 ));
1241 return;
1242 }
1243
1244 // Build email data
1245 $data = $this->resolveEmailData($assoc_args);
1246
1247 // Format the notification and render email
1248 $mailer = new EmailNotificationMailer();
1249
1250 $notification = EmailNotifications::getNotification($emailName);
1251 $notification = EmailNotifications::formatNotification($notification, $data);
1252
1253 list($body, $subject, $to) = $mailer->parseEmailContent($notification, $data);
1254
1255 // Save debug HTML file
1256 $debugDir = FLUENTCART_PLUGIN_PATH . '.debug-emails';
1257 if (!is_dir($debugDir)) {
1258 mkdir($debugDir, 0755, true);
1259 }
1260 $debugFile = $debugDir . '/' . $emailName . '.html';
1261 file_put_contents($debugFile, $body);
1262
1263 \WP_CLI::line('Subject: ' . $subject);
1264 \WP_CLI::line('Debug HTML: ' . $debugFile);
1265
1266 // --render-only: skip sending, just output the rendered HTML file
1267 if (isset($assoc_args['render-only'])) {
1268 \WP_CLI::success('Rendered to ' . $debugFile);
1269 return;
1270 }
1271
1272 // Override recipient if --to provided
1273 $toOverride = Arr::get($assoc_args, 'to');
1274 if ($toOverride) {
1275 $to = sanitize_email($toOverride);
1276 }
1277
1278 if (empty($to)) {
1279 \WP_CLI::error('No recipient email resolved. Use --to=<email> to specify one.');
1280 return;
1281 }
1282
1283 // Send
1284 $result = Mailer::make()->to($to)->subject($subject)->body($body)->send(true);
1285
1286 \WP_CLI::line('To: ' . $to);
1287
1288 if ($result) {
1289 \WP_CLI::success('Email sent successfully.');
1290 } else {
1291 \WP_CLI::warning('wp_mail() returned false. Check your mail configuration.');
1292 }
1293 }
1294
1295 /**
1296 * Render raw block markup through FluentBlockParser and save as HTML.
1297 *
1298 * Useful for testing individual block renderers without a full notification.
1299 *
1300 * <file>
1301 * : Path to a file containing block markup
1302 *
1303 * [--order_id=<id>]
1304 * : Use a real order for shortcode data
1305 *
1306 * [--out=<path>]
1307 * : Output file path (default: .debug-emails/render_blocks.html)
1308 *
1309 * [--wrapper]
1310 * : Wrap in email template (default: yes). Use --no-wrapper to skip.
1311 *
1312 * ## EXAMPLES
1313 *
1314 * wp fluent_cart render_blocks blocks.html
1315 * wp fluent_cart render_blocks blocks.html --order_id=42
1316 * wp fluent_cart render_blocks blocks.html --out=test-output.html
1317 * wp fluent_cart render_blocks blocks.html --no-wrapper
1318 */
1319 public function render_blocks($args, $assoc_args)
1320 {
1321 if (empty($args[0])) {
1322 \WP_CLI::error('Please provide a file path containing block markup.');
1323 return;
1324 }
1325
1326 $file = $args[0];
1327 if (!file_exists($file)) {
1328 \WP_CLI::error("File not found: {$file}");
1329 return;
1330 }
1331
1332 $blockMarkup = file_get_contents($file);
1333 if (empty(trim($blockMarkup))) {
1334 \WP_CLI::error("File is empty: {$file}");
1335 return;
1336 }
1337
1338 // Resolve data for shortcode replacement
1339 $data = $this->resolveEmailData($assoc_args);
1340
1341 // Parse blocks through the pro block parser filter
1342 $rendered = apply_filters('fluent_cart/parse_email_block_content', '', $blockMarkup, $data);
1343
1344 if (empty($rendered)) {
1345 \WP_CLI::error('Block parsing requires FluentCart Pro. Please ensure the pro plugin is active.');
1346 return;
1347 }
1348
1349 // Optionally wrap in the email template (--no-wrapper to skip)
1350 $useWrapper = Arr::get($assoc_args, 'wrapper', true);
1351 if ($useWrapper) {
1352 $mailer = new EmailNotificationMailer();
1353 $rendered = apply_filters('fluent_cart/render_block_email_template', $rendered, [
1354 'emailBody' => $rendered,
1355 'preheader' => '',
1356 'emailFooter' => $mailer->getEmailFooter(),
1357 ]);
1358 }
1359
1360 // Replace shortcodes
1361 $rendered = \FluentCart\App\Services\ShortCodeParser\ShortcodeTemplateBuilder::make($rendered, $data);
1362
1363 // Determine output path
1364 $outPath = Arr::get($assoc_args, 'out');
1365 if (!$outPath) {
1366 $debugDir = FLUENTCART_PLUGIN_PATH . '.debug-emails';
1367 if (!is_dir($debugDir)) {
1368 mkdir($debugDir, 0755, true);
1369 }
1370 $outPath = $debugDir . '/render_blocks.html';
1371 }
1372
1373 file_put_contents($outPath, $rendered);
1374 \WP_CLI::success('Rendered to ' . $outPath);
1375 }
1376
1377 /**
1378 * Resolve order/mock data for test emails.
1379 *
1380 * @param array $assoc_args
1381 * @return array
1382 */
1383 private function resolveEmailData($assoc_args)
1384 {
1385 $order = null;
1386 $orderId = Arr::get($assoc_args, 'order_id');
1387
1388 if ($orderId) {
1389 $order = Order::query()
1390 ->with(['customer', 'shipping_address', 'billing_address', 'transactions', 'order_items'])
1391 ->find(absint($orderId));
1392
1393 if (!$order) {
1394 \WP_CLI::warning("Order #{$orderId} not found. Using mock data.");
1395 }
1396 }
1397
1398 if (!$order) {
1399 $order = Order::query()
1400 ->with(['customer', 'shipping_address', 'billing_address', 'transactions', 'order_items'])
1401 ->latest()
1402 ->first();
1403 }
1404
1405 if ($order) {
1406 $transaction = [];
1407 if (!empty($order->transactions)) {
1408 $transaction = $order->transactions->first();
1409 }
1410
1411 // Load subscription if one exists for this order
1412 $subscription = Subscription::where('parent_order_id', $order->id)->first();
1413
1414 \WP_CLI::line(sprintf('Using order #%d (%s)', $order->id, $order->invoice_no));
1415 $data = [
1416 'order' => $order,
1417 'customer' => $order->customer !== null ? $order->customer : [],
1418 'transaction' => $transaction ? $transaction : [],
1419 ];
1420
1421 if ($subscription) {
1422 $data['subscription'] = $subscription;
1423 \WP_CLI::line(sprintf('Using subscription #%d (%s)', $subscription->id, $subscription->status));
1424 }
1425
1426 return $data;
1427 }
1428
1429 \WP_CLI::line('No orders found. Using mock data.');
1430 return $this->getMockEmailData();
1431 }
1432
1433 private function getMockEmailData()
1434 {
1435 $faker = \Faker\Factory::create();
1436 $now = current_time('mysql');
1437
1438 $customer = new Customer();
1439 $customer->id = 999;
1440 $customer->first_name = $faker->firstName;
1441 $customer->last_name = $faker->lastName;
1442 $customer->email = $faker->safeEmail;
1443 $customer->phone = $faker->phoneNumber;
1444 $customer->city = $faker->city;
1445 $customer->state = $faker->state();
1446 $customer->postcode = $faker->postcode;
1447 $customer->country = $faker->countryCode;
1448
1449 $item1 = new \FluentCart\App\Models\OrderItem();
1450 $item1->id = 1;
1451 $item1->post_title = 'Sample Product One';
1452 $item1->item_price = 4900;
1453 $item1->quantity = 1;
1454 $item1->line_total = 4900;
1455
1456 $item2 = new \FluentCart\App\Models\OrderItem();
1457 $item2->id = 2;
1458 $item2->post_title = 'Sample Product Two';
1459 $item2->item_price = 2500;
1460 $item2->quantity = 2;
1461 $item2->line_total = 5000;
1462
1463 $orderItems = new \FluentCart\Framework\Support\Collection([$item1, $item2]);
1464
1465 $order = new Order();
1466 $order->id = 9999;
1467 $order->invoice_no = 'FC-MOCK-9999';
1468 $order->total = 9900;
1469 $order->subtotal = 9900;
1470 $order->discount_total = 0;
1471 $order->tax_total = 0;
1472 $order->total_paid = 9900;
1473 $order->total_refund = 0;
1474 $order->payment_status = 'paid';
1475 $order->currency = 'USD';
1476 $order->customer_id = 999;
1477 $order->created_at = $now;
1478 $order->updated_at = $now;
1479 $order->setRelation('customer', $customer);
1480 $order->setRelation('order_items', $orderItems);
1481 $order->setRelation('orderTaxRates', new \FluentCart\Framework\Support\Collection());
1482 $order->setRelation('transactions', new \FluentCart\Framework\Support\Collection());
1483 $order->setRelation('shipping_address', null);
1484 $order->setRelation('billing_address', null);
1485
1486 $transaction = new \FluentCart\App\Models\OrderTransaction();
1487 $transaction->id = 1;
1488 $transaction->total = 9900;
1489 $transaction->payment_method = 'stripe';
1490 $transaction->status = 'paid';
1491 $transaction->created_at = $now;
1492
1493 return [
1494 'order' => $order,
1495 'customer' => $customer,
1496 'transaction' => $transaction,
1497 ];
1498 }
1499
1500 public function reset_tax($args, $assoc_args)
1501 {
1502 $this->authorize();
1503
1504 delete_option('fluent_cart_tax_configuration_settings');
1505 delete_option('fluent_cart_has_tax_configure');
1506
1507 $db = fluentCart('db');
1508
1509 $db->table('fct_meta')
1510 ->whereIn('object_type', ['tax', 'eu_vat_registration', 'tax_override'])
1511 ->delete();
1512
1513 $db->table('fct_tax_rates')->delete();
1514 $db->table('fct_tax_classes')->delete();
1515
1516 $now = gmdate('Y-m-d H:i:s');
1517 $db->table('fct_tax_classes')->insert([
1518 'title' => 'Standard',
1519 'slug' => 'standard',
1520 'created_at' => $now,
1521 'updated_at' => $now,
1522 ]);
1523
1524 \WP_CLI::success('Tax configuration has been fully reset. Visit the Tax settings page to reconfigure.');
1525 }
1526
1527 public function sync_stripe_renwals($args, $assoc_args)
1528 {
1529 $days = isset($assoc_args['days']) ? absint($assoc_args['days']) : 30;
1530
1531 if ($days > 365) {
1532 \WP_CLI::line('Days cannot be more than 365');
1533 return;
1534 }
1535
1536 // Base query parameters (applied to every page)
1537 $body_params = array(
1538 'expand' => ['data.payment_intent'],
1539 'status' => 'paid',
1540 'limit' => 10, // Max per page
1541 'created[gt]' => strtotime(-$days . ' days'), // Invoices created after ~30 days ago
1542 );
1543
1544 $starting_after = null;
1545 $has_more = true;
1546
1547 while ($has_more) {
1548 if ($starting_after) {
1549 $body_params['starting_after'] = $starting_after;
1550 }
1551 $data = (new \FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API())->getStripeObject('invoices', $body_params);
1552
1553 if (is_wp_error($data)) {
1554 \WP_CLI::line('Error fetching invoices: ' . $data->get_error_message());
1555 return;
1556 }
1557
1558 foreach ($data['data'] as $invoice) {
1559 if (isset($invoice['billing_reason']) && $invoice['billing_reason'] === 'subscription_cycle') {
1560 $paymentIntent = isset($invoice['payment_intent']) ? $invoice['payment_intent'] : null;
1561 if(!$paymentIntent) {
1562 continue;
1563 }
1564
1565 $transactionExists = \FluentCart\App\Models\OrderTransaction::where('vendor_charge_id', $paymentIntent['id'])
1566 ->where('payment_method', 'stripe')
1567 ->exists();
1568
1569 if ($transactionExists) {
1570 \WP_CLI::line('Skipping already processed Intent ID: ' . $paymentIntent['id']);
1571 continue;
1572 }
1573
1574 $order = (new \FluentCart\App\Modules\PaymentMethods\StripeGateway\Webhook\Webhook())->processSubscriptionRenewal($invoice);
1575
1576 if (is_wp_error($order)) {
1577 \WP_CLI::line('Error processing invoice ID ' . $invoice['id'] . ': ' . $order->get_error_message());
1578 continue;
1579 }
1580
1581 if(!$order) {
1582 \WP_CLI::line('No order created for invoice ID: ' . $invoice['id']);
1583 continue;
1584 }
1585
1586 \WP_CLI::line('Created Order ID: ' . $order->id . ' for Invoice ID: ' . $invoice['id']);
1587 }
1588 }
1589
1590 $has_more = $data['has_more'] ?? false;
1591 if ($has_more && !empty($data['data'])) {
1592 $last_invoice = end($data['data']);
1593 $starting_after = $last_invoice['id'];
1594 }
1595 }
1596
1597 \WP_CLI::line('Completed syncing Stripe renewals.');
1598 }
1599 }
1600