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:
co-authored by
Claude Sonnet 5
parent
5ef655d16f
commit
9114bb2e8d
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Livewire\Recurring;
|
namespace App\Livewire\Recurring;
|
||||||
|
|
||||||
|
use App\Models\Household;
|
||||||
use App\Models\RecurringTransaction;
|
use App\Models\RecurringTransaction;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -19,6 +20,8 @@ class Index extends Component
|
|||||||
|
|
||||||
public string $dayOfMonth = '1';
|
public string $dayOfMonth = '1';
|
||||||
|
|
||||||
|
public string $paidByUserId = '';
|
||||||
|
|
||||||
public ?int $editingId = null;
|
public ?int $editingId = null;
|
||||||
|
|
||||||
public string $editingName = '';
|
public string $editingName = '';
|
||||||
@@ -27,6 +30,23 @@ class Index extends Component
|
|||||||
|
|
||||||
public string $editingDayOfMonth = '1';
|
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
|
public function add(): void
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
@@ -34,6 +54,7 @@ class Index extends Component
|
|||||||
'categoryId' => ['required', 'exists:categories,id'],
|
'categoryId' => ['required', 'exists:categories,id'],
|
||||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||||
'dayOfMonth' => ['required', 'integer', 'min:1', 'max:28'],
|
'dayOfMonth' => ['required', 'integer', 'min:1', 'max:28'],
|
||||||
|
'paidByUserId' => ['nullable', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
@@ -41,12 +62,13 @@ class Index extends Component
|
|||||||
|
|
||||||
$household->recurringTransactions()->create([
|
$household->recurringTransactions()->create([
|
||||||
'category_id' => $category->id,
|
'category_id' => $category->id,
|
||||||
|
'paid_by_user_id' => $this->resolvePaidByUserId($household, $this->paidByUserId),
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'amount' => $this->amount,
|
'amount' => $this->amount,
|
||||||
'day_of_month' => $this->dayOfMonth,
|
'day_of_month' => $this->dayOfMonth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->reset(['name', 'categoryId', 'amount', 'dayOfMonth']);
|
$this->reset(['name', 'categoryId', 'amount', 'dayOfMonth', 'paidByUserId']);
|
||||||
$this->dayOfMonth = '1';
|
$this->dayOfMonth = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +81,7 @@ class Index extends Component
|
|||||||
$this->editingName = $recurring->name;
|
$this->editingName = $recurring->name;
|
||||||
$this->editingAmount = (string) $recurring->amount;
|
$this->editingAmount = (string) $recurring->amount;
|
||||||
$this->editingDayOfMonth = (string) $recurring->day_of_month;
|
$this->editingDayOfMonth = (string) $recurring->day_of_month;
|
||||||
|
$this->editingPaidByUserId = $recurring->paid_by_user_id ? (string) $recurring->paid_by_user_id : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveEdit(): void
|
public function saveEdit(): void
|
||||||
@@ -70,12 +93,16 @@ class Index extends Component
|
|||||||
'editingName' => ['required', 'string', 'max:255'],
|
'editingName' => ['required', 'string', 'max:255'],
|
||||||
'editingAmount' => ['required', 'numeric', 'min:0.01'],
|
'editingAmount' => ['required', 'numeric', 'min:0.01'],
|
||||||
'editingDayOfMonth' => ['required', 'integer', 'min:1', 'max:28'],
|
'editingDayOfMonth' => ['required', 'integer', 'min:1', 'max:28'],
|
||||||
|
'editingPaidByUserId' => ['nullable', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$household = auth()->user()->currentHousehold;
|
||||||
|
|
||||||
$recurring->update([
|
$recurring->update([
|
||||||
'name' => $this->editingName,
|
'name' => $this->editingName,
|
||||||
'amount' => $this->editingAmount,
|
'amount' => $this->editingAmount,
|
||||||
'day_of_month' => $this->editingDayOfMonth,
|
'day_of_month' => $this->editingDayOfMonth,
|
||||||
|
'paid_by_user_id' => $this->resolvePaidByUserId($household, $this->editingPaidByUserId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->editingId = null;
|
$this->editingId = null;
|
||||||
@@ -107,7 +134,7 @@ class Index extends Component
|
|||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
|
|
||||||
$recurrences = $household->recurringTransactions()
|
$recurrences = $household->recurringTransactions()
|
||||||
->with('category')
|
->with(['category', 'paidBy'])
|
||||||
->orderBy('day_of_month')
|
->orderBy('day_of_month')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@@ -117,9 +144,12 @@ class Index extends Component
|
|||||||
->orderBy('position')
|
->orderBy('position')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
$members = $household->members()->orderBy('name')->get();
|
||||||
|
|
||||||
return view('livewire.recurring.index', [
|
return view('livewire.recurring.index', [
|
||||||
'recurrences' => $recurrences,
|
'recurrences' => $recurrences,
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
|
'members' => $members,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Livewire\Suivi;
|
|||||||
use App\Enums\CategoryType;
|
use App\Enums\CategoryType;
|
||||||
use App\Models\BudgetLine;
|
use App\Models\BudgetLine;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\Household;
|
||||||
use App\Models\RecurringTransaction;
|
use App\Models\RecurringTransaction;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
@@ -25,6 +26,10 @@ class Index extends Component
|
|||||||
|
|
||||||
public string $details = '';
|
public string $details = '';
|
||||||
|
|
||||||
|
public string $paidByUserId = '';
|
||||||
|
|
||||||
|
public string $filterPaidBy = '';
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->date = now()->toDateString();
|
$this->date = now()->toDateString();
|
||||||
@@ -39,27 +44,45 @@ class Index extends Component
|
|||||||
'categoryId' => ['required', 'exists:categories,id'],
|
'categoryId' => ['required', 'exists:categories,id'],
|
||||||
'amount' => ['required', 'numeric'],
|
'amount' => ['required', 'numeric'],
|
||||||
'details' => ['nullable', 'string', 'max:255'],
|
'details' => ['nullable', 'string', 'max:255'],
|
||||||
|
'paidByUserId' => ['nullable', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
$category = $household->categories()->findOrFail($this->categoryId);
|
$category = $household->categories()->findOrFail($this->categoryId);
|
||||||
|
$paidByUserId = $this->resolvePaidByUserId($household, $this->paidByUserId);
|
||||||
|
|
||||||
$household->transactions()->create([
|
$household->transactions()->create([
|
||||||
'category_id' => $category->id,
|
'category_id' => $category->id,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
|
'paid_by_user_id' => $paidByUserId,
|
||||||
'date' => $this->date,
|
'date' => $this->date,
|
||||||
'date_effective' => $this->dateEffective,
|
'date_effective' => $this->dateEffective,
|
||||||
'amount' => $this->amount,
|
'amount' => $this->amount,
|
||||||
'details' => $this->details,
|
'details' => $this->details,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->reset(['categoryId', 'amount', 'details']);
|
$this->reset(['categoryId', 'amount', 'details', 'paidByUserId']);
|
||||||
$this->date = now()->toDateString();
|
$this->date = now()->toDateString();
|
||||||
$this->dateEffective = now()->toDateString();
|
$this->dateEffective = now()->toDateString();
|
||||||
|
|
||||||
$this->dispatch('transaction-added');
|
$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
|
public function deleteTransaction(int $transactionId): void
|
||||||
{
|
{
|
||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
@@ -82,6 +105,7 @@ class Index extends Component
|
|||||||
'category_id' => $recurring->category_id,
|
'category_id' => $recurring->category_id,
|
||||||
'recurring_transaction_id' => $recurring->id,
|
'recurring_transaction_id' => $recurring->id,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
|
'paid_by_user_id' => $recurring->paid_by_user_id,
|
||||||
'date' => $today->toDateString(),
|
'date' => $today->toDateString(),
|
||||||
'date_effective' => $today->toDateString(),
|
'date_effective' => $today->toDateString(),
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
@@ -139,13 +163,10 @@ class Index extends Component
|
|||||||
{
|
{
|
||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
|
|
||||||
$transactions = $household->transactions()
|
// Le solde cumulé porte sur l'ensemble des transactions, indépendamment
|
||||||
->with('category')
|
// du filtre "payé par" appliqué à la liste affichée.
|
||||||
->orderByDesc('date')
|
$allTransactions = $household->transactions()->with('category')->get();
|
||||||
->orderByDesc('id')
|
$chronological = $allTransactions->sortBy(['date', 'id'])->values();
|
||||||
->get();
|
|
||||||
|
|
||||||
$chronological = $transactions->sortBy(['date', 'id'])->values();
|
|
||||||
|
|
||||||
$running = [];
|
$running = [];
|
||||||
$balance = 0.0;
|
$balance = 0.0;
|
||||||
@@ -155,6 +176,20 @@ class Index extends Component
|
|||||||
$running[$t->id] = $balance;
|
$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;
|
$year = (int) now()->year;
|
||||||
$month = (int) now()->month;
|
$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', [
|
return view('livewire.suivi.index', [
|
||||||
'transactions' => $transactions,
|
'transactions' => $transactions,
|
||||||
'running' => $running,
|
'running' => $running,
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
'categoryProgress' => $categoryProgress,
|
'categoryProgress' => $categoryProgress,
|
||||||
'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
|
'members' => $members,
|
||||||
'nbrTotal' => $transactions->count(),
|
'paidBySummary' => $paidBySummary,
|
||||||
'lastEntry' => $transactions->first(),
|
'nbrThisYear' => $allTransactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
|
||||||
|
'nbrTotal' => $allTransactions->count(),
|
||||||
|
'lastEntry' => $allTransactions->sortByDesc('date')->first(),
|
||||||
'unaffected' => $unaffected,
|
'unaffected' => $unaffected,
|
||||||
'pendingRecurrences' => $pendingRecurrences,
|
'pendingRecurrences' => $pendingRecurrences,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class RecurringTransaction extends Model
|
class RecurringTransaction extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
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',
|
'active', 'last_generated_year', 'last_generated_month',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,6 +33,11 @@ class RecurringTransaction extends Model
|
|||||||
return $this->belongsTo(Category::class);
|
return $this->belongsTo(Category::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function paidBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'paid_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function transactions(): HasMany
|
public function transactions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Transaction::class);
|
return $this->hasMany(Transaction::class);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
class Transaction extends Model
|
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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
@@ -34,4 +34,9 @@ class Transaction extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+28
@@ -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)
|
@forelse ($recurrences as $recurring)
|
||||||
<li class="px-4 py-3">
|
<li class="px-4 py-3">
|
||||||
@if ($editingId === $recurring->id)
|
@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>
|
<div>
|
||||||
<label for="editing-name-{{ $recurring->id }}" class="sr-only">Nom</label>
|
<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 />
|
<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>
|
<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" />
|
<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>
|
||||||
|
<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">
|
<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="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>
|
<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>
|
<span class="text-text-muted">— {{ $recurring->category->name }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-text-muted">
|
<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
|
@unless ($recurring->active) · <span class="text-danger">en pause</span> @endunless
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +65,7 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</ul>
|
</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>
|
<div>
|
||||||
<x-input-label for="name" value="Nom" />
|
<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-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-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" />
|
<x-input-error :messages="$errors->get('dayOfMonth')" class="mt-1" />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<x-primary-button class="w-full justify-center">Ajouter</x-primary-button>
|
<x-primary-button class="w-full justify-center">Ajouter</x-primary-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,6 +123,34 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
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"
|
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>
|
||||||
</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())
|
@if ($pendingRecurrences->isNotEmpty())
|
||||||
<div class="bg-surface shadow rounded-lg overflow-hidden">
|
<div class="bg-surface shadow rounded-lg overflow-hidden">
|
||||||
<div class="px-4 py-2 bg-brand">
|
<div class="px-4 py-2 bg-brand">
|
||||||
@@ -241,6 +283,15 @@
|
|||||||
<x-input-label for="dateEffective" value="Date effective" />
|
<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" />
|
<x-text-input wire:model="dateEffective" id="dateEffective" type="date" class="block mt-1 w-full text-sm" />
|
||||||
</div>
|
</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">
|
<div class="sm:col-span-6">
|
||||||
<x-primary-button>Ajouter</x-primary-button>
|
<x-primary-button>Ajouter</x-primary-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,6 +307,17 @@
|
|||||||
$amountPrefix = fn ($t) => $t->category->type->value === 'depense' ? '−' : '+';
|
$amountPrefix = fn ($t) => $t->category->type->value === 'depense' ? '−' : '+';
|
||||||
@endphp
|
@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="bg-surface shadow rounded-lg overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<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-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-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">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-right px-4 py-2">Balance</th>
|
||||||
<th scope="col" class="text-left px-4 py-2">Date effective</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>
|
<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, ',', ' ') }} €
|
{{ $amountPrefix($transaction) }}{{ number_format($transaction->amount, 2, ',', ' ') }} €
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-text-muted">{{ $transaction->details }}</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' }}">
|
<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, ',', ' ') }} €
|
{{ number_format($running[$transaction->id] ?? 0, 2, ',', ' ') }} €
|
||||||
</td>
|
</td>
|
||||||
@@ -293,7 +357,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@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
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -65,3 +65,48 @@ it('deletes a recurring transaction', function (): void {
|
|||||||
|
|
||||||
expect(RecurringTransaction::find($recurring->id))->toBeNull();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -236,6 +236,124 @@ it('returns no progress for a category without a budget defined', function (): v
|
|||||||
expect($component->viewData('categoryProgress')[$category->id])->toBeNull();
|
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 {
|
it('does not show another household transactions', function (): void {
|
||||||
$ownerA = User::factory()->create();
|
$ownerA = User::factory()->create();
|
||||||
$ownerB = User::factory()->create();
|
$ownerB = User::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user