From 1056dddc625f8dd7e598375ae2794fec32b7e370 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 16:41:32 +0200 Subject: [PATCH] Take the address by field, fix the customer type, ask why they leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The billing address was one textarea**, so what landed in it was whatever somebody typed: a postcode on the street line, a city with no postcode, no country at all. It is four fields now — street, postcode, town, country — in the customer's settings and in the console's customer editor, which had its own free-text copy of the same field and would have had an operator's correction silently reverted the next time the customer saved. `customers.billing_address` is neither dropped nor parsed: guessing which line of an existing block is the street would put a postcode where a street belongs on a document nobody can correct afterwards. It becomes the COMPOSED form, rewritten from the fields on every save, so IssueInvoice and everything else that reads it keep working, and a record nobody has re-saved keeps the block it always had. **The customer type is fixed once it is on record.** It decides the fourteen-day right of withdrawal, and a business able to set itself to "Privatperson" on this page is a business able to withdraw from a contract it may not withdraw from. Registration asks the question; this page reports the answer, and saveProfile refuses to answer it a second time — in the component, not by hiding a radio, because a form that hides a control has never stopped anybody who can post to /livewire/update. A record created before the question existed may still be given one, once. The panel is a fact rather than a form now, which is also what was odd about it, and the sentence about who has the right of withdrawal is gone. **Cancelling asks why.** A reason from a fixed list, required, with a note beside it that "Sonstiges" cannot go without — the one fact worth having about a departure, asked at the only moment the customer is looking at the question. It lands on the contract (`cancel_reason`, `cancel_reason_note`). And where the fourteen days are still running, the dialogue offers the WITHDRAWAL as a way out of itself rather than as one more reason inside it: withdrawing ends the service the same day and sends the whole amount back, cancelling keeps the term that was paid for, and somebody entitled to the first should not lose it by taking the second unasked. A business is never offered it — WithdrawalRight answers that, not the template. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/EditCustomer.php | 33 +++- app/Livewire/ConfirmCancelPackage.php | 47 +++++- app/Livewire/Settings.php | 80 ++++++++-- app/Models/Customer.php | 1 + app/Models/Subscription.php | 25 +++ ...ake_the_billing_address_field_by_field.php | 44 ++++++ ...00_record_why_a_contract_was_cancelled.php | 35 +++++ lang/de/settings.php | 22 +++ lang/de/withdrawal.php | 2 + lang/en/settings.php | 22 +++ lang/en/withdrawal.php | 2 + .../livewire/admin/edit-customer.blade.php | 13 +- .../livewire/confirm-cancel-package.blade.php | 43 +++++- resources/views/livewire/settings.blade.php | 72 ++++++--- tests/Feature/Admin/EditCustomerTest.php | 4 +- .../Billing/PackageCancellationTest.php | 4 + .../Provisioning/EndInstanceServiceTest.php | 1 + tests/Feature/SettingsTest.php | 146 +++++++++++++++++- 18 files changed, 547 insertions(+), 49 deletions(-) create mode 100644 database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php create mode 100644 database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php diff --git a/app/Livewire/Admin/EditCustomer.php b/app/Livewire/Admin/EditCustomer.php index 06e5b47..76ad9e8 100644 --- a/app/Livewire/Admin/EditCustomer.php +++ b/app/Livewire/Admin/EditCustomer.php @@ -52,7 +52,19 @@ class EditCustomer extends ModalComponent public string $vatId = ''; - public string $billingAddress = ''; + /** + * 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 = ''; @@ -74,7 +86,10 @@ class EditCustomer extends ModalComponent $this->phone = (string) $customer->phone; $this->customerType = (string) $customer->customer_type; $this->vatId = (string) $customer->vat_id; - $this->billingAddress = (string) $customer->billing_address; + $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; @@ -104,7 +119,10 @@ class EditCustomer extends ModalComponent // cleared back to "nobody asked". 'customerType' => ['nullable', Rule::in(Customer::TYPES)], 'vatId' => 'nullable|string|max:64', - 'billingAddress' => 'nullable|string|max:2000', + '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)], ]); @@ -147,7 +165,14 @@ class EditCustomer extends ModalComponent // 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, + '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, ]); diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php index f0919e1..3d2a543 100644 --- a/app/Livewire/ConfirmCancelPackage.php +++ b/app/Livewire/ConfirmCancelPackage.php @@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Models\Instance; use App\Models\Subscription; +use App\Services\Billing\WithdrawalRight; use App\Services\Stripe\StripeClient; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; @@ -46,6 +47,12 @@ class ConfirmCancelPackage extends ModalComponent public string $confirmName = ''; + /** Why. One of Subscription::CANCEL_REASONS. */ + public string $reason = ''; + + /** What they wanted to say about it — required when the reason is "other". */ + public string $note = ''; + public function cancelPackage() { $customer = $this->customer(); @@ -68,6 +75,22 @@ class ConfirmCancelPackage extends ModalComponent return; } + // Asked, and required. A departure with no reason on it is the one fact + // about it worth having, and "we can ask them later" means never: the + // customer is here now and never comes back to this form. + if (! in_array($this->reason, Subscription::CANCEL_REASONS, true)) { + $this->addError('reason', __('settings.cancel_reason_required')); + + return; + } + + // "Sonstiges" with nothing beside it is the same as no answer at all. + if ($this->reason === 'other' && trim($this->note) === '') { + $this->addError('note', __('settings.cancel_note_required')); + + return; + } + $contract = $this->contractFor($customer, $instance); if (! $this->stopBilling($contract)) { @@ -86,7 +109,11 @@ class ConfirmCancelPackage extends ModalComponent // half of the application can tell a cancelled contract from an untouched // one — and the status deliberately stays `active`, because until the term // runs out this is a paying customer. - $contract?->update(['cancel_requested_at' => now()]); + $contract?->update([ + 'cancel_requested_at' => now(), + 'cancel_reason' => $this->reason, + 'cancel_reason_note' => trim($this->note) ?: null, + ]); return $this->redirectRoute('settings', navigate: true); } @@ -200,10 +227,26 @@ class ConfirmCancelPackage extends ModalComponent public function render() { - $instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first(); + $customer = $this->customer(); + $instance = $customer?->instances()->where('status', 'active')->latest('id')->first(); + + // Whether withdrawing is still open to them. Offered as a way OUT of + // this dialogue rather than as a reason inside it: a withdrawal is a + // different act with a different consequence — the whole amount back and + // the service ending the same day — and somebody who is entitled to one + // should not cancel by mistake and lose it. WithdrawalRight answers for + // a business customer as well: it says no. + // contractFor() needs an instance; with none there is nothing to + // withdraw from either, and WithdrawalRight answers null with "no". + $withdrawal = WithdrawalRight::for( + $instance === null ? null : $this->contractFor($customer, $instance) + ); return view('livewire.confirm-cancel-package', [ 'subdomain' => $instance?->subdomain ?? '', + 'reasons' => Subscription::CANCEL_REASONS, + 'canWithdraw' => $withdrawal->applies && $withdrawal->open, + 'withdrawalEndsAt' => $withdrawal->endsAt, ]); } } diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index a3a00ad..1842afb 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -174,8 +174,29 @@ class Settings extends Component #[Validate('nullable|string|max:64')] public string $vatId = ''; - #[Validate('nullable|string|max:2000')] - public string $billingAddress = ''; + /** + * The billing address, field by field. + * + * It was one textarea, so what landed in it was whatever somebody typed — a + * postcode on the street line, a city with no postcode, no country at all. + * An invoice has to name its recipient precisely enough to be a document. + * + * `customers.billing_address` still exists and is written from these on + * every save: everything that already reads it (IssueInvoice, the console's + * customer page) keeps working, and a record nobody has re-saved keeps the + * block it always had rather than being guessed apart. + */ + #[Validate('nullable|string|max:255')] + public string $billingStreet = ''; + + #[Validate('nullable|string|max:32')] + public string $billingPostcode = ''; + + #[Validate('nullable|string|max:255')] + public string $billingCity = ''; + + #[Validate('nullable|string|max:255')] + public string $billingCountry = ''; // Branding #[Validate('nullable|string|max:255')] @@ -210,7 +231,10 @@ class Settings extends Component $this->phone = $c->phone ?? ''; $this->customerType = $c->customer_type ?? ''; $this->vatId = $c->vat_id ?? ''; - $this->billingAddress = $c->billing_address ?? ''; + $this->billingStreet = $c->billing_street ?? ''; + $this->billingPostcode = $c->billing_postcode ?? ''; + $this->billingCity = $c->billing_city ?? ''; + $this->billingCountry = $c->billing_country ?? ''; $this->brandDisplayName = $c->brand_display_name ?? ''; $this->brandPrimary = $c->brand_primary_color ?? ''; $this->brandAccent = $c->brand_accent_color ?? ''; @@ -234,25 +258,61 @@ class Settings extends Component // who never touched this field — but no third value can ever get in. 'customerType' => ['nullable', Rule::in(Customer::TYPES)], 'vatId' => 'nullable|string|max:64', - 'billingAddress' => 'nullable|string|max:2000', + 'billingStreet' => 'nullable|string|max:255', + 'billingPostcode' => 'nullable|string|max:32', + 'billingCity' => 'nullable|string|max:255', + 'billingCountry' => 'nullable|string|max:255', ]); $c->update([ 'name' => $data['companyName'], 'contact_name' => $data['contactName'] ?: null, 'phone' => $data['phone'] ?: null, - // Never cleared back to "unrecorded" from here. Once somebody has - // answered, the answer stands until it is replaced by the other one: - // a form submitted with the field blank must not quietly undo a - // recorded consumer into an unknown. - 'customer_type' => $data['customerType'] ?: $c->customer_type, + // FIXED once it is on record, and this is the line that fixes it. + // + // It decides the fourteen-day right of withdrawal, and a business + // that could set itself to "Privatperson" here would be a business + // that can withdraw from a contract it has no right to withdraw + // from. That is why registration asks the question and why this form + // cannot answer it a second time. A record that has none — created + // before the question existed — may still be given one, once. + 'customer_type' => $c->customer_type ?: ($data['customerType'] ?: null), 'vat_id' => $data['vatId'] ?: null, - 'billing_address' => $data['billingAddress'] ?: null, + 'billing_street' => $data['billingStreet'] ?: null, + 'billing_postcode' => $data['billingPostcode'] ?: null, + 'billing_city' => $data['billingCity'] ?: null, + 'billing_country' => $data['billingCountry'] ?: null, + // The composed block, rewritten from the fields. Everything that + // reads an address today reads this one. + 'billing_address' => self::composeAddress($data) ?: $c->billing_address, ]); $this->dispatch('notify', message: $this->verifyVatId($c)); } + /** + * The four fields as the block every reader already expects. + * + * Street, then postcode and city on one line, then the country — the shape + * an address takes on an Austrian invoice. Empty in, empty out: a customer + * who has filled in nothing must not be given a document with two blank + * lines and a comma on it. + * + * @param array $data + */ + public static function composeAddress(array $data): string + { + $city = trim(trim((string) ($data['billingPostcode'] ?? '')).' '.trim((string) ($data['billingCity'] ?? ''))); + + $lines = array_filter([ + trim((string) ($data['billingStreet'] ?? '')), + $city, + trim((string) ($data['billingCountry'] ?? '')), + ], fn (string $line) => $line !== ''); + + return implode("\n", $lines); + } + /** * Check the number against the register, and say what came back. * diff --git a/app/Models/Customer.php b/app/Models/Customer.php index da61ee5..8c300c4 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -117,6 +117,7 @@ class Customer extends Model protected $fillable = [ 'user_id', 'name', 'contact_name', 'email', 'customer_type', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address', + 'billing_street', 'billing_postcode', 'billing_city', 'billing_country', 'locale', 'stripe_customer_id', 'status', 'closed_at', 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', ]; diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 0163a6c..e3765bd 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -33,6 +33,31 @@ class Subscription extends Model protected $guarded = []; + /** + * Why a customer cancelled, as a fixed list. + * + * A free-text box alone answers nothing that can be counted, and "sonstiges" + * with a sentence beside it is worth more than eight boxes nobody ticks + * honestly. Keys, not sentences: the wording lives in the settings language + * files and can be rewritten without rewriting what is on record. (Written + * that way on purpose — a path with a star and a slash in it ends the + * docblock it is standing in.) + * + * `withdrawal` is deliberately NOT here. Withdrawing is a different act with + * a different consequence — the whole amount back and the service ending the + * same day — and it is recorded by WithdrawContract, not by a cancellation + * carrying a reason that says it was one. + */ + public const CANCEL_REASONS = [ + 'too_expensive', + 'missing_features', + 'switching_provider', + 'no_longer_needed', + 'performance', + 'support', + 'other', + ]; + protected static function booted(): void { static::updating(function (self $subscription) { diff --git a/database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php b/database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php new file mode 100644 index 0000000..9597400 --- /dev/null +++ b/database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php @@ -0,0 +1,44 @@ +string('billing_street')->nullable()->after('billing_address'); + $table->string('billing_postcode', 32)->nullable()->after('billing_street'); + $table->string('billing_city')->nullable()->after('billing_postcode'); + $table->string('billing_country')->nullable()->after('billing_city'); + }); + } + + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn(['billing_street', 'billing_postcode', 'billing_city', 'billing_country']); + }); + } +}; diff --git a/database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php b/database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php new file mode 100644 index 0000000..97123b7 --- /dev/null +++ b/database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php @@ -0,0 +1,35 @@ +string('cancel_reason', 32)->nullable()->after('cancel_requested_at'); + $table->text('cancel_reason_note')->nullable()->after('cancel_reason'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn(['cancel_reason', 'cancel_reason_note']); + }); + } +}; diff --git a/lang/de/settings.php b/lang/de/settings.php index 3cd6a1f..fc58f40 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -20,6 +20,12 @@ return [ 'contact_name' => 'Ansprechpartner', 'phone' => 'Telefon', 'vat_id' => 'USt-IdNr.', + 'billing_street' => 'Straße und Hausnummer', + 'billing_postcode' => 'PLZ', + 'billing_city' => 'Ort', + 'billing_country' => 'Land', + 'customer_type_locked' => 'Bei der Registrierung angegeben. Eine Änderung nehmen wir nur nach Rückfrage vor — schreiben Sie uns.', + 'customer_type_once' => 'Diese Angabe ist noch offen. Sie lässt sich einmal setzen und danach nur noch über den Support ändern.', 'billing_address' => 'Rechnungsadresse', 'profile_saved' => 'Firmendaten gespeichert.', @@ -55,6 +61,22 @@ return [ 'cancel_point_term' => 'Wirksam zum Ende der Laufzeit — bis dahin bleibt alles verfügbar.', 'cancel_point_export' => 'Zum Laufzeitende erhalten Sie einen vollständigen Datenexport.', 'cancel_point_irreversible' => 'Die Kündigung ist nach Bestätigung verbindlich.', + 'cancel_reason_label' => 'Warum kündigen Sie?', + 'cancel_reason_choose' => 'Bitte wählen', + 'cancel_reason_required' => 'Bitte wählen Sie einen Grund.', + 'cancel_note_label' => 'Möchten Sie uns etwas dazu sagen? (optional)', + 'cancel_note_label_required' => 'Bitte sagen Sie uns kurz, worum es geht.', + 'cancel_note_required' => 'Bei „Sonstiges" brauchen wir eine kurze Angabe.', + // The keys live in Subscription::CANCEL_REASONS; only the wording is here. + 'cancel_reason' => [ + 'too_expensive' => 'Zu teuer', + 'missing_features' => 'Funktionen fehlen', + 'switching_provider' => 'Wechsel zu einem anderen Anbieter', + 'no_longer_needed' => 'Wird nicht mehr gebraucht', + 'performance' => 'Geschwindigkeit oder Verfügbarkeit', + 'support' => 'Unzufrieden mit der Betreuung', + 'other' => 'Sonstiges', + ], 'cancel_confirm_label' => 'Zum Bestätigen „:name" eingeben:', 'cancel_confirm' => 'Verbindlich kündigen', 'cancel_mismatch' => 'Die Eingabe stimmt nicht mit Ihrer Cloud-Adresse überein.', diff --git a/lang/de/withdrawal.php b/lang/de/withdrawal.php index d0daa58..5262552 100644 --- a/lang/de/withdrawal.php +++ b/lang/de/withdrawal.php @@ -11,6 +11,8 @@ return [ 'card_title' => 'Widerrufsrecht', 'card_sub' => 'Sie können diesen Vertrag noch bis zum :date widerrufen — :days Tag(e). Der Dienst endet dann sofort und Sie erhalten den gesamten gezahlten Betrag zurück.', + 'instead_title' => 'Sie können noch widerrufen', + 'instead_body' => 'Bis :date können Sie stattdessen vom Vertrag zurücktreten: Sie erhalten den gesamten gezahlten Betrag zurück, die Cloud endet dann allerdings sofort statt zum Ende der bezahlten Periode.', 'cta' => 'Widerruf erklären', 'keep' => 'Abbrechen', 'confirm' => 'Widerruf erklären', diff --git a/lang/en/settings.php b/lang/en/settings.php index 803d952..158611a 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -20,6 +20,12 @@ return [ 'contact_name' => 'Contact person', 'phone' => 'Phone', 'vat_id' => 'VAT ID', + 'billing_street' => 'Street and number', + 'billing_postcode' => 'Postcode', + 'billing_city' => 'Town', + 'billing_country' => 'Country', + 'customer_type_locked' => 'Given at registration. We only change it after asking — write to us.', + 'customer_type_once' => 'Not answered yet. It can be set once and changed afterwards only through support.', 'billing_address' => 'Billing address', 'profile_saved' => 'Company details saved.', @@ -55,6 +61,22 @@ return [ 'cancel_point_term' => 'Effective at the end of the term — everything stays available until then.', 'cancel_point_export' => 'At the end of the term you receive a full data export.', 'cancel_point_irreversible' => 'Once confirmed, the cancellation is binding.', + 'cancel_reason_label' => 'Why are you cancelling?', + 'cancel_reason_choose' => 'Please choose', + 'cancel_reason_required' => 'Please choose a reason.', + 'cancel_note_label' => 'Anything you would like to tell us? (optional)', + 'cancel_note_label_required' => 'Please tell us briefly what it is about.', + 'cancel_note_required' => 'With "other" we need a short note.', + // The keys live in Subscription::CANCEL_REASONS; only the wording is here. + 'cancel_reason' => [ + 'too_expensive' => 'Too expensive', + 'missing_features' => 'Missing features', + 'switching_provider' => 'Moving to another provider', + 'no_longer_needed' => 'No longer needed', + 'performance' => 'Speed or availability', + 'support' => 'Unhappy with the support', + 'other' => 'Other', + ], 'cancel_confirm_label' => 'Type “:name” to confirm:', 'cancel_confirm' => 'Cancel for good', 'cancel_mismatch' => 'That does not match your cloud address.', diff --git a/lang/en/withdrawal.php b/lang/en/withdrawal.php index a73b82a..cbdea95 100644 --- a/lang/en/withdrawal.php +++ b/lang/en/withdrawal.php @@ -11,6 +11,8 @@ return [ 'card_title' => 'Right of withdrawal', 'card_sub' => 'You may withdraw from this contract until :date — :days day(s) left. The service ends immediately and you get the full amount paid back.', + 'instead_title' => 'You can still withdraw', + 'instead_body' => 'Until :date you may withdraw from the contract instead: you get the whole amount back, but the cloud then ends at once rather than at the end of the paid period.', 'cta' => 'Declare withdrawal', 'keep' => 'Cancel', 'confirm' => 'Declare withdrawal', diff --git a/resources/views/livewire/admin/edit-customer.blade.php b/resources/views/livewire/admin/edit-customer.blade.php index 433b065..0934a5a 100644 --- a/resources/views/livewire/admin/edit-customer.blade.php +++ b/resources/views/livewire/admin/edit-customer.blade.php @@ -42,10 +42,15 @@
- - - @error('billingAddress')

{{ $message }}

@enderror +

{{ __('edit_customer.address') }}

+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/confirm-cancel-package.blade.php b/resources/views/livewire/confirm-cancel-package.blade.php index 4aac027..4158b72 100644 --- a/resources/views/livewire/confirm-cancel-package.blade.php +++ b/resources/views/livewire/confirm-cancel-package.blade.php @@ -15,9 +15,50 @@
  • {{ __('settings.cancel_point_irreversible') }}
  • + {{-- Still inside the fourteen days: offered as a way OUT of this dialogue, + not as one more reason inside it. Withdrawing ends the service the same + day and sends the WHOLE amount back; cancelling keeps the term that was + paid for. Somebody entitled to the first should not lose it by taking + the second without being told. --}} + @if ($canWithdraw) +
    +

    {{ __('withdrawal.instead_title') }}

    +

    + {{ __('withdrawal.instead_body', ['date' => $withdrawalEndsAt?->local()->isoFormat('LL')]) }} +

    + + {{ __('withdrawal.cta') }} + +
    + @endif + + {{-- Why. Asked here because this is the only moment the customer is looking + at the question; afterwards they are gone and nobody asks again. --}} +
    + + + @error('reason')

    {{ $message }}

    @enderror +
    + +
    + + + @error('note')

    {{ $message }}

    @enderror +
    +
    - + @error('confirmName')

    {{ $message }}

    @enderror
    diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index f1f8aef..2b1d584 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -56,11 +56,20 @@
    -
    - - - @error('billingAddress')

    {{ $message }}

    @enderror + {{-- Field by field, not a paragraph: what landed in the + textarea was whatever somebody typed — a postcode on the + street line, a city with no postcode, no country at all — + and an invoice has to name its recipient precisely enough to + be a document. Postcode and town share a row because that is + how they are read and written. --}} +
    +

    {{ __('settings.billing_address') }}

    + +
    + + +
    +
    @@ -68,25 +77,40 @@
    - {{-- Beside the form rather than inside it: it is one question, it - decides the fourteen-day right of withdrawal and whether reverse - charge can ever apply, and people mistake it for the VAT field - when the two sit in the same row. Nothing is preselected for a - customer nobody has asked yet. --}} -
    - {{ __('settings.customer_type') }} -
    - @foreach ($customerTypes as $type) - - @endforeach -
    -

    {{ __('settings.customer_type_hint') }}

    - @error('customerType')

    {{ $message }}

    @enderror -
    + {{-- Beside the form, and for a record that has one it is a FACT + rather than a field. It decides the fourteen-day right of + withdrawal: a business able to set itself to "Privatperson" here + is a business able to withdraw from a contract it may not + withdraw from. Registration asks the question; this page reports + the answer. The lock is in Settings::saveProfile, not here — a + form that only hides a control has never stopped anybody who can + post to /livewire/update. + + A record created before the question existed has no answer yet, + and may be given one here, once. --}} +
    +

    {{ __('settings.customer_type') }}

    + + @if ($customer?->customer_type) +

    + + {{ __('settings.customer_type_'.$customer->customer_type) }} +

    +

    {{ __('settings.customer_type_locked') }}

    + @else +
    + @foreach ($customerTypes as $type) + + @endforeach +
    +

    {{ __('settings.customer_type_once') }}

    + @error('customerType')

    {{ $message }}

    @enderror + @endif +
    @endif diff --git a/tests/Feature/Admin/EditCustomerTest.php b/tests/Feature/Admin/EditCustomerTest.php index ba29643..aa5e406 100644 --- a/tests/Feature/Admin/EditCustomerTest.php +++ b/tests/Feature/Admin/EditCustomerTest.php @@ -40,7 +40,9 @@ it('saves the details an operator corrected', function () { ->set('name', 'Beispiel GmbH & Co KG') ->set('contactName', 'Bernd Berger') ->set('phone', '+43 1 999') - ->set('billingAddress', "Neue Gasse 9\n1020 Wien") + ->set('billingStreet', 'Neue Gasse 9') + ->set('billingPostcode', '1020') + ->set('billingCity', 'Wien') ->set('locale', 'en') ->call('save') ->assertHasNoErrors(); diff --git a/tests/Feature/Billing/PackageCancellationTest.php b/tests/Feature/Billing/PackageCancellationTest.php index 1d75a3e..277901e 100644 --- a/tests/Feature/Billing/PackageCancellationTest.php +++ b/tests/Feature/Billing/PackageCancellationTest.php @@ -113,6 +113,7 @@ it('tells Stripe to stop at the end of the term and keeps a yearly customer thei Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') + ->set('reason', 'no_longer_needed') ->call('cancelPackage') ->assertHasNoErrors(); @@ -147,6 +148,7 @@ it('keeps a monthly customer their month and no longer', function () { Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') + ->set('reason', 'no_longer_needed') ->call('cancelPackage') ->assertHasNoErrors(); @@ -171,6 +173,7 @@ it('cancels a granted package cleanly and asks Stripe nothing at all', function Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') + ->set('reason', 'no_longer_needed') ->call('cancelPackage') ->assertHasNoErrors(); @@ -188,6 +191,7 @@ it('records nothing at all when Stripe cannot be told to stop', function () { Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') + ->set('reason', 'no_longer_needed') ->call('cancelPackage') ->assertHasErrors(['confirmName']); diff --git a/tests/Feature/Provisioning/EndInstanceServiceTest.php b/tests/Feature/Provisioning/EndInstanceServiceTest.php index c8b2e42..79182bc 100644 --- a/tests/Feature/Provisioning/EndInstanceServiceTest.php +++ b/tests/Feature/Provisioning/EndInstanceServiceTest.php @@ -186,6 +186,7 @@ it('carries the whole way from the customer cancelling to the address going away Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', $instance->subdomain) + ->set('reason', 'no_longer_needed') ->call('cancelPackage'); $instance->refresh(); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index 0061bdf..e8971bb 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -62,14 +62,66 @@ it('saves the company profile', function () { Livewire::actingAs($user)->test(Settings::class) ->set('companyName', 'Acme GmbH') ->set('vatId', 'ATU12345678') - ->set('billingAddress', "Hauptstraße 1\n1010 Wien") + ->set('billingStreet', 'Hauptstraße 1') + ->set('billingPostcode', '1010') + ->set('billingCity', 'Wien') + ->set('billingCountry', 'Österreich') ->call('saveProfile') ->assertHasNoErrors(); $customer->refresh(); expect($customer->name)->toBe('Acme GmbH') ->and($customer->vat_id)->toBe('ATU12345678') - ->and($customer->billing_address)->toContain('Wien'); + ->and($customer->billing_city)->toBe('Wien') + ->and($customer->billing_postcode)->toBe('1010') + // And the composed block every existing reader uses: street, then + // postcode and town on one line, then the country. + ->and($customer->billing_address)->toBe("Hauptstraße 1\n1010 Wien\nÖsterreich"); +}); + +it('never lets a business turn itself into a consumer', function () { + // The fourteen-day right of withdrawal hangs off this field. A business that + // could set itself to "Privatperson" here is a business that can withdraw + // from a contract it may not withdraw from — which is why registration asks + // the question and why this form cannot answer it a second time. + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); + + Livewire::actingAs($user)->test(Settings::class) + ->set('customerType', App\Models\Customer::TYPE_CONSUMER) + ->call('saveProfile') + ->assertHasNoErrors(); + + expect($customer->fresh()->customer_type)->toBe(App\Models\Customer::TYPE_BUSINESS); +}); + +it('lets a record created before the question was asked answer it once', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => null]); + + Livewire::actingAs($user)->test(Settings::class) + ->set('customerType', App\Models\Customer::TYPE_BUSINESS) + ->call('saveProfile'); + + expect($customer->fresh()->customer_type)->toBe(App\Models\Customer::TYPE_BUSINESS); + + // And then it is fixed like every other. + Livewire::actingAs($user)->test(Settings::class) + ->set('customerType', App\Models\Customer::TYPE_CONSUMER) + ->call('saveProfile'); + + expect($customer->fresh()->customer_type)->toBe(App\Models\Customer::TYPE_BUSINESS); +}); + +it('shows a recorded type as a fact, with no way to change it', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); + + $page = Livewire::actingAs($user)->test(Settings::class)->html(); + + expect($page)->toContain(__('settings.customer_type_locked')) + // No radio to press. + ->and($page)->not->toContain('wire:model="customerType"'); }); it('saves branding and resolves defaults when unset', function () { @@ -128,9 +180,19 @@ it('schedules package cancellation only with the correct typed confirmation', fu expect($instance->fresh()->status)->toBe('active') ->and($stripe->cancellations)->toBeEmpty(); - // Correct confirmation → scheduled. + // Correct confirmation and a reason → scheduled. The reason is required: + // a departure with none on it loses the one fact about it worth having, + // and "we can ask later" means never. Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'acme') + ->call('cancelPackage') + ->assertHasErrors(['reason']); + + expect($instance->fresh()->status)->toBe('active'); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', 'acme') + ->set('reason', 'too_expensive') ->call('cancelPackage'); $instance->refresh(); @@ -237,3 +299,81 @@ it('uses the width it is given', function () { expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php'))) ->not->toContain('max-w-3xl'); }); + +it('records why, and refuses "other" with nothing beside it', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $instance = $customer->instances()->where('status', 'active')->first(); + $contract = App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + ]); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', $instance->subdomain) + ->set('reason', 'other') + ->call('cancelPackage') + ->assertHasErrors(['note']); + + expect($instance->fresh()->status)->toBe('active'); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', $instance->subdomain) + ->set('reason', 'other') + ->set('note', 'Wir ziehen intern um.') + ->call('cancelPackage'); + + expect($contract->fresh()->cancel_reason)->toBe('other') + ->and($contract->fresh()->cancel_reason_note)->toBe('Wir ziehen intern um.'); +}); + +it('takes no reason it was not offered', function () { + // The select is a select; the request is a request. + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $instance = $customer->instances()->where('status', 'active')->first(); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', $instance->subdomain) + ->set('reason', 'weil-ich-es-kann') + ->call('cancelPackage') + ->assertHasErrors(['reason']); + + expect($instance->fresh()->status)->toBe('active'); +}); + +it('offers the withdrawal as a way out of the cancellation, while it is open', function () { + // Withdrawing ends the service the same day and sends the WHOLE amount + // back; cancelling keeps the term that was paid for. Somebody entitled to + // the first should not lose it by taking the second unasked. + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_CONSUMER]); + $instance = $customer->instances()->where('status', 'active')->first(); + App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + 'started_at' => now()->subDay(), + 'withdrawal_ends_at' => now()->addDays(13), + ]); + + $page = Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html(); + + expect($page)->toContain(__('withdrawal.instead_title')) + ->and($page)->toContain('confirm-withdraw'); +}); + +it('never offers a business the withdrawal it does not have', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); + $instance = $customer->instances()->where('status', 'active')->first(); + App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + 'started_at' => now()->subDay(), + 'withdrawal_ends_at' => now()->addDays(13), + ]); + + expect(Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html()) + ->not->toContain(__('withdrawal.instead_title')); +});