From fed4acf31ca28a24bd1c6eeb365225da388dc9fc Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 15:22:35 +0200 Subject: [PATCH] Accept terms instead of a start date, and fix the sender no server accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The test mail was rejected: "553 5.7.1 Sender address rejected: not owned by user no-reply@…".** Every purpose mailbox here has its own SMTP account, and a mail server lets an account send only from the address it owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the mailable asked for, so a mail addressed support@ went out over the no-reply@ login. It sends through the mailable's own mailer now, and the cloud-ready preview — the one mailable with no mailbox at all — takes the provisioning mailbox like the real notification does. Writing the test for that found the same bug in production code: **InvoiceMail and OrderConfirmationMail set their From to billing@ and never named a mailer**, so both went out over mail.default's no-reply@ login. On this installation no customer had ever received an invoice mail or an order confirmation; they rendered perfectly, queued without complaint and were refused at the door. MailSenderOwnershipTest now scans for the mismatch: a mail that takes a mailbox From must use that mailbox's mailer. **The box on the order page accepts the terms now**, not an immediate start. It used to carry the whole FAGG §16 sentence, which read like a choice between "now" and "in fourteen days" — and there is no second option. The terms are what regulate the sale, so they had to exist: resources/views/legal/terms.blade.php replaces the placeholder with fourteen sections written from what this software actually does — the delivery, the capacity queue, the full refund on withdrawal, the cancellation at period end, the deletion deadlines. The company data comes from CompanyProfile, so the page and the invoices cannot drift. No availability figure and no liability cap has been invented. No order goes through without it: the button is unusable until the box is ticked and says why, and CheckoutController still refuses server-side — the browser half refuses nothing. The request field is `terms_accepted`; the Stripe metadata key stays `immediate_start`, because a session opened before a deploy is paid after it and the webhook would find nothing under a new name. **"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one. That was the whole of what we said, so the answer looked like yes. The second rule now exists and is stated: PruneDormantAccounts removes a confirmed account after a year when it never had a package — no customer record at all, which is where every order, contract and seven-year invoice hangs. A fortnight's notice goes out first, once, and `dormant_warned_at` is what permits the deletion: an account whose warning never went out is never removed. Signing in resets the clock, measured off the device rows because users has no last_login_at. Both deadlines are said in the portal settings, on the verification page, and in the terms — each reading the number off the command that enforces it. Also: the wordmark scan matched any element whose text merely BEGINS with the company name, which a paragraph of terms does. It looks for the lockup form now (no whitespace after the tag), which is what it was always about. The seven checkout tests in the parallel session's files were posting the old field name and now post the new one. Co-Authored-By: Claude Opus 5 --- app/Console/Commands/PruneDormantAccounts.php | 206 +++++++++++++++ app/Http/Controllers/CheckoutController.php | 28 +- app/Livewire/Admin/MailPreview.php | 28 +- app/Mail/DormantAccountWarningMail.php | 48 ++++ app/Mail/InvoiceMail.php | 11 +- app/Mail/OrderConfirmationMail.php | 11 +- app/Models/User.php | 3 + app/Services/Mail/MailPreviews.php | 18 +- ...rn_before_a_dormant_account_is_removed.php | 31 +++ lang/de/checkout.php | 12 +- lang/de/dormant_mail.php | 14 + lang/de/order.php | 8 +- lang/de/settings.php | 6 + lang/de/verify_email.php | 2 + lang/en/checkout.php | 17 +- lang/en/dormant_mail.php | 12 + lang/en/order.php | 8 +- lang/en/settings.php | 6 + lang/en/verify_email.php | 2 + resources/views/legal.blade.php | 38 +-- resources/views/legal/terms.blade.php | 249 ++++++++++++++++++ .../livewire/admin/mail-preview.blade.php | 12 +- .../livewire/auth/verify-email.blade.php | 5 + resources/views/livewire/order.blade.php | 76 +++--- resources/views/livewire/settings.blade.php | 19 ++ .../views/mail/dormant-warning.blade.php | 34 +++ routes/console.php | 8 + routes/web.php | 6 +- tests/Feature/Admin/MailPreviewTest.php | 49 ++++ tests/Feature/Auth/DormantAccountTest.php | 204 ++++++++++++++ .../Billing/ReverseChargePriceTest.php | 14 +- tests/Feature/Billing/SetupFeeTest.php | 4 +- tests/Feature/MailSenderOwnershipTest.php | 54 ++++ tests/Feature/SelfServiceOrderTest.php | 61 +++-- tests/Feature/SiteDesignSystemTest.php | 8 +- 35 files changed, 1191 insertions(+), 121 deletions(-) create mode 100644 app/Console/Commands/PruneDormantAccounts.php create mode 100644 app/Mail/DormantAccountWarningMail.php create mode 100644 database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php create mode 100644 lang/de/dormant_mail.php create mode 100644 lang/en/dormant_mail.php create mode 100644 resources/views/legal/terms.blade.php create mode 100644 resources/views/mail/dormant-warning.blade.php create mode 100644 tests/Feature/Auth/DormantAccountTest.php create mode 100644 tests/Feature/MailSenderOwnershipTest.php diff --git a/app/Console/Commands/PruneDormantAccounts.php b/app/Console/Commands/PruneDormantAccounts.php new file mode 100644 index 0000000..4a5362c --- /dev/null +++ b/app/Console/Commands/PruneDormantAccounts.php @@ -0,0 +1,206 @@ +option('dry-run'); + + $warned = $this->sendWarnings($dry); + $removed = $this->remove($dry); + + $this->info($dry + ? "would warn {$warned}, would remove {$removed}." + : "{$warned} warned, {$removed} removed."); + + return self::SUCCESS; + } + + /** + * The fortnight's notice. + * + * Not called warn(): Command::warn() is the framework's "print a warning + * line" and overriding it with something private breaks the class outright. + */ + private function sendWarnings(bool $dry): int + { + $due = $this->dormant() + ->whereNull('dormant_warned_at') + ->get() + ->filter(fn (User $user) => $this->dormantSince($user) + ->lte(now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE))); + + foreach ($due as $user) { + if ($dry) { + $this->line('would warn: '.$user->email); + + continue; + } + + try { + Mail::to($user->email)->queue(new DormantAccountWarningMail($user, self::WARN_DAYS_BEFORE)); + } catch (Throwable $e) { + // Not stamped: the stamp is what permits the deletion, and + // stamping a mail that never went out would delete an account + // whose owner was never told. + Log::warning('Could not warn a dormant account', [ + 'email' => $user->email, + 'exception' => $e->getMessage(), + ]); + + continue; + } + + $user->forceFill(['dormant_warned_at' => now()])->save(); + } + + return $due->count(); + } + + /** The deletion itself, a fortnight after the warning at the earliest. */ + private function remove(bool $dry): int + { + $due = $this->dormant() + ->whereNotNull('dormant_warned_at') + ->where('dormant_warned_at', '<=', now()->subDays(self::WARN_DAYS_BEFORE)) + ->get() + ->filter(fn (User $user) => $this->dormantSince($user) + ->lte(now()->subDays(self::AFTER_DAYS))); + + foreach ($due as $user) { + if ($dry) { + $this->line('would remove: '.$user->email.' (dormant since '.$this->dormantSince($user)->toDateString().')'); + + continue; + } + + // Logged individually rather than as a count: an address that + // disappears is the kind of thing somebody asks about later, and a + // number in a log answers nothing. + Log::info('Removing a dormant account', [ + 'email' => $user->email, + 'registered_at' => $user->created_at?->toIso8601String(), + 'warned_at' => $user->dormant_warned_at?->toIso8601String(), + ]); + + $user->delete(); + } + + return $due->count(); + } + + /** + * Confirmed, and with no customer record behind it at all. + * + * Drawn at the customer record rather than at "has no live package on + * record", and deliberately wider than it has to be: + * + * - A customer record means somebody in the business knows this person — + * an operator entered them, or a checkout created them. Removing the + * login of a record we keep is half a deletion. + * - Anything commercial (an order, a contract, an instance, an invoice) + * hangs off a customer record, and invoices have to be kept for seven + * years (§ 132 BAO). Skipping every customer record skips all of that + * without having to enumerate it and without a new table sneaking past + * the enumeration later. + * + * What is left is exactly the case this exists for: somebody registered, + * confirmed their address, never bought anything, and never came back. + * Registration alone creates no customer record. + * + * Both links count, by id and by address — see Customer::emailTaken for why + * one can exist without the other. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + private function dormant() + { + return User::query() + ->whereNotNull('email_verified_at') + // Old enough to be worth looking at. The precise cut-off is applied + // per user afterwards, because it depends on the devices — and this + // one is <=, not <: a timestamp exactly at the threshold is stored to + // the second, and a stricter pre-filter would drop the very account + // the per-user check was about to catch. + ->where('created_at', '<=', now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE)) + ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers') + ->whereColumn('customers.user_id', 'users.id')) + ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers') + ->whereColumn('customers.email', 'users.email')); + } + + /** + * Since when this account has been doing nothing. + * + * The later of the registration and the last time any of its devices was + * seen: signing in resets the clock, which is what the terms say. + */ + private function dormantSince(User $user): Carbon + { + // Scoped by guard as well as by id: operator 1 is not customer 1 (R21), + // and there is no devices() relation on User to hide that behind. + $lastSeen = UserDevice::query() + ->where('guard', 'web') + ->where('authenticatable_id', $user->id) + ->max('last_seen_at'); + + $since = $user->created_at ?? now(); + + return $lastSeen === null + ? $since + : Carbon::parse($lastSeen)->max($since); + } +} diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 38c1b68..3193012 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -53,16 +53,19 @@ class CheckoutController extends Controller $data = $request->validate([ 'plan' => ['required', 'string', 'max:64'], 'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY], - // The consumer's express request that we begin inside the - // fourteen-day withdrawal window (FAGG §16, §356 BGB). Required, - // because the cloud starts being built the moment the payment - // lands: without this on record a withdrawal on day thirteen gets - // the WHOLE amount back, however many days the machine actually - // ran. A seller who cannot prove the consent was given cannot - // charge for the days — and the proof is a timestamp taken here. - 'immediate_start' => ['accepted'], + // Acceptance of the terms, which is where everything about this + // sale is regulated: the delivery, the price, the fourteen-day + // withdrawal right, and the consumer's express request that we + // begin inside that window (FAGG §16, §356 BGB) — a cloud is built + // the moment the payment lands, so that request cannot be avoided + // and is stated on the checkbox as well as in the terms. + // + // Required. An order cannot be submitted without it: the box is the + // declaration, and the browser only disables the button — this is + // the line that actually refuses. + 'terms_accepted' => ['accepted'], ], [ - 'immediate_start.accepted' => __('checkout.immediate_start_required'), + 'terms_accepted.accepted' => __('checkout.terms_required'), ]); $term = $data['term'] ?? Subscription::TERM_MONTHLY; @@ -156,6 +159,13 @@ class CheckoutController extends Controller // than being stamped here — the contract only exists once // the payment does, and a consent recorded for a checkout // somebody abandoned would be a consent to nothing. + // + // The KEY keeps its old name deliberately. The form field it + // came from is called terms_accepted now, but a checkout + // session opened before a deploy is paid after it, and the + // webhook would find nothing under a new name. What it + // records is unchanged: the customer declared, at this + // moment, that we may start at once. 'immediate_start' => '1', // What part of Stripe's amount_total is the setup fee, so the // webhook can tell the two apart again: the package's charge diff --git a/app/Livewire/Admin/MailPreview.php b/app/Livewire/Admin/MailPreview.php index 2d42188..e4e6a3d 100644 --- a/app/Livewire/Admin/MailPreview.php +++ b/app/Livewire/Admin/MailPreview.php @@ -54,7 +54,17 @@ class MailPreview extends Component // a failure in a worker's log while the button reported success. The // person pressing it is waiting for the mail, so the send happens in // their request and its failure reaches them. - Mail::to($operator->email)->sendNow($mailable); + // + // Through the mailable's OWN mailer. Mail::to() hands back a pending + // mail bound to the DEFAULT mailer, and sendNow() then ignores the + // mailer the mailable asked for — so a support mail addressed + // support@ went out over the no-reply@ login and the server answered + // "553 Sender address rejected: not owned by user". Every purpose + // mailbox here has its own SMTP account, and the From has to belong + // to the account that authenticates. + Mail::mailer($mailable->mailer ?: null) + ->to($operator->email) + ->sendNow($mailable); } catch (Throwable $e) { $this->dispatch('notify', message: __('mail_preview.failed', [ 'reason' => mb_substr($e->getMessage(), 0, 160), @@ -70,8 +80,22 @@ class MailPreview extends Component { $this->authorize('mail.manage'); + $previews = app(MailPreviews::class)->all(); + return view('livewire.admin.mail-preview', [ - 'previews' => app(MailPreviews::class)->all(), + 'previews' => $previews, + // Read off the mailables rather than kept as a second list: the + // sender is decided by the purpose mailbox, and a table here would + // be a copy that drifts. + // From the envelope, not from the mailable's `from` property: that + // property stays empty until the mail is built, so reading it here + // showed nothing at all. + 'senders' => collect($previews) + ->keys() + ->mapWithKeys(fn (string $key) => [ + $key => app(MailPreviews::class)->make($key)?->envelope()->from?->address, + ]) + ->all(), 'me' => Auth::guard('operator')->user()?->email ?? '', // A preview that goes nowhere is worse than none: with a // non-delivering mailer the browser view still works and the send diff --git a/app/Mail/DormantAccountWarningMail.php b/app/Mail/DormantAccountWarningMail.php new file mode 100644 index 0000000..9f5ea31 --- /dev/null +++ b/app/Mail/DormantAccountWarningMail.php @@ -0,0 +1,48 @@ +mailer('cp_'.MailPurpose::SYSTEM); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('dormant_mail.subject')); + } + + public function content(): Content + { + return new Content(view: 'mail.dormant-warning', with: [ + 'name' => (string) $this->user->name, + 'days' => $this->days, + 'loginUrl' => route('login'), + ]); + } +} diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index cc52021..bbe8d5d 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -28,7 +28,16 @@ class InvoiceMail extends Mailable implements ShouldQueue { use Queueable, SendsFromMailbox, SerializesModels; - public function __construct(public Invoice $invoice, public string $name) {} + public function __construct(public Invoice $invoice, public string $name) + { + // The mailer, alongside the From. A purpose mailbox authenticates with + // its OWN account, and a mail server lets an account send only from the + // address it owns — so a From of billing@ over the default mailer's + // no-reply@ login is answered "553 Sender address rejected: not owned by + // user". Setting the From from a mailbox and leaving the mailer at the + // default is a mail that renders perfectly and never arrives. + $this->mailer('cp_'.MailPurpose::BILLING); + } public function envelope(): Envelope { diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index ca0009b..5a3f23e 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -30,7 +30,16 @@ class OrderConfirmationMail extends Mailable implements ShouldQueue { use Queueable, SendsFromMailbox, SerializesModels; - public function __construct(public Order $order, public string $name) {} + public function __construct(public Order $order, public string $name) + { + // The mailer, alongside the From. A purpose mailbox authenticates with + // its OWN account, and a mail server lets an account send only from the + // address it owns — so a From of billing@ over the default mailer's + // no-reply@ login is answered "553 Sender address rejected: not owned by + // user". Setting the From from a mailbox and leaving the mailer at the + // default is a mail that renders perfectly and never arrives. + $this->mailer('cp_'.MailPurpose::BILLING); + } public function envelope(): Envelope { diff --git a/app/Models/User.php b/app/Models/User.php index 12c58a7..9ee8281 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -64,6 +64,9 @@ class User extends Authenticatable implements MustVerifyEmail { return [ 'email_verified_at' => 'datetime', + // When we told this account it would be removed for having no + // package — see App\Console\Commands\PruneDormantAccounts. + 'dormant_warned_at' => 'datetime', 'password' => 'hashed', // is_admin itself is dead: nothing reads it any more (see // Operator/the operator guard). The column stays because dropping diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 32b420d..4d81734 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -8,6 +8,8 @@ use App\Mail\MaintenanceCancelledMail; use App\Mail\NewDeviceSignInMail; use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; +use App\Console\Commands\PruneDormantAccounts; +use App\Mail\DormantAccountWarningMail; use App\Mail\ResetPasswordMail; use App\Mail\VerifyEmailMail; use App\Models\Customer; @@ -58,6 +60,7 @@ class MailPreviews 'maintenance-cancelled' => 'Wartungsfenster abgesagt', 'invoice' => 'Rechnung', 'new-device' => 'Anmeldung von einem neuen Gerät', + 'dormant-warning' => 'Konto ohne Paket wird gelöscht', 'operator-message' => 'Nachricht aus der Konsole', ]; } @@ -91,6 +94,7 @@ class MailPreviews 'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer), 'invoice' => new InvoiceMail($this->invoice($customer), $customer->name), 'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'), + 'dormant-warning' => new DormantAccountWarningMail($user, PruneDormantAccounts::WARN_DAYS_BEFORE), 'operator-message' => new OperatorMessageMail( $customer, 'Zur Datenübernahme', @@ -177,11 +181,21 @@ class MailPreviews { $mail = new class($customer) extends Mailable { - public function __construct(public Customer $customer) {} + use \App\Mail\Concerns\SendsFromMailbox; + + public function __construct(public Customer $customer) + { + // The same mailbox the real notification sends from. Without + // this the preview took the framework default — an address no + // mailbox owns, over whichever account mail.default logs in + // with, which is the "553 Sender address rejected" every + // properly configured mail server answers with. + $this->mailer('cp_'.MailPurpose::PROVISIONING); + } public function envelope(): \Illuminate\Mail\Mailables\Envelope { - return new \Illuminate\Mail\Mailables\Envelope(subject: __('provisioning.mail.ready_subject')); + return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject')); } public function content(): \Illuminate\Mail\Mailables\Content diff --git a/database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php b/database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php new file mode 100644 index 0000000..be6eeff --- /dev/null +++ b/database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php @@ -0,0 +1,31 @@ +timestamp('dormant_warned_at')->nullable()->after('email_verified_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('dormant_warned_at'); + }); + } +}; diff --git a/lang/de/checkout.php b/lang/de/checkout.php index 663ced7..5f3ce60 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -22,11 +22,9 @@ return [ // tatsächlich tun, ist eine falsche Angabe — auch wenn sie zu unseren // Lasten ginge. Von der Zustimmung hängt jetzt nichts mehr ab; sie bleibt // als Beleg dafür, dass der Kunde den sofortigen Beginn verlangt hat. - 'immediate_start' => 'Ich verlange ausdrücklich, dass Sie mit der Leistung schon vor Ablauf der 14-tägigen Widerrufsfrist beginnen. Mein Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalte ich den gesamten gezahlten Betrag zurück.', - // Sagt, was ohne die Bestätigung passiert: die Bestellung kommt nicht - // zustande. Der Satz stand einmal anders da („dann richten wir erst nach - // Ablauf der Frist ein") und versprach damit einen Weg, den es nicht gibt — - // eine Cloud wird nach der Zahlung eingerichtet und nicht vierzehn Tage - // später. - 'immediate_start_required' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande — Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet, einen Beginn erst nach vierzehn Tagen gibt es nicht. Ihr Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalten Sie den gesamten Betrag zurück.', + // Was ohne das Häkchen passiert: die Bestellung kommt nicht zustande. Der + // Satz stand einmal anders da („dann richten wir erst nach Ablauf der Frist + // ein") und versprach damit einen Weg, den es nicht gibt — eine Cloud wird + // nach der Zahlung eingerichtet und nicht vierzehn Tage später. + 'terms_required' => 'Ohne Annahme der AGB kommt die Bestellung nicht zustande. Bitte setzen Sie das Häkchen — darin ist auch Ihr 14-tägiges Widerrufsrecht geregelt.', ]; diff --git a/lang/de/dormant_mail.php b/lang/de/dormant_mail.php new file mode 100644 index 0000000..cbc0f15 --- /dev/null +++ b/lang/de/dormant_mail.php @@ -0,0 +1,14 @@ + 'Ihr CluPilot-Konto wird in :days Tagen gelöscht', + 'heading' => 'Ihr Konto wird gelöscht', + 'preheader' => 'In :days Tagen — anmelden genügt, um das zu verhindern.', + 'greeting' => 'Guten Tag :name,', + 'intro' => 'zu Ihrem CluPilot-Konto besteht kein Paket und hat auch nie eines bestanden. Konten ohne Paket löschen wir nach einem Jahr, damit keine unbenutzten Daten bei uns liegen bleiben. Bei Ihrem Konto ist das in :days Tagen der Fall.', + 'how_to_keep' => 'Wenn Sie das Konto behalten möchten, melden Sie sich einfach einmal an — damit beginnt die Frist neu. Eine Bestellung genügt natürlich ebenso.', + 'action' => 'Anmelden und Konto behalten', + 'nothing_lost' => 'Verloren geht dabei nichts außer dem Zugang selbst: Es gibt zu diesem Konto keine Cloud, keine Dateien und keine Rechnungen. Sie können sich später jederzeit neu registrieren.', +]; diff --git a/lang/de/order.php b/lang/de/order.php index 5a5572a..aaf0fd3 100644 --- a/lang/de/order.php +++ b/lang/de/order.php @@ -7,10 +7,12 @@ return [ 'title' => 'Paket buchen', 'subtitle' => 'Wählen Sie Ihr Paket. Nach der Zahlung wird Ihre Cloud automatisch aufgesetzt — Sie bekommen die Zugangsdaten per E-Mail und müssen nichts weiter tun.', + 'terms_title' => 'AGB annehmen', + 'terms_label' => 'Ich habe die :link gelesen und nehme sie an.', + 'terms_link' => 'Allgemeinen Geschäftsbedingungen', + 'terms_why' => 'Darin ist alles zu dieser Bestellung geregelt: Preis, Umsatzsteuer, Bereitstellung, Kündigung und Ihr 14-tägiges Widerrufsrecht. Sie verlangen damit auch ausdrücklich, dass die Einrichtung sofort beginnt — sonst könnten wir Ihre Cloud nicht gleich bereitstellen. Widerrufen Sie innerhalb der Frist, erhalten Sie den gesamten Betrag zurück.', + 'terms_first' => 'Bitte oben die AGB annehmen.', 'recommended' => 'Empfohlen', - 'consent_title' => 'Sofortiger Beginn — bitte bestätigen', - 'consent_why' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande. Es gibt keinen späteren Beginn: Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet — und Ihr Widerrufsrecht behalten Sie vollständig.', - 'consent_first' => 'Bitte oben zuerst bestätigen.', 'up_to' => 'bis :count', 'per_month' => '/Monat', 'incl_vat' => 'inkl. :rate % MwSt.', diff --git a/lang/de/settings.php b/lang/de/settings.php index 8b14fdb..3b79ce7 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -51,6 +51,12 @@ return [ 'cancel_billing_unreachable' => 'Die Kündigung konnte gerade nicht bei unserem Zahlungsdienstleister eingetragen werden. Es wurde nichts geändert — bitte versuchen Sie es in ein paar Minuten erneut.', 'keep' => 'Abbrechen', + 'lifecycle_title' => 'Wann wir ein Konto von selbst löschen', + 'lifecycle_unverified' => 'Eine Registrierung, deren E-Mail-Adresse nicht bestätigt wurde, löschen wir nach :days Tagen. Die Adresse ist danach wieder frei.', + 'lifecycle_dormant' => 'Ein bestätigtes Konto, zu dem nie ein Paket bestanden hat, löschen wir nach einem Jahr. Wir kündigen das :warn Tage vorher per E-Mail an, und eine Anmeldung genügt, damit die Frist neu beginnt.', + 'lifecycle_kept' => 'Solange ein Paket läuft, wird nichts automatisch gelöscht. Rechnungen bewahren wir unabhängig davon sieben Jahre auf — dazu sind wir gesetzlich verpflichtet.', + 'lifecycle_terms' => 'In den AGB nachlesen', + 'close_account_title' => 'Konto schließen', 'close_account_sub' => 'Ihr CluPilot-Konto dauerhaft schließen.', 'close_cta' => 'Konto schließen', diff --git a/lang/de/verify_email.php b/lang/de/verify_email.php index 8a10315..350eaf5 100644 --- a/lang/de/verify_email.php +++ b/lang/de/verify_email.php @@ -24,6 +24,8 @@ return [ 'notice_delete' => 'Registrierung verwerfen', 'notice_wrong_address' => 'Adresse falsch eingetragen? Verwerfen Sie diese Registrierung und legen Sie sie neu an — erst dann ist die Adresse wieder frei. Bestätigen Sie fünf Tage lang nicht, wird der Zugang von selbst gelöscht; Sie müssen dann nichts tun.', + 'notice_after_confirm' => 'Nach der Bestätigung bleibt Ihr Konto bestehen. Nur ein bestätigtes Konto, zu dem nie ein Paket bestanden hat, löschen wir nach einem Jahr — angekündigt per E-Mail, und eine Anmeldung setzt die Frist zurück.', + 'delete_title' => 'Diese Registrierung verwerfen?', 'delete_body' => 'Der Zugang wird gelöscht und die E-Mail-Adresse ist wieder frei. Es ist noch nichts eingerichtet und nichts bezahlt — es geht nur diese unbestätigte Anmeldung verloren.', 'delete_keep' => 'Behalten', diff --git a/lang/en/checkout.php b/lang/en/checkout.php index 9d8ecb4..ddb3e0b 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -12,14 +12,13 @@ return [ // name for the same charge at the till is when somebody abandons a checkout. 'setup_fee_line' => 'Setting up your cloud, one-off', - // The express request that the service begin at once. + // What happens without the tick: no order at all. // - // The second sentence used to state a pro-rata liability (FAGG §16). There - // is none here any more: a withdrawing consumer gets the whole amount back - // (see App\Actions\WithdrawContract), and a tick-box that promises the - // customer something worse than what we actually do is a false statement, - // even when it is against our own interest. Nothing turns on the consent - // now; it stays as a record that the customer asked us to start at once. - 'immediate_start' => 'I expressly request that you begin providing the service before the 14-day withdrawal period has ended. My right of withdrawal is unaffected: if I withdraw within the period I get the full amount paid back.', - 'immediate_start_required' => 'Without this confirmation the order cannot go through — your cloud is built as soon as the payment lands, and there is no start fourteen days later. Your right of withdrawal is unaffected: if you withdraw within the period you get the full amount back.', + // The box used to carry the whole FAGG §16 sentence, including a pro-rata + // liability that does not exist here — a withdrawing consumer gets the whole + // amount back (App\Actions\WithdrawContract). It is the terms that regulate + // this sale now, and the express request to start at once is stated there and + // summarised beside the box. Nothing turns on the record either way; it stays + // as proof that the customer asked us to start at once. + 'terms_required' => 'Without accepting the terms the order cannot go through. Please tick the box — your 14-day right of withdrawal is regulated in there too.', ]; diff --git a/lang/en/dormant_mail.php b/lang/en/dormant_mail.php new file mode 100644 index 0000000..b1e1dd9 --- /dev/null +++ b/lang/en/dormant_mail.php @@ -0,0 +1,12 @@ + 'Your CluPilot account will be deleted in :days days', + 'heading' => 'Your account will be deleted', + 'preheader' => 'In :days days — signing in once is enough to stop it.', + 'greeting' => 'Hello :name,', + 'intro' => 'there is no package on your CluPilot account and there never has been. We delete accounts without a package after a year, so that no unused data is left lying with us. For your account that happens in :days days.', + 'how_to_keep' => 'If you would like to keep the account, simply sign in once — that starts the period again. An order does the same, of course.', + 'action' => 'Sign in and keep the account', + 'nothing_lost' => 'Nothing is lost but the login itself: there is no cloud, no file and no invoice on this account. You can register again at any time.', +]; diff --git a/lang/en/order.php b/lang/en/order.php index 0964e64..d75f36f 100644 --- a/lang/en/order.php +++ b/lang/en/order.php @@ -7,10 +7,12 @@ return [ 'title' => 'Book a package', 'subtitle' => 'Pick your package. Once it is paid your cloud is built automatically — your credentials arrive by mail and there is nothing else for you to do.', + 'terms_title' => 'Accept the terms', + 'terms_label' => 'I have read and accept the :link.', + 'terms_link' => 'terms and conditions', + 'terms_why' => 'They regulate everything about this order: price, VAT, delivery, cancellation and your 14-day right of withdrawal. You are also expressly requesting that we begin at once — otherwise your cloud could not be built straight away. Withdraw within the period and you get the full amount back.', + 'terms_first' => 'Please accept the terms above.', 'recommended' => 'Recommended', - 'consent_title' => 'Immediate start — please confirm', - 'consent_why' => 'Without this confirmation the order cannot go through. There is no later start: your cloud is built as soon as the payment lands — and you keep your right of withdrawal in full.', - 'consent_first' => 'Please confirm above first.', 'up_to' => 'up to :count', 'per_month' => '/month', 'incl_vat' => 'incl. :rate % VAT', diff --git a/lang/en/settings.php b/lang/en/settings.php index 8f33501..7a41a3b 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -51,6 +51,12 @@ return [ 'cancel_billing_unreachable' => 'The cancellation could not be registered with our payment provider just now. Nothing has been changed — please try again in a few minutes.', 'keep' => 'Keep', + 'lifecycle_title' => 'When we delete an account by ourselves', + 'lifecycle_unverified' => 'A registration whose address was never confirmed is deleted after :days days. The address is free again afterwards.', + 'lifecycle_dormant' => 'A confirmed account that never had a package is deleted after a year. We announce it :warn days beforehand by mail, and signing in once is enough to start the period again.', + 'lifecycle_kept' => 'While a package is running nothing is deleted automatically. Invoices are kept for seven years regardless — we are required to.', + 'lifecycle_terms' => 'Read it in the terms', + 'close_account_title' => 'Close account', 'close_account_sub' => 'Permanently close your CluPilot account.', 'close_cta' => 'Close account', diff --git a/lang/en/verify_email.php b/lang/en/verify_email.php index 9baa2fe..faa578c 100644 --- a/lang/en/verify_email.php +++ b/lang/en/verify_email.php @@ -22,6 +22,8 @@ return [ 'notice_delete' => 'Discard this registration', 'notice_wrong_address' => 'Typed the wrong address? Discard this registration and start again — only then is the address free. If you do not confirm within five days the account is removed by itself; there is nothing for you to do.', + 'notice_after_confirm' => 'Once confirmed, your account stays. Only a confirmed account that never had a package is deleted after a year — announced by mail, and signing in starts the period again.', + 'delete_title' => 'Discard this registration?', 'delete_body' => 'The account is deleted and the address becomes free again. Nothing has been set up and nothing paid for — only this unconfirmed sign-up is lost.', 'delete_keep' => 'Keep it', diff --git a/resources/views/legal.blade.php b/resources/views/legal.blade.php index be1b51a..8ce840d 100644 --- a/resources/views/legal.blade.php +++ b/resources/views/legal.blade.php @@ -1,30 +1,36 @@ {{-- Impressum, Datenschutz, AGB. - Placeholders still — the texts are the company's to write, not mine to - invent. What changed is that they are now pages OF the website rather than - a standalone card in a different design: same header, same footer, so - somebody following the imprint link from the footer does not land on what - looks like a stranded error page. + One shell, three pages. Where a partial exists under resources/views/legal/ + it is included; otherwise the placeholder stands, because those texts are the + company's to write and not mine to invent. Pages OF the website rather than a + standalone card in a different design: same header, same footer, so somebody + following the imprint link from the footer does not land on what looks like a + stranded error page. --}} +@php($partial = isset($page) ? 'legal.'.$page : null)

Rechtliches

{{ $title }}

-
-

Dieser Inhalt wird noch ergänzt.

-

- Bei Fragen erreichen Sie uns unter - office@clupilot.com. -

+ @if ($partial && view()->exists($partial)) + @include($partial) + @else +
+

Dieser Inhalt wird noch ergänzt.

+

+ Bei Fragen erreichen Sie uns unter + office@clupilot.com. +

- - - Zurück zur Startseite - -
+ + + Zurück zur Startseite + +
+ @endif
diff --git a/resources/views/legal/terms.blade.php b/resources/views/legal/terms.blade.php new file mode 100644 index 0000000..08759f0 --- /dev/null +++ b/resources/views/legal/terms.blade.php @@ -0,0 +1,249 @@ +{{-- + AGB. + + Written from what this software actually does — the checkout, the withdrawal + handling in App\Services\Billing\WithdrawalRight, the capacity queue in + App\Provisioning\Steps\Customer\ReserveResources, the two account-deletion + sweeps in app/Console/Commands, the cancellation in ConfirmCancelPackage. + Nothing here promises anything the code does not do, and no availability + figure or liability cap has been invented: where the statute already says it, + the statute is referred to rather than restated with numbers nobody checked. + + The company data comes from CompanyProfile so this page and the invoices + cannot drift apart. Filling in the real register number in the console fixes + it here too. + + This is the operator's text, not a lawyer's opinion. It should be read by one + before it is relied on in a dispute. +--}} +@php + $company = App\Support\CompanyProfile::all(); + $vat = rtrim(rtrim(number_format(App\Support\CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','); + $setup = App\Support\CompanyProfile::setupFeeCents(); + $unverifiedDays = App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS; +@endphp + +
+ +

Fassung vom {{ now()->local()->isoFormat('D. MMMM YYYY') }}

+ +
+

1. Vertragspartner und Geltungsbereich

+

+ Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen + {{ $company['name'] }}, {{ $company['address'] }}, {{ $company['postcode'] }} {{ $company['city'] }}, + {{ $company['country'] }} (im Folgenden „CluPilot“) und ihren Kundinnen und Kunden über die + Bereitstellung und den Betrieb von Nextcloud-Instanzen und der dazugehörigen Zusatzleistungen. + Die vollständigen Unternehmensdaten stehen im Impressum. +

+

+ Abweichende Bedingungen des Kunden werden nicht Vertragsinhalt, auch wenn CluPilot ihnen nicht + ausdrücklich widerspricht. +

+
+ +
+

2. Leistungsgegenstand

+

+ CluPilot stellt eine betriebsfertige Nextcloud-Installation auf eigener Infrastruktur bereit und + betreibt sie. Welcher Speicherplatz, welches Datenvolumen und wie viele Benutzerkonten enthalten + sind, ergibt sich aus dem gebuchten Paket, das dem Kunden bei der Bestellung und im Kundenportal + angezeigt wird. Zum Betrieb gehören das Einspielen von Sicherheits- und Versionsaktualisierungen, + die Überwachung des Dienstes und die im Paket beschriebenen Sicherungen. +

+

+ Der Standort der Instanz wird bei der Bestellung angezeigt. Ein Anspruch auf einen bestimmten + Server besteht nicht; CluPilot darf eine Instanz auf einen anderen Server derselben Region + verlegen, wenn der Betrieb dies erfordert. +

+
+ +
+

3. Zustandekommen des Vertrags

+

+ Die Darstellung der Pakete auf der Website ist kein Angebot, sondern eine Einladung zur Bestellung. + Bestellt wird ausschließlich im Kundenportal. Der Kunde gibt seine Bestellung ab, indem er diese + AGB durch Setzen des Häkchens annimmt und anschließend die Bezahlung abschließt. Ohne diese Annahme + kann keine Bestellung abgeschickt werden. +

+

+ Der Vertrag kommt mit dem Eingang der Zahlung zustande. Der Kunde erhält darüber eine Bestätigung + per E-Mail; die Rechnung wird gesondert zugestellt und ist im Kundenportal abrufbar. +

+
+ +
+

4. Preise, Umsatzsteuer und Zahlung

+

+ Die im Kundenportal angezeigten Preise für Verbraucher sind Bruttopreise und enthalten + {{ $vat }} % Umsatzsteuer. Unternehmerischen Kunden werden die Preise zusätzlich netto ausgewiesen; + bei einer gültigen UID aus einem anderen EU-Mitgliedstaat wird die Umsatzsteuer nach dem + Reverse-Charge-Verfahren nicht in Rechnung gestellt. +

+

+ Das Entgelt ist monatlich im Voraus fällig und wird über den Zahlungsdienstleister Stripe + eingezogen. + @if ($setup > 0) + Zusätzlich wird eine einmalige Einrichtungsgebühr von + {{ Number::currency($setup / 100, in: 'EUR', locale: 'de') }} verrechnet; sie ist bei der + ersten Zahlung fällig und wird bei der Bestellung ausgewiesen. + @else + Eine Einrichtungsgebühr wird derzeit nicht verrechnet. Fällt eine an, wird sie bei der + Bestellung ausgewiesen und ist mit der ersten Zahlung fällig. + @endif +

+

+ Zusatzleistungen (etwa zusätzlicher Speicher oder eine eigene Domain) werden zum jeweils + angezeigten Preis gebucht und ab der nächsten Abrechnungsperiode gemeinsam mit dem Paket + verrechnet. +

+
+ +
+

5. Bereitstellung

+

+ Die Einrichtung beginnt unmittelbar nach Eingang der Zahlung und ist üblicherweise innerhalb + weniger Minuten abgeschlossen. Der Kunde erhält seine Zugangsdaten per E-Mail, sobald die Instanz + bereitsteht. Eine bestimmte Bereitstellungsdauer wird nicht zugesagt. +

+

+ Steht zum Zeitpunkt der Bestellung keine freie Kapazität zur Verfügung, wird die Bestellung + vorgemerkt und ausgeliefert, sobald Kapazität geschaffen ist; der Kunde wird darüber informiert. + Ist die Bereitstellung binnen 14 Tagen nicht möglich, kann der Kunde vom Vertrag zurücktreten und + erhält den gezahlten Betrag zurück. +

+
+ +
+

6. Widerrufsrecht für Verbraucher

+

+ Verbraucher können binnen 14 Tagen ab Vertragsabschluss ohne Angabe von Gründen vom Vertrag + zurücktreten. Für die Erklärung genügt eine Mitteilung an + {{ $company['email'] }}; + im Kundenportal steht dafür auch eine Schaltfläche bereit. +

+

+ Mit der Annahme dieser AGB bei der Bestellung verlangt der Kunde ausdrücklich, dass CluPilot mit + der Leistung schon vor Ablauf dieser Frist beginnt — anders wäre eine sofortige Bereitstellung + nicht möglich. Das Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist + erhält der Kunde den gesamten gezahlten Betrag + zurück. Ein Wertersatz für die Tage, an denen die Cloud bereits gelaufen ist, wird nicht verlangt, + obwohl § 16 FAGG dies zulassen würde. Über den Widerruf wird eine Storno-Rechnung ausgestellt. +

+

+ Unternehmerischen Kunden steht dieses Rücktrittsrecht nicht zu. +

+
+ +
+

7. Laufzeit und Kündigung

+

+ Der Vertrag läuft auf unbestimmte Zeit und kann vom Kunden jederzeit im Kundenportal zum Ende der + laufenden Abrechnungsperiode gekündigt werden. Bis dahin bleibt die Cloud vollständig nutzbar. Zum + Ende der Periode erhält der Kunde einen Datenexport; danach wird die Instanz abgebaut und die + Daten werden gelöscht. +

+

+ CluPilot kann den Vertrag mit einer Frist von drei Monaten zum Monatsende kündigen sowie aus + wichtigem Grund mit sofortiger Wirkung, insbesondere bei Zahlungsverzug trotz Mahnung oder bei + einem Verstoß gegen Punkt 9. +

+
+ +
+

8. Kundenkonto und Löschfristen

+

+ Ein Konto entsteht mit der Registrierung im Kundenportal und ist erst nach Bestätigung der + E-Mail-Adresse nutzbar. CluPilot löscht Konten von selbst, damit keine unbenutzten Daten liegen + bleiben: +

+
    +
  • + Nicht bestätigte Registrierungen werden nach + {{ $unverifiedDays }} Tagen gelöscht. Die E-Mail-Adresse ist danach wieder frei. +
  • +
  • + Bestätigte Konten ohne Paket werden nach einem + Jahr gelöscht, wenn zu ihnen nie ein Paket, eine Buchung oder eine Rechnung bestanden hat. + CluPilot kündigt das vorher per E-Mail an; eine Anmeldung oder eine Bestellung setzt die Frist + zurück. +
  • +
+

+ Ein Konto mit gebuchtem Paket wird nicht automatisch gelöscht. Der Kunde kann sein Konto jederzeit + selbst schließen, sobald kein Paket mehr läuft. Rechnungen und Buchhaltungsunterlagen bewahrt + CluPilot unabhängig davon sieben Jahre auf — dazu ist sie nach § 132 BAO verpflichtet. +

+
+ +
+

9. Pflichten des Kunden

+

+ Der Kunde bewahrt seine Zugangsdaten sorgfältig auf und ist für die Inhalte verantwortlich, die er + und die von ihm angelegten Benutzer in der Cloud speichern. Rechtswidrige Inhalte, das Versenden + unerbetener Massen-E-Mails und jede Nutzung, die den Betrieb der Plattform gefährdet, sind + untersagt. CluPilot sieht Kundendaten nicht ein, außer der Kunde bittet im Rahmen einer + Supportanfrage darum oder eine gesetzliche Verpflichtung besteht. +

+
+ +
+

10. Verfügbarkeit und Wartung

+

+ CluPilot betreibt die Plattform mit der Sorgfalt eines fachkundigen Betreibers, schuldet aber keine + bestimmte Verfügbarkeit, solange keine gesonderte Vereinbarung darüber getroffen wurde. Geplante + Wartungen werden im Kundenportal und per E-Mail angekündigt und nach Möglichkeit in + verkehrsschwache Zeiten gelegt. Sicherheitsaktualisierungen können auch ohne Vorankündigung + eingespielt werden, wenn ein Aufschub ein Risiko wäre. +

+
+ +
+

11. Datenschutz

+

+ Welche Daten CluPilot zu welchem Zweck verarbeitet, steht in der + Datenschutzerklärung. + Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt dafür ein Vertrag über die + Auftragsverarbeitung nach Art. 28 DSGVO, den der Kunde anfordern kann. +

+
+ +
+

12. Haftung

+

+ CluPilot haftet nach den gesetzlichen Bestimmungen. Gegenüber unternehmerischen Kunden ist die + Haftung für leichte Fahrlässigkeit ausgeschlossen; die Haftung für Personenschäden sowie für Vorsatz + und grobe Fahrlässigkeit bleibt in jedem Fall unberührt. Gegenüber Verbrauchern wird die Haftung + nicht eingeschränkt. +

+
+ +
+

13. Änderungen dieser AGB

+

+ CluPilot kann diese AGB ändern und teilt Änderungen mindestens sechs Wochen vor ihrem Wirksamwerden + per E-Mail mit. Widerspricht der Kunde nicht bis zum Wirksamwerden, gelten die Änderungen als + angenommen; darauf wird in der Mitteilung ausdrücklich hingewiesen. Widerspricht er, kann jede + Seite den Vertrag zum Zeitpunkt des Wirksamwerdens kündigen. +

+
+ +
+

14. Schlussbestimmungen

+

+ Es gilt österreichisches Recht. Gegenüber Verbrauchern bleiben zwingende Schutzvorschriften ihres + Aufenthaltsstaats unberührt, und es gilt der gesetzliche Gerichtsstand. Gegenüber unternehmerischen + Kunden ist das für {{ $company['city'] }} sachlich zuständige Gericht zuständig. Sollte eine + Bestimmung unwirksam sein, bleibt der Vertrag im Übrigen gültig. +

+

+ Fragen dazu jederzeit an + {{ $company['email'] }}. +

+
+ + + + Zurück zur Startseite + +
diff --git a/resources/views/livewire/admin/mail-preview.blade.php b/resources/views/livewire/admin/mail-preview.blade.php index e7fdc56..bea833d 100644 --- a/resources/views/livewire/admin/mail-preview.blade.php +++ b/resources/views/livewire/admin/mail-preview.blade.php @@ -18,7 +18,17 @@
  • {{ $label }}

    -

    {{ $key }}

    + {{-- Which mailbox it goes out from, because that is the + field a mail server checks: an account may only send + from the address it owns, and a mismatch here is the + "553 Sender address rejected" that has no other + visible cause. --}} +

    + {{ $key }} + @if ($senders[$key] ?? null) + · {{ $senders[$key] }} + @endif +

    {{-- A new tab, not an iframe: a mail is a whole document with diff --git a/resources/views/livewire/auth/verify-email.blade.php b/resources/views/livewire/auth/verify-email.blade.php index b28704e..1a43c73 100644 --- a/resources/views/livewire/auth/verify-email.blade.php +++ b/resources/views/livewire/auth/verify-email.blade.php @@ -24,6 +24,11 @@ {{-- Said here, not only in the mail: somebody who never got the mail is looking at THIS page, and the deadline is the reason they do not have to do anything about an attempt they abandon. --}} + {{-- Both deadlines, because reading only the first one ("deleted after + five days") leaves somebody thinking a confirmed account goes the + same way. It does not: the five days are about a registration nobody + confirmed. --}}

    {{ __('verify_email.notice_wrong_address') }}

    +

    {{ __('verify_email.notice_after_confirm') }}

    diff --git a/resources/views/livewire/order.blade.php b/resources/views/livewire/order.blade.php index 20c1648..b7a1fc0 100644 --- a/resources/views/livewire/order.blade.php +++ b/resources/views/livewire/order.blade.php @@ -32,35 +32,42 @@ @else @error('plan'){{ $message }}@enderror -
    - {{-- ── The consent, first ──────────────────────────────────────── - It was at the bottom, and every buy button was disabled until it - was ticked — so the page arrived looking broken. Read in the - order it applies: this is the condition, the packages are the - choice. +
    + {{-- ── The terms, first ────────────────────────────────────────── + One declaration, and the terms behind it regulate the sale: + price, VAT, delivery, cancellation, the fourteen-day withdrawal + right and the express request to begin at once. The box used to + carry that last one as a paragraph of its own, which read like a + choice between "now" and "in fourteen days" — and there is no + second option. - The server rule is still the real gate (CheckoutController - validates `accepted`), so with JavaScript off the field arrives - empty and the checkout is refused with the sentence explaining - why. Ticking a box in a browser was never the record: the record - is the timestamp taken when the payment lands. --}} + The label is not the whole contract in small print: it says what + is being accepted, links to it, and summarises underneath what a + reader most needs to know before ticking. + + The server rule is the real gate (CheckoutController validates + `accepted`), so with JavaScript off the field arrives empty and + the checkout is refused with the sentence explaining why. --}} - @error('immediate_start') + @error('terms_accepted') {{ $message }} @enderror @@ -128,17 +135,26 @@
    @csrf - + - {{-- Enabled, with the reason it will not go through - said next to it. A disabled button explains - nothing; this one is refused by the server with a - sentence, and the line below says so first. --}} - + {{-- No order without the terms — so the button is not + usable until they are accepted, and says why + underneath rather than leaving somebody to guess + at a dead button. The refusal on the server + stands regardless: this is the visible half of + one rule, not the rule itself. + + x-bind on a plain attribute, not @disabled on the + component — R24#3: a Blade directive in a + component tag's attribute list lands in the + attribute bag and takes the view apart. --}} + {{ __('order.buy') }} -

    - {{ __('order.consent_first') }} +

    + {{ __('order.terms_first') }}

    diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 7beea0a..7920ca4 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -275,6 +275,25 @@ {{ __('settings.close_cta') }}
    + + {{-- The deadlines, said where somebody asks the question. + "Nach fünf Tagen gelöscht" on the verification page reads as if + it applied to every account; it applies to a registration nobody + confirmed. The other rule — a year without a package — was + nowhere at all. Both come from the commands that enforce them, so + the page cannot drift from what actually happens. --}} +
    +

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

    +
      +
    • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
    • +
    • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
    • +
    • {{ __('settings.lifecycle_kept') }}
    • +
    + + {{ __('settings.lifecycle_terms') }} + +
    diff --git a/resources/views/mail/dormant-warning.blade.php b/resources/views/mail/dormant-warning.blade.php new file mode 100644 index 0000000..8ac3143 --- /dev/null +++ b/resources/views/mail/dormant-warning.blade.php @@ -0,0 +1,34 @@ + + + +

    {{ __('dormant_mail.intro', ['days' => $days]) }}

    + + +{{-- The one thing that stops it. A single button, because there is exactly one + action and asking somebody to "log in or place an order or write to us" + leaves them deciding which. --}} + +

    {{ __('dormant_mail.how_to_keep') }}

    + + +
    + {{ __('dormant_mail.action') }} +
    + + + + + +
    + {{-- Said out loud: nothing is lost, because there was never anything + to lose. Somebody who reads "your account will be deleted" and + owns nothing still wonders about invoices and files. --}} +

    {{ __('dormant_mail.nothing_lost') }}

    +
    + + +
    diff --git a/routes/console.php b/routes/console.php index 94e471c..30021a8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -65,6 +65,14 @@ Schedule::command('clupilot:prune-unverified') ->dailyAt('03:20') ->withoutOverlapping(); +// Confirmed accounts that never had a package. A year, with a fortnight's notice +// by mail — so this has to run daily even though the window is long: the notice +// is what permits the deletion, and an account whose warning was never sent is +// never removed. See App\Console\Commands\PruneDormantAccounts. +Schedule::command('clupilot:prune-dormant') + ->dailyAt('03:35') + ->withoutOverlapping(); + // The support mailbox. Polling, because IMAP is a mailbox you look in — a mail // server has no way to tell us something arrived — so the interval IS the // answer to "how long until I see it". Two minutes is short enough that an diff --git a/routes/web.php b/routes/web.php index d5218cb..ab2db7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -208,7 +208,11 @@ $publicSite = function () { // but the address is not part of the copy. Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); - Route::get('/terms', fn () => view('legal', ['title' => 'AGB']))->name('agb'); + // The AGB has a text now (resources/views/legal/terms.blade.php): the + // order page requires accepting it, and it is where the withdrawal + // right, the immediate start and the account deletion deadlines are + // regulated rather than repeated in a checkbox nobody can read. + Route::get('/terms', fn () => view('legal', ['title' => 'AGB', 'page' => 'terms']))->name('agb'); // The German paths these shipped under. Permanent, because they have // been in mail footers and in the site footer for weeks. diff --git a/tests/Feature/Admin/MailPreviewTest.php b/tests/Feature/Admin/MailPreviewTest.php index a980903..43076de 100644 --- a/tests/Feature/Admin/MailPreviewTest.php +++ b/tests/Feature/Admin/MailPreviewTest.php @@ -2,6 +2,7 @@ use App\Livewire\Admin\MailPreview; use App\Models\Invoice; +use App\Models\Mailbox; use App\Models\MaintenanceWindow; use App\Models\Order; use App\Services\Mail\MailPreviews; @@ -72,6 +73,54 @@ it('sends a preview to the operator own address and to nobody else', function () Mail::assertNotQueued(App\Mail\VerifyEmailMail::class); }); +it('sends each preview through the mailer the mail itself asks for', function () { + // "553 5.7.1 Sender address rejected: not owned by user no-reply@…". + // + // Every purpose mailbox has its OWN SMTP account, and a mail server lets an + // account send only from the address it owns. Mail::to() hands back a pending + // mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the + // mailable asked for — so a support mail addressed support@ went out over the + // no-reply@ login and was rejected. The mailer is not a detail here; it IS + // which account authenticates. + Mail::fake(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPreview::class) + ->call('sendToMe', 'operator-message'); + + Mail::assertSent(App\Mail\OperatorMessageMail::class, fn ($mail) => $mail->mailer === 'cp_support'); +}); + +it('gives every preview a sender its own mailbox owns', function () { + // The invariant behind the 553: From has to be the address of the mailbox + // whose account signs in. A preview that takes the framework default + // (MAIL_FROM_ADDRESS, which no mailbox owns) is rejected by any properly + // configured server — which is exactly how the cloud-ready preview failed + // while all nine rendered perfectly in the browser. + // updateOrCreate, because the installation already seeds mailboxes: this + // test cares about the mapping and the addresses, not about who created the + // rows. + foreach (App\Services\Mail\MailPurpose::ALL as $purpose) { + Mailbox::query()->updateOrCreate(['key' => $purpose], [ + 'address' => $purpose.'@clupilot.test', + 'password' => 'test-passwort', + 'authenticates' => true, + 'active' => true, + ]); + App\Support\Settings::set(App\Services\Mail\MailPurpose::settingKey($purpose), $purpose); + } + + foreach (array_keys(app(MailPreviews::class)->all()) as $key) { + $mailable = app(MailPreviews::class)->make($key); + + // Which mailer it will go out over, and therefore which account signs in. + expect($mailable->mailer)->toBeString()->and($mailable->mailer)->toStartWith('cp_'); + + $purpose = str_replace('cp_', '', $mailable->mailer); + $mailable->assertFrom($purpose.'@clupilot.test'); + } +}); + it('has no recipient field, because that would be a form for mailing strangers', function () { $component = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/MailPreview.php')); diff --git a/tests/Feature/Auth/DormantAccountTest.php b/tests/Feature/Auth/DormantAccountTest.php new file mode 100644 index 0000000..b4a4b68 --- /dev/null +++ b/tests/Feature/Auth/DormantAccountTest.php @@ -0,0 +1,204 @@ +create(array_merge(['email_verified_at' => now()->subDays($daysOld)], $attributes)); + + $user->forceFill(['created_at' => now()->subDays($daysOld)])->save(); + + return $user; +} + +it('warns a fortnight before, rather than deleting out of the blue', function () { + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS - PruneDormantAccounts::WARN_DAYS_BEFORE); + + $this->artisan('clupilot:prune-dormant')->assertSuccessful(); + + Mail::assertQueued(DormantAccountWarningMail::class, fn ($mail) => $mail->hasTo($user->email)); + + // Still here. The warning is a warning, not the deletion. + expect($user->fresh())->not->toBeNull() + ->and($user->fresh()->dormant_warned_at)->not->toBeNull(); +}); + +it('warns once, not every night for a fortnight', function () { + Mail::fake(); + + dormantUser(PruneDormantAccounts::AFTER_DAYS - 5); + + $this->artisan('clupilot:prune-dormant'); + $this->artisan('clupilot:prune-dormant'); + $this->artisan('clupilot:prune-dormant'); + + Mail::assertQueuedCount(1); +}); + +it('deletes the account a year on, once the warning has had its fortnight', function () { + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS + 1); + $user->forceFill(['dormant_warned_at' => now()->subDays(PruneDormantAccounts::WARN_DAYS_BEFORE + 1)])->save(); + + $this->artisan('clupilot:prune-dormant')->assertSuccessful(); + + expect(User::query()->whereKey($user->id)->exists())->toBeFalse(); +}); + +it('never deletes an account that was never warned', function () { + // The stamp is what permits the deletion. An installation whose mail server + // was down for a month delays the deletion; it does not skip the notice. + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2); + + $this->artisan('clupilot:prune-dormant'); + + expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); +}); + +it('gives the warning its full fortnight before deleting', function () { + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS + 30); + $user->forceFill(['dormant_warned_at' => now()->subDays(PruneDormantAccounts::WARN_DAYS_BEFORE - 1)])->save(); + + $this->artisan('clupilot:prune-dormant'); + + expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); +}); + +it('leaves an account alone that signed in inside the year', function () { + // What the terms promise: signing in once starts the period again. There is + // no last_login_at on users, and the device rows carry exactly this. + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 3); + UserDevice::query()->create([ + 'guard' => 'web', + 'authenticatable_id' => $user->id, + 'token_hash' => hash('sha256', 'egal'), + 'name' => 'Firefox', + 'last_seen_at' => now()->subDays(30), + ]); + + $this->artisan('clupilot:prune-dormant'); + + Mail::assertNothingQueued(); + expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); +}); + +it('counts a device of another guard as nobody, because operator 1 is not customer 1', function () { + // R21. A device row of the operator guard with the same id says nothing + // about this customer, and treating it as activity would keep an abandoned + // account alive for as long as an operator keeps signing in. + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS - 1); + UserDevice::query()->create([ + 'guard' => 'operator', + 'authenticatable_id' => $user->id, + 'token_hash' => hash('sha256', 'anders'), + 'name' => 'Konsole', + 'last_seen_at' => now(), + ]); + + $this->artisan('clupilot:prune-dormant'); + + Mail::assertQueued(DormantAccountWarningMail::class); +}); + +it('never touches an unconfirmed account, which is the other sweep business', function () { + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2, ['email_verified_at' => null]); + + $this->artisan('clupilot:prune-dormant'); + + Mail::assertNothingQueued(); + expect(User::query()->whereKey($user->id)->exists())->toBeTrue(); +}); + +it('never touches an account with a customer record, by id or by address', function () { + // A customer record means somebody in the business knows this person, and + // everything commercial — orders, contracts, invoices kept for seven years — + // hangs off one. Both links count: see Customer::emailTaken. + Mail::fake(); + + $byId = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2); + Customer::factory()->create(['user_id' => $byId->id, 'email' => 'andere@example.test']); + + $byEmail = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2, ['email' => 'gleich@example.test']); + Customer::factory()->create(['email' => 'gleich@example.test', 'user_id' => null]); + + $this->artisan('clupilot:prune-dormant'); + + Mail::assertNothingQueued(); + expect(User::query()->whereKey($byId->id)->exists())->toBeTrue() + ->and(User::query()->whereKey($byEmail->id)->exists())->toBeTrue(); +}); + +it('says what it would do without doing it', function () { + Mail::fake(); + + $user = dormantUser(PruneDormantAccounts::AFTER_DAYS - 1, ['email' => 'ruht@example.test']); + + $this->artisan('clupilot:prune-dormant --dry-run') + ->expectsOutputToContain('ruht@example.test') + ->assertSuccessful(); + + Mail::assertNothingQueued(); + expect($user->fresh()->dormant_warned_at)->toBeNull(); +}); + +it('runs on the schedule, or it never runs at all', function () { + // Daily even though the window is a year: the notice is what permits the + // deletion, so the run that sends it has to happen every day. + expect(File::get(base_path('routes/console.php'))) + ->toContain("Schedule::command('clupilot:prune-dormant')") + ->toContain('withoutOverlapping'); +}); + +// ---- What a customer is told ---- + +it('states both deadlines in the portal, not just the five days', function () { + // The question this answers: "wird nun der account gelöscht nach 5 tagen?" + // Reading only the verification page, the answer looked like yes. + $settings = File::get(resource_path('views/livewire/settings.blade.php')); + + expect($settings)->toContain("__('settings.lifecycle_unverified'") + ->and($settings)->toContain("__('settings.lifecycle_dormant'") + // From the commands themselves, so the page cannot promise one number + // while the sweep uses another. + ->and($settings)->toContain('PruneUnverifiedAccounts::AFTER_DAYS') + ->and($settings)->toContain('PruneDormantAccounts::WARN_DAYS_BEFORE'); +}); + +it('regulates both deadlines in the terms, because that is where it was promised', function () { + $terms = $this->get(route('legal.agb'))->assertOk()->getContent(); + + expect($terms)->toContain('Löschfristen') + ->and($terms)->toContain('Nicht bestätigte Registrierungen') + ->and($terms)->toContain('Bestätigte Konten ohne Paket') + // And the number in the text is the number the command uses. + ->and($terms)->toContain(PruneUnverifiedAccounts::AFTER_DAYS.' Tagen'); +}); diff --git a/tests/Feature/Billing/ReverseChargePriceTest.php b/tests/Feature/Billing/ReverseChargePriceTest.php index adc29f6..3820f1c 100644 --- a/tests/Feature/Billing/ReverseChargePriceTest.php +++ b/tests/Feature/Billing/ReverseChargePriceTest.php @@ -117,9 +117,9 @@ it('charges a domestic consumer and a domestic business the same gross, to the c 'vat_id_verified_value' => 'ATU12345678', ]); - $this->actingAs($consumer)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']) + $this->actingAs($consumer)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']) ->assertRedirect(); - $this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']) + $this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']) ->assertRedirect(); // 179,00 € plus 20 %, which is the figure on the website. @@ -135,7 +135,7 @@ it('charges a verified business in another member state the bare net, and invoic Queue::fake(); [$user] = verifiedEuBusiness(); - $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']) + $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']) ->assertRedirect(); // The bare net: no VAT is owed to us, so there is none in the price either. @@ -188,7 +188,7 @@ it('charges an unverified EU business the gross, because an unchecked number is 'vat_id' => 'NL123456789B01', ]); - $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']) + $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']) ->assertRedirect(); expect(chargedByCheckout($this->stripe))->toBe(21480) @@ -202,8 +202,8 @@ it('charges the setup fee under the same rule as the package', function () { [$domestic] = buyerWith(['customer_type' => Customer::TYPE_CONSUMER]); [$business] = verifiedEuBusiness(); - $this->actingAs($domestic)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']); - $this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']); + $this->actingAs($domestic)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']); + $this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']); // A fee is a supply like the package it sets up, so it cannot be taxed // differently from it on the same invoice — and the webhook is told how much @@ -393,7 +393,7 @@ it('refuses the sale rather than overcharging a business the catalogue has no ne [$user] = verifiedEuBusiness(); - $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']) + $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']) ->assertSessionHasErrors('plan'); expect($this->stripe->checkouts)->toBeEmpty(); diff --git a/tests/Feature/Billing/SetupFeeTest.php b/tests/Feature/Billing/SetupFeeTest.php index a5e756d..0deadd7 100644 --- a/tests/Feature/Billing/SetupFeeTest.php +++ b/tests/Feature/Billing/SetupFeeTest.php @@ -48,7 +48,7 @@ function setupFeeBuyer(): User it('charges the fee it advertises, as a one-off line beside the monthly one', function () { $this->actingAs(setupFeeBuyer()) - ->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1']) + ->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => '1']) ->assertRedirect(); $checkout = $this->stripe->checkouts[0]; @@ -72,7 +72,7 @@ it('sends no line at all when the operator charges no setup fee', function () { // something. Settings::set('company.setup_fee_cents', 0); - $this->actingAs(setupFeeBuyer())->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1']); + $this->actingAs(setupFeeBuyer())->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => '1']); expect($this->stripe->checkouts[0]['one_off'])->toBeNull() // Stated as zero rather than left out. The webhook reads it either way, diff --git a/tests/Feature/MailSenderOwnershipTest.php b/tests/Feature/MailSenderOwnershipTest.php new file mode 100644 index 0000000..2fe97e0 --- /dev/null +++ b/tests/Feature/MailSenderOwnershipTest.php @@ -0,0 +1,54 @@ +getContents(); + + // Only the ones that take their sender from a mailbox. A mailable with + // no mailbox From has nothing to mismatch. + if (! str_contains($code, 'mailboxEnvelope(') && ! str_contains($code, 'mailboxAddresses(')) { + continue; + } + + preg_match('/mailbox(?:Envelope|Addresses)\(\s*MailPurpose::([A-Z_]+)/', $code, $from); + preg_match("/->mailer\('cp_'\.MailPurpose::([A-Z_]+)\)/", $code, $mailer); + + $fromPurpose = $from[1] ?? null; + $mailerPurpose = $mailer[1] ?? null; + + if ($fromPurpose !== null && $fromPurpose !== $mailerPurpose) { + $offenders[] = $file->getFilename().': From '.$fromPurpose.', mailer '.($mailerPurpose ?? 'DEFAULT'); + } + } + + expect($offenders)->toBe([]); +}); + +it('sends each purpose over its own mailer, not over whatever mail.default is', function () { + // The purpose mailers exist so that each mailbox authenticates as itself. + // A mailable naming no mailer silently uses mail.default, which on this + // installation is the plain smtp mailer with ONE account behind it. + foreach (App\Services\Mail\MailPurpose::ALL as $purpose) { + expect(config('mail.mailers.cp_'.$purpose)) + ->toBe(['transport' => 'mailbox', 'purpose' => $purpose]); + } +}); diff --git a/tests/Feature/SelfServiceOrderTest.php b/tests/Feature/SelfServiceOrderTest.php index 755f6a7..ee05d2c 100644 --- a/tests/Feature/SelfServiceOrderTest.php +++ b/tests/Feature/SelfServiceOrderTest.php @@ -36,7 +36,7 @@ beforeEach(function () { */ function buying(string $plan): array { - return ['plan' => $plan, 'immediate_start' => '1']; + return ['plan' => $plan, 'terms_accepted' => '1']; } /** A signed-in, verified portal user. */ @@ -191,14 +191,14 @@ it('does not promise a migration it has not looked at', function () { ->not->toContain('wie Ihre Daten sicher umziehen'); }); -it('refuses to open a checkout without the express request to begin at once', function () { +it('refuses to open a checkout without the terms being accepted', function () { // The gap this closes: the webhook has always read `immediate_start` off the // session metadata, and the checkout never put it there. So every contract // was concluded WITHOUT recorded consent, and every withdrawal — even on day // thirteen of a running cloud — had to refund the full amount. $this->actingAs(buyer()) ->post(route('checkout.start'), ['plan' => 'start']) - ->assertSessionHasErrors('immediate_start'); + ->assertSessionHasErrors('terms_accepted'); expect($this->stripe->checkouts)->toBeEmpty(); }); @@ -209,8 +209,8 @@ it('does not take a box that was merely present as a yes', function () { // not a consent. foreach (['', '0', 'nein'] as $value) { $this->actingAs(buyer()) - ->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => $value]) - ->assertSessionHasErrors('immediate_start'); + ->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => $value]) + ->assertSessionHasErrors('terms_accepted'); } expect($this->stripe->checkouts)->toBeEmpty(); @@ -225,15 +225,28 @@ it('carries the consent to Stripe as exactly the string the webhook compares aga expect($this->stripe->checkouts[0]['metadata']['immediate_start'])->toBe('1'); }); -it('puts the consent in front of the customer, in the law own words', function () { - // On the page, once, next to the buttons — not buried in terms nobody - // opens. The sentence is the statutory one, and the buy buttons stay - // disabled until it is ticked. +it('names what is being accepted, and links to it', function () { + // Not a box saying "I agree" with nothing behind it: the label says what, + // links to the document, and summarises beside it what a reader most needs to + // know before ticking — that delivery starts at once and the withdrawal right + // survives it. $this->actingAs(buyer()) ->get(route('order')) ->assertOk() - ->assertSee(__('checkout.immediate_start')) - ->assertSee('immediate_start', false); + ->assertSee(__('order.terms_link')) + ->assertSee(__('order.terms_why')) + ->assertSee(route('legal.agb'), false) + ->assertSee('terms_accepted', false); +}); + +it('serves the terms it asks a customer to accept', function () { + // A link to a page that says "Dieser Inhalt wird noch ergänzt" is not a + // contract, and accepting it would be accepting nothing. + $page = $this->get(route('legal.agb'))->assertOk()->getContent(); + + expect($page)->toContain('Widerrufsrecht') + ->and($page)->toContain('Löschfristen') + ->and($page)->not->toContain('Dieser Inhalt wird noch ergänzt'); }); it('lands as a timestamp on the order, which is what a withdrawal is measured against', function () { @@ -287,20 +300,17 @@ it('records no consent for a session that never carried one', function () { ->toBeNull(); }); -it('asks for the consent before the packages, not after them', function () { - // The consent used to sit at the bottom with every buy button disabled until - // it was ticked, so the page arrived looking broken. Read in the order it - // applies: the condition first, the choice second — and the button stays - // enabled, with the reason it will not go through said next to it. +it('asks for the terms before the packages, not after them', function () { + // It used to sit at the bottom, so the page arrived with every button + // unusable before anybody had done anything wrong. Read in the order it + // applies: the condition first, the choice second. $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php')); - $consentAt = strpos($page, "__('checkout.immediate_start')"); - $gridAt = strpos($page, 'lg:grid-cols-3'); - - expect($consentAt)->toBeLessThan($gridAt) - // No disabled button: a disabled control explains nothing. - ->and($page)->not->toContain('x-bind:disabled="!immediateStart"') - ->and($page)->toContain("__('order.consent_first')"); + expect(strpos($page, "__('order.terms_title')"))->toBeLessThan(strpos($page, 'lg:grid-cols-3')) + // No order without the terms — and the button says why rather than + // leaving somebody to guess at a control that does nothing. + ->and($page)->toContain('x-bind:disabled="!terms"') + ->and($page)->toContain("__('order.terms_first')"); }); it('lays five packages out as three and two', function () { @@ -311,10 +321,11 @@ it('lays five packages out as three and two', function () { }); it('still refuses the checkout when the box was not ticked', function () { - // The layout changed; the rule did not. The server is the gate. + // The layout changed, the wording changed, the rule did not: the server is + // the gate. Disabling a button in a browser refuses nothing. $this->actingAs(buyer()) ->post(route('checkout.start'), ['plan' => 'start']) - ->assertSessionHasErrors('immediate_start'); + ->assertSessionHasErrors('terms_accepted'); expect($this->stripe->checkouts)->toBeEmpty(); }); diff --git a/tests/Feature/SiteDesignSystemTest.php b/tests/Feature/SiteDesignSystemTest.php index a6ece4c..1e25482 100644 --- a/tests/Feature/SiteDesignSystemTest.php +++ b/tests/Feature/SiteDesignSystemTest.php @@ -149,8 +149,12 @@ it('draws the wordmark from one definition, not five', function () { $source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname())); // The wordmark is markup around the name; a plain sentence mentioning - // the company is not. `>Clu` catches exactly the lockup forms. - if (preg_match('/>\s*Clu(Pilot|<)/', $source)) { + // the company is not. `>Clu` with NO whitespace after the tag catches + // exactly the lockup forms — the name is the whole content of its + // element there. Whitespace used to be allowed, and then the terms page + // arrived with a paragraph whose first word is the company name, which + // is a sentence and not a brand lockup. + if (preg_match('/>Clu(Pilot|<)/', $source)) { $offenders[] = str_replace(resource_path().'/', '', $file->getPathname()); } }