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