From 1f32a2d5b3f8a60dcde2abf9ce979c8ef5607d61 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 13:33:36 +0200 Subject: [PATCH] Hoechstens drei Bloecke, geprueft in der Buchung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke. StorageAllowanceTest schrieb die alte Notbremse (50) als erwartete Zahl fest; angepasst auf die jetzt engere kaufmaennische Grenze (3), wie im Aufgabenblatt fuer DowngradeTest vorgezeichnet. Co-Authored-By: Claude Opus 5 --- app/Actions/BookAddon.php | 10 +++ app/Livewire/Billing.php | 13 ++-- app/Livewire/ConfirmBookStorage.php | 15 +++-- app/Services/Billing/AddonCatalogue.php | 48 ++++++++++++++ config/provisioning.php | 6 +- lang/de/billing.php | 1 + lang/en/billing.php | 1 + .../Feature/Billing/StorageAllowanceTest.php | 6 +- .../Feature/Billing/StoragePackLimitTest.php | 64 +++++++++++++++++++ 9 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/Billing/StoragePackLimitTest.php diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index 8f09d43..c25df5c 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -80,6 +80,16 @@ class BookAddon throw new RuntimeException($refusal); } + // Der Deckel, an der Stelle, die ihn halten muss. Gezählt wird über + // ALLE laufenden Buchungen dieses Moduls, nicht je Buchung: drei + // Bestellungen à einem Block sind drei Blöcke, und eine Prüfung je + // Bestellung hätte jede einzeln durchgewinkt. + $overLimit = app(AddonCatalogue::class)->quantityRefusal($subscription, $addonKey, $quantity); + + if ($overLimit !== null) { + throw new RuntimeException($overLimit); + } + try { $addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides); } catch (UniqueConstraintViolationException) { diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 70baaf9..6eb4570 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -32,13 +32,10 @@ class Billing extends Component use ResolvesCustomer; /** - * The most storage packs one click may put in the cart. - * - * A ceiling rather than a rule: the blocked-downgrade card offers exactly - * the number that would clear the blocker, and that number is computed from - * a measurement. This exists because the method is reachable from anywhere - * that can POST to /livewire/update with any argument at all, and a cart - * with nine thousand lines in it is a page nobody can use again. + * Notbremse gegen eine unsinnige Zahl aus dem Formular. Die kaufmännische + * Grenze steht in AddonCatalogue::maxQuantity() und wird von BookAddon + * gehalten — hier wird nur verhindert, dass jemand für etwas bezahlt, das + * die Buchung danach ablehnt. */ private const MAX_STORAGE_PACKS = 50; @@ -176,7 +173,7 @@ class Billing extends Component // upgrade contradicts the first, and a second entitlement is a // second charge for one thing. $lines = $type === 'storage' - ? max(1, min(self::MAX_STORAGE_PACKS, $quantity)) + ? max(1, min(self::MAX_STORAGE_PACKS, app(AddonCatalogue::class)->bookableQuantity($contract, AddonCatalogue::STORAGE), $quantity)) : 1; foreach (range(1, $lines) as $ignored) { diff --git a/app/Livewire/ConfirmBookStorage.php b/app/Livewire/ConfirmBookStorage.php index 206413b..5fb21bb 100644 --- a/app/Livewire/ConfirmBookStorage.php +++ b/app/Livewire/ConfirmBookStorage.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Services\Billing\AddonCatalogue; use LivewireUI\Modal\ModalComponent; /** @@ -25,12 +26,14 @@ class ConfirmBookStorage extends ModalComponent public function mount(int $packs = 1): void { - // Clamped here as well as in the action behind it. A modal is opened - // with arguments from the page, and a page is markup — the figure that - // arrives is not evidence of anything, and the sentence this dialog - // shows must not be able to say something the action would not do. - $this->packs = max(1, min(50, $packs)); - $this->packGb = (int) config('provisioning.storage_addon.gb', 0); + $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. + $this->packs = max(1, min($max, $packs)); + $this->packGb = $catalogue->packGb(); } public function proceed(): void diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php index e423c5d..f9b3b37 100644 --- a/app/Services/Billing/AddonCatalogue.php +++ b/app/Services/Billing/AddonCatalogue.php @@ -122,6 +122,54 @@ final class AddonCatalogue return __('billing.addon_already_booked', ['module' => $this->name($key)]); } + /** Wie viele Einheiten ein Vertrag von diesem Modul halten darf, oder null. */ + public function maxQuantity(string $key): ?int + { + $max = $this->definition($key)['max'] ?? null; + + return $max === null ? null : max(1, (int) $max); + } + + /** Wie viele davon dieser Vertrag jetzt noch buchen könnte. */ + public function bookableQuantity(?Subscription $subscription, string $key): int + { + $max = $this->maxQuantity($key); + + if ($max === null) { + return PHP_INT_MAX; + } + + return max(0, $max - $this->bookedQuantity($subscription, $key)); + } + + /** + * Warum diese Menge nicht gebucht werden darf. Null, wenn sie darf. + * + * In dem Satz, den der Kunde liest — die Aktion weist damit ab, und die + * Ansicht fragt damit, bevor sie einen Knopf anbietet. Zwei Formulierungen + * derselben Grenze wären zwei Grenzen. + */ + public function quantityRefusal(?Subscription $subscription, string $key, int $quantity): ?string + { + $max = $this->maxQuantity($key); + + if ($max === null || $this->bookedQuantity($subscription, $key) + $quantity <= $max) { + return null; + } + + return __('billing.addon_limit_reached', ['module' => $this->name($key), 'max' => $max]); + } + + /** Wie viele Einheiten dieses Moduls auf dem Vertrag laufen. */ + private function bookedQuantity(?Subscription $subscription, string $key): int + { + if ($subscription === null) { + return 0; + } + + return (int) $subscription->addons()->active()->where('addon_key', $key)->sum('quantity'); + } + /** The module's name as the customer reads it, never its key. */ public function name(string $key): string { diff --git a/config/provisioning.php b/config/provisioning.php index 11b4c19..6fc45bc 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -324,7 +324,11 @@ return [ | refuses to let one ship undeclared. Read by | App\Services\Billing\AddonCatalogue, enforced by App\Actions\BookAddon. */ - 'storage_addon' => ['gb' => 100, 'price_cents' => 1000, 'sold_as' => 'quantity'], + // Der Deckel ist kaufmännisch, nicht technisch: er liegt dort, wo Aufsteigen + // billiger wird als Stapeln. Ohne ihn belegt der günstigste Tarif den + // knappsten Rohstoff. Gelesen von AddonCatalogue::maxQuantity(), geprüft in + // BookAddon — im Formular steht er nur zusätzlich. + 'storage_addon' => ['gb' => 100, 'price_cents' => 1000, 'sold_as' => 'quantity', 'max' => 3], 'addons' => [ 'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'], 'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'], diff --git a/lang/de/billing.php b/lang/de/billing.php index 7ec2b71..5dbd5e0 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -145,6 +145,7 @@ return [ 'addon_already_booked' => '„:module“ ist bereits gebucht — ein zweites Mal würde denselben Leistungsumfang ein zweites Mal berechnen.', 'addon_in_cart' => '„:module“ liegt bereits im Warenkorb und wird nach der Zahlung freigeschaltet.', 'addon_in_cart_badge' => 'Im Warenkorb', + 'addon_limit_reached' => ':module ist auf :max Einheiten begrenzt. Ein größeres Paket bietet mehr Speicher für weniger Geld.', // Der gebuchte Wechsel, auf der Karte, die sagt, was der Kunde hat. 'pending_change_note' => 'Wechsel auf :plan zum :date vorgemerkt. Bis dahin ändert sich nichts an Ihrem Paket.', 'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.', diff --git a/lang/en/billing.php b/lang/en/billing.php index 654acaa..c3f3c28 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -145,6 +145,7 @@ return [ 'addon_already_booked' => 'The :module module is already booked — booking it again would charge you a second time for the same thing.', 'addon_in_cart' => 'The :module module is already in your cart and will be activated after payment.', 'addon_in_cart_badge' => 'In cart', + 'addon_limit_reached' => ':module is limited to :max units. A larger package gives you more storage for less money.', // The booked change, on the card that says what the customer has. 'pending_change_note' => 'A move to :plan is booked for :date. Nothing about your package changes until then.', 'purchased' => 'Purchase noted — we activate it after payment.', diff --git a/tests/Feature/Billing/StorageAllowanceTest.php b/tests/Feature/Billing/StorageAllowanceTest.php index 82e3ec7..821b681 100644 --- a/tests/Feature/Billing/StorageAllowanceTest.php +++ b/tests/Feature/Billing/StorageAllowanceTest.php @@ -348,7 +348,11 @@ it('refuses to put more packs in the cart than the ceiling allows', function () ->test(Billing::class) ->call('bookStoragePacks', 9999); - expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(50); + // Die kaufmännische Grenze (drei Blöcke, Task 2) ist inzwischen die + // engere Klemme — vor ihr war MAX_STORAGE_PACKS die einzige, und dieser + // Test schrieb deren Zahl fest. Der Deckel ändert sich nicht dafür, dass + // dieser Test vor ihm entstand. + expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(3); }); it('passes the check on a fresh reading after data was deleted, without waiting for the night', function () { diff --git a/tests/Feature/Billing/StoragePackLimitTest.php b/tests/Feature/Billing/StoragePackLimitTest.php new file mode 100644 index 0000000..34b4c58 --- /dev/null +++ b/tests/Feature/Billing/StoragePackLimitTest.php @@ -0,0 +1,64 @@ +create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']); + + return app(OpenSubscription::class)($order); +} + +it('lehnt den vierten Block auf einmal ab', function () { + $subscription = limitContract(); + + expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 4)) + ->toThrow(RuntimeException::class); + + expect($subscription->addons()->count())->toBe(0); +}); + +it('zählt drei einzelne Buchungen als drei', function () { + $subscription = limitContract(); + + foreach (range(1, 3) as $ignored) { + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, Order::factory()->create([ + 'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid', + ])); + } + + expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, Order::factory()->create([ + 'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid', + ])))->toThrow(RuntimeException::class); + + expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(3); +}); + +it('zählt eine gekündigte Buchung nicht mit', function () { + $subscription = limitContract(); + $addon = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 3); + + app(BookAddon::class)->cancel($addon); + + expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(3); +}); + +it('sagt, wie viele noch gehen', function () { + $subscription = limitContract(); + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2); + + expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(1); +});