345 lines
16 KiB
PHP
345 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Actions;
|
|
|
|
use App\Models\Instance;
|
|
use App\Models\Order;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionRecord;
|
|
use App\Provisioning\Jobs\AdvanceRunJob;
|
|
use App\Services\Billing\CustomDomainAccess;
|
|
use App\Services\Billing\PlanChange;
|
|
use App\Services\Billing\TaxTreatment;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The single place a plan change actually happens.
|
|
*
|
|
* PlanChange is the preview: what a move would cost and whether it is allowed
|
|
* yet. Nothing carried one out. A customer could buy an upgrade, see it sit in
|
|
* the cart as a paid order, and keep the same contract, the same machine and the
|
|
* same quota forever — the order was created and never consumed by anything.
|
|
*
|
|
* This is the consumer. It does the four things a plan change is, in one place
|
|
* so they cannot be done by halves:
|
|
*
|
|
* - the CONTRACT moves onto the target package's current sellable version, so
|
|
* the snapshot every step, bill and entitlement check reads says what the
|
|
* customer is now on;
|
|
* - the REGISTER gets one row, because a change of terms is a commercial event
|
|
* and the register is the evidence of what was agreed;
|
|
* - the ENTITLEMENTS that hang off the package are settled — today that is the
|
|
* own domain, which a smaller package can take away;
|
|
* - the MACHINE is resized, through a provisioning run like every other piece
|
|
* of remote work in this application, so it is retried, logged and visible in
|
|
* the console instead of happening in a web request;
|
|
* - STRIPE is moved onto the new price, because it owns the billing cycle and
|
|
* would otherwise keep charging for the package the customer has left. Last,
|
|
* outside the transaction, and unable to fail the change — see below.
|
|
*
|
|
* Idempotent from both ends. A contract already on the target version is a
|
|
* no-op rather than an error — the order is consumed and nothing else moves —
|
|
* and the register row carries the order's own event key, so a duplicate write
|
|
* collides in the database rather than in a check that two callers could both
|
|
* pass.
|
|
*/
|
|
class ApplyPlanChange
|
|
{
|
|
public function __construct(private RecordCommercialEvent $record) {}
|
|
|
|
/**
|
|
* Carry out the change an order bought.
|
|
*
|
|
* The contract is resolved through CustomDomainAccess::contractOf() rather
|
|
* than re-queried here: it is already the one place that answers "which
|
|
* contract is this customer on", and a second copy of that query is the one
|
|
* that would disagree the day the rule changes.
|
|
*/
|
|
public function forOrder(Order $order, ?Carbon $at = null): ?ProvisioningRun
|
|
{
|
|
$subscription = app(CustomDomainAccess::class)->contractOf($order->customer);
|
|
|
|
if ($subscription === null) {
|
|
Log::warning('No contract to move: this plan change has no live subscription behind it.', [
|
|
'order_id' => $order->id, 'plan' => $order->plan,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $this($subscription, (string) $order->plan, $order, $at);
|
|
}
|
|
|
|
/**
|
|
* @return ProvisioningRun|null the resize run, or null when the change was
|
|
* refused, was a no-op, or there is no machine
|
|
* to resize
|
|
*/
|
|
public function __invoke(Subscription $subscription, string $targetPlan, ?Order $order = null, ?Carbon $at = null): ?ProvisioningRun
|
|
{
|
|
$at = $at?->copy() ?? now();
|
|
|
|
try {
|
|
// The version on sale RIGHT NOW, which is what someone moving today
|
|
// is being sold. Throws for a plan that has been withdrawn or is not
|
|
// priced on this term, and that is the correct outcome: a contract
|
|
// moved onto a guess is worse than a change that visibly fails.
|
|
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
|
|
} catch (Throwable $e) {
|
|
Log::error('Cannot move a contract onto a package the catalogue will not sell.', [
|
|
'subscription' => $subscription->uuid, 'plan' => $targetPlan, 'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
// Already there. Not an error — a retried trigger, a command run twice,
|
|
// an operator pressing the same thing — but the order is consumed so it
|
|
// stops asking.
|
|
if ((string) $subscription->plan === $targetPlan
|
|
&& (int) $subscription->plan_version_id === (int) $target['plan_version_id']) {
|
|
$this->consume($order);
|
|
|
|
return null;
|
|
}
|
|
|
|
// The gate, and the only one. An upgrade with the period already over,
|
|
// a downgrade before the term the customer paid for has run out, and any
|
|
// move on a contract that is no longer active all stop here — see
|
|
// PlanChange::evaluate() for why each of them is refused.
|
|
$change = PlanChange::evaluate($subscription, $targetPlan, $at);
|
|
|
|
if (! $change->allowedNow) {
|
|
return null;
|
|
}
|
|
|
|
$instance = $this->instanceOf($subscription);
|
|
|
|
// Read BEFORE the snapshot moves: the resize step has to know what the
|
|
// machine had, to tell a change of CPU or RAM from a change that only
|
|
// touched storage or price.
|
|
$before = [
|
|
'plan' => (string) $subscription->plan,
|
|
'cores' => (int) ($instance?->cores ?? $subscription->cores),
|
|
'ram_mb' => (int) ($instance?->ram_mb ?? $subscription->ram_mb),
|
|
];
|
|
|
|
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
|
|
$subscription->applyPlanSnapshot($target);
|
|
|
|
// A booked change is spent the moment the contract moves — whether it
|
|
// was this one arriving on its date, or a move in the other direction
|
|
// overtaking it. This is where the shop used to do it: an upgrade CLICK
|
|
// cleared a booked downgrade, in the cart, before a cent had been paid,
|
|
// and since nothing marks a cart order paid the customer lost the
|
|
// booking and got nothing in exchange. The contradiction is real —
|
|
// a downgrade left booked would come due months later and quietly undo
|
|
// the bigger package — but it is only real once the bigger package is
|
|
// actually theirs, which is here.
|
|
//
|
|
// Cleared AFTER PlanChange::evaluate() above, which reads the booked
|
|
// due date to decide whether a downgrade is allowed yet.
|
|
$subscription->clearPendingPlanChange();
|
|
|
|
// The register row is written from the contract that has just been
|
|
// moved, so the evidence and the contract cannot describe different
|
|
// packages — the same order OpenSubscription writes a purchase in.
|
|
($this->record)(
|
|
event: $change->isUpgrade
|
|
? SubscriptionRecord::EVENT_UPGRADE
|
|
: SubscriptionRecord::EVENT_DOWNGRADE,
|
|
subscription: $subscription,
|
|
// Zero, because a plan change moves TERMS and no money at all.
|
|
// Stripe settles the difference on a proration invoice of its
|
|
// own (PRORATE_IMMEDIATELY below), and that invoice arrives here
|
|
// as its own EVENT_INVOICE_PAID row carrying the real figure. An
|
|
// amount on this row as well counted one upgrade's money twice in
|
|
// any sum over the register.
|
|
//
|
|
// What the move is WORTH is not lost — it goes into the snapshot
|
|
// below, which is where the question "what were they quoted?"
|
|
// belongs. Deliberately NOT the order's amount_cents either:
|
|
// that is the shop's headline price for the whole package, and
|
|
// recording it for a move made on the sixteenth would state a sum
|
|
// nobody agreed to.
|
|
netCents: 0,
|
|
at: $at,
|
|
extra: ['plan_change' => [
|
|
'from_plan' => $before['plan'],
|
|
'to_plan' => $targetPlan,
|
|
'remaining_days' => $change->remainingDays,
|
|
'term_days' => $change->termDays,
|
|
// The preview's pro-rata difference, and what the till would
|
|
// take for it. Both, because PlanChange prorates the NET
|
|
// catalogue figures while the Stripe Prices it prorates
|
|
// against are gross — so the net figure alone reads as a fifth
|
|
// less than the proration invoice that follows, and somebody
|
|
// reconciling the two needs the number to compare.
|
|
'prorated_net_cents' => $change->chargeCents,
|
|
'prorated_charge_cents' => TaxTreatment::chargedCents($change->chargeCents),
|
|
]],
|
|
// Nothing has been charged AT THIS EVENT. Left null rather than
|
|
// reported as zero, which the register would read as a free sale.
|
|
chargedGrossCents: null,
|
|
order: $order,
|
|
// One row per order, enforced by the unique index rather than by
|
|
// a check two concurrent triggers could both pass.
|
|
eventKey: $order !== null ? 'plan-change:order:'.$order->id : null,
|
|
);
|
|
|
|
$this->resizeInstance($instance, $target, $targetPlan);
|
|
$this->consume($order);
|
|
|
|
return $this->startResize($instance, $targetPlan, $before, $change->isUpgrade, $order);
|
|
});
|
|
|
|
// After the run exists, never before. settleCustomDomain() withdraws a
|
|
// domain the new package no longer carries, and withdrawing one asks
|
|
// ReapplyInstanceAddress for a run to stop serving it — which finds this
|
|
// run already in flight against the same order and stands aside, because
|
|
// this pipeline carries the address steps itself. The other way round
|
|
// there would be two runs writing one router.
|
|
PlanChange::settleCustomDomain($subscription);
|
|
|
|
// Stripe bills the next term, so it has to be told which package that
|
|
// is — without this a customer who upgraded goes on paying for the one
|
|
// they left, every month, for as long as the contract runs.
|
|
//
|
|
// After the transaction and never inside it. The change has already
|
|
// landed on the contract and on the machine, and an unreachable or
|
|
// unconfigured Stripe must not roll any of that back: the action never
|
|
// throws, and parks what it could not do on the contract for the hourly
|
|
// sweep to pick up. A contract with no Stripe subscription behind it —
|
|
// a granted package — is left entirely alone.
|
|
//
|
|
// Resolved from the container here rather than injected, so a test that
|
|
// swaps the client in after this action exists still gets its fake.
|
|
app(MoveStripeSubscriptionPrice::class)(
|
|
$subscription,
|
|
$change->isUpgrade ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
|
|
);
|
|
|
|
if ($run !== null) {
|
|
AdvanceRunJob::dispatch($run->uuid); // after commit
|
|
|
|
Log::info('Applying a plan change.', [
|
|
'subscription' => $subscription->uuid,
|
|
'from' => $before['plan'],
|
|
'to' => $targetPlan,
|
|
'upgrade' => $change->isUpgrade,
|
|
'run' => $run->uuid,
|
|
]);
|
|
}
|
|
|
|
return $run;
|
|
}
|
|
|
|
/**
|
|
* The order is spent. Anything but `pending` takes it out of the customer's
|
|
* cart, and `applied` says which way it went — it was never paid in the sense
|
|
* an invoice means, and calling it that would put a charge in the record that
|
|
* never happened.
|
|
*/
|
|
private function consume(?Order $order): void
|
|
{
|
|
if ($order !== null && $order->status !== 'applied') {
|
|
$order->update(['status' => 'applied']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bring the machine's own row in line with the package it now serves.
|
|
*
|
|
* Everything except the disk. A disk cannot be shrunk — Proxmox grows one
|
|
* online and has no way back — so a downgrade leaves the guest exactly as
|
|
* large as it already is, and writing the smaller figure here would make this
|
|
* row claim a size the machine does not have. Two things read it and both
|
|
* would be wrong: the host's storage accounting, which would think it had
|
|
* space it does not, and the next resize, which would ask Proxmox to shrink.
|
|
*
|
|
* What actually shrinks is the QUOTA, which Nextcloud enforces and which is
|
|
* what was sold — see the plan-change pipeline's quota step. The disk is
|
|
* infrastructure; the quota is the product. The contract's own `disk_gb` still
|
|
* records what the smaller package includes, so nothing has been lost: this
|
|
* column is what the machine HAS, and that is all it has ever meant.
|
|
*
|
|
* @param array<string, mixed> $target
|
|
*/
|
|
private function resizeInstance(?Instance $instance, array $target, string $targetPlan): void
|
|
{
|
|
$instance?->update([
|
|
'plan' => $targetPlan,
|
|
'quota_gb' => $target['quota_gb'],
|
|
'ram_mb' => $target['ram_mb'],
|
|
'cores' => $target['cores'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Start the run that makes the machine match the package.
|
|
*
|
|
* Subject is the instance's own purchase order, exactly as the `address`
|
|
* pipeline does it — not the upgrade order. That is what CustomerStep::order()
|
|
* and ::subscription() resolve from, so the steps read this customer's
|
|
* contract rather than nothing; it is what ReapplyInstanceAddress checks for
|
|
* a run already in flight, so two runs cannot write one router; and it keeps
|
|
* RunRunner from marking a paid, running customer's order failed because a
|
|
* resize could not be applied (see ProvisioningSubject::provisioningPipeline).
|
|
*
|
|
* @param array{plan: string, cores: int, ram_mb: int} $before
|
|
*/
|
|
private function startResize(?Instance $instance, string $targetPlan, array $before, bool $isUpgrade, ?Order $order): ?ProvisioningRun
|
|
{
|
|
// A contract with no machine — one still being built, one whose build
|
|
// failed, a package granted before provisioning ran — has nothing to
|
|
// resize. The contract has still moved, and the machine will be built
|
|
// from it whenever it is built.
|
|
if ($instance === null || ! $instance->hasLiveMachine()) {
|
|
return null;
|
|
}
|
|
|
|
return ProvisioningRun::create([
|
|
'subject_type' => Order::class,
|
|
'subject_id' => $instance->order_id,
|
|
'pipeline' => 'plan-change',
|
|
'status' => ProvisioningRun::STATUS_PENDING,
|
|
'current_step' => 0,
|
|
'context' => [
|
|
'instance_id' => $instance->id,
|
|
'host_id' => $instance->host_id,
|
|
'node' => $instance->host?->node,
|
|
'vmid' => $instance->vmid,
|
|
'subdomain' => $instance->subdomain,
|
|
// What the machine had before, so the resize step can tell which
|
|
// of its changes need the guest to come back round before they
|
|
// are true. Kept on the run rather than recomputed: by the time
|
|
// the step executes, the instance row already says the new size.
|
|
'plan_change' => [
|
|
'order_id' => $order?->id,
|
|
'from_plan' => $before['plan'],
|
|
'to_plan' => $targetPlan,
|
|
'from_cores' => $before['cores'],
|
|
'from_ram_mb' => $before['ram_mb'],
|
|
'upgrade' => $isUpgrade,
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The machine this contract pays for: the link when provisioning has written
|
|
* it, and otherwise the customer's newest instance — the same fallback
|
|
* CustomDomainAccess makes, for the same reason.
|
|
*/
|
|
private function instanceOf(Subscription $subscription): ?Instance
|
|
{
|
|
return $subscription->instance
|
|
?? $subscription->customer?->instances()->latest('id')->first();
|
|
}
|
|
}
|