authorize('customers.manage'); $customer = Customer::query()->where('uuid', $uuid)->firstOrFail(); $this->uuid = $uuid; $this->name = (string) $customer->name; $this->contactName = (string) $customer->contact_name; $this->email = (string) $customer->email; $this->phone = (string) $customer->phone; $this->customerType = (string) $customer->customer_type; $this->vatId = (string) $customer->vat_id; $this->billingStreet = (string) $customer->billing_street; $this->billingPostcode = (string) $customer->billing_postcode; $this->billingCity = (string) $customer->billing_city; $this->billingCountry = (string) $customer->billing_country; $this->locale = (string) $customer->locale; $this->originalEmail = (string) $customer->email; $this->originalVatId = (string) $customer->vat_id; } public function save() { $this->authorize('customers.manage'); // Read again, never trusted from the hydrated property: everything below // is decided against the STORED record. $customer = Customer::query()->where('uuid', $this->uuid)->firstOrFail(); $data = $this->validate([ 'name' => 'required|string|max:255', 'contactName' => 'nullable|string|max:255', 'email' => [ 'required', 'email', 'max:255', // Among customers, ignoring this one. Two customers on one // address would make every lookup that matches by address — // inbound mail, the portal user, the register — ambiguous. Rule::unique('customers', 'email')->ignore($customer->id), ], 'phone' => 'nullable|string|max:64', // Only one of the two real answers may be stored; an empty string // leaves the record as it was. See below for why it is never // cleared back to "nobody asked". 'customerType' => ['nullable', Rule::in(Customer::TYPES)], 'vatId' => 'nullable|string|max:64', 'billingStreet' => 'nullable|string|max:255', 'billingPostcode' => 'nullable|string|max:32', 'billingCity' => 'nullable|string|max:255', 'billingCountry' => 'nullable|string|max:255', 'locale' => ['nullable', Rule::in(self::LOCALES)], ]); $email = strtolower(trim($data['email'])); $emailChanged = $email !== strtolower($customer->email); // R21: an address belonging to an operator may never also belong to a // customer. Checked here rather than left to a unique index, because the // two identities live in different tables and no index spans them. if ($emailChanged && Operator::query()->where('email', $email)->exists()) { $this->addError('email', __('edit_customer.email_is_operator')); return null; } // A `users` row can outlive or predate a matching customer — see // Customer::emailTaken(). One belonging to somebody else must not be // walked into. if ($emailChanged && User::query()->where('email', $email)->whereKeyNot($customer->user_id ?? 0)->exists()) { $this->addError('email', __('edit_customer.email_taken')); return null; } DB::transaction(function () use ($customer, $data, $email, $emailChanged) { $customer->update([ 'name' => trim($data['name']), 'contact_name' => trim((string) $data['contactName']) ?: null, 'email' => $email, 'phone' => trim((string) $data['phone']) ?: null, // Never cleared back to "unrecorded". Once somebody has // answered, the answer stands until it is replaced by the other // one — a form saved with the field blank must not quietly undo // a recorded consumer into an unknown, and that answer decides // whether a withdrawal right exists. 'customer_type' => $data['customerType'] ?: $customer->customer_type, // The verification is bound to the VALUE (see // Customer::hasVerifiedVatId), so a new number is unverified the // moment it is stored. `vat_id_verified_value` is deliberately // left alone: it is the record of what was checked, and clearing // it would destroy the evidence rather than the claim. 'vat_id' => trim((string) $data['vatId']) ?: null, 'billing_street' => trim((string) $data['billingStreet']) ?: null, 'billing_postcode' => trim((string) $data['billingPostcode']) ?: null, 'billing_city' => trim((string) $data['billingCity']) ?: null, 'billing_country' => trim((string) $data['billingCountry']) ?: null, // The composed block, from the same composer the portal uses — // one shape for one field, whichever side writes it. An address // that was never split keeps the block it had. 'billing_address' => \App\Livewire\Settings::composeAddress($data) ?: $customer->billing_address, 'locale' => $data['locale'] ?: null, ]); if (! $emailChanged || $customer->user_id === null) { return; } // The portal login moves with it, or the customer is locked out of // an account that no longer matches their record. And the new // address is unconfirmed by definition — nobody has shown they can // read it — so it goes back to unverified, which is what Laravel's // own profile update does for the same reason. No mail is sent from // here: the portal asks them to confirm on their next sign-in, and a // mail an operator did not knowingly send is a surprise. User::query()->whereKey($customer->user_id)->update([ 'email' => $email, 'email_verified_at' => null, ]); }); $this->dispatch('notify', message: $this->message($emailChanged, $data)); return $this->redirectRoute('admin.customer', ['uuid' => $this->uuid], navigate: true); } /** * One message, and the consequence if there is one. * * Both go through the same toast and the second would replace the first, so * the sentence that matters has to be the only one. * * @param array $data */ private function message(bool $emailChanged, array $data): string { if ($emailChanged) { return __('edit_customer.saved_email_changed'); } if (trim((string) $data['vatId']) !== trim($this->originalVatId)) { return __('edit_customer.saved_vat_unverified'); } return __('edit_customer.saved'); } public function render() { return view('livewire.admin.edit-customer', [ 'types' => Customer::TYPES, 'locales' => self::LOCALES, ]); } }