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:
jeremy bayse
2026-07-26 09:10:16 +02:00
co-authored by Claude Sonnet 5
parent 674c43afea
commit b78c314569
14 changed files with 655 additions and 1 deletions
@@ -83,6 +83,12 @@ new class extends Component
</x-dropdown-link>
@endif
@if (Route::has('recurring'))
<x-dropdown-link :href="route('recurring')" wire:navigate>
{{ __('Récurrences') }}
</x-dropdown-link>
@endif
<!-- Authentication -->
<button wire:click="logout" class="w-full text-start">
<x-dropdown-link>
@@ -159,6 +165,12 @@ new class extends Component
</x-responsive-nav-link>
@endif
@if (Route::has('recurring'))
<x-responsive-nav-link :href="route('recurring')" wire:navigate>
{{ __('Récurrences') }}
</x-responsive-nav-link>
@endif
<!-- Authentication -->
<button wire:click="logout" class="w-full text-start">
<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>
@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">
<fieldset>
<legend class="text-sm font-medium text-text-muted mb-2">Catégorie</legend>
@@ -108,6 +145,50 @@
</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">
<form wire:submit="addTransaction" class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-end">
<div>