$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.'); } 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) { // 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; } } $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; }); } }