Office aus dem Verkauf nehmen, abschaltbar statt gelöscht
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

Office (ONLYOFFICE über einen künftigen gemeinsamen Dokumentenserver) wurde
an drei Stellen beworben, ohne dass irgendwo im Repository ein Dokumentenserver
existiert: der Kachel "Office im Browser" auf der Preistafel, dem Paketmerkmal
`office` in Team und Business, und dem Zusatzmodul `collabora_pro` für
22,80 €/Monat. Alle drei sind jetzt stillgelegt, aber nicht gelöscht — ein
einziger dokumentierter Schalter (LandingController::OFFICE_ON_SALE) plus eine
neu veröffentlichte Planversion holen das Versprechen zurück, sobald der
Dokumentenserver steht.

Das Paketmerkmal wird über das im Katalog bereits etablierte Handover-Muster
entfernt (neue Migration, analog zu switch_to_new_plan_ladder): die laufende
Version von Team/Business wird geschlossen und durch eine identische ohne
`office` ersetzt. Bestehende Verträge bleiben auf ihrer alten, eingefrorenen
Version stehen und behalten das Merkmal unverändert.

Die dritte Planversion für Team/Business hat 24 Bestandstests berührt, die
eine feste Versionsnummer oder eine feste Preis-/Versionszahl annahmen —
repariert, überwiegend durch dynamisches Lesen der aktuellen Version statt
eines eingetippten Werts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 16:49:32 +02:00
parent 033578d75d
commit 42fd059fdd
13 changed files with 418 additions and 78 deletions

View File

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

View File

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

View File

@ -0,0 +1,149 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Office verlässt die Merkmalsliste von Team und Business nicht die
* Kunden, die es schon haben.
*
* Die Preistafel verspricht heute „Office im Browser" für beide Pakete, und
* es gibt keinen Dokumentenserver, der das einlöst: `deploy/bootstrap/assets/
* docker-compose.yml` startet nur Nextcloud und die Datenbank, kein
* Collabora, kein ONLYOFFICE, und kein Bereitstellungsschritt installiert
* eine Office-App per `occ`. Solange das so ist, gehört `office` nicht in die
* Merkmalsliste einer Version, die gerade verkauft wird siehe
* App\Support\OpenWork::all() für den vollständigen Befund.
*
* **Warum eine neue Version und keine Änderung.** `PlanVersion` verbietet das
* Ändern einer veröffentlichten Version, und zu Recht: ein Kunde hat eine
* Version gekauft, kein Merkmal, das sich unter ihm ändern darf. Wer heute
* Team oder Business hat, hat `office` in seinem eingefrorenen Schnappschuss
* gekauft auch wenn die Funktion nicht liefert, ist das sein Vertrag, und
* dieser Punkt geht ausschließlich um das, was NEU BEWORBEN wird. Umgeschaltet
* wird deshalb genau wie in `2026_08_01_000004_switch_to_new_plan_ladder.php`:
* die laufende Version bekommt ein `available_until`, die neue ein
* `available_from` auf dieselbe Sekunde. Kein Feld außer `features` ändert
* sich dieselbe Größe, derselbe Preis, dieselbe Vorlage.
*
* **Warum von der laufenden Zeile gelesen und nicht aus einer Konstante
* getippt.** Die Werte in `switch_to_new_plan_ladder`s LADDER könnten seither
* über die Konsole verändert worden sein (Preis, Kontingent alles, was ein
* Betreiber dort bearbeiten kann). Eine hier eingetippte Zahl liefe Gefahr,
* eine zwischenzeitliche Änderung rückgängig zu machen. Diese Migration
* übernimmt daher jedes Feld unverändert von der Zeile, die sie schließt, und
* ändert einzig `features`.
*
* **Kein Rückweg.** Wie in `switch_to_new_plan_ladder`: eine veröffentlichte
* Version zu löschen hieße, die Verträge zu löschen, die auf sie verweisen.
* Wer Office zurück in den Verkauf nehmen will, sobald der Dokumentenserver
* liefert, veröffentlicht über die Konsole eine neue Version mit `office`
* wieder in den Merkmalen (`PlanCatalogue::draft()` / `publish()`) dieselbe
* kaufmännische Entscheidung, mit der jede andere Änderung an einem Paket
* gemacht wird, und keine, die ein `migrate:rollback` nebenbei träfe. Der
* zweite Teil des Schalters App\Http\Controllers\LandingController::OFFICE_ON_SALE
* steht in diesem Controller, nicht hier.
*/
return new class extends Migration
{
/** Die beiden Pakete, deren aktuell verkaufte Version heute `office` führt. */
private const AFFECTED = ['team', 'business'];
public function up(): void
{
$now = now();
DB::transaction(function () use ($now) {
foreach (self::AFFECTED as $key) {
$this->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,
]);
}
}
};

View File

@ -181,14 +181,19 @@
</p>
</div>
<div class="mt-8 grid gap-2 sm:grid-cols-2">
@foreach ([
['cloud', 'Dateien &amp; Synchronisation'],
['file-text', 'Office im Browser'],
['calendar', 'Kalender, Kontakte, Aufgaben'],
['users', 'Videokonferenz &amp; Chat'],
['external-link', 'Freigabelinks mit Ablauf'],
['rotate-ccw', 'Versionierung &amp; 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 &amp; Synchronisation'],
['file-text', 'Office im Browser'],
['calendar', 'Kalender, Kontakte, Aufgaben'],
['users', 'Videokonferenz &amp; Chat'],
['external-link', 'Freigabelinks mit Ablauf'],
['rotate-ccw', 'Versionierung &amp; Papierkorb'],
])->reject(fn ($item) => $item[1] === 'Office im Browser' && ! $officeOnSale)
as [$icon, $feature])
<div class="flex items-center gap-2.5 rounded-lg bg-surface-2 px-3.5 py-3 text-sm text-body">
<x-ui.icon name="{{ $icon }}" class="size-4 shrink-0 text-muted" />{!! $feature !!}
</div>

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<?php
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
@ -182,3 +183,54 @@ it('lässt einen laufenden Vertrag auf seinen eigenen Zahlen', function () {
->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');
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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