PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | database/DBMigrator.php +261 -8 1.3.19 → 1.6.5 View file →
@@ -6,11 +6,8 @@
6 6 use FluentCart\App\Models\Product;
7 7
8 8 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
9 9
10 -use FluentCart\Database\Migrations\AttributeGroupsMigrator;
11 -use FluentCart\Database\Migrations\AttributeObjectRelationsMigrator;
12 -use FluentCart\Database\Migrations\AttributeTermsMigrator;
13 10 use FluentCart\Database\Migrations\CartMigrator;
14 11 use FluentCart\Database\Migrations\CustomersMigrator;
15 12 use FluentCart\Database\Migrations\MetaMigrator;
16 13 use FluentCart\Database\Migrations\Migrator;
@@ -47,16 +44,17 @@
47 44 use FluentCartPro\App\Modules\Licensing\Models\LicenseSite;
48 45 use FluentCart\Database\Migrations\ShippingZonesMigrator;
49 46 use FluentCart\Database\Migrations\ShippingMethodsMigrator;
50 47 use FluentCart\Database\Migrations\RetentionSnapshotsMigrator;
48 +use FluentCart\Database\Migrations\AttributeGroupsMigrator;
49 +use FluentCart\Database\Migrations\AttributeObjectRelationsMigrator;
50 +use FluentCart\Database\Migrations\AttributeTermsMigrator;
51 +use FluentCart\Database\Seeder\AttributeSeeder;
51 52
52 53 class DBMigrator
53 54 {
54 55 private static array $migrators = [
55 56 MetaMigrator::class,
56 - AttributeGroupsMigrator::class,
57 - AttributeObjectRelationsMigrator::class,
58 - AttributeTermsMigrator::class,
59 57 CartMigrator::class,
60 58 CouponsMigrator::class,
61 59 CustomerAddressesMigrator::class,
62 60 CustomerMetaMigrator::class,
@@ -62,13 +60,13 @@
62 60 CustomerMetaMigrator::class,
63 61 CustomersMigrator::class,
64 62 OrderAddressesMigrator::class,
65 63 OrderDownloadPermissionsMigrator::class,
66 - OrderMetaMigrator::class,
67 64 OrderOperationsMigrator::class,
68 65 OrdersItemsMigrator::class,
69 66 OrdersMigrator::class,
70 67 OrderTaxRateMigrator::class,
68 + OrderMetaMigrator::class,
71 69 OrderTransactionsMigrator::class,
72 70 ProductDetailsMigrator::class,
73 71 ProductDownloadsMigrator::class,
74 72 ProductMetaMigrator::class,
@@ -85,9 +83,15 @@
85 83 ShippingZonesMigrator::class,
86 84 ShippingMethodsMigrator::class,
87 85 ShippingClassesMigrator::class,
88 86 ScheduledActionsMigrator::class,
89 - RetentionSnapshotsMigrator::class
87 + RetentionSnapshotsMigrator::class,
88 + // Order matters: AttributeTermsMigrator::migrated() INNER JOINs
89 + // fct_atts_relations to repoint orphaned term references, so the
90 + // relations table must exist before terms runs its post-migration hook.
91 + AttributeGroupsMigrator::class,
92 + AttributeObjectRelationsMigrator::class,
93 + AttributeTermsMigrator::class
90 94 ];
91 95
92 96 public static function migrateUp($network_wide = false)
93 97 {
@@ -125,12 +129,15 @@
125 129 */
126 130 foreach (self::$migrators as $migrator) {
127 131 $migrator::migrate();
128 132 }
133 +
134 + do_action('fluent_cart/after_migrate');
129 135 }
130 136
131 137 public static function maybeMigrateDBChanges()
132 138 {
139 + self::maybeMigrateCustomerRecoveryIndexes();
133 140
134 141 /*
135 142 * TODO We will remove this after final release
136 143 */
@@ -137,10 +144,46 @@
137 144 $currentDBVersion = get_option('_fluent_cart_db_version');
138 145
139 146 if (!$currentDBVersion || version_compare($currentDBVersion, FLUENTCART_DB_VERSION, '<')) {
140 147
148 + // Right after a plugin update, multiple requests (admin page +
149 + // heartbeat/ajax) hit init before the version option is written and
150 + // would all run the schema changes in parallel, producing
151 + // duplicate-index / duplicate-entry errors. Only the request that
152 + // wins the advisory lock proceeds; the others skip — the winner
153 + // updates the version option for everyone.
154 + if (!self::acquireMigrationLock()) {
155 + return;
156 + }
157 +
141 158 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
142 159
160 + if (!$currentDBVersion) {
161 + // Brand-new store: default tax display to the simplified single line.
162 + // Existing stores fall through to the read-time default 'itemized'.
163 + $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
164 + if (!isset($taxSettings['checkout_tax_breakdown_display'])) {
165 + $taxSettings['checkout_tax_breakdown_display'] = 'simplified';
166 + update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
167 + }
168 + }
169 +
170 + // 2026-04-25
171 + TaxRatesMigrator::upgradeShippingOverridePrecision();
172 +
173 + // 2026-07-01
174 + OrderTaxRateMigrator::allowVirtualTaxRateIds();
175 +
176 + // 2026-05-27
177 + TaxRatesMigrator::fixPostcodeRangeSeparator();
178 + OrdersMigrator::addFeeTotalColumn();
179 +
180 + // 2026-07-24
181 + OrdersMigrator::addUuidIndex();
182 +
183 + // 2026-08-20
184 + ProductMetaMigrator::addObjectMetaIndex();
185 +
143 186 // let's check the orders table sequence number
144 187 global $wpdb;
145 188
146 189 // Product Meta unique index removal
@@ -183,8 +226,17 @@
183 226 }
184 227
185 228 $ordersTable = $wpdb->prefix . 'fct_orders';
186 229
230 + if (!Schema::hasColumn('fee_total', 'fct_orders')) {
231 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
232 + $wpdb->query($wpdb->prepare(
233 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
234 + "ALTER TABLE %i ADD COLUMN `fee_total` BIGINT NOT NULL DEFAULT '0' AFTER `shipping_total`",
235 + $ordersTable
236 + ));
237 + }
238 +
187 239 // check if scheduled_at is exist or not
188 240 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
189 241 $isReceiptNumberMigrated = $wpdb->get_col($wpdb->prepare("SELECT * FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='receipt_number' AND TABLE_NAME=%s", $ordersTable));
190 242 if (!$isReceiptNumberMigrated) {
@@ -431,10 +483,211 @@
431 483 // Shipping schema upgrades — also run here so plugin updates
432 484 // without deactivation/reactivation still apply new columns
433 485 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
434 486 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
487 + \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
435 488
489 + // 2026-08-06
490 + // fct_order_transactions subscription_id index — also run here so
491 + // in-place plugin updates deliver it; migrate() only covers the
492 + // activation path.
493 + \FluentCart\Database\Migrations\OrderTransactionsMigrator::migrated();
494 +
495 +
496 + // 2026-05-06
497 + // Backfill, dedup, then add unique index — must run in this order
498 + // so the constraint can be applied without "Duplicate entry" errors.
499 + TaxClassesMigrator::backfillNullSlugs();
500 + TaxClassesMigrator::deduplicateSlugs();
501 + TaxClassesMigrator::addSlugUniqueIndex();
502 +
503 + // Ensure Standard tax class exists for all installs
504 + TaxClassesMigrator::seedDefaultTaxClass();
505 +
506 + // 2026-05-15
507 + // Move EU VAT country registrations from wp_options blob to fct_meta rows.
508 + \FluentCart\Database\Migrations\EuVatRegistrationMigrator::migrate();
509 +
510 + // 2026-06-10
511 + // One-time migration: move legacy price_suffix into the correct split field
512 + // based on the store's tax_inclusion setting at the time of migration.
513 + if (!get_option('_fluent_cart_price_suffix_migrated')) {
514 + $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
515 + $legacySuffix = isset($taxSettings['price_suffix']) ? $taxSettings['price_suffix'] : '';
516 + $hasIncluded = isset($taxSettings['price_suffix_included']) && $taxSettings['price_suffix_included'] !== '';
517 + $hasExcluded = isset($taxSettings['price_suffix_excluded']) && $taxSettings['price_suffix_excluded'] !== '';
518 +
519 + if ($legacySuffix !== '' && !$hasIncluded && !$hasExcluded) {
520 + $taxInclusion = isset($taxSettings['tax_inclusion']) ? $taxSettings['tax_inclusion'] : 'excluded';
521 + if ($taxInclusion === 'excluded') {
522 + $taxSettings['price_suffix_excluded'] = $legacySuffix;
523 + } else {
524 + $taxSettings['price_suffix_included'] = $legacySuffix;
525 + }
526 + update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
527 + }
528 + update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
529 + }
530 +
531 + // 2026-07-08
532 + // One-time migration: rename the legacy tax-display value 'both'
533 + // (and the removed 'label'/'tooltip') to 'itemized'. Value-guarded —
534 + // once migrated the stored value is 'itemized' so this no-ops; no
535 + // separate flag option is written (nothing left behind when this
536 + // block is removed later). Safe to remove after ~2027-07.
537 + $fctTaxSettings = get_option('fluent_cart_tax_configuration_settings', []);
538 + if (isset($fctTaxSettings['checkout_tax_breakdown_display'])
539 + && in_array($fctTaxSettings['checkout_tax_breakdown_display'], ['both', 'label', 'tooltip'], true)) {
540 + $fctTaxSettings['checkout_tax_breakdown_display'] = 'itemized';
541 + update_option('fluent_cart_tax_configuration_settings', $fctTaxSettings, true);
542 + }
543 +
544 + // 2026-05-18
545 + // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
546 + // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
547 + if (!get_option('_fluent_cart_seller_details_migrated')) {
548 + $sellerDetails = fluent_cart_get_option('seller_details', []);
549 + if (is_array($sellerDetails) && !empty($sellerDetails)) {
550 + $storeSettingsOption = get_option('fluent_cart_store_settings', []);
551 + if (!is_array($storeSettingsOption)) {
552 + $storeSettingsOption = [];
553 + }
554 + $fieldMap = [
555 + 'seller_vat_id' => 'seller_vat_id',
556 + 'seller_tax_id' => 'seller_tax_id',
557 + 'seller_legal_name' => 'company_name',
558 + 'seller_legal_registration_id' => 'legal_registration_id',
559 + ];
560 + $changed = false;
561 + foreach ($fieldMap as $fromKey => $toKey) {
562 + if (!empty($sellerDetails[$fromKey]) && empty($storeSettingsOption[$toKey])) {
563 + $storeSettingsOption[$toKey] = sanitize_text_field($sellerDetails[$fromKey]);
564 + $changed = true;
565 + }
566 + }
567 + if ($changed) {
568 + update_option('fluent_cart_store_settings', $storeSettingsOption, true);
569 + }
570 + }
571 + update_option('_fluent_cart_seller_details_migrated', '1', 'no');
572 + }
573 +
574 + // 2026-06-03
575 + // Fix: convert zero-date datetime columns to NULL (Issue #1911).
576 + // Zero-dates arise on MySQL servers without STRICT_TRANS_TABLES when an
577 + // empty string is written to a DATETIME column.
578 + $table_name = $wpdb->prefix . 'fct_subscriptions';
579 + foreach (['next_billing_date', 'canceled_at', 'expire_at'] as $col) {
580 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
581 + $wpdb->query($wpdb->prepare(
582 + "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
583 + $table_name
584 + ));
585 + }
586 +
587 + // 2026-06-17
588 + // Advanced Variation - Create the attribute
589 + // schema (and seed the system-template groups) on in-place plugin
590 + // updates too — migrate() only runs on activation, but existing
591 + // installs reach this version-gated path without reactivating. Each
592 + // migrator is idempotent (dbDelta) and the seeder early-returns when
593 + // any group already exists, so re-runs never duplicate data. Order
594 + // matters: relations table must exist before AttributeTermsMigrator's
595 + // post-migration JOIN cleanup.
596 + AttributeGroupsMigrator::migrate();
597 + AttributeObjectRelationsMigrator::migrate();
598 + AttributeTermsMigrator::migrate();
599 + AttributeSeeder::seed();
600 +
601 + self::releaseMigrationLock();
436 602 }
603 + }
604 +
605 + /** Upgrade active stores without running index DDL on customer requests. */
606 + public static function maybeMigrateCustomerRecoveryIndexes(): void
607 + {
608 + $option = '_fluent_cart_customer_recovery_indexes_version';
609 + if (get_option($option) === '1') {
610 + return;
611 + }
612 + if (!(defined('WP_CLI') && WP_CLI) && (!is_admin() || !current_user_can('manage_options'))) {
613 + return;
614 + }
615 + if (!self::acquireMigrationLock()) {
616 + return;
617 + }
618 + try {
619 + if (get_option($option) === '1') {
620 + return;
621 + }
622 + foreach ([SubscriptionsMigrator::class, OrderDownloadPermissionsMigrator::class, CartMigrator::class] as $migrator) {
623 + if (!Schema::hasTable($migrator::$tableName)) {
624 + return;
625 + }
626 + $migrator::addCustomerRecoveryIndex();
627 + if (!$migrator::hasCustomerRecoveryIndex()) {
628 + return; // Keep the upgrade pending if ALTER failed.
629 + }
630 + }
631 + update_option($option, '1', false);
632 + } finally {
633 + self::releaseMigrationLock();
634 + }
635 + }
636 +
637 + /**
638 + * Try to acquire the cross-request migration lock.
639 + *
640 + * Uses a MySQL advisory lock (GET_LOCK with zero wait) so only one request
641 + * runs the schema upgrade at a time. If the connection dies mid-migration,
642 + * MySQL releases the lock automatically when the connection closes.
643 + *
644 + * @return bool True when this request may run the migration.
645 + */
646 + private static function acquireMigrationLock()
647 + {
648 + global $wpdb;
649 +
650 + if (Schema::isSqlite()) {
651 + // GET_LOCK is MySQL-only; SQLite has no concurrent writers.
652 + return true;
653 + }
654 +
655 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
656 + $acquired = $wpdb->get_var($wpdb->prepare(
657 + "SELECT GET_LOCK(%s, 0)",
658 + self::getMigrationLockName()
659 + ));
660 +
661 + // NULL means the server could not create the lock — fail open so a
662 + // locking hiccup can never block schema upgrades entirely.
663 + return $acquired === null || (string)$acquired === '1';
664 + }
665 +
666 + private static function releaseMigrationLock()
667 + {
668 + global $wpdb;
669 +
670 + if (Schema::isSqlite()) {
671 + return;
672 + }
673 +
674 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
675 + $wpdb->query($wpdb->prepare(
676 + "SELECT RELEASE_LOCK(%s)",
677 + self::getMigrationLockName()
678 + ));
679 + }
680 +
681 + private static function getMigrationLockName()
682 + {
683 + global $wpdb;
684 +
685 + // GET_LOCK names are server-wide; scope to this site's DB and prefix
686 + // so two WordPress installs on one MySQL server can't block each other.
687 + $dbName = defined('DB_NAME') ? DB_NAME : '';
688 +
689 + return 'fct_db_migration_' . md5($dbName . '|' . $wpdb->prefix);
437 690 }
438 691
439 692 public static function migrateDown($network_wide = false)
440 693 {