Let an operator correct a customer's details
The console could show them and not touch them, so a moved office or a new contact person meant impersonating the customer or editing the database by hand. Both are worse than a form. R20: a modal, opened from the Stammdaten tab, authorising again and reading the record itself rather than trusting anything the browser hydrated. Most of the work is in what saving must NOT quietly do. The customer type is never reset to "nobody asked". The field opens on "leave unchanged" precisely so that saving an address correction cannot undo it — that answer decides whether a withdrawal right exists, and losing it silently would be the expensive kind of quiet. A changed VAT number is unverified from the moment it is stored — the verification is bound to the value, so that happens by itself — but `vat_id_verified_value` is left alone: it is the record of what WAS checked, and clearing it would destroy the evidence rather than the claim. The address is also the sign-in address and the key inbound mail is matched by, so changing it moves the linked portal login in the same transaction and puts it back to unverified, which is what Laravel's own profile update does for the same reason: nobody has shown they can read the new address. No mail is sent from here — the portal asks for the confirmation at the next sign-in, and a mail an operator did not knowingly send is a surprise. Three addresses are refused: one belonging to an operator (R21 — no index spans the two tables, so it is checked), one belonging to another customer, and one belonging to somebody else's user row, which can outlive or predate a matching customer. Not offered here, each for its own reason: `status`, because suspending has consequences for access and a lifecycle switch hidden among address fields is one somebody flips by accident; `stripe_customer_id`, because a wrong one does not move a contract, it points our records at somebody else's payments; the brand fields, because the customer sets those for themselves and an operator overwriting a logo is not a correction. There is a test that fails if any of them appears — with the comments stripped first, since the class documents at length why they are absent and that documentation is not the offence. This repository has fallen into that trap three times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus v1.3.41
parent
57c6912987
commit
f00cbf18d3
|
|
@ -0,0 +1,204 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ return [
|
|||
|
||||
// Stammdaten
|
||||
'identity' => 'Kunde',
|
||||
'edit' => 'Bearbeiten',
|
||||
'company' => 'Firma',
|
||||
'contact' => 'Ansprechpartner',
|
||||
'email' => 'E-Mail',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
// Die Stammdaten eines Kunden berichtigen. Die Konsole konnte sie bis jetzt nur
|
||||
// anzeigen — ein Umzug oder ein neuer Ansprechpartner hieß: als Kunde anmelden
|
||||
// oder in der Datenbank schreiben. Beides ist schlechter als ein Formular.
|
||||
return [
|
||||
'title' => 'Stammdaten bearbeiten',
|
||||
'subtitle' => 'Was der Kunde selbst im Kundenbereich ändern kann — und was Sie für ihn richtigstellen, wenn er anruft.',
|
||||
|
||||
'name' => 'Firma',
|
||||
'contact' => 'Ansprechpartner',
|
||||
'email' => 'E-Mail',
|
||||
'email_hint' => 'Das ist auch die Anmeldeadresse und der Schlüssel, über den eingehende E-Mails zugeordnet werden. Nach einer Änderung muss der Kunde die neue Adresse beim nächsten Anmelden bestätigen.',
|
||||
'phone' => 'Telefon',
|
||||
|
||||
'type' => 'Kundenart',
|
||||
'type_keep' => 'Unverändert lassen',
|
||||
'type_hint' => 'Entscheidet, ob ein Widerrufsrecht besteht. Eine einmal erfasste Antwort wird hier nie auf „nicht erfasst“ zurückgesetzt — nur durch die andere ersetzt.',
|
||||
'vat_id' => 'UID',
|
||||
'vat_hint' => 'Eine geänderte Nummer gilt als ungeprüft, bis sie erneut geprüft wurde. Was zuletzt geprüft wurde, bleibt als Nachweis erhalten.',
|
||||
|
||||
'address' => 'Rechnungsadresse',
|
||||
'locale' => 'Sprache',
|
||||
'locale_default' => 'Wie eingestellt',
|
||||
'locale_de' => 'Deutsch',
|
||||
'locale_en' => 'Englisch',
|
||||
'locale_hint' => 'In dieser Sprache bekommt der Kunde seine E-Mails.',
|
||||
|
||||
'save' => 'Speichern',
|
||||
'cancel' => 'Abbrechen',
|
||||
|
||||
'saved' => 'Stammdaten gespeichert.',
|
||||
'saved_email_changed' => 'Gespeichert. Die Anmeldeadresse wurde mitgeändert; der Kunde muss sie beim nächsten Anmelden bestätigen.',
|
||||
'saved_vat_unverified' => 'Gespeichert. Die geänderte UID gilt als ungeprüft, bis sie erneut geprüft wurde.',
|
||||
|
||||
'email_is_operator' => 'Diese Adresse gehört zu einem Betreiber-Zugang. Kunde und Betreiber dürfen sich keine Adresse teilen.',
|
||||
'email_taken' => 'Diese Adresse ist schon einem anderen Zugang zugeordnet.',
|
||||
];
|
||||
|
|
@ -15,6 +15,7 @@ return [
|
|||
|
||||
// Details
|
||||
'identity' => 'Customer',
|
||||
'edit' => 'Edit',
|
||||
'company' => 'Company',
|
||||
'contact' => 'Contact',
|
||||
'email' => 'Mail',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
// Correcting a customer's own details. The console could only show them until
|
||||
// now — a moved office or a new contact person meant impersonating the customer
|
||||
// or editing the database by hand. Both are worse than a form.
|
||||
return [
|
||||
'title' => 'Edit customer details',
|
||||
'subtitle' => 'What the customer can change themselves in the portal — and what you put right for them when they call.',
|
||||
|
||||
'name' => 'Company',
|
||||
'contact' => 'Contact',
|
||||
'email' => 'Mail',
|
||||
'email_hint' => 'This is also the sign-in address and the key incoming mail is matched by. After a change the customer has to confirm the new address at their next sign-in.',
|
||||
'phone' => 'Phone',
|
||||
|
||||
'type' => 'Customer type',
|
||||
'type_keep' => 'Leave unchanged',
|
||||
'type_hint' => 'Decides whether a withdrawal right exists. An answer already on record is never reset to "not recorded" here — only replaced by the other one.',
|
||||
'vat_id' => 'VAT ID',
|
||||
'vat_hint' => 'A changed number counts as unverified until it has been checked again. What was last checked is kept as evidence.',
|
||||
|
||||
'address' => 'Billing address',
|
||||
'locale' => 'Language',
|
||||
'locale_default' => 'As configured',
|
||||
'locale_de' => 'German',
|
||||
'locale_en' => 'English',
|
||||
'locale_hint' => 'The language the customer receives mail in.',
|
||||
|
||||
'save' => 'Save',
|
||||
'cancel' => 'Cancel',
|
||||
|
||||
'saved' => 'Details saved.',
|
||||
'saved_email_changed' => 'Saved. The sign-in address moved with it; the customer has to confirm it at their next sign-in.',
|
||||
'saved_vat_unverified' => 'Saved. The changed VAT ID counts as unverified until it has been checked again.',
|
||||
|
||||
'email_is_operator' => 'That address belongs to an operator account. A customer and an operator must not share one.',
|
||||
'email_taken' => 'That address already belongs to another account.',
|
||||
];
|
||||
|
|
@ -52,8 +52,23 @@
|
|||
{{-- ══ Stammdaten ═══════════════════════════════════════════════════════ --}}
|
||||
@if ($tab === 'overview')
|
||||
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<h2 class="font-semibold text-ink">{{ __('customer_detail.identity') }}</h2>
|
||||
{{-- `editing` is local: fetching the modal is a round trip, and a
|
||||
click with nothing to show for it reads as a click that missed. --}}
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
|
||||
x-data="{ editing: false }"
|
||||
x-on:modal-opened.window="editing = false"
|
||||
x-on:modalclosed.window="editing = false">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="font-semibold text-ink">{{ __('customer_detail.identity') }}</h2>
|
||||
{{-- R20: editing happens in a modal, never in the row. --}}
|
||||
<x-ui.button variant="secondary" size="sm" class="ml-auto"
|
||||
x-bind:disabled="editing"
|
||||
x-on:click="editing = true; setTimeout(() => editing = false, 6000); $dispatch('openModal', { component: 'admin.edit-customer', arguments: { uuid: '{{ $customer->uuid }}' } })">
|
||||
<x-ui.icon name="pencil" class="size-4" x-show="!editing" />
|
||||
<x-ui.icon name="refresh" class="size-4 animate-spin" x-show="editing" x-cloak />
|
||||
{{ __('customer_detail.edit') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
@foreach ([
|
||||
__('customer_detail.company') => $customer->name,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('edit_customer.title') }}</h2>
|
||||
<p class="mt-1 max-w-[60ch] text-sm text-muted">{{ __('edit_customer.subtitle') }}</p>
|
||||
|
||||
<form wire:submit="save" class="mt-5 space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('edit_customer.name')" />
|
||||
<x-ui.input name="contactName" wire:model="contactName" :label="__('edit_customer.contact')" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<x-ui.input name="email" type="email" wire:model="email" :label="__('edit_customer.email')" />
|
||||
{{-- Said before it is done, not after: the address is the portal
|
||||
login and the key everything matching by address uses. --}}
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('edit_customer.email_hint') }}</p>
|
||||
</div>
|
||||
<x-ui.input name="phone" wire:model="phone" :label="__('edit_customer.phone')" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="customerType">{{ __('edit_customer.type') }}</label>
|
||||
<select id="customerType" wire:model="customerType"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
{{-- No "nobody asked" option: once somebody has answered, the
|
||||
answer stands until it is replaced by the other one. An
|
||||
empty selection here leaves the record as it was, and the
|
||||
hint says which that is. --}}
|
||||
<option value="">{{ __('edit_customer.type_keep') }}</option>
|
||||
@foreach ($types as $type)
|
||||
<option value="{{ $type }}">{{ __('customer_detail.type_'.$type) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('edit_customer.type_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-ui.input name="vatId" wire:model="vatId" :label="__('edit_customer.vat_id')" placeholder="ATU12345678" />
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('edit_customer.vat_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billingAddress" class="block text-sm font-medium text-body">{{ __('edit_customer.address') }}</label>
|
||||
<textarea id="billingAddress" rows="4" wire:model="billingAddress"
|
||||
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink"></textarea>
|
||||
@error('billingAddress')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="max-w-56">
|
||||
<label class="text-sm font-medium text-body" for="locale">{{ __('edit_customer.locale') }}</label>
|
||||
<select id="locale" wire:model="locale"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
<option value="">{{ __('edit_customer.locale_default') }}</option>
|
||||
@foreach ($locales as $code)
|
||||
<option value="{{ $code }}">{{ __('edit_customer.locale_'.$code) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
{{-- What it actually decides: the language of the mail they get. --}}
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('edit_customer.locale_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 border-t border-line pt-4">
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
|
||||
{{ __('edit_customer.save') }}
|
||||
</x-ui.button>
|
||||
{{-- Livewire.dispatch, not Alpine's: the modal listens for a Livewire
|
||||
event of that name, and the Alpine form silently does nothing. --}}
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">
|
||||
{{ __('edit_customer.cancel') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\CustomerDetail;
|
||||
use App\Livewire\Admin\EditCustomer;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Correcting a customer's own details from the console.
|
||||
*
|
||||
* It could show them and not touch them, so a moved office meant impersonating
|
||||
* the customer or editing the database by hand. Most of what is tested here is
|
||||
* not the saving — it is what saving must NOT quietly do.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->customer = Customer::factory()->create([
|
||||
'name' => 'Beispiel GmbH',
|
||||
'contact_name' => 'Bea Berger',
|
||||
'email' => 'bea@beispiel.test',
|
||||
'phone' => '+43 1 111',
|
||||
'customer_type' => Customer::TYPE_BUSINESS,
|
||||
'vat_id' => 'ATU11111111',
|
||||
'vat_id_verified_at' => now(),
|
||||
'vat_id_verified_value' => 'ATU11111111',
|
||||
'billing_address' => "Alte Gasse 1\n1010 Wien",
|
||||
]);
|
||||
});
|
||||
|
||||
/** The modal, mounted on this customer. */
|
||||
function editing(Customer $customer, string $role = 'Owner')
|
||||
{
|
||||
return Livewire::actingAs(operator($role), 'operator')
|
||||
->test(EditCustomer::class, ['uuid' => $customer->uuid]);
|
||||
}
|
||||
|
||||
it('saves the details an operator corrected', function () {
|
||||
editing($this->customer)
|
||||
->set('name', 'Beispiel GmbH & Co KG')
|
||||
->set('contactName', 'Bernd Berger')
|
||||
->set('phone', '+43 1 999')
|
||||
->set('billingAddress', "Neue Gasse 9\n1020 Wien")
|
||||
->set('locale', 'en')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$fresh = $this->customer->fresh();
|
||||
|
||||
expect($fresh->name)->toBe('Beispiel GmbH & Co KG')
|
||||
->and($fresh->contact_name)->toBe('Bernd Berger')
|
||||
->and($fresh->phone)->toBe('+43 1 999')
|
||||
->and($fresh->billing_address)->toContain('1020 Wien')
|
||||
->and($fresh->locale)->toBe('en');
|
||||
});
|
||||
|
||||
it('never resets a recorded customer type back to nobody asked', function () {
|
||||
// That answer decides whether a withdrawal right exists. A form saved with
|
||||
// the field untouched must not quietly undo it — and the field opens on
|
||||
// "leave unchanged", so saving anything else would do exactly that.
|
||||
editing($this->customer)->set('customerType', '')->call('save');
|
||||
|
||||
expect($this->customer->fresh()->customer_type)->toBe(Customer::TYPE_BUSINESS);
|
||||
});
|
||||
|
||||
it('records the other answer when that is what was chosen', function () {
|
||||
editing($this->customer)->set('customerType', Customer::TYPE_CONSUMER)->call('save');
|
||||
|
||||
expect($this->customer->fresh()->isConsumer())->toBeTrue();
|
||||
});
|
||||
|
||||
it('treats a changed VAT number as unverified, and keeps what was checked', function () {
|
||||
// The verification is bound to the VALUE, so a new number is unverified the
|
||||
// moment it is stored. The record of what WAS checked stays: clearing it
|
||||
// would destroy the evidence rather than the claim.
|
||||
editing($this->customer)->set('vatId', 'ATU22222222')->call('save');
|
||||
|
||||
$fresh = $this->customer->fresh();
|
||||
|
||||
expect($fresh->vat_id)->toBe('ATU22222222')
|
||||
->and($fresh->hasVerifiedVatId())->toBeFalse()
|
||||
->and($fresh->vat_id_verified_value)->toBe('ATU11111111');
|
||||
});
|
||||
|
||||
it('leaves a verification alone when the number was not touched', function () {
|
||||
editing($this->customer)->set('phone', '+43 1 222')->call('save');
|
||||
|
||||
expect($this->customer->fresh()->hasVerifiedVatId())->toBeTrue();
|
||||
});
|
||||
|
||||
// ---- The address, which is also the login ----
|
||||
|
||||
it('moves the portal login with the address and asks for it to be confirmed', function () {
|
||||
// Otherwise 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.
|
||||
$user = User::factory()->create(['email' => 'bea@beispiel.test', 'email_verified_at' => now()]);
|
||||
$this->customer->update(['user_id' => $user->id]);
|
||||
|
||||
editing($this->customer->fresh())->set('email', 'neu@beispiel.test')->call('save')->assertHasNoErrors();
|
||||
|
||||
expect($this->customer->fresh()->email)->toBe('neu@beispiel.test')
|
||||
->and($user->fresh()->email)->toBe('neu@beispiel.test')
|
||||
->and($user->fresh()->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an address that belongs to an operator', function () {
|
||||
// R21: a customer and an operator must never share an address. No index
|
||||
// spans the two tables, so this is checked rather than left to the database.
|
||||
$operator = operator('Support');
|
||||
|
||||
editing($this->customer)
|
||||
->set('email', $operator->email)
|
||||
->call('save')
|
||||
->assertHasErrors('email');
|
||||
|
||||
expect($this->customer->fresh()->email)->toBe('bea@beispiel.test');
|
||||
});
|
||||
|
||||
it('refuses an address that belongs to another customer', function () {
|
||||
// Two customers on one address makes every lookup that matches by address —
|
||||
// inbound mail, the portal user, the register — ambiguous.
|
||||
Customer::factory()->create(['email' => 'schon@da.test']);
|
||||
|
||||
editing($this->customer)
|
||||
->set('email', 'schon@da.test')
|
||||
->call('save')
|
||||
->assertHasErrors('email');
|
||||
|
||||
expect($this->customer->fresh()->email)->toBe('bea@beispiel.test');
|
||||
});
|
||||
|
||||
it('refuses an address that belongs to somebody else user account', function () {
|
||||
User::factory()->create(['email' => 'fremd@example.test']);
|
||||
|
||||
editing($this->customer)
|
||||
->set('email', 'fremd@example.test')
|
||||
->call('save')
|
||||
->assertHasErrors('email');
|
||||
});
|
||||
|
||||
it('lets a customer keep their own address while something else is corrected', function () {
|
||||
// The unique rule has to ignore this record, or nothing else on the form can
|
||||
// ever be saved.
|
||||
editing($this->customer)->set('name', 'Anderer Name')->call('save')->assertHasNoErrors();
|
||||
|
||||
expect($this->customer->fresh()->name)->toBe('Anderer Name');
|
||||
});
|
||||
|
||||
it('lowercases the address, because that is how every lookup asks for it', function () {
|
||||
editing($this->customer)->set('email', 'GROSS@Beispiel.test')->call('save');
|
||||
|
||||
expect($this->customer->fresh()->email)->toBe('gross@beispiel.test');
|
||||
});
|
||||
|
||||
// ---- The rules around it ----
|
||||
|
||||
it('refuses an empty company name rather than storing one', function () {
|
||||
editing($this->customer)->set('name', '')->call('save')->assertHasErrors('name');
|
||||
|
||||
expect($this->customer->fresh()->name)->toBe('Beispiel GmbH');
|
||||
});
|
||||
|
||||
it('takes no language this application does not have', function () {
|
||||
editing($this->customer)->set('locale', 'fr')->call('save')->assertHasErrors('locale');
|
||||
});
|
||||
|
||||
it('keeps the form to operators who may manage customers', function () {
|
||||
Livewire::actingAs(operator('Read-only'), 'operator')
|
||||
->test(EditCustomer::class, ['uuid' => $this->customer->uuid])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('is reached from the customer page through openModal, not a page method', function () {
|
||||
// R20: an edit button that calls a method on the page component is the inline
|
||||
// editor coming back under a new name.
|
||||
$page = File::get(resource_path('views/livewire/admin/customer-detail.blade.php'));
|
||||
|
||||
expect($page)->toContain("component: 'admin.edit-customer'");
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
|
||||
->assertSee(__('customer_detail.edit'));
|
||||
});
|
||||
|
||||
it('does not offer to change what an operator must not change here', function () {
|
||||
// status has its own action with its own consequences for access;
|
||||
// stripe_customer_id points at somebody else's payments if it is wrong; the
|
||||
// brand fields are the customer's own.
|
||||
// Comments stripped first. The class documents at length WHY each of these
|
||||
// is absent, and reading that documentation as the offence is a trap this
|
||||
// repository has fallen into three times — see the @vite, prefers-color-scheme
|
||||
// and clupilot.cloud scans.
|
||||
$code = implode('', array_map(
|
||||
fn ($token) => is_array($token)
|
||||
? (in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true) ? '' : $token[1])
|
||||
: $token,
|
||||
token_get_all(File::get(app_path('Livewire/Admin/EditCustomer.php'))),
|
||||
));
|
||||
|
||||
expect($code)->not->toContain("'status'")
|
||||
->not->toContain('stripe_customer_id')
|
||||
->not->toContain('brand_logo_path')
|
||||
// And the fields it DOES write are the ones it is meant to.
|
||||
->toContain("'billing_address'")
|
||||
->toContain("'customer_type'");
|
||||
});
|
||||
Loading…
Reference in New Issue