| @@ -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; |
| @@ -135,13 +136,17 @@ | ||
| 135 | 136 | $email = Arr::get($data, 'email'); |
| 136 | 137 | $data = static::resolveCustomerName($data); |
| 137 | 138 | |
| 138 | 139 | $data['purchase_value'] = []; |
| 139 | - $customer = static::getQuery()->firstOrCreate( | |
| 140 | - ['email' => $email], | |
| 141 | - $data | |
| 142 | - ); | |
| 143 | 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 | + | |
| 144 | 149 | if (empty($customer)) { |
| 145 | 150 | return static::makeErrorResponse([ |
| 146 | 151 | ['code' => 400, 'message' => __('Customer creation failed.', 'fluent-cart')] |
| 147 | 152 | ]); |
| @@ -146,13 +151,22 @@ | ||
| 146 | 151 | ['code' => 400, 'message' => __('Customer creation failed.', 'fluent-cart')] |
| 147 | 152 | ]); |
| 148 | 153 | } |
| 149 | 154 | |
| 150 | - $isUserAttached = false; | |
| 151 | - $user = get_user_by('email', $email); | |
| 152 | - if ($user) { | |
| 153 | - $customer->update(['user_id' => $user->ID]); | |
| 154 | - $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 | + } | |
| 155 | 169 | } |
| 156 | 170 | |
| 157 | 171 | if (Arr::get($data, 'wp_user') === 'yes' && !$isUserAttached) { |
| 158 | 172 | $isUserCreated = \FluentCart\App\Services\AuthService::createUserFromCustomer($customer); |
| @@ -410,29 +424,26 @@ | ||
| 410 | 424 | } |
| 411 | 425 | |
| 412 | 426 | $currentUser = get_user_by('ID', get_current_user_id()); |
| 413 | 427 | |
| 414 | - // Try to get the existing customer | |
| 415 | - $query = Customer::query()->where('user_id', $currentUser->ID) | |
| 416 | - ->orWhere('email', $currentUser->user_email) | |
| 417 | - ->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(); | |
| 418 | 433 | |
| 434 | + if (!$existingCustomer && !EmailVerificationService::isEnabled()) { | |
| 435 | + $existingCustomer = static::claimUnlinkedCustomerByEmail($currentUser); | |
| 436 | + } | |
| 419 | 437 | |
| 420 | - $existingCustomer = $query->first(); | |
| 421 | - | |
| 422 | - // Return if found | |
| 423 | 438 | if ($existingCustomer) { |
| 424 | - if ($existingCustomer->user_id != $currentUser->ID) { | |
| 425 | - // Update the user_id if it doesn't match | |
| 426 | - $existingCustomer->user_id = $currentUser->ID; | |
| 427 | - $existingCustomer->save(); | |
| 428 | - } | |
| 429 | - | |
| 430 | 439 | static::$currentCustomerRuntimeCache = $existingCustomer; |
| 431 | 440 | return $existingCustomer; |
| 432 | 441 | } |
| 433 | 442 | |
| 434 | - 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. | |
| 435 | 446 | return null; |
| 436 | 447 | } |
| 437 | 448 | |
| 438 | 449 | $userId = $currentUser->ID; |
| @@ -457,8 +468,32 @@ | ||
| 457 | 468 | ->first(); |
| 458 | 469 | |
| 459 | 470 | return static::$currentCustomerRuntimeCache; |
| 460 | 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(); | |
| 461 | 496 | } |
| 462 | 497 | |
| 463 | 498 | private static function resolveCustomerName(array $data): array |
| 464 | 499 | { |