CluPilotCloud/app/Models/Subscription.php

143 lines
4.8 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\BelongsTo;
use RuntimeException;
class Subscription extends Model
{
use HasFactory;
use HasUuids;
public const TERM_MONTHLY = 'monthly';
public const TERM_YEARLY = 'yearly';
/**
* The conditions the customer is owed. Changing any of these after the fact
* would rewrite a contract, so the model refuses — a price rise applies to
* new subscriptions, never to existing ones.
*/
public const FROZEN = [
'plan', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at',
];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $subscription) {
$frozen = array_intersect(array_keys($subscription->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',
'template_vmid' => '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 order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
/** Whether the catalogue still sells this plan at all. */
public static function knowsPlan(string $plan): bool
{
return (array) config("provisioning.plans.{$plan}") !== [];
}
/** The single currency every catalogue price is expressed in. */
public static function catalogueCurrency(): string
{
return strtoupper((string) config('provisioning.currency', 'EUR'));
}
/** 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' => self::catalogueCurrency(),
'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,
// Copied so provisioning never has to ask the catalogue what to
// clone. Not in FROZEN — see the migration for why.
'template_vmid' => isset($catalogue['template_vmid']) ? (int) $catalogue['template_vmid'] : 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;
}
}