| @@ -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 | { |