sameEmail($shoppingUser->billing_email, $newEmail); } /** * A customer without a real address yet (placeholder) has no login to lose, so only * replacing a real address needs the customer's confirmation. */ public function requiresConfirmation(ShoppingUser $shoppingUser, ?string $newEmail): bool { return ! $this->hasPlaceholderEmail($shoppingUser) && $this->changesAddress($shoppingUser, $newEmail); } /** * Customer sovereignty: the e-mail is the main indicator who a customer belongs to. A * consultant may not take over an address that already belongs to an active consultant * account or to a customer of another consultant. * * @return self::CONFLICT_*|null */ public function ownershipConflict(ShoppingUser $shoppingUser, string $newEmail): ?string { $newEmail = trim($newEmail); if (User::where('email', $newEmail)->where('active', 1)->exists()) { return self::CONFLICT_CONSULTANT_ACCOUNT; } return $this->customersOfOtherConsultants($shoppingUser, $newEmail)->isNotEmpty() ? self::CONFLICT_OTHER_CONSULTANT : null; } /** * Records a change rejected because of customer sovereignty, so it stays visible in the * admin overview until handled, and informs the customer sovereignty inbox. */ public function reportConflict(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null, ?ShoppingUserEmailChange $change = null): ShoppingUserEmailChange { $newEmail = trim($newEmail); $owners = $this->customersOfOtherConsultants($shoppingUser, $newEmail) ->map(fn (ShoppingUser $customer) => $customer->member?->email) ->filter() ->unique() ->values() ->all(); $attributes = ['token_hash' => null, 'blocked_at' => now()]; if ($change) { $change->forceFill($attributes)->save(); } else { $change = ShoppingUserEmailChange::create($attributes + [ 'shopping_user_id' => $shoppingUser->id, 'number' => $shoppingUser->number, 'old_email' => $shoppingUser->billing_email, 'new_email' => $newEmail, 'requested_by_user_id' => $requestedBy, ]); } Log::warning('CustomerEmailChange: Änderung wegen Kundenhoheit abgelehnt', [ 'shopping_user_id' => $shoppingUser->id, 'number' => $shoppingUser->number, 'new_email' => $newEmail, 'requested_by_user_id' => $requestedBy, 'owners' => $owners, ]); Mail::to(config('app.priority_mail'))->send(new CustomerEmailChangeBlockedMail( $shoppingUser, $newEmail, $requestedBy ? User::find($requestedBy)?->email : null, $owners, )); return $change; } public function resolveBlocked(ShoppingUserEmailChange $change, ?int $resolvedBy = null): void { $change->forceFill(['resolved_at' => now(), 'resolved_by_user_id' => $resolvedBy])->save(); } /** * Withdraws an open request; the link in the mail stops working. */ public function discard(ShoppingUserEmailChange $change): void { $change->forceFill(['token_hash' => null, 'expires_at' => now()])->save(); } /** * Starts the confirmation for a new address and sends the link to it. Older open * requests of the same customer are replaced. */ public function request(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null): ShoppingUserEmailChange { $newEmail = trim($newEmail); $token = Str::random(48); ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete(); $change = ShoppingUserEmailChange::create([ 'shopping_user_id' => $shoppingUser->id, 'number' => $shoppingUser->number, 'old_email' => $shoppingUser->billing_email, 'new_email' => $newEmail, 'token_hash' => ShoppingUserEmailChange::hashToken($token), 'expires_at' => now()->addHours(self::TTL_HOURS), 'requested_by_user_id' => $requestedBy, ]); Mail::to($newEmail) ->locale($shoppingUser->language ?: 'de') ->send(new CustomerEmailChangeMail($shoppingUser, $token)); return $change; } public function findConfirmable(string $token): ?ShoppingUserEmailChange { $change = ShoppingUserEmailChange::where('token_hash', ShoppingUserEmailChange::hashToken($token))->first(); return $change && $change->isConfirmable() ? $change : null; } /** * Activates the requested address. Fails when the customer's address was changed in * the meantime (an outdated link must not overwrite a newer address) or when the new * address has meanwhile been assigned to another consultant (customer sovereignty). * * @return self::STATUS_* */ public function confirm(ShoppingUserEmailChange $change): string { $shoppingUser = $change->shopping_user; if (! $change->isConfirmable() || ! $shoppingUser || ! $this->sameEmail($shoppingUser->billing_email, $change->old_email)) { return self::STATUS_INVALID; } if ($this->ownershipConflict($shoppingUser, $change->new_email)) { $this->reportConflict($shoppingUser, $change->new_email, $change->requested_by_user_id, $change); return self::STATUS_CONFLICT; } DB::transaction(function () use ($change, $shoppingUser) { $this->apply($shoppingUser, $change->old_email, $change->new_email); $change->forceFill(['confirmed_at' => now(), 'token_hash' => null])->save(); }); return self::STATUS_CONFIRMED; } /** * Direct change without confirmation (admin). Logged like a confirmed request. */ public function changeDirectly(ShoppingUser $shoppingUser, string $newEmail, ?int $changedBy = null): void { $oldEmail = $shoppingUser->billing_email; $newEmail = trim($newEmail); DB::transaction(function () use ($shoppingUser, $oldEmail, $newEmail, $changedBy) { $this->apply($shoppingUser, $oldEmail, $newEmail); ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete(); ShoppingUserEmailChange::create([ 'shopping_user_id' => $shoppingUser->id, 'number' => $shoppingUser->number, 'old_email' => $oldEmail, 'new_email' => $newEmail, 'confirmed_at' => now(), 'requested_by_user_id' => $changedBy, ]); }); } public function pendingFor(ShoppingUser $shoppingUser): ?ShoppingUserEmailChange { return ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->latest('id')->first(); } /** * Moves all entries of the customer that still carry the old address, plus their * customer abos, to the new address. * * @return array{shopping_users: int, abos: int} */ private function apply(ShoppingUser $shoppingUser, string $oldEmail, string $newEmail): array { $shoppingUserIds = ShoppingUser::query() ->where('billing_email', $oldEmail) ->when( $shoppingUser->number > 0, fn ($query) => $query->where('number', $shoppingUser->number), fn ($query) => $query->whereKey($shoppingUser->id), ) ->pluck('id') ->push($shoppingUser->id) ->unique() ->values(); $updatedShoppingUsers = ShoppingUser::whereIn('id', $shoppingUserIds)->update(['billing_email' => $newEmail]); $updatedAbos = UserAbo::query() ->whereIn('shopping_user_id', $shoppingUserIds) ->where('is_for', 'ot') ->where('email', $oldEmail) ->update(['email' => $newEmail]); $shoppingUser->billing_email = $newEmail; Log::info('CustomerEmailChange: E-Mail geändert', [ 'shopping_user_id' => $shoppingUser->id, 'number' => $shoppingUser->number, 'old_email' => $oldEmail, 'new_email' => $newEmail, 'shopping_users' => $updatedShoppingUsers, 'abos' => $updatedAbos, ]); return ['shopping_users' => $updatedShoppingUsers, 'abos' => $updatedAbos]; } /** * Customers (with a customer number) of other consultants that use the address. * * @return Collection */ private function customersOfOtherConsultants(ShoppingUser $shoppingUser, string $email): Collection { return ShoppingUser::query() ->with('member') ->whereNull('auth_user_id') ->whereNotNull('number') ->whereNotNull('member_id') ->when($shoppingUser->member_id, fn ($query) => $query->where('member_id', '!=', $shoppingUser->member_id)) ->where('billing_email', $email) ->withoutGdprDeleted() ->get(); } private function hasPlaceholderEmail(ShoppingUser $shoppingUser): bool { return (bool) $shoppingUser->faker_mail || blank($shoppingUser->billing_email) || str_ends_with((string) $shoppingUser->billing_email, '-faker@mivita.care'); } private function sameEmail(?string $a, ?string $b): bool { return mb_strtolower(trim((string) $a)) === mb_strtolower(trim((string) $b)); } }