PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.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.6.0, at database/DBMigrator.php

675 lines 34.8 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
485 // 2026-05-06
486 // Backfill, dedup, then add unique index — must run in this order
487 // so the constraint can be applied without "Duplicate entry" errors.
488 TaxClassesMigrator::backfillNullSlugs();
489 TaxClassesMigrator::deduplicateSlugs();
490 TaxClassesMigrator::addSlugUniqueIndex();
491
492 // Ensure Standard tax class exists for all installs
493 TaxClassesMigrator::seedDefaultTaxClass();
494
495 // 2026-05-15
496 // Move EU VAT country registrations from wp_options blob to fct_meta rows.
497 \FluentCart\Database\Migrations\EuVatRegistrationMigrator::migrate();
498
499 // 2026-06-10
500 // One-time migration: move legacy price_suffix into the correct split field
501 // based on the store's tax_inclusion setting at the time of migration.
502 if (!get_option('_fluent_cart_price_suffix_migrated')) {
503 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
504 $legacySuffix = isset($taxSettings['price_suffix']) ? $taxSettings['price_suffix'] : '';
505 $hasIncluded = isset($taxSettings['price_suffix_included']) && $taxSettings['price_suffix_included'] !== '';
506 $hasExcluded = isset($taxSettings['price_suffix_excluded']) && $taxSettings['price_suffix_excluded'] !== '';
507
508 if ($legacySuffix !== '' && !$hasIncluded && !$hasExcluded) {
509 $taxInclusion = isset($taxSettings['tax_inclusion']) ? $taxSettings['tax_inclusion'] : 'excluded';
510 if ($taxInclusion === 'excluded') {
511 $taxSettings['price_suffix_excluded'] = $legacySuffix;
512 } else {
513 $taxSettings['price_suffix_included'] = $legacySuffix;
514 }
515 update_option('fluent_cart_tax_configuration_settings', $taxSettings, true);
516 }
517 update_option('_fluent_cart_price_suffix_migrated', '1', 'no');
518 }
519
520 // 2026-07-08
521 // One-time migration: rename the legacy tax-display value 'both'
522 // (and the removed 'label'/'tooltip') to 'itemized'. Value-guarded —
523 // once migrated the stored value is 'itemized' so this no-ops; no
524 // separate flag option is written (nothing left behind when this
525 // block is removed later). Safe to remove after ~2027-07.
526 $fctTaxSettings = get_option('fluent_cart_tax_configuration_settings', []);
527 if (isset($fctTaxSettings['checkout_tax_breakdown_display'])
528 && in_array($fctTaxSettings['checkout_tax_breakdown_display'], ['both', 'label', 'tooltip'], true)) {
529 $fctTaxSettings['checkout_tax_breakdown_display'] = 'itemized';
530 update_option('fluent_cart_tax_configuration_settings', $fctTaxSettings, true);
531 }
532
533 // 2026-05-18
534 // One-time migration: copy seller identity fields from the Pro seller_details fct_meta
535 // entry into fluent_cart_store_settings. Guarded by a sentinel so it runs exactly once.
536 if (!get_option('_fluent_cart_seller_details_migrated')) {
537 $sellerDetails = fluent_cart_get_option('seller_details', []);
538 if (is_array($sellerDetails) && !empty($sellerDetails)) {
539 $storeSettingsOption = get_option('fluent_cart_store_settings', []);
540 if (!is_array($storeSettingsOption)) {
541 $storeSettingsOption = [];
542 }
543 $fieldMap = [
544 'seller_vat_id' => 'seller_vat_id',
545 'seller_tax_id' => 'seller_tax_id',
546 'seller_legal_name' => 'company_name',
547 'seller_legal_registration_id' => 'legal_registration_id',
548 ];
549 $changed = false;
550 foreach ($fieldMap as $fromKey => $toKey) {
551 if (!empty($sellerDetails[$fromKey]) && empty($storeSettingsOption[$toKey])) {
552 $storeSettingsOption[$toKey] = sanitize_text_field($sellerDetails[$fromKey]);
553 $changed = true;
554 }
555 }
556 if ($changed) {
557 update_option('fluent_cart_store_settings', $storeSettingsOption, true);
558 }
559 }
560 update_option('_fluent_cart_seller_details_migrated', '1', 'no');
561 }
562
563 // 2026-06-03
564 // Fix: convert zero-date datetime columns to NULL (Issue #1911).
565 // Zero-dates arise on MySQL servers without STRICT_TRANS_TABLES when an
566 // empty string is written to a DATETIME column.
567 $table_name = $wpdb->prefix . 'fct_subscriptions';
568 foreach (['next_billing_date', 'canceled_at', 'expire_at'] as $col) {
569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
570 $wpdb->query($wpdb->prepare(
571 "UPDATE %i SET `{$col}` = NULL WHERE `{$col}` IN ('0000-00-00 00:00:00', '0000-00-00')",
572 $table_name
573 ));
574 }
575
576 // 2026-06-17
577 // Advanced Variation - Create the attribute
578 // schema (and seed the system-template groups) on in-place plugin
579 // updates too — migrate() only runs on activation, but existing
580 // installs reach this version-gated path without reactivating. Each
581 // migrator is idempotent (dbDelta) and the seeder early-returns when
582 // any group already exists, so re-runs never duplicate data. Order
583 // matters: relations table must exist before AttributeTermsMigrator's
584 // post-migration JOIN cleanup.
585 AttributeGroupsMigrator::migrate();
586 AttributeObjectRelationsMigrator::migrate();
587 AttributeTermsMigrator::migrate();
588 AttributeSeeder::seed();
589
590 self::releaseMigrationLock();
591 }
592 }
593
594 /**
595 * Try to acquire the cross-request migration lock.
596 *
597 * Uses a MySQL advisory lock (GET_LOCK with zero wait) so only one request
598 * runs the schema upgrade at a time. If the connection dies mid-migration,
599 * MySQL releases the lock automatically when the connection closes.
600 *
601 * @return bool True when this request may run the migration.
602 */
603 private static function acquireMigrationLock()
604 {
605 global $wpdb;
606
607 if (Schema::isSqlite()) {
608 // GET_LOCK is MySQL-only; SQLite has no concurrent writers.
609 return true;
610 }
611
612 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
613 $acquired = $wpdb->get_var($wpdb->prepare(
614 "SELECT GET_LOCK(%s, 0)",
615 self::getMigrationLockName()
616 ));
617
618 // NULL means the server could not create the lock — fail open so a
619 // locking hiccup can never block schema upgrades entirely.
620 return $acquired === null || (string)$acquired === '1';
621 }
622
623 private static function releaseMigrationLock()
624 {
625 global $wpdb;
626
627 if (Schema::isSqlite()) {
628 return;
629 }
630
631 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
632 $wpdb->query($wpdb->prepare(
633 "SELECT RELEASE_LOCK(%s)",
634 self::getMigrationLockName()
635 ));
636 }
637
638 private static function getMigrationLockName()
639 {
640 global $wpdb;
641
642 // GET_LOCK names are server-wide; scope to this site's DB and prefix
643 // so two WordPress installs on one MySQL server can't block each other.
644 $dbName = defined('DB_NAME') ? DB_NAME : '';
645
646 return 'fct_db_migration_' . md5($dbName . '|' . $wpdb->prefix);
647 }
648
649 public static function migrateDown($network_wide = false)
650 {
651 /**
652 * @var $migrator Migrator
653 */
654 foreach (self::$migrators as $migrator) {
655 $migrator::dropTable();
656 }
657
658 Product::query()->where('post_type', '=', FluentProducts::CPT_NAME)->delete();
659
660 //Migrate Down The Licenses
661 if (class_exists(License::class)) {
662 License::query()->truncate();
663 LicenseActivation::query()->truncate();
664 LicenseSite::query()->truncate();
665 LicenseMeta::query()->truncate();
666 }
667 }
668
669 public static function refresh($network_wide = false)
670 {
671 static::migrateDown($network_wide);
672 static::migrateUp($network_wide);
673 }
674 }
675