From c6ead1afc98bfd55336359a530df583c9cf0a1e8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:50:07 +0200 Subject: [PATCH] Deckel haelt auch am Rand: Warenkorb und Bestaetigungsdialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Luecken aus der Durchsicht von Task 2, beide am selben Rand: genau am erreichten Deckel. purchase() klemmte $lines mit max(1, min(..., bookableQuantity, ...)) — bei bookableQuantity=0 floort das auf eine Bestellzeile statt auf keine, und der Speicher-Knopf war bedingungslos gerendert. Jetzt bricht purchase() fuer type=storage frueh ab, wenn AddonCatalogue::quantityRefusal() ablehnt, und der Knopf verschwindet zugunsten des Ablehnungssatzes. ConfirmBookStorage klemmte an der absoluten Grenze (3), nicht an der Restmenge — ein Vertrag mit einem gebuchten Block haette drei weitere versprochen bekommen, obwohl nur zwei noch buchbar sind. Das Modal loest jetzt seinen eigenen Vertrag auf (wie ConfirmCancelAddon und ConfirmRevokeSeat) und klemmt an bookableQuantity(). Beide Faelle waren ungetestet; zwei neue Tests in StoragePackLimitTest.php belegen sie und wurden vor dem Fix gegen den alten Stand als rot verifiziert. Co-Authored-By: Claude Opus 5 --- app/Livewire/Billing.php | 25 ++++++++ app/Livewire/ConfirmBookStorage.php | 21 +++++-- resources/views/livewire/billing.blade.php | 13 ++++- .../Feature/Billing/StoragePackLimitTest.php | 58 +++++++++++++++++++ 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 6eb4570..ebb17d0 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -105,6 +105,26 @@ class Billing extends Component } } + // Speicher an der kaufmännischen Grenze: derselbe Grund wie oben bei + // einem Entitlement, aus demselben Satz (AddonCatalogue::quantityRefusal(), + // die auch BookAddon spricht). Der Knopf, der hierher führt, ist + // bedingungslos gerendert — ohne diese Prüfung entstünde eine + // Bestellzeile ($lines unten floort sonst auf mindestens 1), die nach + // der Zahlung nur noch BookAddon ablehnen könnte, während der Auftrag + // selbst `paid` stehen bliebe (OrderObserver fängt den Fehlschlag nur + // ab und loggt ihn). Ein Teil-Kontingent wird weiter befüllt (unten, + // $lines) — hier wird nur der Fall abgefangen, in dem gar nichts mehr + // geht. + if ($type === 'storage') { + $refusal = app(AddonCatalogue::class)->quantityRefusal($contract, AddonCatalogue::STORAGE, 1); + + if ($refusal !== null) { + $this->dispatch('notify', message: $refusal); + + return; + } + } + // Guard against invalid keys / non-upgrades. $valid = match ($type) { // By rank, matching PlanChange and the cards on the page. Comparing @@ -629,6 +649,11 @@ class Billing extends Component 'upgrades' => $upgrades, 'downgrades' => $downgrades, 'storage' => (array) config('provisioning.storage_addon'), + // Null while the button may still sell a pack, the sentence + // BookAddon would refuse with once it may not — the same call + // purchase() makes before writing an order, so the card and the + // action never disagree about whether one more block is possible. + 'storageLimitNote' => app(AddonCatalogue::class)->quantityRefusal($instance?->subscription ?? $contract, AddonCatalogue::STORAGE, 1), 'trafficAddon' => (array) config('provisioning.traffic.addon'), // Resolved once: the cart and the plan cards must not state // different tax treatments on the same page. diff --git a/app/Livewire/ConfirmBookStorage.php b/app/Livewire/ConfirmBookStorage.php index 5fb21bb..70b42eb 100644 --- a/app/Livewire/ConfirmBookStorage.php +++ b/app/Livewire/ConfirmBookStorage.php @@ -2,7 +2,9 @@ namespace App\Livewire; +use App\Livewire\Concerns\ResolvesCustomer; use App\Services\Billing\AddonCatalogue; +use App\Services\Billing\CustomDomainAccess; use LivewireUI\Modal\ModalComponent; /** @@ -20,6 +22,8 @@ use LivewireUI\Modal\ModalComponent; */ class ConfirmBookStorage extends ModalComponent { + use ResolvesCustomer; + public int $packs = 1; public int $packGb = 0; @@ -27,11 +31,20 @@ class ConfirmBookStorage extends ModalComponent public function mount(int $packs = 1): void { $catalogue = app(AddonCatalogue::class); - $max = $catalogue->maxQuantity(AddonCatalogue::STORAGE) ?? 50; - // Geklemmt wie bisher, nur an der kaufmännischen Grenze statt an einer - // erfundenen: ein Modal wird mit Argumenten aus der Seite geöffnet, und - // eine Seite ist Markup. + // Der eigene Vertrag, aufgelöst wie in jedem anderen Bestätigungs-Modal + // (siehe ConfirmCancelAddon, ConfirmRevokeSeat) — nicht aus dem Markup + // übernommen, denn ein Modal ist ohne die Middleware der Seite + // erreichbar. Dieselbe Auflösung, die Billing::purchase() nachher + // tatsächlich bucht (CustomDomainAccess::contractOf()): sonst könnte + // der Dialog eine Zahl versprechen, die die Aktion nicht einhält. + $subscription = app(CustomDomainAccess::class)->contractOf($this->customer()); + + // Geklemmt an der tatsächlich noch buchbaren Menge, nicht an der + // absoluten Grenze: ein Vertrag, der schon einen Block hält, darf im + // Dialog nicht mehr versprechen, als purchase() nachher wirklich in + // den Warenkorb legt. + $max = $catalogue->bookableQuantity($subscription, AddonCatalogue::STORAGE); $this->packs = max(1, min($max, $packs)); $this->packGb = $catalogue->packGb(); } diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 46fb73f..4135e03 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -371,9 +371,16 @@

{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}

{{ __('billing.net_per_month') }}

- - {{ __('billing.storage_cta', ['gb' => $storage['gb']]) }} - + {{-- Kein Knopf, wo purchase() ohnehin ablehnen würde: ein Angebot, + das abgewiesen wird, ist schlechter als gar keines — derselbe + Grund wie beim Eigene-Domain-Knopf oben im Modul-Grid. --}} + @if ($storageLimitNote === null) + + {{ __('billing.storage_cta', ['gb' => $storage['gb']]) }} + + @else +

{{ $storageLimitNote }}

+ @endif {{-- Traffic --}} diff --git a/tests/Feature/Billing/StoragePackLimitTest.php b/tests/Feature/Billing/StoragePackLimitTest.php index 34b4c58..b14927b 100644 --- a/tests/Feature/Billing/StoragePackLimitTest.php +++ b/tests/Feature/Billing/StoragePackLimitTest.php @@ -2,9 +2,13 @@ use App\Actions\BookAddon; use App\Actions\OpenSubscription; +use App\Livewire\Billing; +use App\Livewire\ConfirmBookStorage; use App\Models\Order; use App\Models\Subscription; +use App\Models\User; use App\Services\Billing\AddonCatalogue; +use Livewire\Livewire; /** * Höchstens drei Blöcke. @@ -22,6 +26,12 @@ function limitContract(string $plan = 'start'): Subscription return app(OpenSubscription::class)($order); } +/** The signed-in user behind a contract's customer, for the Livewire tests below. */ +function limitUser(Subscription $subscription): User +{ + return User::factory()->create(['email' => $subscription->customer->email]); +} + it('lehnt den vierten Block auf einmal ab', function () { $subscription = limitContract(); @@ -62,3 +72,51 @@ it('sagt, wie viele noch gehen', function () { expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(1); }); + +/** + * Nachtrag aus der Durchsicht: der Deckel hielt in BookAddon, aber der + * Warenkorb kannte ihn nur halb. + * + * `purchase()` klemmte $lines mit `max(1, min(..., bookableQuantity, ...))` — + * bei erreichtem Deckel (bookableQuantity = 0) floort das `max(1, 0)` auf + * genau eine Bestellzeile statt auf keine. Der Knopf, der `purchase('storage')` + * ohne Menge aufruft, ist in der Ansicht bedingungslos gerendert, also + * entstand am Deckel weiterhin ein bezahlbarer Auftrag, den BookAddon nach + * der Zahlung nur noch ablehnen konnte — während OrderObserver den + * Fehlschlag abfängt und der Auftrag `paid` stehen bleibt. Genau das Szenario, + * dessentwegen diese Aufgabe existiert, nur auf den Fall "genau am Deckel" + * verengt statt beseitigt. + */ +it('legt am erreichten Deckel keine Bestellzeile mehr an', function () { + $subscription = limitContract(); + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 3); + + withStripeSecret(); + + Livewire::actingAs(limitUser($subscription)) + ->test(Billing::class) + ->call('purchase', 'storage'); + + expect(Order::where('customer_id', $subscription->customer_id)->where('type', 'storage')->count())->toBe(0); +}); + +/** + * Nachtrag aus der Durchsicht: das Bestätigungs-Modal klemmte an der + * absoluten Grenze (3), nicht an der Restmenge. Die Karte, die das Modal + * öffnet, kennt nur die Zahl, die einen Downgrade-Blocker aufheben würde — + * nicht, dass der Vertrag schon einen Block hält. Ohne diese Klemmung hätte + * der Dialog "3 Pakete" versprochen, während purchase() danach nur die + * tatsächlich noch buchbaren 2 in den Warenkorb gelegt hätte: Dialogtext und + * wirkliche Bestellmenge liefen auseinander. + */ +it('klemmt das Bestätigungs-Modal an der Restmenge, nicht an der absoluten Grenze', function () { + $subscription = limitContract(); + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); + + Livewire::actingAs(limitUser($subscription)) + ->test(ConfirmBookStorage::class, ['packs' => 3]) + ->assertSet('packs', 2) + ->assertSee(__('billing.storage_confirm_title', ['count' => 2])) + ->call('proceed') + ->assertDispatched('storage-packs-confirmed', packs: 2); +});