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