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
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response
->assertOk()
->assertSeeVolt('pages.auth.login');
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$component = Volt::test('pages.auth.login')
->set('form.email', $user->email)
->set('form.password', 'password');
$component->call('login');
$component
->assertHasNoErrors()
->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$component = Volt::test('pages.auth.login')
->set('form.email', $user->email)
->set('form.password', 'wrong-password');
$component->call('login');
$component
->assertHasErrors()
->assertNoRedirect();
$this->assertGuest();
}
public function test_navigation_menu_can_be_rendered(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get('/dashboard');
$response
->assertOk()
->assertSeeVolt('layout.navigation');
}
public function test_users_can_logout(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('layout.navigation');
$component->call('logout');
$component
->assertHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
}
}
@@ -0,0 +1,60 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered(): void
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response
->assertSeeVolt('pages.auth.verify-email')
->assertStatus(200);
}
public function test_email_can_be_verified(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}
@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response
->assertSeeVolt('pages.auth.confirm-password')
->assertStatus(200);
}
public function test_password_can_be_confirmed(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('pages.auth.confirm-password')
->set('password', 'password');
$component->call('confirmPassword');
$component
->assertRedirect('/dashboard')
->assertHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('pages.auth.confirm-password')
->set('password', 'wrong-password');
$component->call('confirmPassword');
$component
->assertNoRedirect()
->assertHasErrors('password');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Livewire\Volt\Volt;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get('/forgot-password');
$response
->assertSeeVolt('pages.auth.forgot-password')
->assertStatus(200);
}
public function test_reset_password_link_can_be_requested(): void
{
Notification::fake();
$user = User::factory()->create();
Volt::test('pages.auth.forgot-password')
->set('email', $user->email)
->call('sendPasswordResetLink');
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
$user = User::factory()->create();
Volt::test('pages.auth.forgot-password')
->set('email', $user->email)
->call('sendPasswordResetLink');
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response
->assertSeeVolt('pages.auth.reset-password')
->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token(): void
{
Notification::fake();
$user = User::factory()->create();
Volt::test('pages.auth.forgot-password')
->set('email', $user->email)
->call('sendPasswordResetLink');
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$component = Volt::test('pages.auth.reset-password', ['token' => $notification->token])
->set('email', $user->email)
->set('password', 'password')
->set('password_confirmation', 'password');
$component->call('resetPassword');
$component
->assertRedirect('/login')
->assertHasNoErrors();
return true;
});
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Livewire\Volt\Volt;
use Tests\TestCase;
class PasswordUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('profile.update-password-form')
->set('current_password', 'password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$component
->assertHasNoErrors()
->assertNoRedirect();
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('profile.update-password-form')
->set('current_password', 'wrong-password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$component
->assertHasErrors(['current_password'])
->assertNoRedirect();
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
$response = $this->get('/register');
$response
->assertOk()
->assertSeeVolt('pages.auth.register');
}
public function test_new_users_can_register(): void
{
$component = Volt::test('pages.auth.register')
->set('name', 'Test User')
->set('email', 'test@example.com')
->set('password', 'password')
->set('password_confirmation', 'password');
$component->call('register');
$component->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticated();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
use App\Livewire\Categories\Index;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
it('lets a member add a category to their household', function (): void {
$user = User::factory()->create();
Livewire::actingAs($user)
->test(Index::class)
->set('newName.depense', 'Loyer')
->call('add', 'depense')
->assertHasNoErrors();
expect($user->currentHousehold->categories()->where('name', 'Loyer')->exists())->toBeTrue();
});
it('prevents a user from editing another household categorie', function (): void {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$category = $owner->currentHousehold->categories()->create([
'name' => 'Courses',
'type' => 'depense',
'position' => 1,
]);
Livewire::actingAs($intruder)
->test(Index::class)
->call('startEdit', $category->id)
->assertForbidden();
});
it('deletes a category', function (): void {
$user = User::factory()->create();
$category = $user->currentHousehold->categories()->create([
'name' => 'Assurances',
'type' => 'depense',
'position' => 1,
]);
Livewire::actingAs($user)
->test(Index::class)
->call('delete', $category->id);
expect(Category::find($category->id))->toBeNull();
});
+76
View File
@@ -0,0 +1,76 @@
<?php
use App\Livewire\Dashboard\Index;
use App\Models\User;
use Livewire\Livewire;
it('shows budget vs tracked totals for the selected year', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 1200]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $user->id,
'date' => '2026-03-01', 'date_effective' => '2026-03-01', 'amount' => 1200,
]);
$component = Livewire::actingAs($user)->test(Index::class)->set('year', 2026)->set('period', 'year');
$sections = $component->viewData('sections');
expect($sections['depense']['totals']['budget'])->toBe(14400.0); // 1200 * 12
expect($sections['depense']['totals']['tracked'])->toBe(1200.0);
});
it('defaults to the current month instead of the full year', function (): void {
$user = User::factory()->create();
$component = Livewire::actingAs($user)->test(Index::class);
expect($component->get('period'))->toBe((string) now()->month);
expect($component->get('year'))->toBe((int) now()->year);
});
it('filters tracked and budget to a single month when a period is selected', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$category = $household->categories()->create(['name' => 'Assurances', 'type' => 'depense', 'position' => 1]);
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 189.89]);
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => 8, 'amount' => 270]);
$component = Livewire::actingAs($user)->test(Index::class)
->set('year', 2026)
->set('period', '8');
$sections = $component->viewData('sections');
expect($sections['depense']['totals']['budget'])->toBe(270.0);
});
it('excludes zero-tracked categories from chart data and caps it at 6', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$tracked = collect();
for ($i = 1; $i <= 8; $i++) {
$category = $household->categories()->create(['name' => "Cat{$i}", 'type' => 'depense', 'position' => $i]);
$household->transactions()->create([
'category_id' => $category->id, 'user_id' => $user->id,
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => $i * 10,
]);
$tracked->push($category);
}
$zero = $household->categories()->create(['name' => 'CatZero', 'type' => 'depense', 'position' => 99]);
$component = Livewire::actingAs($user)->test(Index::class)->set('year', 2026)->set('period', 'year');
$chartData = $component->viewData('chartData');
expect($chartData['depense']['labels'])->toHaveCount(6);
expect($chartData['depense']['labels'])->not->toContain('CatZero');
expect($chartData['depense']['labels']->first())->toBe('Cat8'); // highest tracked first
});
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Models\Household;
use App\Models\HouseholdInvite;
use App\Models\User;
use Livewire\Volt\Volt;
it('creates a household automatically on registration', function (): void {
Volt::test('pages.auth.register')
->set('name', 'Jeremy')
->set('email', 'jeremy@example.com')
->set('password', 'password')
->set('password_confirmation', 'password')
->call('register');
$user = User::where('email', 'jeremy@example.com')->first();
expect($user)->not->toBeNull();
expect($user->current_household_id)->not->toBeNull();
expect($user->households()->wherePivot('role', 'owner')->count())->toBe(1);
});
it('allows an owner to invite a member who joins on registration', function (): void {
$owner = User::factory()->create();
$household = Household::create(['name' => 'Foyer Test', 'owner_id' => $owner->id]);
$owner->households()->attach($household->id, ['role' => 'owner']);
$owner->forceFill(['current_household_id' => $household->id])->save();
$invite = HouseholdInvite::create([
'household_id' => $household->id,
'invited_by' => $owner->id,
'email' => 'member@example.com',
'token' => 'test-token-123',
'role' => 'member',
'expires_at' => now()->addDays(7),
]);
Volt::test('pages.auth.register')
->set('invite', $invite->token)
->set('name', 'Member')
->set('email', 'member@example.com')
->set('password', 'password')
->set('password_confirmation', 'password')
->call('register');
$member = User::where('email', 'member@example.com')->first();
expect($member->current_household_id)->toBe($household->id);
expect($household->fresh()->members()->wherePivot('role', 'member')->where('users.id', $member->id)->exists())->toBeTrue();
expect($invite->fresh()->accepted_at)->not->toBeNull();
});
it('isolates categories between households', function (): void {
$userA = User::factory()->create();
$householdA = Household::create(['name' => 'A', 'owner_id' => $userA->id]);
$userA->households()->attach($householdA->id, ['role' => 'owner']);
$userB = User::factory()->create();
$householdB = Household::create(['name' => 'B', 'owner_id' => $userB->id]);
$userB->households()->attach($householdB->id, ['role' => 'owner']);
$householdA->categories()->create(['name' => 'Loyer', 'type' => 'depense']);
expect($householdB->categories()->count())->toBe(0);
expect($householdA->categories()->count())->toBe(1);
});
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Livewire\Plan\Index;
use App\Models\User;
use Livewire\Livewire;
it('sets a default amount that applies to all months without override', function (): void {
$user = User::factory()->create();
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($user)
->test(Index::class)
->call('setYear', 2026)
->call('updateAmount', $category->id, null, '1200');
$line = $category->budgetLines()->where('year', 2026)->whereNull('month')->first();
expect((float) $line->amount)->toBe(1200.0);
});
it('overrides a single month while others keep the default', function (): void {
$user = User::factory()->create();
$category = $user->currentHousehold->categories()->create(['name' => 'Assurances', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($user)
->test(Index::class)
->call('setYear', 2026)
->call('updateAmount', $category->id, null, '189.89')
->call('updateAmount', $category->id, 8, '270');
expect((float) $category->budgetLines()->where('year', 2026)->whereNull('month')->first()->amount)->toBe(189.89);
expect((float) $category->budgetLines()->where('year', 2026)->where('month', 8)->first()->amount)->toBe(270.0);
expect($category->budgetLines()->where('year', 2026)->where('month', 9)->exists())->toBeFalse();
});
it('rejects updating a category from another household', function (): void {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$category = $owner->currentHousehold->categories()->create(['name' => 'Courses', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($intruder)
->test(Index::class)
->call('updateAmount', $category->id, null, '500')
->assertForbidden();
});
it('computes remaining revenue to allocate per month after depenses and epargne', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$epargne = $household->categories()->create(['name' => 'Vacances', 'type' => 'epargne', 'position' => 1]);
$component = Livewire::actingAs($user)->test(Index::class)->call('setYear', 2026);
$component->call('updateAmount', $revenu->id, null, '2900');
$component->call('updateAmount', $depense->id, null, '1200');
$component->call('updateAmount', $epargne->id, null, '200');
$component->call('updateAmount', $depense->id, 8, '1500');
$sections = $component->viewData('sections');
expect($sections['revenu']['remaining'][1])->toBe(1500.0); // 2900 - 1200 - 200
expect($sections['revenu']['remaining'][8])->toBe(1200.0); // 2900 - 1500 - 200
});
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Tests\TestCase;
class ProfileTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/profile');
$response
->assertOk()
->assertSeeVolt('profile.update-profile-information-form')
->assertSeeVolt('profile.update-password-form')
->assertSeeVolt('profile.delete-user-form');
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('profile.update-profile-information-form')
->set('name', 'Test User')
->set('email', 'test@example.com')
->call('updateProfileInformation');
$component
->assertHasNoErrors()
->assertNoRedirect();
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('profile.update-profile-information-form')
->set('name', 'Test User')
->set('email', $user->email)
->call('updateProfileInformation');
$component
->assertHasNoErrors()
->assertNoRedirect();
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('profile.delete-user-form')
->set('password', 'password')
->call('deleteUser');
$component
->assertHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('profile.delete-user-form')
->set('password', 'wrong-password')
->call('deleteUser');
$component
->assertHasErrors('password')
->assertNoRedirect();
$this->assertNotNull($user->fresh());
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
use App\Livewire\Sankey\Index;
use App\Models\User;
use Livewire\Livewire;
it('builds flows from revenue to depense and epargne categories', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$epargne = $household->categories()->create(['name' => 'Vacances', 'type' => 'epargne', 'position' => 1]);
$household->transactions()->create([
'category_id' => $revenu->id, 'user_id' => $user->id,
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2000,
]);
$household->transactions()->create([
'category_id' => $depense->id, 'user_id' => $user->id,
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 1200,
]);
$household->transactions()->create([
'category_id' => $epargne->id, 'user_id' => $user->id,
'date' => '2026-01-03', 'date_effective' => '2026-01-03', 'amount' => 300,
]);
$component = Livewire::actingAs($user)->test(Index::class)
->set('year', 2026)
->set('period', 'year');
$flows = $component->viewData('flows');
expect($flows)->toHaveCount(3); // Loyer, Vacances, Non affecté
expect($flows->firstWhere('to', 'Loyer')['flow'])->toBe(1200.0);
expect($flows->firstWhere('to', 'Vacances')['flow'])->toBe(300.0);
expect($flows->firstWhere('to', 'Non affecté')['flow'])->toBe(500.0);
expect($component->viewData('totalRevenue'))->toBe(2000.0);
expect($component->viewData('hasData'))->toBeTrue();
});
it('omits the unallocated flow when all revenue is spent or saved', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$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' => $user->id,
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 1000,
]);
$household->transactions()->create([
'category_id' => $depense->id, 'user_id' => $user->id,
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 1000,
]);
$component = Livewire::actingAs($user)->test(Index::class)
->set('year', 2026)
->set('period', 'year');
$flows = $component->viewData('flows');
expect($flows)->toHaveCount(1);
expect($flows->firstWhere('to', 'Non affecté'))->toBeNull();
});
it('shows no data state when there is no tracked revenue', function (): void {
$user = User::factory()->create();
$component = Livewire::actingAs($user)->test(Index::class);
expect($component->viewData('hasData'))->toBeFalse();
expect($component->viewData('flows'))->toHaveCount(0);
});
it('does not leak another household categories into the flows', function (): void {
$ownerA = User::factory()->create();
$ownerB = User::factory()->create();
$revenuA = $ownerA->currentHousehold->categories()->create(['name' => 'Salaire A', 'type' => 'revenu', 'position' => 1]);
$depenseB = $ownerB->currentHousehold->categories()->create(['name' => 'Loyer B', 'type' => 'depense', 'position' => 1]);
$ownerA->currentHousehold->transactions()->create([
'category_id' => $revenuA->id, 'user_id' => $ownerA->id,
'date' => now(), 'date_effective' => now(), 'amount' => 1000,
]);
$component = Livewire::actingAs($ownerA)->test(Index::class);
expect($component->viewData('flows')->pluck('to'))->not->toContain('Loyer B');
});
+114
View File
@@ -0,0 +1,114 @@
<?php
use App\Livewire\Suivi\Index;
use App\Models\User;
use Livewire\Livewire;
it('records a transaction for the current household', function (): void {
$user = User::factory()->create();
$category = $user->currentHousehold->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
Livewire::actingAs($user)
->test(Index::class)
->set('categoryId', $category->id)
->set('amount', '2900')
->set('details', 'Paie juillet')
->call('addTransaction')
->assertHasNoErrors();
expect($user->currentHousehold->transactions()->count())->toBe(1);
expect((float) $user->currentHousehold->transactions()->first()->amount)->toBe(2900.0);
});
it('computes a running balance that subtracts depenses and adds revenus', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$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' => $user->id,
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2900,
]);
$household->transactions()->create([
'category_id' => $depense->id, 'user_id' => $user->id,
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 1200,
]);
$component = Livewire::actingAs($user)->test(Index::class);
$running = $component->viewData('running');
$transactions = $component->viewData('transactions');
$ordered = $transactions->sortBy('date')->values();
expect($running[$ordered[0]->id])->toBe(2900.0);
expect($running[$ordered[1]->id])->toBe(1700.0);
});
it('subtracts epargne contributions from the running balance', function (): void {
$user = User::factory()->create();
$household = $user->currentHousehold;
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
$epargne = $household->categories()->create(['name' => 'Vacances', 'type' => 'epargne', 'position' => 1]);
$household->transactions()->create([
'category_id' => $revenu->id, 'user_id' => $user->id,
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2900,
]);
$household->transactions()->create([
'category_id' => $epargne->id, 'user_id' => $user->id,
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 200,
]);
$component = Livewire::actingAs($user)->test(Index::class);
$running = $component->viewData('running');
$transactions = $component->viewData('transactions');
$ordered = $transactions->sortBy('date')->values();
expect($running[$ordered[0]->id])->toBe(2900.0);
expect($running[$ordered[1]->id])->toBe(2700.0);
});
it('dispatches a transaction-added event after successfully adding a transaction', function (): void {
$user = User::factory()->create();
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
Livewire::actingAs($user)
->test(Index::class)
->set('categoryId', $category->id)
->set('amount', '1200')
->call('addTransaction')
->assertDispatched('transaction-added');
});
it('resets the form to defaults after adding a transaction', function (): void {
$user = User::factory()->create();
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
$component = Livewire::actingAs($user)
->test(Index::class)
->set('categoryId', $category->id)
->set('amount', '1200')
->set('details', 'Août')
->call('addTransaction');
expect($component->get('categoryId'))->toBeNull();
expect($component->get('amount'))->toBe('');
expect($component->get('details'))->toBe('');
});
it('does not show another household transactions', function (): void {
$ownerA = User::factory()->create();
$ownerB = User::factory()->create();
$categoryA = $ownerA->currentHousehold->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
$ownerA->currentHousehold->transactions()->create([
'category_id' => $categoryA->id, 'user_id' => $ownerA->id,
'date' => now(), 'date_effective' => now(), 'amount' => 1000,
]);
$component = Livewire::actingAs($ownerB)->test(Index::class);
expect($component->viewData('transactions'))->toHaveCount(0);
});