Hoechstens drei Bloecke, geprueft in der Buchung

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 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 13:33:36 +02:00
parent 98354716d0
commit 1f32a2d5b3
9 changed files with 148 additions and 16 deletions

View File

@ -80,6 +80,16 @@ class BookAddon
throw new RuntimeException($refusal); 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 { try {
$addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides); $addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) { } catch (UniqueConstraintViolationException) {

View File

@ -32,13 +32,10 @@ class Billing extends Component
use ResolvesCustomer; use ResolvesCustomer;
/** /**
* The most storage packs one click may put in the cart. * Notbremse gegen eine unsinnige Zahl aus dem Formular. Die kaufmännische
* * Grenze steht in AddonCatalogue::maxQuantity() und wird von BookAddon
* A ceiling rather than a rule: the blocked-downgrade card offers exactly * gehalten hier wird nur verhindert, dass jemand für etwas bezahlt, das
* the number that would clear the blocker, and that number is computed from * die Buchung danach ablehnt.
* 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.
*/ */
private const MAX_STORAGE_PACKS = 50; private const MAX_STORAGE_PACKS = 50;
@ -176,7 +173,7 @@ class Billing extends Component
// upgrade contradicts the first, and a second entitlement is a // upgrade contradicts the first, and a second entitlement is a
// second charge for one thing. // second charge for one thing.
$lines = $type === 'storage' $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; : 1;
foreach (range(1, $lines) as $ignored) { foreach (range(1, $lines) as $ignored) {

View File

@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Services\Billing\AddonCatalogue;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
/** /**
@ -25,12 +26,14 @@ class ConfirmBookStorage extends ModalComponent
public function mount(int $packs = 1): void public function mount(int $packs = 1): void
{ {
// Clamped here as well as in the action behind it. A modal is opened $catalogue = app(AddonCatalogue::class);
// with arguments from the page, and a page is markup — the figure that $max = $catalogue->maxQuantity(AddonCatalogue::STORAGE) ?? 50;
// arrives is not evidence of anything, and the sentence this dialog
// shows must not be able to say something the action would not do. // Geklemmt wie bisher, nur an der kaufmännischen Grenze statt an einer
$this->packs = max(1, min(50, $packs)); // erfundenen: ein Modal wird mit Argumenten aus der Seite geöffnet, und
$this->packGb = (int) config('provisioning.storage_addon.gb', 0); // eine Seite ist Markup.
$this->packs = max(1, min($max, $packs));
$this->packGb = $catalogue->packGb();
} }
public function proceed(): void public function proceed(): void

View File

@ -122,6 +122,54 @@ final class AddonCatalogue
return __('billing.addon_already_booked', ['module' => $this->name($key)]); 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. */ /** The module's name as the customer reads it, never its key. */
public function name(string $key): string public function name(string $key): string
{ {

View File

@ -324,7 +324,11 @@ return [
| refuses to let one ship undeclared. Read by | refuses to let one ship undeclared. Read by
| App\Services\Billing\AddonCatalogue, enforced by App\Actions\BookAddon. | 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' => [ 'addons' => [
'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'], 'extra_backups' => ['price_cents' => 500, 'sold_as' => 'entitlement'],
'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'], 'priority_support' => ['price_cents' => 2900, 'sold_as' => 'entitlement'],

View File

@ -145,6 +145,7 @@ return [
'addon_already_booked' => '„:module“ ist bereits gebucht — ein zweites Mal würde denselben Leistungsumfang ein zweites Mal berechnen.', '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' => '„:module“ liegt bereits im Warenkorb und wird nach der Zahlung freigeschaltet.',
'addon_in_cart_badge' => 'Im Warenkorb', '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. // 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.', '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.', 'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.',

View File

@ -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_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' => 'The :module module is already in your cart and will be activated after payment.',
'addon_in_cart_badge' => 'In cart', '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. // 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.', '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.', 'purchased' => 'Purchase noted — we activate it after payment.',

View File

@ -348,7 +348,11 @@ it('refuses to put more packs in the cart than the ceiling allows', function ()
->test(Billing::class) ->test(Billing::class)
->call('bookStoragePacks', 9999); ->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 () { it('passes the check on a fresh reading after data was deleted, without waiting for the night', function () {

View File

@ -0,0 +1,64 @@
<?php
use App\Actions\BookAddon;
use App\Actions\OpenSubscription;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
/**
* Höchstens drei Blöcke.
*
* Der Deckel liegt dort, wo Aufsteigen billiger wird: Start + 3 Blöcke sind
* 90 GB für 84 , Team ist 85 GB für 79 mit mehr Nutzern und mehr RAM. Er
* gehört in die Aktion und nicht ins Formular dieselbe Lektion wie bei
* ReissueTakeover (v1.3.82): die Ansicht versteckte den Knopf, die Methode
* prüfte nichts, und Codex fand es zweimal.
*/
function limitContract(string $plan = 'start'): Subscription
{
$order = Order::factory()->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);
});