getDirty()), self::FROZEN); if ($frozen !== []) { throw new RuntimeException( 'A subscription snapshot is immutable; tried to change: '.implode(', ', $frozen). '. Cancel this subscription and start a new one instead.' ); } }); } protected function casts(): array { return [ 'price_cents' => 'integer', 'quota_gb' => 'integer', 'traffic_gb' => 'integer', 'seats' => 'integer', 'ram_mb' => 'integer', 'cores' => 'integer', 'disk_gb' => 'integer', 'tier' => 'integer', 'started_at' => 'datetime', 'current_period_start' => 'datetime', 'current_period_end' => 'datetime', 'pending_effective_at' => 'datetime', 'cancelled_at' => 'datetime', ]; } public function uniqueIds(): array { return ['uuid']; } public function customer(): BelongsTo { return $this->belongsTo(Customer::class); } public function instance(): BelongsTo { return $this->belongsTo(Instance::class); } /** Take today's catalogue entry and freeze it onto a new subscription. */ public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY): array { $catalogue = (array) config("provisioning.plans.{$plan}"); if ($catalogue === []) { throw new RuntimeException("Unknown plan: {$plan}"); } // A typo would otherwise be priced monthly while carrying an unknown // term — a subscription whose price and billing period disagree. if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) { throw new RuntimeException("Unknown term: {$term}"); } $monthly = (int) ($catalogue['price_cents'] ?? 0); return [ 'plan' => $plan, 'term' => $term, // A yearly term is twelve months of the same price. Any discount // belongs in the catalogue, not hidden in this conversion. 'price_cents' => $term === self::TERM_YEARLY ? $monthly * 12 : $monthly, 'currency' => 'EUR', 'quota_gb' => (int) ($catalogue['quota_gb'] ?? 0), 'traffic_gb' => (int) ($catalogue['traffic_gb'] ?? 0), 'seats' => (int) ($catalogue['seats'] ?? 0), 'ram_mb' => (int) ($catalogue['ram_mb'] ?? 0), 'cores' => (int) ($catalogue['cores'] ?? 0), 'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0), 'performance' => $catalogue['performance'] ?? null, // Frozen too: which direction a later change goes must not depend // on where the plan sits in today's catalogue. 'tier' => (int) ($catalogue['tier'] ?? 0), ]; } public function isYearly(): bool { return $this->term === self::TERM_YEARLY; } }