diff --git a/app/Filament/Resources/ExpenseResource.php b/app/Filament/Resources/ExpenseResource.php index 2c966aa..9d5ff48 100644 --- a/app/Filament/Resources/ExpenseResource.php +++ b/app/Filament/Resources/ExpenseResource.php @@ -8,6 +8,7 @@ use Closure; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Number; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; @@ -73,8 +74,18 @@ public static function table(Table $table): Table ->label(__('vat')) ->money('eur') ->fontFamily(FontFamily::Mono) - ->state(fn(Expense $record): float => $record->vat) - ->color(fn(string $state): string => $state == 0 ? 'gray' : 'normal') + ->state(fn(Expense $record): float => $record->deductibleVat) + ->color(fn(Expense $record): string => match (true) { + $record->taxable_ratio < 1 => 'warning', + $record->deductibleVat == 0 => 'gray', + default => 'normal', + }) + ->tooltip(fn(Expense $record): ?string => $record->taxable_ratio < 1 + ? __('taxableRatioTooltip', [ + 'ratio' => Number::percentage($record->taxable_ratio * 100), + 'vat' => Number::currency($record->vat, 'eur'), + ]) + : null) ->sortable(), TextColumn::make('quantity') ->label(__('quantity')) diff --git a/app/Filament/Widgets/MinorAssetsList.php b/app/Filament/Widgets/MinorAssetsList.php index 89c4400..bfd9f84 100644 --- a/app/Filament/Widgets/MinorAssetsList.php +++ b/app/Filament/Widgets/MinorAssetsList.php @@ -14,6 +14,7 @@ use Filament\Widgets\TableWidget; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Number; class MinorAssetsList extends TableWidget { @@ -60,8 +61,18 @@ public function table(Table $table): Table ->label(__('vat')) ->money('eur') ->fontFamily(FontFamily::Mono) - ->state(fn(Expense $record): float => $record->vat) - ->color(fn(string $state): string => $state == 0 ? 'gray' : 'normal') + ->state(fn(Expense $record): float => $record->deductibleVat) + ->color(fn(Expense $record): string => match (true) { + $record->taxable_ratio < 1 => 'warning', + $record->deductibleVat == 0 => 'gray', + default => 'normal', + }) + ->tooltip(fn(Expense $record): ?string => $record->taxable_ratio < 1 + ? __('taxableRatioTooltip', [ + 'ratio' => Number::percentage($record->taxable_ratio * 100), + 'vat' => Number::currency($record->vat, 'eur'), + ]) + : null) ->sortable(), TextColumn::make('net') ->label(__('net')) diff --git a/app/Models/Expense.php b/app/Models/Expense.php index 849ff7e..0ef5dc5 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -76,7 +76,7 @@ public static function ofTime(Carbon $d, TimeUnit $u, ?ExpenseCategory $category ->whereIn('category', $categories) ->get(); $net = array_sum($records->map(fn(self $r) => $r->deductibleNet)->toArray()); - $vat = array_sum($records->map(fn(self $r) => $r->vat)->toArray()); + $vat = array_sum($records->map(fn(self $r) => $r->deductibleVat)->toArray()); return [$net, $vat]; } @@ -136,4 +136,12 @@ protected function deductibleNet(): Attribute { return Attribute::make(fn(): float => round($this->net * $this->taxable_ratio, 2)); } + + /** + * Vat amount of this expense that is actually deductible, pro-rated by taxable_ratio + */ + protected function deductibleVat(): Attribute + { + return Attribute::make(fn(): float => round($this->vat * $this->taxable_ratio, 2)); + } } diff --git a/lang/de.json b/lang/de.json index 0974a9a..4747e4f 100644 --- a/lang/de.json +++ b/lang/de.json @@ -215,6 +215,7 @@ "sumWorkingHoursPerYear": "Summe bezahlter Arbeitsstunden pro Jahr", "taxable": "Versteuert", "taxableRatio": "Abzugsfähiger Anteil", + "taxableRatioTooltip": "Anteilig :ratio von :vat USt.", "taxes": "Steuern", "taxOffice": "Finanzamt", "taxReport": "Steuererklärung", diff --git a/lang/en.json b/lang/en.json index dd29642..77613d4 100644 --- a/lang/en.json +++ b/lang/en.json @@ -215,6 +215,7 @@ "sumWorkingHoursPerYear": "Sum of paid working hours per year", "taxable": "Taxable", "taxableRatio": "Deductible share", + "taxableRatioTooltip": "Pro-rated to :ratio of :vat VAT", "taxes": "Taxes", "taxOffice": "Tax office", "taxReport": "Tax return", diff --git a/tests/Feature/ExpenseResourceTest.php b/tests/Feature/ExpenseResourceTest.php index 563e4d6..46f60f2 100644 --- a/tests/Feature/ExpenseResourceTest.php +++ b/tests/Feature/ExpenseResourceTest.php @@ -49,6 +49,23 @@ public function it_lists_expenses_in_the_table(): void ->assertCanSeeTableRecords($expenses); } + #[Test] + public function it_shows_the_pro_rated_vat_in_the_table(): void + { + $this->actingAs(User::factory()->create()); + $expense = Expense::factory()->create([ + 'price' => 100, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + 'taxable_ratio' => 0.5, + ]); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->loadTable() + ->assertTableColumnStateSet('vat', 7.99, $expense); + } + #[Test] public function it_creates_an_expense(): void { diff --git a/tests/Feature/MinorAssetsListTest.php b/tests/Feature/MinorAssetsListTest.php index d5ded65..dcb221d 100644 --- a/tests/Feature/MinorAssetsListTest.php +++ b/tests/Feature/MinorAssetsListTest.php @@ -127,6 +127,26 @@ public function it_only_lists_minor_assets_expenses_from_the_selected_year(): vo $this->assertTrue($records->contains($inYear)); } + #[Test] + public function it_shows_the_pro_rated_vat_in_the_table(): void + { + $this->actingAs(User::factory()->create()); + $year = now()->year - 1; + $expense = Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::MinorAssets, + 'price' => 500, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + 'taxable_ratio' => 0.5, + ]); + + Livewire::test(MinorAssetsList::class) + ->loadTable() + ->assertTableColumnStateSet('vat', round($expense->vat * 0.5, 2), $expense); + } + #[Test] public function it_hides_the_bill_download_until_a_document_exists(): void { diff --git a/tests/Feature/TaxOverviewTest.php b/tests/Feature/TaxOverviewTest.php index a58c20b..85751ef 100644 --- a/tests/Feature/TaxOverviewTest.php +++ b/tests/Feature/TaxOverviewTest.php @@ -60,6 +60,26 @@ public function it_summarizes_taxable_net_and_vat_for_the_current_month(): void $this->assertSame(174.03, $record['totalVat']); } + #[Test] + public function it_pro_rates_the_expense_vat_by_taxable_ratio(): void + { + Expense::factory()->create([ + 'expended_at' => now(), + 'category' => ExpenseCategory::Good, + 'price' => 100, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + 'taxable_ratio' => 0.5, + ]); + + $widget = new TaxOverview(); + $record = $widget->getTableRecords()->first(); + + $this->assertSame(7.99, $record['vatExpenses']); + $this->assertSame(-7.99, $record['totalVat']); + } + #[Test] public function it_disables_the_last_advance_vat_action_when_it_already_exists(): void { diff --git a/tests/Feature/TaxReturnFormInputTest.php b/tests/Feature/TaxReturnFormInputTest.php index c5cb50b..b9d1bed 100644 --- a/tests/Feature/TaxReturnFormInputTest.php +++ b/tests/Feature/TaxReturnFormInputTest.php @@ -129,6 +129,29 @@ public function it_computes_the_tax_return_lines_for_the_selected_year(): void $this->assertSame(161.0, $records[1]['value']); // itr1 - taxable profit, rounded to whole euros for Elster (raw value is 160.76) } + #[Test] + public function it_pro_rates_expense_vat_by_taxable_ratio(): void + { + $year = now()->year - 1; + + Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::Good, + 'price' => 100, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + 'taxable_ratio' => 0.5, + ]); + + $widget = new TaxReturnFormInput(); + $widget->filter = $year; + $records = $widget->getTableRecords()->keyBy('__key'); + + $this->assertSame(7.99, $records[11]['value']); // rsc57 - vat expended, pro-rated by taxable_ratio + $this->assertSame(-7.99, $records[14]['value']); // vr118 - vat payable, must reflect the pro-rated vat + } + #[Test] public function it_rounds_the_final_profit_line_to_cents_despite_floating_point_noise(): void { diff --git a/tests/Unit/Models/ExpenseTest.php b/tests/Unit/Models/ExpenseTest.php index 4d29d53..59ffab4 100644 --- a/tests/Unit/Models/ExpenseTest.php +++ b/tests/Unit/Models/ExpenseTest.php @@ -82,6 +82,30 @@ expect($expense->deductibleNet)->toBe($expense->net); }); +it('pro-rates the deductible vat amount by taxable_ratio', function () { + $expense = Expense::factory()->create([ + 'price' => 100, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + 'taxable_ratio' => 0.5, + ]); + + expect($expense->vat)->toBe(15.97) + ->and($expense->deductibleVat)->toBe(7.99); +}); + +it('leaves the deductible vat amount unchanged when taxable_ratio is 1', function () { + $expense = Expense::factory()->create([ + 'price' => 100, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + ])->refresh(); + + expect($expense->deductibleVat)->toBe($expense->vat); +}); + it('calculates gross, net and vat amounts based on price, quantity and vat rate', function () { $expense = Expense::factory()->create([ 'price' => 100, @@ -201,7 +225,7 @@ ->and($vat)->toBe(0.0); }); -it('sums the deductible net amount honoring taxable_ratio within a time range', function () { +it('sums the deductible net and vat amounts honoring taxable_ratio within a time range', function () { $month = Carbon::parse('2026-03-15'); Expense::factory()->create([ @@ -214,7 +238,8 @@ 'category' => ExpenseCategory::Good, ]); - [$net] = Expense::ofTime($month, TimeUnit::MONTH, ExpenseCategory::Good); + [$net, $vat] = Expense::ofTime($month, TimeUnit::MONTH, ExpenseCategory::Good); - expect($net)->toBe(42.02); + expect($net)->toBe(42.02) + ->and($vat)->toBe(7.99); });