CluPilotCloud/app/Livewire/Admin/EditCustomer.php

230 lines
9.4 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 = '';
/**
* The address, field by field — the same four the customer's own settings
* page writes. One free-text block here and four fields there would mean an
* operator's correction being silently reverted the next time the customer
* saved their profile: their fields still held the old values.
*/
public string $billingStreet = '';
public string $billingPostcode = '';
public string $billingCity = '';
public string $billingCountry = '';
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->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<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,
]);
}
}