CluPilotCloud/app/Actions/BookAddon.php

232 lines
10 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.
*/
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);
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;
});
}
/**
* Stop charging for a module, 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.
*/
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);
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);
}
}