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
← All changes | api/Resource/CustomerResource.php +83 -33 1.5.1 → 1.7.0 View file →
@@ -5,8 +5,9 @@
5 5 use FluentCart\App\App;
6 6 use FluentCart\App\Helpers\AddressHelper;
7 7 use FluentCart\App\Helpers\Status;
8 8 use FluentCart\App\Models\Customer;
9 +use FluentCart\App\Services\CustomerIdentity\EmailVerificationService;
9 10 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
10 11 use FluentCart\Framework\Database\Orm\Builder;
11 12 use FluentCart\Framework\Database\Orm\Collection;
12 13 use FluentCart\Framework\Support\Arr;
@@ -12,9 +13,26 @@
12 13 use FluentCart\Framework\Support\Arr;
13 14
14 15 class CustomerResource extends BaseResourceApi
15 16 {
17 + /**
18 + * Per-request memo for getCurrentCustomer(). In production every HTTP
19 + * request runs in a fresh PHP process, so this lives exactly one
20 + * request. Long-running processes that simulate multiple requests
21 + * (test suites, CLI) must clear it between simulated requests via
22 + * resetCurrentCustomerRuntimeCache() — as a function-static it was
23 + * unreachable and leaked the first request's customer into every
24 + * subsequent one.
25 + *
26 + * @var object|null
27 + */
28 + private static $currentCustomerRuntimeCache = null;
16 29
30 + public static function resetCurrentCustomerRuntimeCache(): void
31 + {
32 + static::$currentCustomerRuntimeCache = null;
33 + }
34 +
17 35 public static function getQuery(): Builder
18 36 {
19 37 return Customer::query();
20 38 }
@@ -118,13 +136,17 @@
118 136 $email = Arr::get($data, 'email');
119 137 $data = static::resolveCustomerName($data);
120 138
121 139 $data['purchase_value'] = [];
122 - $customer = static::getQuery()->firstOrCreate(
123 - ['email' => $email],
124 - $data
125 - );
126 140
141 + // Preserve an established account link; otherwise reuse the email row
142 + // without linking it. Only the verification flow can claim guest history.
143 + $ownerId = (int) Arr::get($data, 'user_id');
144 + $customer = $ownerId ? static::getQuery()->where('user_id', $ownerId)->orderBy('id')->first() : null;
145 + if (!$customer) {
146 + $customer = static::getQuery()->firstOrCreate(['email' => $email], $data);
147 + }
148 +
127 149 if (empty($customer)) {
128 150 return static::makeErrorResponse([
129 151 ['code' => 400, 'message' => __('Customer creation failed.', 'fluent-cart')]
130 152 ]);
@@ -129,13 +151,22 @@
129 151 ['code' => 400, 'message' => __('Customer creation failed.', 'fluent-cart')]
130 152 ]);
131 153 }
132 154
133 - $isUserAttached = false;
134 - $user = get_user_by('email', $email);
135 - if ($user) {
136 - $customer->update(['user_id' => $user->ID]);
137 - $isUserAttached = true;
155 + // Linking happens only where identity is established. A row that
156 + // already existed is never claimed here: firstOrCreate() may have found
157 + // somebody else's record by its address. A fresh row is linked to the
158 + // account holding its email only for an actor with authority over that
159 + // account (an admin screen, the MCP tools) or when the caller supplied
160 + // the user_id it established itself (a signed-in checkout, the User
161 + // API). An anonymous caller — a guest at checkout — links nothing.
162 + $isUserAttached = (bool) $customer->user_id;
163 + if ($customer->wasRecentlyCreated && !$isUserAttached) {
164 + $user = get_user_by('email', $email);
165 + if ($user && get_current_user_id() && current_user_can('edit_user', $user->ID)) {
166 + $customer->update(['user_id' => $user->ID]);
167 + $isUserAttached = true;
168 + }
138 169 }
139 170
140 171 if (Arr::get($data, 'wp_user') === 'yes' && !$isUserAttached) {
141 172 $isUserCreated = \FluentCart\App\Services\AuthService::createUserFromCustomer($customer);
@@ -286,19 +317,19 @@
286 317 }
287 318
288 319 return static::makeErrorResponse([
289 320 ['code' => 400, 'message' => __('Customer update failed.', 'fluent-cart')]
290 - ]);
321 + ], 400);
291 322 }
292 323
293 324 return static::makeErrorResponse([
294 325 ['code' => 400, 'message' => __('Customer does not have any changes to update.', 'fluent-cart')]
295 - ]);
326 + ], 400);
296 327 }
297 328
298 329 return static::makeErrorResponse([
299 330 ['code' => 404, 'message' => __('Customer not found, please reload the page and try again!', 'fluent-cart')]
300 - ]);
331 + ], 404);
301 332 }
302 333
303 334 /**
304 335 * Update the status of multiple customers with the given parameters.
@@ -383,12 +414,10 @@
383 414 }
384 415
385 416 public static function getCurrentCustomer(bool $createIfNotExists = false): ?object
386 417 {
387 - static $cachedCustomer = null;
388 -
389 - if ($cachedCustomer !== null) {
390 - return $cachedCustomer;
418 + if (static::$currentCustomerRuntimeCache !== null) {
419 + return static::$currentCustomerRuntimeCache;
391 420 }
392 421
393 422 if (!is_user_logged_in()) {
394 423 return null;
@@ -395,29 +424,26 @@
395 424 }
396 425
397 426 $currentUser = get_user_by('ID', get_current_user_id());
398 427
399 - // Try to get the existing customer
400 - $query = Customer::query()->where('user_id', $currentUser->ID)
401 - ->orWhere('email', $currentUser->user_email)
402 - ->with(['billing_address', 'shipping_address']);
428 + // With verification enabled, reading the current customer must not claim a record by email.
429 + $existingCustomer = Customer::query()->where('user_id', $currentUser->ID)
430 + ->orderBy('id', 'ASC')
431 + ->with(['billing_address', 'shipping_address'])
432 + ->first();
403 433
434 + if (!$existingCustomer && !EmailVerificationService::isEnabled()) {
435 + $existingCustomer = static::claimUnlinkedCustomerByEmail($currentUser);
436 + }
404 437
405 - $existingCustomer = $query->first();
406 -
407 - // Return if found
408 438 if ($existingCustomer) {
409 - if ($existingCustomer->user_id != $currentUser->ID) {
410 - // Update the user_id if it doesn't match
411 - $existingCustomer->user_id = $currentUser->ID;
412 - $existingCustomer->save();
413 - }
414 -
415 - $cachedCustomer = $existingCustomer;
439 + static::$currentCustomerRuntimeCache = $existingCustomer;
416 440 return $existingCustomer;
417 441 }
418 442
419 - if (!$createIfNotExists) {
443 + if (!$createIfNotExists || (EmailVerificationService::isEnabled() && Customer::query()->where('email', $currentUser->user_email)->exists())) {
444 + // With verification enabled, confirmation links an existing guest row.
445 + // Otherwise an explicitly requested profile is separate from guest history.
420 446 return null;
421 447 }
422 448
423 449 $userId = $currentUser->ID;
@@ -435,15 +461,39 @@
435 461 'postcode' => Arr::get($appRequestData, 'postcode', ''),
436 462 ]);
437 463
438 464 // get customer by id
439 - $cachedCustomer = static::getQuery()
465 + static::$currentCustomerRuntimeCache = static::getQuery()
440 466 ->where('id', $customer->id)
441 467 ->with(['billing_address', 'shipping_address'])
442 468 ->first();
443 469
444 - return $cachedCustomer;
470 + return static::$currentCustomerRuntimeCache;
445 471
472 + }
473 +
474 + private static function claimUnlinkedCustomerByEmail(\WP_User $user): ?Customer
475 + {
476 + if (!$user->user_email) {
477 + return null;
478 + }
479 +
480 + $unlinked = Customer::query()->where('email', $user->user_email)
481 + ->unclaimed()
482 + ->orderBy('id', 'ASC')
483 + ->first();
484 +
485 + if (!$unlinked) {
486 + return null;
487 + }
488 +
489 + Customer::query()->where('id', $unlinked->id)->unclaimed()->update(['user_id' => $user->ID]);
490 +
491 + // A concurrent request for the same account may have won the update.
492 + return Customer::query()->where('user_id', $user->ID)
493 + ->orderBy('id', 'ASC')
494 + ->with(['billing_address', 'shipping_address'])
495 + ->first();
446 496 }
447 497
448 498 private static function resolveCustomerName(array $data): array
449 499 {