205 lines
8.0 KiB
PHP
205 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Operator;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\Rule;
|
|
use LivewireUI\Modal\ModalComponent;
|
|
|
|
/**
|
|
* Correct a customer's own details.
|
|
*
|
|
* The console could show them and not touch them, so a moved office or a new
|
|
* contact person meant either impersonating the customer or editing the database
|
|
* by hand. Both are worse than a form.
|
|
*
|
|
* R20: a modal, not the row. And a modal is reachable WITHOUT the page's route
|
|
* middleware, so it authorises again and reads the record itself rather than
|
|
* trusting anything the browser hydrated.
|
|
*
|
|
* ## What is not here, and why
|
|
*
|
|
* `status` — suspending or reactivating is its own action on the customer list,
|
|
* with its own consequences for access; a lifecycle switch hidden among address
|
|
* fields is one somebody flips by accident.
|
|
*
|
|
* `stripe_customer_id` — Stripe's own identifier for them. Typing a different
|
|
* one does not move a contract; it points our records at somebody else's
|
|
* payments.
|
|
*
|
|
* The brand fields — the customer sets those for themselves in the portal, and
|
|
* an operator overwriting somebody's logo is not a correction.
|
|
*/
|
|
class EditCustomer extends ModalComponent
|
|
{
|
|
/** The two languages this application actually has. Empty = as configured. */
|
|
public const LOCALES = ['de', 'en'];
|
|
|
|
public string $uuid = '';
|
|
|
|
public string $name = '';
|
|
|
|
public string $contactName = '';
|
|
|
|
public string $email = '';
|
|
|
|
public string $phone = '';
|
|
|
|
public string $customerType = '';
|
|
|
|
public string $vatId = '';
|
|
|
|
public string $billingAddress = '';
|
|
|
|
public string $locale = '';
|
|
|
|
/** The address the record had when the modal opened, to notice a change. */
|
|
public string $originalEmail = '';
|
|
|
|
public string $originalVatId = '';
|
|
|
|
public function mount(string $uuid): void
|
|
{
|
|
$this->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->billingAddress = (string) $customer->billing_address;
|
|
$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',
|
|
'billingAddress' => 'nullable|string|max:2000',
|
|
'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_address' => trim((string) $data['billingAddress']) ?: null,
|
|
'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<string, mixed> $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,
|
|
]);
|
|
}
|
|
}
|