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
+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>