Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/framework/*
/vendor
/tests/coverage
.env
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Resources/ExpenseResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
}
},
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Widgets/MinorAssetsList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
9 changes: 5 additions & 4 deletions app/Filament/Widgets/MonthlyIncomeChart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}
Expand Down
6 changes: 3 additions & 3 deletions app/Filament/Widgets/SalesChart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
27 changes: 14 additions & 13 deletions app/Filament/Widgets/TaxReturnFormInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,25 +67,26 @@ 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([
[
'__key' => 1,
'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',
],
Expand All @@ -95,7 +96,7 @@ public function getTableRecords(): Collection
'vr' => '22',
'rsc' => '15',
'value' => $netEarned,
'help' => __('formLabels')['rsc14'],
'help' => __('formLabels')['rsc15'],
'color' => 'primary',
],
[
Expand All @@ -113,7 +114,7 @@ public function getTableRecords(): Collection
'vr' => null,
'rsc' => '17',
'value' => $vatEarned,
'help' => __('formLabels')['rsc16'],
'help' => __('formLabels')['rsc17'],
'color' => 'primary',
],
[
Expand All @@ -122,7 +123,7 @@ public function getTableRecords(): Collection
'vr' => null,
'rsc' => '27',
'value' => $netGoodExpended,
'help' => __('formLabels')['rsc26'],
'help' => __('formLabels')['rsc27'],
'color' => 'danger',
],
[
Expand All @@ -131,7 +132,7 @@ public function getTableRecords(): Collection
'vr' => null,
'rsc' => '29',
'value' => $netServiceExpended,
'help' => __('formLabels')['rsc27'],
'help' => __('formLabels')['rsc29'],
'color' => 'danger',
],
[
Expand Down Expand Up @@ -176,7 +177,7 @@ public function getTableRecords(): Collection
'vr' => '79',
'rsc' => '57',
'value' => $vatExpended,
'help' => __('formLabels')['rsc55'],
'help' => __('formLabels')['rsc57'],
'color' => 'danger',
],
[
Expand Down Expand Up @@ -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',
],
Expand Down
10 changes: 8 additions & 2 deletions app/Models/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
}
Expand Down
20 changes: 20 additions & 0 deletions app/Models/Estimate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}
2 changes: 1 addition & 1 deletion app/Models/Expense.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

/**
Expand Down
7 changes: 5 additions & 2 deletions app/Models/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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));
}
Expand Down
2 changes: 1 addition & 1 deletion app/Models/Position.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion app/Services/InvoiceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/Services/ProjectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 23 additions & 2 deletions tests/Feature/ExpenseResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ 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());

Livewire::test(ListExpenses::class)
->callAction(CreateAction::class, data: [
'expended_at' => '2026-01-15',
'category' => ExpenseCategory::MinorAssets->value,
'price' => 800,
'price' => 800.01,
'quantity' => 1,
'taxable' => false,
])
Expand All @@ -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
{
Expand Down
Loading