CluPilotCloud/app/Models/PlanFamily.php

96 lines
3.1 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* A plan line — the thing that has a name.
*
* "Team" stays Team through every price change and every resize. Orders,
* instances and contracts have referred to plans by `key` since day one, so the
* key is the one part of a family that must never move.
*/
class PlanFamily extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $family) {
// Orders, instances and every subscription snapshot store this
// string. Renaming it does not rename those — it orphans them, and
// a checkout in flight for this family stops matching its own
// version. Display names are what the `name` column is for.
if ($family->isDirty('key')) {
throw new RuntimeException(
'A plan family key is permanent; existing orders and contracts refer to it by name. '.
'Change the display name instead.'
);
}
});
static::deleting(function (self $family) {
if ($family->versions()->whereNotNull('published_at')->exists()) {
throw new RuntimeException(
'A plan family with published versions cannot be deleted; customers are contracted to them. '.
'Switch sales off instead — it disappears from the shop and stays on record.'
);
}
// Only drafts left. The foreign key restricts, so they have to go
// explicitly — and nothing was ever promised on a draft.
$family->versions->each->delete();
});
}
protected function casts(): array
{
return [
'tier' => 'integer',
'sales_enabled' => 'boolean',
'is_recommended' => 'boolean',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function versions(): HasMany
{
return $this->hasMany(PlanVersion::class);
}
/**
* The version on sale at a given moment, or null.
*
* Resolved with sole(): two overlapping windows are a mistake we want to
* hear about, not one we want silently resolved by whichever row the
* database happened to return first.
*
* @throws \Illuminate\Database\MultipleRecordsFoundException on overlap
*/
public function versionAt(?Carbon $at = null): ?PlanVersion
{
$query = $this->versions()->available($at);
return $query->count() === 0 ? null : $query->sole();
}
/** On sale right now: not killed by the switch, and a version is running. */
public function isSellableAt(?Carbon $at = null): bool
{
return $this->sales_enabled && $this->versionAt($at) !== null;
}
}