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

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