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 / app / Services / CustomerIdentity / CustomerMerger.php

CustomerMerger.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Services/CustomerIdentity/CustomerMerger.php

253 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\CustomerIdentity;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Models\Activity;
7 use FluentCart\App\Models\Cart;
8 use FluentCart\App\Models\Customer;
9 use FluentCart\App\Models\CustomerAddresses;
10 use FluentCart\App\Models\CustomerMeta;
11 use FluentCart\App\Models\LabelRelationship;
12 use FluentCart\App\Models\Order;
13 use FluentCart\App\Models\OrderDownloadPermission;
14 use FluentCart\App\Models\Subscription;
15 use FluentCart\Framework\Database\Schema;
16 use FluentCart\Framework\Database\Orm\Builder;
17
18 /**
19 * Moves an unlinked customer's resources after proof of inbox ownership.
20 *
21 * Keep the source as a valid parent for in-flight checkout and extension writes.
22 * Without a writer protocol or foreign keys, an empty check cannot make deletion
23 * safe. Late resources stay unlinked and can be recovered by another email claim.
24 */
25 class CustomerMerger
26 {
27 const BATCH_SIZE = 100;
28
29 /** Fail closed: recovery relies on InnoDB rollback and row locks. */
30 public static function supportsTransactions(): bool
31 {
32 global $wpdb;
33
34 static $coreTables = null;
35 if ($coreTables === null) {
36 $coreTables = [];
37 foreach ([Customer::class, Order::class, Subscription::class, OrderDownloadPermission::class, Cart::class, CustomerAddresses::class, CustomerMeta::class, LabelRelationship::class, Activity::class] as $model) {
38 $coreTables[] = (new $model())->getTable();
39 }
40 }
41 $required = [$wpdb->users, $wpdb->usermeta, $wpdb->options];
42 foreach ($coreTables as $table) {
43 $required[] = $wpdb->prefix . $table;
44 }
45 // Extensions must register the full table names written by their handoff.
46 // This is additive: listeners cannot remove the core transaction requirements.
47 $additional = apply_filters('fluent_cart/customer/recovery_transaction_tables', []);
48 if (!is_array($additional)) {
49 return false;
50 }
51 foreach ($additional as $table) {
52 if (!is_string($table) || $table === '') {
53 return false;
54 }
55 $required[] = $table;
56 }
57 $required = array_values(array_unique($required));
58 $optional = $wpdb->prefix . 'fct_licenses';
59 $tables = array_values(array_unique(array_merge($required, [$optional])));
60 $placeholders = implode(', ', array_fill(0, count($tables), '%s'));
61 $rows = $wpdb->get_results($wpdb->prepare(
62 "SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ($placeholders)",
63 $tables
64 ));
65 $engines = [];
66 foreach ($rows ?: [] as $row) {
67 $engines[$row->TABLE_NAME] = strtolower((string) $row->ENGINE);
68 }
69 foreach ($required as $table) {
70 if (($engines[$table] ?? '') !== 'innodb') {
71 return false;
72 }
73 }
74 return !isset($engines[$optional]) || $engines[$optional] === 'innodb';
75 }
76
77 /**
78 * @param Customer $source Unlinked record being absorbed.
79 * @param Customer $target Account-linked record it is folded into.
80 * @return bool True when all checked resources have moved; the source is retained.
81 */
82 public static function absorb(Customer $source, Customer $target, ?bool &$needsAnotherBatch = null): bool
83 {
84 $needsAnotherBatch = false;
85 if ((int) $source->id === (int) $target->id || $source->user_id || !$target->user_id || !static::supportsTransactions()) {
86 return false;
87 }
88
89 return Customer::query()->getConnection()->transaction(function () use ($source, $target, &$needsAnotherBatch) {
90 $sourceId = (int) $source->id;
91 $targetId = (int) $target->id;
92
93 static::moveRows(Order::query()->where('customer_id', $sourceId), 'customer_id', $targetId);
94 static::moveRows(Subscription::query()->where('customer_id', $sourceId), 'customer_id', $targetId);
95 static::moveRows(OrderDownloadPermission::query()->where('customer_id', $sourceId), 'customer_id', $targetId);
96 static::moveRows(Cart::query()->where('customer_id', $sourceId), 'customer_id', $targetId);
97
98 static::moveAddresses($sourceId, $targetId);
99 static::moveMeta($sourceId, $targetId);
100
101 static::moveRows(LabelRelationship::query()->where('labelable_type', Customer::class)->where('labelable_id', $sourceId), 'labelable_id', $targetId);
102 static::moveRows(Activity::query()->where('module_type', Customer::class)->where('module_id', $sourceId), 'module_id', $targetId);
103
104 if (static::hasCoreResources($sourceId)) {
105 $needsAnotherBatch = true;
106 return false;
107 }
108
109 // Existing contract: Pro licensing moves its rows here.
110 do_action('fluent_cart/customer_resources_moved', [
111 'from_customer_id' => $sourceId,
112 'to_customer_id' => $targetId
113 ]);
114
115 // This is a completion check, not permission to delete the source.
116 return static::isEmpty($sourceId);
117 });
118 }
119
120 protected static function moveRows(Builder $query, string $column, int $targetId): void
121 {
122 $key = $query->getModel()->getKeyName();
123 $ids = (clone $query)->orderBy($key)->limit(static::BATCH_SIZE)->toBase()->pluck($key)->toArray();
124 if ($ids) {
125 $query->whereIn($key, $ids)->update([$column => $targetId]);
126 }
127 }
128
129 /** Target primaries win; otherwise keep the earliest source primary. */
130 protected static function moveAddresses(int $sourceId, int $targetId): void
131 {
132 $rows = CustomerAddresses::query()->where('customer_id', $sourceId)->orderBy('id')
133 ->limit(static::BATCH_SIZE)->toBase()->get(['id', 'type', 'is_primary']);
134 $types = $rows->pluck('type')->unique()->toArray();
135 $primaryTypes = CustomerAddresses::query()->where('customer_id', $targetId)
136 ->whereIn('type', $types)->where('is_primary', 1)->groupBy('type')->toBase()->pluck('type')->toArray();
137 $demote = [];
138 foreach ($rows as $row) {
139 if ($row->is_primary) {
140 if (in_array($row->type, $primaryTypes, true)) {
141 $demote[] = $row->id;
142 } else {
143 $primaryTypes[] = $row->type;
144 }
145 }
146 }
147 if ($demote) {
148 CustomerAddresses::query()->where('customer_id', $sourceId)->whereIn('id', $demote)->update(['is_primary' => 0]);
149 }
150 if ($rows->isNotEmpty()) {
151 CustomerAddresses::query()->where('customer_id', $sourceId)->whereIn('id', $rows->pluck('id')->toArray())
152 ->update(['customer_id' => $targetId]);
153 }
154 }
155
156 /** Target values win; batch duplicate detection and writes without loading values. */
157 protected static function moveMeta(int $sourceId, int $targetId): void
158 {
159 $rows = CustomerMeta::query()->where('customer_id', $sourceId)->orderBy('id')
160 ->limit(static::BATCH_SIZE)->toBase()->get(['id', 'meta_key']);
161 $keys = CustomerMeta::query()->where('customer_id', $targetId)
162 ->whereIn('meta_key', $rows->pluck('meta_key')->toArray())->groupBy('meta_key')->toBase()->pluck('meta_key')->toArray();
163 $move = [];
164 $discard = [];
165 foreach ($rows as $row) {
166 if (in_array($row->meta_key, $keys, true)) {
167 $discard[] = $row->id;
168 } else {
169 $move[] = $row->id;
170 $keys[] = $row->meta_key;
171 }
172 }
173 if ($discard) {
174 CustomerMeta::query()->where('customer_id', $sourceId)->whereIn('id', $discard)->delete();
175 }
176 if ($move) {
177 CustomerMeta::query()->where('customer_id', $sourceId)->whereIn('id', $move)->update(['customer_id' => $targetId]);
178 }
179 }
180
181 public static function hasCoreResources(int $customerId): bool
182 {
183 foreach ([Order::class, Subscription::class, OrderDownloadPermission::class, Cart::class, CustomerAddresses::class, CustomerMeta::class] as $model) {
184 if ($model::query()->where('customer_id', $customerId)->exists()) {
185 return true;
186 }
187 }
188 return LabelRelationship::query()->where('labelable_type', Customer::class)->where('labelable_id', $customerId)->exists()
189 || Activity::query()->where('module_type', Customer::class)->where('module_id', $customerId)->exists();
190 }
191
192 /** Empty retained source rows are not a reason to request inbox proof again. */
193 public static function hasRecoverableCustomers(Builder $customers): bool
194 {
195 $resources = [];
196 foreach ([Order::class, Subscription::class, OrderDownloadPermission::class, Cart::class, CustomerAddresses::class, CustomerMeta::class] as $model) {
197 $resources[] = [(new $model())->getTable(), 'customer_id', null];
198 }
199 $resources[] = [(new LabelRelationship())->getTable(), 'labelable_id', 'labelable_type'];
200 $resources[] = [(new Activity())->getTable(), 'module_id', 'module_type'];
201 if (Schema::hasTable('fct_licenses')) {
202 $resources[] = ['fct_licenses', 'customer_id', null];
203 }
204 $customerTable = $customers->getModel()->getTable();
205 return (clone $customers)->where(function ($query) use ($resources, $customerTable) {
206 foreach ($resources as [$table, $column, $type]) {
207 $query->orWhereExists(function ($resource) use ($table, $column, $type, $customerTable) {
208 $resource->selectRaw('1')->from($table)->whereColumn($table . '.' . $column, $customerTable . '.id');
209 if ($type) {
210 $resource->where($table . '.' . $type, Customer::class);
211 }
212 });
213 }
214 })->exists();
215 }
216
217 /** A small foreground recovery has a fixed source and resource budget. */
218 public static function fitsForeground(array $sourceIds): bool
219 {
220 $remaining = static::BATCH_SIZE;
221 foreach ([Order::class, Subscription::class, OrderDownloadPermission::class, Cart::class, CustomerAddresses::class, CustomerMeta::class, LabelRelationship::class, Activity::class] as $model) {
222 $query = $model::query();
223 if ($model === LabelRelationship::class) {
224 $query->where('labelable_type', Customer::class)->whereIn('labelable_id', $sourceIds);
225 } elseif ($model === Activity::class) {
226 $query->where('module_type', Customer::class)->whereIn('module_id', $sourceIds);
227 } else {
228 $query->whereIn('customer_id', $sourceIds);
229 }
230 $remaining -= $query->limit($remaining + 1)->toBase()->pluck($query->getModel()->getKeyName())->count();
231 if ($remaining < 0) {
232 return false;
233 }
234 }
235 return true;
236 }
237
238 /** Re-read to detect resources left behind by an extension. */
239 protected static function isEmpty(int $customerId): bool
240 {
241 if (static::hasCoreResources($customerId)) {
242 return false;
243 }
244
245 // Pro licenses when Pro is inactive: its listener could not move them.
246 if (Schema::hasTable('fct_licenses') && App::db()->table('fct_licenses')->where('customer_id', $customerId)->exists()) {
247 return false;
248 }
249
250 return true;
251 }
252 }
253