$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 $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); } }