diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index f9fc2a5..bc57f3c 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -3,7 +3,9 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; +use App\Models\Customer; use App\Models\Order; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Component; @@ -54,32 +56,42 @@ class Billing extends Component ?? $customer->orders()->latest('id')->value('datacenter') ?? 'fsn'; - // A cart cannot hold two plan changes: they contradict each other and - // no checkout could resolve which one the customer meant. Choosing - // another simply replaces the pending one — add-ons still stack. - if ($type === 'upgrade') { - $replaced = Order::query() - ->where('customer_id', $customer->id) - ->where('status', 'pending') - ->where('type', 'upgrade') - ->delete(); + // A cart cannot hold two plan changes: they contradict each other and no + // checkout could resolve which one the customer meant. Choosing another + // replaces the pending one — add-ons still stack. + // + // Replacement and insert run together with the customer row locked: + // two clicks in flight could otherwise both delete and then both + // insert, leaving exactly the two upgrades this rule exists to prevent. + $replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) { + Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); - if ($replaced > 0) { - $this->dispatch('notify', message: __('billing.cart.plan_replaced')); - } + $replaced = $type === 'upgrade' + ? Order::query() + ->where('customer_id', $customer->id) + ->where('status', 'pending') + ->where('type', 'upgrade') + ->delete() + : 0; + + Order::create([ + 'customer_id' => $customer->id, + 'plan' => $plan, + 'type' => $type, + 'addon_key' => $addonKey, + 'amount_cents' => $amount, + 'currency' => 'EUR', + 'datacenter' => $datacenter, + 'status' => 'pending', + ]); + + return $replaced; + }); + + if ($replaced > 0) { + $this->dispatch('notify', message: __('billing.cart.plan_replaced')); } - Order::create([ - 'customer_id' => $customer->id, - 'plan' => $plan, - 'type' => $type, - 'addon_key' => $addonKey, - 'amount_cents' => $amount, - 'currency' => 'EUR', - 'datacenter' => $datacenter, - 'status' => 'pending', - ]); - $this->dispatch('notify', message: __('billing.purchased')); }