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') }}
-{{ $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); +});