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', ]; } 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; } }