133 lines
5.1 KiB
PHP
133 lines
5.1 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 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.
|
|
*/
|
|
class BookAddon
|
|
{
|
|
public function __construct(private RecordCommercialEvent $record) {}
|
|
|
|
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): 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);
|
|
} 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();
|
|
}
|
|
}
|
|
|
|
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): 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) {
|
|
// 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([
|
|
'subscription_id' => $subscription->id,
|
|
'order_id' => $order?->id,
|
|
'addon_key' => $addonKey,
|
|
'price_cents' => $price,
|
|
'currency' => Subscription::catalogueCurrency(),
|
|
'quantity' => $quantity,
|
|
'booked_at' => now(),
|
|
]);
|
|
|
|
($this->record)(
|
|
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
|
|
subscription: $subscription,
|
|
netCents: $addon->monthlyCents(),
|
|
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
|
|
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;
|
|
});
|
|
}
|
|
}
|