CluPilotCloud/app/Models/PlanVersion.php

168 lines
5.5 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* What a plan WAS at a point in time.
*
* A customer buys a version, not a name. Once a version is published it is
* fixed: publication is a promise made in public, and it is made whether or not
* anyone has bought yet — which is why the lock is on publication and not on
* the first sale. Selling different terms means publishing a new version, and
* the old one keeps describing what its customers are owed.
*
* The availability WINDOW is not frozen. Rescheduling when a version is sold
* changes nothing about what it promises, and the owner has to be able to move
* a launch date without rewriting the plan.
*/
class PlanVersion extends Model
{
use HasFactory;
use HasUuids;
/** Everything publication nails down. */
public const FROZEN = [
'plan_family_id', 'version', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
'cores', 'disk_gb', 'performance', 'template_vmid', 'features', 'published_at',
];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $version) {
// A draft is still a draft: nothing has been promised to anyone.
if ($version->getOriginal('published_at') === null) {
return;
}
$frozen = array_intersect(array_keys($version->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'A published plan version is immutable; tried to change: '.implode(', ', $frozen).
'. Publish a new version instead — the customers on this one bought these terms.'
);
}
});
static::deleting(function (self $version) {
// Deleting is the loophole that would undo the rest. Contracts and
// orders point here to record what was sold, and the foreign keys
// null out on delete — so removing a published version quietly
// erases the provenance of every customer on it, and can leave an
// in-flight checkout resolving against whatever replaced it.
if ($version->isPublished()) {
throw new RuntimeException(
'A published plan version cannot be deleted; customers are contracted to it. '.
'Close its availability window instead — it stops being sold and stays on record.'
);
}
});
}
protected function casts(): array
{
return [
'version' => 'integer',
'quota_gb' => 'integer',
'traffic_gb' => 'integer',
'seats' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'disk_gb' => 'integer',
'template_vmid' => 'integer',
'features' => 'array',
'available_from' => 'datetime',
'available_until' => 'datetime',
'published_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function family(): BelongsTo
{
return $this->belongsTo(PlanFamily::class, 'plan_family_id');
}
public function prices(): HasMany
{
return $this->hasMany(PlanPrice::class);
}
/**
* Published, and inside its window at `$at`.
*
* Half-open [from, until): `until` is the first moment the version is no
* longer sold, so a successor starting exactly then leaves neither a gap
* nor an overlap.
*/
public function scopeAvailable(Builder $query, ?Carbon $at = null): Builder
{
$at ??= now();
return $query
->whereNotNull('published_at')
->where('available_from', '<=', $at)
->where(fn (Builder $open) => $open
->whereNull('available_until')
->orWhere('available_until', '>', $at));
}
public function isAvailableAt(?Carbon $at = null): bool
{
$at ??= now();
return $this->published_at !== null
&& $this->available_from->lessThanOrEqualTo($at)
&& ($this->available_until === null || $this->available_until->greaterThan($at));
}
public function isPublished(): bool
{
return $this->published_at !== null;
}
public function priceFor(string $term, ?string $currency = null): ?PlanPrice
{
return $this->prices()
->where('term', $term)
->where('currency', $currency ?? Subscription::catalogueCurrency())
->first();
}
/**
* The capabilities, in the shape the rest of the app already speaks.
*
* @return array<string, mixed>
*/
public function capabilities(): array
{
return [
'tier' => (int) $this->family->tier,
'quota_gb' => $this->quota_gb,
'traffic_gb' => $this->traffic_gb,
'seats' => $this->seats,
'ram_mb' => $this->ram_mb,
'cores' => $this->cores,
'disk_gb' => $this->disk_gb,
'performance' => $this->performance,
'template_vmid' => $this->template_vmid,
'features' => $this->features ?? [],
];
}
}