diff --git a/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php b/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php new file mode 100644 index 0000000..cc6fdee --- /dev/null +++ b/database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php @@ -0,0 +1,246 @@ +> */ + 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')); + } +}; diff --git a/tests/Feature/Admin/CapacityQueueTest.php b/tests/Feature/Admin/CapacityQueueTest.php index b661629..380e35b 100644 --- a/tests/Feature/Admin/CapacityQueueTest.php +++ b/tests/Feature/Admin/CapacityQueueTest.php @@ -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') diff --git a/tests/Feature/Admin/CustomerDetailTabsTest.php b/tests/Feature/Admin/CustomerDetailTabsTest.php index 2b5b3f1..44edbcb 100644 --- a/tests/Feature/Admin/CustomerDetailTabsTest.php +++ b/tests/Feature/Admin/CustomerDetailTabsTest.php @@ -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')); }); diff --git a/tests/Feature/Admin/CustomerRevenueTest.php b/tests/Feature/Admin/CustomerRevenueTest.php index 4b420bd..85e8eef 100644 --- a/tests/Feature/Admin/CustomerRevenueTest.php +++ b/tests/Feature/Admin/CustomerRevenueTest.php @@ -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 () { diff --git a/tests/Feature/Admin/GrantPlanTest.php b/tests/Feature/Admin/GrantPlanTest.php index 3ba8798..154b9be 100644 --- a/tests/Feature/Admin/GrantPlanTest.php +++ b/tests/Feature/Admin/GrantPlanTest.php @@ -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(); diff --git a/tests/Feature/Admin/HostCapacityTest.php b/tests/Feature/Admin/HostCapacityTest.php index 1963892..25020d7 100644 --- a/tests/Feature/Admin/HostCapacityTest.php +++ b/tests/Feature/Admin/HostCapacityTest.php @@ -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(); diff --git a/tests/Feature/Admin/MailTemplateTest.php b/tests/Feature/Admin/MailTemplateTest.php index 538a234..d1a8bee 100644 --- a/tests/Feature/Admin/MailTemplateTest.php +++ b/tests/Feature/Admin/MailTemplateTest.php @@ -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 () { diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 5c9c18e..5edb7e0 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -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. diff --git a/tests/Feature/Billing/ApplyPlanChangeTest.php b/tests/Feature/Billing/ApplyPlanChangeTest.php index 4dfd691..4ac7b80 100644 --- a/tests/Feature/Billing/ApplyPlanChangeTest.php +++ b/tests/Feature/Billing/ApplyPlanChangeTest.php @@ -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]) diff --git a/tests/Feature/Billing/CustomDomainAccessTest.php b/tests/Feature/Billing/CustomDomainAccessTest.php index 283ffca..79aae56 100644 --- a/tests/Feature/Billing/CustomDomainAccessTest.php +++ b/tests/Feature/Billing/CustomDomainAccessTest.php @@ -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() diff --git a/tests/Feature/Billing/InternalPlanTest.php b/tests/Feature/Billing/InternalPlanTest.php index 5679f83..f5daae6 100644 --- a/tests/Feature/Billing/InternalPlanTest.php +++ b/tests/Feature/Billing/InternalPlanTest.php @@ -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 () { diff --git a/tests/Feature/Billing/NewPlanLadderTest.php b/tests/Feature/Billing/NewPlanLadderTest.php new file mode 100644 index 0000000..92b036a --- /dev/null +++ b/tests/Feature/Billing/NewPlanLadderTest.php @@ -0,0 +1,79 @@ +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); +}); diff --git a/tests/Feature/Billing/PlanCatalogueTest.php b/tests/Feature/Billing/PlanCatalogueTest.php index 73f763e..f3589e3 100644 --- a/tests/Feature/Billing/PlanCatalogueTest.php +++ b/tests/Feature/Billing/PlanCatalogueTest.php @@ -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); }); diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index f7a0e68..d15d345 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -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')); diff --git a/tests/Feature/Billing/ProofRegisterTest.php b/tests/Feature/Billing/ProofRegisterTest.php index d4343b8..cfbe6cd 100644 --- a/tests/Feature/Billing/ProofRegisterTest.php +++ b/tests/Feature/Billing/ProofRegisterTest.php @@ -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(); }); diff --git a/tests/Feature/Billing/ReverseChargePriceTest.php b/tests/Feature/Billing/ReverseChargePriceTest.php index 3820f1c..5d07aa4 100644 --- a/tests/Feature/Billing/ReverseChargePriceTest.php +++ b/tests/Feature/Billing/ReverseChargePriceTest.php @@ -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 () { diff --git a/tests/Feature/Billing/SetupFeeTest.php b/tests/Feature/Billing/SetupFeeTest.php index 0deadd7..78a427a 100644 --- a/tests/Feature/Billing/SetupFeeTest.php +++ b/tests/Feature/Billing/SetupFeeTest.php @@ -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(); }); diff --git a/tests/Feature/Billing/StorageAllowanceTest.php b/tests/Feature/Billing/StorageAllowanceTest.php index 4739063..d4e7ade 100644 --- a/tests/Feature/Billing/StorageAllowanceTest.php +++ b/tests/Feature/Billing/StorageAllowanceTest.php @@ -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 buchen“ 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. */ 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); }); diff --git a/tests/Feature/Billing/StoragePackPricingTest.php b/tests/Feature/Billing/StoragePackPricingTest.php index 69f5155..0735569 100644 --- a/tests/Feature/Billing/StoragePackPricingTest.php +++ b/tests/Feature/Billing/StoragePackPricingTest.php @@ -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', ]); diff --git a/tests/Feature/Billing/StripeBillingTest.php b/tests/Feature/Billing/StripeBillingTest.php index cebe736..16c83cd 100644 --- a/tests/Feature/Billing/StripeBillingTest.php +++ b/tests/Feature/Billing/StripeBillingTest.php @@ -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); }); diff --git a/tests/Feature/Billing/YearlyTermTest.php b/tests/Feature/Billing/YearlyTermTest.php index 565f393..3450a82 100644 --- a/tests/Feature/Billing/YearlyTermTest.php +++ b/tests/Feature/Billing/YearlyTermTest.php @@ -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 () { diff --git a/tests/Feature/BillingTest.php b/tests/Feature/BillingTest.php index 0b7340a..50270dd 100644 --- a/tests/Feature/BillingTest.php +++ b/tests/Feature/BillingTest.php @@ -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 () { diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php index eaee2b9..5981585 100644 --- a/tests/Feature/CartTest.php +++ b/tests/Feature/CartTest.php @@ -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 () { diff --git a/tests/Feature/CheckoutPageTest.php b/tests/Feature/CheckoutPageTest.php index 8bef529..a1bf046 100644 --- a/tests/Feature/CheckoutPageTest.php +++ b/tests/Feature/CheckoutPageTest.php @@ -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); }); diff --git a/tests/Feature/DeliveryPromiseTest.php b/tests/Feature/DeliveryPromiseTest.php index a6ec53f..dc707fd 100644 --- a/tests/Feature/DeliveryPromiseTest.php +++ b/tests/Feature/DeliveryPromiseTest.php @@ -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('/'); diff --git a/tests/Feature/GrossPricingTest.php b/tests/Feature/GrossPricingTest.php index 573eba7..ce97a9a 100644 --- a/tests/Feature/GrossPricingTest.php +++ b/tests/Feature/GrossPricingTest.php @@ -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 () { diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 094f69c..61e8ebb 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -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('