Initial commit: SuiviCPT personal finance tracker

Laravel 11 + Livewire 3/Volt + Alpine.js + Tailwind app for household
budget planning, transaction tracking, and financial dashboards.

- Multi-tenant households with member invites
- Plan: monthly/yearly budget per category with overrides
- Suivi: transaction log with running balance (desktop + mobile quick-entry)
- Dashboard: budget vs tracked breakdown + Chart.js donuts
- Flux: Sankey diagram of income allocation
- WCAG 2.1 AA color tokens, dark mode, accessible tables/forms
- 50 Pest tests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-07-25 22:39:42 +02:00
co-authored by Claude Sonnet 5
commit d8dc57a5df
164 changed files with 19880 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): void
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Categories;
use App\Enums\CategoryType;
use App\Models\Category;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, string> */
public array $newName = ['revenu' => '', 'depense' => '', 'epargne' => ''];
public ?int $editingId = null;
public string $editingName = '';
public function add(string $type): void
{
$this->validate([
"newName.{$type}" => ['required', 'string', 'max:255'],
]);
if (! in_array($type, ['revenu', 'depense', 'epargne'], true)) {
abort(422);
}
$household = auth()->user()->currentHousehold;
$position = $household->categories()->where('type', $type)->max('position') + 1;
$household->categories()->create([
'name' => $this->newName[$type],
'type' => $type,
'position' => $position,
]);
$this->newName[$type] = '';
}
public function startEdit(int $categoryId): void
{
$category = Category::findOrFail($categoryId);
$this->authorize('update', $category);
$this->editingId = $category->id;
$this->editingName = $category->name;
}
public function saveEdit(): void
{
$category = Category::findOrFail($this->editingId);
$this->authorize('update', $category);
$this->validate([
'editingName' => ['required', 'string', 'max:255'],
]);
$category->update(['name' => $this->editingName]);
$this->editingId = null;
}
public function cancelEdit(): void
{
$this->editingId = null;
}
public function delete(int $categoryId): void
{
$category = Category::findOrFail($categoryId);
$this->authorize('delete', $category);
$category->delete();
}
public function render()
{
$household = auth()->user()->currentHousehold;
$categories = $household->categories()->orderBy('position')->get()->groupBy(fn (Category $category) => $category->type->value);
return view('livewire.categories.index', [
'types' => CategoryType::cases(),
'categories' => $categories,
]);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use App\Enums\CategoryType;
use App\Models\Category;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public int $year;
public string $period = 'year'; // 'year' or '1'..'12'
public function mount(): void
{
$this->year = (int) now()->year;
$this->period = (string) now()->month;
}
private function budgetFor(Category $category): float
{
$lines = $category->budgetLines()->where('year', $this->year)->get();
if ($this->period === 'year') {
$default = (float) ($lines->firstWhere('month', null)->amount ?? 0);
$total = 0.0;
for ($m = 1; $m <= 12; $m++) {
$override = $lines->firstWhere('month', $m);
$total += $override ? (float) $override->amount : $default;
}
return $total;
}
$month = (int) $this->period;
$override = $lines->firstWhere('month', $month);
return $override ? (float) $override->amount : (float) ($lines->firstWhere('month', null)->amount ?? 0);
}
private function trackedFor(Category $category): float
{
$query = $category->transactions()->whereYear('date_effective', $this->year);
if ($this->period !== 'year') {
$query->whereMonth('date_effective', (int) $this->period);
}
return (float) $query->sum('amount');
}
public function render()
{
$household = auth()->user()->currentHousehold;
$sections = collect(CategoryType::cases())->mapWithKeys(function (CategoryType $type) use ($household) {
$rows = $household->categories()
->where('type', $type)
->orderBy('position')
->get()
->map(function (Category $category) {
$budget = $this->budgetFor($category);
$tracked = $this->trackedFor($category);
return [
'category' => $category,
'tracked' => $tracked,
'budget' => $budget,
'pct' => $budget > 0 ? min(100, round($tracked / $budget * 100)) : 0,
'reste' => max(0, $budget - $tracked),
'manque' => max(0, $tracked - $budget),
];
});
return [$type->value => [
'rows' => $rows,
'totals' => [
'tracked' => $rows->sum('tracked'),
'budget' => $rows->sum('budget'),
'reste' => $rows->sum('reste'),
'manque' => $rows->sum('manque'),
],
]];
});
$chartData = $sections->mapWithKeys(function ($section, $key) {
$top = $section['rows']
->filter(fn ($row) => $row['tracked'] > 0)
->sortByDesc('tracked')
->take(6)
->values();
return [$key => [
'labels' => $top->pluck('category.name')->values(),
'data' => $top->pluck('tracked')->values(),
]];
});
return view('livewire.dashboard.index', [
'types' => CategoryType::cases(),
'sections' => $sections,
'chartData' => $chartData,
]);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Livewire\Forms;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Form;
class LoginForm extends Form
{
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
#[Validate('boolean')]
public bool $remember = false;
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only(['email', 'password']), $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'form.email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'form.email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Household;
use App\Models\HouseholdInvite;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.guest')]
class AcceptInvite extends Component
{
public string $token;
public ?HouseholdInvite $invite = null;
public bool $invalid = false;
public function mount(string $token): void
{
$this->token = $token;
$invite = HouseholdInvite::with('household')->where('token', $token)->first();
if (! $invite || $invite->isExpired() || $invite->isAccepted()) {
$this->invalid = true;
return;
}
$this->invite = $invite;
$existingUser = User::where('email', $invite->email)->first();
if ($existingUser && auth()->check() && auth()->id() === $existingUser->id) {
$this->accept();
}
}
public function accept(): void
{
if (! $this->invite || ! auth()->check()) {
return;
}
$user = auth()->user();
if ($user->email !== $this->invite->email) {
$this->invalid = true;
return;
}
$user->households()->syncWithoutDetaching([
$this->invite->household_id => ['role' => $this->invite->role],
]);
$this->invite->update(['accepted_at' => now()]);
$user->forceFill(['current_household_id' => $this->invite->household_id])->save();
$this->redirect(route('dashboard', absolute: false), navigate: true);
}
public function render()
{
return view('livewire.household.accept-invite');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Household;
use App\Models\Household;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.guest')]
class Create extends Component
{
public string $name = '';
public function create(): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
]);
$user = auth()->user();
$household = Household::create([
'name' => $this->name,
'owner_id' => $user->id,
]);
$user->households()->attach($household->id, ['role' => 'owner']);
$user->forceFill(['current_household_id' => $household->id])->save();
$this->redirect(route('dashboard', absolute: false), navigate: true);
}
public function render()
{
return view('livewire.household.create');
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Household;
use App\Mail\HouseholdInviteMail;
use App\Models\HouseholdInvite;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Settings extends Component
{
public string $inviteEmail = '';
public string $inviteRole = 'member';
public function invite(): void
{
$household = auth()->user()->currentHousehold;
$this->authorize('manageMembers', $household);
$this->validate([
'inviteEmail' => ['required', 'email', 'max:255'],
'inviteRole' => ['required', 'in:owner,member'],
]);
$invite = HouseholdInvite::create([
'household_id' => $household->id,
'invited_by' => auth()->id(),
'email' => $this->inviteEmail,
'token' => Str::random(48),
'role' => $this->inviteRole,
'expires_at' => now()->addDays(7),
]);
Mail::to($this->inviteEmail)->send(new HouseholdInviteMail($invite));
$this->reset('inviteEmail');
$this->dispatch('invite-sent');
}
public function revokeInvite(int $inviteId): void
{
$household = auth()->user()->currentHousehold;
$this->authorize('manageMembers', $household);
$household->invites()->whereKey($inviteId)->whereNull('accepted_at')->delete();
}
public function removeMember(int $userId): void
{
$household = auth()->user()->currentHousehold;
$this->authorize('manageMembers', $household);
if ($userId === $household->owner_id) {
return;
}
$household->members()->detach($userId);
}
public function render()
{
$household = auth()->user()->currentHousehold()->with(['members', 'invites' => fn ($q) => $q->whereNull('accepted_at')])->first();
return view('livewire.household.settings', [
'household' => $household,
]);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Plan;
use App\Enums\CategoryType;
use App\Models\BudgetLine;
use App\Models\Category;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public int $year;
public function mount(): void
{
$this->year = (int) now()->year;
}
public function setYear(int $year): void
{
$this->year = $year;
}
public function updateAmount(int $categoryId, ?int $month, string $amount): void
{
$category = Category::findOrFail($categoryId);
$household = auth()->user()->currentHousehold;
if ($category->household_id !== $household->id) {
abort(403);
}
$amount = is_numeric($amount) ? (float) $amount : 0.0;
BudgetLine::updateOrCreate(
[
'household_id' => $household->id,
'category_id' => $categoryId,
'year' => $this->year,
'month' => $month,
],
['amount' => $amount],
);
}
/**
* @return array<int|string, float> keyed 'default',1..12
*/
private function resolveRow(Category $category): array
{
$lines = $category->budgetLines()->where('year', $this->year)->get();
$default = (float) ($lines->firstWhere('month', null)->amount ?? 0);
$row = ['default' => $default];
for ($m = 1; $m <= 12; $m++) {
$override = $lines->firstWhere('month', $m);
$row[$m] = $override ? (float) $override->amount : $default;
}
return $row;
}
public function render()
{
$household = auth()->user()->currentHousehold;
$sections = collect(CategoryType::cases())->mapWithKeys(function (CategoryType $type) use ($household) {
$categories = $household->categories()
->where('type', $type)
->orderBy('position')
->get()
->map(fn (Category $category) => [
'category' => $category,
'row' => $this->resolveRow($category),
]);
$totals = ['default' => 0.0];
for ($m = 1; $m <= 12; $m++) {
$totals[$m] = 0.0;
}
foreach ($categories as $entry) {
foreach ($entry['row'] as $key => $value) {
$totals[$key] += $value;
}
}
return [$type->value => ['categories' => $categories, 'totals' => $totals]];
});
$remaining = [];
for ($m = 1; $m <= 12; $m++) {
$remaining[$m] = $sections['revenu']['totals'][$m]
- $sections['depense']['totals'][$m]
- $sections['epargne']['totals'][$m];
}
$sections['revenu'] = [...$sections['revenu'], 'remaining' => $remaining];
return view('livewire.plan.index', [
'types' => CategoryType::cases(),
'sections' => $sections,
'months' => ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'],
]);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Sankey;
use App\Enums\CategoryType;
use App\Models\Category;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public int $year;
public string $period = 'year'; // 'year' or '1'..'12'
public function mount(): void
{
$this->year = (int) now()->year;
$this->period = (string) now()->month;
}
private function trackedFor(Category $category): float
{
$query = $category->transactions()->whereYear('date_effective', $this->year);
if ($this->period !== 'year') {
$query->whereMonth('date_effective', (int) $this->period);
}
return (float) $query->sum('amount');
}
public function render()
{
$household = auth()->user()->currentHousehold;
$revenueCategories = $household->categories()->where('type', CategoryType::Revenu)->orderBy('position')->get();
$totalRevenue = $revenueCategories->sum(fn (Category $c) => $this->trackedFor($c));
$outflowCategories = $household->categories()
->whereIn('type', [CategoryType::Depense, CategoryType::Epargne])
->orderBy('type')
->orderBy('position')
->get()
->map(fn (Category $category) => [
'category' => $category,
'tracked' => $this->trackedFor($category),
])
->filter(fn ($row) => $row['tracked'] > 0)
->values();
$totalOutflow = $outflowCategories->sum('tracked');
$unallocated = max(0, $totalRevenue - $totalOutflow);
$flows = $outflowCategories->map(fn ($row) => [
'from' => 'Revenus',
'to' => $row['category']->name,
'flow' => (float) $row['tracked'],
'type' => $row['category']->type->value,
]);
if ($unallocated > 0) {
$flows->push([
'from' => 'Revenus',
'to' => 'Non affecté',
'flow' => $unallocated,
'type' => 'unallocated',
]);
}
return view('livewire.sankey.index', [
'flows' => $flows->values(),
'totalRevenue' => $totalRevenue,
'totalOutflow' => $totalOutflow,
'unallocated' => $unallocated,
'hasData' => $totalRevenue > 0,
]);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Suivi;
use App\Enums\CategoryType;
use App\Models\BudgetLine;
use App\Models\Transaction;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public string $date;
public string $dateEffective;
public ?int $categoryId = null;
public string $amount = '';
public string $details = '';
public function mount(): void
{
$this->date = now()->toDateString();
$this->dateEffective = now()->toDateString();
}
public function addTransaction(): void
{
$this->validate([
'date' => ['required', 'date'],
'dateEffective' => ['required', 'date'],
'categoryId' => ['required', 'exists:categories,id'],
'amount' => ['required', 'numeric'],
'details' => ['nullable', 'string', 'max:255'],
]);
$household = auth()->user()->currentHousehold;
$category = $household->categories()->findOrFail($this->categoryId);
$household->transactions()->create([
'category_id' => $category->id,
'user_id' => auth()->id(),
'date' => $this->date,
'date_effective' => $this->dateEffective,
'amount' => $this->amount,
'details' => $this->details,
]);
$this->reset(['categoryId', 'amount', 'details']);
$this->date = now()->toDateString();
$this->dateEffective = now()->toDateString();
$this->dispatch('transaction-added');
}
public function deleteTransaction(int $transactionId): void
{
$household = auth()->user()->currentHousehold;
$transaction = $household->transactions()->findOrFail($transactionId);
$transaction->delete();
}
public function render()
{
$household = auth()->user()->currentHousehold;
$transactions = $household->transactions()
->with('category')
->orderByDesc('date')
->orderByDesc('id')
->get();
$chronological = $transactions->sortBy(['date', 'id'])->values();
$running = [];
$balance = 0.0;
foreach ($chronological as $t) {
$sign = $t->category->type === CategoryType::Revenu ? 1 : -1;
$balance += (float) $t->amount * $sign;
$running[$t->id] = $balance;
}
$year = (int) now()->year;
$month = (int) now()->month;
$revenueCategoryIds = $household->categories()->where('type', CategoryType::Revenu)->pluck('id');
$revenueBudget = (float) BudgetLine::whereIn('category_id', $revenueCategoryIds)
->where('year', $year)
->where(fn ($q) => $q->where('month', $month)->orWhereNull('month'))
->get()
->groupBy('category_id')
->map(fn ($lines) => $lines->firstWhere('month', $month)?->amount ?? $lines->firstWhere('month', null)?->amount ?? 0)
->sum();
$revenueTracked = (float) $household->transactions()
->whereHas('category', fn ($q) => $q->where('type', CategoryType::Revenu))
->whereYear('date_effective', $year)
->whereMonth('date_effective', $month)
->sum('amount');
$unaffected = max(0, $revenueBudget - $revenueTracked);
return view('livewire.suivi.index', [
'transactions' => $transactions,
'running' => $running,
'categories' => $household->categories()->orderBy('type')->orderBy('position')->get(),
'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
'nbrTotal' => $transactions->count(),
'lastEntry' => $transactions->first(),
'unaffected' => $unaffected,
]);
}
}