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.6 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 All 49 releases
fluent-cart / database / DBMigrator.php

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

681 lines 35.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\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 if (!$currentDBVersion) {
159 // Brand-new store: default tax display to the simplified single line.
160 // Existing stores fall through to the read-time default 'itemized'.
161 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
162 if (!isset($taxSettings['checkout_tax_breakdown_display'])) {
163 $taxSettings['checkout_tax_breakdown_display'] = 'simplified';
164 update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
165 }
166 }
167
168 // 2026-04-25
169 TaxRatesMigrator::upgradeShippingOverridePrecision();
170
171 // 2026-07-01
172 OrderTaxRateMigrator::allowVirtualTaxRateIds();
173
174 // 2026-05-27
175 TaxRatesMigrator::fixPostcodeRangeSeparator();
176 OrdersMigrator::addFeeTotalColumn();
177
178 // 2026-07-24
179 OrdersMigrator::addUuidIndex();
180
181 // let's check the orders table sequence number
182 global $wpdb;
183
184 // Product Meta unique index removal
185 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
186 $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'));
187 if ($hasProductMetaUnqIndex) {
188
189 $table_name = $wpdb->prefix . 'fct_product_meta';
190 $index_name = 'fct_pm__comp_unq';
191
192 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
193 $wpdb->query(
194 $wpdb->prepare(
195 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
196 "ALTER TABLE %i DROP INDEX %i",
197 $table_name,
198 $index_name
199 ));
200
201 }
202
203 if (!Schema::hasColumn('tax_behavior', 'fct_orders')) {
204 $table_name = $wpdb->prefix . 'fct_orders';
205 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
206 $wpdb->query($wpdb->prepare(
207 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
208 "ALTER TABLE %i ADD COLUMN `tax_behavior` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0 => no_tax, 1 => exclusive, 2 => inclusive' AFTER `rate`",
209 $table_name
210 ));
211 }
212
213 if (!Schema::hasColumn('slug', 'fct_tax_classes')) {
214 $table_name = $wpdb->prefix . 'fct_tax_classes';
215 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
216 $wpdb->query($wpdb->prepare(
217 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
218 "ALTER TABLE %i ADD COLUMN `slug` VARCHAR(100) NULL AFTER `title`",
219 $table_name
220 ));
221 }
222
223 $ordersTable = $wpdb->prefix . 'fct_orders';
224
225 if (!Schema::hasColumn('fee_total', 'fct_orders')) {
226 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
227 $wpdb->query($wpdb->prepare(
228 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
229 "ALTER TABLE %i ADD COLUMN `fee_total` BIGINT NOT NULL DEFAULT '0' AFTER `shipping_total`",
230 $ordersTable
231 ));
232 }
233
234 // check if scheduled_at is exist or not
235 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
236 $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));
237 if (!$isReceiptNumberMigrated) {
238 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
239 $wpdb->query($wpdb->prepare(
240 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
241 "ALTER TABLE %i ADD COLUMN `receipt_number` BIGINT NULL AFTER `parent_id`",
242 $ordersTable
243 ));
244 }
245
246 /**
247 * Changing fct_meta.key to fct_meta.meta_key
248 */
249 if (Schema::hasColumn('discount_total', 'fct_orders')) {
250 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
251 $wpdb->query($wpdb->prepare(
252 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
253 "ALTER TABLE %i CHANGE `discount_total` `manual_discount_total` BIGINT NOT NULL DEFAULT '0'",
254 $ordersTable
255 ));
256 }
257
258 /**
259 * Changing fct_meta.key to fct_meta.meta_key
260 */
261 if (Schema::hasColumn('key', 'fct_meta')) {
262 $table_name = $wpdb->prefix . 'fct_meta';
263 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
264 $wpdb->query($wpdb->prepare(
265 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
266 "ALTER TABLE %i CHANGE `key` `meta_key` VARCHAR(192)",
267 $table_name
268 ));
269 }
270
271 /**
272 * Changing fct_meta.value to fct_meta.meta_value
273 */
274 if (Schema::hasColumn('value', 'fct_meta')) {
275 $table_name = $wpdb->prefix . 'fct_meta';
276 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
277 $wpdb->query($wpdb->prepare(
278 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
279 "ALTER TABLE %i CHANGE `value` `meta_value` LONGTEXT",
280 $table_name
281 ));
282 }
283
284 /**
285 * Changing fct_order_meta.key to fct_order_meta.meta_key and fct_order_meta.value to fct_order_meta.meta_value
286 */
287 if (Schema::hasColumn('key', 'fct_order_meta')) {
288 $table_name = $wpdb->prefix . 'fct_order_meta';
289 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
290 $wpdb->query($wpdb->prepare(
291 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
292 "ALTER TABLE %i CHANGE `key` `meta_key` VARCHAR(192)",
293 $table_name
294 ));
295 }
296 /**
297 * Changing fct_meta.key to fct_meta.meta_key and fct_meta.value to fct_meta.meta_value
298 */
299 if (Schema::hasColumn('value', 'fct_order_meta')) {
300 $table_name = $wpdb->prefix . 'fct_order_meta';
301 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
302 $wpdb->query($wpdb->prepare(
303 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
304 "ALTER TABLE %i CHANGE `value` `meta_value` LONGTEXT",
305 $table_name
306 ));
307 }
308
309 /**
310 * adding ltv column to fct_customers table
311 */
312 if (!Schema::hasColumn('ltv', 'fct_customers')) {
313 $table_name = $wpdb->prefix . 'fct_customers';
314 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
315 $wpdb->query($wpdb->prepare(
316 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
317 "ALTER TABLE %i ADD COLUMN `ltv` BIGINT NOT NULL DEFAULT '0' AFTER `purchase_count`",
318 $table_name
319 ));
320 }
321
322 /**
323 * adding states column to fct_shipping_methods table
324 */
325 if (!Schema::hasColumn('states', 'fct_shipping_methods')) {
326 $table_name = $wpdb->prefix . 'fct_shipping_methods';
327 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
328 $wpdb->query($wpdb->prepare(
329 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
330 "ALTER TABLE %i ADD COLUMN `states` LONGTEXT NULL AFTER `is_enabled`",
331 $table_name
332 ));
333 }
334
335 /**
336 * modify states column to json in fct_shipping_methods table
337 */
338 if (Schema::hasColumn('states', 'fct_shipping_methods')) {
339 $table_name = $wpdb->prefix . 'fct_shipping_methods';
340 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
341 $wpdb->query($wpdb->prepare(
342 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
343 "ALTER TABLE %i MODIFY COLUMN `states` JSON NULL",
344 $table_name
345 ));
346 }
347
348 /**
349 * Changing fct_shipping_zones.regions to fct_shipping_zones.region
350 */
351 if (Schema::hasColumn('regions', 'fct_shipping_zones')) {
352 $table_name = $wpdb->prefix . 'fct_shipping_zones';
353 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
354 $wpdb->query($wpdb->prepare(
355 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
356 "ALTER TABLE %i CHANGE `regions` `region` VARCHAR(192) NOT NULL",
357 $table_name
358 ));
359 }
360
361 /**
362 * adding uuid column to fct_subscriptions table
363 */
364
365 if (!Schema::hasColumn('uuid', 'fct_subscriptions')) {
366 $table_name = $wpdb->prefix . 'fct_subscriptions';
367 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
368 $wpdb->query($wpdb->prepare(
369 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
370 "ALTER TABLE %i ADD COLUMN `uuid` VARCHAR(100) NOT NULL AFTER `id`",
371 $table_name
372 ));
373 }
374
375 if (Schema::hasColumn('initial_amount', 'fct_subscriptions')) {
376 $table_name = $wpdb->prefix . 'fct_subscriptions';
377 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
378 $wpdb->query($wpdb->prepare(
379 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
380 "ALTER TABLE %i CHANGE `initial_amount` `signup_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0",
381 $table_name
382 ));
383 }
384
385 SubscriptionsMigrator::backfillEmptyUuids();
386
387 if (!Schema::hasColumn('meta', 'fct_order_addresses')) {
388 $table_name = $wpdb->prefix . 'fct_order_addresses';
389 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
390 $wpdb->query($wpdb->prepare(
391 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
392 "ALTER TABLE %i ADD COLUMN `meta` JSON DEFAULT NULL AFTER `country`",
393 $table_name
394 ));
395 }
396
397 if (!Schema::hasColumn('meta', 'fct_customer_addresses')) {
398 $table_name = $wpdb->prefix . 'fct_customer_addresses';
399 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
400 $wpdb->query($wpdb->prepare(
401 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
402 "ALTER TABLE %i ADD COLUMN `meta` JSON DEFAULT NULL AFTER `country`",
403 $table_name
404 ));
405 }
406
407 if (!Schema::hasColumn('meta', 'fct_order_tax_rate')) {
408 $table_name = $wpdb->prefix . 'fct_order_tax_rate';
409 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
410 $wpdb->query($wpdb->prepare(
411 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
412 "ALTER TABLE %i ADD COLUMN `meta` JSON",
413 $table_name
414 ));
415 }
416
417 if (!Schema::hasColumn('filed_at', 'fct_order_tax_rate')) {
418 $table_name = $wpdb->prefix . 'fct_order_tax_rate';
419 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
420 $wpdb->query($wpdb->prepare(
421 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
422 "ALTER TABLE %i ADD COLUMN `filed_at` DATETIME NULL AFTER `meta`",
423 $table_name
424 ));
425 }
426
427 if (Schema::hasColumn('categories', 'fct_tax_classes') && !Schema::hasColumn('meta', 'fct_tax_classes')) {
428 $table_name = $wpdb->prefix . 'fct_tax_classes';
429 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
430 $wpdb->query($wpdb->prepare(
431 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
432 "ALTER TABLE %i CHANGE `categories` `meta` JSON",
433 $table_name
434 ));
435 }
436
437 if (!Schema::hasColumn('meta', 'fct_tax_classes')) {
438 $table_name = $wpdb->prefix . 'fct_tax_classes';
439 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
440 $wpdb->query($wpdb->prepare(
441 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
442 "ALTER TABLE %i ADD COLUMN `meta` JSON",
443 $table_name
444 ));
445 }
446
447 if (!Schema::hasColumn('description', 'fct_tax_classes')) {
448 $table_name = $wpdb->prefix . 'fct_tax_classes';
449 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
450 $wpdb->query($wpdb->prepare(
451 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
452 "ALTER TABLE %i ADD COLUMN `description` LONGTEXT NULL AFTER `title`",
453 $table_name
454 ));
455 }
456
457 if (!Schema::hasColumn('group', 'fct_tax_rates')) {
458 $table_name = $wpdb->prefix . 'fct_tax_rates';
459 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
460 $wpdb->query($wpdb->prepare(
461 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
462 "ALTER TABLE %i ADD COLUMN `group` VARCHAR(45) NULL AFTER `name`",
463 $table_name
464 ));
465 }
466
467 if (!Schema::hasColumn('meta', 'fct_shipping_methods')) {
468 $table_name = $wpdb->prefix . 'fct_shipping_methods';
469 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
470 $wpdb->query($wpdb->prepare(
471 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
472 "ALTER TABLE %i ADD COLUMN `meta` JSON NULL AFTER `order`",
473 $table_name
474 ));
475
476 }
477
478 // Shipping schema upgrades — also run here so plugin updates
479 // without deactivation/reactivation still apply new columns
480 \FluentCart\Database\Migrations\ShippingZonesMigrator::migrated();
481 \FluentCart\Database\Migrations\ShippingClassesMigrator::migrated();
482 \FluentCart\Database\Migrations\SubscriptionsMigrator::migrated();
483
484 // 2026-08-06
485 // fct_order_transactions subscription_id index — also run here so
486 // in-place plugin updates deliver it; migrate() only covers the
487 // activation path.
488 \FluentCart\Database\Migrations\OrderTransactionsMigrator::migrated();
489
490
491 // 2026-05-06
492 // Backfill, dedup, then add unique index — must run in this order
493 // so the constraint can be applied without "Duplicate entry" errors.
494 TaxClassesMigrator::backfillNullSlugs();
495 TaxClassesMigrator::deduplicateSlugs();
496 TaxClassesMigrator::addSlugUniqueIndex();
497
498 // Ensure Standard tax class exists for all installs
499 TaxClassesMigrator::seedDefaultTaxClass();
500
501 // 2026-05-15
502 // Move EU VAT country registrations from wp_options blob to fct_meta rows.
503 \FluentCart\Database\Migrations\EuVatRegistrationMigrator::migrate();
504
505 // 2026-06-10
506 // One-time migration: move legacy price_suffix into the correct split field
507 // based on the store's tax_inclusion setting at the time of migration.
508 if (!get_option('_fluent_cart_price_suffix_migrated')) {
509 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
510 $legacySuffix = isset($taxSettings['price_suffix']) ? $taxSettings['price_suffix'] : '';
511 $hasIncluded = isset($taxSettings['price_suffix_included']) && $taxSettings['price_suffix_included'] !== '';
512 $hasExcluded = isset($taxSettings['price_suffix_excluded']) && $taxSettings['price_suffix_excluded'] !== '';
513
514 if ($legacySuffix !== '' && !$hasIncluded && !$hasExcluded) {
515 $taxInclusion = isset($taxSettings['tax_inclusion']) ? $taxSettings['tax_inclusion'] : 'excluded';
516 if ($taxInclusion === 'excluded') {
517 $taxSettings['price_suffix_excluded'] = $legacySuffix;
518 } else {
519 $taxSettings['price_suffix_included'] = $legacySuffix;
520 }
521 update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
522 }
523 update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
524 }
525
526 // 2026-07-08
527 // One-time migration: rename the legacy tax-display value 'both'
528 // (and the removed 'label'/'tooltip') to 'itemized'. Value-guarded —
529 // once migrated the stored value is 'itemized' so this no-ops; no
530 // separate flag option is written (nothing left behind when this
531 // block is removed later). Safe to remove after ~2027-07.
532 $fctTaxSettings = get_option('fluent_cart_tax_configuration_settings', []);
533 if (isset($fctTaxSettings['checkout_tax_breakdown_display'])
534 && in_array($fctTaxSettings['checkout_tax_breakdown_display'], ['both', 'label', 'tooltip'], true)) {
535 $fctTaxSettings['checkout_tax_breakdown_display'] = 'itemized';
536 update_option('fluent_cart_tax_configuration_settings', $fctTaxSettings, true);
537 }
538
539 // 2026-05-18
540 // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
541 // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
542 if (!get_option('_fluent_cart_seller_details_migrated')) {
543 $sellerDetails = fluent_cart_get_option('seller_details', []);
544 if (is_array($sellerDetails) && !empty($sellerDetails)) {
545 $storeSettingsOption = get_option('fluent_cart_store_settings', []);
546 if (!is_array($storeSettingsOption)) {
547 $storeSettingsOption = [];
548 }
549 $fieldMap = [
550 'seller_vat_id' => 'seller_vat_id',
551 'seller_tax_id' => 'seller_tax_id',
552 'seller_legal_name' => 'company_name',
553 'seller_legal_registration_id' => 'legal_registration_id',
554 ];
555 $changed = false;
556 foreach ($fieldMap as $fromKey => $toKey) {
557 if (!empty($sellerDetails[$fromKey]) && empty($storeSettingsOption[$toKey])) {
558 $storeSettingsOption[$toKey] = sanitize_text_field($sellerDetails[$fromKey]);
559 $changed = true;
560 }
561 }
562 if ($changed) {
563 update_option('fluent_cart_store_settings', $storeSettingsOption, true);
564 }
565 }
566 update_option('_fluent_cart_seller_details_migrated', '1', 'no');
567 }
568
569 // 2026-06-03
570 // Fix: convert zero-date datetime columns to NULL (Issue #1911).
571 // Zero-dates arise on MySQL servers without STRICT_TRANS_TABLES when an
572 // empty string is written to a DATETIME column.
573 $table_name = $wpdb->prefix . 'fct_subscriptions';
574 foreach (['next_billing_date', 'canceled_at', 'expire_at'] as $col) {
575 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
576 $wpdb->query($wpdb->prepare(
577 "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
578 $table_name
579 ));
580 }
581
582 // 2026-06-17
583 // Advanced Variation - Create the attribute
584 // schema (and seed the system-template groups) on in-place plugin
585 // updates too — migrate() only runs on activation, but existing
586 // installs reach this version-gated path without reactivating. Each
587 // migrator is idempotent (dbDelta) and the seeder early-returns when
588 // any group already exists, so re-runs never duplicate data. Order
589 // matters: relations table must exist before AttributeTermsMigrator's
590 // post-migration JOIN cleanup.
591 AttributeGroupsMigrator::migrate();
592 AttributeObjectRelationsMigrator::migrate();
593 AttributeTermsMigrator::migrate();
594 AttributeSeeder::seed();
595
596 self::releaseMigrationLock();
597 }
598 }
599
600 /**
601 * Try to acquire the cross-request migration lock.
602 *
603 * Uses a MySQL advisory lock (GET_LOCK with zero wait) so only one request
604 * runs the schema upgrade at a time. If the connection dies mid-migration,
605 * MySQL releases the lock automatically when the connection closes.
606 *
607 * @return bool True when this request may run the migration.
608 */
609 private static function acquireMigrationLock()
610 {
611 global $wpdb;
612
613 if (Schema::isSqlite()) {
614 // GET_LOCK is MySQL-only; SQLite has no concurrent writers.
615 return true;
616 }
617
618 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
619 $acquired = $wpdb->get_var($wpdb->prepare(
620 "SELECT GET_LOCK(%s, 0)",
621 self::getMigrationLockName()
622 ));
623
624 // NULL means the server could not create the lock — fail open so a
625 // locking hiccup can never block schema upgrades entirely.
626 return $acquired === null || (string)$acquired === '1';
627 }
628
629 private static function releaseMigrationLock()
630 {
631 global $wpdb;
632
633 if (Schema::isSqlite()) {
634 return;
635 }
636
637 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
638 $wpdb->query($wpdb->prepare(
639 "SELECT RELEASE_LOCK(%s)",
640 self::getMigrationLockName()
641 ));
642 }
643
644 private static function getMigrationLockName()
645 {
646 global $wpdb;
647
648 // GET_LOCK names are server-wide; scope to this site's DB and prefix
649 // so two WordPress installs on one MySQL server can't block each other.
650 $dbName = defined('DB_NAME') ? DB_NAME : '';
651
652 return 'fct_db_migration_' . md5($dbName . '|' . $wpdb->prefix);
653 }
654
655 public static function migrateDown($network_wide = false)
656 {
657 /**
658 * @var $migrator Migrator
659 */
660 foreach (self::$migrators as $migrator) {
661 $migrator::dropTable();
662 }
663
664 Product::query()->where('post_type', '=', FluentProducts::CPT_NAME)->delete();
665
666 //Migrate Down The Licenses
667 if (class_exists(License::class)) {
668 License::query()->truncate();
669 LicenseActivation::query()->truncate();
670 LicenseSite::query()->truncate();
671 LicenseMeta::query()->truncate();
672 }
673 }
674
675 public static function refresh($network_wide = false)
676 {
677 static::migrateDown($network_wide);
678 static::migrateUp($network_wide);
679 }
680 }
681