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);
}
// 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) {

View File

@ -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) {

View File

@ -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

View File

@ -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
{

View File

@ -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'],

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_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.',

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_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.',

View File

@ -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 () {

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