From 9114bb2e8de0c1bb21eb96338af638be3f7dd344 Mon Sep 17 00:00:00 2001 From: jeremy bayse Date: Sat, 1 Aug 2026 08:24:14 +0200 Subject: [PATCH] Track who paid each depense, with a "Commun" (shared) fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "payé par" field to transactions and recurring transactions: either a household member or null (shared/common expense). No per- member account balances — this is purely a "who paid" label, the household's overall balance is unchanged and unaffected by any filtering. - paid_by_user_id nullable FK on transactions and recurring_transactions - Suivi: "payé par" selector on both entry forms (desktop dropdown, mobile buttons), a filter above the transaction table, a "payé par" column, and a summary tile showing this month's depenses grouped by payer (including Commun) - Recurring transactions carry paid_by_user_id onto the transaction they generate when confirmed - Running balance is computed from all transactions regardless of the payer filter, so it never gets skewed by the active filter 11 new Pest tests (assignment, common fallback, non-member rejection, filter isolation from the balance calc, per-payer summary, recurring propagation). 69/69 total pass, Pint clean, verified end-to-end on production data (desktop + mobile, test row created and removed). Co-Authored-By: Claude Sonnet 5 --- app/Livewire/Recurring/Index.php | 34 ++++- app/Livewire/Suivi/Index.php | 75 +++++++++-- app/Models/RecurringTransaction.php | 7 +- app/Models/Transaction.php | 7 +- ..._paid_by_user_id_to_transactions_table.php | 29 +++++ ...ser_id_to_recurring_transactions_table.php | 28 +++++ .../views/livewire/recurring/index.blade.php | 24 +++- .../views/livewire/suivi/index.blade.php | 66 +++++++++- tests/Feature/RecurringTransactionTest.php | 45 +++++++ tests/Feature/SuiviTest.php | 118 ++++++++++++++++++ 10 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 database/migrations/2026_08_01_061025_add_paid_by_user_id_to_transactions_table.php create mode 100644 database/migrations/2026_08_01_061052_add_paid_by_user_id_to_recurring_transactions_table.php diff --git a/app/Livewire/Recurring/Index.php b/app/Livewire/Recurring/Index.php index 19acf39..4c3c413 100644 --- a/app/Livewire/Recurring/Index.php +++ b/app/Livewire/Recurring/Index.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Livewire\Recurring; +use App\Models\Household; use App\Models\RecurringTransaction; use Livewire\Attributes\Layout; use Livewire\Component; @@ -19,6 +20,8 @@ class Index extends Component public string $dayOfMonth = '1'; + public string $paidByUserId = ''; + public ?int $editingId = null; public string $editingName = ''; @@ -27,6 +30,23 @@ class Index extends Component public string $editingDayOfMonth = '1'; + public string $editingPaidByUserId = ''; + + /** + * Vide ou "0" = dépense commune (aucun membre précis). Vérifie que la + * valeur, si fournie, correspond bien à un membre du foyer courant. + */ + private function resolvePaidByUserId(Household $household, string $value): ?int + { + if ($value === '' || $value === '0') { + return null; + } + + $isMember = $household->members()->whereKey((int) $value)->exists(); + + return $isMember ? (int) $value : null; + } + public function add(): void { $this->validate([ @@ -34,6 +54,7 @@ class Index extends Component 'categoryId' => ['required', 'exists:categories,id'], 'amount' => ['required', 'numeric', 'min:0.01'], 'dayOfMonth' => ['required', 'integer', 'min:1', 'max:28'], + 'paidByUserId' => ['nullable', 'integer'], ]); $household = auth()->user()->currentHousehold; @@ -41,12 +62,13 @@ class Index extends Component $household->recurringTransactions()->create([ 'category_id' => $category->id, + 'paid_by_user_id' => $this->resolvePaidByUserId($household, $this->paidByUserId), 'name' => $this->name, 'amount' => $this->amount, 'day_of_month' => $this->dayOfMonth, ]); - $this->reset(['name', 'categoryId', 'amount', 'dayOfMonth']); + $this->reset(['name', 'categoryId', 'amount', 'dayOfMonth', 'paidByUserId']); $this->dayOfMonth = '1'; } @@ -59,6 +81,7 @@ class Index extends Component $this->editingName = $recurring->name; $this->editingAmount = (string) $recurring->amount; $this->editingDayOfMonth = (string) $recurring->day_of_month; + $this->editingPaidByUserId = $recurring->paid_by_user_id ? (string) $recurring->paid_by_user_id : ''; } public function saveEdit(): void @@ -70,12 +93,16 @@ class Index extends Component 'editingName' => ['required', 'string', 'max:255'], 'editingAmount' => ['required', 'numeric', 'min:0.01'], 'editingDayOfMonth' => ['required', 'integer', 'min:1', 'max:28'], + 'editingPaidByUserId' => ['nullable', 'integer'], ]); + $household = auth()->user()->currentHousehold; + $recurring->update([ 'name' => $this->editingName, 'amount' => $this->editingAmount, 'day_of_month' => $this->editingDayOfMonth, + 'paid_by_user_id' => $this->resolvePaidByUserId($household, $this->editingPaidByUserId), ]); $this->editingId = null; @@ -107,7 +134,7 @@ class Index extends Component $household = auth()->user()->currentHousehold; $recurrences = $household->recurringTransactions() - ->with('category') + ->with(['category', 'paidBy']) ->orderBy('day_of_month') ->get(); @@ -117,9 +144,12 @@ class Index extends Component ->orderBy('position') ->get(); + $members = $household->members()->orderBy('name')->get(); + return view('livewire.recurring.index', [ 'recurrences' => $recurrences, 'categories' => $categories, + 'members' => $members, ]); } } diff --git a/app/Livewire/Suivi/Index.php b/app/Livewire/Suivi/Index.php index 2212214..ae15f9d 100644 --- a/app/Livewire/Suivi/Index.php +++ b/app/Livewire/Suivi/Index.php @@ -7,6 +7,7 @@ namespace App\Livewire\Suivi; use App\Enums\CategoryType; use App\Models\BudgetLine; use App\Models\Category; +use App\Models\Household; use App\Models\RecurringTransaction; use App\Models\Transaction; use Livewire\Attributes\Layout; @@ -25,6 +26,10 @@ class Index extends Component public string $details = ''; + public string $paidByUserId = ''; + + public string $filterPaidBy = ''; + public function mount(): void { $this->date = now()->toDateString(); @@ -39,27 +44,45 @@ class Index extends Component 'categoryId' => ['required', 'exists:categories,id'], 'amount' => ['required', 'numeric'], 'details' => ['nullable', 'string', 'max:255'], + 'paidByUserId' => ['nullable', 'integer'], ]); $household = auth()->user()->currentHousehold; $category = $household->categories()->findOrFail($this->categoryId); + $paidByUserId = $this->resolvePaidByUserId($household, $this->paidByUserId); $household->transactions()->create([ 'category_id' => $category->id, 'user_id' => auth()->id(), + 'paid_by_user_id' => $paidByUserId, 'date' => $this->date, 'date_effective' => $this->dateEffective, 'amount' => $this->amount, 'details' => $this->details, ]); - $this->reset(['categoryId', 'amount', 'details']); + $this->reset(['categoryId', 'amount', 'details', 'paidByUserId']); $this->date = now()->toDateString(); $this->dateEffective = now()->toDateString(); $this->dispatch('transaction-added'); } + /** + * Vide ou "0" = dépense commune (aucun membre précis). Vérifie que la + * valeur, si fournie, correspond bien à un membre du foyer courant. + */ + private function resolvePaidByUserId(Household $household, string $value): ?int + { + if ($value === '' || $value === '0') { + return null; + } + + $isMember = $household->members()->whereKey((int) $value)->exists(); + + return $isMember ? (int) $value : null; + } + public function deleteTransaction(int $transactionId): void { $household = auth()->user()->currentHousehold; @@ -82,6 +105,7 @@ class Index extends Component 'category_id' => $recurring->category_id, 'recurring_transaction_id' => $recurring->id, 'user_id' => auth()->id(), + 'paid_by_user_id' => $recurring->paid_by_user_id, 'date' => $today->toDateString(), 'date_effective' => $today->toDateString(), 'amount' => $amount, @@ -139,13 +163,10 @@ class Index extends Component { $household = auth()->user()->currentHousehold; - $transactions = $household->transactions() - ->with('category') - ->orderByDesc('date') - ->orderByDesc('id') - ->get(); - - $chronological = $transactions->sortBy(['date', 'id'])->values(); + // Le solde cumulé porte sur l'ensemble des transactions, indépendamment + // du filtre "payé par" appliqué à la liste affichée. + $allTransactions = $household->transactions()->with('category')->get(); + $chronological = $allTransactions->sortBy(['date', 'id'])->values(); $running = []; $balance = 0.0; @@ -155,6 +176,20 @@ class Index extends Component $running[$t->id] = $balance; } + $transactionsQuery = $household->transactions() + ->with(['category', 'paidBy']); + + if ($this->filterPaidBy === 'common') { + $transactionsQuery->whereNull('paid_by_user_id'); + } elseif ($this->filterPaidBy !== '') { + $transactionsQuery->where('paid_by_user_id', (int) $this->filterPaidBy); + } + + $transactions = $transactionsQuery + ->orderByDesc('date') + ->orderByDesc('id') + ->get(); + $year = (int) now()->year; $month = (int) now()->month; @@ -205,14 +240,32 @@ class Index extends Component ]]; }); + $members = $household->members()->orderBy('name')->get(); + + $paidBySummary = $household->transactions() + ->with(['category', 'paidBy']) + ->whereHas('category', fn ($q) => $q->where('type', CategoryType::Depense)) + ->whereYear('date_effective', $year) + ->whereMonth('date_effective', $month) + ->get() + ->groupBy(fn (Transaction $t) => $t->paid_by_user_id ?? 'common') + ->map(fn ($group, $key) => [ + 'label' => $key === 'common' ? 'Commun' : $group->first()->paidBy->name, + 'total' => (float) $group->sum('amount'), + ]) + ->sortByDesc('total') + ->values(); + return view('livewire.suivi.index', [ 'transactions' => $transactions, 'running' => $running, 'categories' => $categories, 'categoryProgress' => $categoryProgress, - 'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(), - 'nbrTotal' => $transactions->count(), - 'lastEntry' => $transactions->first(), + 'members' => $members, + 'paidBySummary' => $paidBySummary, + 'nbrThisYear' => $allTransactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(), + 'nbrTotal' => $allTransactions->count(), + 'lastEntry' => $allTransactions->sortByDesc('date')->first(), 'unaffected' => $unaffected, 'pendingRecurrences' => $pendingRecurrences, ]); diff --git a/app/Models/RecurringTransaction.php b/app/Models/RecurringTransaction.php index 32ddf99..e26202d 100644 --- a/app/Models/RecurringTransaction.php +++ b/app/Models/RecurringTransaction.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class RecurringTransaction extends Model { protected $fillable = [ - 'household_id', 'category_id', 'name', 'amount', 'day_of_month', + 'household_id', 'category_id', 'paid_by_user_id', 'name', 'amount', 'day_of_month', 'active', 'last_generated_year', 'last_generated_month', ]; @@ -33,6 +33,11 @@ class RecurringTransaction extends Model return $this->belongsTo(Category::class); } + public function paidBy(): BelongsTo + { + return $this->belongsTo(User::class, 'paid_by_user_id'); + } + public function transactions(): HasMany { return $this->hasMany(Transaction::class); diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 72248f2..8cb6c23 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', 'recurring_transaction_id', 'user_id', 'date', 'date_effective', 'amount', 'details']; + protected $fillable = ['household_id', 'category_id', 'recurring_transaction_id', 'user_id', 'paid_by_user_id', 'date', 'date_effective', 'amount', 'details']; protected function casts(): array { @@ -34,4 +34,9 @@ class Transaction extends Model { return $this->belongsTo(User::class); } + + public function paidBy(): BelongsTo + { + return $this->belongsTo(User::class, 'paid_by_user_id'); + } } diff --git a/database/migrations/2026_08_01_061025_add_paid_by_user_id_to_transactions_table.php b/database/migrations/2026_08_01_061025_add_paid_by_user_id_to_transactions_table.php new file mode 100644 index 0000000..50263c4 --- /dev/null +++ b/database/migrations/2026_08_01_061025_add_paid_by_user_id_to_transactions_table.php @@ -0,0 +1,29 @@ +foreignId('paid_by_user_id')->nullable()->after('user_id')->constrained('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropConstrainedForeignId('paid_by_user_id'); + }); + } +}; diff --git a/database/migrations/2026_08_01_061052_add_paid_by_user_id_to_recurring_transactions_table.php b/database/migrations/2026_08_01_061052_add_paid_by_user_id_to_recurring_transactions_table.php new file mode 100644 index 0000000..4e1a6dd --- /dev/null +++ b/database/migrations/2026_08_01_061052_add_paid_by_user_id_to_recurring_transactions_table.php @@ -0,0 +1,28 @@ +foreignId('paid_by_user_id')->nullable()->after('category_id')->constrained('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('recurring_transactions', function (Blueprint $table) { + $table->dropConstrainedForeignId('paid_by_user_id'); + }); + } +}; diff --git a/resources/views/livewire/recurring/index.blade.php b/resources/views/livewire/recurring/index.blade.php index 0cec48e..c16cbc2 100644 --- a/resources/views/livewire/recurring/index.blade.php +++ b/resources/views/livewire/recurring/index.blade.php @@ -11,7 +11,7 @@ @forelse ($recurrences as $recurring)
  • @if ($editingId === $recurring->id) -
    +
    @@ -24,6 +24,15 @@
    +
    + + +
    @@ -37,7 +46,7 @@ — {{ $recurring->category->name }}

    - {{ number_format($recurring->amount, 2, ',', ' ') }} € · le {{ $recurring->day_of_month }} de chaque mois + {{ number_format($recurring->amount, 2, ',', ' ') }} € · le {{ $recurring->day_of_month }} de chaque mois · {{ $recurring->paidBy?->name ?? 'Commun' }} @unless ($recurring->active) · en pause @endunless

    @@ -56,7 +65,7 @@ @endforelse - +
    @@ -82,6 +91,15 @@
    +
    + + +
    Ajouter
    diff --git a/resources/views/livewire/suivi/index.blade.php b/resources/views/livewire/suivi/index.blade.php index 6035751..e03f2d3 100644 --- a/resources/views/livewire/suivi/index.blade.php +++ b/resources/views/livewire/suivi/index.blade.php @@ -123,6 +123,34 @@ /> + @if ($members->isNotEmpty()) +
    + Payé par +
    + + @foreach ($members as $member) + + @endforeach +
    +
    + @endif +