From 9fab251fb37330d394d29064c1e34a43fd1da7fc Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 09:25:29 +0200 Subject: [PATCH] fix(billing): make the one-plan-change rule hold under two clicks Both requests could finish their delete before either inserted, leaving exactly the two pending upgrades the rule exists to prevent. Replacement and insert now run in one transaction with the customer row locked. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Billing.php | 58 ++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 23 deletions(-) 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')); }