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 @@