Track who paid each depense, with a "Commun" (shared) fallback

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 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-08-01 08:24:14 +02:00
co-authored by Claude Sonnet 5
parent 5ef655d16f
commit 9114bb2e8d
10 changed files with 414 additions and 19 deletions
+32 -2
View File
@@ -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,
]);
}
}
+64 -11
View File
@@ -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,
]);
+6 -1
View File
@@ -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);
+6 -1
View File
@@ -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');
}
}
@@ -0,0 +1,29 @@
<?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) {
// Null = dépense commune, non attribuée à un membre en particulier.
$table->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');
});
}
};
@@ -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('recurring_transactions', function (Blueprint $table) {
$table->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');
});
}
};
@@ -11,7 +11,7 @@
@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">
<form wire:submit="saveEdit" class="grid grid-cols-1 sm:grid-cols-5 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 />
@@ -24,6 +24,15 @@
<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>
<label for="editing-paid-by-{{ $recurring->id }}" class="sr-only">Payé par</label>
<select wire:model="editingPaidByUserId" id="editing-paid-by-{{ $recurring->id }}" class="w-full rounded-md bg-surface border-border-strong text-text text-sm py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
<option value="">Commun</option>
@foreach ($members as $member)
<option value="{{ $member->id }}">{{ $member->name }}</option>
@endforeach
</select>
</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>
@@ -37,7 +46,7 @@
<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
{{ number_format($recurring->amount, 2, ',', ' ') }} · le {{ $recurring->day_of_month }} de chaque mois · {{ $recurring->paidBy?->name ?? 'Commun' }}
@unless ($recurring->active) · <span class="text-danger">en pause</span> @endunless
</p>
</div>
@@ -56,7 +65,7 @@
@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">
<form wire:submit="add" class="px-4 py-4 bg-surface-alt grid grid-cols-1 sm:grid-cols-6 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" />
@@ -82,6 +91,15 @@
<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-input-label for="paidByUserId" value="Payé par" />
<select wire:model="paidByUserId" id="paidByUserId" 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="">Commun</option>
@foreach ($members as $member)
<option value="{{ $member->id }}">{{ $member->name }}</option>
@endforeach
</select>
</div>
<div>
<x-primary-button class="w-full justify-center">Ajouter</x-primary-button>
</div>
+65 -1
View File
@@ -123,6 +123,34 @@
/>
</div>
@if ($members->isNotEmpty())
<fieldset>
<legend class="text-sm font-medium text-text-muted mb-2">Payé par</legend>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
wire:click="$set('paidByUserId', '')"
aria-pressed="{{ $paidByUserId === '' ? 'true' : 'false' }}"
class="min-h-[44px] px-3 py-2 rounded-lg border text-sm font-medium transition focus:outline-none focus-visible:ring-2 ring-brand
{{ $paidByUserId === '' ? 'bg-brand text-white border-brand' : 'bg-surface border-border-strong text-text' }}"
>
Commun
</button>
@foreach ($members as $member)
<button
type="button"
wire:click="$set('paidByUserId', '{{ $member->id }}')"
aria-pressed="{{ (string) $paidByUserId === (string) $member->id ? 'true' : 'false' }}"
class="min-h-[44px] px-3 py-2 rounded-lg border text-sm font-medium transition focus:outline-none focus-visible:ring-2 ring-brand
{{ (string) $paidByUserId === (string) $member->id ? 'bg-brand text-white border-brand' : 'bg-surface border-border-strong text-text' }}"
>
{{ $member->name }}
</button>
@endforeach
</div>
</fieldset>
@endif
<button
type="submit"
class="w-full py-4 rounded-lg bg-brand text-white text-lg font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
@@ -168,6 +196,20 @@
</div>
</div>
@if ($paidBySummary->isNotEmpty())
<div class="bg-surface shadow rounded-lg p-4">
<p class="text-xs text-text-muted mb-2">Dépenses ce mois-ci, par personne</p>
<div class="flex flex-wrap gap-4">
@foreach ($paidBySummary as $entry)
<div>
<p class="text-xs text-text-muted">{{ $entry['label'] }}</p>
<p class="text-sm font-semibold text-negative">{{ number_format($entry['total'], 2, ',', ' ') }} </p>
</div>
@endforeach
</div>
</div>
@endif
@if ($pendingRecurrences->isNotEmpty())
<div class="bg-surface shadow rounded-lg overflow-hidden">
<div class="px-4 py-2 bg-brand">
@@ -241,6 +283,15 @@
<x-input-label for="dateEffective" value="Date effective" />
<x-text-input wire:model="dateEffective" id="dateEffective" type="date" class="block mt-1 w-full text-sm" />
</div>
<div>
<x-input-label for="paidByUserId" value="Payé par" />
<select wire:model="paidByUserId" id="paidByUserId" 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="">Commun</option>
@foreach ($members as $member)
<option value="{{ $member->id }}">{{ $member->name }}</option>
@endforeach
</select>
</div>
<div class="sm:col-span-6">
<x-primary-button>Ajouter</x-primary-button>
</div>
@@ -256,6 +307,17 @@
$amountPrefix = fn ($t) => $t->category->type->value === 'depense' ? '' : '+';
@endphp
<div class="flex items-center gap-2">
<label for="filterPaidBy" class="text-xs text-text-muted">Payé par</label>
<select wire:model.live="filterPaidBy" id="filterPaidBy" class="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="">Tout le monde</option>
<option value="common">Commun</option>
@foreach ($members as $member)
<option value="{{ $member->id }}">{{ $member->name }}</option>
@endforeach
</select>
</div>
<div class="bg-surface shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
@@ -267,6 +329,7 @@
<th scope="col" class="text-left px-4 py-2">Catégorie</th>
<th scope="col" class="text-right px-4 py-2">Montant</th>
<th scope="col" class="text-left px-4 py-2">Détails</th>
<th scope="col" class="text-left px-4 py-2">Payé par</th>
<th scope="col" class="text-right px-4 py-2">Balance</th>
<th scope="col" class="text-left px-4 py-2">Date effective</th>
<th scope="col" class="px-4 py-2"><span class="sr-only">Actions</span></th>
@@ -282,6 +345,7 @@
{{ $amountPrefix($transaction) }}{{ number_format($transaction->amount, 2, ',', ' ') }}
</td>
<td class="px-4 py-2 text-text-muted">{{ $transaction->details }}</td>
<td class="px-4 py-2 text-text-muted">{{ $transaction->paidBy?->name ?? 'Commun' }}</td>
<td class="px-4 py-2 text-right font-semibold {{ ($running[$transaction->id] ?? 0) < 0 ? 'text-negative' : 'text-text' }}">
{{ number_format($running[$transaction->id] ?? 0, 2, ',', ' ') }}
</td>
@@ -293,7 +357,7 @@
</td>
</tr>
@empty
<tr><td colspan="8" class="px-4 py-3 text-sm text-text-muted">Aucun enregistrement.</td></tr>
<tr><td colspan="9" class="px-4 py-3 text-sm text-text-muted">Aucun enregistrement.</td></tr>
@endforelse
</tbody>
</table>
@@ -65,3 +65,48 @@ it('deletes a recurring transaction', function (): void {
expect(RecurringTransaction::find($recurring->id))->toBeNull();
});
it('assigns a recurring transaction to a household member', function (): void {
$owner = User::factory()->create();
$member = User::factory()->create();
$household = $owner->currentHousehold;
$member->households()->attach($household->id, ['role' => 'member']);
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($owner)
->test(Index::class)
->set('name', 'Loyer')
->set('categoryId', $category->id)
->set('amount', '1200')
->set('dayOfMonth', '5')
->set('paidByUserId', (string) $member->id)
->call('add');
$recurring = $household->recurringTransactions()->first();
expect($recurring->paid_by_user_id)->toBe($member->id);
});
it('carries the recurring paid-by user onto the generated transaction', function (): void {
$this->travelTo(now()->setDate(2026, 3, 10));
$owner = User::factory()->create();
$member = User::factory()->create();
$household = $owner->currentHousehold;
$member->households()->attach($household->id, ['role' => 'member']);
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$recurring = $household->recurringTransactions()->create([
'category_id' => $category->id, 'paid_by_user_id' => $member->id,
'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
]);
Livewire::actingAs($owner)
->test(App\Livewire\Suivi\Index::class)
->call('confirmRecurrence', $recurring->id, '1200');
$transaction = $household->transactions()->where('recurring_transaction_id', $recurring->id)->first();
expect($transaction->paid_by_user_id)->toBe($member->id);
});
+118
View File
@@ -236,6 +236,124 @@ it('returns no progress for a category without a budget defined', function (): v
expect($component->viewData('categoryProgress')[$category->id])->toBeNull();
});
it('records who paid a transaction and defaults to common when none is selected', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($user)
->test(Index::class)
->set('categoryId', $category->id)
->set('amount', '1200')
->call('addTransaction');
$transaction = $household->transactions()->first();
expect($transaction->paid_by_user_id)->toBeNull();
});
it('assigns a transaction to a household member when selected', function (): void {
$owner = User::factory()->create();
$member = User::factory()->create();
$household = $owner->currentHousehold;
$member->households()->attach($household->id, ['role' => 'member']);
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($owner)
->test(Index::class)
->set('categoryId', $category->id)
->set('amount', '1200')
->set('paidByUserId', (string) $member->id)
->call('addTransaction');
$transaction = $household->transactions()->first();
expect($transaction->paid_by_user_id)->toBe($member->id);
});
it('ignores a paid-by user id that does not belong to the household', function (): void {
$owner = User::factory()->create();
$outsider = User::factory()->create();
$household = $owner->currentHousehold;
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($owner)
->test(Index::class)
->set('categoryId', $category->id)
->set('amount', '1200')
->set('paidByUserId', (string) $outsider->id)
->call('addTransaction');
$transaction = $household->transactions()->first();
expect($transaction->paid_by_user_id)->toBeNull();
});
it('filters transactions by who paid without affecting the running balance', function (): void {
$owner = User::factory()->create();
$member = User::factory()->create();
$household = $owner->currentHousehold;
$member->households()->attach($household->id, ['role' => 'member']);
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$household->transactions()->create([
'category_id' => $revenu->id, 'user_id' => $owner->id,
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2000,
]);
$ownerTx = $household->transactions()->create([
'category_id' => $depense->id, 'user_id' => $owner->id, 'paid_by_user_id' => $owner->id,
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 500,
]);
$memberTx = $household->transactions()->create([
'category_id' => $depense->id, 'user_id' => $owner->id, 'paid_by_user_id' => $member->id,
'date' => '2026-01-03', 'date_effective' => '2026-01-03', 'amount' => 300,
]);
$component = Livewire::actingAs($owner)->test(Index::class)->set('filterPaidBy', (string) $member->id);
$shown = $component->viewData('transactions');
expect($shown->pluck('id'))->toContain($memberTx->id);
expect($shown->pluck('id'))->not->toContain($ownerTx->id);
// Le solde reste calculé sur toutes les transactions malgré le filtre d'affichage.
$running = $component->viewData('running');
expect($running[$memberTx->id])->toBe(1200.0); // 2000 - 500 - 300
});
it('summarizes this month depenses by payer including common', function (): void {
$this->travelTo(now()->setDate(2026, 3, 10));
$owner = User::factory()->create();
$member = User::factory()->create();
$household = $owner->currentHousehold;
$member->households()->attach($household->id, ['role' => 'member']);
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $owner->id, 'paid_by_user_id' => $owner->id,
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 500,
]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $owner->id, 'paid_by_user_id' => $member->id,
'date' => '2026-03-06', 'date_effective' => '2026-03-06', 'amount' => 300,
]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $owner->id, 'paid_by_user_id' => null,
'date' => '2026-03-07', 'date_effective' => '2026-03-07', 'amount' => 100,
]);
$component = Livewire::actingAs($owner)->test(Index::class);
$summary = $component->viewData('paidBySummary')->keyBy('label');
expect($summary[$owner->name]['total'])->toBe(500.0);
expect($summary[$member->name]['total'])->toBe(300.0);
expect($summary['Commun']['total'])->toBe(100.0);
});
it('does not show another household transactions', function (): void {
$ownerA = User::factory()->create();
$ownerB = User::factory()->create();