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