PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / database / DBMigrator.php

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

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