diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index 6267d1b..8f09d43 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -151,6 +151,10 @@ class BookAddon 'currency' => Subscription::catalogueCurrency(), 'quantity' => $quantity, 'booked_at' => now(), + // What this pack weighs, at the moment of purchase. Without + // these two figures the size would be a view again. + 'pack_gb' => $addonKey === AddonCatalogue::STORAGE ? $catalogue->packGb() : 0, + 'pack_disk_gb' => $addonKey === AddonCatalogue::STORAGE ? $catalogue->packDiskGb() : 0, ], $overrides)); ($this->record)( diff --git a/app/Models/SubscriptionAddon.php b/app/Models/SubscriptionAddon.php index 34eeeae..f66479b 100644 --- a/app/Models/SubscriptionAddon.php +++ b/app/Models/SubscriptionAddon.php @@ -23,7 +23,7 @@ class SubscriptionAddon extends Model use HasUuids; /** What the customer agreed to. */ - public const FROZEN = ['subscription_id', 'addon_key', 'price_cents', 'currency', 'quantity', 'booked_at']; + public const FROZEN = ['subscription_id', 'addon_key', 'price_cents', 'currency', 'quantity', 'booked_at', 'pack_gb', 'pack_disk_gb']; protected $guarded = []; @@ -46,6 +46,8 @@ class SubscriptionAddon extends Model return [ 'price_cents' => 'integer', 'quantity' => 'integer', + 'pack_gb' => 'integer', + 'pack_disk_gb' => 'integer', 'booked_at' => 'datetime', 'cancelled_at' => 'datetime', // The end this booking has an appointment with. A cancellation is diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php index 687c338..e423c5d 100644 --- a/app/Services/Billing/AddonCatalogue.php +++ b/app/Services/Billing/AddonCatalogue.php @@ -45,6 +45,27 @@ final class AddonCatalogue return $price === null ? null : (int) $price; } + /** + * How big a pack is that someone buys TODAY. + * + * Only for new bookings. What an already-bought pack weighs lives on the + * booking itself — see StorageAllowance. + */ + public function packGb(): int + { + return (int) config('provisioning.storage_addon.gb', 0); + } + + /** + * What a pack takes up on the host: the usable gigabytes plus the + * headroom a stacked pack needs too. Defaults to the usable figure when + * none is configured — that was the state before headroom existed. + */ + public function packDiskGb(): int + { + return (int) config('provisioning.storage_addon.disk_gb', $this->packGb()); + } + public function knows(string $key): bool { return $this->priceCents($key) !== null; diff --git a/app/Services/Billing/StorageAllowance.php b/app/Services/Billing/StorageAllowance.php index a8bb049..dc7d350 100644 --- a/app/Services/Billing/StorageAllowance.php +++ b/app/Services/Billing/StorageAllowance.php @@ -35,12 +35,16 @@ use App\Models\Subscription; */ final readonly class StorageAllowance { - private function __construct( + public function __construct( /** What the package itself includes. */ public int $planGb, - /** How many extra-storage packs are booked and still running. */ + /** How many packs are running — the figure the customer reads. */ public int $packs, - /** How big one pack is today — see packSizeGb() for why "today". */ + /** What these packs give the customer, at the sizes they were booked. */ + public int $bookedGb, + /** What they take up on the host, at the sizes they were booked. */ + public int $bookedDiskGb, + /** How big a pack would be TODAY — for quotes, not for what is already booked. */ public int $packSizeGb, ) {} @@ -75,13 +79,15 @@ final readonly class StorageAllowance */ public static function forPlan(int $planGb, ?Subscription $subscription): self { - return new self(max(0, $planGb), self::bookedPacks($subscription), self::packSizeGb()); - } + $booked = self::bookedPacks($subscription); - /** Everything the booked packs add. */ - public function packGb(): int - { - return $this->packs * $this->packSizeGb; + return new self( + max(0, $planGb), + $booked['packs'], + $booked['gb'], + $booked['disk_gb'], + app(AddonCatalogue::class)->packGb(), + ); } /** The figure Nextcloud is told, the disk is sized for, and the portal shows. */ @@ -114,35 +120,41 @@ final readonly class StorageAllowance } /** - * Every pack still running on this contract, counted by quantity. + * The packs still running on this contract, with their own sizes. * - * Queried rather than read off a loaded relation: this is asked immediately - * after a pack is booked or cancelled, and a relation loaded before that - * would answer with the state the caller has just changed. + * Queried rather than read off a loaded relation, for the same reason as + * before: this is asked immediately after a pack is booked or cancelled, + * and a relation loaded before that would answer with the state before + * the change. + * + * @return array{packs: int, gb: int, disk_gb: int} */ - private static function bookedPacks(?Subscription $subscription): int + private static function bookedPacks(?Subscription $subscription): array { if ($subscription === null) { - return 0; + return ['packs' => 0, 'gb' => 0, 'disk_gb' => 0]; } - return (int) $subscription->addons()->active() + $rows = $subscription->addons()->active() ->where('addon_key', AddonCatalogue::STORAGE) - ->sum('quantity'); + ->get(['quantity', 'pack_gb', 'pack_disk_gb']); + + return [ + 'packs' => (int) $rows->sum('quantity'), + 'gb' => (int) $rows->sum(fn ($row) => (int) $row->quantity * (int) $row->pack_gb), + 'disk_gb' => (int) $rows->sum(fn ($row) => (int) $row->quantity * (int) $row->pack_disk_gb), + ]; } - /** - * What one pack is worth, from the catalogue. - * - * Read live rather than frozen onto the booking, unlike the PRICE. That is - * not an oversight but it is a limit worth naming: `subscription_addons` - * freezes what the customer agreed to PAY, and if the owner ever re-cuts the - * pack from 100 GB to 200 GB, existing bookings would grow with it. Today - * there is one pack size and it has never moved; the day it does, the size - * belongs on the booking beside the price. - */ - private static function packSizeGb(): int + /** Everything the booked packs give the customer. */ + public function packGb(): int { - return max(0, (int) config('provisioning.storage_addon.gb', 0)); + return $this->bookedGb; + } + + /** Everything the booked packs take up on the host. */ + public function packDiskGb(): int + { + return $this->bookedDiskGb; } } diff --git a/database/migrations/2026_08_01_000001_add_pack_size_to_subscription_addons.php b/database/migrations/2026_08_01_000001_add_pack_size_to_subscription_addons.php new file mode 100644 index 0000000..f844ef5 --- /dev/null +++ b/database/migrations/2026_08_01_000001_add_pack_size_to_subscription_addons.php @@ -0,0 +1,44 @@ +unsignedInteger('pack_gb')->default(0)->after('quantity'); + $table->unsignedInteger('pack_disk_gb')->default(0)->after('pack_gb'); + }); + + DB::table('subscription_addons') + ->where('addon_key', 'storage') + ->update(['pack_gb' => 100, 'pack_disk_gb' => 100]); + } + + public function down(): void + { + Schema::table('subscription_addons', function (Blueprint $table) { + $table->dropColumn(['pack_gb', 'pack_disk_gb']); + }); + } +}; diff --git a/tests/Feature/Billing/StoragePackSizeTest.php b/tests/Feature/Billing/StoragePackSizeTest.php new file mode 100644 index 0000000..af35770 --- /dev/null +++ b/tests/Feature/Billing/StoragePackSizeTest.php @@ -0,0 +1,65 @@ +create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']); + + return app(OpenSubscription::class)($order); +} + +it('friert die Packungsgröße auf der Buchung ein', function () { + config(['provisioning.storage_addon.gb' => 100, 'provisioning.storage_addon.disk_gb' => 100]); + + $subscription = packContract(); + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); + + // Der Betreiber schneidet das Paket neu zu. + config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); + + $allowance = StorageAllowance::forPlan(85, $subscription->refresh()); + + expect($allowance->packGb())->toBe(100) + ->and($allowance->packDiskGb())->toBe(100) + ->and($allowance->totalGb())->toBe(185); +}); + +it('bucht neue Blöcke zur heutigen Größe', function () { + config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); + + $subscription = packContract(); + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2); + + $allowance = StorageAllowance::forPlan(30, $subscription->refresh()); + + expect($allowance->packs)->toBe(2) + ->and($allowance->packGb())->toBe(40) + ->and($allowance->packDiskGb())->toBe(44); +}); + +it('lässt die eingefrorene Größe nicht nachträglich ändern', function () { + config(['provisioning.storage_addon.gb' => 100, 'provisioning.storage_addon.disk_gb' => 100]); + + $subscription = packContract(); + $addon = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); + + expect(fn () => $addon->update(['pack_gb' => 20])) + ->toThrow(RuntimeException::class); + + expect(SubscriptionAddon::query()->whereKey($addon->id)->value('pack_gb'))->toBe(100); +});