diff --git a/.gitignore b/.gitignore index 87f41df..5ceffa7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /public/hot /public/storage /storage/*.key +/storage/framework/* /vendor /tests/coverage .env diff --git a/app/Filament/Resources/ExpenseResource.php b/app/Filament/Resources/ExpenseResource.php index 392a0d1..3b4d727 100644 --- a/app/Filament/Resources/ExpenseResource.php +++ b/app/Filament/Resources/ExpenseResource.php @@ -205,7 +205,7 @@ public static function formFields(int $columns = 12, bool $useSection = true): a $net = round(round((float) $value * $quantity, 2) / (1 + $rate), 2); $max = config('business.minor_assets.max_net'); - if ($net >= $max) { + if ($net > $max) { $fail(__('minorAssetsNetLimitExceeded', ['max' => $max])); } }, diff --git a/app/Filament/Widgets/MinorAssetsList.php b/app/Filament/Widgets/MinorAssetsList.php index 398af84..a18524c 100644 --- a/app/Filament/Widgets/MinorAssetsList.php +++ b/app/Filament/Widgets/MinorAssetsList.php @@ -88,7 +88,7 @@ public function getTableRecords(): Collection ->where('expended_at', '>=', $dt->startOfYear()->toDateString()) ->where('expended_at', '<=', $dt->endOfYear()->toDateString()) ->get() - ->filter(fn(Expense $expense): bool => $expense->net >= $minNet && $expense->net < $maxNet) + ->filter(fn(Expense $expense): bool => $expense->net > $minNet && $expense->net <= $maxNet) ->sortByDesc('expended_at') ->values(); } diff --git a/app/Filament/Widgets/MonthlyIncomeChart.php b/app/Filament/Widgets/MonthlyIncomeChart.php index 0a561f4..1a77dc8 100644 --- a/app/Filament/Widgets/MonthlyIncomeChart.php +++ b/app/Filament/Widgets/MonthlyIncomeChart.php @@ -51,7 +51,7 @@ protected function getData(): array break; } foreach ($invoices as $obj) { - if (CarbonPeriod::create($date, $period[$i + 1])->contains($obj->paid_at)) { + if (CarbonPeriod::create($date, $period[$i + 1])->excludeEndDate()->contains($obj->paid_at)) { $invoiceData[$i] += match ($this->filter) { 'net' => $obj->net, 'gross' => $obj->gross, @@ -61,12 +61,13 @@ protected function getData(): array if ($this->filter === 'net') { foreach ($taxes as $obj) { // Shift yearly income taxes post pays to the year before - if ($i > 0 && !Str($obj->description)->contains('EStVA') && CarbonPeriod::create($date, $period[$i + 1])->contains(Carbon::parse($obj->expended_at))) { - $invoiceData[$i - 1] = round($invoiceData[$i - 1] - $obj->net / ($i == count($period) - 2 ? now()->month : 12), 2); + // TODO: Make it more obvious in the UI that this happens + if ($i > 0 && !Str($obj->description)->contains('EStVA') && CarbonPeriod::create($date, $period[$i + 1])->excludeEndDate()->contains(Carbon::parse($obj->expended_at))) { + $invoiceData[$i - 1] = round($invoiceData[$i - 1] - $obj->net / 12, 2); continue; } // Handle income tax advance pays - if (CarbonPeriod::create($date, $period[$i + 1])->contains(Carbon::parse($obj->expended_at))) { + if (CarbonPeriod::create($date, $period[$i + 1])->excludeEndDate()->contains(Carbon::parse($obj->expended_at))) { $invoiceData[$i] -= $obj->net; } } diff --git a/app/Filament/Widgets/SalesChart.php b/app/Filament/Widgets/SalesChart.php index fc8ccb2..e6ebc06 100644 --- a/app/Filament/Widgets/SalesChart.php +++ b/app/Filament/Widgets/SalesChart.php @@ -65,17 +65,17 @@ protected function getData(): array break; } foreach ($invoices as $obj) { - if (CarbonPeriod::create($date, $period[$i + 1])->contains($obj->paid_at)) { + if (CarbonPeriod::create($date, $period[$i + 1])->excludeEndDate()->contains($obj->paid_at)) { $invoiceData[$i] += $obj->net; } } foreach ($expenses as $obj) { - if (CarbonPeriod::create($date, $period[$i + 1])->contains($obj->expended_at)) { + if (CarbonPeriod::create($date, $period[$i + 1])->excludeEndDate()->contains($obj->expended_at)) { $expenseData[$i] += $obj->deductibleNet; } } foreach ($taxes as $obj) { - if (CarbonPeriod::create($date, $period[$i + 1])->contains($obj->expended_at)) { + if (CarbonPeriod::create($date, $period[$i + 1])->excludeEndDate()->contains($obj->expended_at)) { $taxData[$i] += $obj->net; } } diff --git a/app/Filament/Widgets/TaxReturnFormInput.php b/app/Filament/Widgets/TaxReturnFormInput.php index 542672e..189495f 100644 --- a/app/Filament/Widgets/TaxReturnFormInput.php +++ b/app/Filament/Widgets/TaxReturnFormInput.php @@ -67,17 +67,17 @@ public function getTableRecords(): Collection [$netEarned, $netUntaxableEarned, $vatEarned] = Invoice::ofTime($dt, TimeUnit::YEAR); [$netGoodExpended, $vatGoodExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Good); [$netServiceExpended, $vatServiceExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Service); - [$rentExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Rent); - [$utilityCostsExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Utility); + [$rentExpended, $vatRentExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Rent); + [$utilityCostsExpended, $vatUtilityExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Utility); [$netMinorAssetsExpended, $vatMinorAssetsExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::MinorAssets); [$netEdvExpended, $vatEdvExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Edv); [$netWorkEquipmentExpended, $vatWorkEquipmentExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::WorkEquipment); [$netAdvertisingExpended, $vatAdvertisingExpended] = Expense::ofTime($dt, TimeUnit::YEAR, ExpenseCategory::Advertising); - $netExpended = $netGoodExpended + $netServiceExpended + $rentExpended + $utilityCostsExpended + $netMinorAssetsExpended - + $netEdvExpended + $netWorkEquipmentExpended + $netAdvertisingExpended; - $vatExpended = $vatGoodExpended + $vatServiceExpended + $vatMinorAssetsExpended - + $vatEdvExpended + $vatWorkEquipmentExpended + $vatAdvertisingExpended; + $netExpended = $netGoodExpended + $netServiceExpended + $rentExpended + $utilityCostsExpended + + $netMinorAssetsExpended + $netEdvExpended + $netWorkEquipmentExpended + $netAdvertisingExpended; + $vatExpended = $vatGoodExpended + $vatServiceExpended + $vatMinorAssetsExpended + $vatEdvExpended + + $vatWorkEquipmentExpended + $vatAdvertisingExpended + $vatRentExpended + $vatUtilityExpended; return collect([ [ @@ -85,7 +85,8 @@ public function getTableRecords(): Collection 'itr' => '1 (S)', 'vr' => null, 'rsc' => null, - 'value' => round($netEarned + $netUntaxableEarned - $netExpended), + // Elster requires the income tax return's profit line (Zeile 1 ESt Anlage S) in whole euros + 'value' => round($netEarned + $netUntaxableEarned - $netExpended, 0), 'help' => __('formLabels')['itr1'], 'color' => 'primary', ], @@ -95,7 +96,7 @@ public function getTableRecords(): Collection 'vr' => '22', 'rsc' => '15', 'value' => $netEarned, - 'help' => __('formLabels')['rsc14'], + 'help' => __('formLabels')['rsc15'], 'color' => 'primary', ], [ @@ -113,7 +114,7 @@ public function getTableRecords(): Collection 'vr' => null, 'rsc' => '17', 'value' => $vatEarned, - 'help' => __('formLabels')['rsc16'], + 'help' => __('formLabels')['rsc17'], 'color' => 'primary', ], [ @@ -122,7 +123,7 @@ public function getTableRecords(): Collection 'vr' => null, 'rsc' => '27', 'value' => $netGoodExpended, - 'help' => __('formLabels')['rsc26'], + 'help' => __('formLabels')['rsc27'], 'color' => 'danger', ], [ @@ -131,7 +132,7 @@ public function getTableRecords(): Collection 'vr' => null, 'rsc' => '29', 'value' => $netServiceExpended, - 'help' => __('formLabels')['rsc27'], + 'help' => __('formLabels')['rsc29'], 'color' => 'danger', ], [ @@ -176,7 +177,7 @@ public function getTableRecords(): Collection 'vr' => '79', 'rsc' => '57', 'value' => $vatExpended, - 'help' => __('formLabels')['rsc55'], + 'help' => __('formLabels')['rsc57'], 'color' => 'danger', ], [ @@ -211,7 +212,7 @@ public function getTableRecords(): Collection 'itr' => null, 'vr' => null, 'rsc' => '97', - 'value' => $netEarned + $vatEarned + $netUntaxableEarned - $netExpended - $vatExpended, + 'value' => round($netEarned + $vatEarned + $netUntaxableEarned - $netExpended - $vatExpended, 2), 'help' => __('formLabels')['rsc97'], 'color' => 'gray', ], diff --git a/app/Models/Client.php b/app/Models/Client.php index 27da24a..0576802 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -75,13 +75,16 @@ protected function fullAddress(): Attribute } /** - * Number of hours worked for this client + * Number of hours worked for this client, based on paid, non-transitory invoices */ protected function hours(): Attribute { $hours = 0.0; foreach ($this->projects as $project) { foreach ($project->invoices as $invoice) { + if (!$invoice->paid_at || $invoice->transitory) { + continue; + } foreach ($invoice->positions as $position) { $hours += $position->duration; } @@ -91,13 +94,16 @@ protected function hours(): Attribute } /** - * Net amount earned by this client + * Net amount earned by this client, based on paid, non-transitory invoices */ protected function net(): Attribute { $net = 0.0; foreach ($this->projects as $project) { foreach ($project->invoices as $invoice) { + if (!$invoice->paid_at || $invoice->transitory) { + continue; + } $net += $invoice->net; } } diff --git a/app/Models/Estimate.php b/app/Models/Estimate.php index 3c41735..285395e 100644 --- a/app/Models/Estimate.php +++ b/app/Models/Estimate.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Enums\PricingUnit; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -36,4 +38,22 @@ protected function casts(): array 'updated_at' => 'datetime', ]; } + + /** + * Total net of the estimate + */ + protected function net(): Attribute + { + if (!$this->project) { + return Attribute::make(fn(): float => 0.0); + } + + $net = 0; + if ($this->project->pricing_unit === PricingUnit::Project) { + $net = $this->project->estimated_net / $this->project->estimated_hours * $this->amount; + } else { + $net += $this->amount * $this->project->price / $this->project->pricing_hours; + } + return Attribute::make(fn(): float => round($net, 2)); + } } diff --git a/app/Models/Expense.php b/app/Models/Expense.php index 1a610fc..7f9951d 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -92,7 +92,7 @@ protected function casts(): array */ protected function year(): Attribute { - return Attribute::make(fn(): int => intval(substr($this->expanded_at, 0, 4))); + return Attribute::make(fn(): int => intval(substr($this->expended_at, 0, 4))); } /** diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index e94f65f..f14409b 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -86,7 +86,7 @@ public static function ofTime(Carbon $d, TimeUnit $u): array TimeUnit::QUARTER => $d->endOfQuarter()->toDateString(), TimeUnit::YEAR => $d->endOfYear()->toDateString(), }; - $records = self::where('paid_at', '>=', $start)->where('paid_at', '<=', $end)->get(); + $records = self::where('paid_at', '>=', $start)->where('paid_at', '<=', $end)->where('transitory', 0)->get(); $netTaxable = $records->filter(fn(self $r) => $r->taxable)->map(fn(self $r) => $r->net)->sum(); $netUntaxable = $records->filter(fn(self $r) => !$r->taxable)->map(fn(self $r) => $r->net)->sum(); $vat = $records->map(fn(self $r) => $r->vat)->sum(); @@ -224,7 +224,10 @@ protected function realNet(): Attribute if ($this->pricing_unit === PricingUnit::Project) { $net = $this->price; } else { - $net += $this->hours * $this->price / $this->pricing_hours; + // Sum each position's own already rounded net to avoid rounding differences + foreach ($this->positions as $position) { + $net += $position->net; + } } return Attribute::make(fn(): float => round($net, 2)); } diff --git a/app/Models/Position.php b/app/Models/Position.php index 0a5d0c4..eb14359 100644 --- a/app/Models/Position.php +++ b/app/Models/Position.php @@ -64,7 +64,7 @@ protected function net(): Attribute $net = 0; if ($this->invoice->pricing_unit === PricingUnit::Project) { - $net = $this->invoice->hours / $this->invoice->net * $this->duration; + $net = $this->invoice->net / $this->invoice->hours * $this->duration; } else { $net += $this->duration * $this->invoice->price / $this->invoice->pricing_hours; } diff --git a/app/Services/InvoiceService.php b/app/Services/InvoiceService.php index 912d6b9..250ab7f 100644 --- a/app/Services/InvoiceService.php +++ b/app/Services/InvoiceService.php @@ -276,7 +276,7 @@ public static function generatePdf(Invoice $invoice): string 'hours' => $billedPerProject ? '' : Number::format($poshours, 1, locale: $lang), 'price' => $billedPerProject ? '' : Number::currency($invoice->price, 'EUR', locale: $lang), 'title' => $invoice->undated ? "{$num}. {$label['position']}" : $posdate, - 'total' => $billedPerProject ? '' : Number::currency($invoice->price * $poshours, 'EUR', locale: $lang), + 'total' => $billedPerProject ? '' : Number::currency($position->net, 'EUR', locale: $lang), ]); // Convert to supported char encoding diff --git a/app/Services/ProjectService.php b/app/Services/ProjectService.php index 00704b5..2f59ad7 100644 --- a/app/Services/ProjectService.php +++ b/app/Services/ProjectService.php @@ -284,7 +284,7 @@ public static function generateQuotePdf(Project $project): string 'hours' => Number::format($estimate->amount, 1, locale: $lang), 'price' => $billedPerProject ? '' : Number::currency($project->price, 'EUR', locale: $lang), 'title' => $estimate->title, - 'total' => $billedPerProject ? '' : Number::currency($project->price * $estimate->amount, 'EUR', locale: $lang), + 'total' => $billedPerProject ? '' : Number::currency($estimate->net, 'EUR', locale: $lang), ]); // Convert to supported char encoding diff --git a/lang/de.json b/lang/de.json index 1327246..7182d73 100644 --- a/lang/de.json +++ b/lang/de.json @@ -68,15 +68,15 @@ "flatRate": "pauschal", "formLabels": { "itr1": "Gewinn aus freiberuflicher Tätigkeit", - "rsc14": "Umsatzsteuerpflichtige Betriebseinnahmen", - "rsc16": "Vereinnahmte Umsatzsteuer", - "rsc26": "Waren, Rohstoffe und Hilfsstoffe", - "rsc27": "Bezogene Leistungen", + "rsc15": "Umsatzsteuerpflichtige Betriebseinnahmen", + "rsc17": "Vereinnahmte Umsatzsteuer", + "rsc27": "Waren, Rohstoffe und Hilfsstoffe", + "rsc29": "Bezogene Leistungen", "rsc36": "Sofort abzugsfähige Betriebsausgaben (GWG)", "rsc50": "EDV-Kosten", "rsc51": "Arbeitsmittel", "rsc54": "Werbekosten", - "rsc55": "Gezahlte Vorsteuerbeträge", + "rsc57": "Gezahlte Vorsteuerbeträge", "rsc65a": "Miete", "rsc65b": "Nebenkosten", "rsc97": "Steuerpflichtiger Gewinn/Verlust", @@ -125,7 +125,7 @@ "logo": "Logo", "minimum": "Minimum", "minorAssets": "GWG", - "minorAssetsNetLimitExceeded": "Der Nettobetrag darf bei GWG nicht :max € oder mehr betragen.", + "minorAssetsNetLimitExceeded": "Der Nettobetrag darf bei GWG :max € nicht übersteigen.", "minorAssetsRegister": "GWG-Verzeichnis", "minorAssetsRegisterDescription": "Aufwendungen zwischen :min € und :max € netto", "month": "Monat", diff --git a/lang/en.json b/lang/en.json index be09b79..cb71b5c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -68,15 +68,15 @@ "flatRate": "flat-rate", "formLabels": { "itr1": "Profit from freelance work", - "rsc14": "Operating income subject to VAT", - "rsc16": "Sales tax collected", - "rsc26": "Goods, raw materials and auxiliary materials", - "rsc27": "Purchased services", + "rsc15": "Operating income subject to VAT", + "rsc17": "Sales tax collected", + "rsc27": "Goods, raw materials and auxiliary materials", + "rsc29": "Purchased services", "rsc36": "Immediately deductible expenses (GWG)", "rsc50": "IT costs", "rsc51": "Work equipment", "rsc54": "Advertising costs", - "rsc55": "Input tax paid", + "rsc57": "Input tax paid", "rsc65a": "Rent", "rsc65b": "Utility costs", "rsc97": "Taxable profit/loss", @@ -125,7 +125,7 @@ "logo": "Logo", "minimum": "Minimum", "minorAssets": "Minor assets", - "minorAssetsNetLimitExceeded": "The net amount must not be :max € or more for minor assets.", + "minorAssetsNetLimitExceeded": "The net amount must not exceed :max € for minor assets.", "minorAssetsRegister": "Minor assets register", "minorAssetsRegisterDescription": "Expenses between :min € and :max € net", "month": "Month", diff --git a/tests/Feature/ExpenseResourceTest.php b/tests/Feature/ExpenseResourceTest.php index 4db1805..a42f40c 100644 --- a/tests/Feature/ExpenseResourceTest.php +++ b/tests/Feature/ExpenseResourceTest.php @@ -72,7 +72,7 @@ public function it_creates_an_expense(): void } #[Test] - public function it_rejects_a_minor_assets_expense_with_a_net_value_of_800_euros_or_more(): void + public function it_rejects_a_minor_assets_expense_with_a_net_value_exceeding_800_euros(): void { $this->actingAs(User::factory()->create()); @@ -80,7 +80,7 @@ public function it_rejects_a_minor_assets_expense_with_a_net_value_of_800_euros_ ->callAction(CreateAction::class, data: [ 'expended_at' => '2026-01-15', 'category' => ExpenseCategory::MinorAssets->value, - 'price' => 800, + 'price' => 800.01, 'quantity' => 1, 'taxable' => false, ]) @@ -89,6 +89,27 @@ public function it_rejects_a_minor_assets_expense_with_a_net_value_of_800_euros_ $this->assertDatabaseCount('expenses', 0); } + #[Test] + public function it_accepts_a_minor_assets_expense_with_a_net_value_of_exactly_800_euros(): void + { + $this->actingAs(User::factory()->create()); + + Livewire::test(ListExpenses::class) + ->callAction(CreateAction::class, data: [ + 'expended_at' => '2026-01-15', + 'category' => ExpenseCategory::MinorAssets->value, + 'price' => 800, + 'quantity' => 1, + 'taxable' => false, + ]) + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas('expenses', [ + 'category' => ExpenseCategory::MinorAssets->value, + 'price' => 800, + ]); + } + #[Test] public function it_accepts_a_minor_assets_expense_with_a_net_value_below_800_euros(): void { diff --git a/tests/Feature/MinorAssetsListTest.php b/tests/Feature/MinorAssetsListTest.php index 9567006..a0b6e37 100644 --- a/tests/Feature/MinorAssetsListTest.php +++ b/tests/Feature/MinorAssetsListTest.php @@ -44,6 +44,15 @@ public function it_only_lists_minor_assets_expenses_with_a_net_value_trackable_i 'taxable' => false, ]); + // exactly at the 800 € GWG cap, must be included (§6(2) EStG: costs not exceeding 800 €) + $atUpperBound = Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::MinorAssets, + 'price' => 800, + 'quantity' => 1, + 'taxable' => false, + ]); + // below the 250 € tracking threshold, must be excluded Expense::factory()->create([ 'expended_at' => "$year-06-01", @@ -53,11 +62,20 @@ public function it_only_lists_minor_assets_expenses_with_a_net_value_trackable_i 'taxable' => false, ]); - // at/above the 800 € GWG cap, must be excluded + // exactly at the 250 € tracking threshold, must be excluded (§6(2) EStG: costs exceeding 250 €) Expense::factory()->create([ 'expended_at' => "$year-06-01", 'category' => ExpenseCategory::MinorAssets, - 'price' => 800, + 'price' => 250, + 'quantity' => 1, + 'taxable' => false, + ]); + + // above the 800 € GWG cap, must be excluded + Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::MinorAssets, + 'price' => 801, 'quantity' => 1, 'taxable' => false, ]); @@ -73,8 +91,9 @@ public function it_only_lists_minor_assets_expenses_with_a_net_value_trackable_i $records = (new MinorAssetsList())->getTableRecords(); - $this->assertCount(1, $records); + $this->assertCount(2, $records); $this->assertTrue($records->contains($trackable)); + $this->assertTrue($records->contains($atUpperBound)); } #[Test] diff --git a/tests/Feature/MonthlyIncomeChartTest.php b/tests/Feature/MonthlyIncomeChartTest.php index 6029097..60badd4 100644 --- a/tests/Feature/MonthlyIncomeChartTest.php +++ b/tests/Feature/MonthlyIncomeChartTest.php @@ -2,8 +2,10 @@ namespace Tests\Feature; +use App\Enums\ExpenseCategory; use App\Enums\PricingUnit; use App\Filament\Widgets\MonthlyIncomeChart; +use App\Models\Expense; use App\Models\Invoice; use App\Models\Project; use App\Models\User; @@ -68,4 +70,70 @@ public function it_averages_gross_income_per_month_for_the_year(): void $this->assertNotFalse($yearIndex); $this->assertSame(99.17, $data['datasets'][0]['data'][$yearIndex]); } + + #[Test] + public function it_does_not_double_count_an_invoice_paid_exactly_on_a_year_boundary(): void + { + $anchorYear = now()->year - 4; + $boundaryYear = now()->year - 2; + + // establishes the chart's period start well before the boundary under test + Invoice::factory() + ->for(Project::factory()) + ->create(['paid_at' => "$anchorYear-01-15", 'transitory' => false, 'pricing_unit' => PricingUnit::Project, 'price' => 12, 'discount' => null, 'taxable' => false]); + + // paid exactly on the year boundary: must count only for $boundaryYear, not also for $boundaryYear - 1 + Invoice::factory() + ->for(Project::factory()) + ->create(['paid_at' => "$boundaryYear-01-01", 'transitory' => false, 'pricing_unit' => PricingUnit::Project, 'price' => 1200, 'discount' => null, 'taxable' => false]); + + $widget = new MonthlyIncomeChart(); + $widget->filter = 'net'; + $data = (new ReflectionMethod($widget, 'getData'))->invoke($widget); + + $previousYearIndex = array_search((string) ($boundaryYear - 1), $data['labels'], true); + $boundaryYearIndex = array_search((string) $boundaryYear, $data['labels'], true); + + $this->assertNotFalse($previousYearIndex); + $this->assertNotFalse($boundaryYearIndex); + $this->assertSame(0.0, $data['datasets'][0]['data'][$previousYearIndex]); + $this->assertSame(100.0, $data['datasets'][0]['data'][$boundaryYearIndex]); + } + + #[Test] + public function it_divides_a_shifted_tax_payment_by_twelve_months_of_the_completed_prior_year(): void + { + $anchorYear = now()->year - 3; + $priorYear = now()->year - 1; + + // establishes the chart's period start well before the prior year under test + Invoice::factory() + ->for(Project::factory()) + ->create(['paid_at' => "$anchorYear-01-15", 'transitory' => false, 'pricing_unit' => PricingUnit::Project, 'price' => 12, 'discount' => null, 'taxable' => false]); + + // the completed prior year's own income, averaging to 100.00/month before any shift + Invoice::factory() + ->for(Project::factory()) + ->create(['paid_at' => "$priorYear-06-01", 'transitory' => false, 'pricing_unit' => PricingUnit::Project, 'price' => 1200, 'discount' => null, 'taxable' => false]); + + // a non-EStVA tax payment in the current (still partial) year for the prior, completed year + Expense::factory()->create([ + 'expended_at' => now()->format('Y') . '-03-01', + 'category' => ExpenseCategory::Tax, + 'price' => 2400, + 'quantity' => 1, + 'taxable' => false, + 'description' => 'Nachzahlung', + ]); + + $widget = new MonthlyIncomeChart(); + $widget->filter = 'net'; + $data = (new ReflectionMethod($widget, 'getData'))->invoke($widget); + + $priorYearIndex = array_search((string) $priorYear, $data['labels'], true); + + $this->assertNotFalse($priorYearIndex); + // 1200/12 (own average) - 2400/12 (shifted payment, divided by the completed year's 12 months) = -100.00 + $this->assertSame(-100.0, $data['datasets'][0]['data'][$priorYearIndex]); + } } diff --git a/tests/Feature/SalesChartTest.php b/tests/Feature/SalesChartTest.php index 5e1fd34..b6bbddd 100644 --- a/tests/Feature/SalesChartTest.php +++ b/tests/Feature/SalesChartTest.php @@ -76,4 +76,34 @@ public function it_sums_income_expenses_and_taxes_per_year(): void $this->assertSame(150.0, $expense['data'][$yearIndex]); $this->assertSame(50.0, $taxes['data'][$yearIndex]); } + + #[Test] + public function it_does_not_double_count_an_invoice_paid_exactly_on_a_year_boundary(): void + { + $anchorYear = now()->year - 4; + $boundaryYear = now()->year - 2; + + // establishes the chart's period start well before the boundary under test + Invoice::factory() + ->for(Project::factory()) + ->create(['paid_at' => "$anchorYear-01-15", 'transitory' => false, 'pricing_unit' => PricingUnit::Project, 'price' => 1, 'discount' => null]); + + // paid exactly on the year boundary: must count only for $boundaryYear, not also for $boundaryYear - 1 + Invoice::factory() + ->for(Project::factory()) + ->create(['paid_at' => "$boundaryYear-01-01", 'transitory' => false, 'pricing_unit' => PricingUnit::Project, 'price' => 1200, 'discount' => null]); + + $widget = new SalesChart(); + $widget->filter = 'y'; + $data = (new ReflectionMethod($widget, 'getData'))->invoke($widget); + + $income = collect($data['datasets'])->firstWhere('label', __('income')); + $previousYearIndex = array_search((string) ($boundaryYear - 1), $data['labels'], true); + $boundaryYearIndex = array_search((string) $boundaryYear, $data['labels'], true); + + $this->assertNotFalse($previousYearIndex); + $this->assertNotFalse($boundaryYearIndex); + $this->assertEquals(0, $income['data'][$previousYearIndex]); + $this->assertSame(1200.0, $income['data'][$boundaryYearIndex]); + } } diff --git a/tests/Feature/TaxReturnFormInputTest.php b/tests/Feature/TaxReturnFormInputTest.php index afa5bea..c5cb50b 100644 --- a/tests/Feature/TaxReturnFormInputTest.php +++ b/tests/Feature/TaxReturnFormInputTest.php @@ -100,6 +100,16 @@ public function it_computes_the_tax_return_lines_for_the_selected_year(): void 'taxable' => false, ]); + // Taxable utility expense: its vat must still reduce vr118/rsc97, not just its net reduce rsc65b. + Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::Utility, + 'price' => 30, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + ]); + $widget = new TaxReturnFormInput(); $widget->filter = $year; $records = $widget->getTableRecords()->keyBy('__key'); @@ -112,7 +122,47 @@ public function it_computes_the_tax_return_lines_for_the_selected_year(): void $this->assertSame(80.0, $records[8]['value']); // rsc50 - net edv expended $this->assertSame(60.0, $records[9]['value']); // rsc51 - net work equipment expended $this->assertSame(40.0, $records[10]['value']); // rsc54 - net advertising expended + $this->assertSame(20.76, $records[11]['value']); // rsc57 - vat expended, must include the taxable utility expense's vat $this->assertSame(50.0, $records[12]['value']); // rsc65a - rent expended - $this->assertEqualsWithDelta(186.0, $records[1]['value'], 0.01); // itr1 - taxable profit + $this->assertSame(25.21, $records[13]['value']); // rsc65b - utility costs expended + $this->assertSame(169.24, $records[14]['value']); // vr118 - vat payable, must reflect the utility expense's vat too + $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_rounds_the_final_profit_line_to_cents_despite_floating_point_noise(): void + { + $year = now()->year - 1; + + Invoice::factory() + ->for(Project::factory()) + ->create([ + 'paid_at' => "$year-06-01", + 'transitory' => false, + 'pricing_unit' => PricingUnit::Project, + 'price' => 1192.14, + 'discount' => null, + 'taxable' => true, + 'vat_rate' => 0.19, + ]); + + // gross values chosen so summing their rounded net/vat amounts leaves binary floating-point + // residue (e.g. -864.54999999999972715159 instead of exactly -864.55) unless rsc97 is rounded. + foreach ([129.63, 326.69, 182.57, 493.48, 490.55, 660.28] as $gross) { + Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::Good, + 'price' => $gross, + 'quantity' => 1, + 'taxable' => true, + 'vat_rate' => 0.19, + ]); + } + + $widget = new TaxReturnFormInput(); + $widget->filter = $year; + $records = $widget->getTableRecords()->keyBy('__key'); + + $this->assertSame(-864.55, $records[15]['value']); // rsc97 - final profit, must be exactly 2 decimals } } diff --git a/tests/Unit/Models/ClientTest.php b/tests/Unit/Models/ClientTest.php index 527e15a..bc90b6c 100644 --- a/tests/Unit/Models/ClientTest.php +++ b/tests/Unit/Models/ClientTest.php @@ -70,8 +70,8 @@ it('sums worked hours across all its projects, invoices and positions', function () { $client = Client::factory()->create(); $project = Project::factory()->create(['client_id' => $client->id]); - $invoiceOne = Invoice::factory()->create(['project_id' => $project->id]); - $invoiceTwo = Invoice::factory()->create(['project_id' => $project->id]); + $invoiceOne = Invoice::factory()->create(['project_id' => $project->id, 'paid_at' => '2026-03-01', 'transitory' => false]); + $invoiceTwo = Invoice::factory()->create(['project_id' => $project->id, 'paid_at' => '2026-03-02', 'transitory' => false]); Position::factory()->create([ 'invoice_id' => $invoiceOne->id, 'started_at' => '2026-03-01 09:00:00', @@ -88,6 +88,34 @@ expect($client->hours)->toBe(8.0); }); +it('excludes unpaid and transitory invoices when summing worked hours', function () { + $client = Client::factory()->create(); + $project = Project::factory()->create(['client_id' => $client->id]); + $paid = Invoice::factory()->create(['project_id' => $project->id, 'paid_at' => '2026-03-01', 'transitory' => false]); + $unpaid = Invoice::factory()->create(['project_id' => $project->id, 'paid_at' => null, 'transitory' => false]); + $transitory = Invoice::factory()->create(['project_id' => $project->id, 'paid_at' => '2026-03-02', 'transitory' => true]); + Position::factory()->create([ + 'invoice_id' => $paid->id, + 'started_at' => '2026-03-01 09:00:00', + 'finished_at' => '2026-03-01 14:00:00', + 'pause_duration' => 0, + ]); + Position::factory()->create([ + 'invoice_id' => $unpaid->id, + 'started_at' => '2026-03-02 09:00:00', + 'finished_at' => '2026-03-02 12:00:00', + 'pause_duration' => 0, + ]); + Position::factory()->create([ + 'invoice_id' => $transitory->id, + 'started_at' => '2026-03-03 09:00:00', + 'finished_at' => '2026-03-03 12:00:00', + 'pause_duration' => 0, + ]); + + expect($client->hours)->toBe(5.0); +}); + it('sums the net amount earned across all its projects and invoices', function () { $client = Client::factory()->create(); $project = Project::factory()->create(['client_id' => $client->id]); @@ -96,12 +124,16 @@ 'pricing_unit' => PricingUnit::Hour, 'price' => 100, 'discount' => null, + 'paid_at' => '2026-03-01', + 'transitory' => false, ]); $invoiceTwo = Invoice::factory()->create([ 'project_id' => $project->id, 'pricing_unit' => PricingUnit::Hour, 'price' => 100, 'discount' => null, + 'paid_at' => '2026-03-02', + 'transitory' => false, ]); Position::factory()->create([ 'invoice_id' => $invoiceOne->id, @@ -119,6 +151,39 @@ expect($client->net)->toBe(800.0); }); +it('excludes unpaid and transitory invoices when summing the net amount earned', function () { + $client = Client::factory()->create(); + $project = Project::factory()->create(['client_id' => $client->id]); + Invoice::factory()->create([ + 'project_id' => $project->id, + 'pricing_unit' => PricingUnit::Project, + 'price' => 500, + 'discount' => null, + 'paid_at' => '2026-03-01', + 'transitory' => false, + ]); + // unpaid draft, must be excluded + Invoice::factory()->create([ + 'project_id' => $project->id, + 'pricing_unit' => PricingUnit::Project, + 'price' => 1000, + 'discount' => null, + 'paid_at' => null, + 'transitory' => false, + ]); + // transitory pass-through amount, must be excluded + Invoice::factory()->create([ + 'project_id' => $project->id, + 'pricing_unit' => PricingUnit::Project, + 'price' => 1000, + 'discount' => null, + 'paid_at' => '2026-03-02', + 'transitory' => true, + ]); + + expect($client->net)->toBe(500.0); +}); + it('calculates the average payment delay across paid invoices', function () { $client = Client::factory()->create(); $project = Project::factory()->create(['client_id' => $client->id]); diff --git a/tests/Unit/Models/EstimateTest.php b/tests/Unit/Models/EstimateTest.php index 5c7528b..d4f07f5 100644 --- a/tests/Unit/Models/EstimateTest.php +++ b/tests/Unit/Models/EstimateTest.php @@ -29,3 +29,28 @@ expect($estimate->project)->toBeInstanceOf(Project::class) ->and($estimate->project->id)->toBe($project->id); }); + +it('calculates net for an hourly project based on amount and price', function () { + $project = Project::factory()->hourly()->create(['price' => 100]); + $estimate = Estimate::factory()->create(['project_id' => $project->id, 'amount' => 5]); + + expect($estimate->net)->toBe(500.0); +}); + +it('calculates net for a day-priced project by dividing the day rate across its pricing hours', function () { + $project = Project::factory()->daily()->create(['price' => 800]); + $estimate = Estimate::factory()->create(['project_id' => $project->id, 'amount' => 4]); + + // 4 hours estimated, day rate split across 8 pricing hours: 800 / 8 * 4 + expect($estimate->net)->toBe(400.0); +}); + +it('calculates net for a project-priced project proportional to its share of hours', function () { + $project = Project::factory()->project()->create(['price' => 3000]); + $shorter = Estimate::factory()->create(['project_id' => $project->id, 'amount' => 10]); + $longer = Estimate::factory()->create(['project_id' => $project->id, 'amount' => 20]); + + // total estimated hours: 10 + 20 = 30, flat project net: 3000 -> 100 €/hour + expect($shorter->net)->toBe(1000.0) + ->and($longer->net)->toBe(2000.0); +}); diff --git a/tests/Unit/Models/ExpenseTest.php b/tests/Unit/Models/ExpenseTest.php index dae3c58..943c515 100644 --- a/tests/Unit/Models/ExpenseTest.php +++ b/tests/Unit/Models/ExpenseTest.php @@ -18,6 +18,12 @@ ]); }); +it('derives the year from the expended_at date', function () { + $expense = Expense::factory()->create(['expended_at' => '2026-03-15']); + + expect($expense->year)->toBe(2026); +}); + it('casts attributes to their expected types', function () { $expense = Expense::factory()->create([ 'expended_at' => '2026-03-15', diff --git a/tests/Unit/Models/InvoiceTest.php b/tests/Unit/Models/InvoiceTest.php index f840e2c..c040191 100644 --- a/tests/Unit/Models/InvoiceTest.php +++ b/tests/Unit/Models/InvoiceTest.php @@ -160,6 +160,23 @@ expect($invoice->real_net)->toBe(550.0); }); +it('calculates real net as the sum of its positions rounded nets, not a top-down rounded total', function () { + $invoice = Invoice::factory()->create(['pricing_unit' => PricingUnit::Hour, 'price' => 100]); + // three positions of 20 minutes each (1.0h total): each position's net rounds to 33.33, + // summing to 99.99 - one cent below the top-down calculation (1.0 * 100 = 100.00), which + // would make the PDF's own printed position rows not add up to the printed invoice total. + for ($i = 1; $i <= 3; $i++) { + Position::factory()->create([ + 'invoice_id' => $invoice->id, + 'started_at' => "2026-03-0{$i} 09:00:00", + 'finished_at' => "2026-03-0{$i} 09:20:00", + 'pause_duration' => 0, + ]); + } + + expect($invoice->real_net)->toBe(99.99); +}); + it('uses the flat price as real net for project-based pricing', function () { $invoice = Invoice::factory()->create(['pricing_unit' => PricingUnit::Project, 'price' => 2000]); Position::factory()->create([ @@ -260,6 +277,7 @@ 'discount' => null, 'taxable' => true, 'vat_rate' => 0.19, + 'transitory' => false, ]); Invoice::factory()->create([ 'paid_at' => '2026-03-20', @@ -268,6 +286,7 @@ 'discount' => null, 'taxable' => false, 'vat_rate' => null, + 'transitory' => false, ]); // Outside the requested month, must be excluded. Invoice::factory()->create([ @@ -278,6 +297,16 @@ 'taxable' => false, 'vat_rate' => null, ]); + // Transitory (pass-through) invoice within the month, must be excluded. + Invoice::factory()->create([ + 'paid_at' => '2026-03-10', + 'pricing_unit' => PricingUnit::Project, + 'price' => 1000, + 'discount' => null, + 'taxable' => true, + 'vat_rate' => 0.19, + 'transitory' => true, + ]); [$netTaxable, $netUntaxable, $vat] = Invoice::ofTime(Carbon::parse('2026-03-15'), TimeUnit::MONTH); diff --git a/tests/Unit/Models/PositionTest.php b/tests/Unit/Models/PositionTest.php index 7a7d40a..328ee77 100644 --- a/tests/Unit/Models/PositionTest.php +++ b/tests/Unit/Models/PositionTest.php @@ -62,23 +62,47 @@ expect($position->net)->toBe(500.0); }); -it('calculates net for a project-priced invoice proportional to its share of hours', function () { +it('calculates net for a day-priced invoice by dividing the day rate across its pricing hours', function () { $invoice = Invoice::factory()->create([ - 'pricing_unit' => PricingUnit::Project, - 'price' => 1000, + 'pricing_unit' => PricingUnit::Day, + 'price' => 800, 'discount' => null, ]); $position = Position::factory()->create([ 'invoice_id' => $invoice->id, 'started_at' => '2026-03-01 09:00:00', - 'finished_at' => '2026-03-01 14:00:00', + 'finished_at' => '2026-03-01 13:00:00', 'pause_duration' => 0, ]); - $position->refresh(); - $expected = round($invoice->fresh()->hours / $invoice->fresh()->net * $position->duration, 2); + // 4 hours worked, day rate split across 8 pricing hours: 800 / 8 * 4 + expect($position->net)->toBe(400.0); +}); + +it('calculates net for a project-priced invoice proportional to its share of hours', function () { + $invoice = Invoice::factory()->create([ + 'pricing_unit' => PricingUnit::Project, + 'price' => 3000, + 'discount' => null, + ]); + $shorter = Position::factory()->create([ + 'invoice_id' => $invoice->id, + 'started_at' => '2026-03-01 09:00:00', + 'finished_at' => '2026-03-01 19:00:00', + 'pause_duration' => 0, + ]); + $longer = Position::factory()->create([ + 'invoice_id' => $invoice->id, + 'started_at' => '2026-03-02 09:00:00', + 'finished_at' => '2026-03-03 05:00:00', + 'pause_duration' => 0, + ]); - expect($position->net)->toBe($expected); + // total invoice hours: 10 + 20 = 30, flat invoice net: 3000 -> 100 €/hour + expect($shorter->duration)->toBe(10.0) + ->and($longer->duration)->toBe(20.0) + ->and($shorter->net)->toBe(1000.0) + ->and($longer->net)->toBe(2000.0); }); it('formats the time range using the start and finish timestamps', function () {