CluPilotCloud/app/Actions/BookAddon.php

363 lines
16 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue — that is a sale still to be
* made, at whatever it costs now.
*
* It is also the one place that decides a module may not be booked at all. Two
* rules live here rather than in the pages that call it, because a rule enforced
* in markup is not enforced: not every package may have an own domain, and not
* every module may be held twice. Both fail closed, with the sentence the
* customer would be shown rather than a developer's, so whatever refuses —
* the portal, the console's grant screen, a webhook — says the same thing.
*
* It is also where a booking stops being only a row. Storage packs are the one
* module that changes what the machine has to be, and they were sold for months
* without anything anywhere acting on them — so booking or cancelling one asks
* ApplyStorageAllowance for the run that makes the disk, the guest's filesystem
* and Nextcloud's quota match what the customer now pays for. Every route into a
* booking goes through here — the shop, a webhook, an operator's grant — which
* is why the delivery hangs off this action and not off any one of them.
*
* `$overrides` exists for GrantAddon: a granted module is booked through this
* same action, with its price replaced by what the customer actually pays and
* its provenance stamped on the row. Empty for every ordinary booking. A grant
* is a booking like any other, so it is refused on the same terms — giving away
* a module the customer is already paying for would bill them for both.
*
* It is also where a booking becomes MONEY, every month, and not only in the
* month it was made. A module is an item on the Stripe subscription, so booking
* one adds it and cancelling one takes it off — see
* App\Actions\SyncStripeAddonItems, which is asked to bring the two into step
* after every change here. Like the storage delivery beside it, it never fails a
* booking: what it could not tell Stripe is parked on the contract and retried.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
/** @param array<string, mixed> $overrides */
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null, array $overrides = []): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
// Not every module is for sale to everyone. The own domain is possible
// on some packages, included in others and impossible on the smallest,
// and the portal already leaves it out where it cannot be booked — but
// a hidden button is markup, and a rule enforced only in markup is not
// enforced. A stale tab, a second window or anything that can name this
// action arrives here too, so it fails closed, with the sentence the
// customer would be shown rather than a developer's.
$refusal = $addonKey === CustomDomainAccess::ADDON
? app(CustomDomainAccess::class)->bookingRefusal($subscription)
: null;
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
try {
$addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
$addon = SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
$this->deliverStorage($subscription, $addonKey);
$this->billThroughStripe($subscription);
return $addon;
}
/** @param array<string, mixed> $overrides */
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price, array $overrides = []): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price, $overrides) {
$catalogue = app(AddonCatalogue::class);
// Held while we look and write, for the same reason the shop holds
// the customer row: two clicks in flight would otherwise both find
// nothing booked and both book, which is precisely the double
// charge the check below exists to prevent. Only for a module of
// which there may be one — a second storage pack is meant to
// succeed, and serialising those would buy nothing.
if ($catalogue->isEntitlement($addonKey)) {
Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
}
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
// Asked AFTER the retry above, so a webhook delivered twice still
// gets its one booking back rather than an error: that is the same
// order arriving again, not a second purchase. What is refused here
// is a SECOND order for a module the contract already carries — a
// stale tab, a double click, an operator granting something the
// customer has bought. The unique index on (order_id, addon_key)
// never saw those: two orders are two different rows.
$refusal = $catalogue->duplicateRefusal($subscription, $addonKey);
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
$addon = SubscriptionAddon::create(array_merge([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
], $overrides));
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $addon->price_cents]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Book the end of a module for the end of the term it is paid up to.
*
* The owner's rule for a module is the same as for a package: what has been
* paid for is kept. A customer cancelling on the tenth keeps the module
* until the cycle turns and gets no credit for the days they did not use —
* so the row stays active, `cancels_at` holds the appointment, and
* clupilot:end-cancelled-addons keeps it.
*
* Stripe is told at once all the same. Its item is only ever about what the
* NEXT cycle bills — the term in progress was paid in advance — and taking
* the item off with no proration moves no money while making sure the next
* invoice has no such line. Waiting for the boundary instead would be a race
* against Stripe raising that very invoice; see SyncStripeAddonItems.
*
* Ends the module outright when there is no term to wait for — a contract
* with no period end, or one that is already past. There is nothing to keep.
*/
public function cancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
{
$subscription = $addon->subscription;
$endsOn = $subscription?->current_period_end;
if ($endsOn === null || $endsOn->isPast()) {
return $this->cancel($addon);
}
// Claimed conditionally, like the cancellation below: two requests
// arriving together would otherwise both write an appointment, and the
// second would move a date the customer had already been told.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->whereNull('cancels_at')
->update(['cancels_at' => $endsOn, 'updated_at' => now()]);
$addon->refresh();
if ($claimed === 0) {
return $addon;
}
$this->billThroughStripe($subscription);
return $addon;
}
/**
* Take back a cancellation that has not landed yet.
*
* The customer changed their mind again, which is allowed right up to the
* moment the appointment is kept: until then the module is still running and
* still theirs, and nothing has been undone that would have to be rebuilt.
* After it — `cancelled_at` set — this refuses, because the module has
* genuinely stopped and putting it back is a new booking at today's price.
*
* Claimed conditionally like the cancellation it undoes, so two clicks write
* one answer between them.
*
* **Nothing is charged for it.** The term this module was cancelled for was
* paid for in advance and the cancellation raised no credit — that is the
* owner's rule — so putting the item back must not raise a charge either.
* Left to its own devices the reconciler would add it with
* `always_invoice`, which is right for a module being bought in the middle
* of a term and wrong here: the customer would be billed a second time for
* days they have already paid for. So the reconciler is told, in the one
* place where the difference is known, that this addition is a restoration
* and not a sale.
*/
public function undoCancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
{
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->whereNotNull('cancels_at')
->update(['cancels_at' => null, 'updated_at' => now()]);
$addon->refresh();
if ($claimed === 0) {
return $addon;
}
$subscription = $addon->subscription;
if ($subscription !== null) {
app(SyncStripeAddonItems::class)($subscription->refresh(), chargeForAdditions: false);
}
return $addon;
}
/**
* Stop charging for a module now, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*
* This is the immediate end, and it is not what a customer's own
* cancellation does — see cancelAtPeriodEnd() above. It is what happens when
* a module stops being POSSIBLE rather than wanted: a downgrade onto a
* package that cannot have an own domain takes the module with it, and
* leaving the customer billed for a feature that has already gone is a
* complaint we would deserve. It is also the appointment being kept, once
* cancelAtPeriodEnd()'s date has passed.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
$cancelled = DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
$this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key);
$this->billThroughStripe($cancelled->subscription);
return $cancelled;
}
/**
* Make a change to the storage packs real on the machine.
*
* Storage is the one module whose booking changes what the machine has to
* BE: a pack is a hundred gigabytes the disk, the guest's filesystem and
* Nextcloud's quota all have to grow to, and cancelling one is the same
* sentence read backwards. Every other module is a licence, a support tier
* or a flag — nothing on the machine moves when one is booked.
*
* Deliberately AFTER the transaction has committed, never inside it. The run
* this starts is picked up by a queue worker in another process, and a
* worker that arrived first would read a contract whose booking had not been
* written yet — and would then deliver the allowance as it was a moment ago.
*
* Nothing here fails a booking. ApplyStorageAllowance returns null for every
* ordinary "not now" — no machine, one still being built, a run already in
* flight — and that run applies the same allowance when it gets there.
*/
private function deliverStorage(?Subscription $subscription, string $addonKey): void
{
if ($addonKey !== AddonCatalogue::STORAGE) {
return;
}
app(ApplyStorageAllowance::class)($subscription);
}
/**
* Put what the contract now holds in front of Stripe.
*
* After the transaction for the same reason the storage delivery is: the
* reconciler reads the bookings back out of the database, and one running
* inside the transaction that wrote them would settle on the state as it was
* a moment ago.
*
* Nothing here fails a booking either. The module has been booked and, if it
* was storage, already delivered to the machine; Stripe being unreachable
* parks the difference on the contract and clupilot:sync-stripe-subscriptions
* finishes it.
*/
private function billThroughStripe(?Subscription $subscription): void
{
if ($subscription === null) {
return;
}
app(SyncStripeAddonItems::class)($subscription->refresh());
}
}