177 lines
6.1 KiB
PHP
177 lines
6.1 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',
|
|
// How many of the twelve months a year costs nothing. Frozen with the
|
|
// rest: it is not presentation, it is what the customer bought — and the
|
|
// yearly amount in `plan_prices` was derived from it.
|
|
'free_months',
|
|
];
|
|
|
|
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,
|
|
// Part of the terms, so it travels with them: every reader of the
|
|
// catalogue — the shop, the order page, the console — gets the
|
|
// figure from the version rather than dividing two prices and
|
|
// hoping the answer is whole.
|
|
'free_months' => (int) $this->free_months,
|
|
'ram_mb' => $this->ram_mb,
|
|
'cores' => $this->cores,
|
|
'disk_gb' => $this->disk_gb,
|
|
'performance' => $this->performance,
|
|
'template_vmid' => $this->template_vmid,
|
|
'features' => $this->features ?? [],
|
|
];
|
|
}
|
|
}
|