PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / database / DBMigrator.php

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

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