From 6314bb60fb7cfb4083e20e686f49cff27e4c0d84 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 17:53:06 +0200 Subject: [PATCH] Schalter je Paket, ehrliche Statusanzeige, Reste der alten Leiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Umschaltmigration verglich beim Erkennen eines bereits umgeschalteten Bestands nur Kontingent und Platte. Bei Intern waren beide schon vorher richtig (5/20 GB), also hielt sie das Paket für erledigt und ließ RAM, Kerne und Plätze auf ihren alten Werten stehen (1024 MB/1 Kern/5 statt 4096 MB/2/3). Die Prüfung vergleicht jetzt alle neun Vorgaben; eine zweite, kleine Migration hebt einen Bestand nach, auf dem die erste schon lief, und tut nichts auf einer Neuinstallation. Die Statusanzeige unterschied bisher nicht zwischen "kein Angebot" und "läuft, aber nicht im Laden" — ein internes Paket zeigte "Nichts verfügbar" in der Liste und "Im Verkauf" auf der Versionsseite darunter. Vier Zustände statt zwei, mit fester Reihenfolge: der Notausschalter (sales_enabled) schlägt die Konsolen-Kennzeichnung (internal). Ein zweiter Schalter je Paketfamilie nimmt sie aus dem Preisblatt, ohne sie unverkäuflich zu machen — nach dem Vorbild des vorhandenen Verkaufsschalters, ohne Bestätigungsmodal, weil reversibel. Enterprise wechselt von sales_enabled=false (weder käuflich noch verschenkbar) auf internal=true, wie das Testpaket. Dazu die liegengebliebenen Zahlen der alten Leiter in Produktattrappe, Mail-Vorschau, Fabrik-Vorgaben und Seedern, sowie eine Testzusicherung, die auf eine wandernde ID statt auf den berechneten Wert hätte treffen können. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/PlanVersions.php | 8 +- app/Livewire/Admin/Plans.php | 21 +++ .../Steps/Customer/ResizeVirtualMachine.php | 6 +- app/Services/Mail/MailPreviews.php | 10 +- database/factories/InstanceFactory.php | 5 +- ...08_01_000004_switch_to_new_plan_ladder.php | 41 ++++- ...1_000005_finish_the_plan_ladder_switch.php | 168 ++++++++++++++++++ database/seeders/DatabaseSeeder.php | 3 +- database/seeders/DemoCustomerSeeder.php | 6 +- lang/de/plans.php | 5 + lang/en/plans.php | 5 + resources/views/landing.blade.php | 5 +- .../livewire/admin/plan-versions.blade.php | 11 +- .../views/livewire/admin/plans.blade.php | 30 +++- tests/Feature/Admin/CustomerRevenueTest.php | 6 +- tests/Feature/Admin/PlanAdminTest.php | 80 +++++++++ tests/Feature/Billing/NewPlanLadderTest.php | 109 +++++++++++- tests/Feature/LandingPriceSheetTest.php | 16 +- 18 files changed, 504 insertions(+), 31 deletions(-) create mode 100644 database/migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php index 0b9611b..f5e4c39 100644 --- a/app/Livewire/Admin/PlanVersions.php +++ b/app/Livewire/Admin/PlanVersions.php @@ -51,7 +51,9 @@ class PlanVersions extends Component * them too turned €799 into "79900", where one slipped digit is a factor of * ten on an invoice. Money::toCents does the conversion, once. */ - public string $monthlyPrice = '49,00'; + // 39,00 €: der Monatspreis von Start auf der neuen Leiter — als Vorbelegung + // für die allererste Version einer neuen Familie, die noch keine eigene hat. + public string $monthlyPrice = '39,00'; /** * How many of the twelve months a year costs nothing. @@ -95,7 +97,9 @@ class PlanVersions extends Component $this->features = $latest->features ?? []; $monthly = $latest->priceFor(Subscription::TERM_MONTHLY); - $this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900); + // 3900 = 39,00 €, dieselbe neue Vorgabe wie oben, für den seltenen + // Fall, dass die letzte Version keinen Monatspreis trägt. + $this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 3900); $this->freeMonths = (int) $latest->free_months; } } diff --git a/app/Livewire/Admin/Plans.php b/app/Livewire/Admin/Plans.php index 0641a0e..460cdc5 100644 --- a/app/Livewire/Admin/Plans.php +++ b/app/Livewire/Admin/Plans.php @@ -80,6 +80,27 @@ class Plans extends Component $this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn')); } + /** + * The second switch, per family: off the price sheet without making it + * unsellable. + * + * Unabhängig von toggleSales() — `internal` und `sales_enabled` sind zwei + * verschiedene Fragen (siehe PlanFamily::isSellableAt() und + * PlanCatalogue::sellable()/grantable()), und dieser Schalter rührt nur an + * der ersten. Ohne Bestätigungsmodal, wie toggleSales(): reversibel, und + * nichts, was einem Kunden je etwas wegnimmt — ein internes Paket stand + * ohnehin nie im Preisblatt. + */ + public function toggleInternal(string $uuid): void + { + $this->authorize('plans.manage'); + + $family = PlanFamily::query()->where('uuid', $uuid)->first(); + $family?->update(['internal' => ! $family->internal]); + + $this->dispatch('notify', message: __($family?->internal ? 'plans.made_internal' : 'plans.made_public')); + } + public function render() { $now = now(); diff --git a/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php b/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php index 385edbd..8765a24 100644 --- a/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php +++ b/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php @@ -87,9 +87,9 @@ class ResizeVirtualMachine extends CustomerStep * The target is the customer's WHOLE allowance — package plus booked storage * packs — with TWO overheads left on top of it, never one. The package's own * system overhead is read off the package and never invented: the catalogue's - * four packages ship 120/100, 540/500, 1050/1000 and 2100/2000 GB, which is - * 20, 40, 50 and 100 GB of room beside the customer's files and is a decision - * the owner makes per package, not a ratio anybody may derive. Every booked + * three sellable packages ship 40/30, 100/85 and 200/175 GB, which is 10, 15 + * and 25 GB of room beside the customer's files and is a decision the owner + * makes per package, not a ratio anybody may derive. Every booked * pack brings ITS OWN overhead the same way, frozen on the booking rather * than on the package — what it gives the customer and what it takes on the * host are two different figures, and only their difference belongs here. diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index e0b9081..984bbea 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -163,7 +163,8 @@ class MailPreviews { return Order::make([ 'plan' => 'start', - 'amount_cents' => 4900, + // 39,00 €: der Monatspreis von Start auf der neuen Leiter. + 'amount_cents' => 3900, 'currency' => 'EUR', 'datacenter' => 'fsn', 'status' => 'paid', @@ -178,9 +179,10 @@ class MailPreviews 'number' => 'RE-2026-0042', 'issued_on' => Carbon::now()->toDateString(), 'due_on' => Carbon::now()->addDays(14)->toDateString(), - 'net_cents' => 4900, - 'tax_cents' => 980, - 'gross_cents' => 5880, + // Dieselben 39,00 € netto wie order() oben, plus 20 % USt. + 'net_cents' => 3900, + 'tax_cents' => 780, + 'gross_cents' => 4680, 'currency' => 'EUR', 'snapshot' => ['meta' => ['number' => 'RE-2026-0042']], ])->forceFill(['id' => 0, 'uuid' => '00000000-0000-0000-0000-000000000000']); diff --git a/database/factories/InstanceFactory.php b/database/factories/InstanceFactory.php index 11233c7..9237f9f 100644 --- a/database/factories/InstanceFactory.php +++ b/database/factories/InstanceFactory.php @@ -18,8 +18,9 @@ class InstanceFactory extends Factory 'customer_id' => Customer::factory(), 'order_id' => Order::factory(), 'plan' => 'start', - 'quota_gb' => 100, - 'disk_gb' => 120, + // Start auf der neuen Leiter: 30 GB Kontingent, 40 GB Platte. + 'quota_gb' => 30, + 'disk_gb' => 40, 'ram_mb' => 4096, 'cores' => 2, 'subdomain' => 'nc-'.$this->faker->unique()->numberBetween(1, 999999), diff --git a/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php b/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php index cc6fdee..3a71dfd 100644 --- a/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php +++ b/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php @@ -105,8 +105,14 @@ return new class extends Migration // Zahlen): nichts zu tun, und wiederholbar bleibt es damit auch. // Geprüft an dem, was die Version TRÄGT, nicht an ihrer Nummer — // eine Nummer sagt nur, wie oft jemand etwas veröffentlicht hat. - if ($current !== null && (int) $current->quota_gb === (int) $spec['quota_gb'] - && (int) $current->disk_gb === (int) $spec['disk_gb']) { + // + // Geprüft werden ALLE neun Vorgaben, nicht nur Kontingent und + // Platte: bei Intern waren genau diese beiden schon vorher + // richtig (5 GB / 20 GB, von Hand über die Konsole angelegt), + // eine Prüfung auf nur zwei Felder hielt das Paket deshalb für + // erledigt und ließ RAM, Kerne und Plätze auf ihren alten Werten + // stehen. + if ($current !== null && $this->matchesLadder($current, $spec)) { continue; } @@ -231,6 +237,37 @@ return new class extends Migration ]); } + /** + * Trägt die laufende Version bereits genau das, was die neue Leiter für + * diesen Schlüssel vorschreibt — Kontingent, Platte, RAM, Kerne, Plätze, + * Traffic, Leistungsklasse, Monatspreis und Freimonate? + * + * Alle neun, nicht nur zwei: der Defekt, den dieser Vergleich behebt, kam + * genau daher, dass eine frühere Fassung nur Kontingent und Platte prüfte + * und ein Paket deshalb für erledigt hielt, dessen übrige Werte gar nicht + * geprüft wurden. + */ + private function matchesLadder(object $current, array $spec): bool + { + if ((int) $current->quota_gb !== (int) $spec['quota_gb'] + || (int) $current->disk_gb !== (int) $spec['disk_gb'] + || (int) $current->ram_mb !== (int) $spec['ram_mb'] + || (int) $current->cores !== (int) $spec['cores'] + || (int) $current->seats !== (int) $spec['seats'] + || (int) $current->traffic_gb !== (int) $spec['traffic_gb'] + || (string) $current->performance !== (string) $spec['performance'] + || (int) $current->free_months !== (int) $spec['free_months']) { + return false; + } + + $monthly = DB::table('plan_prices') + ->where('plan_version_id', $current->id) + ->where('term', 'monthly') + ->value('amount_cents'); + + return (int) $monthly === (int) $spec['price_cents']; + } + /** * Die Währung, in der diese Familie schon ausgepreist ist — sonst die der * Installation. diff --git a/database/migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php b/database/migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php new file mode 100644 index 0000000..a303f92 --- /dev/null +++ b/database/migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php @@ -0,0 +1,168 @@ + 5, 'disk_gb' => 20, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 3, + 'traffic_gb' => 1000, 'performance' => 'standard', 'price_cents' => 100, 'free_months' => 2, + 'features' => '["managed_updates","daily_backups","monitoring","subdomain","custom_domain"]', + ]; + + public function up(): void + { + $now = now(); + + DB::transaction(function () use ($now) { + $this->liftInternalOntoItsSizes($now); + $this->makeEnterpriseGrantable($now); + }); + } + + /** + * Kein Rückweg, aus demselben Grund wie in `000004`: eine veröffentlichte + * Version darf nicht gelöscht werden, solange Verträge auf sie verweisen + * könnten. + */ + public function down(): void {} + + private function liftInternalOntoItsSizes(Carbon $now): void + { + $family = DB::table('plan_families')->where('key', 'test')->first(); + + if ($family === null) { + // Keine Familie, die es zu heben gäbe — auf keiner Installation zu + // erwarten, die `000004` schon durchlaufen hat, aber diese + // Migration erfindet keine Familie, die eine andere ausgelassen + // hat. + return; + } + + $current = DB::table('plan_versions') + ->where('plan_family_id', $family->id) + ->whereNotNull('published_at') + ->where('available_from', '<=', $now) + ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', $now)) + ->orderByDesc('version') + ->first(); + + if ($current === null || $this->matchesTarget($current)) { + return; + } + + DB::table('plan_versions')->where('id', $current->id) + ->update(['available_until' => $now, 'updated_at' => $now]); + + $next = (int) DB::table('plan_versions')->where('plan_family_id', $family->id)->max('version') + 1; + + $versionId = DB::table('plan_versions')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'plan_family_id' => $family->id, + 'version' => $next, + 'quota_gb' => self::TARGET['quota_gb'], + 'traffic_gb' => self::TARGET['traffic_gb'], + 'seats' => self::TARGET['seats'], + 'free_months' => self::TARGET['free_months'], + 'ram_mb' => self::TARGET['ram_mb'], + 'cores' => self::TARGET['cores'], + 'disk_gb' => self::TARGET['disk_gb'], + 'performance' => self::TARGET['performance'], + // Übernommen wie in `000004`: wovon geklont wird, ist keine + // kaufmännische Entscheidung dieses Nachtrags. + 'template_vmid' => $current->template_vmid ?? 9000, + 'features' => self::TARGET['features'], + 'available_from' => $now, + 'available_until' => null, + 'published_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $yearly = self::TARGET['price_cents'] * (12 - self::TARGET['free_months']); + $currency = DB::table('plan_prices')->where('plan_version_id', $current->id)->value('currency') + ?? strtoupper((string) config('provisioning.currency', 'EUR')); + + foreach ([['monthly', self::TARGET['price_cents']], ['yearly', $yearly]] as [$term, $amount]) { + DB::table('plan_prices')->insert([ + 'uuid' => (string) Str::uuid(), + 'plan_version_id' => $versionId, + 'term' => $term, + 'amount_cents' => $amount, + 'currency' => $currency, + 'stripe_price_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + /** Trägt die laufende Version bereits alle neun Vorgaben? */ + private function matchesTarget(object $current): bool + { + if ((int) $current->quota_gb !== self::TARGET['quota_gb'] + || (int) $current->disk_gb !== self::TARGET['disk_gb'] + || (int) $current->ram_mb !== self::TARGET['ram_mb'] + || (int) $current->cores !== self::TARGET['cores'] + || (int) $current->seats !== self::TARGET['seats'] + || (int) $current->traffic_gb !== self::TARGET['traffic_gb'] + || (string) $current->performance !== self::TARGET['performance'] + || (int) $current->free_months !== self::TARGET['free_months']) { + return false; + } + + $monthly = DB::table('plan_prices') + ->where('plan_version_id', $current->id) + ->where('term', 'monthly') + ->value('amount_cents'); + + return (int) $monthly === self::TARGET['price_cents']; + } + + /** + * Enterprise bleibt im Katalog, wird aber wie das Testpaket von Hand + * vergeben statt gekauft: `sellable()` lässt es weiterhin aus (Preisblatt, + * Bestellung, Warenkorb), `grantable()` lässt es herein. + */ + private function makeEnterpriseGrantable(Carbon $now): void + { + DB::table('plan_families')->where('key', 'enterprise')->update([ + 'internal' => true, + 'sales_enabled' => true, + 'updated_at' => $now, + ]); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 424d66a..7716445 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -100,7 +100,8 @@ class DatabaseSeeder extends Seeder $berger->ensureUser(); // links to the kunde@clupilot.local portal login (admin impersonation) $bergerOrder = Order::updateOrCreate( ['stripe_event_id' => 'seed_berger'], - ['customer_id' => $berger->id, 'plan' => 'team', 'amount_cents' => 17900, 'currency' => 'EUR', 'datacenter' => 'fsn', 'status' => 'active'], + // 7900 = 79,00 €, der Team-Monatspreis auf der neuen Leiter. + ['customer_id' => $berger->id, 'plan' => 'team', 'amount_cents' => 7900, 'currency' => 'EUR', 'datacenter' => 'fsn', 'status' => 'active'], ); Instance::updateOrCreate( ['subdomain' => 'kanzlei-berger'], diff --git a/database/seeders/DemoCustomerSeeder.php b/database/seeders/DemoCustomerSeeder.php index 24a7c6d..af2d88b 100644 --- a/database/seeders/DemoCustomerSeeder.php +++ b/database/seeders/DemoCustomerSeeder.php @@ -73,7 +73,8 @@ class DemoCustomerSeeder extends Seeder ['customer_id' => $customer->id, 'type' => 'plan'], [ 'plan' => 'team', - 'amount_cents' => 17900, + // 7900 = 79,00 €, der Team-Monatspreis auf der neuen Leiter. + 'amount_cents' => 7900, 'currency' => 'EUR', 'datacenter' => $datacenter->code, 'status' => 'paid', @@ -108,7 +109,8 @@ class DemoCustomerSeeder extends Seeder 'plan' => 'team', 'tier' => 2, 'term' => 'monthly', - 'price_cents' => 17900, + // 7900 = 79,00 €, derselbe Team-Monatspreis wie oben. + 'price_cents' => 7900, 'currency' => 'EUR', 'quota_gb' => 500, 'traffic_gb' => 3072, diff --git a/lang/de/plans.php b/lang/de/plans.php index 1fd5d15..86f9fae 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -17,10 +17,13 @@ return [ 'manage_versions' => 'Versionen (:count)', 'on_sale' => 'Im Verkauf', + 'console_only' => 'Nur über die Konsole', 'nothing_available' => 'Nichts verfügbar', 'withdrawn_badge' => 'Aus dem Verkauf', 'now_selling' => 'Paket ist wieder im Verkauf.', 'withdrawn' => 'Paket aus dem Verkauf genommen. Bestandskunden behalten ihren Vertrag.', + 'made_internal' => 'Paket ist jetzt nur noch über die Konsole vergebbar.', + 'made_public' => 'Paket steht wieder im Preisblatt.', 'new_title' => 'Neues Paket', 'new_body' => 'Legt nur die Paketlinie an. Was sie kann und kostet, entscheiden die Versionen.', @@ -30,6 +33,8 @@ return [ 'create' => 'Paket anlegen', 'created' => 'Paket angelegt.', + 'make_internal' => 'Aus dem Preisblatt nehmen (nur über die Konsole vergebbar)', + 'make_public' => 'Wieder ins Preisblatt aufnehmen', 'edit_marketing' => 'Marketing bearbeiten', 'marketing_title' => 'Marketing', 'marketing_saved' => 'Marketing-Angaben gespeichert.', diff --git a/lang/en/plans.php b/lang/en/plans.php index 7069cf7..65b3681 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -17,10 +17,13 @@ return [ 'manage_versions' => 'Versions (:count)', 'on_sale' => 'On sale', + 'console_only' => 'Console only', 'nothing_available' => 'Nothing available', 'withdrawn_badge' => 'Withdrawn', 'now_selling' => 'Plan is on sale again.', 'withdrawn' => 'Plan withdrawn from sale. Existing customers keep their contract.', + 'made_internal' => 'Plan can now only be granted through the console.', + 'made_public' => 'Plan is back on the price sheet.', 'new_title' => 'New plan', 'new_body' => 'Creates the plan line only. What it can do and costs is decided by its versions.', @@ -30,6 +33,8 @@ return [ 'create' => 'Create plan', 'created' => 'Plan created.', + 'make_internal' => 'Take off the price sheet (grant through the console only)', + 'make_public' => 'Put back on the price sheet', 'edit_marketing' => 'Edit marketing', 'marketing_title' => 'Marketing', 'marketing_saved' => 'Marketing details saved.', diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 69d24f5..73b41ac 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -102,7 +102,10 @@
@foreach ([ - ['Speicher', '235', 'von 500 GB', 47], + // 175 GB, nicht 500: die Produktattrappe darf nichts + // zeigen, was größer ist als das größte Paket, das die + // Preistabelle darunter tatsächlich führt (Business). + ['Speicher', '82', 'von 175 GB', 47], ['Benutzer', '8', 'von 25 Plätzen', 32], ['Verfügbarkeit', '99,98', '% letzte 30 Tage', 100], ] as [$label, $value, $sub, $fill]) diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php index 59067a8..84fb13a 100644 --- a/resources/views/livewire/admin/plan-versions.blade.php +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -35,9 +35,14 @@ @forelse ($versions as $version) @php // The window being open is not the same as the plan being - // sold: the family's kill switch overrides every window. + // sold: the family's kill switch overrides every window, + // and `internal` keeps it off the shop even with the + // switch on — that used to say "Im Verkauf" here while the + // list said "Nichts verfügbar", the same two questions + // sharing one label that (b) was written to fix. $windowOpen = $version->isAvailableAt($now); - $live = $windowOpen && $family->sales_enabled; + $live = $windowOpen && $family->sales_enabled && ! $family->internal; + $consoleOnly = $windowOpen && $family->sales_enabled && $family->internal; $scheduled = $version->isPublished() && $version->available_from->greaterThan($now); @endphp
{{ __('plans.version', ['n' => $version->version]) }} @if ($live) {{ __('plans.badge_live') }} + @elseif ($consoleOnly) + {{ __('plans.console_only') }} @elseif ($windowOpen) {{ __('plans.badge_withdrawn') }} @elseif ($scheduled) diff --git a/resources/views/livewire/admin/plans.blade.php b/resources/views/livewire/admin/plans.blade.php index 87ea8a8..8162ba5 100644 --- a/resources/views/livewire/admin/plans.blade.php +++ b/resources/views/livewire/admin/plans.blade.php @@ -60,16 +60,40 @@ @endif - @php $selling = in_array($family['key'], $sellable, true); @endphp + @php + // Vier Zustände, jeder sagt genau eine Sache. "steht die + // Familie im Verkaufskatalog?" (sellable()) beantwortet nur + // eine von zwei möglichen Fragen — die andere ist "läuft hier + // überhaupt etwas?", und beide teilten sich bisher eine + // Beschriftung. "Zurückgezogen" schlägt "nur über die + // Konsole": der Notausschalter ist die stärkere Aussage. + $selling = in_array($family['key'], $sellable, true); + $status = match (true) { + ! $family['sales_enabled'] => 'withdrawn_badge', + $family['internal'] && $family['live'] => 'console_only', + $selling => 'on_sale', + default => 'nothing_available', + }; + @endphp
+ {{-- Der zweite Schalter, je Familie einzeln — unabhängig vom + Verkaufsschalter in der Statusspalte. Ohne + Bestätigungsmodal wie toggleSales(): reversibel, und + niemandem wird damit etwas weggenommen. --}} +