Office aus dem Verkauf nehmen, abschaltbar statt gelöscht
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
parent
033578d75d
commit
42fd059fdd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -181,14 +181,19 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="mt-8 grid gap-2 sm:grid-cols-2">
|
||||
@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])
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue