fix(portal): atomic seat-limit (row lock) + billing-day-anchored cancellation date

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:47:04 +02:00
parent 074b0c041b
commit c011a11b48
2 changed files with 33 additions and 17 deletions

View File

@ -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

View File

@ -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'));