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

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

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