122 lines
4.0 KiB
PHP
122 lines
4.0 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',
|
|
'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;
|
|
}
|
|
}
|