Schalter je Paket, ehrliche Statusanzeige, Reste der alten Leiter

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 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 17:53:06 +02:00
parent f961205512
commit 6314bb60fb
18 changed files with 504 additions and 31 deletions

View File

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

View File

@ -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();

View File

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

View File

@ -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']);

View File

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

View File

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

View File

@ -0,0 +1,168 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Der Nachtrag zur Umschaltung, für einen Bestand, auf dem sie schon lief.
*
* `2026_08_01_000004_switch_to_new_plan_ladder.php` erkannte einen bereits
* umgeschalteten Bestand daran, dass die laufende Version dieselbe
* `quota_gb` UND `disk_gb` trägt wie die Vorgabe zwei von neun Feldern.
* Bei Intern waren beide schon vorher richtig (5 GB / 20 GB, von Hand über
* die Konsole angelegt), also hielt die Migration das Paket für erledigt und
* ließ RAM, Kerne und Plätze auf ihren alten Werten stehen (1024 MB, 1 Kern,
* 5 Plätze statt 4096 MB, 2 Kernen, 3 Plätzen). Die Prüfung selbst ist in
* derselben Migration korrigiert aber eine Migration läuft nur einmal, und
* auf einem Bestand, der sie schon hinter sich hat, ändert die Korrektur
* nichts mehr rückwirkend. Dieser Nachtrag schreibt für genau diesen Fall
* vorwärts, was die erste Migration hätte schreiben sollen, statt die
* gelaufene Migration im Nachhinein umzuschreiben.
*
* Enterprise zieht hier mit: die erste Migration setzt es auf
* `sales_enabled = false` und macht es damit weder käuflich noch
* verschenkbar im Widerspruch zum Entwurf, nach dem Enterprise von Hand
* vergeben wird, sobald eine Maschine dasteht. Richtig ist `internal = true`
* bei eingeschaltetem Verkauf, genau wie beim Testpaket. Das steht hier und
* nicht in der ersten Migration, damit eine Neuinstallation denselben Weg
* nimmt wie ein bestehender Betrieb: beide durchlaufen diese Datei.
*
* Wiederholbar wie die erste: nichts zu tun, wenn Intern seine neun Werte
* schon trägt. Auf einer Neuinstallation ist das sofort der Fall, weil die
* korrigierte Prüfung in `000004` dort in einem Schritt landet dieser
* Nachtrag betrifft dann nur noch Enterprise.
*/
return new class extends Migration
{
/** Dieselben neun Zahlen wie `LADDER['test']` in der ersten Migration. */
private const TARGET = [
'quota_gb' => 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,
]);
}
};

View File

@ -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'],

View File

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

View File

@ -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.',

View File

@ -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.',

View File

@ -102,7 +102,10 @@
<div class="mt-5 grid gap-3 sm:grid-cols-3">
@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])

View File

@ -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
<div wire:key="v-{{ $version->uuid }}" class="rounded-lg border border-line bg-surface p-5 shadow-xs
@ -48,6 +53,8 @@
<h2 class="text-base font-semibold text-ink">{{ __('plans.version', ['n' => $version->version]) }}</h2>
@if ($live)
<x-ui.badge status="active">{{ __('plans.badge_live') }}</x-ui.badge>
@elseif ($consoleOnly)
<x-ui.badge status="info">{{ __('plans.console_only') }}</x-ui.badge>
@elseif ($windowOpen)
<x-ui.badge status="suspended">{{ __('plans.badge_withdrawn') }}</x-ui.badge>
@elseif ($scheduled)

View File

@ -60,16 +60,40 @@
@endif
</td>
<td class="px-4 py-3">
@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
<button type="button" wire:click="toggleSales('{{ $family['uuid'] }}')"
class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
{{ $selling ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
{{ $status === 'on_sale' ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $selling ? __('plans.on_sale') : ($family['sales_enabled'] ? __('plans.nothing_available') : __('plans.withdrawn_badge')) }}
{{ __('plans.'.$status) }}
</button>
</td>
<td class="px-4 py-3 text-right">
<div class="inline-flex items-center gap-2">
{{-- 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. --}}
<button type="button"
aria-label="{{ $family['internal'] ? __('plans.make_public') : __('plans.make_internal') }}"
wire:click="toggleInternal('{{ $family['uuid'] }}')"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="{{ $family['internal'] ? 'unlock' : 'lock' }}" class="size-4" />
</button>
<button type="button" aria-label="{{ __('plans.edit_marketing') }}"
x-on:click="$dispatch('openModal', { component: 'admin.edit-plan-family', arguments: { uuid: '{{ $family['uuid'] }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">

View File

@ -66,9 +66,11 @@ it('shows a yearly customer their monthly rate on the portal too', function () {
$cloud = Livewire::actingAs($user)->test(App\Livewire\Cloud::class)->viewData('instance');
// 790,00 € im Jahr sind 65,83 € im Monat — die Wolkenseite rundet das auf
// ganze Euro, die Karte nennt die Cents.
// ganze Euro, die Karte nennt die Cents. '66' allein ist eine zweistellige
// Teilzeichenkette, die im Lauf einer Suite auch eine ID trifft; mit dem
// Einheitenzeichen davor ist sie eindeutig, wie schon '65,83' oben.
expect($card['price_cents'])->toBe(6583)
->and($cloud['plan'])->toContain('66')
->and($cloud['plan'])->toContain('66 €/Monat')
->and($cloud['plan'])->not->toContain('790');
});

View File

@ -95,6 +95,40 @@ it('takes a plan out of the shop without touching anyone\'s contract', function
expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue();
});
it('takes a plan off the price sheet without touching whether it sells at all', function () {
// Der zweite Schalter, unabhängig vom ersten: `internal` entscheidet, ob
// eine Familie im Laden steht, `sales_enabled` bleibt unberührt — anders
// als toggleSales() macht dieser Schalter ein Paket nicht unverkäuflich,
// er nimmt es nur aus dem Preisblatt.
$team = teamFamily();
expect($team->internal)->toBeFalse();
Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleInternal', $team->uuid);
expect($team->fresh()->internal)->toBeTrue()
->and($team->fresh()->sales_enabled)->toBeTrue()
->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('team')
->and(array_keys(app(PlanCatalogue::class)->grantable()))->toContain('team');
Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleInternal', $team->uuid);
expect($team->fresh()->internal)->toBeFalse()
->and(array_keys(app(PlanCatalogue::class)->sellable()))->toContain('team');
});
it('re-checks the capability on the internal switch rather than trusting the mount', function () {
// Wie beim vorhandenen Muster für reopen(): die Seite ist offen und
// rechtmäßig aufgerufen, dann wird das Konto herabgestuft.
$user = owner();
$page = Livewire::actingAs($user, 'operator')->test(Plans::class);
$user->syncRoles(['Support']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$page->call('toggleInternal', teamFamily()->uuid)->assertForbidden();
expect(teamFamily()->internal)->toBeFalse();
});
it('drafts a version prefilled from the last one, priced for both terms', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
@ -351,6 +385,52 @@ it('lists a withdrawn plan as withdrawn, not as missing', function () {
->assertSee(__('plans.withdrawn_badge'));
});
it('shows an internal plan as console-only, not as nothing available', function () {
// Intern (`test`) ist internal=true mit einer laufenden Version — die
// Liste fragte bisher nur "steht die Familie im Verkaufskatalog?" und
// fiel deshalb auf "Nichts verfügbar" zurück, obwohl eine Version läuft.
// Das war der Widerspruch, den der Betreiber gemeldet hat.
Livewire::actingAs(owner(), 'operator')->test(Plans::class)
->assertSee(__('plans.console_only'))
->assertDontSee(__('plans.nothing_available'));
});
it('lets the kill switch outrank the console-only label', function () {
// "Zurückgezogen" schlägt "nur über die Konsole": der Notausschalter ist
// die stärkere Aussage, Teil der Aufgabe. Enterprise ist seit der zweiten
// Migration ebenfalls internal+live, deshalb wird über die Häufigkeit
// geprüft statt über die bloße Anwesenheit des Textes.
$before = substr_count(
Livewire::actingAs(owner(), 'operator')->test(Plans::class)->html(),
__('plans.console_only')
);
PlanFamily::query()->where('key', 'test')->update(['sales_enabled' => false]);
$page = Livewire::actingAs(owner(), 'operator')->test(Plans::class);
expect(substr_count($page->html(), __('plans.console_only')))->toBe($before - 1);
$page->assertSee(__('plans.withdrawn_badge'));
});
it('shows nothing available only when a sellable plan has no version in its window', function () {
$live = app(PlanCatalogue::class)->currentVersion('team');
app(PlanCatalogue::class)->schedule($live, $live->available_from, now());
Livewire::actingAs(owner(), 'operator')->test(Plans::class)
->assertSee(__('plans.nothing_available'));
});
it('does not call an internal plan on sale on its own version page either', function () {
// Der zweite Teil desselben Widerspruchs: die Versionsseite zeigte "Im
// Verkauf", weil sie nur prüfte, ob das Fenster offen und der
// Verkaufsschalter an ist — ohne `internal` zu fragen.
Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => PlanFamily::query()->where('key', 'test')->value('uuid')])
->assertSee(__('plans.console_only'))
->assertDontSee(__('plans.badge_live'));
});
it('recommends a plan, and recommending a second one clears the first', function () {
$team = teamFamily();
$business = PlanFamily::query()->where('key', 'business')->sole();

View File

@ -3,6 +3,8 @@
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Die Leiter, wie sie verkauft wird.
@ -39,10 +41,113 @@ it('lässt neun Startkunden auf 388 GB', function () {
expect(intdiv(388, $start['disk_gb']))->toBe(9);
});
it('nimmt das interne Paket und Enterprise aus dem Laden', function () {
it('nimmt Intern und Enterprise aus dem Laden, lässt aber beide verschenkbar', function () {
// Beide verlassen sellable() über dasselbe Feld — `internal` —, nicht über
// den Verkaufsschalter: der bleibt an, sonst verböte currentVersion() auch
// das Verschenken (Aufgabe 6/InternalPlanTest).
expect(PlanFamily::query()->where('key', 'test')->value('internal'))->toBeTruthy()
->and(PlanFamily::query()->where('key', 'test')->value('name'))->toBe('Intern')
->and(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeFalsy();
->and(PlanFamily::query()->where('key', 'test')->value('sales_enabled'))->toBeTruthy()
->and(PlanFamily::query()->where('key', 'enterprise')->value('internal'))->toBeTruthy()
->and(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeTruthy();
});
it('hält Intern nicht für erledigt, nur weil Kontingent und Platte schon stimmten', function () {
// Der Defekt: die Prüfung verglich nur quota_gb und disk_gb. Genau der
// Bestand, den der Betreiber früher von Hand über die Konsole angelegt
// hatte — Kontingent und Platte schon richtig (5 GB / 20 GB), RAM, Kerne
// und Plätze noch auf den alten Werten (1024 MB, 1 Kern, 5 Plätze).
$family = PlanFamily::query()->where('key', 'test')->sole();
DB::table('plan_versions')->where('plan_family_id', $family->id)
->update(['available_until' => now(), 'updated_at' => now()]);
DB::table('plan_versions')->insert([
'uuid' => (string) Str::uuid(),
'plan_family_id' => $family->id,
'version' => 99,
'quota_gb' => 5, 'disk_gb' => 20, // richtig, wie die Vorgabe
'ram_mb' => 1024, 'cores' => 1, 'seats' => 5, // die alten, falschen Werte
'traffic_gb' => 1000, 'performance' => 'standard', 'free_months' => 2,
'template_vmid' => 9000, 'features' => '[]',
'available_from' => now()->subYear(), 'available_until' => null,
'published_at' => now()->subYear(),
'created_at' => now(), 'updated_at' => now(),
]);
// Der Preis muss ebenfalls stimmen, sonst würde die Prüfung ihn für sich
// allein schon als Unterschied werten, ohne dass RAM/Kerne/Plätze geprüft
// wurden — hier soll ausdrücklich der Rest der Vorgabe geprüft werden.
DB::table('plan_prices')->insert([
'uuid' => (string) Str::uuid(),
'plan_version_id' => DB::table('plan_versions')->where('plan_family_id', $family->id)->where('version', 99)->value('id'),
'term' => 'monthly', 'amount_cents' => 100, 'currency' => 'EUR',
'created_at' => now(), 'updated_at' => now(),
]);
(require database_path('migrations/2026_08_01_000004_switch_to_new_plan_ladder.php'))->up();
$current = app(PlanCatalogue::class)->currentVersion('test');
expect($current->ram_mb)->toBe(4096)
->and($current->cores)->toBe(2)
->and($current->seats)->toBe(3);
});
it('hebt Intern nachträglich auf seine neuen Größen, für einen Bestand, auf dem die erste Migration schon lief', function () {
// Nachgebaut statt echt reproduziert: die erste Migration ist inzwischen
// korrigiert und erzeugt diesen kaputten Bestand selbst nicht mehr. Genau
// das war aber der gemessene Zustand auf der Entwicklungsdatenbank, bevor
// dieser Nachtrag existierte — Kontingent und Platte schon richtig, RAM,
// Kerne und Plätze noch auf den alten Werten, nur eine einzige Version.
$family = PlanFamily::query()->where('key', 'test')->sole();
$liveVersionId = DB::table('plan_versions')
->where('plan_family_id', $family->id)->whereNull('available_until')->value('id');
// Dieselbe Zeile, auf ihre alten Werte zurückgesetzt — die frische
// Installation legt Version 1 schon richtig an, und dieser Test spielt
// genau die Zeile durch, die vor diesem Nachtrag falsch stehen blieb.
DB::table('plan_versions')->where('id', $liveVersionId)
->update(['ram_mb' => 1024, 'cores' => 1, 'seats' => 5, 'updated_at' => now()]);
// Vorher: der gemessene Bestand aus dem Aufgabenblatt.
$before = app(PlanCatalogue::class)->currentVersion('test');
expect($before->ram_mb)->toBe(1024)
->and($before->cores)->toBe(1)
->and($before->seats)->toBe(5);
(require database_path('migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php'))->up();
// Nachher: 4096 MB, 2 Kerne, 3 Plätze — Kontingent und Platte unverändert.
$after = app(PlanCatalogue::class)->currentVersion('test');
expect($after->ram_mb)->toBe(4096)
->and($after->cores)->toBe(2)
->and($after->seats)->toBe(3)
->and($after->quota_gb)->toBe(5)
->and($after->disk_gb)->toBe(20);
});
it('tut nichts an Intern, wenn es seine neuen Größen bereits trägt', function () {
// Der Fall einer Neuinstallation: die korrigierte erste Migration hat
// Intern schon richtig angelegt, und dieser Nachtrag darf keine dritte
// Version nachschieben.
$before = App\Models\PlanVersion::query()->where(
'plan_family_id', PlanFamily::query()->where('key', 'test')->value('id')
)->count();
(require database_path('migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php'))->up();
expect(App\Models\PlanVersion::query()->where(
'plan_family_id', PlanFamily::query()->where('key', 'test')->value('id')
)->count())->toBe($before)
->and(app(PlanCatalogue::class)->currentVersion('test')->ram_mb)->toBe(4096);
});
it('macht Enterprise verschenkbar statt käuflich', function () {
(require database_path('migrations/2026_08_01_000005_finish_the_plan_ladder_switch.php'))->up();
expect(array_keys(app(PlanCatalogue::class)->grantable()))->toContain('enterprise')
->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('enterprise');
});
it('schaltet kein zweites Mal um, wenn sie noch einmal gefahren wird', function () {

View File

@ -251,8 +251,10 @@ it('shows one support level per plan, and never a dash for the best of them', fu
//
// Enterprise gehört hier auf die Seite, denn es ist das Paket, an dem der
// Fehler entstand — die neue Leiter nimmt es aus dem Verkauf, und ohne
// diese Zeile prüfte der Test die Stufe „Premium" gar nicht mehr.
PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true]);
// diese Zeile prüfte der Test die Stufe „Premium" gar nicht mehr. Beide
// Schalter zurück auf "verkäuflich": `internal` hält es seit Aufgabe 11
// zusätzlich aus sellable() heraus, unabhängig von `sales_enabled`.
PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true, 'internal' => false]);
$content = $this->get('/')->assertOk()->getContent();
$row = priceSheetRow($content, 'Support');
@ -335,7 +337,8 @@ it('leaves Enterprise in the comparison table while it is still on sale', functi
// against the CTA text rather than the (now interpolated) heading — a
// heading built with no replacement never matches what the page actually
// renders anyway, sellable or not, and would prove nothing either way.
PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true]);
// Beide Schalter zurück auf "verkäuflich" (siehe Test oben).
PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true, 'internal' => false]);
$content = $this->get('/')->assertOk()->getContent();
@ -344,10 +347,13 @@ it('leaves Enterprise in the comparison table while it is still on sale', functi
});
it('shows Enterprise as a request for a quote once it is taken off sale', function () {
// Der Zustand, den die Umschaltung auf die neue Leiter gesetzt hat.
// Der Zustand, den Aufgabe 11 gesetzt hat: `internal`, nicht
// `sales_enabled = false` — Enterprise bleibt verschenkbar (grantable()),
// nur eben nicht mehr im Preisblatt (sellable()).
$content = $this->get('/')->assertOk()->getContent();
expect(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeFalsy();
expect(PlanFamily::query()->where('key', 'enterprise')->value('internal'))->toBeTruthy()
->and(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeTruthy();
expect($content)
// Business (175 GB auf der neuen Leiter) ist das größte Paket, das