diff --git a/app/Livewire/Recurring/Index.php b/app/Livewire/Recurring/Index.php new file mode 100644 index 0000000..19acf39 --- /dev/null +++ b/app/Livewire/Recurring/Index.php @@ -0,0 +1,125 @@ +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, + ]); + } +} diff --git a/app/Livewire/Suivi/Index.php b/app/Livewire/Suivi/Index.php index c710361..9c64f80 100644 --- a/app/Livewire/Suivi/Index.php +++ b/app/Livewire/Suivi/Index.php @@ -6,6 +6,7 @@ namespace App\Livewire\Suivi; use App\Enums\CategoryType; use App\Models\BudgetLine; +use App\Models\RecurringTransaction; use App\Models\Transaction; use Livewire\Attributes\Layout; use Livewire\Component; @@ -65,6 +66,48 @@ class Index extends Component $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() { $household = auth()->user()->currentHousehold; @@ -106,6 +149,16 @@ class Index extends Component $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', [ 'transactions' => $transactions, 'running' => $running, @@ -114,6 +167,7 @@ class Index extends Component 'nbrTotal' => $transactions->count(), 'lastEntry' => $transactions->first(), 'unaffected' => $unaffected, + 'pendingRecurrences' => $pendingRecurrences, ]); } } diff --git a/app/Models/Household.php b/app/Models/Household.php index 678f54f..7563e19 100644 --- a/app/Models/Household.php +++ b/app/Models/Household.php @@ -44,4 +44,9 @@ class Household extends Model { return $this->hasMany(HouseholdInvite::class); } + + public function recurringTransactions(): HasMany + { + return $this->hasMany(RecurringTransaction::class); + } } diff --git a/app/Models/RecurringTransaction.php b/app/Models/RecurringTransaction.php new file mode 100644 index 0000000..32ddf99 --- /dev/null +++ b/app/Models/RecurringTransaction.php @@ -0,0 +1,45 @@ + '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; + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 9b6197c..72248f2 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 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 { diff --git a/app/Policies/RecurringTransactionPolicy.php b/app/Policies/RecurringTransactionPolicy.php new file mode 100644 index 0000000..f37fa2c --- /dev/null +++ b/app/Policies/RecurringTransactionPolicy.php @@ -0,0 +1,21 @@ +household_id === $user->current_household_id; + } + + public function delete(User $user, RecurringTransaction $recurringTransaction): bool + { + return $recurringTransaction->household_id === $user->current_household_id; + } +} diff --git a/database/migrations/2026_07_26_065948_create_recurring_transactions_table.php b/database/migrations/2026_07_26_065948_create_recurring_transactions_table.php new file mode 100644 index 0000000..792492b --- /dev/null +++ b/database/migrations/2026_07_26_065948_create_recurring_transactions_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_26_070006_add_recurring_transaction_id_to_transactions_table.php b/database/migrations/2026_07_26_070006_add_recurring_transaction_id_to_transactions_table.php new file mode 100644 index 0000000..ff9cc74 --- /dev/null +++ b/database/migrations/2026_07_26_070006_add_recurring_transaction_id_to_transactions_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/views/livewire/layout/navigation.blade.php b/resources/views/livewire/layout/navigation.blade.php index 1775024..04fa3b9 100644 --- a/resources/views/livewire/layout/navigation.blade.php +++ b/resources/views/livewire/layout/navigation.blade.php @@ -83,6 +83,12 @@ new class extends Component @endif + @if (Route::has('recurring')) + + {{ __('Récurrences') }} + + @endif + @@ -159,6 +165,12 @@ new class extends Component @endif + @if (Route::has('recurring')) + + {{ __('Récurrences') }} + + @endif + diff --git a/resources/views/livewire/recurring/index.blade.php b/resources/views/livewire/recurring/index.blade.php new file mode 100644 index 0000000..0cec48e --- /dev/null +++ b/resources/views/livewire/recurring/index.blade.php @@ -0,0 +1,90 @@ + + + Récurrences + + 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. + + + + + + @forelse ($recurrences as $recurring) + + @if ($editingId === $recurring->id) + + + Nom + + + + Montant + + + + Jour du mois + + + + Sauver + Annuler + + + @else + + + + {{ $recurring->name }} + — {{ $recurring->category->name }} + + + {{ number_format($recurring->amount, 2, ',', ' ') }} € · le {{ $recurring->day_of_month }} de chaque mois + @unless ($recurring->active) · en pause @endunless + + + + + {{ $recurring->active ? 'Mettre en pause' : 'Réactiver' }} + + Modifier + Supprimer + + + @endif + + @empty + Aucune récurrence. + @endforelse + + + + + + + + + + + + — + @foreach ($categories as $category) + {{ $category->type->label() }} — {{ $category->name }} + @endforeach + + + + + + + + + + + + + + + Ajouter + + + + diff --git a/resources/views/livewire/suivi/index.blade.php b/resources/views/livewire/suivi/index.blade.php index 78595d6..a90c01d 100644 --- a/resources/views/livewire/suivi/index.blade.php +++ b/resources/views/livewire/suivi/index.blade.php @@ -7,6 +7,43 @@ > Nouvelle opération + @if ($pendingRecurrences->isNotEmpty()) + + À confirmer ce mois-ci + @foreach ($pendingRecurrences as $recurring) + + {{ $recurring->name }} — {{ $recurring->category->name }} + + Montant pour {{ $recurring->name }} + + + Confirmer + + + Ignorer + + + + @endforeach + + @endif + Catégorie @@ -108,6 +145,50 @@ + @if ($pendingRecurrences->isNotEmpty()) + + + À confirmer ce mois-ci + + + @foreach ($pendingRecurrences as $recurring) + + + {{ $recurring->name }} — {{ $recurring->category->name }} + le {{ $recurring->day_of_month }} de chaque mois + + + Montant pour {{ $recurring->name }} + + + Confirmer + + + Ignorer + + + + @endforeach + + + @endif + diff --git a/routes/web.php b/routes/web.php index ede85f8..4e4ac07 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Livewire\Household\AcceptInvite; use App\Livewire\Household\Create as HouseholdCreate; use App\Livewire\Household\Settings as HouseholdSettings; use App\Livewire\Plan\Index as PlanIndex; +use App\Livewire\Recurring\Index as RecurringIndex; use App\Livewire\Sankey\Index as SankeyIndex; use App\Livewire\Suivi\Index as SuiviIndex; use Illuminate\Support\Facades\Route; @@ -44,6 +45,10 @@ Route::get('flux', SankeyIndex::class) ->middleware(['auth', 'verified', 'household']) ->name('sankey'); +Route::get('recurrences', RecurringIndex::class) + ->middleware(['auth', 'verified', 'household']) + ->name('recurring'); + Route::get('invites/{token}', AcceptInvite::class) ->name('invites.accept'); diff --git a/tests/Feature/RecurringTransactionTest.php b/tests/Feature/RecurringTransactionTest.php new file mode 100644 index 0000000..734ff7c --- /dev/null +++ b/tests/Feature/RecurringTransactionTest.php @@ -0,0 +1,67 @@ +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(); +}); diff --git a/tests/Feature/SuiviTest.php b/tests/Feature/SuiviTest.php index 5c6da37..302d5c1 100644 --- a/tests/Feature/SuiviTest.php +++ b/tests/Feature/SuiviTest.php @@ -98,6 +98,92 @@ it('resets the form to defaults after adding a transaction', function (): void { 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 { $ownerA = User::factory()->create(); $ownerB = User::factory()->create();
+ 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. +
+ {{ $recurring->name }} + — {{ $recurring->category->name }} +
+ {{ number_format($recurring->amount, 2, ',', ' ') }} € · le {{ $recurring->day_of_month }} de chaque mois + @unless ($recurring->active) · en pause @endunless +
À confirmer ce mois-ci
{{ $recurring->name }} — {{ $recurring->category->name }}
le {{ $recurring->day_of_month }} de chaque mois