Die neue Leiter: Start 39, Team 79, Business 139

Zugeschnitten auf die Maschine, die dasteht. 388 GB vergebbar geteilt durch
40 GB Platte sind neun Startkunden statt drei — Speicher und RAM gehen damit
gleichzeitig aus, wo vorher sechs RAM-Plaetze verfielen. 351 Euro Umsatz je
Server statt 147.

Umgeschaltet wird durch Beenden und Nachfolgen, nicht durch Aendern: eine
veroeffentlichte Version ist unveraenderlich, und laufende Vertraege tragen
ihren eigenen Schnappschuss. Als Migration und nicht als Befehl, weil der
Katalog von einer Migration gesetzt wird — sonst blieben Neuinstallation und
Testsuite auf einer Leiter, die wir nicht mehr verkaufen.

Enterprise verlaesst den Verkauf (2000 GB finden auf 388 GB keinen Host), das
Testpaket heisst Intern und wird intern — es steht ausserdem zum ersten Mal im
Katalog einer frischen Installation, statt von Hand angelegt werden zu muessen.

Dreissig Testdateien nennen die neuen Zahlen. Drei Faelle waren keine Zahlen:
Vertraege auf Enterprise entstehen nur noch als Bestandsvertraege (Helfer
asGrandfathered() in tests/Pest.php), die Preisblatt-Stufe "Premium" haengt am
Paket, das den Laden verlassen hat, und ein Katalogleser, der die einzige
Preiszeile einer Familie suchte, findet seit der Handreichung zwei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 17:05:58 +02:00
parent f8d0c2c353
commit f961205512
33 changed files with 797 additions and 353 deletions

View File

@ -0,0 +1,246 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Die neue Leiter.
*
* Die alte war entworfen, bevor jemand ausgerechnet hatte, was ein Server
* trägt: ein Host mit 456 GB und 15 % Reserve vergibt 388 GB, und darauf
* passen bei 120 GB Platte je Startkunde genau drei Kunden während 64 GB RAM
* acht bis sechzehn getragen hätten. Sechs RAM-Plätze verfielen. Neun Kunden à
* 40 GB lasten beides gleichzeitig aus und bringen 351 statt 147 je Server.
*
* **Warum eine Migration und kein Befehl.** Der Katalog wird von einer
* Migration gesetzt (`…_create_plan_catalogue_tables`). Ein Befehl liefe nur
* dort, wo jemand ihn aufruft jede Neuinstallation und die gesamte Testsuite
* blieben auf einer Leiter stehen, die nicht mehr verkauft wird.
*
* **Warum die Werte als Zahlen dastehen** und nicht aus der Konfiguration
* kommen: aus demselben Grund, aus dem die setzende Migration sie eingetragen
* hat. Eine Migration muss in fünf Jahren dieselben Zeilen schreiben wie heute.
* Zöge sie ihre Zahlen aus `config`, schriebe sie beim nächsten Deploy etwas
* anderes und niemand könnte am Datenbestand ablesen, was am 1. August 2026
* beschlossen wurde. Nicht kaufmännisch sind nur zwei Dinge, und die werden
* deshalb übernommen statt eingetippt: `template_vmid` (wovon geklont wird) und
* die Währung (worin diese Installation handelt).
*
* **Warum Beenden und Nachfolgen statt Ändern.** Eine veröffentlichte Version
* ist unveränderlich `PlanVersion` verbietet es, und zwar zu Recht: ein
* Kunde hat eine Version gekauft, keinen Namen. Umgeschaltet wird deshalb durch
* Handreichung: die laufende Version bekommt ein `available_until`, die neue
* ein `available_from` auf dieselbe Sekunde. Das Fenster ist halboffen, also
* überschneidet sich nichts, es entsteht keine Lücke, und es gibt keinen Job,
* der laufen müsste.
*
* Laufende Verträge merken davon nichts sie tragen ihren eigenen
* eingefrorenen Schnappschuss, und seit dem Katalogumbau liest kein Vertrag
* mehr den Katalog.
*/
return new class extends Migration
{
/** @var array<string, array<string, int|string>> */
private const LADDER = [
'test' => ['quota_gb' => 5, 'disk_gb' => 20, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 3,
'traffic_gb' => 1000, 'performance' => 'standard', 'price_cents' => 100, 'free_months' => 2,
'features' => '["managed_updates","daily_backups","monitoring","subdomain","custom_domain"]'],
'start' => ['quota_gb' => 30, 'disk_gb' => 40, 'ram_mb' => 6144, 'cores' => 2, 'seats' => 10,
'traffic_gb' => 1000, 'performance' => 'standard', 'price_cents' => 3900, 'free_months' => 2,
'features' => '["managed_updates","daily_backups","monitoring","subdomain"]'],
'team' => ['quota_gb' => 85, 'disk_gb' => 100, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25,
'traffic_gb' => 2000, 'performance' => 'enhanced', 'price_cents' => 7900, 'free_months' => 2,
'features' => '["managed_updates","daily_backups","monitoring","subdomain","office","branding","priority_support"]'],
'business' => ['quota_gb' => 175, 'disk_gb' => 200, 'ram_mb' => 12288, 'cores' => 6, 'seats' => 50,
'traffic_gb' => 4000, 'performance' => 'high', 'price_cents' => 13900, 'free_months' => 2,
'features' => '["managed_updates","daily_backups","monitoring","custom_domain","office","branding","priority_support","extended_retention","audit_log"]'],
];
/**
* Das interne Paket die einzige Familie, die diese Migration anlegen darf.
*
* Sie hat bisher in keiner Migration gestanden: der Betreiber hat sie von
* Hand über die Konsole angelegt, weshalb sie auf jeder Neuinstallation und
* in der Testsuite fehlte. Sie gehört in den Katalog, den eine frische
* Installation mitbringt, denn ohne sie kann niemand eine Abnahme fahren,
* ohne vorher ein Paket zu erfinden. Der Schlüssel bleibt `test` das
* Modell verbietet Umbenennen, und Bestellungen verweisen darauf; nur der
* Anzeigename wird zu „Intern".
*/
private const INTERNAL_FAMILY = [
'key' => 'test',
'name' => 'Intern',
'tier' => 0,
'audience' => 'Nur für interne Tests',
'note' => 'Nicht im Verkauf. Wird von Hand vergeben.',
];
public function up(): void
{
$now = now();
DB::transaction(function () use ($now) {
$this->settleInternalFamily($now);
foreach (self::LADDER as $key => $spec) {
$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.
if ($family === null) {
continue;
}
$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();
// Schon umgeschaltet (die laufende Version trägt die neuen
// Zahlen): nichts zu tun, und wiederholbar bleibt es damit auch.
// Geprüft an dem, was die Version TRÄGT, nicht an ihrer Nummer —
// eine Nummer sagt nur, wie oft jemand etwas veröffentlicht hat.
if ($current !== null && (int) $current->quota_gb === (int) $spec['quota_gb']
&& (int) $current->disk_gb === (int) $spec['disk_gb']) {
continue;
}
if ($current !== null) {
DB::table('plan_versions')->where('id', $current->id)
->update(['available_until' => $now, 'updated_at' => $now]);
}
$next = (int) DB::table('plan_versions')->where('plan_family_id', $family->id)->max('version') + 1;
$versionId = DB::table('plan_versions')->insertGetId([
'uuid' => (string) Str::uuid(),
'plan_family_id' => $family->id,
'version' => $next,
'quota_gb' => $spec['quota_gb'],
'traffic_gb' => $spec['traffic_gb'],
'seats' => $spec['seats'],
'free_months' => $spec['free_months'],
'ram_mb' => $spec['ram_mb'],
'cores' => $spec['cores'],
'disk_gb' => $spec['disk_gb'],
'performance' => $spec['performance'],
// Übernommen, nicht gesetzt: wovon geklont wird, ist keine
// kaufmännische Entscheidung dieser Umschaltung. 9000 nur,
// wenn es nichts zu übernehmen gibt — sonst ginge eine
// Version ohne Vorlage in den Verkauf, und die Bereitstellung
// bräche bei CloneVirtualMachine, nachdem der Kunde gezahlt
// hat.
'template_vmid' => $current->template_vmid ?? 9000,
'features' => $spec['features'],
'available_from' => $now,
'available_until' => null,
'published_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// Der Jahrespreis wird gerechnet, nicht getippt: zwölf Monate
// minus die freien, zum Monatspreis. Zwei Zahlen für dieselbe
// Sache driften auseinander, und die Zeile "zwei Monate frei"
// muss stimmen, was auch immer Stripe abbucht.
$yearly = (int) $spec['price_cents'] * (12 - (int) $spec['free_months']);
foreach ([['monthly', (int) $spec['price_cents']], ['yearly', $yearly]] as [$term, $amount]) {
DB::table('plan_prices')->insert([
'uuid' => (string) Str::uuid(),
'plan_version_id' => $versionId,
'term' => $term,
'amount_cents' => $amount,
// Übernommen wie die Vorlage: in welcher Währung diese
// Installation handelt, entscheidet nicht diese
// Migration. Stünde hier fest "EUR", fiele auf einem
// Bestand mit anderer Währung jedes Paket aus dem
// Verkauf — PlanCatalogue zeigt nur, was in der
// Katalogwährung ausgepreist ist.
'currency' => $this->currencyOf($current),
'stripe_price_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
// Enterprise wird angefragt, nicht gekauft: 2000 GB finden auf 388 GB
// vergebbarem Platz keinen Host, und der Kunde erführe das nach der
// Zahlung. Die Familie bleibt samt Version stehen — laufende
// Verträge verweisen darauf.
DB::table('plan_families')->where('key', 'enterprise')->update([
'sales_enabled' => false,
'updated_at' => $now,
]);
});
}
/**
* Kein Rückweg.
*
* Eine veröffentlichte Version zu löschen hieße, Verträge zu löschen, die
* darauf verweisen `plan_version_id` liegt auf jedem Vertrag und jeder
* Bestellung, und `PlanVersion::deleting()` verbietet es aus genau diesem
* Grund. Wer zurück will, schaltet die alte Version über die Konsole wieder
* in den Verkauf (`PlanCatalogue::reopen()`); das ist eine kaufmännische
* Entscheidung und keine, die ein `migrate:rollback` nebenbei trifft.
*/
public function down(): void {}
/**
* Das Testpaket heißt Intern, verlässt den Laden und bleibt verschenkbar.
*
* `sales_enabled` bleibt an: es ist der Hebel, der auch das Verschenken
* abschaltet (`currentVersion()` wirft bei einer nicht verkäuflichen
* Familie). Aus dem Laden nimmt es `internal`, und genau dafür gibt es das
* zweite Feld.
*/
private function settleInternalFamily(Carbon $now): void
{
$shared = [
'name' => self::INTERNAL_FAMILY['name'],
'internal' => true,
'audience' => self::INTERNAL_FAMILY['audience'],
'note' => self::INTERNAL_FAMILY['note'],
'updated_at' => $now,
];
if (DB::table('plan_families')->where('key', self::INTERNAL_FAMILY['key'])->exists()) {
DB::table('plan_families')->where('key', self::INTERNAL_FAMILY['key'])->update($shared);
return;
}
DB::table('plan_families')->insert([
...$shared,
'uuid' => (string) Str::uuid(),
'key' => self::INTERNAL_FAMILY['key'],
// Rang 0: unter allem, was verkauft wird. Der Rang entscheidet, ob
// ein Paketwechsel ein Aufstieg ist, und aus dem internen Paket
// heraus ist jeder Wechsel einer.
'tier' => self::INTERNAL_FAMILY['tier'],
'sales_enabled' => true,
'is_recommended' => false,
'created_at' => $now,
]);
}
/**
* Die Währung, in der diese Familie schon ausgepreist ist sonst die der
* Installation.
*/
private function currencyOf(?object $current): string
{
$existing = $current === null ? null : DB::table('plan_prices')
->where('plan_version_id', $current->id)
->value('currency');
return $existing ?? strtoupper((string) config('provisioning.currency', 'EUR'));
}
};

View File

@ -149,8 +149,8 @@ it('recommends what to buy from the mix that is actually waiting', function () {
$demand = app(HostCapacity::class)->queueDemand();
expect($demand['count'])->toBe(3)
->and($demand['largest'])->toBe(1050) // business
->and($demand['total'])->toBe(1290) // 2 × 120 + 1050
->and($demand['largest'])->toBe(200) // business
->and($demand['total'])->toBe(280) // 2 × 40 + 200
->and($demand['by_plan'])->toHaveCount(2);
});
@ -171,8 +171,8 @@ it('promises immediate delivery only where a host could actually take it', funct
$capacity = app(HostCapacity::class);
expect($capacity->deliveryFor(120))->toBe('immediate') // start fits
->and($capacity->deliveryFor(1050))->toBe('scheduled') // business does not
expect($capacity->deliveryFor(120))->toBe('immediate') // a small one fits
->and($capacity->deliveryFor(1050))->toBe('scheduled') // a big one does not
// Nothing sized is nothing promised.
->and($capacity->deliveryFor(0))->toBe('scheduled');
});
@ -190,7 +190,10 @@ it('stops promising immediate delivery once the room is committed', function ()
});
it('shows the delivery promise for every package on the console front page', function () {
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);
// 150 GB: Start und Team passen darauf, Business (200 GB Platte) nicht —
// die Seite muss beide Antworten zeigen können, und auf der neuen Leiter
// fänden auf 500 GB alle drei Platz.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 150, 'reserve_pct' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
@ -224,7 +227,9 @@ it('waits rather than quietly placing a pinned order somewhere else', function (
// host happened to be full answers a different question than the one the
// operator answered — and they would find out from the finished instance.
$run = parkedOrder('start');
$full = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 100, 'reserve_pct' => 0]);
// Zu klein für Start (40 GB Platte auf der neuen Leiter), damit der Pin
// wirklich wartet, statt bequem doch zu passen.
$full = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 20, 'reserve_pct' => 0]);
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')

View File

@ -83,7 +83,7 @@ it('shows the contract the customer is owed, with its own frozen figures', funct
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSee(__('billing.plan.team'))
->assertSee('500 GB') // team's quota
->assertSee('85 GB') // team's quota
->assertSee(__('customer_detail.price'));
});

View File

@ -43,10 +43,11 @@ function mrrOf(Customer $customer): string
it('reports a yearly contract as what it costs per month', function () {
$customer = customerOnPlan('team', 'yearly');
// The contract stores 12 × 179 €. Reporting that in a monthly column would
// show this customer as worth twelve times what they are.
expect(mrrOf($customer))->toContain('179')
->and(mrrOf($customer))->not->toContain('2.148');
// The contract stores 10 × 79 € — ten paid months for a year. Reporting the
// whole of it in a monthly column would show this customer as worth twelve
// times what they are.
expect(mrrOf($customer))->toContain('65,83')
->and(mrrOf($customer))->not->toContain('790,00');
});
it('reports what a grandfathered customer pays, not today\'s price', function () {
@ -64,8 +65,11 @@ it('shows a yearly customer their monthly rate on the portal too', function () {
$card = Livewire::actingAs($user)->test(App\Livewire\Billing::class)->viewData('current');
$cloud = Livewire::actingAs($user)->test(App\Livewire\Cloud::class)->viewData('instance');
expect($card['price_cents'])->toBe(17900)
->and($cloud['plan'])->toContain('179');
// 790,00 € im Jahr sind 65,83 € im Monat — die Wolkenseite rundet das auf
// ganze Euro, die Karte nennt die Cents.
expect($card['price_cents'])->toBe(6583)
->and($cloud['plan'])->toContain('66')
->and($cloud['plan'])->not->toContain('790');
});
it('keeps customers on a withdrawn plan in the distribution', function () {

View File

@ -7,7 +7,6 @@ use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -76,16 +75,9 @@ it('still lets an internal package be granted — that is the one door it has',
// und Warenkorb (Aufgabe 6). Dieses Formular liest bewusst grantable()
// statt sellable() für sein Dropdown UND seine Validierung — sonst wäre
// das interne Paket auch hier draußen und nirgends mehr vergebbar.
$family = PlanFamily::create([
'key' => 'test', 'name' => 'Test', 'tier' => 0,
'sales_enabled' => true, 'internal' => true,
]);
$version = app(PlanCatalogue::class)->draft($family, [
'quota_gb' => 5, 'traffic_gb' => 1000, 'seats' => 3, 'free_months' => 0,
'ram_mb' => 4096, 'cores' => 2, 'disk_gb' => 20, 'performance' => 'standard',
'template_vmid' => 9000, 'features' => [],
], ['monthly' => 100, 'yearly' => 1200]);
app(PlanCatalogue::class)->publish($version);
// Die Familie bringt die Migration mit, seit die neue Leiter das
// Testpaket in den Katalog einer frischen Installation räumt.
expect(PlanFamily::query()->where('key', 'test')->value('internal'))->toBeTruthy();
$customer = Customer::factory()->create();

View File

@ -50,7 +50,9 @@ it('respects the reserve the operator set aside', function () {
it('warns on the console about a package that can no longer be placed', function () {
// The whole point of the feature: this is the sentence that has to appear
// BEFORE somebody pays for the package it names.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 400, 'reserve_pct' => 0]);
// 150 GB: Business (200 GB Platte) findet darauf keinen Platz mehr, Team
// (100) noch — auf der neuen Leiter passte jedes Paket auf 400 GB.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 150, 'reserve_pct' => 0]);
$atRisk = app(HostCapacity::class)->unplaceablePlans();

View File

@ -69,8 +69,8 @@ it('quotes the amount the customer knows, which is the gross one', function () {
$amount = app(MailTemplateRenderer::class)->values($customer)['amount'];
// 49 € net at 20 % → 58,80 €.
expect($amount)->toContain('58,80');
// 39 € net at 20 % → 46,80 €.
expect($amount)->toContain('46,80');
});
it('leaves a placeholder empty rather than inventing a package nobody bought', function () {

View File

@ -28,6 +28,18 @@ function teamFamily(): PlanFamily
return PlanFamily::query()->where('key', 'team')->sole();
}
/**
* 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.
*/
function teamDraft(): PlanVersion
{
return teamFamily()->versions()->where('version', 3)->sole();
}
it('is closed to operators without the capability', function () {
$viewer = Operator::factory()->role('Support')->create();
@ -76,7 +88,7 @@ it('takes a plan out of the shop without touching anyone\'s contract', function
expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse()
->and(teamFamily()->sales_enabled)->toBeFalse()
// The customer keeps everything they bought.
->and($contract->fresh()->price_cents)->toBe(17900)
->and($contract->fresh()->price_cents)->toBe(7900)
->and($contract->fresh()->status)->toBe('active');
Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid);
@ -88,8 +100,8 @@ it('drafts a version prefilled from the last one, priced for both terms', functi
// Prefilled, so "same plan, new price" is one edit rather than nine.
$page->assertSet('ramMb', 8192)
->assertSet('quotaGb', 500)
->assertSet('monthlyPrice', '179,00');
->assertSet('quotaGb', 85)
->assertSet('monthlyPrice', '79,00');
// The monthly price and the free months. The yearly total is derived from
// them — two free means ten months paid, 199,00 × 10 — because a typed total
@ -97,7 +109,7 @@ it('drafts a version prefilled from the last one, priced for both terms', functi
// "zwei Monate gratis" without working it out a second time by hand.
$page->set('monthlyPrice', '199,00')->set('freeMonths', 2)->call('draft')->assertHasNoErrors();
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
expect($draft->isPublished())->toBeFalse()
->and($draft->ram_mb)->toBe(8192)
@ -105,7 +117,7 @@ it('drafts a version prefilled from the last one, priced for both terms', functi
->and($draft->priceFor('monthly')->amount_cents)->toBe(19900)
->and($draft->priceFor('yearly')->amount_cents)->toBe(199000)
// A draft is not on sale, so the shop is unchanged.
->and(app(PlanCatalogue::class)->sellable()['team']['price_cents'])->toBe(17900);
->and(app(PlanCatalogue::class)->sellable()['team']['price_cents'])->toBe(7900);
});
it('refuses a mistyped figure with a message instead of overflowing the column', function () {
@ -118,7 +130,9 @@ it('refuses a mistyped figure with a message instead of overflowing the column',
->call('draft')
->assertHasErrors(['monthlyPrice', 'ramMb']);
expect(teamFamily()->versions()->count())->toBe(1);
// 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);
});
it('does not call a version live when the plan is withdrawn', function () {
@ -161,7 +175,7 @@ it('publishes a draft straight over the running version, announcing the handover
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->set('monthlyPrice', '199,00')->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
$live = app(PlanCatalogue::class)->currentVersion('team');
$at = now()->addHour();
@ -180,8 +194,8 @@ 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(1)
->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(2);
->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(2)
->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(3);
});
it('puts a closed version back on sale from the version list', function () {
@ -203,10 +217,18 @@ it('puts a closed version back on sale from the version list', function () {
});
it('refuses to reopen a version whose successor is selling, in the console\'s own words', function () {
// Eine Stunde weiter, bevor irgendetwas beginnt. Das Formularfeld ist auf
// die Minute genau (`datetime-local` kennt keine Sekunden), und die
// laufende Version beginnt in der Sekunde, in der die Umschaltung auf die
// neue Leiter lief — ein abgeschnittenes „jetzt" fiele in derselben Minute
// noch davor, und die Veröffentlichung überlappte die laufende Version,
// statt sie abzulösen.
$this->travelTo(now()->addHour());
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->set('monthlyPrice', '199,00')->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
$live = app(PlanCatalogue::class)->currentVersion('team');
$page->call('choose', $draft->uuid)
@ -222,14 +244,14 @@ 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(2);
->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(3);
});
it('will not reopen a draft, and says why instead of doing nothing', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
// The list offers no such button for a draft, but a request can carry
// anything, and "was never on sale" is a different refusal from "something
@ -263,7 +285,7 @@ it('will not publish a window that ends before it starts', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
$page->call('choose', $draft->uuid)
->set('availableFrom', LocalTime::toField(now()->addWeek()))
@ -276,7 +298,7 @@ it('discards a draft through the modal, and never a published version', function
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid])
->call('delete');
@ -293,7 +315,7 @@ it('does not discard a draft that was published while the dialog was open', func
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
// The dialog opens on a draft...
$modal = Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]);
@ -313,7 +335,7 @@ it('authorises the modal itself, not just the page that opens it', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$draft = teamDraft();
$viewer = Operator::factory()->role('Support')->create();
// Modals are reachable without the page's guards.

View File

@ -243,7 +243,7 @@ it('shrinks the quota on a downgrade and never the disk', function () {
// What was sold does shrink, and Nextcloud is what enforces it.
expect($instance->quota_gb)->toBe($start['quota_gb'])
->and($services['pve']->guestRan("config:app:set files default_quota --value='100 GB'"))->toBeTrue();
->and($services['pve']->guestRan("config:app:set files default_quota --value='30 GB'"))->toBeTrue();
Carbon::setTestNow();
});
@ -257,8 +257,9 @@ it('grows the disk on an upgrade', function () {
app(ResizeVirtualMachine::class)->execute(planChangeRun());
expect($services['pve']->resizeCalls)->toBe(['101:scsi0:1050G'])
->and($fixture['instance']->fresh()->disk_gb)->toBe(1050);
// Business ist 175 GB auf einer 200-GB-Platte.
expect($services['pve']->resizeCalls)->toBe(['101:scsi0:200G'])
->and($fixture['instance']->fresh()->disk_gb)->toBe(200);
});
it('refuses an upgrade on a contract that is no longer active', function () {
@ -294,8 +295,8 @@ it('never reboots a live machine, and says so where a human looks', function ()
$instance = $fixture['instance']->fresh();
// The configuration carries the bigger machine…
expect($services['pve']->cloudInitParams[101]['cores'])->toBe(8)
->and($services['pve']->cloudInitParams[101]['memory'])->toBe(16384)
expect($services['pve']->cloudInitParams[101]['cores'])->toBe(6)
->and($services['pve']->cloudInitParams[101]['memory'])->toBe(12288)
// …the guest was left running, untouched: startVm appends to this list,
// so an unchanged list is a machine that was never stopped and started.
->and($services['pve']->runningVmids)->toBe([101])

View File

@ -112,8 +112,10 @@ it('gives Team the domain only once the module is booked', function () {
it('gives Business and Enterprise the domain with the package, and does not sell it to them twice', function () {
$access = app(CustomDomainAccess::class);
// Enterprise wird seit der neuen Leiter angefragt statt gekauft, aber die
// Verträge darauf laufen weiter — und die Regel gilt für sie unverändert.
foreach (['business', 'enterprise'] as $plan) {
[, $user, , $subscription] = domainCustomer($plan);
[, $user, , $subscription] = asGrandfathered($plan, fn () => domainCustomer($plan));
expect($access->allows($subscription))->toBeTrue()
->and($access->hasBooked($subscription))->toBeFalse()

View File

@ -14,38 +14,23 @@ use App\Services\Stripe\StripeClient;
* currentVersion() wirft dann bei einer nicht verkäuflichen Familie , und
* genau dafür existiert `internal` als eigenes Feld.
*
* Das Testpaket selbst legt heute der Betreiber von Hand über die Konsole an
* (Aufgabe 9 räumt es per Migration in den frisch installierten Katalog ein);
* es steckt in keiner Migration, die diese Suite mitbringt. Für die Tests
* bauen wir es hier deshalb selbst auf, denselben Weg, den die Konsole nimmt:
* Familie anlegen, Entwurf schreiben, veröffentlichen.
* Das Testpaket legte der Betreiber früher von Hand über die Konsole an; seit
* der Umschaltung auf die neue Leiter bringt es die Migration mit, damit eine
* frische Installation nicht erst ein Paket erfinden muss, um eine Abnahme zu
* fahren. Diese Tests lesen deshalb, was dort steht, statt sich ein eigenes zu
* bauen.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
$family = PlanFamily::create([
'key' => 'test',
'name' => 'Test',
'tier' => 0,
'sales_enabled' => true,
'internal' => true,
]);
// Der Bestand, gegen den hier geprüft wird: die Familie ist da, sie ist
// intern, und sie steht im Verkauf — denn `sales_enabled` schaltet auch das
// Verschenken ab, und genau das soll `internal` nicht tun.
$family = PlanFamily::query()->where('key', 'test')->sole();
$version = app(PlanCatalogue::class)->draft($family, [
'quota_gb' => 5,
'traffic_gb' => 1000,
'seats' => 3,
'free_months' => 0,
'ram_mb' => 4096,
'cores' => 2,
'disk_gb' => 20,
'performance' => 'standard',
'template_vmid' => 9000,
'features' => [],
], ['monthly' => 100, 'yearly' => 1200]);
app(PlanCatalogue::class)->publish($version);
expect($family->internal)->toBeTrue()
->and($family->sales_enabled)->toBeTrue();
});
it('zeigt ein internes Paket nicht im Verkauf', function () {

View File

@ -0,0 +1,79 @@
<?php
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
/**
* Die Leiter, wie sie verkauft wird.
*
* Zugeschnitten auf die Maschine, die dasteht: 388 GB vergebbar, ~58 GB RAM für
* Gäste. Neun Startkunden à 40 GB Platte lasten Speicher und RAM gleichzeitig
* aus die alte Leiter band bei drei Kunden und ließ sechs RAM-Plätze
* verfallen.
*/
it('verkauft drei Pakete', function () {
expect(array_keys(app(PlanCatalogue::class)->sellable()))->toBe(['start', 'team', 'business']);
});
it('verkauft sie zu den beschlossenen Zahlen', function () {
$sellable = app(PlanCatalogue::class)->sellable();
expect($sellable['start']['quota_gb'])->toBe(30)
->and($sellable['start']['disk_gb'])->toBe(40)
->and($sellable['start']['ram_mb'])->toBe(6144)
->and($sellable['start']['seats'])->toBe(10)
->and($sellable['start']['price_cents'])->toBe(3900)
->and($sellable['start']['yearly_price_cents'])->toBe(39000)
->and($sellable['team']['quota_gb'])->toBe(85)
->and($sellable['team']['disk_gb'])->toBe(100)
->and($sellable['team']['price_cents'])->toBe(7900)
->and($sellable['business']['quota_gb'])->toBe(175)
->and($sellable['business']['disk_gb'])->toBe(200)
->and($sellable['business']['price_cents'])->toBe(13900);
});
it('lässt neun Startkunden auf 388 GB', function () {
$start = app(PlanCatalogue::class)->sellable()['start'];
expect(intdiv(388, $start['disk_gb']))->toBe(9);
});
it('nimmt das interne Paket und Enterprise aus dem Laden', function () {
expect(PlanFamily::query()->where('key', 'test')->value('internal'))->toBeTruthy()
->and(PlanFamily::query()->where('key', 'test')->value('name'))->toBe('Intern')
->and(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeFalsy();
});
it('schaltet kein zweites Mal um, wenn sie noch einmal gefahren wird', function () {
// Ein zweiter Deploy, ein wiederholtes `migrate` auf einem Bestand, der die
// Umschaltung schon hinter sich hat: die Migration darf dann keine dritte
// Fassung nachschieben und die laufende nicht beenden. Erkannt wird das an
// dem, was die laufende Fassung TRÄGT, nicht an ihrer Nummer.
$before = App\Models\PlanVersion::query()->count();
(require database_path('migrations/2026_08_01_000004_switch_to_new_plan_ladder.php'))->up();
expect(App\Models\PlanVersion::query()->count())->toBe($before)
->and(app(PlanCatalogue::class)->sellable()['team']['quota_gb'])->toBe(85)
->and(app(PlanCatalogue::class)->currentVersion('team')->available_until)->toBeNull();
});
it('lässt einen laufenden Vertrag auf seinen eigenen Zahlen', function () {
// Ein Vertrag, wie ihn OpenSubscription vor der Umschaltung geschrieben
// hat, mit der Maschine dazu.
$subscription = Subscription::factory()->create([
'plan' => 'team', 'quota_gb' => 500, 'disk_gb' => 540, 'price_cents' => 17900,
]);
$instance = App\Models\Instance::factory()->create([
'customer_id' => $subscription->customer_id, 'status' => 'active',
'quota_gb' => 500, 'disk_gb' => 540,
]);
// Der Katalog sagt heute etwas anderes — und keine Leseseite schreibt es
// dem Vertrag um. Die mittlere Zusicherung ist die eigentliche: sie geht
// durch denselben Weg, über den eine Bereitstellung das Kontingent holt.
expect(app(PlanCatalogue::class)->sellable()['team']['quota_gb'])->toBe(85)
->and(App\Services\Billing\StorageAllowance::for($instance)->planGb)->toBe(500)
->and($subscription->fresh()->price_cents)->toBe(17900);
});

View File

@ -8,34 +8,38 @@ use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\MultipleRecordsFoundException;
/**
* The catalogue moved out of config and into three tables. This is the shadow
* comparison the move was supposed to survive: the values the seeded catalogue
* serves are pinned literally, so a migration that quietly changes what we sell
* fails here rather than in a customer's invoice.
* The catalogue lives in three tables. This is the shadow comparison it has to
* survive: every figure the seeded catalogue serves is pinned literally, so a
* migration that quietly changes what we sell fails here rather than in a
* customer's invoice.
*
* The figures below are the ladder the owner decided on nine start customers
* per host instead of three not a transcript of what the code happens to
* return. Enterprise is deliberately absent: it is answered by enquiry now, so
* it is not in the shop and not in this list.
*/
/** What config/provisioning.php sold, before the catalogue became a table. */
const CATALOGUE_BEFORE_THE_MOVE = [
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
/** The ladder as sold, capability by capability. */
const THE_LADDER_WE_SELL = [
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 30, 'disk_gb' => 40, 'ram_mb' => 6144, 'cores' => 2, 'seats' => 10, 'performance' => 'standard', 'price_cents' => 3900, 'template_vmid' => 9000],
'team' => ['tier' => 2, 'traffic_gb' => 2000, 'quota_gb' => 85, 'disk_gb' => 100, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 7900, 'template_vmid' => 9000],
'business' => ['tier' => 3, 'traffic_gb' => 4000, 'quota_gb' => 175, 'disk_gb' => 200, 'ram_mb' => 12288, 'cores' => 6, 'seats' => 50, 'performance' => 'high', 'price_cents' => 13900, 'template_vmid' => 9000],
];
it('sells exactly what the config catalogue sold', function () {
it('sells exactly the ladder the owner decided on', function () {
$sellable = app(PlanCatalogue::class)->sellable();
expect(array_keys($sellable))->toBe(array_keys(CATALOGUE_BEFORE_THE_MOVE));
expect(array_keys($sellable))->toBe(array_keys(THE_LADDER_WE_SELL));
foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) {
foreach (THE_LADDER_WE_SELL as $key => $expected) {
foreach ($expected as $field => $value) {
expect($sellable[$key][$field])->toBe($value, "{$key}.{$field}");
}
}
});
it('freezes the same snapshot the config catalogue would have frozen', function () {
foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) {
it('freezes exactly that ladder onto a contract', function () {
foreach (THE_LADDER_WE_SELL as $key => $expected) {
$snapshot = Subscription::snapshotFrom($key);
expect($snapshot['price_cents'])->toBe($expected['price_cents'])
@ -49,10 +53,11 @@ it('freezes the same snapshot the config catalogue would have frozen', function
->and($snapshot['template_vmid'])->toBe($expected['template_vmid'])
->and($snapshot['plan_version_id'])->not->toBeNull();
// Yearly is twelve months of the same price until the owner says
// otherwise — and it comes from its own row, not from arithmetic.
// Ten paid months, because every package on the new ladder gives two
// away for a yearly term. The figure comes from its own row, not from
// arithmetic at read time — this is the pin on what was typed into it.
expect(Subscription::snapshotFrom($key, 'yearly')['price_cents'])
->toBe($expected['price_cents'] * 12);
->toBe($expected['price_cents'] * 10);
}
});
@ -72,8 +77,10 @@ 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.
expect($version->family->key)->toBe('team')
->and($version->version)->toBe(1);
->and($version->version)->toBe(2);
});
it('contracts a checkout to the version it was shown, not the one that replaced it', function () {
@ -81,12 +88,13 @@ it('contracts a checkout to the version it was shown, not the one that replaced
$shown = $catalogue->currentVersion('team');
$currency = Subscription::catalogueCurrency();
// The customer is looking at v1 when the owner's scheduled swap lands.
// The customer is looking at the running version when the owner's
// scheduled swap lands.
$catalogue->schedule($shown, $shown->available_from, now());
$successor = PlanVersion::query()->create([
...$shown->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $shown->features, 'available_from' => now(),
'version' => 3, '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]);
@ -95,7 +103,7 @@ it('contracts a checkout to the version it was shown, not the one that replaced
// Their payment arrives after the swap. They pay what they were quoted.
$snapshot = Subscription::snapshotFrom('team', 'monthly', $shown->id);
expect($snapshot['price_cents'])->toBe(17900)
expect($snapshot['price_cents'])->toBe(7900)
->and($snapshot['plan_version_id'])->toBe($shown->id);
// Someone arriving now gets the successor.
@ -108,14 +116,14 @@ it('fixes the price of a published version, so a checkout cannot be repriced mid
expect(fn () => $price->update(['amount_cents' => 24900]))
->toThrow(RuntimeException::class, 'fixed');
expect($price->fresh()->amount_cents)->toBe(17900);
expect($price->fresh()->amount_cents)->toBe(7900);
// Stripe's id is filled in afterwards, so that stays writable. Re-read
// first: the refused edit is still sitting on the rejected object, and the
// guard would rightly stop it riding along on the next save.
$price->refresh()->update(['stripe_price_id' => 'price_123']);
expect($price->fresh()->stripe_price_id)->toBe('price_123')
->and($price->fresh()->amount_cents)->toBe(17900);
->and($price->fresh()->amount_cents)->toBe(7900);
});
it('publishes a version once, even if two attempts arrive together', function () {
@ -158,8 +166,10 @@ 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]);
expect($first->version)->toBe(2)
->and($second->version)->toBe(3)
// 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)
->and($second->priceFor('monthly')->amount_cents)->toBe(19900);
});
@ -221,7 +231,7 @@ it('still honours a quoted version after the plan is withdrawn', function () {
expect($catalogue->isSellable('team'))->toBeFalse()
// They saw it and paid for it, so it is still deliverable to them.
->and($catalogue->isDeliverable('team', $quoted->id))->toBeTrue()
->and(Subscription::snapshotFrom('team', 'monthly', $quoted->id)['price_cents'])->toBe(17900);
->and(Subscription::snapshotFrom('team', 'monthly', $quoted->id)['price_cents'])->toBe(7900);
});
it('will not contract a customer to a version of a different plan', function () {
@ -255,13 +265,14 @@ it('does not sell a version before its window opens or after it closes', functio
});
it('crashes instead of choosing between two versions on sale at once', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $family->versions()->sole();
// The one on sale, not the family's only one: the ladder switch left the
// superseded version in place beside it, closed.
$first = app(PlanCatalogue::class)->currentVersion('team');
// Forced past the guard, the way a bad migration or a direct edit would.
PlanVersion::query()->create([
...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'version' => 3,
'features' => $first->features,
'available_from' => now()->subDay(),
'available_until' => null,
@ -282,7 +293,7 @@ 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' => 2,
'version' => 3,
'features' => $first->features,
'available_from' => now()->addMonth(),
]);
@ -300,7 +311,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(2);
expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe(3);
});
it('locks a version once it is published, but still lets its window move', function () {
@ -318,7 +329,7 @@ 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' => 2,
'plan_family_id' => $family->id, 'version' => 3,
'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(),
@ -375,14 +386,14 @@ 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' => 2, 'features' => $v1->features, 'available_from' => now()->addMonth(),
'version' => 3, '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());
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 4,
'plan_family_id' => $family->id, 'version' => 5,
'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(),
@ -405,7 +416,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(4)
expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(5)
->and($v1->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue();
});
@ -473,7 +484,7 @@ it('puts a closed version back on sale', function () {
// selling until someone decides otherwise.
expect($catalogue->isSellable('team'))->toBeTrue()
->and($catalogue->isSellable('team', now()->addYear()))->toBeTrue()
->and($catalogue->sellable()['team']['price_cents'])->toBe(17900)
->and($catalogue->sellable()['team']['price_cents'])->toBe(7900)
->and($catalogue->currentVersion('team')->version)->toBe($version->version);
});

View File

@ -20,7 +20,7 @@ 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' => 2, 'features' => $current->features, 'available_from' => now(),
'version' => 3, '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]);
@ -132,12 +132,15 @@ it('will not call an upgrade a credit', function () {
->toThrow(RuntimeException::class);
});
it('prices a yearly term as twelve months of the same plan', function () {
it('prices a yearly term as the ten months the catalogue charges for', function () {
$monthly = Subscription::snapshotFrom('team');
$yearly = Subscription::snapshotFrom('team', Subscription::TERM_YEARLY);
// Any discount belongs in the catalogue, not hidden in the conversion.
expect($yearly['price_cents'])->toBe($monthly['price_cents'] * 12);
// Any discount belongs in the catalogue, not hidden in the conversion — and
// here it is both halves of one decision: two free months on the version,
// ten months of the monthly figure in the price row beside it.
expect($yearly['price_cents'])->toBe($monthly['price_cents'] * 10)
->and(app(PlanCatalogue::class)->sellable()['team']['free_months'])->toBe(2);
});
it('reads the direction from the plan rank, not from a grandfathered price', function () {
@ -148,7 +151,7 @@ it('reads the direction from the plan rank, not from a grandfathered price', fun
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
$subscription->forceFill(['price_cents' => 9900])->saveQuietly(); // grandfathered
$subscription->forceFill(['price_cents' => 4900])->saveQuietly(); // grandfathered
// The premise, asserted rather than written into config('provisioning.plans.*')
// — a key that has not existed since the catalogue moved into the database, so
@ -156,7 +159,7 @@ it('reads the direction from the plan rank, not from a grandfathered price', fun
// Today's team has to cost MORE than this customer's grandfathered business
// price, or a price-led reading of the direction would agree with the rank one
// and the test would prove nothing.
expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(9900);
expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(4900);
$change = PlanChange::evaluate($subscription->fresh(), 'team', Carbon::parse('2026-08-15'));
@ -194,8 +197,8 @@ it('never invoices a customer for downgrading', function () {
]);
// Grandfathered below today's smaller plan — asserted, for the reason given in
// the direction test above: the config write this replaces was a no-op.
$subscription->forceFill(['price_cents' => 9900])->saveQuietly();
expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(9900);
$subscription->forceFill(['price_cents' => 4900])->saveQuietly();
expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(4900);
$credit = PlanChange::goodwillCredit($subscription->fresh(), 'team', Carbon::parse('2026-08-15'));

View File

@ -41,7 +41,7 @@ it('records the sale the moment a contract is opened', function () {
'payment_status' => 'paid',
'customer_details' => ['email' => 'register@example.com', 'name' => 'Kanzlei Berger'],
// Stripe's amount_total is what was charged: net plus 20 % VAT.
'amount_total' => 21480,
'amount_total' => 9480,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
@ -52,11 +52,13 @@ 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')
->and($record->plan_version)->toBe(1)
// Die zweite Fassung: die Umschaltung auf die neue Leiter hat die erste
// beendet und diese nachgeschoben.
->and($record->plan_version)->toBe(2)
->and($record->term)->toBe('monthly')
->and($record->net_cents)->toBe(17900) // what was agreed
->and($record->gross_cents)->toBe(21480) // what was charged
->and($record->tax_cents)->toBe(3580)
->and($record->net_cents)->toBe(7900) // what was agreed
->and($record->gross_cents)->toBe(9480) // what was charged
->and($record->tax_cents)->toBe(1580)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue()
->and($record->currency)->toBe('EUR')
->and($record->reverse_charge)->toBeFalse()
@ -84,15 +86,15 @@ it('shows the customer their whole monthly bill, modules included', function ()
$page = Livewire\Livewire::actingAs($user)->test(Billing::class);
// The plan alone is not what they pay once a module is booked.
expect($page->viewData('totalMonthlyCents'))->toBe(17900 + 2900);
expect($page->viewData('totalMonthlyCents'))->toBe(7900 + 2900);
// And it reaches the page, not only the component.
expect($page->html())->toContain('Gesamt inkl. Module')->toContain('208,00');
expect($page->html())->toContain('Gesamt inkl. Module')->toContain('108,00');
});
it('refuses to be edited or deleted, in bulk as well as one at a time', function () {
$subscription = Subscription::factory()->plan('team')->create();
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900);
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900);
// A careless data fix takes exactly this shape, and model events do not
// fire for it.
@ -109,7 +111,7 @@ it('refuses to be edited or deleted, in bulk as well as one at a time', function
expect(fn () => $record->delete())->toThrow(RuntimeException::class, 'append-only')
->and(SubscriptionRecord::query()->count())->toBe(1)
->and($record->fresh()->net_cents)->toBe(17900);
->and($record->fresh()->net_cents)->toBe(7900);
});
it('books a module at today\'s price and freezes it there', function () {
@ -145,7 +147,7 @@ it('adds booked modules to the monthly total, all of it frozen', function () {
config()->set('provisioning.addons.priority_support.price_cents', 9900);
config()->set('provisioning.addons.extra_backups.price_cents', 9900);
expect($subscription->fresh()->totalMonthlyCents())->toBe(17900 + 2900 + 500);
expect($subscription->fresh()->totalMonthlyCents())->toBe(7900 + 2900 + 500);
});
it('counts a yearly contract and its modules per month', function () {
@ -153,8 +155,10 @@ it('counts a yearly contract and its modules per month', function () {
app(BookAddon::class)($subscription, 'priority_support');
// The plan is stored for the whole year; the module is per month.
expect($subscription->fresh()->totalMonthlyCents())->toBe(17900 + 2900);
// The plan is stored for the whole year — 790,00 € for ten paid months of
// twelve — and the page divides it back down: 65,83 a month, plus the
// module's own monthly figure beside it.
expect($subscription->fresh()->totalMonthlyCents())->toBe(6583 + 2900);
});
it('refuses to reprice a module that is already booked', function () {
@ -174,7 +178,7 @@ it('keeps a cancelled module on record, and off the bill', function () {
app(BookAddon::class)->cancel($subscription->addons()->sole());
expect(SubscriptionRecord::query()->where('event', 'addon_cancelled')->count())->toBe(1)
->and($subscription->fresh()->totalMonthlyCents())->toBe(17900)
->and($subscription->fresh()->totalMonthlyCents())->toBe(7900)
// What they were paying, and until when, is evidence.
->and(SubscriptionAddon::query()->sole()->cancelled_at)->not->toBeNull()
->and(app(AddonCatalogue::class)->forSubscription($subscription->fresh())['priority_support']['booked'])->toBeFalse();
@ -221,7 +225,7 @@ it('sums several bookings of one module rather than showing an arbitrary one', f
->and($storage['price_cents'])->toBeNull()
->and($storage['bookings'])->toHaveCount(2)
// And the page cannot disagree with the bill.
->and($subscription->fresh()->totalMonthlyCents())->toBe(17900 + 2500);
->and($subscription->fresh()->totalMonthlyCents())->toBe(7900 + 2500);
});
it('records what was actually charged, not only what the catalogue says', function () {
@ -235,7 +239,7 @@ it('records what was actually charged, not only what the catalogue says', functi
'payment_status' => 'paid',
'customer_details' => ['email' => 'gutschein@example.com'],
// A discount was applied: less than the catalogue price plus VAT.
'amount_total' => 14900,
'amount_total' => 5900,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
@ -245,14 +249,14 @@ it('records what was actually charged, not only what the catalogue says', functi
// The transaction, split by the rate that applied: a discount lowers the
// taxable amount, it does not produce negative VAT.
expect($record->gross_cents)->toBe(14900) // what the bank saw
->and($record->net_cents)->toBe(12417)
->and($record->tax_cents)->toBe(2483)
expect($record->gross_cents)->toBe(5900) // what the bank saw
->and($record->net_cents)->toBe(4917)
->and($record->tax_cents)->toBe(983)
->and($record->net_cents + $record->tax_cents)->toBe($record->gross_cents)
// And what was agreed is kept beside it, rather than reconciled away —
// it is exactly the thing someone asks about later.
->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900)
->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(21480)
->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(7900)
->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(9480)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeFalse();
});
@ -277,7 +281,7 @@ it('records a free checkout as free, not as paid in full', function () {
expect($record->gross_cents)->toBe(0)
->and($record->net_cents)->toBe(0)
->and($record->tax_cents)->toBe(0)
->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900)
->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(7900)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeFalse();
});
@ -296,14 +300,14 @@ it('records a customer name that later disappears', function () {
$subscription = Subscription::factory()->plan('team')->create();
$customer = $subscription->customer;
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900);
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900);
$customer->delete();
// The link goes; what happened does not.
expect($record->fresh()->customer_id)->toBeNull()
->and($record->fresh()->customer_name)->toBe($customer->name)
->and($record->fresh()->net_cents)->toBe(17900)
->and($record->fresh()->net_cents)->toBe(7900)
->and($record->fresh()->plan_key)->toBe('team');
});
@ -314,26 +318,26 @@ it('states net, tax and gross rather than leaving them to be recomputed', functi
// the charged figure, and for this customer that figure is the BARE NET —
// their checkout used the net Stripe Price, because reverse charge means no
// VAT is owed to us at all.
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 17900);
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 7900);
// The whole of it is net at 0 %. Recorded as it stood on the day — a rate
// that changes later must not change the answer for this sale.
expect($record->net_cents)->toBe(17900)
expect($record->net_cents)->toBe(7900)
->and($record->tax_cents)->toBe(0)
->and($record->gross_cents)->toBe(17900)
->and($record->gross_cents)->toBe(7900)
->and($record->reverse_charge)->toBeTrue();
});
it('calls a correctly charged reverse-charge sale a match, and an overcharged one a mismatch', function () {
$subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]);
// 179,00 net is the WHOLE of what this customer owes: no VAT is charged to
// 79,00 net is the WHOLE of what this customer owes: no VAT is charged to
// them, so there is none in the price either, and their checkout used the net
// Stripe Price. The register expects that figure, and finding it is a match.
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 17900);
$record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 7900);
expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(17900)
->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(17900)
expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(7900)
->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(7900)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();
// And now it catches the thing it is for, in the direction that actually cost
@ -341,12 +345,12 @@ it('calls a correctly charged reverse-charge sale a match, and an overcharged on
// flat 20 % surcharge with no VAT line anywhere for them to reclaim. That is
// the defect this whole change ends, and the flag whose job is "somebody must
// look at this" has to be the thing that would have found it.
$overcharged = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 21480);
$overcharged = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 9480);
expect($overcharged->snapshot['amounts']['matches_catalogue'])->toBeFalse();
// A short charge is still a mismatch, in the other direction.
$short = app(RecordCommercialEvent::class)('purchase', $subscription, 17900, chargedGrossCents: 14900);
$short = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 5900);
expect($short->snapshot['amounts']['matches_catalogue'])->toBeFalse();
});
@ -387,12 +391,12 @@ it('holds a renewal against the whole term, modules included', function () {
'billing_reason' => 'subscription_cycle',
'period_start' => $start->getTimestamp(),
'period_end' => $start->copy()->addMonth()->getTimestamp(),
'amount_paid' => 24960, // (179,00 + 29,00) + 20 %
'amount_paid' => 12960, // (79,00 + 29,00) + 20 %
]);
$record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->sole();
expect($record->snapshot['amounts']['agreed_net_cents'])->toBe(17900 + 2900)
->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(24960)
expect($record->snapshot['amounts']['agreed_net_cents'])->toBe(7900 + 2900)
->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(12960)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();
});

View File

@ -3,12 +3,12 @@
use App\Actions\BookAddon;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
@ -93,13 +93,18 @@ function chargedByCheckout(FakeStripeClient $stripe, int $index = 0): int
return (int) $stripe->prices[$stripe->checkouts[$index]['price']]['amount'];
}
/** The Stripe Price the catalogue sells a package on to a customer treated so. */
/**
* The Stripe Price the catalogue sells a package on to a customer treated so.
*
* The version ON SALE, not the family's only priced row: the ladder switch left
* every family with a closed predecessor beside its current version, and both
* are mirrored into Stripe a checkout that began before the switch is still
* owed the Price it was quoted. Asking the family for "its" monthly row would
* now find two and throw.
*/
function packagePrice(string $plan, TaxTreatment $treatment, string $term = Subscription::TERM_MONTHLY): string
{
$row = PlanPrice::query()
->whereHas('version.family', fn ($query) => $query->where('key', $plan))
->where('term', $term)
->sole();
$row = app(PlanCatalogue::class)->currentVersion($plan)->priceFor($term);
return (string) app(PlanPrices::class)->liveFor($row, $treatment);
}
@ -122,9 +127,9 @@ it('charges a domestic consumer and a domestic business the same gross, to the c
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertRedirect();
// 179,00 € plus 20 %, which is the figure on the website.
expect(chargedByCheckout($this->stripe, 0))->toBe(21480)
->and(chargedByCheckout($this->stripe, 1))->toBe(21480)
// 79,00 € plus 20 %, which is the figure on the website.
expect(chargedByCheckout($this->stripe, 0))->toBe(9480)
->and(chargedByCheckout($this->stripe, 1))->toBe(9480)
->and($this->stripe->checkouts[0]['price'])
->toBe($this->stripe->checkouts[1]['price'])
->and($this->stripe->checkouts[0]['price'])
@ -140,14 +145,14 @@ it('charges a verified business in another member state the bare net, and invoic
// The bare net: no VAT is owed to us, so there is none in the price either.
// A different Stripe Price from the domestic one, on the same Product.
expect(chargedByCheckout($this->stripe))->toBe(17900)
expect(chargedByCheckout($this->stripe))->toBe(7900)
->and($this->stripe->checkouts[0]['price'])
->toBe(packagePrice('team', TaxTreatment::reverseCharge()))
->and($this->stripe->checkouts[0]['price'])
->not->toBe(packagePrice('team', TaxTreatment::domestic()));
// And what Stripe took is what the document says. This is the half that made
// the overcharge unlawful as well as expensive: 214,80 € taken, and a document
// the overcharge unlawful as well as expensive: 94,80 € taken, and a document
// stating all of it as net at 0 %, so the customer self-accounted their VAT on
// a fifth too much.
$this->postJson(route('webhooks.stripe'), [
@ -155,15 +160,15 @@ it('charges a verified business in another member state the bare net, and invoic
'data' => ['object' => [
'id' => 'cs_rc', 'payment_status' => 'paid', 'subscription' => 'sub_rc',
'customer_details' => ['email' => $user->email, 'name' => 'Berger B.V.'],
'amount_total' => 17900, 'currency' => 'eur',
'amount_total' => 7900, 'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
$invoice = Invoice::query()->sole();
expect($invoice->gross_cents)->toBe(17900)
->and($invoice->net_cents)->toBe(17900)
expect($invoice->gross_cents)->toBe(7900)
->and($invoice->net_cents)->toBe(7900)
->and($invoice->tax_cents)->toBe(0)
// The note that makes a zero-rated document lawful.
->and($invoice->snapshot['meta']['closing'])->toBe(__('invoice.reverse_charge'));
@ -173,8 +178,8 @@ it('charges a verified business in another member state the bare net, and invoic
// reported as a mismatch, and an overcharged one would be.
$record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_PURCHASE)->sole();
expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(17900)
->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(17900)
expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(7900)
->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(7900)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue()
->and($record->reverse_charge)->toBeTrue();
});
@ -191,7 +196,7 @@ it('charges an unverified EU business the gross, because an unchecked number is
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertRedirect();
expect(chargedByCheckout($this->stripe))->toBe(21480)
expect(chargedByCheckout($this->stripe))->toBe(9480)
->and($this->stripe->checkouts[0]['price'])->toBe(packagePrice('team', TaxTreatment::domestic()));
});
@ -289,7 +294,7 @@ it('moves a contract onto the net price when the VAT id is verified after the sa
// The contract's own frozen figure is untouched by all of it. It is the
// catalogue's NET amount, every pro-rata sum reads it, and reinterpreting it
// would corrupt every plan change ever computed.
expect($contract->fresh()->price_cents)->toBe(17900);
expect($contract->fresh()->price_cents)->toBe(7900);
});
it('moves a contract back onto the gross price when a registration lapses', function () {
@ -337,14 +342,19 @@ it('creates no second Price for a figure it already has, however often it is run
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
// Sixteen priced rows: five families (Start, Team, Business, Enterprise and
// the internal package) on eight versions — the ladder switch left a closed
// predecessor beside each of the three it replaced — at two terms each. A
// closed version keeps its Prices because a checkout quoted on it is still
// owed them.
expect($this->stripe->prices)->toHaveCount($before)
// Two rows per priced catalogue row, both live, one per treatment.
->and(StripePlanPrice::query()->count())->toBe(16)
->and(StripePlanPrice::query()->whereNull('archived_at')->count())->toBe(16)
->and(StripePlanPrice::query()->where('reverse_charge', true)->count())->toBe(8)
->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(16);
->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 () {
@ -377,10 +387,10 @@ 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 eight priced rows 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 + 8 + $modules * 2);
// each of the sixteen priced rows 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 + 16 + $modules * 2);
});
it('refuses the sale rather than overcharging a business the catalogue has no net price for', function () {

View File

@ -99,9 +99,9 @@ it('puts the fee on the first invoice as its own line, and nowhere else', functi
'id' => 'cs_setup',
'payment_status' => 'paid',
'customer_details' => ['email' => 'einrichtung@example.com', 'name' => 'Kanzlei Berger'],
// What Stripe took: 179,00 + 20 % for the package, plus 99,00 + 20 %
// What Stripe took: 79,00 + 20 % for the package, plus 99,00 + 20 %
// once for the setting-up.
'amount_total' => 21480 + 11880,
'amount_total' => 9480 + 11880,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn', 'setup_fee_cents' => '11880'],
]],
@ -111,9 +111,9 @@ it('puts the fee on the first invoice as its own line, and nowhere else', functi
// The whole of what was taken stays on the order — that is what a withdrawal
// sends back — with the fee recorded beside it rather than subtracted from it.
expect($order->amount_cents)->toBe(33360)
expect($order->amount_cents)->toBe(21360)
->and($order->setup_fee_cents)->toBe(11880)
->and($order->packageChargedCents())->toBe(21480);
->and($order->packageChargedCents())->toBe(9480);
$invoice = Invoice::query()->where('order_id', $order->id)->sole();
$lines = $invoice->snapshot['lines'];
@ -123,13 +123,13 @@ it('puts the fee on the first invoice as its own line, and nowhere else', functi
// statement and cannot find on their invoice.
expect($lines)->toHaveCount(2)
->and($lines[1]['description'])->toBe(__('checkout.setup_fee_line'))
->and($invoice->gross_cents)->toBe(33360);
->and($invoice->gross_cents)->toBe(21360);
// And the register holds the PACKAGE's charge against the contract's price, so
// a sale with a setup fee on it is not reported as charged at the wrong amount.
$record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_PURCHASE)->sole();
expect($record->snapshot['amounts']['charged_gross_cents'])->toBe(21480)
expect($record->snapshot['amounts']['charged_gross_cents'])->toBe(9480)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();
});

View File

@ -40,11 +40,10 @@ use Livewire\Livewire;
/** A customer on a package, with a machine that is built, running and served. */
function storageFixture(string $plan = 'team', array $instanceAttributes = []): array
{
// Sized above the biggest plan this file books (business, 1050 GB disk)
// plus room for the packs these tests add on top: since Task 3, BookAddon
// asks the host before it books, and `active()`'s default 1000 GB is
// already too small to have legitimately hosted a business instance in
// the first place — placeableIn() would never have put one there.
// Sized well above anything this file books: since Task 3, BookAddon asks
// the host before it books, and `active()`'s default 1000 GB is too small
// for the grandfathered Enterprise machine some of these tests stand up —
// placeableIn() would never have put one there.
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 2000]);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);
$subscription = $order->subscription;
@ -118,20 +117,20 @@ beforeEach(function () {
// ─── A. one authority for the allowance ──────────────────────────────────────
it('raises the allowance with a booked pack and tells Nextcloud the larger figure', function () {
$fixture = storageFixture('team'); // 500 GB package
$fixture = storageFixture('team'); // 85 GB package
$pve = app(ProxmoxClient::class);
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
// 500 + 20 (ein Block, Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(520);
// 85 + 20 (ein Block, Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(105);
expect(app(ApplyStorageQuota::class)->execute(storageRun($fixture))->type)->toBe('advance');
expect($pve->guestRan("files default_quota --value='520 GB'"))->toBeTrue()
expect($pve->guestRan("files default_quota --value='105 GB'"))->toBeTrue()
// Recorded as applied, so the sweep can tell an enforced machine from
// one where the figure has only ever been a row in our database.
->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(520)
->and($fixture['instance']->fresh()->quota_applied_gb)->toBe(105)
->and($fixture['instance']->fresh()->quotaIsEnforced())->toBeTrue();
});
@ -142,12 +141,12 @@ it('stacks two packs and lowers the allowance again when one is cancelled', func
$first = app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
app(BookAddon::class)($contract, AddonCatalogue::STORAGE);
// 500 + 2 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(540);
// 85 + 2 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(125);
app(BookAddon::class)->cancel($first);
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(520)
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(105)
// Cancelled, never deleted: what somebody was paying, and until when, is
// evidence.
->and(SubscriptionAddon::query()->count())->toBe(2);
@ -158,8 +157,8 @@ it('counts a pack booked with a quantity of more than one', function () {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE, 3);
// 500 + 3 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(560);
// 85 + 3 × 20 (Task 5).
expect(StorageAllowance::for($fixture['instance']->fresh())->totalGb())->toBe(145);
});
it('shows the whole allowance in the portal, not the package alone', function () {
@ -168,7 +167,8 @@ it('shows the whole allowance in the portal, not the package alone', function ()
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertSee('540 GB')
// 85 + 2 × 20.
->assertSee('125 GB')
->assertSee(__('billing.storage_includes_packs', ['count' => 2, 'gb' => 40]));
});
@ -201,7 +201,7 @@ it('starts a delivery run when a pack is cancelled, so the quota comes back down
// ─── B. the machine really gets the space ────────────────────────────────────
it('grows the disk to the allowance plus the package overhead, and the guest with it', function () {
// Team is 500 GB sold on a 540 GB disk: 40 GB of overhead the package was
// Team is 85 GB sold on a 100 GB disk: 15 GB of overhead the package was
// sized with, and the pack (Task 5: 22 GB on disk) comes ON TOP of it.
$fixture = storageFixture('team');
$pve = app(ProxmoxClient::class);
@ -211,8 +211,8 @@ it('grows the disk to the allowance plus the package overhead, and the guest wit
$run = storageRun($fixture);
expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance')
->and($pve->resizeCalls)->toContain('101:scsi0:562G')
->and($fixture['instance']->fresh()->disk_gb)->toBe(562);
->and($pve->resizeCalls)->toContain('101:scsi0:122G')
->and($fixture['instance']->fresh()->disk_gb)->toBe(122);
expect(app(GrowGuestFilesystem::class)->execute($run)->type)->toBe('advance')
->and($pve->guestRan('/sys/class/block/sda/device/rescan'))->toBeTrue()
@ -240,7 +240,7 @@ it('does nothing the second time round', function () {
expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
->and($pve->resizeCalls)->toBe([])
->and($fixture['instance']->fresh()->disk_gb)->toBe(562);
->and($fixture['instance']->fresh()->disk_gb)->toBe(122);
expect(app(GrowGuestFilesystem::class)->execute($run->fresh())->type)->toBe('advance');
});
@ -290,25 +290,28 @@ function storedBytes(Instance $instance, int $gib, ?string $day = null): Instanc
{
return InstanceMetric::query()->updateOrCreate(
['instance_id' => $instance->id, 'day' => $day ?? now()->toDateString()],
['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => 1050 * 1024 ** 3],
// Der Datenträger, den die Maschine wirklich hat — eine Messung kommt
// von dort und nicht von einer festen Zahl, die mit jeder Paketleiter
// wieder falsch wird.
['disk_used_bytes' => $gib * 1024 ** 3, 'disk_total_bytes' => $instance->disk_gb * 1024 ** 3],
);
}
it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () {
$fixture = storageFixture('business'); // 1000 GB package
$fixture = storageFixture('business'); // 175 GB package
// Fünfzig Gigabyte zu viel: drei Blöcke à 20 GB decken sie, und das ist
// genau der Deckel — der letzte Betrag, für den Kaufen überhaupt noch ein
// Ausweg ist. Vorher standen hier 600 GB, aus der Zeit, als ein Block noch
// 100 GB brachte; seit Task 5 wären das fünf Blöcke und damit keiner.
storedBytes($fixture['instance'], 550);
// Ausweg ist. 135 gegen Teams 85 ist derselbe Abstand, den vorher 550 gegen
// 500 hatte; nur die Leiter darunter ist eine andere geworden.
storedBytes($fixture['instance'], 135);
$team = app(PlanCatalogue::class)->sellable()['team']; // 500 GB
$team = app(PlanCatalogue::class)->sellable()['team']; // 85 GB
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');
expect($check->allowed)->toBeFalse()
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage'])
->and($check->storageRemedy)->not->toBeNull()
->and($check->storageRemedy['limit'])->toBe('500 GB')
->and($check->storageRemedy['limit'])->toBe('85 GB')
->and($check->storageRemedy['packs'])->toBe(3)
->and($check->storageRemedy['pack_gb'])->toBe(20)
// Gedeckt, also kein Restbetrag — und damit der Satz, der zum Knopf
@ -318,11 +321,9 @@ it('still blocks a downgrade the stored data does not fit, and says what would f
it('lets exactly that downgrade through once the offered packs are booked', function () {
$fixture = storageFixture('business');
// Absichtlich NICHT die 600 GB von oben: fünf Blöcke passen nicht mehr
// unter den Deckel (Task 2: höchstens drei), seit ein Block auf 20 GB
// geschrumpft ist (Task 5). Vierzig Gigabyte zu viel sind zwei Blöcke —
// innerhalb des Deckels, und genug, um denselben Ablauf zu belegen.
storedBytes($fixture['instance'], 540);
// Vierzig Gigabyte über Teams 85 sind zwei Blöcke — innerhalb des Deckels
// (Task 2: höchstens drei), und genug, um denselben Ablauf zu belegen.
storedBytes($fixture['instance'], 125);
$team = app(PlanCatalogue::class)->sellable()['team'];
$packs = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->storageRemedy['packs'];
@ -342,17 +343,17 @@ it('lets exactly that downgrade through once the offered packs are booked', func
/**
* Der Ausweg muss auch kaufmännisch gehbar sein, nicht nur rechnerisch.
*
* `packsToCover()` rechnet, und die Rechnung kennt keinen Deckel: Hundert
* Gigabyte zu viel waren fünf Blöcke, und höchstens drei sind buchbar. Die
* Karte bot 5 × Zusatzspeicher buchen (+100 GB)an, das Modal klemmte still
* auf drei, BookAddon hätte den vierten ohnehin abgelehnt und der Kunde
* stand nach 45 im Monat genau dort, wo er vorher stand. Solange ein Block
* 100 GB brachte, war das unerreichbar; seit Task 5 sind es 20, und jede
* Überschreitung über 60 GB landet hier.
* `packsToCover()` rechnet, und die Rechnung kennt keinen Deckel: achtzig
* Gigabyte zu viel sind vier Blöcke, und höchstens drei sind buchbar. Die
* Karte bot 4 × Zusatzspeicher buchenan, das Modal klemmte still auf drei,
* BookAddon hätte den vierten ohnehin abgelehnt und der Kunde stand nach 45
* im Monat genau dort, wo er vorher stand. Solange ein Block 100 GB brachte,
* war das unerreichbar; seit Task 5 sind es 20, und jede Überschreitung über
* 60 GB landet hier.
*/
it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600); // 100 GB zu viel für Team = fünf Blöcke
storedBytes($fixture['instance'], 165); // 80 GB zu viel für Teams 85 = vier Blöcke
$team = app(PlanCatalogue::class)->sellable()['team'];
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team');
@ -362,9 +363,9 @@ it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', func
// an diese Zahl.
->and($check->storageRemedy['packs'])->toBe(0)
// Und was nach allen drei buchbaren Blöcken übrig bliebe, in Ziffern —
// nicht die vollen 107 GB, die ohne Blöcke wegmüssten.
->and($check->storageRemedy['short'])->toBe('42,9 GB')
->and($check->storageRemedy['over'])->toBe('107 GB');
// 20 GiB, nicht die vollen 80, die ohne Blöcke wegmüssten.
->and($check->storageRemedy['short'])->toBe('21,5 GB')
->and($check->storageRemedy['over'])->toBe('85,9 GB');
});
/**
@ -373,7 +374,7 @@ it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', func
*/
it('bleibt gesperrt, auch wenn der Kunde das Höchstmaß an Blöcken bucht', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600);
storedBytes($fixture['instance'], 165);
foreach (range(1, 3) as $ignored) {
app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE);
@ -388,22 +389,22 @@ it('bleibt gesperrt, auch wenn der Kunde das Höchstmaß an Blöcken bucht', fun
expect($after->allowed)->toBeFalse()
->and($after->storageRemedy['packs'])->toBe(0)
->and($after->storageRemedy['short'])->toBe('42,9 GB');
->and($after->storageRemedy['short'])->toBe('21,5 GB');
});
/** Dieselbe Grenze in der Ansicht: kein Knopf, und der Satz sagt warum. */
it('bietet auf der Karte keinen Kauf an, den der Vertrag nicht abschließen kann', function () {
$fixture = storageFixture('business');
storedBytes($fixture['instance'], 600);
storedBytes($fixture['instance'], 165);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
->assertDontSee('confirm-book-storage')
->assertSee(__('billing.downgrade_escape.capped', [
'used' => '644 GB',
'limit' => '500 GB',
'over' => '107 GB',
'short' => '42,9 GB',
'used' => '177 GB',
'limit' => '85 GB',
'over' => '85,9 GB',
'short' => '21,5 GB',
]))
// Der andere Ausweg bleibt: löschen und neu messen.
->assertSee(__('billing.downgrade_escape.remeasure'));
@ -416,8 +417,10 @@ it('bietet auf der Karte keinen Kauf an, den der Vertrag nicht abschließen kann
* dieses Paket nicht gibt.
*/
it('bietet einem Enterprise-Vertrag keine Blöcke an, den es dort nicht gibt', function () {
$fixture = storageFixture('enterprise');
storedBytes($fixture['instance'], 1200); // 200 GB zu viel für Business
// Enterprise wird seit der neuen Leiter angefragt statt gekauft; der
// Vertrag, um den es hier geht, ist einer aus der Zeit davor.
$fixture = asGrandfathered('enterprise', fn () => storageFixture('enterprise'));
storedBytes($fixture['instance'], 375); // 200 GB zu viel für Business' 175
$business = app(PlanCatalogue::class)->sellable()['business'];
$check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $business, 'business');
@ -444,7 +447,7 @@ it('legt über den Bestätigungsweg keine Bestellzeile an, wenn nichts buchbar i
// Eigene Adresse: `storageFixture()` vergibt sonst zweimal dieselbe, und
// `instances.subdomain` ist eindeutig.
$enterprise = storageFixture('enterprise', ['subdomain' => 'huber']);
$enterprise = asGrandfathered('enterprise', fn () => storageFixture('enterprise', ['subdomain' => 'huber']));
withStripeSecret();
@ -462,11 +465,9 @@ it('legt über den Bestätigungsweg keine Bestellzeile an, wenn nichts buchbar i
it('offers the packs through a modal, and books exactly the number that was offered', function () {
$fixture = storageFixture('business');
// Sechzig Gigabyte zu viel, also genau drei Blöcke. Vorher 800 GB, was
// damals „drei Blöcke zu wenig“ hieß und seit Task 5 fünfzehn wären — die
// Karte böte dafür gar keinen Knopf mehr an, und der Test hätte das Modal
// gesucht, das sie zu Recht nicht mehr öffnet.
storedBytes($fixture['instance'], 560);
// Sechzig Gigabyte über Teams 85, also genau drei Blöcke — der letzte
// Betrag, für den die Karte überhaupt noch einen Knopf anbietet.
storedBytes($fixture['instance'], 145);
Livewire::actingAs($fixture['user'])
->test(Billing::class)
@ -504,14 +505,14 @@ it('passes the check on a fresh reading after data was deleted, without waiting
$fixture = storageFixture('business');
$pve = app(ProxmoxClient::class);
// Last night's reading: 600 GB stored, which does not fit Team.
storedBytes($fixture['instance'], 600, now()->subDay()->toDateString());
// Last night's reading: 165 GB stored, which does not fit Team's 85.
storedBytes($fixture['instance'], 165, now()->subDay()->toDateString());
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team')->allowed)->toBeFalse();
// The customer deletes 300 GB and asks us to look now.
$pve->guestScript('df -B1', 0, "size used\n".(1050 * 1024 ** 3).' '.(300 * 1024 ** 3));
// The customer deletes over 100 GB and asks us to look now.
$pve->guestScript('df -B1', 0, "size used\n".(200 * 1024 ** 3).' '.(60 * 1024 ** 3));
Livewire::actingAs($fixture['user'])
->test(Billing::class)
@ -524,7 +525,7 @@ it('passes the check on a fresh reading after data was deleted, without waiting
it('says it could not look rather than reporting an unreachable cloud as empty', function () {
$fixture = storageFixture('business');
$pve = app(ProxmoxClient::class);
storedBytes($fixture['instance'], 600, now()->subDay()->toDateString());
storedBytes($fixture['instance'], 165, now()->subDay()->toDateString());
$pve->guestAgentUp = false;
Livewire::actingAs($fixture['user'])
@ -552,9 +553,9 @@ it('keeps booked packs through a plan change and delivers them on the new packag
$instance = $fixture['instance']->fresh();
expect(SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->active()->sum('quantity'))->toBe(2)
// Business is 1000 GB; the two packs (Task 5: 20 GB each) are still
// Business is 175 GB; the two packs (Task 5: 20 GB each) are still
// theirs on top of it.
->and(StorageAllowance::for($instance)->totalGb())->toBe(1040);
->and(StorageAllowance::for($instance)->totalGb())->toBe(215);
$run = ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->firstOrFail();
@ -562,9 +563,9 @@ it('keeps booked packs through a plan change and delivers them on the new packag
app(GrowGuestFilesystem::class)->execute($run->fresh());
app(ApplyStorageQuota::class)->execute($run->fresh());
// 1040 GB for the customer, on Business's own 50 GB of overhead plus the
// two packs' own 22 GB disk headroom each (44 GB).
expect($pve->resizeCalls)->toContain('101:scsi0:1094G')
->and($pve->guestRan("files default_quota --value='1040 GB'"))->toBeTrue()
->and($instance->fresh()->quota_applied_gb)->toBe(1040);
// 215 GB for the customer, on Business's own 25 GB of overhead (175 sold on
// a 200 GB disk) plus the two packs' own 22 GB disk headroom each (44 GB).
expect($pve->resizeCalls)->toContain('101:scsi0:244G')
->and($pve->guestRan("files default_quota --value='215 GB'"))->toBeTrue()
->and($instance->fresh()->quota_applied_gb)->toBe(215);
});

View File

@ -48,8 +48,10 @@ it('bleibt teurer je Gigabyte als der Aufstieg', function () {
});
it('bietet Enterprise keinen Zusatzspeicher an', function () {
// Enterprise wird seit der neuen Leiter angefragt statt gekauft; der
// Vertrag hier ist einer aus der Zeit davor, und die laufen weiter.
$order = Order::factory()->create(['plan' => 'enterprise', 'datacenter' => 'fsn', 'status' => 'paid']);
$subscription = app(OpenSubscription::class)($order);
$subscription = asGrandfathered('enterprise', fn () => app(OpenSubscription::class)($order));
expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1))
->toThrow(RuntimeException::class);
@ -64,7 +66,7 @@ it('bietet Enterprise keinen Zusatzspeicher an', function () {
*/
it('lässt auch den Betreiber keinen Zusatzspeicher an Enterprise verschenken', function () {
$order = Order::factory()->create(['plan' => 'enterprise', 'datacenter' => 'fsn', 'status' => 'paid']);
$subscription = app(OpenSubscription::class)($order);
$subscription = asGrandfathered('enterprise', fn () => app(OpenSubscription::class)($order));
$operator = Operator::factory()->create();
expect(fn () => app(GrantAddon::class)($subscription, $operator, AddonCatalogue::STORAGE, 0, 1))
@ -84,7 +86,7 @@ it('lässt auch den Betreiber keinen Zusatzspeicher an Enterprise verschenken',
it('bietet Enterprise auch im Warenkorb keinen Zusatzspeicher an', function () {
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
$order = Order::factory()->withSubscription()->for($customer)->create(['plan' => 'enterprise']);
$order = asGrandfathered('enterprise', fn () => Order::factory()->withSubscription()->for($customer)->create(['plan' => 'enterprise']));
Instance::factory()->for($customer)->create([
'order_id' => $order->id, 'plan' => 'enterprise', 'status' => 'active',
]);

View File

@ -72,10 +72,16 @@ it('mirrors the catalogue into Stripe, once', function () {
// version: the domestic gross and the bare net a reverse-charge business is
// charged. Both are minted up front, because the Price has to exist before
// the checkout that needs it.
expect(planProducts($stripe))->toHaveCount(4)
->and(planPrices($stripe))->toHaveCount(16)
->and(planPrices($stripe, 'domestic'))->toHaveCount(8)
->and(planPrices($stripe, 'reverse_charge'))->toHaveCount(8)
// Fünf Familien: die drei verkauften, Enterprise (auf Anfrage, aber mit
// laufenden Verträgen) und das interne Testpaket. Sechzehn 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.
expect(planProducts($stripe))->toHaveCount(5)
->and(planPrices($stripe))->toHaveCount(32)
->and(planPrices($stripe, 'domestic'))->toHaveCount(16)
->and(planPrices($stripe, 'reverse_charge'))->toHaveCount(16)
->and(PlanFamily::query()->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.
@ -104,9 +110,9 @@ 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(16)
expect(planPrices($stripe))->toHaveCount(32)
->and(modulePrices($stripe))->toHaveCount(count($modules) * 4)
->and($stripe->products)->toHaveCount(4 + count($modules));
->and($stripe->products)->toHaveCount(5 + count($modules));
});
it('gives each price its own recurring interval', function () {
@ -117,23 +123,33 @@ 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(4)
->and($intervals['year'])->toBe(4);
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
// erste, den die Sammlung hergibt" wäre eine Zusicherung über die
// Reihenfolge einer Liste.
$live = (string) app(PlanCatalogue::class)->currentVersion('team')->version;
// The DOMESTIC GROSS, which is the figure on the website and the one taken
// from the card of everybody who is charged VAT. The catalogue row stays net.
$team = planPrices($stripe, 'domestic')
->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month');
expect($team['amount'])->toBe(21480)
->and($team['amount'])->toBe(TaxTreatment::advertisedCents(17900))
->and($team['metadata']['plan_version'])->toBe('1');
->first(fn ($p) => $p['metadata']['plan_family'] === 'team'
&& $p['interval'] === 'month'
&& $p['metadata']['plan_version'] === $live);
expect($team['amount'])->toBe(9480)
->and($team['amount'])->toBe(TaxTreatment::advertisedCents(7900))
->and($team['metadata']['plan_version'])->toBe('2');
// 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: 214,80 € taken
// for a 179,00 € package from exactly the customers who read their invoices.
// no VAT at all. One Price for everybody was the overcharge: 94,80 € taken
// for a 79,00 € package from exactly the customers who read their invoices.
$reverse = planPrices($stripe, 'reverse_charge')
->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month');
expect($reverse['amount'])->toBe(17900)
->first(fn ($p) => $p['metadata']['plan_family'] === 'team'
&& $p['interval'] === 'month'
&& $p['metadata']['plan_version'] === $live);
expect($reverse['amount'])->toBe(7900)
->and($reverse['product'])->toBe($team['product']);
});
@ -151,7 +167,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(16)
expect(planPrices($stripe))->toHaveCount(32)
->and(planPrices($stripe)->pluck('amount'))->not->toContain(19900);
});
@ -169,8 +185,8 @@ it('does not mint a second object when a run is interrupted before the id is sto
// Stripe replays the original answer for a repeated idempotency key, so the
// 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(4)
->and(planPrices($stripe))->toHaveCount(16)
expect(planProducts($stripe))->toHaveCount(5)
->and(planPrices($stripe))->toHaveCount(32)
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
});
@ -216,7 +232,7 @@ it('does not put a plan that has never been published in Stripe at all', functio
// Not even the Product: it would be a price-list entry for something that
// has promised nothing to anyone.
expect(planProducts($stripe))->toHaveCount(4)
expect(planProducts($stripe))->toHaveCount(5)
->and($family->fresh()->stripe_product_id)->toBeNull();
});
@ -549,7 +565,7 @@ 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' => 2, 'features' => $current->features, 'available_from' => now(),
'version' => 3, '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']);
@ -558,13 +574,13 @@ it('never rewrites the snapshot when a renewal arrives', function () {
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_ren', 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 21480,
'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 9480,
'billing_reason' => 'subscription_cycle',
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
]],
])->assertOk();
// A renewal moves the month, not the terms.
expect($subscription->fresh()->price_cents)->toBe(17900)
expect($subscription->fresh()->price_cents)->toBe(7900)
->and($subscription->fresh()->plan_version_id)->toBe($current->id);
});

View File

@ -55,10 +55,11 @@ it('offers both terms in the shop, with the figure behind the saving', function
$plan = app(PlanCatalogue::class)->sellable()['team'];
expect($plan)->toHaveKeys(['price_cents', 'yearly_price_cents', 'free_months'])
->and($plan['price_cents'])->toBe(17900)
// Twelve for twelve on this installation until somebody sets otherwise —
// what matters is that the figure is READ rather than assumed.
->and($plan['yearly_price_cents'])->toBe(17900 * (12 - $plan['free_months']));
->and($plan['price_cents'])->toBe(7900)
// Zwei Freimonate auf der neuen Leiter — was hier zählt, ist, dass die
// Zahl GELESEN und nicht angenommen wird.
->and($plan['free_months'])->toBe(2)
->and($plan['yearly_price_cents'])->toBe(7900 * (12 - $plan['free_months']));
});
it('freezes the free months when the version is published', function () {
@ -115,8 +116,8 @@ it('carries the chosen term into the checkout page and its form', function () {
->getContent();
expect($page)->toContain('name="term" value="yearly"')
// And the yearly figure is the one on the summary: 179,00 × 12 gross.
->and($page)->toContain('2.577,60');
// And the yearly figure is the one on the summary: 79,00 × 10 gross.
->and($page)->toContain('948,00');
});
it('refuses a term nobody sells even in the address bar', function () {

View File

@ -43,7 +43,7 @@ it('records an upgrade purchase intent', function () {
expect($order)->not->toBeNull()
->and($order->plan)->toBe('team')
->and($order->status)->toBe('pending')
->and($order->amount_cents)->toBe(17900);
->and($order->amount_cents)->toBe(7900);
});
it('records a storage and an addon purchase intent', function () {

View File

@ -83,14 +83,16 @@ it('keeps at most one plan change in the cart', function () {
Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'upgrade', 'team');
$component->call('purchase', 'upgrade', 'business');
$component->call('purchase', 'upgrade', 'enterprise');
// Two contradicting plan changes cannot both be paid for — the second
// replaces the first instead of stacking.
// replaces the first instead of stacking. Business statt Enterprise als
// zweiter Schritt: Enterprise wird seit der neuen Leiter angefragt und
// nicht mehr verkauft, ein Aufstieg dorthin ist also gar kein Kauf mehr.
$upgrades = Order::query()->where('type', 'upgrade')->where('status', 'pending')->get();
expect($upgrades)->toHaveCount(1)
->and($upgrades->first()->plan)->toBe('enterprise');
->and($upgrades->first()->plan)->toBe('business');
});
it('still lets add-ons stack', function () {

View File

@ -29,9 +29,9 @@ it('states the package, the term and every figure that will be charged', functio
$page->assertSee('Team')
->assertSee(__('order.term_monthly'))
// Net, tax and gross: 179,00 net, 20 % VAT, 214,80 to pay.
->assertSee('179,00')
->assertSee('214,80')
// Net, tax and gross: 79,00 net, 20 % VAT, 94,80 to pay.
->assertSee('79,00')
->assertSee('94,80')
->assertSee(__('checkout.total_today'))
// And what comes after the first charge, which is not the same figure
// whenever a setup fee is on the first one. Asserted in two pieces: the
@ -47,12 +47,12 @@ it('adds the setup fee to what is due today and to nothing after it', function (
->get(route('checkout', ['plan' => 'team', 'term' => 'monthly']))
->assertOk();
// 214,80 for the month plus 118,80 for the setup — both gross.
// 94,80 for the month plus 118,80 for the setup — both gross.
$page->assertSee(__('checkout.setup_line'))
// 214,80 for the month plus 118,80 for the setup.
->assertSee('333,60')
// 94,80 for the month plus 118,80 for the setup.
->assertSee('213,60')
// The recurring figure alone is what follows the first charge.
->assertSee('214,80');
->assertSee('94,80');
expect(SetupFee::netCents())->toBe(9900);
});

View File

@ -23,8 +23,9 @@ beforeEach(function () {
});
it('promises immediate delivery on the price sheet only where a host has room', function () {
// Room for the small package, nowhere near enough for the big one.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);
// Room for the small package, nowhere near enough for the big one: Start
// braucht 40 GB, Business 200.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 150, 'reserve_pct' => 0]);
$response = $this->get('/');

View File

@ -14,9 +14,9 @@ use App\Support\Settings;
it('quotes packages including VAT, with the net figure underneath', function () {
$content = $this->get('/')->assertOk()->getContent();
// start is 49 € net in the catalogue → 58,80 € at 20 %.
expect($content)->toContain('58,80'."\u{00A0}".'€')
->and($content)->toContain('49'."\u{00A0}".'€')
// start is 39 € net in the catalogue → 46,80 € at 20 %.
expect($content)->toContain('46,80'."\u{00A0}".'€')
->and($content)->toContain('39'."\u{00A0}".'€')
->and($content)->toContain('inkl. 20 % MwSt.');
});
@ -28,9 +28,9 @@ it('follows the rate the operator set, not one written into the page', function
$content = $this->get('/')->assertOk()->getContent();
expect($content)->toContain('53,90'."\u{00A0}".'€') // 49 € + 10 %
expect($content)->toContain('42,90'."\u{00A0}".'€') // 39 € + 10 %
->and($content)->toContain('inkl. 10 % MwSt.')
->and($content)->not->toContain('58,80'."\u{00A0}".'€');
->and($content)->not->toContain('46,80'."\u{00A0}".'€');
});
it('drops the fractional part of a rate that has none', function () {

View File

@ -75,7 +75,10 @@ function priceSheetRow(string $html, string $label): string
it('quotes the price the catalogue would actually charge', function () {
$team = PlanFamily::query()->where('key', 'team')->firstOrFail();
$version = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail();
// Die Fassung IM VERKAUF, nicht die erste der Familie: seit der Umschaltung
// auf die neue Leiter steht daneben eine beendete, und die Seite quotiert
// selbstverständlich die laufende.
$version = $team->versionAt();
$monthly = $version->prices()->where('term', 'monthly')->firstOrFail();
$page = $this->get('/')->assertOk();
@ -94,17 +97,21 @@ it('quotes the price the catalogue would actually charge', function () {
it('follows the catalogue when the owner changes a price', function () {
// The point of the whole exercise: one place to change a price.
DB::table('plan_prices')
->where('plan_version_id', PlanVersion::query()
->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id'))
->value('id'))
->where('term', 'monthly')
->update(['amount_cents' => 21500]);
// Beide Laufzeiten, wie die Konsole sie ändert: der Jahrespreis wird aus dem
// Monatspreis und den zwei Freimonaten gerechnet, also zehn Monate. Nur eine
// Zeile umzuschreiben ließe die Karte „entspricht … pro Monat" auf dem alten
// Betrag stehen — und genau der soll hier verschwinden.
$version = PlanFamily::query()->where('key', 'team')->firstOrFail()->versionAt();
DB::table('plan_prices')->where('plan_version_id', $version->id)
->where('term', 'monthly')->update(['amount_cents' => 21500]);
DB::table('plan_prices')->where('plan_version_id', $version->id)
->where('term', 'yearly')->update(['amount_cents' => 215000]);
$this->get('/')
->assertOk()
->assertSee("215\u{00A0}€", false)
->assertDontSee("179\u{00A0}€", false);
->assertDontSee("79\u{00A0}€", false);
});
it('keeps the front page up when the catalogue cannot be read', function () {
@ -115,7 +122,10 @@ it('keeps the front page up when the catalogue cannot be read', function () {
Log::spy();
$team = PlanFamily::query()->where('key', 'team')->firstOrFail();
$original = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail();
// Die laufende Fassung geklont, nicht die erste: deren Nummer ist längst
// vergeben, und ein Klon mit derselben Nummer scheiterte am eindeutigen
// Schlüssel, statt zwei Fassungen gleichzeitig in den Verkauf zu stellen.
$original = $team->versionAt();
$clone = collect($original->getAttributes())->except('id')->all();
$clone['uuid'] = (string) Str::uuid();
@ -126,7 +136,7 @@ it('keeps the front page up when the catalogue cannot be read', function () {
->assertOk()
->assertSee('Preise derzeit auf Anfrage')
// No number survives from the broken catalogue.
->assertDontSee("179\u{00A0}€", false);
->assertDontSee("79\u{00A0}€", false);
Log::shouldHaveReceived('error')
->withArgs(fn (string $message) => str_contains($message, 'plan catalogue'))
@ -142,8 +152,8 @@ it('names the currency the catalogue is priced in, not the one the page was writ
$this->get('/')
->assertOk()
->assertSee("179\u{00A0}CHF", false)
->assertDontSee("179\u{00A0}€", false);
->assertSee("79\u{00A0}CHF", false)
->assertDontSee("79\u{00A0}€", false);
});
it('marks the plan the console recommends, and only that one', function () {
@ -207,19 +217,19 @@ it('offers what a plan lacks at the price the module catalogue would charge', fu
it('states the own-domain rule package by package: impossible, optional, included', function () {
// The owner's rule, on the page that has to say it: an own domain is
// impossible on the entry package, a module on the one above it, and part
// of the two above that. Quoting nine euros in the first column was the
// of the one above that. Quoting nine euros in the first column was the
// sheet making an offer the shop would then refuse — and the price sheet is
// where somebody decides which package to buy in the first place.
$row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Eigene Domain');
$cells = array_slice(explode('<td', $row), 1);
expect($cells)->toHaveCount(4)
// Drei Spalten, seit Enterprise angefragt statt gekauft wird.
expect($cells)->toHaveCount(3)
->and($cells[0])->toContain('—')
->and($cells[0])->not->toContain('optional')
->and($cells[0])->not->toContain('€')
->and($cells[1])->toContain('optional · '.priceSheetGross(900))
->and($cells[2])->toContain('inklusive')
->and($cells[3])->toContain('inklusive');
->and($cells[2])->toContain('inklusive');
});
it('leaves a feature we do not sell as a dash, without inventing a price for it', function () {
@ -238,6 +248,12 @@ it('shows one support level per plan, and never a dash for the best of them', fu
// independent rows they made the top plan look worse than the middle one:
// enterprise carries the SLA and not the priority flag, so it rendered
// "Bevorzugter Support —" beside team's tick.
//
// Enterprise gehört hier auf die Seite, denn es ist das Paket, an dem der
// Fehler entstand — die neue Leiter nimmt es aus dem Verkauf, und ohne
// diese Zeile prüfte der Test die Stufe „Premium" gar nicht mehr.
PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true]);
$content = $this->get('/')->assertOk()->getContent();
$row = priceSheetRow($content, 'Support');
@ -314,11 +330,13 @@ it('drops a catalogue feature it has no wording for, rather than printing its ke
});
it('leaves Enterprise in the comparison table while it is still on sale', function () {
// Today's state (sales_enabled defaults to true): nothing about this task
// changes the page until the owner actually flips the switch. Checked
// Die Seite folgt dem Schalter und nicht einer Liste im Quelltext: wird
// Enterprise wieder verkauft, steht es wieder in der Tabelle. Checked
// against the CTA text rather than the (now interpolated) heading — a
// heading built with no replacement never matches what the page actually
// renders anyway, sellable or not, and would prove nothing either way.
PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true]);
$content = $this->get('/')->assertOk()->getContent();
expect(priceSheetTable($content))->toContain('>Enterprise<')
@ -326,17 +344,15 @@ it('leaves Enterprise in the comparison table while it is still on sale', functi
});
it('shows Enterprise as a request for a quote once it is taken off sale', function () {
// The state Task 9's cutover migration will eventually set. Toggled by
// hand here on purpose — setting sales_enabled is that task's job, only
// reacting to it is this one's.
App\Models\PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => false]);
// Der Zustand, den die Umschaltung auf die neue Leiter gesetzt hat.
$content = $this->get('/')->assertOk()->getContent();
expect(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeFalsy();
expect($content)
// Business (seeded at 1000 GB, which formats as "1 TB") is the
// largest family left once Enterprise drops out of $plans.
->toContain(__('landing.enterprise_title', ['quota' => '1 TB']))
// Business (175 GB auf der neuen Leiter) ist das größte Paket, das
// übrig bleibt, wenn Enterprise aus $plans fällt.
->toContain(__('landing.enterprise_title', ['quota' => '175 GB']))
->and($content)->toContain(__('landing.enterprise_cta'))
// No price is left to compare it against once it drops out of
// PlanCatalogue::sellable() — not a dash in a row, no column at all.
@ -348,7 +364,6 @@ it('names the largest sellable quota in the heading, not a figure typed into the
// package" holds, and the heading has to move with it. Two different
// sizes in a row is the actual proof — a single value could just as
// easily be the coincidence of matching a number already sitting there.
App\Models\PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => false]);
$businessId = PlanFamily::query()->where('key', 'business')->value('id');
// Both above Team's own quota, so Business stays the largest family
@ -374,7 +389,7 @@ it('keeps the front page up instead of naming a size when no package is sellable
Log::spy();
$team = PlanFamily::query()->where('key', 'team')->firstOrFail();
$original = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail();
$original = $team->versionAt();
$clone = collect($original->getAttributes())->except('id')->all();
$clone['uuid'] = (string) Str::uuid();

View File

@ -21,7 +21,7 @@ it('resolves order, instance and plan from the run', function () {
expect($step->orderOf($run)->is($order))->toBeTrue()
->and($step->instanceOf($run)->is($instance))->toBeTrue()
->and($step->planOf($run)['quota_gb'])->toBe(500)
->and($step->planOf($run)['quota_gb'])->toBe(85)
->and($step->label())->toBe('provisioning.step.probe');
});

View File

@ -25,8 +25,8 @@ use App\Services\Billing\AddonCatalogue;
*/
/**
* Ein frisch reservierter 'start'-Lauf: 100 GB Kontingent, 120 GB Platte
* (`database/migrations/2026_07_26_040000_create_plan_catalogue_tables.php`),
* Ein frisch reservierter 'start'-Lauf: 30 GB Kontingent, 40 GB Platte
* (`database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php`),
* kein Block gebucht.
*
* Absichtlich eine eigene, in sich geschlossene Hilfsfunktion statt
@ -42,9 +42,15 @@ function packHeadroomRun(): array
{
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'start']);
// Größe aus dem Vertrag, nicht aus den Vorgaben der Fabrik: so schreibt sie
// ReserveResources, und die Vorgaben der Fabrik beschreiben noch das alte
// Startpaket (100 GB auf 120). Ein Lauf, der von dort ausginge, prüfte den
// Kopfraum eines Pakets, das es nicht mehr gibt.
$instance = Instance::factory()->create([
'order_id' => $order->id, 'customer_id' => $order->customer_id,
'host_id' => $host->id, 'vmid' => 101, 'status' => 'provisioning',
'quota_gb' => $order->subscription->quota_gb,
'disk_gb' => $order->subscription->disk_gb,
]);
$run = ProvisioningRun::factory()->create([
'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer',
@ -63,8 +69,8 @@ function packHeadroomRun(): array
// tut; gefragt ist nur, was ResizeVirtualMachine aus einer bereits gebuchten
// Zeile macht.
//
// Ziel: 100 (Kontingent) + 60 (drei Blöcke à 20) + 20 (Kopfraum des Pakets,
// 120-100) + 6 (Kopfraum der Blöcke, 66-60 belegt) = 186 GB.
// Ziel: 30 (Kontingent) + 60 (drei Blöcke à 20) + 10 (Kopfraum des Pakets,
// 40-30) + 6 (Kopfraum der Blöcke, 66-60 belegt) = 106 GB.
it('vergrößert die Platte um Paket- und Block-Kopfraum, und nur einmal', function () {
$s = fakeServices();
['run' => $run, 'instance' => $instance, 'order' => $order] = packHeadroomRun();
@ -81,13 +87,13 @@ it('vergrößert die Platte um Paket- und Block-Kopfraum, und nur einmal', funct
]);
expect(app(ResizeVirtualMachine::class)->execute($run)->type)->toBe('advance')
->and($s['pve']->resizeCalls)->toContain('101:scsi0:186G')
->and($instance->fresh()->disk_gb)->toBe(186);
->and($s['pve']->resizeCalls)->toContain('101:scsi0:106G')
->and($instance->fresh()->disk_gb)->toBe(106);
// Wiederholungslauf: das Ziel ist absolut, nicht '+…', also darf ein
// zweiter Durchlauf nicht noch einmal vergrößern.
$s['pve']->resizeCalls = [];
expect(app(ResizeVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance')
->and($s['pve']->resizeCalls)->toBe([])
->and($instance->fresh()->disk_gb)->toBe(186);
->and($instance->fresh()->disk_gb)->toBe(106);
});

View File

@ -94,7 +94,7 @@ it('freezes the catalogue onto a subscription when the checkout is paid', functi
'id' => 'cs_snap',
'payment_status' => 'paid',
'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'],
'amount_total' => 17900,
'amount_total' => 9480,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
@ -106,8 +106,8 @@ it('freezes the catalogue onto a subscription when the checkout is paid', functi
expect($subscription->plan)->toBe('team')
->and($subscription->customer_id)->toBe($order->customer_id)
->and($subscription->ram_mb)->toBe(8192)
->and($subscription->disk_gb)->toBe(540)
->and($subscription->price_cents)->toBe(17900)
->and($subscription->disk_gb)->toBe(100)
->and($subscription->price_cents)->toBe(7900)
->and($subscription->tier)->toBe(2)
->and($subscription->status)->toBe('active')
->and($subscription->current_period_end->greaterThan($subscription->current_period_start))->toBeTrue();
@ -140,14 +140,14 @@ it('sizes the machine from the contract, not from a catalogue edited afterwards'
['order' => $order, 'run' => $run] = paidOrderWithSubscription('team');
// The operator cuts the team plan back after this customer has paid for it.
publishSuccessor('team', ['ram_mb' => 4096, 'cores' => 2, 'disk_gb' => 120, 'quota_gb' => 100]);
publishSuccessor('team', ['ram_mb' => 4096, 'cores' => 2, 'disk_gb' => 60, 'quota_gb' => 50]);
// The premise, asserted and not assumed. This edit used to be four
// config('provisioning.plans.team.*') writes — a key that has not existed
// since the catalogue moved into the database — so the catalogue was never
// touched at all and the test could not detect the regression it exists for.
expect(Subscription::snapshotFrom('team')['ram_mb'])->toBe(4096)
->and(Subscription::snapshotFrom('team')['disk_gb'])->toBe(120);
->and(Subscription::snapshotFrom('team')['disk_gb'])->toBe(60);
expect(app(ReserveResources::class)->execute($run)->type)->toBe('advance');
@ -155,8 +155,8 @@ it('sizes the machine from the contract, not from a catalogue edited afterwards'
expect($instance->ram_mb)->toBe(8192)
->and($instance->cores)->toBe(4)
->and($instance->disk_gb)->toBe(540)
->and($instance->quota_gb)->toBe(500);
->and($instance->disk_gb)->toBe(100)
->and($instance->quota_gb)->toBe(85);
});
it('links the instance back to the contract it was provisioned for', function () {

View File

@ -150,12 +150,12 @@ it('does not take an unverified address through checkout', function () {
});
it('shows the packages with the same gross prices the public sheet quotes', function () {
// A customer quoted 58,80 € out there must not meet 49 € on the page with
// A customer quoted 46,80 € out there must not meet 39 € on the page with
// the button on it.
$this->actingAs(buyer())
->get(route('order'))
->assertOk()
->assertSee('58,80')
->assertSee('46,80')
// "Weiter", not "buy": the order page chooses, the checkout page buys.
->assertSee(__('order.choose'));
});

View File

@ -146,7 +146,7 @@ it('keeps the allowance the customer bought when a leaner version replaces it',
$leaner = App\Models\PlanVersion::query()->create([
...$current->only(['plan_family_id', 'quota_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'version' => 3,
'traffic_gb' => 500,
'features' => $current->features,
'available_from' => now(),

View File

@ -2,6 +2,7 @@
use App\Models\Mailbox;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Services\Dns\FakeHetznerDnsClient;
use App\Services\Dns\FakeHostDnsDirectory;
use App\Services\Dns\HetznerDnsClient;
@ -281,3 +282,36 @@ function scriptLiveTunnel(FakeRemoteShell $shell): void
CommandResult::success($now."\n".$hub."\t".$now),
);
}
/**
* Open a contract on a plan that has left the shop.
*
* The new ladder takes Enterprise off sale, but the contracts opened while it
* WAS on sale keep running a frozen snapshot each, untouched by the switch
* and several tests are about precisely those customers. Building one has to go
* through OpenSubscription, which rightly refuses a plan that is not sellable,
* so the plan is put back on sale for the length of the purchase and taken off
* again straight after.
*
* That is not a way around the guard: it is the order in which a grandfathered
* contract actually came about. The plan was on sale, somebody bought it, and
* then it stopped being sold.
*
* @template T
*
* @param callable(): T $buy
* @return T
*/
function asGrandfathered(string $planKey, callable $buy): mixed
{
$family = PlanFamily::query()->where('key', $planKey)->sole();
$wasOnSale = $family->sales_enabled;
PlanFamily::query()->whereKey($family->getKey())->update(['sales_enabled' => true]);
try {
return $buy();
} finally {
PlanFamily::query()->whereKey($family->getKey())->update(['sales_enabled' => $wasOnSale]);
}
}