Configurable rate rounding (#5734)

* added invoice hydration of the issuer object
* added "rate calculator" mode
* new config to select rounding mode
* replace static calls with dependency injection
This commit is contained in:
Kevin Papst
2025-12-16 16:47:46 +01:00
committed by GitHub
parent 07ac6c308f
commit 595b5f4b25
43 changed files with 876 additions and 411 deletions

View File

@@ -449,6 +449,11 @@ final class SystemConfigurationController extends AbstractController
->setType(TextType::class)
->setConstraints([new NotBlank()])
->setTranslationDomain('system-configuration'),
(new Configuration('invoice.rounding_mode'))
->setLabel('invoice.rounding_mode')
->setType(ChoiceType::class)
->setOptions(['choices' => ['classic' => 'classic', 'decimal' => 'decimal']])
->setTranslationDomain('system-configuration'),
]),
$authentication,
(new SystemConfigurationModel('customer'))

View File

@@ -15,7 +15,8 @@ use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Timesheet\Util;
use App\Timesheet\RateCalculator\ClassicRateCalculator;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
@@ -60,10 +61,14 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$allUser = $this->getAllUsers($manager);
$faker = Factory::create();
$all = 0;
$calculator = new ClassicRateCalculator();
foreach ($allUser as $user) {
// reload, because the manager might have been cleared
$user = $manager->find(User::class, $user->getId());
if ($user === null) {
continue;
}
// random amount of timesheet entries for every user
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
@@ -84,6 +89,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
}
$entry = $this->createTimesheetEntry(
$calculator,
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
@@ -98,6 +104,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
// create active records
if ($all % 3 === 0) {
$entry = $this->createTimesheetEntry(
$calculator,
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
@@ -208,7 +215,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
return $this->findRandom($manager, Activity::class, 50);
}
private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate): Timesheet
private function createTimesheetEntry(RateCalculatorMode $calculatorMode, User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate): Timesheet
{
$start = $this->getRandomFirstDay();
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
@@ -227,7 +234,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$duration = $end->getTimestamp() - $start->getTimestamp();
$hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE);
$rate = Util::calculateRate($hourlyRate, $duration);
$rate = $calculatorMode->calculateRate($hourlyRate, $duration);
$entry->setEnd($end);
$entry->setRate($rate);

View File

@@ -369,6 +369,10 @@ final class Configuration implements ConfigurationInterface
->booleanNode('upload_twig')
->defaultFalse()
->end()
->enumNode('rounding_mode')
->values(['decimal', 'classic'])
->defaultValue('classic')
->end()
->end()
;

View File

@@ -12,7 +12,6 @@ namespace App\Invoice\Calculator;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow;
use App\Timesheet\Util;
abstract class AbstractCalculator
{
@@ -42,10 +41,10 @@ abstract class AbstractCalculator
if (\count($this->cached) === 0) {
foreach ($this->calculateEntries() as $entry) {
if (!$entry->isFixedRate() && $entry->getHourlyRate() !== null && $entry->getHourlyRate() > 0) {
$entry->setDuration(Util::decimalizeDuration($entry->getDuration()));
$entry->setDuration($this->model->getRateCalculatorMode()->roundDuration($entry->getDuration()));
// when merging many entries, we might run into rounding issues
// so we have to recalculate the hourly rate here
$entry->setRate(Util::calculateRate($entry->getHourlyRate(), $entry->getDuration()));
$entry->setRate($this->model->getRateCalculatorMode()->calculateRate($entry->getHourlyRate(), $entry->getDuration()));
}
$this->cached[] = $entry;

View File

@@ -32,7 +32,7 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
$values = [
$prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getFormattedAddress() ?? '',
$prefix . 'address' => $customer->getFormattedAddress() ?? '', // deprecated since 2.44
$prefix . 'address_line1' => $customer->getAddressLine1() ?? '',
$prefix . 'address_line2' => $customer->getAddressLine2() ?? '',
$prefix . 'address_line3' => $customer->getAddressLine3() ?? '',

View File

@@ -62,12 +62,12 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.subtotal_plain' => $subtotal,
'template.name' => $template->getName() ?? '',
'template.company' => $template->getCompany() ?? '',
'template.company' => $template->getCompany() ?? '', // deprecated since 2.45
'template.address' => $template->getAddress() ?? '',
'template.title' => $template->getTitle() ?? '',
'template.payment_terms' => $template->getPaymentTerms() ?? '',
'template.due_days' => $template->getDueDays(),
'template.vat_id' => $template->getVatId() ?? '',
'template.vat_id' => $template->getVatId() ?? '', // deprecated since 2.45
'template.contact' => $template->getContact() ?? '',
'template.country' => null,
'template.country_name' => null,
@@ -75,16 +75,16 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'query.begin' => '',
'query.begin_day' => '',
'query.begin_process' => null, // since 2.14
'query.begin_process' => null, // since 2.14
'query.begin_month' => '',
'query.begin_month_number' => '',
'query.begin_year' => '',
'query.end' => '', // since 1.9
'query.end_day' => '', // since 1.9
'query.end_process' => null, // since 2.14
'query.end_month' => '', // since 1.9
'query.end_month_number' => '', // since 1.9
'query.end_year' => '', // since 1.9
'query.end' => '', // since 1.9
'query.end_day' => '', // since 1.9
'query.end_process' => null, // since 2.14
'query.end_month' => '', // since 1.9
'query.end_month_number' => '', // since 1.9
'query.end_year' => '', // since 1.9
// since 2.0.15
'user.see_others' => ($model->getQuery()?->getUser() === null),
@@ -105,8 +105,8 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
if ($seller !== null) {
$country = $seller->getCountry();
if ($country !== null) {
$values['template.country'] = $country;
$values['template.country_name'] = Countries::getName($country, $language);
$values['template.country'] = $country; // deprecated since 2.45
$values['template.country_name'] = Countries::getName($country, $language); // deprecated since 2.45
}
}
@@ -116,14 +116,10 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
if ($begin !== null) {
$values = array_merge($values, [
'query.day' => $begin->format('d'),
// @deprecated - but impossible to delete
'query.month' => $formatter->getFormattedMonthName($begin),
// @deprecated - but impossible to delete
'query.month_number' => $begin->format('m'),
// @deprecated - but impossible to delete
'query.year' => $begin->format('Y'),
// @deprecated - but impossible to delete
'query.begin' => $formatter->getFormattedDateTime($begin),
'query.month' => $formatter->getFormattedMonthName($begin), // @deprecated - but impossible to delete
'query.month_number' => $begin->format('m'), // @deprecated - but impossible to delete
'query.year' => $begin->format('Y'), // @deprecated - but impossible to delete
'query.begin' => $formatter->getFormattedDateTime($begin), // @deprecated - but impossible to delete
'query.begin_process' => $begin->format(self::DATE_PROCESS_FORMAT), // since 2.14
'query.begin_day' => $begin->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($begin),

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use Symfony\Component\Intl\Countries;
final class InvoiceModelIssuerHydrator implements InvoiceModelHydrator
{
/**
* @return array<string, mixed>
*/
public function hydrate(InvoiceModel $model): array
{
$customer = $model->getTemplate()->getCustomer();
if (null === $customer) {
return [];
}
$prefix = 'issuer.';
$language = $model->getTemplate()->getLanguage();
$country = $customer->getCountry();
$values = [
$prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getFormattedAddress() ?? '',
$prefix . 'address_line1' => $customer->getAddressLine1() ?? '',
$prefix . 'address_line2' => $customer->getAddressLine2() ?? '',
$prefix . 'address_line3' => $customer->getAddressLine3() ?? '',
$prefix . 'postcode' => $customer->getPostCode() ?? '',
$prefix . 'city' => $customer->getCity() ?? '',
$prefix . 'name' => $customer->getName() ?? '',
$prefix . 'contact' => $customer->getContact() ?? '',
$prefix . 'company' => $customer->getCompany() ?? '',
$prefix . 'vat_id' => $customer->getVatId() ?? '',
$prefix . 'number' => $customer->getNumber() ?? '',
$prefix . 'country' => $country,
$prefix . 'country_name' => $country !== null ? Countries::getName($country, $language) : null,
$prefix . 'homepage' => $customer->getHomepage() ?? '',
$prefix . 'comment' => $customer->getComment() ?? '',
$prefix . 'email' => $customer->getEmail() ?? '',
$prefix . 'fax' => $customer->getFax() ?? '',
$prefix . 'phone' => $customer->getPhone() ?? '',
$prefix . 'mobile' => $customer->getMobile() ?? '',
$prefix . 'invoice_text' => $customer->getInvoiceText() ?? '',
$prefix . 'buyer_reference' => $customer->getBuyerReference() ?? '',
];
foreach ($customer->getMetaFields() as $metaField) {
$values = array_merge($values, [
$prefix . 'meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
}
}

View File

@@ -19,10 +19,12 @@ use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
/**
@@ -68,12 +70,14 @@ final class InvoiceModel
ActivityStatisticService $activityStatistic,
private readonly Customer $customer,
private readonly InvoiceTemplate $template,
private readonly RateCalculatorMode $rateCalculatorMode
)
{
$this->invoiceDate = new \DateTimeImmutable();
$this->formatter = $formatter;
$this->addModelHydrator(new InvoiceModelDefaultHydrator());
$this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic));
$this->addModelHydrator(new InvoiceModelIssuerHydrator());
$this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic));
$this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic));
$this->addModelHydrator(new InvoiceModelUserHydrator());
@@ -207,6 +211,11 @@ final class InvoiceModel
return $this->calculator;
}
public function getRateCalculatorMode(): RateCalculatorMode
{
return $this->rateCalculatorMode;
}
/**
* Returns the user currently creating the invoice.
*/

View File

@@ -15,19 +15,21 @@ use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
final class InvoiceModelFactory
{
public function __construct(
private readonly CustomerStatisticService $customerStatisticService,
private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService
private readonly ActivityStatisticService $activityStatisticService,
private readonly RateCalculatorMode $rateCalculatorMode
) {
}
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template);
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode);
$model->setQuery($query);

View File

@@ -49,11 +49,11 @@ final class ServiceInvoice
private array $invoiceItemRepositories = [];
public function __construct(
private InvoiceDocumentRepository $documents,
private FileHelper $fileHelper,
private InvoiceRepository $invoiceRepository,
private LocaleService $formatter,
private InvoiceModelFactory $invoiceModelFactory
private readonly InvoiceDocumentRepository $documents,
private readonly FileHelper $fileHelper,
private readonly InvoiceRepository $invoiceRepository,
private readonly LocaleService $formatter,
private readonly InvoiceModelFactory $invoiceModelFactory
) {
}

View File

@@ -18,7 +18,7 @@ use App\Timesheet\RateServiceInterface;
*/
final class RateCalculator implements CalculatorInterface
{
public function __construct(private RateServiceInterface $service)
public function __construct(private readonly RateServiceInterface $service)
{
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
final class ClassicRateCalculator implements RateCalculatorMode
{
/**
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = $hourlyRate * ($seconds / 3600);
return round($rate, 4);
}
/**
* Does not round the duration, we keep the original sum.
*/
public function roundDuration(int $seconds): int
{
return $seconds;
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
final class DecimalRateCalculator implements RateCalculatorMode
{
/**
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = $hourlyRate * round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return round($rate, 2, PHP_ROUND_HALF_UP);
}
/**
* Makes sure tha the duration is full compatible with decimal format, stripping away overflowing seconds.
*/
public function roundDuration(int $seconds): int
{
$decimal = round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return (int) round(($decimal * 3600), 0, PHP_ROUND_HALF_UP);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
use App\Configuration\SystemConfiguration;
/**
* @final
*/
class RateCalculatorFactory
{
public function __construct(private readonly SystemConfiguration $configuration)
{
}
/**
* @internal do not call, use RateCalculatorMode injection
*/
public function getRateCalculatorMode(): RateCalculatorMode
{
if ($this->configuration->find('invoice.rounding_mode') === 'decimal') {
return new DecimalRateCalculator();
}
return new ClassicRateCalculator();
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet\RateCalculator;
interface RateCalculatorMode
{
/**
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public function calculateRate(float $hourlyRate, int $seconds): float;
/**
* * Makes sure tha the duration is fully rounded.
*/
public function roundDuration(int $seconds): int;
}

View File

@@ -13,13 +13,18 @@ use App\Entity\RateInterface;
use App\Entity\Timesheet;
use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\RateCalculator\RateCalculatorMode;
/**
* Implementation to calculate the rate for a timesheet record.
*/
final class RateService implements RateServiceInterface
{
public function __construct(private array $rates, private TimesheetRepository $repository)
public function __construct(
private readonly array $rates,
private readonly TimesheetRepository $repository,
private readonly RateCalculatorMode $calculatorMode
)
{
}
@@ -79,8 +84,8 @@ final class RateService implements RateServiceInterface
$totalInternalRate = 0;
if (null !== $record->getDuration()) {
$totalRate = Util::calculateRate($factoredHourlyRate, $record->getDuration());
$totalInternalRate = Util::calculateRate($factoredInternalRate, $record->getDuration());
$totalRate = $this->calculatorMode->calculateRate($factoredHourlyRate, $record->getDuration());
$totalInternalRate = $this->calculatorMode->calculateRate($factoredInternalRate, $record->getDuration());
}
return new Rate($totalRate, $totalInternalRate, $factoredHourlyRate, null);

View File

@@ -10,7 +10,7 @@
namespace App\Timesheet;
/**
* A static helper class for re-usable functionality.
* @deprecated use RateCalculatorMode instead
*/
final class Util
{
@@ -29,7 +29,7 @@ final class Util
}
/**
* Makes sure tha the duration is full compatible with decimal format, stripping away overflowing seconds.
* Makes sure that the duration is full compatible with decimal format, stripping away overflowing seconds.
*/
public static function decimalizeDuration(int $seconds): int
{