Die Packungsgroesse steht auf der Buchung, nicht in der Konfiguration

Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es
eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere
es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden,
der bereits Daten darin liegen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/hostnamen-vergabe
nexxo 2026-08-01 12:23:21 +02:00
parent 0ac76fb05b
commit 2ab6a92fd8
6 changed files with 178 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Wie groß ein gekaufter Block ist auf der Buchung, nicht in der Konfiguration.
*
* `subscription_addons` friert seit jeher ein, was der Kunde zu ZAHLEN
* zugesagt hat. Was er dafür BEKOMMT, stand weiter in der Konfiguration und
* wurde bei jeder Anzeige neu gelesen. Solange es eine einzige Packungsgröße
* gab, war das folgenlos; mit dem Zuschnitt von 100 GB auf 20 GB wäre es der
* stille Verlust von 80 GB je gekauftem Block.
*
* Die 100 steht hier als Zahl und nicht als config()-Aufruf. Was verkauft
* wurde, ist Geschichte: eine Migration, die den Nachtrag aus der
* Konfiguration zöge, schriebe je nach Reihenfolge des Deployments 20 hinein
* und wäre damit genau der Fehler, den sie behebt.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscription_addons', function (Blueprint $table) {
// 0 für alles, was kein Speicher ist: ein Support-Tarif hat keine
// Größe, und NULL wäre eine dritte Antwort auf eine Frage mit zwei.
$table->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']);
});
}
};

View File

@ -0,0 +1,65 @@
<?php
use App\Actions\BookAddon;
use App\Actions\OpenSubscription;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\StorageAllowance;
/**
* Wie groß ein gekaufter Block ist.
*
* Die Größe stand in der Konfiguration und wurde bei jeder Anzeige neu gelesen.
* Solange sie sich nie bewegte, fiel das nicht auf; sie bewegt sich jetzt von
* 100 GB auf 20 GB. Ein Block, dessen Größe erst zur Anzeigezeit entsteht, ist
* kein gekauftes Gut, sondern eine Ansicht.
*/
function packContract(string $plan = 'team'): Subscription
{
$order = Order::factory()->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);
});