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')); +});