diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 285a7b8..663aa90 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -49,6 +49,36 @@ use Throwable; */ class LandingController extends Controller { + /** + * Ob Office (ONLYOFFICE über den geplanten gemeinsamen Dokumentenserver) + * schon verkauft wird. + * + * Auf false, bis der Dokumentenserver steht — siehe App\Support\OpenWork::all(). + * Der einzige Schalter für dieses Versprechen HIER im Controller: nimmt + * unten in modulePrices() die Zeile `collabora_pro` aus der Modulliste, + * und __invoke() reicht ihn der View als `officeOnSale` weiter, die damit + * dieselbe Kachel „Office im Browser" aus der Aufzählung „Was enthalten + * ist" filtert. Beide Werbetexte (hier in ADDONS und in landing.blade.php) + * bleiben dabei stehen, statt gelöscht zu werden — sie müssen beim + * Wiederanschalten nicht neu geschrieben werden. + * + * Das Paketmerkmal `office` selbst steht NICHT hier: die aktuell verkaufte + * Version von Team und Business führt es seit + * database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php + * nicht mehr, darum verschwindet die zugehörige Zeile in der + * Vergleichstabelle schon dadurch, dass kein verkauftes Paket den + * Schlüssel mehr trägt (siehe comparison()) — bestehende Verträge tragen + * ihn unverändert weiter, und ihre eigene Ansicht in der Konsole/im Portal + * zeigt Office deshalb weiterhin als enthalten. + * + * Wieder anschalten, sobald der Dokumentenserver liefert: hier auf true + * stellen UND in der Konsole eine neue Planversion für Team und Business + * veröffentlichen, die `office` erneut führt — derselbe Weg, mit dem jede + * andere Änderung an einem Paket gemacht wird (PlanCatalogue::draft() / + * publish()), kein Sonderfall für dieses Merkmal. + */ + private const OFFICE_ON_SALE = false; + /** * Catalogue feature keys, in the words a customer uses. * @@ -180,6 +210,10 @@ class LandingController extends Controller 'name' => 'Bevorzugter Support', 'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.', ], + // Aus dem Verkauf genommen, solange OFFICE_ON_SALE oben false ist — + // der Text bleibt stehen, modulePrices() liefert für diesen Schlüssel + // aber keinen Preis mehr, solange die Konstante steht, und ohne Preis + // taucht der Eintrag in addons() gar nicht erst auf. 'collabora_pro' => [ 'name' => 'Office mit vollem Funktionsumfang', 'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.', @@ -229,6 +263,9 @@ class LandingController extends Controller 'comparison' => $this->comparison($plans, $modules), 'addons' => $this->addons($modules), 'enterprise' => $this->enterprise($plans), + // Der einzige Schalter für die Office-Kachel in der Aufzählung + // „Was enthalten ist" — siehe OFFICE_ON_SALE oben. + 'officeOnSale' => self::OFFICE_ON_SALE, ]); } @@ -416,6 +453,13 @@ class LandingController extends Controller $prices = []; foreach ($keys as $key) { + // Office ist vom Markt genommen (siehe OFFICE_ON_SALE) — die + // Pro-Stufe einer Grundfunktion, die es nicht gibt, gehört so + // wenig auf die Preistafel wie das Grundmodul selbst. + if ($key === 'collabora_pro' && ! self::OFFICE_ON_SALE) { + continue; + } + $cents = $catalogue->priceCents((string) $key); // Nothing is sold for nothing. An entry without a price reads diff --git a/app/Support/OpenWork.php b/app/Support/OpenWork.php index e26fb65..9da8739 100644 --- a/app/Support/OpenWork.php +++ b/app/Support/OpenWork.php @@ -43,11 +43,11 @@ final class OpenWork 'risk' => 'Wer es bucht, zahlt monatlich für eine Leistung, die nicht erbracht wird.', ], [ - 'title' => 'Office mit vollem Funktionsumfang wird verkauft, aber nicht geliefert', - 'state' => self::SOLD, - 'where' => 'config/provisioning.php → addons.collabora_pro', - 'missing' => 'Kostet 22,80 € im Monat und verspricht mehr gleichzeitige Bearbeiter und erweiterte Office-Funktionen. Es gibt keine Collabora-Konfiguration, die vom gebuchten Modul abhängt.', - 'risk' => 'Wie oben: bezahlt und nicht erbracht. Die Paketsperre für Start ist inzwischen gesetzt, das Modul selbst tut weiterhin nichts.', + 'title' => 'Office (ONLYOFFICE über einen gemeinsamen Dokumentenserver) ist aus dem Verkauf genommen', + 'state' => self::PLANNED, + 'where' => 'App\\Http\\Controllers\\LandingController::OFFICE_ON_SALE, database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php', + 'missing' => 'Die Preistafel versprach „Office im Browser" an drei Stellen — der Aufzählung „Was enthalten ist", dem Paketmerkmal `office` in Team und Business, und dem Zusatzmodul `collabora_pro` für 22,80 € im Monat —, obwohl weder die goldene Vorlage (deploy/bootstrap/assets/docker-compose.yml enthält nur Nextcloud und die Datenbank) noch ein Bereitstellungsschritt je ein Office installiert haben. Alle drei Stellen sind jetzt stillgelegt, aber nicht gelöscht: der Code-Schalter ist die Konstante `OFFICE_ON_SALE` (dokumentiert an ihrer eigenen Stelle mit den zwei Handgriffen, die ein Wiederanschalten braucht — die Konstante auf true, und in der Konsole eine neue Planversion für Team und Business veröffentlichen, die `office` wieder führt). Wer die beiden Pakete vor dieser Umschaltung gekauft hat, behält `office` in seinem eingefrorenen Vertrag unverändert — dieser Punkt betrifft ausschließlich, was NEU beworben wird. `collabora_pro` hat bislang niemand gebucht (subscription_addons enthielt zum Zeitpunkt dieser Änderung keine einzige Zeile dazu), es gibt also keinen laufenden Vertrag, den eine spätere Entscheidung träfe.', + 'risk' => 'Kein akuter Schaden mehr — die Preistafel verspricht nichts, was sie nicht einlöst. Der eigentliche Dokumentenserver ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.', ], [ 'title' => 'Der Rettungssystem-Weg liegt still', diff --git a/database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php b/database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php new file mode 100644 index 0000000..b101d09 --- /dev/null +++ b/database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php @@ -0,0 +1,149 @@ +dropOfficeFrom($key, $now); + } + }); + } + + /** + * Kein Rückweg — siehe der Klassenkommentar oben, derselbe Grund wie in + * `switch_to_new_plan_ladder`s `down()`. + */ + public function down(): void {} + + private function dropOfficeFrom(string $key, Carbon $now): void + { + $family = DB::table('plan_families')->where('key', $key)->first(); + + // Eine Familie, die es nicht gibt, wird nicht erfunden: dann ist + // dieser Bestand anders aufgebaut, und das gehört gesehen, nicht + // überschrieben. + if ($family === null) { + 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) { + return; + } + + $features = (array) json_decode((string) $current->features, true); + + // Schon ohne `office` — wiederholbar wie die Vorlage in + // `switch_to_new_plan_ladder`, kein zweiter Lauf legt eine leere + // Version obendrauf. + if (! in_array('office', $features, true)) { + return; + } + + $features = array_values(array_diff($features, ['office'])); + + 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, + // Jedes Feld außer `features` unverändert von der Zeile + // übernommen, die hier geschlossen wird — siehe Klassenkommentar. + 'quota_gb' => $current->quota_gb, + 'traffic_gb' => $current->traffic_gb, + 'seats' => $current->seats, + 'free_months' => $current->free_months, + 'ram_mb' => $current->ram_mb, + 'cores' => $current->cores, + 'disk_gb' => $current->disk_gb, + 'performance' => $current->performance, + 'template_vmid' => $current->template_vmid, + 'features' => json_encode($features), + 'available_from' => $now, + 'available_until' => null, + 'published_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + foreach (DB::table('plan_prices')->where('plan_version_id', $current->id)->get() as $price) { + DB::table('plan_prices')->insert([ + 'uuid' => (string) Str::uuid(), + 'plan_version_id' => $versionId, + 'term' => $price->term, + 'amount_cents' => $price->amount_cents, + 'currency' => $price->currency, + // Neu statt übernommen: eine neue Version ist ein neuer + // Stripe Price, den StripeProductAdoption bei Bedarf anlegt — + // derselbe Weg wie in `switch_to_new_plan_ladder`. + 'stripe_price_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +}; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index ecf6c6f..d8e53ef 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -181,14 +181,19 @@

- @foreach ([ - ['cloud', 'Dateien & Synchronisation'], - ['file-text', 'Office im Browser'], - ['calendar', 'Kalender, Kontakte, Aufgaben'], - ['users', 'Videokonferenz & Chat'], - ['external-link', 'Freigabelinks mit Ablauf'], - ['rotate-ccw', 'Versionierung & Papierkorb'], - ] as [$icon, $feature]) + {{-- „Office im Browser" bleibt in der Liste stehen und wird nur + herausgefiltert (App\Http\Controllers\LandingController::OFFICE_ON_SALE), + damit die Zeile beim Wiederanschalten nicht neu getippt + werden muss. --}} + @foreach (collect([ + ['cloud', 'Dateien & Synchronisation'], + ['file-text', 'Office im Browser'], + ['calendar', 'Kalender, Kontakte, Aufgaben'], + ['users', 'Videokonferenz & Chat'], + ['external-link', 'Freigabelinks mit Ablauf'], + ['rotate-ccw', 'Versionierung & Papierkorb'], + ])->reject(fn ($item) => $item[1] === 'Office im Browser' && ! $officeOnSale) + as [$icon, $feature])
{!! $feature !!}
diff --git a/tests/Feature/Admin/OpenWorkTest.php b/tests/Feature/Admin/OpenWorkTest.php index bade8b8..8073502 100644 --- a/tests/Feature/Admin/OpenWorkTest.php +++ b/tests/Feature/Admin/OpenWorkTest.php @@ -38,13 +38,28 @@ it('nennt zu jedem Punkt wo, was fehlt und was passiert, wenn es so bleibt', fun } }); -it('führt die zwei bezahlten Module, die nichts tun, als solche', function () { - // Der Anlass für die ganze Seite. Verschwinden sie hier stillschweigend, - // ist die Liste wertlos — sie kosten Geld, solange sie offen sind. +it('führt das bezahlte Modul, das nichts tut, als solches', function () { + // Der Anlass für die ganze Seite. Verschwindet es hier stillschweigend, + // ist die Liste wertlos — es kostet Geld, solange es offen ist. + // + // Bis zur Office-Umstellung standen hier zwei Module (Sicherungsort UND + // Office) — Office ist jetzt kein SOLD-Punkt mehr, siehe die eigene + // Prüfung unten, die genau diesen Wechsel festhält. $sold = collect(OpenWork::all())->where('state', OpenWork::SOLD)->pluck('title')->implode(' '); - expect($sold)->toContain('Sicherungsort') - ->and($sold)->toContain('Office'); + expect($sold)->toContain('Sicherungsort'); +}); + +it('führt Office nicht mehr als verkauft, sondern als aus dem Verkauf genommen', function () { + // Der Grund, warum der Test oben nur noch ein Modul findet: Office wurde + // von der Preistafel genommen, statt weiter verkauft und nicht geliefert + // zu werden — der Unterschied zwischen SOLD (kostet gerade Geld) und + // PLANNED (tut nichts, bis jemand anfängt) ist genau das, was die + // Reihenfolge auf der Seite bestimmt (siehe ORDER unten). + $office = collect(OpenWork::all())->first(fn (array $item) => str_contains($item['title'], 'Office')); + + expect($office)->not->toBeNull() + ->and($office['state'])->toBe(OpenWork::PLANNED); }); it('steht jedem offen, der die Konsole öffnen darf', function () { diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 0c5568e..5a2e04e 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -31,13 +31,18 @@ function teamFamily(): PlanFamily /** * Der Entwurf, den `draft()` gerade angelegt hat. * - * Version 3, nicht 2: die Umschaltung auf die neue Leiter hat Version 1 - * beendet und Version 2 veröffentlicht, und ein neuer Entwurf zählt von dort - * weiter. Die Zahl steht hier einmal, statt in acht Tests. + * Über `published_at IS NULL` gefunden, nicht über eine feste Versionsnummer: + * welche Nummer als Nächstes frei ist, hängt daran, wie oft die laufende + * Version schon abgelöst wurde — die Umschaltung auf die neue Leiter hat + * Version 1 beendet und Version 2 veröffentlicht, das Herausnehmen von Office + * aus der Merkmalsliste hat Version 2 beendet und Version 3 veröffentlicht, + * und eine feste Zahl hier müsste bei jeder weiteren Umschaltung von Neuem + * nachgezogen werden. „Der eine unveröffentlichte Entwurf" ist dagegen immer + * dieselbe Frage, ganz gleich, bei welcher Nummer der Katalog gerade steht. */ function teamDraft(): PlanVersion { - return teamFamily()->versions()->where('version', 3)->sole(); + return teamFamily()->versions()->whereNull('published_at')->sole(); } it('is closed to operators without the capability', function () { @@ -164,9 +169,11 @@ it('refuses a mistyped figure with a message instead of overflowing the column', ->call('draft') ->assertHasErrors(['monthlyPrice', 'ramMb']); - // Zwei: die beendete erste Fassung und die, die seit der Umschaltung - // verkauft wird. Der abgelehnte Entwurf ist keine dritte geworden. - expect(teamFamily()->versions()->count())->toBe(2); + // Drei: die beiden beendeten Fassungen (Umschaltung auf die neue Leiter, + // dann Herausnehmen von Office aus der Merkmalsliste) und die, die seit + // Letzterem verkauft wird. Der abgelehnte Entwurf ist keine vierte + // geworden. + expect(teamFamily()->versions()->count())->toBe(3); }); it('does not call a version live when the plan is withdrawn', function () { @@ -228,8 +235,11 @@ it('publishes a draft straight over the running version, announcing the handover expect($draft->fresh()->isPublished())->toBeTrue() ->and($live->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue() - ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(2) - ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(3); + // Dynamisch statt fest getippt: $live ist die Fassung, auf der der + // Test aufsetzt, ganz gleich, bei welcher Nummer der Katalog gerade + // steht, und $draft ist die, die sie ablöst. + ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe($live->version) + ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe($draft->version); }); it('puts a closed version back on sale from the version list', function () { @@ -278,7 +288,8 @@ it('refuses to reopen a version whose successor is selling, in the console\'s ow ->assertDontSee('overlaps'); expect($live->fresh()->available_until)->not->toBeNull() - ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(3); + // Dynamisch statt fest getippt — siehe teamDraft() oben. + ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe($draft->version); }); it('will not reopen a draft, and says why instead of doing nothing', function () { diff --git a/tests/Feature/Billing/NewPlanLadderTest.php b/tests/Feature/Billing/NewPlanLadderTest.php index 56c7471..0638bcc 100644 --- a/tests/Feature/Billing/NewPlanLadderTest.php +++ b/tests/Feature/Billing/NewPlanLadderTest.php @@ -1,6 +1,7 @@ and(App\Services\Billing\StorageAllowance::for($instance)->planGb)->toBe(500) ->and($subscription->fresh()->price_cents)->toBe(17900); }); + +/** + * Office verlässt die Merkmalsliste — siehe App\Support\OpenWork::all() für + * den Befund und die Migration 2026_08_05_120000_office_verlaesst_team_und_business.php + * für den Weg. Eigene Prüfung statt einer Ergänzung der Zahlen oben: hier geht + * es um ein MERKMAL, nicht um Kontingent oder Preis, und beide dürfen sich + * durch diese Umschaltung nicht bewegt haben. + */ +it('nimmt Office aus der Merkmalsliste von Team und Business, ohne Kontingent oder Preis zu berühren', function () { + $sellable = app(PlanCatalogue::class)->sellable(); + + expect($sellable['team']['features'])->not->toContain('office') + ->and($sellable['business']['features'])->not->toContain('office') + // Dieselben neun Zahlen wie oben — nur das eine Merkmal ist weg. + ->and($sellable['team']['quota_gb'])->toBe(85) + ->and($sellable['team']['price_cents'])->toBe(7900) + ->and($sellable['business']['quota_gb'])->toBe(175) + ->and($sellable['business']['price_cents'])->toBe(13900); +}); + +it('schaltet Office kein zweites Mal heraus, wenn die Migration erneut läuft', function () { + // Dasselbe Muster wie oben bei der Leiter selbst: ein wiederholtes + // `migrate` darf keine dritte Fassung nachschieben. + $before = PlanVersion::query()->count(); + + (require database_path('migrations/2026_08_05_120000_office_verlaesst_team_und_business.php'))->up(); + + expect(PlanVersion::query()->count())->toBe($before); +}); + +it('lässt einen Vertrag, der Office gekauft hat, bei seinem eigenen Merkmal', function () { + // Eine eingefrorene Fassung, wie sie ein Team-Kunde vor dieser Umschaltung + // gekauft hat — sie trägt `office` noch, weil ihr Vertrag es versprochen + // bekam, ganz gleich, was der Katalog heute verkauft. + $oldVersion = PlanVersion::query() + ->whereHas('family', fn ($q) => $q->where('key', 'team')) + ->whereNotNull('available_until') + ->latest('version') + ->firstOrFail(); + + expect($oldVersion->features)->toContain('office'); + + $subscription = Subscription::factory()->create(Subscription::snapshotFrom('team', 'monthly', $oldVersion->id)); + + // Der eigene Vertrag bleibt beim Merkmal, das er gekauft hat — der Katalog + // von heute schreibt es ihm nicht um. Genau das ist gemeint mit + // „Bestandskunden verlieren nichts": es geht ausschließlich darum, was + // NEU beworben wird. + expect($subscription->planVersion->features)->toContain('office') + ->and(app(PlanCatalogue::class)->sellable()['team']['features'])->not->toContain('office'); +}); diff --git a/tests/Feature/Billing/PlanCatalogueTest.php b/tests/Feature/Billing/PlanCatalogueTest.php index f3589e3..e446d43 100644 --- a/tests/Feature/Billing/PlanCatalogueTest.php +++ b/tests/Feature/Billing/PlanCatalogueTest.php @@ -77,10 +77,12 @@ it('records which version a contract was sold under', function () { $snapshot = Subscription::snapshotFrom('team'); $version = app(PlanCatalogue::class)->version($snapshot['plan_version_id']); - // Version 2: the ladder switch closed v1 and published its successor, so a - // contract opened today records the second one. + // Version 3: die Umschaltung auf die neue Leiter beendete v1 und + // veröffentlichte ihren Nachfolger (v2), und das Herausnehmen von Office + // aus der Merkmalsliste beendete v2 und veröffentlichte v3 — ein Vertrag, + // der heute eröffnet wird, verzeichnet also den dritten. expect($version->family->key)->toBe('team') - ->and($version->version)->toBe(2); + ->and($version->version)->toBe(3); }); it('contracts a checkout to the version it was shown, not the one that replaced it', function () { @@ -94,7 +96,9 @@ it('contracts a checkout to the version it was shown, not the one that replaced $successor = PlanVersion::query()->create([ ...$shown->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), - 'version' => 3, 'features' => $shown->features, 'available_from' => now(), + // Die nächste Nummer nach der laufenden statt fest getippt: eine feste + // 3 kollidiert seit Office aus der Merkmalsliste ist mit $shown selbst. + 'version' => $shown->version + 1, 'features' => $shown->features, 'available_from' => now(), ]); $successor->prices()->create(['term' => 'monthly', 'amount_cents' => 24900, 'currency' => $currency]); $successor->prices()->create(['term' => 'yearly', 'amount_cents' => 298800, 'currency' => $currency]); @@ -166,10 +170,11 @@ it('numbers drafts in sequence without colliding', function () { $first = $catalogue->draft($family, $capabilities, ['monthly' => 18900, 'yearly' => 226800]); $second = $catalogue->draft($family, $capabilities, ['monthly' => 19900, 'yearly' => 238800]); - // Three and four: the seeded version and the one the ladder switch - // published are already there. - expect($first->version)->toBe(3) - ->and($second->version)->toBe(4) + // Four and five: die gesäte Fassung, die von der Umschaltung auf die neue + // Leiter veröffentlichte, und die vom Herausnehmen von Office aus der + // Merkmalsliste nachgeschobene stehen schon davor. + expect($first->version)->toBe(4) + ->and($second->version)->toBe(5) ->and($second->priceFor('monthly')->amount_cents)->toBe(19900); }); @@ -270,9 +275,11 @@ it('crashes instead of choosing between two versions on sale at once', function $first = app(PlanCatalogue::class)->currentVersion('team'); // Forced past the guard, the way a bad migration or a direct edit would. + // Die Nummer muss nur von $first abweichen — die eindeutige Zusammensetzung + // ist (Familie, Version), nicht die Zahl an sich. PlanVersion::query()->create([ ...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), - 'version' => 3, + 'version' => $first->version + 1, 'features' => $first->features, 'available_from' => now()->subDay(), 'available_until' => null, @@ -293,7 +300,8 @@ it('refuses a window that overlaps another version of the same plan', function ( $second = PlanVersion::query()->create([ ...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), - 'version' => 3, + // Die nächste Nummer nach $first statt fest getippt — siehe oben. + 'version' => $first->version + 1, 'features' => $first->features, 'available_from' => now()->addMonth(), ]); @@ -311,7 +319,7 @@ it('refuses a window that overlaps another version of the same plan', function ( // Starting exactly where the predecessor stops is not an overlap. $catalogue->schedule($second, now()->addMonth()); - expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe(3); + expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe($second->version); }); it('locks a version once it is published, but still lets its window move', function () { @@ -329,7 +337,9 @@ it('locks a version once it is published, but still lets its window move', funct it('lets an unpublished draft be edited freely', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); $draft = PlanVersion::query()->create([ - 'plan_family_id' => $family->id, 'version' => 3, + // Eine freie Nummer statt fest 3 — die laufende Fassung von Team belegt + // sie schon, seit Office aus der Merkmalsliste ist. + 'plan_family_id' => $family->id, 'version' => $family->versions()->max('version') + 1, 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, 'features' => ['monitoring'], 'available_from' => now()->addMonth(), @@ -360,7 +370,8 @@ it('will not sell a version that provisioning could not build', function () { it('will not publish a version that is not priced for every term', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); $draft = PlanVersion::query()->create([ - 'plan_family_id' => $family->id, 'version' => 3, + // Eine freie Nummer statt fest 3 — siehe die gleiche Stelle weiter oben. + 'plan_family_id' => $family->id, 'version' => $family->versions()->max('version') + 1, 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, 'features' => [], 'available_from' => now(), @@ -386,14 +397,18 @@ it('leaves the version unpublished and its predecessor untouched when the window $v2 = PlanVersion::query()->create([ ...$v1->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), - 'version' => 3, 'features' => $v1->features, 'available_from' => now()->addMonth(), + // Die nächste Nummer nach $v1 statt fest 3 — $v1 selbst trägt sie schon, + // seit Office aus der Merkmalsliste ist. + 'version' => $v1->version + 1, 'features' => $v1->features, 'available_from' => now()->addMonth(), ]); $v2->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]); $v2->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]); $catalogue->publish($v2, now()->addMonth()); + // Absichtlich zwei über $v2 hinaus statt direkt eine Nummer weiter: der + // Sprung zeigt, dass PlanCatalogue keine lückenlose Zählung verlangt. $draft = PlanVersion::query()->create([ - 'plan_family_id' => $family->id, 'version' => 5, + 'plan_family_id' => $family->id, 'version' => $v2->version + 2, 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, 'features' => [], 'available_from' => now(), @@ -416,7 +431,7 @@ it('leaves the version unpublished and its predecessor untouched when the window // A window that fits between the two is accepted, and takes v1 over then. $catalogue->publish($draft, now()->addWeek(), now()->addMonth()); - expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(5) + expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe($draft->version) ->and($v1->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue(); }); diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index d15d345..d90476f 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -20,7 +20,9 @@ it('freezes what the customer bought against later price rises', function () { $dearer = PlanVersion::query()->create([ ...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), - 'version' => 3, 'features' => $current->features, 'available_from' => now(), + // Die nächste Nummer nach der laufenden statt fest getippt — eine feste + // 3 kollidiert seit Office aus der Merkmalsliste ist mit $current selbst. + 'version' => $current->version + 1, 'features' => $current->features, 'available_from' => now(), ]); $dearer->prices()->create(['term' => 'monthly', 'amount_cents' => $bookedPrice * 2, 'currency' => $currency]); $dearer->prices()->create(['term' => 'yearly', 'amount_cents' => $bookedPrice * 24, 'currency' => $currency]); diff --git a/tests/Feature/Billing/ProofRegisterTest.php b/tests/Feature/Billing/ProofRegisterTest.php index cfbe6cd..7e7d3da 100644 --- a/tests/Feature/Billing/ProofRegisterTest.php +++ b/tests/Feature/Billing/ProofRegisterTest.php @@ -52,9 +52,11 @@ it('records the sale the moment a contract is opened', function () { // Flat columns, because these are what gets searched and relied on. expect($record->event)->toBe('purchase') ->and($record->plan_key)->toBe('team') - // Die zweite Fassung: die Umschaltung auf die neue Leiter hat die erste - // beendet und diese nachgeschoben. - ->and($record->plan_version)->toBe(2) + // Die dritte Fassung: die Umschaltung auf die neue Leiter hat die + // erste beendet (Version 2), und das Herausnehmen von Office aus der + // Merkmalsliste hat Version 2 ihrerseits beendet und Version 3 + // nachgeschoben (2026_08_05_120000_office_verlaesst_team_und_business.php). + ->and($record->plan_version)->toBe(3) ->and($record->term)->toBe('monthly') ->and($record->net_cents)->toBe(7900) // what was agreed ->and($record->gross_cents)->toBe(9480) // what was charged diff --git a/tests/Feature/Billing/ReverseChargePriceTest.php b/tests/Feature/Billing/ReverseChargePriceTest.php index 59bef23..70e7357 100644 --- a/tests/Feature/Billing/ReverseChargePriceTest.php +++ b/tests/Feature/Billing/ReverseChargePriceTest.php @@ -342,21 +342,25 @@ it('creates no second Price for a figure it already has, however often it is run $this->artisan('stripe:sync-catalogue')->assertSuccessful(); - // Twelve priced rows: three SELLABLE families (Start, Team, Business) on - // six versions — the ladder switch left a closed predecessor beside each - // one it replaced — at two terms each. A closed version keeps its Prices - // because a checkout quoted on it is still owed them. Enterprise and the - // internal package are `internal = true` and stripe:sync-catalogue skips - // them entirely since Task 12 — a package nobody is ever charged through - // Stripe gets no Stripe Price, because one cannot be deleted once minted. + // Sechzehn Preiszeilen: drei VERKÄUFLICHE Familien (Start, Team, Business) + // auf acht Versionen — die Umschaltung auf die neue Leiter ließ bei allen + // dreien eine beendete Vorgängerfassung neben der neuen stehen, und das + // Herausnehmen von Office aus der Merkmalsliste hat Team und Business noch + // eine dritte beschert (2026_08_05_120000_office_verlaesst_team_und_business.php) + // — zu je zwei Laufzeiten. Eine beendete Version behält ihre Preise, denn + // eine auf ihr eröffnete Bestellung ist sie noch schuldig. Enterprise und + // das interne Paket sind `internal = true`, und stripe:sync-catalogue lässt + // sie seit Task 12 ganz aus — ein Paket, das nie über Stripe abgerechnet + // wird, bekommt keinen Stripe-Preis, denn einer lässt sich nicht wieder + // löschen, sobald er geprägt ist. expect($this->stripe->prices)->toHaveCount($before) - // Two rows per priced catalogue row, both live, one per treatment. - ->and(StripePlanPrice::query()->count())->toBe(24) - ->and(StripePlanPrice::query()->whereNull('archived_at')->count())->toBe(24) - ->and(StripePlanPrice::query()->where('reverse_charge', true)->count())->toBe(12) + // Zwei Preise je Katalogzeile, beide aktiv, einer je Steuerbehandlung. + ->and(StripePlanPrice::query()->count())->toBe(32) + ->and(StripePlanPrice::query()->whereNull('archived_at')->count())->toBe(32) + ->and(StripePlanPrice::query()->where('reverse_charge', true)->count())->toBe(16) // And no Price at one figure for one row twice, which is the thing the // register's unique key exists to make impossible. - ->and(StripePlanPrice::query()->distinct()->count('stripe_price_id'))->toBe(24); + ->and(StripePlanPrice::query()->distinct()->count('stripe_price_id'))->toBe(32); }); it('leaves the net Price alone when the VAT rate moves, and puts a Price back on sale rather than minting a second', function () { @@ -389,11 +393,12 @@ it('leaves the net Price alone when the VAT rate moves, and puts a Price back on ->and($this->stripe->activated)->toContain($domesticBefore) ->and($this->stripe->archived)->not->toContain($domesticBefore) // What the rate change minted and nothing more: one 10 % gross Price for - // each of the twelve SELLABLE priced rows (Enterprise and the internal - // package are skipped, Task 12) and for each module on each interval. + // each of the sixteen SELLABLE priced rows (Enterprise and the internal + // package are skipped, Task 12; siehe die sechzehn oben seit Office aus + // der Merkmalsliste ist) and for each module on each interval. // No duplicate of anything that existed before, and nothing new on the // net side at all. - ->and($this->stripe->prices)->toHaveCount($count + 12 + $modules * 2); + ->and($this->stripe->prices)->toHaveCount($count + 16 + $modules * 2); }); it('refuses the sale rather than overcharging a business the catalogue has no net price for', function () { diff --git a/tests/Feature/Billing/StripeBillingTest.php b/tests/Feature/Billing/StripeBillingTest.php index 2a152d5..fd2bb92 100644 --- a/tests/Feature/Billing/StripeBillingTest.php +++ b/tests/Feature/Billing/StripeBillingTest.php @@ -77,14 +77,17 @@ it('mirrors the catalogue into Stripe, once', function () { // `internal = true` und werden von stripe:sync-catalogue seit Aufgabe 12 // übersprungen — ein Paket, das nie über Stripe abgerechnet wird, bekommt // keinen Stripe-Preis, weil sich ein einmal angelegter nicht löschen lässt. - // Zwölf ausgepreiste Zeilen, weil die Umschaltung auf die neue Leiter neben - // jede der drei verkauften eine beendete Vorgängerfassung gestellt hat — - // und deren Preise bleiben stehen, denn ein Checkout, der auf ihr eröffnet - // wurde, hat sie gesehen und bezahlt. + // Sechzehn ausgepreiste Zeilen, weil die Umschaltung auf die neue Leiter + // neben jede der drei verkauften eine beendete Vorgängerfassung gestellt + // hat, und das Herausnehmen von Office aus der Merkmalsliste Team und + // Business noch eine dritte beschert + // (2026_08_05_120000_office_verlaesst_team_und_business.php) — deren + // Preise bleiben allesamt stehen, denn ein Checkout, der auf einer von + // ihnen eröffnet wurde, hat sie gesehen und bezahlt. expect(planProducts($stripe))->toHaveCount(3) - ->and(planPrices($stripe))->toHaveCount(24) - ->and(planPrices($stripe, 'domestic'))->toHaveCount(12) - ->and(planPrices($stripe, 'reverse_charge'))->toHaveCount(12) + ->and(planPrices($stripe))->toHaveCount(32) + ->and(planPrices($stripe, 'domestic'))->toHaveCount(16) + ->and(planPrices($stripe, 'reverse_charge'))->toHaveCount(16) ->and(PlanFamily::query()->where('internal', false)->whereNull('stripe_product_id')->count())->toBe(0) // The catalogue row still points at the DOMESTIC Price, which is the // ordinary sale; the net one is only ever read out of the register. @@ -121,7 +124,7 @@ it('mirrors the catalogue into Stripe, once', function () { // would leave two live prices for one plan and no way to tell them apart. $this->artisan('stripe:sync-catalogue')->assertSuccessful(); - expect(planPrices($stripe))->toHaveCount(24) + expect(planPrices($stripe))->toHaveCount(32) ->and(modulePrices($stripe))->toHaveCount(count($modules) * 4) ->and($stripe->products)->toHaveCount(3 + count($modules)); }); @@ -201,8 +204,8 @@ it('gives each price its own recurring interval', function () { // Monthly and yearly cannot share a Price, because the interval belongs to // the Price itself. - expect($intervals['month'])->toBe(6) - ->and($intervals['year'])->toBe(6); + expect($intervals['month'])->toBe(8) + ->and($intervals['year'])->toBe(8); // Die Fassung im Verkauf, ausdrücklich benannt: seit der Umschaltung auf die // neue Leiter gibt es zwei monatliche Team-Preise bei Stripe, und „der @@ -218,7 +221,10 @@ it('gives each price its own recurring interval', function () { && $p['metadata']['plan_version'] === $live); expect($team['amount'])->toBe(9480) ->and($team['amount'])->toBe(TaxTreatment::advertisedCents(7900)) - ->and($team['metadata']['plan_version'])->toBe('2'); + // Dieselbe dynamisch gelesene Nummer wie $live oben, nicht mehr fest + // '2' getippt: seit Office aus der Merkmalsliste ist (2026_08_05_120000_ + // office_verlaesst_team_und_business.php), läuft Team auf Version 3. + ->and($team['metadata']['plan_version'])->toBe($live); // And the bare net beside it, on the same Product, for the business that owes // no VAT at all. One Price for everybody was the overcharge: 94,80 € taken @@ -245,7 +251,7 @@ it('does not put an unpublished draft in the price list', function () { // A draft has promised nothing; a Price for it would be a price list entry // for something that may never exist. - expect(planPrices($stripe))->toHaveCount(24) + expect(planPrices($stripe))->toHaveCount(32) ->and(planPrices($stripe)->pluck('amount'))->not->toContain(19900); }); @@ -264,7 +270,7 @@ it('does not mint a second object when a run is interrupted before the id is sto // catalogue reconnects to what is already there instead of duplicating it — // and a Price cannot be deleted afterwards to tidy up. expect(planProducts($stripe))->toHaveCount(3) - ->and(planPrices($stripe))->toHaveCount(24) + ->and(planPrices($stripe))->toHaveCount(32) ->and(PlanPrice::query() ->whereHas('version.family', fn ($q) => $q->where('internal', false)) ->whereNull('stripe_price_id')->count())->toBe(0); @@ -645,7 +651,10 @@ it('never rewrites the snapshot when a renewal arrives', function () { $dearer = PlanVersion::query()->create([ ...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), - 'version' => 3, 'features' => $current->features, 'available_from' => now(), + // Die nächste Nummer nach der laufenden, statt fest getippt: seit + // Office aus der Merkmalsliste ist, läuft Team schon auf Version 3, + // und eine feste 3 hier würde mit genau dieser Zeile kollidieren. + 'version' => $current->version + 1, 'features' => $current->features, 'available_from' => now(), ]); $dearer->prices()->create(['term' => 'monthly', 'amount_cents' => 29900, 'currency' => 'EUR']); $dearer->prices()->create(['term' => 'yearly', 'amount_cents' => 358800, 'currency' => 'EUR']); diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index a5689f3..da1be88 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -292,7 +292,11 @@ it('names the modules it sells, with the prices the catalogue charges for them', $page->assertSee('Optional dazubuchbar'); - foreach (['extra_backups', 'priority_support', 'collabora_pro', 'custom_domain'] as $key) { + // `collabora_pro` fehlt hier absichtlich: es ist konfiguriert (BookAddon + // kennt den Preis weiterhin, für ein Portal, das niemand hier prüft), aber + // vom Markt genommen — siehe die eigene Prüfung unten und + // LandingController::OFFICE_ON_SALE. + foreach (['extra_backups', 'priority_support', 'custom_domain'] as $key) { $page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false); } @@ -301,6 +305,33 @@ it('names the modules it sells, with the prices the catalogue charges for them', ->assertSee($money((int) config('provisioning.storage_addon.price_cents')), false); }); +/** + * Office liefert nicht — siehe App\Support\OpenWork::all() — und darf deshalb + * an keiner der drei Stellen stehen, an denen die Preistafel es heute + * anbietet: der Aufzählung „Was enthalten ist", der Vergleichstabelle (über + * das Paketmerkmal `office`) und der Modulliste (`collabora_pro`, der + * Aufpreis auf ein Office, das es nicht gibt). + * + * Absichtlich rot gedreht während der Entwicklung — `OFFICE_ON_SALE` auf + * `true` gestellt zeigte alle drei Zeilen wieder, dieser Test schlug wie + * erwartet fehl. Siehe den Bericht für das Ergebnis. + */ +it('bewirbt Office nicht, solange der Dokumentenserver nicht steht', function () { + $page = $this->get('/')->assertOk(); + + // Die Kachel in „Was enthalten ist". + $page->assertDontSee('Office im Browser'); + + // Die Zeile in der Vergleichstabelle — verschwindet von selbst, weil kein + // verkauftes Paket das Merkmal `office` mehr trägt (siehe die Migration + // 2026_08_05_120000_office_verlaesst_team_und_business.php). + $page->assertDontSee('Office-Integration'); + + // Das Zusatzmodul und sein Preis in der Liste „Optional dazubuchbar". + $page->assertDontSee('Office mit vollem Funktionsumfang') + ->assertDontSee(priceSheetGross((int) config('provisioning.addons.collabora_pro.price_cents')), false); +}); + it('keeps the sheet up when nothing is on sale on top of a package', function () { // An installation that sells no modules is a configuration, not a failure. // The block disappears; the cells that could have been an offer go back to