Take the address by field, fix the customer type, ask why they leave

**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 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 16:41:32 +02:00
parent 865fd16f58
commit 1056dddc62
18 changed files with 547 additions and 49 deletions

View File

@ -52,7 +52,19 @@ class EditCustomer extends ModalComponent
public string $vatId = ''; 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 = ''; public string $locale = '';
@ -74,7 +86,10 @@ class EditCustomer extends ModalComponent
$this->phone = (string) $customer->phone; $this->phone = (string) $customer->phone;
$this->customerType = (string) $customer->customer_type; $this->customerType = (string) $customer->customer_type;
$this->vatId = (string) $customer->vat_id; $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->locale = (string) $customer->locale;
$this->originalEmail = (string) $customer->email; $this->originalEmail = (string) $customer->email;
$this->originalVatId = (string) $customer->vat_id; $this->originalVatId = (string) $customer->vat_id;
@ -104,7 +119,10 @@ class EditCustomer extends ModalComponent
// cleared back to "nobody asked". // cleared back to "nobody asked".
'customerType' => ['nullable', Rule::in(Customer::TYPES)], 'customerType' => ['nullable', Rule::in(Customer::TYPES)],
'vatId' => 'nullable|string|max:64', '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)], '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 // left alone: it is the record of what was checked, and clearing
// it would destroy the evidence rather than the claim. // it would destroy the evidence rather than the claim.
'vat_id' => trim((string) $data['vatId']) ?: null, '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, 'locale' => $data['locale'] ?: null,
]); ]);

View File

@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Instance; use App\Models\Instance;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\StripeClient; use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -46,6 +47,12 @@ class ConfirmCancelPackage extends ModalComponent
public string $confirmName = ''; 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() public function cancelPackage()
{ {
$customer = $this->customer(); $customer = $this->customer();
@ -68,6 +75,22 @@ class ConfirmCancelPackage extends ModalComponent
return; 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); $contract = $this->contractFor($customer, $instance);
if (! $this->stopBilling($contract)) { if (! $this->stopBilling($contract)) {
@ -86,7 +109,11 @@ class ConfirmCancelPackage extends ModalComponent
// half of the application can tell a cancelled contract from an untouched // half of the application can tell a cancelled contract from an untouched
// one — and the status deliberately stays `active`, because until the term // one — and the status deliberately stays `active`, because until the term
// runs out this is a paying customer. // 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); return $this->redirectRoute('settings', navigate: true);
} }
@ -200,10 +227,26 @@ class ConfirmCancelPackage extends ModalComponent
public function render() 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', [ return view('livewire.confirm-cancel-package', [
'subdomain' => $instance?->subdomain ?? '', 'subdomain' => $instance?->subdomain ?? '',
'reasons' => Subscription::CANCEL_REASONS,
'canWithdraw' => $withdrawal->applies && $withdrawal->open,
'withdrawalEndsAt' => $withdrawal->endsAt,
]); ]);
} }
} }

View File

@ -174,8 +174,29 @@ class Settings extends Component
#[Validate('nullable|string|max:64')] #[Validate('nullable|string|max:64')]
public string $vatId = ''; 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 // Branding
#[Validate('nullable|string|max:255')] #[Validate('nullable|string|max:255')]
@ -210,7 +231,10 @@ class Settings extends Component
$this->phone = $c->phone ?? ''; $this->phone = $c->phone ?? '';
$this->customerType = $c->customer_type ?? ''; $this->customerType = $c->customer_type ?? '';
$this->vatId = $c->vat_id ?? ''; $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->brandDisplayName = $c->brand_display_name ?? '';
$this->brandPrimary = $c->brand_primary_color ?? ''; $this->brandPrimary = $c->brand_primary_color ?? '';
$this->brandAccent = $c->brand_accent_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. // who never touched this field — but no third value can ever get in.
'customerType' => ['nullable', Rule::in(Customer::TYPES)], 'customerType' => ['nullable', Rule::in(Customer::TYPES)],
'vatId' => 'nullable|string|max:64', '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([ $c->update([
'name' => $data['companyName'], 'name' => $data['companyName'],
'contact_name' => $data['contactName'] ?: null, 'contact_name' => $data['contactName'] ?: null,
'phone' => $data['phone'] ?: null, 'phone' => $data['phone'] ?: null,
// Never cleared back to "unrecorded" from here. Once somebody has // FIXED once it is on record, and this is the line that fixes it.
// answered, the answer stands until it is replaced by the other one: //
// a form submitted with the field blank must not quietly undo a // It decides the fourteen-day right of withdrawal, and a business
// recorded consumer into an unknown. // that could set itself to "Privatperson" here would be a business
'customer_type' => $data['customerType'] ?: $c->customer_type, // 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, '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)); $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<string, mixed> $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. * Check the number against the register, and say what came back.
* *

View File

@ -117,6 +117,7 @@ class Customer extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'name', 'contact_name', 'email', 'customer_type', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address', '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', 'locale', 'stripe_customer_id', 'status', 'closed_at',
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
]; ];

View File

@ -33,6 +33,31 @@ class Subscription extends Model
protected $guarded = []; 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 protected static function booted(): void
{ {
static::updating(function (self $subscription) { static::updating(function (self $subscription) {

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The billing address, as fields rather than as a paragraph.
*
* It was one textarea, so what landed in it was whatever somebody typed: a
* postcode on the street line, a city with no postcode at all, an address with
* the country missing. An invoice has to name a recipient precisely enough to be
* a document, and a free-text block cannot be checked, cannot be sorted, and
* cannot be handed to anything that expects a country.
*
* ## The old column stays, and stays authoritative for what is in it
*
* `billing_address` is not dropped and not 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 the same reason IssueInvoice carries
* it as lines today. It becomes the COMPOSED form instead: whenever the fields
* are saved, the block is rewritten from them, so invoices, the console's
* customer page and everything else that reads it keep working untouched. A
* record nobody has re-saved keeps the block it always had.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Why a customer left.
*
* The cancellation recorded WHEN and nothing else, so the one thing worth
* knowing about a departure whether it was the price, a missing feature, or a
* competitor existed only in whatever the customer happened to write to
* support afterwards, which most do not.
*
* On the contract rather than on the instance: a contract is what is cancelled,
* and it is where the withdrawal is already recorded (withdrawal_channel and the
* rest). The reason is a fixed key, the note is what they typed.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -20,6 +20,12 @@ return [
'contact_name' => 'Ansprechpartner', 'contact_name' => 'Ansprechpartner',
'phone' => 'Telefon', 'phone' => 'Telefon',
'vat_id' => 'USt-IdNr.', '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', 'billing_address' => 'Rechnungsadresse',
'profile_saved' => 'Firmendaten gespeichert.', '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_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_export' => 'Zum Laufzeitende erhalten Sie einen vollständigen Datenexport.',
'cancel_point_irreversible' => 'Die Kündigung ist nach Bestätigung verbindlich.', '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_label' => 'Zum Bestätigen „:name" eingeben:',
'cancel_confirm' => 'Verbindlich kündigen', 'cancel_confirm' => 'Verbindlich kündigen',
'cancel_mismatch' => 'Die Eingabe stimmt nicht mit Ihrer Cloud-Adresse überein.', 'cancel_mismatch' => 'Die Eingabe stimmt nicht mit Ihrer Cloud-Adresse überein.',

View File

@ -11,6 +11,8 @@
return [ return [
'card_title' => 'Widerrufsrecht', '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.', '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', 'cta' => 'Widerruf erklären',
'keep' => 'Abbrechen', 'keep' => 'Abbrechen',
'confirm' => 'Widerruf erklären', 'confirm' => 'Widerruf erklären',

View File

@ -20,6 +20,12 @@ return [
'contact_name' => 'Contact person', 'contact_name' => 'Contact person',
'phone' => 'Phone', 'phone' => 'Phone',
'vat_id' => 'VAT ID', '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', 'billing_address' => 'Billing address',
'profile_saved' => 'Company details saved.', '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_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_export' => 'At the end of the term you receive a full data export.',
'cancel_point_irreversible' => 'Once confirmed, the cancellation is binding.', '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_label' => 'Type “:name” to confirm:',
'cancel_confirm' => 'Cancel for good', 'cancel_confirm' => 'Cancel for good',
'cancel_mismatch' => 'That does not match your cloud address.', 'cancel_mismatch' => 'That does not match your cloud address.',

View File

@ -11,6 +11,8 @@
return [ return [
'card_title' => 'Right of withdrawal', '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.', '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', 'cta' => 'Declare withdrawal',
'keep' => 'Cancel', 'keep' => 'Cancel',
'confirm' => 'Declare withdrawal', 'confirm' => 'Declare withdrawal',

View File

@ -42,10 +42,15 @@
</div> </div>
<div> <div>
<label for="billingAddress" class="block text-sm font-medium text-body">{{ __('edit_customer.address') }}</label> <p class="block text-sm font-medium text-body">{{ __('edit_customer.address') }}</p>
<textarea id="billingAddress" rows="4" wire:model="billingAddress" <div class="mt-1.5 space-y-3">
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink"></textarea> <x-ui.input name="billingStreet" wire:model="billingStreet" :label="__('settings.billing_street')" />
@error('billingAddress')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror <div class="grid gap-3 sm:grid-cols-[140px_minmax(0,1fr)]">
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :label="__('settings.billing_postcode')" />
<x-ui.input name="billingCity" wire:model="billingCity" :label="__('settings.billing_city')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
</div>
</div> </div>
<div class="max-w-56"> <div class="max-w-56">

View File

@ -15,9 +15,50 @@
<li class="flex items-start gap-2"><x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />{{ __('settings.cancel_point_irreversible') }}</li> <li class="flex items-start gap-2"><x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />{{ __('settings.cancel_point_irreversible') }}</li>
</ul> </ul>
{{-- 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)
<div class="mt-4 rounded border border-accent-border bg-accent-subtle p-4">
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.instead_title') }}</p>
<p class="mt-1 text-xs leading-relaxed text-body">
{{ __('withdrawal.instead_body', ['date' => $withdrawalEndsAt?->local()->isoFormat('LL')]) }}
</p>
<x-ui.button variant="secondary" size="sm" class="mt-3"
x-on:click="Livewire.dispatch('closeModal'); $nextTick(() => $dispatch('openModal', { component: 'confirm-withdraw' }))">
{{ __('withdrawal.cta') }}
</x-ui.button>
</div>
@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. --}}
<div class="mt-4">
<label class="text-sm text-body" for="cancel-reason">{{ __('settings.cancel_reason_label') }}</label>
<select id="cancel-reason" wire:model.live="reason"
class="mt-1.5 w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink">
<option value="">{{ __('settings.cancel_reason_choose') }}</option>
@foreach ($reasons as $key)
<option value="{{ $key }}">{{ __('settings.cancel_reason.'.$key) }}</option>
@endforeach
</select>
@error('reason')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-3">
<label class="text-sm text-body" for="cancel-note">
{{ $reason === 'other' ? __('settings.cancel_note_label_required') : __('settings.cancel_note_label') }}
</label>
<textarea id="cancel-note" wire:model="note" rows="2"
class="mt-1.5 w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
@error('note')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-4"> <div class="mt-4">
<label class="text-sm text-body">{{ __('settings.cancel_confirm_label', ['name' => $subdomain]) }}</label> <label class="text-sm text-body">{{ __('settings.cancel_confirm_label', ['name' => $subdomain]) }}</label>
<input type="text" wire:model="confirmName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" /> <input type="text" wire:model="confirmName" class="mt-1.5 w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('confirmName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror @error('confirmName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div> </div>

View File

@ -56,11 +56,20 @@
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" /> <x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
</div> </div>
<div> {{-- Field by field, not a paragraph: what landed in the
<label class="text-sm font-medium text-body" for="billingAddress">{{ __('settings.billing_address') }}</label> textarea was whatever somebody typed a postcode on the
<textarea id="billingAddress" wire:model="billingAddress" rows="3" street line, a city with no postcode, no country at all
class="mt-1.5 w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea> and an invoice has to name its recipient precisely enough to
@error('billingAddress')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror be a document. Postcode and town share a row because that is
how they are read and written. --}}
<div class="space-y-4">
<p class="text-sm font-medium text-body">{{ __('settings.billing_address') }}</p>
<x-ui.input name="billingStreet" wire:model="billingStreet" :label="__('settings.billing_street')" />
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :label="__('settings.billing_postcode')" />
<x-ui.input name="billingCity" wire:model="billingCity" :label="__('settings.billing_city')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
</div> </div>
<div class="flex justify-end border-t border-line pt-4"> <div class="flex justify-end border-t border-line pt-4">
@ -68,25 +77,40 @@
</div> </div>
</div> </div>
{{-- Beside the form rather than inside it: it is one question, it {{-- Beside the form, and for a record that has one it is a FACT
decides the fourteen-day right of withdrawal and whether reverse rather than a field. It decides the fourteen-day right of
charge can ever apply, and people mistake it for the VAT field withdrawal: a business able to set itself to "Privatperson" here
when the two sit in the same row. Nothing is preselected for a is a business able to withdraw from a contract it may not
customer nobody has asked yet. --}} withdraw from. Registration asks the question; this page reports
<fieldset class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs"> the answer. The lock is in Settings::saveProfile, not here a
<legend class="text-sm font-bold text-ink">{{ __('settings.customer_type') }}</legend> form that only hides a control has never stopped anybody who can
<div class="space-y-2"> post to /livewire/update.
@foreach ($customerTypes as $type)
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink"> A record created before the question existed has no answer yet,
<input type="radio" wire:model="customerType" value="{{ $type }}" and may be given one here, once. --}}
class="size-4 shrink-0 border-line text-ink" /> <div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
{{ __('settings.customer_type_'.$type) }} <p class="text-sm font-bold text-ink">{{ __('settings.customer_type') }}</p>
</label>
@endforeach @if ($customer?->customer_type)
</div> <p class="flex items-center gap-2 text-sm font-semibold text-ink">
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_hint') }}</p> <x-ui.icon name="lock" class="size-4 text-muted" />
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror {{ __('settings.customer_type_'.$customer->customer_type) }}
</fieldset> </p>
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_locked') }}</p>
@else
<div class="space-y-2">
@foreach ($customerTypes as $type)
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="customerType" value="{{ $type }}"
class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.customer_type_'.$type) }}
</label>
@endforeach
</div>
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_once') }}</p>
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
@endif
</div>
</form> </form>
@endif @endif

View File

@ -40,7 +40,9 @@ it('saves the details an operator corrected', function () {
->set('name', 'Beispiel GmbH & Co KG') ->set('name', 'Beispiel GmbH & Co KG')
->set('contactName', 'Bernd Berger') ->set('contactName', 'Bernd Berger')
->set('phone', '+43 1 999') ->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') ->set('locale', 'en')
->call('save') ->call('save')
->assertHasNoErrors(); ->assertHasNoErrors();

View File

@ -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) Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger') ->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage') ->call('cancelPackage')
->assertHasNoErrors(); ->assertHasNoErrors();
@ -147,6 +148,7 @@ it('keeps a monthly customer their month and no longer', function () {
Livewire::actingAs($user)->test(ConfirmCancelPackage::class) Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger') ->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage') ->call('cancelPackage')
->assertHasNoErrors(); ->assertHasNoErrors();
@ -171,6 +173,7 @@ it('cancels a granted package cleanly and asks Stripe nothing at all', function
Livewire::actingAs($user)->test(ConfirmCancelPackage::class) Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger') ->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage') ->call('cancelPackage')
->assertHasNoErrors(); ->assertHasNoErrors();
@ -188,6 +191,7 @@ it('records nothing at all when Stripe cannot be told to stop', function () {
Livewire::actingAs($user)->test(ConfirmCancelPackage::class) Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'berger') ->set('confirmName', 'berger')
->set('reason', 'no_longer_needed')
->call('cancelPackage') ->call('cancelPackage')
->assertHasErrors(['confirmName']); ->assertHasErrors(['confirmName']);

View File

@ -186,6 +186,7 @@ it('carries the whole way from the customer cancelling to the address going away
Livewire::actingAs($user)->test(ConfirmCancelPackage::class) Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', $instance->subdomain) ->set('confirmName', $instance->subdomain)
->set('reason', 'no_longer_needed')
->call('cancelPackage'); ->call('cancelPackage');
$instance->refresh(); $instance->refresh();

View File

@ -62,14 +62,66 @@ it('saves the company profile', function () {
Livewire::actingAs($user)->test(Settings::class) Livewire::actingAs($user)->test(Settings::class)
->set('companyName', 'Acme GmbH') ->set('companyName', 'Acme GmbH')
->set('vatId', 'ATU12345678') ->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') ->call('saveProfile')
->assertHasNoErrors(); ->assertHasNoErrors();
$customer->refresh(); $customer->refresh();
expect($customer->name)->toBe('Acme GmbH') expect($customer->name)->toBe('Acme GmbH')
->and($customer->vat_id)->toBe('ATU12345678') ->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 () { 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') expect($instance->fresh()->status)->toBe('active')
->and($stripe->cancellations)->toBeEmpty(); ->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) Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'acme') ->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'); ->call('cancelPackage');
$instance->refresh(); $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'))) expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php')))
->not->toContain('max-w-3xl'); ->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'));
});