$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 { return $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. return SubscriptionAddon::query() ->where('order_id', $order?->id) ->where('addon_key', $addonKey) ->firstOrFail(); } } /** @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 { return 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; }); } }