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, // 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 ?? [], ]; } /** * Every VM template a currently sellable version points at. * * Published versions whose window has not CLOSED — deliberately not * scopeAvailable(): a version scheduled to launch next week is not * "available" yet, and leaving its template out would move the gap to its * first order, which is the one place nobody is watching. A closed window * is different: nobody can buy it any more, so its template is nobody's * problem. * * Lives here because three callers ask exactly this — BuildVmTemplate * builds them, VerifyVmTemplate refuses to finish a host without them, and * VmTemplateCheck answers the same question before a purchase. Three copies * of the window logic would drift, and the one that drifted would be found * by a customer. * * @return array */ public static function requiredTemplateVmids(): array { return static::query() ->whereNotNull('published_at') ->whereNotNull('template_vmid') ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now())) ->pluck('template_vmid') ->map(fn ($vmid) => (int) $vmid) ->unique() ->sort() ->values() ->all(); } }