86 lines
3.0 KiB
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');
|
|
}
|
|
}
|