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
parent
074b0c041b
commit
c011a11b48
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Reference in New Issue