Desktop reuses the add-form with a Modifier link per row; mobile gets a "Dernières opérations" list under the quick-entry form since no table exists there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
455 lines
20 KiB
PHP
455 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Suivi\Index;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
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('prefills the form when starting to edit a transaction', function (): void {
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$transaction = $household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $user->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-06', 'amount' => 1200, 'details' => 'Loyer mars',
|
|
]);
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class)->call('startEditTransaction', $transaction->id);
|
|
|
|
expect($component->get('editingTransactionId'))->toBe($transaction->id);
|
|
expect($component->get('categoryId'))->toBe($category->id);
|
|
expect($component->get('amount'))->toBe('1200.00');
|
|
expect($component->get('details'))->toBe('Loyer mars');
|
|
expect($component->get('date'))->toBe('2026-03-05');
|
|
expect($component->get('dateEffective'))->toBe('2026-03-06');
|
|
});
|
|
|
|
it('updates an existing transaction instead of creating a new one', function (): void {
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$newCategory = $household->categories()->create(['name' => 'Courses', 'type' => 'depense', 'position' => 2]);
|
|
$transaction = $household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $user->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 1200, 'details' => 'Loyer',
|
|
]);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(Index::class)
|
|
->call('startEditTransaction', $transaction->id)
|
|
->set('categoryId', $newCategory->id)
|
|
->set('amount', '150')
|
|
->set('details', 'Courses rectifiées')
|
|
->call('addTransaction')
|
|
->assertDispatched('transaction-updated');
|
|
|
|
expect($household->transactions()->count())->toBe(1);
|
|
|
|
$transaction->refresh();
|
|
expect($transaction->category_id)->toBe($newCategory->id);
|
|
expect((float) $transaction->amount)->toBe(150.0);
|
|
expect($transaction->details)->toBe('Courses rectifiées');
|
|
});
|
|
|
|
it('cancels editing and resets the form without touching the transaction', function (): void {
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$transaction = $household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $user->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 1200, 'details' => 'Loyer',
|
|
]);
|
|
|
|
$component = Livewire::actingAs($user)
|
|
->test(Index::class)
|
|
->call('startEditTransaction', $transaction->id)
|
|
->call('cancelEditTransaction');
|
|
|
|
expect($component->get('editingTransactionId'))->toBeNull();
|
|
expect($component->get('categoryId'))->toBeNull();
|
|
expect($component->get('amount'))->toBe('');
|
|
|
|
$transaction->refresh();
|
|
expect((float) $transaction->amount)->toBe(1200.0);
|
|
});
|
|
|
|
it('does not allow editing a transaction belonging to another household', function (): void {
|
|
$owner = User::factory()->create();
|
|
$intruder = User::factory()->create();
|
|
$category = $owner->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$transaction = $owner->currentHousehold->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $owner->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 1200,
|
|
]);
|
|
|
|
expect(fn () => Livewire::actingAs($intruder)
|
|
->test(Index::class)
|
|
->call('startEditTransaction', $transaction->id)
|
|
)->toThrow(ModelNotFoundException::class);
|
|
});
|
|
|
|
it('lists active recurring transactions due this month as pending', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
$due = $household->recurringTransactions()->create([
|
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
|
]);
|
|
$notYetDue = $household->recurringTransactions()->create([
|
|
'category_id' => $category->id, 'name' => 'Internet', 'amount' => 40, 'day_of_month' => 20,
|
|
]);
|
|
$inactive = $household->recurringTransactions()->create([
|
|
'category_id' => $category->id, 'name' => 'Ancien', 'amount' => 10, 'day_of_month' => 1, 'active' => false,
|
|
]);
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
$pending = $component->viewData('pendingRecurrences');
|
|
|
|
expect($pending->pluck('id'))->toContain($due->id);
|
|
expect($pending->pluck('id'))->not->toContain($notYetDue->id);
|
|
expect($pending->pluck('id'))->not->toContain($inactive->id);
|
|
});
|
|
|
|
it('confirming a recurring transaction creates a transaction and stops it reappearing', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$recurring = $household->recurringTransactions()->create([
|
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
|
]);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(Index::class)
|
|
->call('confirmRecurrence', $recurring->id, '1250')
|
|
->assertDispatched('transaction-added');
|
|
|
|
$transaction = $household->transactions()->where('recurring_transaction_id', $recurring->id)->first();
|
|
|
|
expect($transaction)->not->toBeNull();
|
|
expect((float) $transaction->amount)->toBe(1250.0);
|
|
expect($transaction->details)->toBe('Loyer');
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
expect($component->viewData('pendingRecurrences'))->toHaveCount(0);
|
|
});
|
|
|
|
it('dismissing a recurring transaction hides it without creating a transaction', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$recurring = $household->recurringTransactions()->create([
|
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
|
]);
|
|
|
|
Livewire::actingAs($user)->test(Index::class)->call('dismissRecurrence', $recurring->id);
|
|
|
|
expect($household->transactions()->count())->toBe(0);
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
expect($component->viewData('pendingRecurrences'))->toHaveCount(0);
|
|
});
|
|
|
|
it('shows the recurrence again the following month after being confirmed', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
$recurring = $household->recurringTransactions()->create([
|
|
'category_id' => $category->id, 'name' => 'Loyer', 'amount' => 1200, 'day_of_month' => 5,
|
|
]);
|
|
|
|
Livewire::actingAs($user)->test(Index::class)->call('confirmRecurrence', $recurring->id, '1200');
|
|
|
|
$this->travelTo(now()->setDate(2026, 4, 10));
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
expect($component->viewData('pendingRecurrences')->pluck('id'))->toContain($recurring->id);
|
|
});
|
|
|
|
it('computes the percentage and remaining budget already tracked for a category this month', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$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' => 1000]);
|
|
$household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $user->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 600,
|
|
]);
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
$progress = $component->viewData('categoryProgress')[$category->id];
|
|
|
|
expect($progress['pct'])->toBe(60.0);
|
|
expect($progress['remaining'])->toBe(400.0);
|
|
});
|
|
|
|
it('caps the category progress percentage at 100 and floors remaining at 0', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$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' => 1000]);
|
|
$household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $user->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 1500,
|
|
]);
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
$progress = $component->viewData('categoryProgress')[$category->id];
|
|
|
|
expect($progress['pct'])->toBe(100);
|
|
expect($progress['remaining'])->toBe(0);
|
|
});
|
|
|
|
it('returns no progress for a category without a budget defined', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
$component = Livewire::actingAs($user)->test(Index::class);
|
|
|
|
expect($component->viewData('categoryProgress')[$category->id])->toBeNull();
|
|
});
|
|
|
|
it('records who paid a transaction and defaults to common when none is selected', function (): void {
|
|
$user = User::factory()->create();
|
|
$household = $user->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(Index::class)
|
|
->set('categoryId', $category->id)
|
|
->set('amount', '1200')
|
|
->call('addTransaction');
|
|
|
|
$transaction = $household->transactions()->first();
|
|
|
|
expect($transaction->paid_by_user_id)->toBeNull();
|
|
});
|
|
|
|
it('assigns a transaction to a household member when selected', function (): void {
|
|
$owner = User::factory()->create();
|
|
$member = User::factory()->create();
|
|
$household = $owner->currentHousehold;
|
|
$member->households()->attach($household->id, ['role' => 'member']);
|
|
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
Livewire::actingAs($owner)
|
|
->test(Index::class)
|
|
->set('categoryId', $category->id)
|
|
->set('amount', '1200')
|
|
->set('paidByUserId', (string) $member->id)
|
|
->call('addTransaction');
|
|
|
|
$transaction = $household->transactions()->first();
|
|
|
|
expect($transaction->paid_by_user_id)->toBe($member->id);
|
|
});
|
|
|
|
it('ignores a paid-by user id that does not belong to the household', function (): void {
|
|
$owner = User::factory()->create();
|
|
$outsider = User::factory()->create();
|
|
$household = $owner->currentHousehold;
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
Livewire::actingAs($owner)
|
|
->test(Index::class)
|
|
->set('categoryId', $category->id)
|
|
->set('amount', '1200')
|
|
->set('paidByUserId', (string) $outsider->id)
|
|
->call('addTransaction');
|
|
|
|
$transaction = $household->transactions()->first();
|
|
|
|
expect($transaction->paid_by_user_id)->toBeNull();
|
|
});
|
|
|
|
it('filters transactions by who paid without affecting the running balance', function (): void {
|
|
$owner = User::factory()->create();
|
|
$member = User::factory()->create();
|
|
$household = $owner->currentHousehold;
|
|
$member->households()->attach($household->id, ['role' => 'member']);
|
|
|
|
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
|
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
$household->transactions()->create([
|
|
'category_id' => $revenu->id, 'user_id' => $owner->id,
|
|
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2000,
|
|
]);
|
|
$ownerTx = $household->transactions()->create([
|
|
'category_id' => $depense->id, 'user_id' => $owner->id, 'paid_by_user_id' => $owner->id,
|
|
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 500,
|
|
]);
|
|
$memberTx = $household->transactions()->create([
|
|
'category_id' => $depense->id, 'user_id' => $owner->id, 'paid_by_user_id' => $member->id,
|
|
'date' => '2026-01-03', 'date_effective' => '2026-01-03', 'amount' => 300,
|
|
]);
|
|
|
|
$component = Livewire::actingAs($owner)->test(Index::class)->set('filterPaidBy', (string) $member->id);
|
|
|
|
$shown = $component->viewData('transactions');
|
|
expect($shown->pluck('id'))->toContain($memberTx->id);
|
|
expect($shown->pluck('id'))->not->toContain($ownerTx->id);
|
|
|
|
// Le solde reste calculé sur toutes les transactions malgré le filtre d'affichage.
|
|
$running = $component->viewData('running');
|
|
expect($running[$memberTx->id])->toBe(1200.0); // 2000 - 500 - 300
|
|
});
|
|
|
|
it('summarizes this month depenses by payer including common', function (): void {
|
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
|
|
|
$owner = User::factory()->create();
|
|
$member = User::factory()->create();
|
|
$household = $owner->currentHousehold;
|
|
$member->households()->attach($household->id, ['role' => 'member']);
|
|
|
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
|
|
|
$household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $owner->id, 'paid_by_user_id' => $owner->id,
|
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 500,
|
|
]);
|
|
$household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $owner->id, 'paid_by_user_id' => $member->id,
|
|
'date' => '2026-03-06', 'date_effective' => '2026-03-06', 'amount' => 300,
|
|
]);
|
|
$household->transactions()->create([
|
|
'category_id' => $category->id, 'user_id' => $owner->id, 'paid_by_user_id' => null,
|
|
'date' => '2026-03-07', 'date_effective' => '2026-03-07', 'amount' => 100,
|
|
]);
|
|
|
|
$component = Livewire::actingAs($owner)->test(Index::class);
|
|
$summary = $component->viewData('paidBySummary')->keyBy('label');
|
|
|
|
expect($summary[$owner->name]['total'])->toBe(500.0);
|
|
expect($summary[$member->name]['total'])->toBe(300.0);
|
|
expect($summary['Commun']['total'])->toBe(100.0);
|
|
});
|
|
|
|
it('does not show another household transactions', function (): void {
|
|
$ownerA = User::factory()->create();
|
|
$ownerB = User::factory()->create();
|
|
|
|
$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);
|
|
});
|