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 */ 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 ?? [], ]; } }