CluPilotCloud/app/Models/PlanPrice.php

86 lines
3.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;
/**
* What a version costs for one term.
*
* One row per version AND term, each with its own Stripe Price id — a Stripe
* Price carries its own recurring interval, so monthly and yearly can never
* share one. Amounts are NET; what is actually charged depends on the
* customer's tax treatment.
*
* Frozen with its version. Repricing means publishing a new version, which is
* what Stripe requires anyway — a Stripe Price is immutable, so a rise mints a
* new one and existing subscriptions keep the old.
*
* The reason is not tidiness. A checkout is not instant: the session opens, the
* customer types their card details, the webhook arrives. An amount edited in
* that gap would contract them at a price they were never quoted, while their
* payment carries the old one. Only `stripe_price_id` stays writable, because
* it is filled in after the fact.
*/
class PlanPrice extends Model
{
use HasFactory;
use HasUuids;
/** What publishing the version nails down. */
public const FROZEN = ['plan_version_id', 'term', 'amount_cents', 'currency'];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $price) {
// A draft is still a draft: nothing has been quoted to anyone.
if ($price->version?->isPublished() !== true) {
return;
}
$frozen = array_intersect(array_keys($price->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'The price of a published plan version is fixed; tried to change: '.implode(', ', $frozen).
'. Publish a new version instead — someone may be at the checkout looking at this one.'
);
}
});
static::deleting(function (self $price) {
// Same loophole as editing. Remove the price of a version someone is
// checking out on, and their payment lands on a plan that can no
// longer be priced — the order is recorded, no contract opens, and
// they are left paid-for and unprovisioned.
if ($price->version?->isPublished() === true) {
throw new RuntimeException(
'The price of a published plan version cannot be deleted. '.
'Close the version\'s availability window instead.'
);
}
});
}
protected function casts(): array
{
return ['amount_cents' => 'integer'];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function version(): BelongsTo
{
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
}
}