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