363 lines
16 KiB
PHP
363 lines
16 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.
|
|
*
|
|
* It is also where a booking becomes MONEY, every month, and not only in the
|
|
* month it was made. A module is an item on the Stripe subscription, so booking
|
|
* one adds it and cancelling one takes it off — see
|
|
* App\Actions\SyncStripeAddonItems, which is asked to bring the two into step
|
|
* after every change here. Like the storage delivery beside it, it never fails a
|
|
* booking: what it could not tell Stripe is parked on the contract and retried.
|
|
*/
|
|
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);
|
|
$this->billThroughStripe($subscription);
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
}
|