316 lines
12 KiB
PHP
316 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanVersion;
|
|
use App\Models\Subscription;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* The one place the plan catalogue is read.
|
|
*
|
|
* There is deliberately **no fallback to config**. "Use the DB, and config if
|
|
* the DB has no row" would resurrect a plan the owner had just switched off,
|
|
* and would leave commerce reading one source while provisioning read another —
|
|
* the exact split-brain this whole rebuild exists to close. An empty catalogue
|
|
* is an outage, and it should look like one.
|
|
*
|
|
* Availability is computed here, on every read. Nothing schedules a plan into
|
|
* or out of sale, because a job that fails to run is a plan that silently
|
|
* misbehaves, and "the launch didn't happen because a worker was down" is not
|
|
* something you can tell a customer.
|
|
*/
|
|
final class PlanCatalogue
|
|
{
|
|
/**
|
|
* Every plan on sale right now, keyed by family key, in the array shape the
|
|
* app has always used.
|
|
*
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
public function sellable(?Carbon $at = null): array
|
|
{
|
|
$at ??= now();
|
|
$currency = Subscription::catalogueCurrency();
|
|
|
|
return PlanFamily::query()
|
|
->where('sales_enabled', true)
|
|
->with(['versions' => fn ($q) => $q->available($at)->with('prices')])
|
|
->orderBy('tier')
|
|
->get()
|
|
->mapWithKeys(function (PlanFamily $family) use ($currency) {
|
|
// sole() semantics, kept here so a caller listing the shop hits
|
|
// the same loud failure as a caller resolving one plan.
|
|
if ($family->versions->count() > 1) {
|
|
throw new RuntimeException(
|
|
"Plan '{$family->key}' has {$family->versions->count()} versions on sale at once. ".
|
|
'Overlapping availability windows must be fixed before anything can be sold.'
|
|
);
|
|
}
|
|
|
|
$version = $family->versions->first();
|
|
|
|
if ($version === null) {
|
|
return [];
|
|
}
|
|
|
|
// Every supported term, or the plan is not shown at all. Listing
|
|
// one that is priced monthly but not yearly would let a
|
|
// customer pick it, pay, and land on a contract that cannot be
|
|
// opened — the shop and the checkout must agree on this.
|
|
$priced = $this->requiredPrices($version, $currency);
|
|
|
|
if ($priced === null) {
|
|
return [];
|
|
}
|
|
|
|
$monthly = $priced[Subscription::TERM_MONTHLY];
|
|
|
|
return [$family->key => array_merge($version->capabilities(), [
|
|
'name' => $family->name,
|
|
'price_cents' => $monthly->amount_cents,
|
|
'currency' => $monthly->currency,
|
|
'plan_version_id' => $version->id,
|
|
])];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* The version a purchase of this family right now would be sold under.
|
|
*
|
|
* Fails closed and loudly: an unknown or unsold plan throws, and so does an
|
|
* overlap. Picking one of two overlapping versions would decide a customer's
|
|
* terms by row order.
|
|
*/
|
|
public function currentVersion(string $familyKey, ?Carbon $at = null): PlanVersion
|
|
{
|
|
$family = PlanFamily::query()->where('key', $familyKey)->first();
|
|
|
|
if ($family === null) {
|
|
throw new RuntimeException("Unknown plan: {$familyKey}");
|
|
}
|
|
|
|
if (! $family->sales_enabled) {
|
|
throw new RuntimeException("Plan '{$familyKey}' is not on sale.");
|
|
}
|
|
|
|
try {
|
|
$version = $family->versions()->available($at)->sole();
|
|
} catch (ModelNotFoundException) {
|
|
throw new RuntimeException("Plan '{$familyKey}' has no version on sale.");
|
|
}
|
|
|
|
$version->setRelation('family', $family);
|
|
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Whether a purchase of this family could actually be completed right now.
|
|
*
|
|
* Pricing is part of the question, not a detail left to the checkout. A
|
|
* version whose price row has been deleted is still inside its window, and
|
|
* answering "yes" here would send an already-paid webhook into a contract
|
|
* it cannot open.
|
|
*/
|
|
public function isSellable(string $familyKey, ?Carbon $at = null): bool
|
|
{
|
|
try {
|
|
$version = $this->currentVersion($familyKey, $at);
|
|
} catch (RuntimeException) {
|
|
return false;
|
|
}
|
|
|
|
return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
|
|
}
|
|
|
|
/**
|
|
* Every term we sell on, priced in this currency — or null if any is
|
|
* missing.
|
|
*
|
|
* The single definition of "this version can actually be bought", so the
|
|
* shop, the checkout and the consistency command can never disagree about
|
|
* which plans are real.
|
|
*
|
|
* @return array<string, \App\Models\PlanPrice>|null
|
|
*/
|
|
private function requiredPrices(PlanVersion $version, string $currency): ?array
|
|
{
|
|
$found = [];
|
|
|
|
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
|
|
// relationLoaded() so a listing that eager-loaded prices does not
|
|
// fire a query per plan per term.
|
|
$price = $version->relationLoaded('prices')
|
|
? $version->prices->first(fn ($p) => $p->term === $term && $p->currency === $currency)
|
|
: $version->priceFor($term, $currency);
|
|
|
|
if ($price === null) {
|
|
return null;
|
|
}
|
|
|
|
$found[$term] = $price;
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* The exact version a customer was sold, checked against the plan they
|
|
* bought.
|
|
*
|
|
* Used when the purchase carried its own version — a checkout that started
|
|
* before a scheduled transition and finished after it. A version that has
|
|
* closed is still honoured, because the customer saw and paid for it; one
|
|
* that was never published is not, because nothing was ever promised.
|
|
*/
|
|
public function soldVersion(string $familyKey, int $versionId): PlanVersion
|
|
{
|
|
$version = $this->version($versionId);
|
|
|
|
if ($version->family->key !== $familyKey) {
|
|
throw new RuntimeException(
|
|
"Version {$versionId} belongs to '{$version->family->key}', not to '{$familyKey}'."
|
|
);
|
|
}
|
|
|
|
if (! $version->isPublished()) {
|
|
throw new RuntimeException("Version {$versionId} was never published.");
|
|
}
|
|
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Whether a contract can still be opened on a version a customer was quoted.
|
|
*
|
|
* Deliberately NOT the same question as "is this plan on sale". A checkout
|
|
* that began while the version was available is owed that version, even if
|
|
* the window has closed or the owner has withdrawn the plan since — they
|
|
* paid for what they were shown. Only realness and pricing matter here.
|
|
*/
|
|
public function isDeliverable(string $familyKey, int $versionId): bool
|
|
{
|
|
try {
|
|
$version = $this->soldVersion($familyKey, $versionId);
|
|
} catch (RuntimeException|ModelNotFoundException) {
|
|
return false;
|
|
}
|
|
|
|
return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
|
|
}
|
|
|
|
/**
|
|
* A historical reference, resolved by version id — never by family key.
|
|
*
|
|
* Looking a past contract up by name would hand back today's terms, which
|
|
* is the same mistake the snapshot exists to prevent, one level up.
|
|
*/
|
|
public function version(int $id): PlanVersion
|
|
{
|
|
return PlanVersion::query()->with('family')->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* Move a version's availability window.
|
|
*
|
|
* Under a lock on the family, because two admins scheduling at the same
|
|
* moment would each see a clean check and both commit — leaving two
|
|
* versions on sale and every read of that family throwing.
|
|
*/
|
|
public function schedule(PlanVersion $version, Carbon $from, ?Carbon $until = null): PlanVersion
|
|
{
|
|
if ($until !== null && $until->lessThanOrEqualTo($from)) {
|
|
throw new RuntimeException('A plan cannot stop being sold before it starts.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($version, $from, $until) {
|
|
PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail();
|
|
|
|
$clash = PlanVersion::query()
|
|
->where('plan_family_id', $version->plan_family_id)
|
|
->whereKeyNot($version->getKey())
|
|
// Only published versions can clash: a draft is not on sale, so
|
|
// its provisional window must not block the owner from
|
|
// rescheduling the version that customers can actually buy.
|
|
->whereNotNull('published_at')
|
|
// Half-open overlap: a starts before b ends AND b starts before
|
|
// a ends. A null end is "never ends".
|
|
->where(fn ($q) => $q
|
|
->whereNull('available_until')
|
|
->orWhere('available_until', '>', $from))
|
|
->when($until !== null, fn ($q) => $q->where('available_from', '<', $until))
|
|
->exists();
|
|
|
|
if ($clash) {
|
|
throw new RuntimeException(
|
|
'That window overlaps another version of this plan. Two versions on sale at once '.
|
|
'would leave the price a customer pays decided by row order.'
|
|
);
|
|
}
|
|
|
|
// Written by query, not by save(): a model handed to us may carry
|
|
// unsaved edits, and rescheduling a window must never be the thing
|
|
// that quietly persists a change to what the plan promises.
|
|
PlanVersion::query()->whereKey($version->getKey())->update([
|
|
'available_from' => $from,
|
|
'available_until' => $until,
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return $version->refresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Publish a draft: lock its capabilities and put it on sale.
|
|
*
|
|
* Publication is the promise. From here the version describes what its
|
|
* customers are owed, and changing it would rewrite their contract.
|
|
*/
|
|
public function publish(PlanVersion $version, ?Carbon $from = null, ?Carbon $until = null): PlanVersion
|
|
{
|
|
// Re-read before deciding anything. A previous attempt that was rolled
|
|
// back leaves this object claiming a publication the database never
|
|
// kept, and the owner would then be told a draft they can still see is
|
|
// already published.
|
|
$version->refresh();
|
|
|
|
if ($version->isPublished()) {
|
|
throw new RuntimeException('That version is already published.');
|
|
}
|
|
|
|
$currency = Subscription::catalogueCurrency();
|
|
|
|
// Both terms, or neither. A version priced only yearly passes as
|
|
// sellable and then fails at the checkout of anyone who picks monthly —
|
|
// and once published its capabilities are frozen, so the mistake cannot
|
|
// simply be edited away.
|
|
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
|
|
$priced = $version->prices()->where('term', $term)->where('currency', $currency)->exists();
|
|
|
|
if (! $priced) {
|
|
throw new RuntimeException("A version cannot go on sale without a {$term} price in {$currency}.");
|
|
}
|
|
}
|
|
|
|
// Publication and scheduling together, or not at all. Publishing first
|
|
// and then failing the overlap check would leave the version frozen but
|
|
// unscheduled — and publish() refuses it from then on, so nothing short
|
|
// of a manual repair could rescue it.
|
|
return DB::transaction(function () use ($version, $from, $until) {
|
|
PlanVersion::query()->whereKey($version->getKey())->update([
|
|
'published_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return $this->schedule($version->refresh(), $from ?? now(), $until);
|
|
});
|
|
}
|
|
}
|