From c011a11b485ac35a378ee682472bceb7b67f82fa Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 14:47:04 +0200 Subject: [PATCH] fix(portal): atomic seat-limit (row lock) + billing-day-anchored cancellation date Co-Authored-By: Claude Opus 4.8 --- app/Livewire/ConfirmCancelPackage.php | 11 +++++--- app/Livewire/Users.php | 39 ++++++++++++++++++--------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php index 82cd8d9..d79f27f 100644 --- a/app/Livewire/ConfirmCancelPackage.php +++ b/app/Livewire/ConfirmCancelPackage.php @@ -50,12 +50,15 @@ class ConfirmCancelPackage extends ModalComponent private function currentPeriodEnd(Instance $instance): \Illuminate\Support\Carbon { $start = $instance->order?->created_at ?? $instance->created_at ?? now(); - $end = $start->copy(); - while ($end->lessThanOrEqualTo(now())) { - $end->addMonthNoOverflow(); + + // Always add from the immutable start so the billing-day anchor is kept + // (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28). + $months = 1; + while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) { + $months++; } - return $end; + return $start->copy()->addMonthsNoOverflow($months); } private function customer(): ?Customer diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 456a864..00c0590 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Models\Customer; use App\Models\Seat; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; use Livewire\Component; @@ -44,28 +45,40 @@ class Users extends Component $data = $this->validate(); - // Seat-limit guard against the plan allowance. - if ($this->usedSeats($customer) >= $this->seatLimit($customer)) { + // Serialize the limit check with the insert so two concurrent invites for + // the last free seat can't both pass (lock the customer row). + $result = DB::transaction(function () use ($customer, $data) { + $locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); + + if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) { + return 'duplicate'; + } + if ($this->usedSeats($locked) >= $this->seatLimit($locked)) { + return 'limit'; + } + + $locked->seats()->create([ + 'email' => $data['inviteEmail'], + 'name' => $data['inviteName'] ?: null, + 'role' => $data['inviteRole'], + 'status' => 'invited', + 'invited_at' => now(), + ]); + + return 'ok'; + }); + + if ($result === 'limit') { $this->addError('inviteEmail', __('users.limit_reached')); return; } - - // One seat per email per customer. - if ($customer->seats()->where('email', $data['inviteEmail'])->exists()) { + if ($result === 'duplicate') { $this->addError('inviteEmail', __('users.duplicate')); return; } - $customer->seats()->create([ - 'email' => $data['inviteEmail'], - 'name' => $data['inviteName'] ?: null, - 'role' => $data['inviteRole'], - 'status' => 'invited', - 'invited_at' => now(), - ]); - $this->reset('inviteEmail', 'inviteName', 'inviteRole'); $this->inviteRole = 'member'; $this->dispatch('notify', message: __('users.invited'));