Add recurring transactions with monthly confirmation flow
New "Récurrences" section lets users define recurring depense/epargne models (name, category, default amount, day of month). Nothing is created automatically: from the day a recurrence is due, it appears as a pending proposal in Suivi (desktop + mobile) with an editable amount, so variable bills (electricity, etc.) can be adjusted before confirming. Dismissing a proposal skips it for the current month without creating a transaction, and it reappears the following month. - recurring_transactions table (household/category scoped, active flag, last_generated_year/month to prevent duplicate generation) - transactions.recurring_transaction_id nullable FK to trace origin - RecurringTransactionPolicy mirrors CategoryPolicy tenant scoping - 10 new Pest tests covering CRUD, tenant isolation, and the pending/confirm/dismiss lifecycle across month boundaries (travelTo) 58/58 tests pass, Pint clean, verified end-to-end against production data (test recurrence created and cleaned up). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
674c43afea
commit
b78c314569
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Recurring;
|
||||||
|
|
||||||
|
use App\Models\RecurringTransaction;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
public ?int $categoryId = null;
|
||||||
|
|
||||||
|
public string $amount = '';
|
||||||
|
|
||||||
|
public string $dayOfMonth = '1';
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $editingName = '';
|
||||||
|
|
||||||
|
public string $editingAmount = '';
|
||||||
|
|
||||||
|
public string $editingDayOfMonth = '1';
|
||||||
|
|
||||||
|
public function add(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'categoryId' => ['required', 'exists:categories,id'],
|
||||||
|
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||||
|
'dayOfMonth' => ['required', 'integer', 'min:1', 'max:28'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$household = auth()->user()->currentHousehold;
|
||||||
|
$category = $household->categories()->findOrFail($this->categoryId);
|
||||||
|
|
||||||
|
$household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'amount' => $this->amount,
|
||||||
|
'day_of_month' => $this->dayOfMonth,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset(['name', 'categoryId', 'amount', 'dayOfMonth']);
|
||||||
|
$this->dayOfMonth = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startEdit(int $recurringId): void
|
||||||
|
{
|
||||||
|
$recurring = RecurringTransaction::findOrFail($recurringId);
|
||||||
|
$this->authorize('update', $recurring);
|
||||||
|
|
||||||
|
$this->editingId = $recurring->id;
|
||||||
|
$this->editingName = $recurring->name;
|
||||||
|
$this->editingAmount = (string) $recurring->amount;
|
||||||
|
$this->editingDayOfMonth = (string) $recurring->day_of_month;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveEdit(): void
|
||||||
|
{
|
||||||
|
$recurring = RecurringTransaction::findOrFail($this->editingId);
|
||||||
|
$this->authorize('update', $recurring);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editingName' => ['required', 'string', 'max:255'],
|
||||||
|
'editingAmount' => ['required', 'numeric', 'min:0.01'],
|
||||||
|
'editingDayOfMonth' => ['required', 'integer', 'min:1', 'max:28'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recurring->update([
|
||||||
|
'name' => $this->editingName,
|
||||||
|
'amount' => $this->editingAmount,
|
||||||
|
'day_of_month' => $this->editingDayOfMonth,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEdit(): void
|
||||||
|
{
|
||||||
|
$this->editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleActive(int $recurringId): void
|
||||||
|
{
|
||||||
|
$recurring = RecurringTransaction::findOrFail($recurringId);
|
||||||
|
$this->authorize('update', $recurring);
|
||||||
|
|
||||||
|
$recurring->update(['active' => ! $recurring->active]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $recurringId): void
|
||||||
|
{
|
||||||
|
$recurring = RecurringTransaction::findOrFail($recurringId);
|
||||||
|
$this->authorize('delete', $recurring);
|
||||||
|
|
||||||
|
$recurring->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$household = auth()->user()->currentHousehold;
|
||||||
|
|
||||||
|
$recurrences = $household->recurringTransactions()
|
||||||
|
->with('category')
|
||||||
|
->orderBy('day_of_month')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$categories = $household->categories()
|
||||||
|
->whereIn('type', ['depense', 'epargne'])
|
||||||
|
->orderBy('type')
|
||||||
|
->orderBy('position')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('livewire.recurring.index', [
|
||||||
|
'recurrences' => $recurrences,
|
||||||
|
'categories' => $categories,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Livewire\Suivi;
|
|||||||
|
|
||||||
use App\Enums\CategoryType;
|
use App\Enums\CategoryType;
|
||||||
use App\Models\BudgetLine;
|
use App\Models\BudgetLine;
|
||||||
|
use App\Models\RecurringTransaction;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -65,6 +66,48 @@ class Index extends Component
|
|||||||
$transaction->delete();
|
$transaction->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function confirmRecurrence(int $recurringId, string $amount): void
|
||||||
|
{
|
||||||
|
$household = auth()->user()->currentHousehold;
|
||||||
|
$recurring = $household->recurringTransactions()->findOrFail($recurringId);
|
||||||
|
|
||||||
|
$today = now();
|
||||||
|
|
||||||
|
if ($recurring->wasGeneratedFor((int) $today->year, (int) $today->month)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$household->transactions()->create([
|
||||||
|
'category_id' => $recurring->category_id,
|
||||||
|
'recurring_transaction_id' => $recurring->id,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'date' => $today->toDateString(),
|
||||||
|
'date_effective' => $today->toDateString(),
|
||||||
|
'amount' => $amount,
|
||||||
|
'details' => $recurring->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recurring->update([
|
||||||
|
'last_generated_year' => $today->year,
|
||||||
|
'last_generated_month' => $today->month,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('transaction-added');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismissRecurrence(int $recurringId): void
|
||||||
|
{
|
||||||
|
$household = auth()->user()->currentHousehold;
|
||||||
|
$recurring = $household->recurringTransactions()->findOrFail($recurringId);
|
||||||
|
|
||||||
|
$today = now();
|
||||||
|
|
||||||
|
$recurring->update([
|
||||||
|
'last_generated_year' => $today->year,
|
||||||
|
'last_generated_month' => $today->month,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
@@ -106,6 +149,16 @@ class Index extends Component
|
|||||||
|
|
||||||
$unaffected = max(0, $revenueBudget - $revenueTracked);
|
$unaffected = max(0, $revenueBudget - $revenueTracked);
|
||||||
|
|
||||||
|
$today = now();
|
||||||
|
$pendingRecurrences = $household->recurringTransactions()
|
||||||
|
->with('category')
|
||||||
|
->where('active', true)
|
||||||
|
->where('day_of_month', '<=', $today->day)
|
||||||
|
->get()
|
||||||
|
->reject(fn (RecurringTransaction $r) => $r->wasGeneratedFor((int) $today->year, (int) $today->month))
|
||||||
|
->sortBy('day_of_month')
|
||||||
|
->values();
|
||||||
|
|
||||||
return view('livewire.suivi.index', [
|
return view('livewire.suivi.index', [
|
||||||
'transactions' => $transactions,
|
'transactions' => $transactions,
|
||||||
'running' => $running,
|
'running' => $running,
|
||||||
@@ -114,6 +167,7 @@ class Index extends Component
|
|||||||
'nbrTotal' => $transactions->count(),
|
'nbrTotal' => $transactions->count(),
|
||||||
'lastEntry' => $transactions->first(),
|
'lastEntry' => $transactions->first(),
|
||||||
'unaffected' => $unaffected,
|
'unaffected' => $unaffected,
|
||||||
|
'pendingRecurrences' => $pendingRecurrences,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,4 +44,9 @@ class Household extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(HouseholdInvite::class);
|
return $this->hasMany(HouseholdInvite::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function recurringTransactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(RecurringTransaction::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class RecurringTransaction extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'household_id', 'category_id', 'name', 'amount', 'day_of_month',
|
||||||
|
'active', 'last_generated_year', 'last_generated_month',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function household(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Household::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Transaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function wasGeneratedFor(int $year, int $month): bool
|
||||||
|
{
|
||||||
|
return $this->last_generated_year === $year && $this->last_generated_month === $month;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
class Transaction extends Model
|
class Transaction extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['household_id', 'category_id', 'user_id', 'date', 'date_effective', 'amount', 'details'];
|
protected $fillable = ['household_id', 'category_id', 'recurring_transaction_id', 'user_id', 'date', 'date_effective', 'amount', 'details'];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\RecurringTransaction;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class RecurringTransactionPolicy
|
||||||
|
{
|
||||||
|
public function update(User $user, RecurringTransaction $recurringTransaction): bool
|
||||||
|
{
|
||||||
|
return $recurringTransaction->household_id === $user->current_household_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, RecurringTransaction $recurringTransaction): bool
|
||||||
|
{
|
||||||
|
return $recurringTransaction->household_id === $user->current_household_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('recurring_transactions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('household_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->decimal('amount', 12, 2);
|
||||||
|
$table->unsignedTinyInteger('day_of_month');
|
||||||
|
$table->boolean('active')->default(true);
|
||||||
|
$table->unsignedSmallInteger('last_generated_year')->nullable();
|
||||||
|
$table->unsignedTinyInteger('last_generated_month')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('recurring_transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->foreignId('recurring_transaction_id')->nullable()->after('category_id')->constrained()->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('recurring_transaction_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -83,6 +83,12 @@ new class extends Component
|
|||||||
</x-dropdown-link>
|
</x-dropdown-link>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (Route::has('recurring'))
|
||||||
|
<x-dropdown-link :href="route('recurring')" wire:navigate>
|
||||||
|
{{ __('Récurrences') }}
|
||||||
|
</x-dropdown-link>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Authentication -->
|
<!-- Authentication -->
|
||||||
<button wire:click="logout" class="w-full text-start">
|
<button wire:click="logout" class="w-full text-start">
|
||||||
<x-dropdown-link>
|
<x-dropdown-link>
|
||||||
@@ -159,6 +165,12 @@ new class extends Component
|
|||||||
</x-responsive-nav-link>
|
</x-responsive-nav-link>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (Route::has('recurring'))
|
||||||
|
<x-responsive-nav-link :href="route('recurring')" wire:navigate>
|
||||||
|
{{ __('Récurrences') }}
|
||||||
|
</x-responsive-nav-link>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Authentication -->
|
<!-- Authentication -->
|
||||||
<button wire:click="logout" class="w-full text-start">
|
<button wire:click="logout" class="w-full text-start">
|
||||||
<x-responsive-nav-link>
|
<x-responsive-nav-link>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<div class="max-w-3xl mx-auto py-6 px-4 sm:px-0 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-text">Récurrences</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-muted">
|
||||||
|
Modèles de dépenses/épargne qui reviennent chaque mois. Une proposition apparaît dans Suivi à partir du jour prévu — rien n'est ajouté automatiquement sans ta confirmation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-surface shadow rounded-lg overflow-hidden">
|
||||||
|
<ul class="divide-y divide-border">
|
||||||
|
@forelse ($recurrences as $recurring)
|
||||||
|
<li class="px-4 py-3">
|
||||||
|
@if ($editingId === $recurring->id)
|
||||||
|
<form wire:submit="saveEdit" class="grid grid-cols-1 sm:grid-cols-4 gap-2 items-end">
|
||||||
|
<div>
|
||||||
|
<label for="editing-name-{{ $recurring->id }}" class="sr-only">Nom</label>
|
||||||
|
<input wire:model="editingName" id="editing-name-{{ $recurring->id }}" type="text" class="w-full bg-surface border-border-strong text-text rounded-md text-sm py-1" autofocus />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="editing-amount-{{ $recurring->id }}" class="sr-only">Montant</label>
|
||||||
|
<input wire:model="editingAmount" id="editing-amount-{{ $recurring->id }}" type="number" step="0.01" class="w-full bg-surface border-border-strong text-text rounded-md text-sm py-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="editing-day-{{ $recurring->id }}" class="sr-only">Jour du mois</label>
|
||||||
|
<input wire:model="editingDayOfMonth" id="editing-day-{{ $recurring->id }}" type="number" min="1" max="28" class="w-full bg-surface border-border-strong text-text rounded-md text-sm py-1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button type="submit" class="text-xs text-positive hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">Sauver</button>
|
||||||
|
<button type="button" wire:click="cancelEdit" class="text-xs text-text-muted hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-text {{ ! $recurring->active ? 'opacity-50' : '' }}">
|
||||||
|
{{ $recurring->name }}
|
||||||
|
<span class="text-text-muted">— {{ $recurring->category->name }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-text-muted">
|
||||||
|
{{ number_format($recurring->amount, 2, ',', ' ') }} € · le {{ $recurring->day_of_month }} de chaque mois
|
||||||
|
@unless ($recurring->active) · <span class="text-danger">en pause</span> @endunless
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
<button wire:click="toggleActive({{ $recurring->id }})" class="text-xs text-brand hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
|
||||||
|
{{ $recurring->active ? 'Mettre en pause' : 'Réactiver' }}
|
||||||
|
</button>
|
||||||
|
<button wire:click="startEdit({{ $recurring->id }})" aria-label="Modifier {{ $recurring->name }}" class="text-xs text-brand hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">Modifier</button>
|
||||||
|
<button wire:click="delete({{ $recurring->id }})" wire:confirm="Supprimer cette récurrence ?" aria-label="Supprimer {{ $recurring->name }}" class="text-xs text-danger hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="px-4 py-3 text-sm text-text-muted">Aucune récurrence.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form wire:submit="add" class="px-4 py-4 bg-surface-alt grid grid-cols-1 sm:grid-cols-5 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<x-input-label for="name" value="Nom" />
|
||||||
|
<x-text-input wire:model="name" id="name" type="text" class="block mt-1 w-full text-sm" placeholder="Loyer" />
|
||||||
|
<x-input-error :messages="$errors->get('name')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<x-input-label for="categoryId" value="Catégorie" />
|
||||||
|
<select wire:model="categoryId" id="categoryId" class="block mt-1 w-full rounded-md bg-surface border-border-strong text-text text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach ($categories as $category)
|
||||||
|
<option value="{{ $category->id }}">{{ $category->type->label() }} — {{ $category->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<x-input-error :messages="$errors->get('categoryId')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<x-input-label for="amount" value="Montant" />
|
||||||
|
<x-text-input wire:model="amount" id="amount" type="number" step="0.01" class="block mt-1 w-full text-sm" />
|
||||||
|
<x-input-error :messages="$errors->get('amount')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<x-input-label for="dayOfMonth" value="Jour du mois" />
|
||||||
|
<x-text-input wire:model="dayOfMonth" id="dayOfMonth" type="number" min="1" max="28" class="block mt-1 w-full text-sm" />
|
||||||
|
<x-input-error :messages="$errors->get('dayOfMonth')" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<x-primary-button class="w-full justify-center">Ajouter</x-primary-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -7,6 +7,43 @@
|
|||||||
>
|
>
|
||||||
<h1 class="text-xl font-semibold text-text">Nouvelle opération</h1>
|
<h1 class="text-xl font-semibold text-text">Nouvelle opération</h1>
|
||||||
|
|
||||||
|
@if ($pendingRecurrences->isNotEmpty())
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm font-medium text-text-muted">À confirmer ce mois-ci</p>
|
||||||
|
@foreach ($pendingRecurrences as $recurring)
|
||||||
|
<div class="bg-surface-alt border border-border-strong rounded-lg p-3" wire:key="pending-mobile-{{ $recurring->id }}">
|
||||||
|
<p class="text-sm text-text">{{ $recurring->name }} <span class="text-text-muted">— {{ $recurring->category->name }}</span></p>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<label for="pending-amount-mobile-{{ $recurring->id }}" class="sr-only">Montant pour {{ $recurring->name }}</label>
|
||||||
|
<input
|
||||||
|
x-ref="amount{{ $recurring->id }}"
|
||||||
|
id="pending-amount-mobile-{{ $recurring->id }}"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value="{{ $recurring->amount }}"
|
||||||
|
class="flex-1 bg-surface border-border-strong text-text rounded-md text-sm py-1.5"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="$wire.confirmRecurrence({{ $recurring->id }}, $refs.amount{{ $recurring->id }}.value)"
|
||||||
|
class="text-xs text-white bg-brand px-3 py-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
Confirmer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="dismissRecurrence({{ $recurring->id }})"
|
||||||
|
wire:confirm="Ignorer cette récurrence pour ce mois ?"
|
||||||
|
class="text-xs text-text-muted hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
Ignorer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<form wire:submit="addTransaction" class="space-y-5">
|
<form wire:submit="addTransaction" class="space-y-5">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend class="text-sm font-medium text-text-muted mb-2">Catégorie</legend>
|
<legend class="text-sm font-medium text-text-muted mb-2">Catégorie</legend>
|
||||||
@@ -108,6 +145,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($pendingRecurrences->isNotEmpty())
|
||||||
|
<div class="bg-surface shadow rounded-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-brand">
|
||||||
|
<h2 class="text-white font-semibold text-sm">À confirmer ce mois-ci</h2>
|
||||||
|
</div>
|
||||||
|
<ul class="divide-y divide-border">
|
||||||
|
@foreach ($pendingRecurrences as $recurring)
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between gap-3" wire:key="pending-desktop-{{ $recurring->id }}">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-text">{{ $recurring->name }} <span class="text-text-muted">— {{ $recurring->category->name }}</span></p>
|
||||||
|
<p class="text-xs text-text-muted">le {{ $recurring->day_of_month }} de chaque mois</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<label for="pending-amount-{{ $recurring->id }}" class="sr-only">Montant pour {{ $recurring->name }}</label>
|
||||||
|
<input
|
||||||
|
x-ref="amountDesktop{{ $recurring->id }}"
|
||||||
|
id="pending-amount-{{ $recurring->id }}"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value="{{ $recurring->amount }}"
|
||||||
|
class="w-28 bg-surface border-border-strong text-text rounded-md text-sm py-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="$wire.confirmRecurrence({{ $recurring->id }}, $refs.amountDesktop{{ $recurring->id }}.value)"
|
||||||
|
class="text-xs text-white bg-brand px-3 py-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
Confirmer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="dismissRecurrence({{ $recurring->id }})"
|
||||||
|
wire:confirm="Ignorer cette récurrence pour ce mois ?"
|
||||||
|
class="text-xs text-text-muted hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
Ignorer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="bg-surface shadow rounded-lg p-4">
|
<div class="bg-surface shadow rounded-lg p-4">
|
||||||
<form wire:submit="addTransaction" class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-end">
|
<form wire:submit="addTransaction" class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-end">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Livewire\Household\AcceptInvite;
|
|||||||
use App\Livewire\Household\Create as HouseholdCreate;
|
use App\Livewire\Household\Create as HouseholdCreate;
|
||||||
use App\Livewire\Household\Settings as HouseholdSettings;
|
use App\Livewire\Household\Settings as HouseholdSettings;
|
||||||
use App\Livewire\Plan\Index as PlanIndex;
|
use App\Livewire\Plan\Index as PlanIndex;
|
||||||
|
use App\Livewire\Recurring\Index as RecurringIndex;
|
||||||
use App\Livewire\Sankey\Index as SankeyIndex;
|
use App\Livewire\Sankey\Index as SankeyIndex;
|
||||||
use App\Livewire\Suivi\Index as SuiviIndex;
|
use App\Livewire\Suivi\Index as SuiviIndex;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -44,6 +45,10 @@ Route::get('flux', SankeyIndex::class)
|
|||||||
->middleware(['auth', 'verified', 'household'])
|
->middleware(['auth', 'verified', 'household'])
|
||||||
->name('sankey');
|
->name('sankey');
|
||||||
|
|
||||||
|
Route::get('recurrences', RecurringIndex::class)
|
||||||
|
->middleware(['auth', 'verified', 'household'])
|
||||||
|
->name('recurring');
|
||||||
|
|
||||||
Route::get('invites/{token}', AcceptInvite::class)
|
Route::get('invites/{token}', AcceptInvite::class)
|
||||||
->name('invites.accept');
|
->name('invites.accept');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Recurring\Index;
|
||||||
|
use App\Models\RecurringTransaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('creates a recurring transaction for the current household', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(Index::class)
|
||||||
|
->set('name', 'Loyer')
|
||||||
|
->set('categoryId', $category->id)
|
||||||
|
->set('amount', '1200')
|
||||||
|
->set('dayOfMonth', '5')
|
||||||
|
->call('add')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$recurring = $user->currentHousehold->recurringTransactions()->first();
|
||||||
|
|
||||||
|
expect($recurring)->not->toBeNull();
|
||||||
|
expect($recurring->name)->toBe('Loyer');
|
||||||
|
expect((float) $recurring->amount)->toBe(1200.0);
|
||||||
|
expect($recurring->day_of_month)->toBe(5);
|
||||||
|
expect($recurring->active)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents editing a recurring transaction from another household', function (): void {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$intruder = User::factory()->create();
|
||||||
|
|
||||||
|
$category = $owner->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
$recurring = $owner->currentHousehold->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($intruder)
|
||||||
|
->test(Index::class)
|
||||||
|
->call('startEdit', $recurring->id)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles active state', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
$recurring = $user->currentHousehold->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Index::class)->call('toggleActive', $recurring->id);
|
||||||
|
|
||||||
|
expect($recurring->fresh()->active)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a recurring transaction', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
$recurring = $user->currentHousehold->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Index::class)->call('delete', $recurring->id);
|
||||||
|
|
||||||
|
expect(RecurringTransaction::find($recurring->id))->toBeNull();
|
||||||
|
});
|
||||||
@@ -98,6 +98,92 @@ it('resets the form to defaults after adding a transaction', function (): void {
|
|||||||
expect($component->get('details'))->toBe('');
|
expect($component->get('details'))->toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists active recurring transactions due this month as pending', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
|
||||||
|
$due = $household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
$notYetDue = $household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Internet', 'amount' => 40, 'day_of_month' => 20,
|
||||||
|
]);
|
||||||
|
$inactive = $household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Ancien', 'amount' => 10, 'day_of_month' => 1, 'active' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
$pending = $component->viewData('pendingRecurrences');
|
||||||
|
|
||||||
|
expect($pending->pluck('id'))->toContain($due->id);
|
||||||
|
expect($pending->pluck('id'))->not->toContain($notYetDue->id);
|
||||||
|
expect($pending->pluck('id'))->not->toContain($inactive->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirming a recurring transaction creates a transaction and stops it reappearing', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
$recurring = $household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(Index::class)
|
||||||
|
->call('confirmRecurrence', $recurring->id, '1250')
|
||||||
|
->assertDispatched('transaction-added');
|
||||||
|
|
||||||
|
$transaction = $household->transactions()->where('recurring_transaction_id', $recurring->id)->first();
|
||||||
|
|
||||||
|
expect($transaction)->not->toBeNull();
|
||||||
|
expect((float) $transaction->amount)->toBe(1250.0);
|
||||||
|
expect($transaction->details)->toBe('Loyer');
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
expect($component->viewData('pendingRecurrences'))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismissing a recurring transaction hides it without creating a transaction', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
$recurring = $household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Index::class)->call('dismissRecurrence', $recurring->id);
|
||||||
|
|
||||||
|
expect($household->transactions()->count())->toBe(0);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
expect($component->viewData('pendingRecurrences'))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the recurrence again the following month after being confirmed', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
$recurring = $household->recurringTransactions()->create([
|
||||||
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Index::class)->call('confirmRecurrence', $recurring->id, '1200');
|
||||||
|
|
||||||
|
$this->travelTo(now()->setDate(2026, 4, 10));
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
expect($component->viewData('pendingRecurrences')->pluck('id'))->toContain($recurring->id);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not show another household transactions', function (): void {
|
it('does not show another household transactions', function (): void {
|
||||||
$ownerA = User::factory()->create();
|
$ownerA = User::factory()->create();
|
||||||
$ownerB = User::factory()->create();
|
$ownerB = User::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user