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
parent
0ac76fb05b
commit
2ab6a92fd8
|
|
@ -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)(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue