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 / database / DBMigrator.php

DBMigrator.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.0, at database/DBMigrator.php

646 lines 33.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\Database;
4
5 use FluentCart\App\CPT\FluentProducts;
6 use FluentCart\App\Models\Product;
7
8 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
9
10 use FluentCart\Database\Migrations\CartMigrator;
11 use FluentCart\Database\Migrations\CustomersMigrator;
12 use FluentCart\Database\Migrations\MetaMigrator;
13 use FluentCart\Database\Migrations\Migrator;
14 use FluentCart\Database\Migrations\OrderMetaMigrator;
15 use FluentCart\Database\Migrations\OrdersMigrator;
16 use FluentCart\Database\Migrations\OrdersItemsMigrator;
17 use FluentCart\Database\Migrations\OrderTransactionsMigrator;
18 use FluentCart\Database\Migrations\ProductDetailsMigrator;
19 use FluentCart\Database\Migrations\ProductDownloadsMigrator;
20 use FluentCart\Database\Migrations\ProductMetaMigrator;
21 use FluentCart\Database\Migrations\ProductVariationMigrator;
22 use FluentCart\Database\Migrations\ScheduledActionsMigrator;
23 use FluentCart\Database\Migrations\ShippingClassesMigrator;
24 use FluentCart\Database\Migrations\SubscriptionMetaMigrator;
25 use FluentCart\Database\Migrations\SubscriptionsMigrator;
26 use FluentCart\Database\Migrations\TaxClassesMigrator;
27 use FluentCart\Database\Migrations\TaxRatesMigrator;
28 use FluentCart\Database\Migrations\OrderTaxRateMigrator;
29 use FluentCart\Database\Migrations\CouponsMigrator;
30 use FluentCart\Database\Migrations\CustomerAddressesMigrator;
31 use FluentCart\Database\Migrations\CustomerMetaMigrator;
32 use FluentCart\Database\Migrations\OrderAddressesMigrator;
33 use FluentCart\Database\Migrations\OrderDownloadPermissionsMigrator;
34 use FluentCart\Database\Migrations\OrderOperationsMigrator;
35 use FluentCart\Database\Migrations\AppliedCouponsMigrator;
36 use FluentCart\Database\Migrations\LabelMigrator;
37 use FluentCart\Database\Migrations\LabelRelationshipsMigrator;
38 use FluentCart\Database\Migrations\ActivityMigrator;
39 use FluentCart\Database\Migrations\WebhookLogger;
40 use FluentCart\Framework\Database\Schema;
41 use FluentCartPro\App\Modules\Licensing\Models\License;
42 use FluentCartPro\App\Modules\Licensing\Models\LicenseActivation;
43 use FluentCartPro\App\Modules\Licensing\Models\LicenseMeta;
44 use FluentCartPro\App\Modules\Licensing\Models\LicenseSite;
45 use FluentCart\Database\Migrations\ShippingZonesMigrator;
46 use FluentCart\Database\Migrations\ShippingMethodsMigrator;
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;
52
53 class DBMigrator
54 {
55 private static array $migrators = [
56 MetaMigrator::class,
57 CartMigrator::class,
58 CouponsMigrator::class,
59 CustomerAddressesMigrator::class,
60 CustomerMetaMigrator::class,
61 CustomersMigrator::class,
62 OrderAddressesMigrator::class,
63 OrderDownloadPermissionsMigrator::class,
64 OrderOperationsMigrator::class,
65 OrdersItemsMigrator::class,
66 OrdersMigrator::class,
67 OrderTaxRateMigrator::class,
68 OrderMetaMigrator::class,
69 OrderTransactionsMigrator::class,
70 ProductDetailsMigrator::class,
71 ProductDownloadsMigrator::class,
72 ProductMetaMigrator::class,
73 SubscriptionMetaMigrator::class,
74 SubscriptionsMigrator::class,
75 TaxClassesMigrator::class,
76 TaxRatesMigrator::class,
77 ProductVariationMigrator::class,
78 AppliedCouponsMigrator::class,
79 LabelMigrator::class,
80 LabelRelationshipsMigrator::class,
81 ActivityMigrator::class,
82 WebhookLogger::class,
83 ShippingZonesMigrator::class,
84 ShippingMethodsMigrator::class,
85 ShippingClassesMigrator::class,
86 ScheduledActionsMigrator::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
94 ];
95
96 public static function migrateUp($network_wide = false)
97 {
98 global $wpdb;
99 if ($network_wide) {
100 // Retrieve all site IDs from this network (WordPress >= 4.6 provides easy to use functions for that).
101 if (function_exists('get_sites') && function_exists('get_current_network_id')) {
102 $site_ids = get_sites(array('fields' => 'ids', 'network_id' => get_current_network_id()));
103 } else {
104 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
105 $site_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs WHERE site_id = $wpdb->siteid;");
106 }
107 // Install the plugin for all these sites.
108 foreach ($site_ids as $site_id) {
109 switch_to_blog($site_id);
110 self::run_migrate();
111 restore_current_blog();
112 }
113 } else {
114 self::run_migrate();
115 }
116 }
117
118 public static function run_migrate()
119 {
120 self::migrate();
121 self::maybeMigrateDBChanges();
122 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
123 }
124
125 public static function migrate()
126 {
127 /**
128 * @var $migrator Migrator
129 */
130 foreach (self::$migrators as $migrator) {
131 $migrator::migrate();
132 }
133
134 do_action('fluent_cart/after_migrate');
135 }
136
137 public static function maybeMigrateDBChanges()
138 {
139 /*
140 * TODO We will remove this after final release
141 */
142 $currentDBVersion = get_option('_fluent_cart_db_version');
143
144 if (!$currentDBVersion || version_compare($currentDBVersion, FLUENTCART_DB_VERSION, '<')) {
145
146 // Right after a plugin update, multiple requests (admin page +
147 // heartbeat/ajax) hit init before the version option is written and
148 // would all run the schema changes in parallel, producing
149 // duplicate-index / duplicate-entry errors. Only the request that
150 // wins the advisory lock proceeds; the others skip — the winner
151 // updates the version option for everyone.
152 if (!self::acquireMigrationLock()) {
153 return;
154 }
155
156 update_option('_fluent_cart_db_version', FLUENTCART_DB_VERSION, 'no');
157
158 // 2026-04-25
159 TaxRatesMigrator::upgradeShippingOverridePrecision();
160
161 // 2026-05-27
162 TaxRatesMigrator::fixPostcodeRangeSeparator();
163 OrdersMigrator::addFeeTotalColumn();
164
165 // let's check the orders table sequence number
166 global $wpdb;
167
168 // Product Meta unique index removal
169 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
170 $hasProductMetaUnqIndex = $wpdb->get_col($wpdb->prepare("SELECT * FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND INDEX_NAME='" . $wpdb->prefix . "fct_pm__comp_unq' AND TABLE_NAME=%s", $wpdb->prefix . 'fct_product_meta'));
171 if ($hasProductMetaUnqIndex) {
172
173 $table_name = $wpdb->prefix . 'fct_product_meta';
174 $index_name = 'fct_pm__comp_unq';
175
176 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
177 $wpdb->query(
178 $wpdb->prepare(
179 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
180 "ALTER TABLE %i DROP INDEX %i",
181 $table_name,
182 $index_name
183 ));
184
185 }
186
187 if (!Schema::hasColumn('tax_behavior', 'fct_orders')) {
188 $table_name = $wpdb->prefix . 'fct_orders';
189 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
190 $wpdb->query($wpdb->prepare(
191 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
192 "ALTER TABLE %i ADD COLUMN `tax_behavior` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0 => no_tax, 1 => exclusive, 2 => inclusive' AFTER `rate`",
193 $table_name
194 ));
195 }
196
197 if (!Schema::hasColumn('slug', 'fct_tax_classes')) {
198 $table_name = $wpdb->prefix . 'fct_tax_classes';
199 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
200 $wpdb->query($wpdb->prepare(
201 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
202 "ALTER TABLE %i ADD COLUMN `slug` VARCHAR(100) NULL AFTER `title`",
203 $table_name
204 ));
205 }
206
207 $ordersTable = $wpdb->prefix . 'fct_orders';
208
209 if (!Schema::hasColumn('fee_total', 'fct_orders')) {
210 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
211 $wpdb->query($wpdb->prepare(
212 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
213 "ALTER TABLE %i ADD COLUMN `fee_total` BIGINT NOT NULL DEFAULT '0' AFTER `shipping_total`",
214 $ordersTable
215 ));
216 }
217
218 // check if scheduled_at is exist or not
219 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
220 $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));
221 if (!$isReceiptNumberMigrated) {
222 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
223 $wpdb->query($wpdb->prepare(
224 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
225 "ALTER TABLE %i ADD COLUMN `receipt_number` BIGINT NULL AFTER `parent_id`",
226 $ordersTable
227 ));
228 }
229
230 /**
231 * Changing fct_meta.key to fct_meta.meta_key
232 */
233 if (Schema::hasColumn('discount_total', 'fct_orders')) {
234 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
235 $wpdb->query($wpdb->prepare(
236 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
237 "ALTER TABLE %i CHANGE `discount_total` `manual_discount_total` BIGINT NOT NULL DEFAULT '0'",
238 $ordersTable
239 ));
240 }
241
242 /**
243 * Changing fct_meta.key to fct_meta.meta_key
244 */
245 if (Schema::hasColumn('key', 'fct_meta')) {
246 $table_name = $wpdb->prefix . 'fct_meta';
247 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
248 $wpdb->query($wpdb->prepare(
249 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
250 "ALTER TABLE %i CHANGE `key` `meta_key` VARCHAR(192)",
251 $table_name
252 ));
253 }
254
255 /**
256 * Changing fct_meta.value to fct_meta.meta_value
257 */
258 if (Schema::hasColumn('value', 'fct_meta')) {
259 $table_name = $wpdb->prefix . 'fct_meta';
260 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
261 $wpdb->query($wpdb->prepare(
262 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
263 "ALTER TABLE %i CHANGE `value` `meta_value` LONGTEXT",
264 $table_name
265 ));
266 }
267
268 /**
269 * Changing fct_order_meta.key to fct_order_meta.meta_key and fct_order_meta.value to fct_order_meta.meta_value
270 */
271 if (Schema::hasColumn('key', 'fct_order_meta')) {
272 $table_name = $wpdb->prefix . 'fct_order_meta';
273 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
274 $wpdb->query($wpdb->prepare(
275 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
276 "ALTER TABLE %i CHANGE `key` `meta_key` VARCHAR(192)",
277 $table_name
278 ));
279 }
280 /**
281 * Changing fct_meta.key to fct_meta.meta_key and fct_meta.value to fct_meta.meta_value
282 */
283 if (Schema::hasColumn('value', 'fct_order_meta')) {
284 $table_name = $wpdb->prefix . 'fct_order_meta';
285 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
286 $wpdb->query($wpdb->prepare(
287 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
288 "ALTER TABLE %i CHANGE `value` `meta_value` LONGTEXT",
289 $table_name
290 ));
291 }
292
293 /**
294 * adding ltv column to fct_customers table
295 */
296 if (!Schema::hasColumn('ltv', 'fct_customers')) {
297 $table_name = $wpdb->prefix . 'fct_customers';
298 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
299 $wpdb->query($wpdb->prepare(
300 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
301 "ALTER TABLE %i ADD COLUMN `ltv` BIGINT NOT NULL DEFAULT '0' AFTER `purchase_count`",
302 $table_name
303 ));
304 }
305
306 /**
307 * adding states column to fct_shipping_methods table
308 */
309 if (!Schema::hasColumn('states', 'fct_shipping_methods')) {
310 $table_name = $wpdb->prefix . 'fct_shipping_methods';
311 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
312 $wpdb->query($wpdb->prepare(
313 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
314 "ALTER TABLE %i ADD COLUMN `states` LONGTEXT NULL AFTER `is_enabled`",
315 $table_name
316 ));
317 }
318
319 /**
320 * modify states column to json in fct_shipping_methods table
321 */
322 if (Schema::hasColumn('states', 'fct_shipping_methods')) {
323 $table_name = $wpdb->prefix . 'fct_shipping_methods';
324 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
325 $wpdb->query($wpdb->prepare(
326 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
327 "ALTER TABLE %i MODIFY COLUMN `states` JSON NULL",
328 $table_name
329 ));
330 }
331
332 /**
333 * Changing fct_shipping_zones.regions to fct_shipping_zones.region
334 */
335 if (Schema::hasColumn('regions', 'fct_shipping_zones')) {
336 $table_name = $wpdb->prefix . 'fct_shipping_zones';
337 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
338 $wpdb->query($wpdb->prepare(
339 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
340 "ALTER TABLE %i CHANGE `regions` `region` VARCHAR(192) NOT NULL",
341 $table_name
342 ));
343 }
344
345 /**
346 * adding uuid column to fct_subscriptions table
347 */
348
349 if (!Schema::hasColumn('uuid', 'fct_subscriptions')) {
350 $table_name = $wpdb->prefix . 'fct_subscriptions';
351 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
352 $wpdb->query($wpdb->prepare(
353 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
354 "ALTER TABLE %i ADD COLUMN `uuid` VARCHAR(100) NOT NULL AFTER `id`",
355 $table_name
356 ));
357 }
358
359 if (Schema::hasColumn('initial_amount', 'fct_subscriptions')) {
360 $table_name = $wpdb->prefix . 'fct_subscriptions';
361 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
362 $wpdb->query($wpdb->prepare(
363 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
364 "ALTER TABLE %i CHANGE `initial_amount` `signup_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0",
365 $table_name
366 ));
367 }
368
369 SubscriptionsMigrator::backfillEmptyUuids();
370
371 if (!Schema::hasColumn('meta', 'fct_order_addresses')) {
372 $table_name = $wpdb->prefix . 'fct_order_addresses';
373 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
374 $wpdb->query($wpdb->prepare(
375 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
376 "ALTER TABLE %i ADD COLUMN `meta` JSON DEFAULT NULL AFTER `country`",
377 $table_name
378 ));
379 }
380
381 if (!Schema::hasColumn('meta', 'fct_customer_addresses')) {
382 $table_name = $wpdb->prefix . 'fct_customer_addresses';
383 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
384 $wpdb->query($wpdb->prepare(
385 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
386 "ALTER TABLE %i ADD COLUMN `meta` JSON DEFAULT NULL AFTER `country`",
387 $table_name
388 ));
389 }
390
391 if (!Schema::hasColumn('meta', 'fct_order_tax_rate')) {
392 $table_name = $wpdb->prefix . 'fct_order_tax_rate';
393 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
394 $wpdb->query($wpdb->prepare(
395 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
396 "ALTER TABLE %i ADD COLUMN `meta` JSON",
397 $table_name
398 ));
399 }
400
401 if (!Schema::hasColumn('filed_at', 'fct_order_tax_rate')) {
402 $table_name = $wpdb->prefix . 'fct_order_tax_rate';
403 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
404 $wpdb->query($wpdb->prepare(
405 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
406 "ALTER TABLE %i ADD COLUMN `filed_at` DATETIME NULL AFTER `meta`",
407 $table_name
408 ));
409 }
410
411 if (Schema::hasColumn('categories', 'fct_tax_classes') && !Schema::hasColumn('meta', 'fct_tax_classes')) {
412 $table_name = $wpdb->prefix . 'fct_tax_classes';
413 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
414 $wpdb->query($wpdb->prepare(
415 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
416 "ALTER TABLE %i CHANGE `categories` `meta` JSON",
417 $table_name
418 ));
419 }
420
421 if (!Schema::hasColumn('meta', 'fct_tax_classes')) {
422 $table_name = $wpdb->prefix . 'fct_tax_classes';
423 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
424 $wpdb->query($wpdb->prepare(
425 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
426 "ALTER TABLE %i ADD COLUMN `meta` JSON",
427 $table_name
428 ));
429 }
430
431 if (!Schema::hasColumn('description', 'fct_tax_classes')) {
432 $table_name = $wpdb->prefix . 'fct_tax_classes';
433 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
434 $wpdb->query($wpdb->prepare(
435 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
436 "ALTER TABLE %i ADD COLUMN `description` LONGTEXT NULL AFTER `title`",
437 $table_name
438 ));
439 }
440
441 if (!Schema::hasColumn('group', 'fct_tax_rates')) {
442 $table_name = $wpdb->prefix . 'fct_tax_rates';
443 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
444 $wpdb->query($wpdb->prepare(
445 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
446 "ALTER TABLE %i ADD COLUMN `group` VARCHAR(45) NULL AFTER `name`",
447 $table_name
448 ));
449 }
450
451 if (!Schema::hasColumn('meta', 'fct_shipping_methods')) {
452 $table_name = $wpdb->prefix . 'fct_shipping_methods';
453 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
454 $wpdb->query($wpdb->prepare(
455 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
456 "ALTER TABLE %i ADD COLUMN `meta` JSON NULL AFTER `order`",
457 $table_name
458 ));
459
460 }
461
462 // Shipping schema upgrades — also run here so plugin updates
463 // without deactivation/reactivation still apply new columns
464 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
465 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
466 \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
467
468
469 // 2026-05-06
470 // Backfill, dedup, then add unique index — must run in this order
471 // so the constraint can be applied without "Duplicate entry" errors.
472 TaxClassesMigrator::backfillNullSlugs();
473 TaxClassesMigrator::deduplicateSlugs();
474 TaxClassesMigrator::addSlugUniqueIndex();
475
476 // Ensure Standard tax class exists for all installs
477 TaxClassesMigrator::seedDefaultTaxClass();
478
479 // 2026-05-15
480 // Move EU VAT country registrations from wp_options blob to fct_meta rows.
481 \FluentCart\Database\Migrations\EuVatRegistrationMigrator::migrate();
482
483 // 2026-06-10
484 // One-time migration: move legacy price_suffix into the correct split field
485 // based on the store's tax_inclusion setting at the time of migration.
486 if (!get_option('_fluent_cart_price_suffix_migrated')) {
487 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
488 $legacySuffix = isset($taxSettings['price_suffix']) ? $taxSettings['price_suffix'] : '';
489 $hasIncluded = isset($taxSettings['price_suffix_included']) && $taxSettings['price_suffix_included'] !== '';
490 $hasExcluded = isset($taxSettings['price_suffix_excluded']) && $taxSettings['price_suffix_excluded'] !== '';
491
492 if ($legacySuffix !== '' && !$hasIncluded && !$hasExcluded) {
493 $taxInclusion = isset($taxSettings['tax_inclusion']) ? $taxSettings['tax_inclusion'] : 'excluded';
494 if ($taxInclusion === 'excluded') {
495 $taxSettings['price_suffix_excluded'] = $legacySuffix;
496 } else {
497 $taxSettings['price_suffix_included'] = $legacySuffix;
498 }
499 update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
500 }
501 update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
502 }
503
504 // 2026-05-18
505 // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
506 // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
507 if (!get_option('_fluent_cart_seller_details_migrated')) {
508 $sellerDetails = fluent_cart_get_option('seller_details', []);
509 if (is_array($sellerDetails) && !empty($sellerDetails)) {
510 $storeSettingsOption = get_option('fluent_cart_store_settings', []);
511 if (!is_array($storeSettingsOption)) {
512 $storeSettingsOption = [];
513 }
514 $fieldMap = [
515 'seller_vat_id' => 'seller_vat_id',
516 'seller_tax_id' => 'seller_tax_id',
517 'seller_legal_name' => 'company_name',
518 'seller_legal_registration_id' => 'legal_registration_id',
519 ];
520 $changed = false;
521 foreach ($fieldMap as $fromKey => $toKey) {
522 if (!empty($sellerDetails[$fromKey]) && empty($storeSettingsOption[$toKey])) {
523 $storeSettingsOption[$toKey] = sanitize_text_field($sellerDetails[$fromKey]);
524 $changed = true;
525 }
526 }
527 if ($changed) {
528 update_option('fluent_cart_store_settings', $storeSettingsOption, true);
529 }
530 }
531 update_option('_fluent_cart_seller_details_migrated', '1', 'no');
532 }
533
534 // 2026-06-03
535 // Fix: convert zero-date datetime columns to NULL (Issue #1911).
536 // Zero-dates arise on MySQL servers without STRICT_TRANS_TABLES when an
537 // empty string is written to a DATETIME column.
538 $table_name = $wpdb->prefix . 'fct_subscriptions';
539 foreach (['next_billing_date', 'canceled_at', 'expire_at'] as $col) {
540 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
541 $wpdb->query($wpdb->prepare(
542 "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
543 $table_name
544 ));
545 }
546
547 // 2026-06-17
548 // Advanced Variation - Create the attribute
549 // schema (and seed the system-template groups) on in-place plugin
550 // updates too — migrate() only runs on activation, but existing
551 // installs reach this version-gated path without reactivating. Each
552 // migrator is idempotent (dbDelta) and the seeder early-returns when
553 // any group already exists, so re-runs never duplicate data. Order
554 // matters: relations table must exist before AttributeTermsMigrator's
555 // post-migration JOIN cleanup.
556 AttributeGroupsMigrator::migrate();
557 AttributeObjectRelationsMigrator::migrate();
558 AttributeTermsMigrator::migrate();
559 AttributeSeeder::seed();
560
561 self::releaseMigrationLock();
562 }
563 }
564
565 /**
566 * Try to acquire the cross-request migration lock.
567 *
568 * Uses a MySQL advisory lock (GET_LOCK with zero wait) so only one request
569 * runs the schema upgrade at a time. If the connection dies mid-migration,
570 * MySQL releases the lock automatically when the connection closes.
571 *
572 * @return bool True when this request may run the migration.
573 */
574 private static function acquireMigrationLock()
575 {
576 global $wpdb;
577
578 if (Schema::isSqlite()) {
579 // GET_LOCK is MySQL-only; SQLite has no concurrent writers.
580 return true;
581 }
582
583 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
584 $acquired = $wpdb->get_var($wpdb->prepare(
585 "SELECT GET_LOCK(%s, 0)",
586 self::getMigrationLockName()
587 ));
588
589 // NULL means the server could not create the lock — fail open so a
590 // locking hiccup can never block schema upgrades entirely.
591 return $acquired === null || (string)$acquired === '1';
592 }
593
594 private static function releaseMigrationLock()
595 {
596 global $wpdb;
597
598 if (Schema::isSqlite()) {
599 return;
600 }
601
602 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
603 $wpdb->query($wpdb->prepare(
604 "SELECT RELEASE_LOCK(%s)",
605 self::getMigrationLockName()
606 ));
607 }
608
609 private static function getMigrationLockName()
610 {
611 global $wpdb;
612
613 // GET_LOCK names are server-wide; scope to this site's DB and prefix
614 // so two WordPress installs on one MySQL server can't block each other.
615 $dbName = defined('DB_NAME') ? DB_NAME : '';
616
617 return 'fct_db_migration_' . md5($dbName . '|' . $wpdb->prefix);
618 }
619
620 public static function migrateDown($network_wide = false)
621 {
622 /**
623 * @var $migrator Migrator
624 */
625 foreach (self::$migrators as $migrator) {
626 $migrator::dropTable();
627 }
628
629 Product::query()->where('post_type', '=', FluentProducts::CPT_NAME)->delete();
630
631 //Migrate Down The Licenses
632 if (class_exists(License::class)) {
633 License::query()->truncate();
634 LicenseActivation::query()->truncate();
635 LicenseSite::query()->truncate();
636 LicenseMeta::query()->truncate();
637 }
638 }
639
640 public static function refresh($network_wide = false)
641 {
642 static::migrateDown($network_wide);
643 static::migrateUp($network_wide);
644 }
645 }
646