Compare commits

..

5 Commits

Author SHA1 Message Date
Weblate (bot)
a3ef1fd1cd Translated using Weblate (#5649)
Co-authored-by: Adrián Gelmotto Ruiz <adriangelmotto@gmail.com>
Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Bamowen <mathieu.monsauret@gmail.com>
Co-authored-by: Carlos Carreras <mytriponlinux@gmail.com>
Co-authored-by: Dao Duy Tin <duytin095@gmail.com>
Co-authored-by: GD <guillaume.debat65@gmail.com>
Co-authored-by: Henry Higgins <leserboka@outlook.com>
Co-authored-by: Jonas Tisell <jonas.tisell@live.no>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Lenny Angst <lenny@familie-angst.ch>
Co-authored-by: Levente Déri <derilevi@gmail.com>
Co-authored-by: LordTenebrous <danielmorenoperez836@gmail.com>
Co-authored-by: Lourenço Martins <hlourencoam93@gmail.com>
Co-authored-by: Marco Moreno <hibarioath@proton.me>
Co-authored-by: Martin Maslyankov <m.maslyankov@me.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Michedepain <benoitravel63000@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nadie <ef.dal.1200@gmail.com>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Parms <shoppingpar+weblate@simplelogin.com>
Co-authored-by: Preben Rather Sørensen <preben@rather.dk>
Co-authored-by: Py- Droid <droidpy587@gmail.com>
Co-authored-by: Rafa Herzog <49111482+necronyxon@users.noreply.github.com>
Co-authored-by: RoboDoc <theonlyrobodoc@gmail.com>
Co-authored-by: Serhii Horichenko <serhii@horichenko.com>
Co-authored-by: Tuna <ahmettunadem@gmail.com>
Co-authored-by: Turkish Language Team 🇹🇷 <turkishmark@yandex.com>
Co-authored-by: Wolf <wolski.marex@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: michte <michteting@proton.me>
Co-authored-by: mostafa <m2-vision@users.noreply.hosted.weblate.org>
Co-authored-by: no <kotvpaltoof@yandex.com>
Co-authored-by: vanapro1 <law820314@gmail.com>
Co-authored-by: zhao rongkuan (kuank) <zhaorongkuan2011@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic4@gmail.com>
2025-11-09 22:48:25 +01:00
Kevin Papst
1a38c7d7a3 Release 2.41 (#5653) 2025-11-08 23:03:44 +01:00
Kevin Papst
636a51e721 group invoices by project and activity (#5675) 2025-11-02 12:34:41 +01:00
Kevin Papst
cc64acf0f8 Meta-fields for InvoiceTemplate, structured Customer address (#5519) 2025-11-02 11:24:17 +01:00
Kevin Papst
76821c24ed bump workflow version 2025-10-06 12:37:30 +02:00
184 changed files with 6432 additions and 1589 deletions

View File

@@ -16,7 +16,7 @@ jobs:
persist-credentials: false
- name: Prevent file change
uses: xalvarez/prevent-file-change-action@v1
uses: xalvarez/prevent-file-change-action@v3
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
pattern: .*\.lock$

View File

@@ -35,6 +35,8 @@
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"horstoeko/zugferd": "^1.0",
"horstoeko/zugferdublbridge": "^1.0",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^1.4",
"league/csv": "^9.4",

1340
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -39,3 +39,5 @@ fos_rest:
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json ] }
zone:
- { path: ^/api/* }
service:
view_handler: App\API\ViewHandler

View File

@@ -112,102 +112,102 @@ services:
# ================================================================================
App\Repository\TimesheetRepository:
class: App\Repository\TimesheetRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\TimesheetRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Timesheet']
App\Repository\UserRepository:
class: App\Repository\UserRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\UserRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\User']
App\Repository\TeamRepository:
class: App\Repository\TeamRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\TeamRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Team']
App\Repository\ActivityRepository:
class: App\Repository\ActivityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\ActivityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Activity']
App\Repository\ProjectRepository:
class: App\Repository\ProjectRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\ProjectRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Project']
App\Repository\TagRepository:
class: App\Repository\TagRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\TagRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Tag']
App\Repository\CustomerRepository:
class: App\Repository\CustomerRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\CustomerRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Customer']
App\Repository\InvoiceTemplateRepository:
class: App\Repository\InvoiceTemplateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\InvoiceTemplateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\InvoiceTemplate']
App\Repository\ConfigurationRepository:
class: App\Repository\ConfigurationRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\ConfigurationRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Configuration']
App\Repository\RoleRepository:
class: App\Repository\RoleRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\RoleRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Role']
App\Repository\RolePermissionRepository:
class: App\Repository\RolePermissionRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\RolePermissionRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\RolePermission']
App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository
arguments: ['%kimai.invoice.documents%']
App\Repository\CustomerRateRepository:
class: App\Repository\CustomerRateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\CustomerRateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\CustomerRate']
App\Repository\ActivityRateRepository:
class: App\Repository\ActivityRateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\ActivityRateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ActivityRate']
App\Repository\ProjectRateRepository:
class: App\Repository\ProjectRateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\ProjectRateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ProjectRate']
App\Repository\InvoiceRepository:
class: App\Repository\InvoiceRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\InvoiceRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Invoice']
App\Repository\BookmarkRepository:
class: App\Repository\BookmarkRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\BookmarkRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Bookmark']
App\Repository\WorkingTimeRepository:
class: App\Repository\WorkingTimeRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\WorkingTimeRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\WorkingTime']
App\Repository\AccessTokenRepository:
class: App\Repository\AccessTokenRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\AccessTokenRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\AccessToken']
App\Repository\ExportTemplateRepository:
class: App\Repository\ExportTemplateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
class: App\Repository\ExportTemplateRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ExportTemplate']
monolog.formatter.kimai:
@@ -221,3 +221,6 @@ services:
arguments:
- "[%%datetime%%] %%message%% %%context%%\n"
- "Y-m-d H:i:s"
App\API\ViewHandler:
arguments: ['@fos_rest.view_handler.default']

View File

@@ -0,0 +1,46 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.41
*/
final class Version20251031142000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add dedicated address fields for customer and customer-field for invoice template';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_customers ADD address_line1 VARCHAR(150) DEFAULT NULL, ADD address_line2 VARCHAR(150) DEFAULT NULL, ADD address_line3 VARCHAR(150) DEFAULT NULL, ADD postcode VARCHAR(20) DEFAULT NULL, ADD city VARCHAR(50) DEFAULT NULL, ADD buyer_reference VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD customer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_invoice_templates MODIFY company VARCHAR(255) NULL');
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD CONSTRAINT FK_1626CFE99395C3F3 FOREIGN KEY (customer_id) REFERENCES kimai2_customers (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates (customer_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP FOREIGN KEY FK_1626CFE99395C3F3');
$this->addSql('DROP INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates');
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP customer_id');
$this->addSql('ALTER TABLE kimai2_customers DROP address_line1, DROP address_line2, DROP address_line3, DROP postcode, DROP city, DROP buyer_reference');
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -0,0 +1,43 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.41
*/
final class Version20251031143000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the invoice template meta table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE kimai2_invoice_templates_meta (id INT AUTO_INCREMENT NOT NULL, template_id INT NOT NULL, name VARCHAR(50) NOT NULL, value TEXT DEFAULT NULL, visible TINYINT(1) DEFAULT 0 NOT NULL, INDEX IDX_A165B0555DA0FB8 (template_id), UNIQUE INDEX UNIQ_A165B0555DA0FB85E237E06 (template_id, name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE kimai2_invoice_templates_meta ADD CONSTRAINT FK_A165B0555DA0FB8 FOREIGN KEY (template_id) REFERENCES kimai2_invoice_templates (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$table = $schema->getTable('kimai2_invoice_templates_meta');
$table->removeForeignKey('FK_A165B0555DA0FB8');
$schema->dropTable('kimai2_invoice_templates_meta');
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -41,6 +41,8 @@ parameters:
symfony:
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
ignoreErrors:
- identifier: throws.unusedType
- identifier: offsetAccess.notFound
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
-
@@ -753,11 +755,6 @@ parameters:
count: 1
path: src/Controller/InvoiceController.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Controller/InvoiceController.php
-
message: "#^Cannot call method getName\\(\\) on Symfony\\\\Component\\\\Form\\\\FormInterface\\|null\\.$#"
count: 1
@@ -803,11 +800,6 @@ parameters:
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Call to function array_key_exists\\(\\) with 'entry' and array\\{day\\: DateTime, entry\\: App\\\\Entity\\\\Timesheet\\} will always evaluate to true\\.$#"
count: 1
path: src/Controller/QuickEntryController.php
-
message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#"
count: 1
@@ -983,11 +975,6 @@ parameters:
count: 1
path: src/Customer/CustomerStatisticService.php
-
message: "#^Method App\\\\DataFixtures\\\\InvoiceFixtures\\:\\:getInvoiceConfigs\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/DataFixtures/InvoiceFixtures.php
-
message: "#^Method App\\\\DataFixtures\\\\TeamFixtures\\:\\:getGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -2763,11 +2750,6 @@ parameters:
count: 1
path: src/Form/UserTwoFactorType.php
-
message: "#^Cannot call method getVat\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Invoice/Calculator/AbstractCalculator.php
-
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
@@ -2818,31 +2800,6 @@ parameters:
count: 1
path: src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php
-
message: "#^Cannot call method getSubtotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTax\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTimeWorked\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 2
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getVat\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -2873,16 +2830,6 @@ parameters:
count: 1
path: src/Invoice/Hydrator/InvoiceModelUserHydrator.php
-
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Invoice/InvoiceFilename.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Invoice/InvoiceFilename.php
-
message: "#^Parameter \\#1 \\$filename of method App\\\\Invoice\\\\InvoiceFilename\\:\\:convert\\(\\) expects string, string\\|null given\\.$#"
count: 1
@@ -2928,11 +2875,6 @@ parameters:
count: 1
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
count: 5
@@ -2953,11 +2895,6 @@ parameters:
count: 1
path: src/Invoice/Renderer/AbstractTwigRenderer.php
-
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Invoice/Renderer/AbstractTwigRenderer.php
-
message: "#^Method App\\\\Invoice\\\\Renderer\\\\AbstractTwigRenderer\\:\\:renderTwigTemplate\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
@@ -2983,11 +2920,6 @@ parameters:
count: 1
path: src/Invoice/ServiceInvoice.php
-
message: "#^Cannot call method getRenderer\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 6
path: src/Invoice/ServiceInvoice.php
-
message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
@@ -4207,3 +4139,8 @@ parameters:
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
count: 1
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
-
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/InvoiceTemplate.php

View File

@@ -161,7 +161,7 @@ final class ActivityController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveActivity($activity);
$this->activityService->saveActivity($activity);
$view = new View($activity, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -203,7 +203,7 @@ final class ActivityController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->repository->saveActivity($activity);
$this->activityService->saveActivity($activity);
$view = new View($activity, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -253,7 +253,7 @@ final class ActivityController extends BaseApiController
$meta->setValue($value);
$this->repository->saveActivity($activity);
$this->activityService->saveActivity($activity);
$view = new View($activity, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -12,13 +12,12 @@ namespace App\API;
use App\Entity\User;
use App\Repository\Query\BaseQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\Pagination;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
abstract class BaseApiController extends AbstractController
{
@@ -60,13 +59,19 @@ abstract class BaseApiController extends AbstractController
return DateTimeFactory::createByUser($user);
}
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): void
/**
* @template T of BaseQuery
* @param T $query
* @param ParamFetcherInterface $paramFetcher
* @return T
*/
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): BaseQuery
{
$query->setIsApiCall(true);
$query->setCurrentUser($this->getUser());
// there is no function has() in ParamFetcherInterface, so we need to use all() and check for the key
$all = $paramFetcher->all(true);
$all = $paramFetcher->all();
if (\array_key_exists('page', $all)) {
$page = $all['page'];
@@ -78,33 +83,28 @@ abstract class BaseApiController extends AbstractController
if (\array_key_exists('size', $all)) {
$size = $all['size'];
if (is_numeric($size)) {
$query->setPageSize((int) $size);
$size = (int) $size;
if ($size < 1 || $size > 500) {
throw new BadRequestHttpException('Size must be between 1 and 500');
}
$query->setPageSize($size);
}
}
if (\array_key_exists('pageSize', $all)) {
$size = $all['pageSize'];
if (is_numeric($size)) {
$query->setPageSize((int) $size);
if (\array_key_exists('order', $all)) {
$order = $all['order'];
if (\is_string($order) && $order !== '') {
$query->setOrder($order);
}
}
}
protected function createPaginatedView(Pagination $pagination): View
{
$results = (array) $pagination->getCurrentPageResults();
if (\array_key_exists('orderBy', $all)) {
$orderBy = $all['orderBy'];
if (\is_string($orderBy) && $orderBy !== '') {
$query->setOrderBy($orderBy);
}
}
$view = new View($results, 200);
$this->addPagination($view, $pagination);
return $view;
}
protected function addPagination(View $view, Pagination $pagination): void
{
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());
$view->setHeader('X-Total-Count', (string) $pagination->getNbResults());
$view->setHeader('X-Total-Pages', (string) $pagination->getNbPages());
$view->setHeader('X-Per-Page', (string) $pagination->getMaxPerPage());
return $query;
}
}

View File

@@ -136,7 +136,7 @@ final class CustomerController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveCustomer($customer);
$this->customerService->saveCustomer($customer);
$view = new View($customer, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -178,7 +178,7 @@ final class CustomerController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->repository->saveCustomer($customer);
$this->customerService->saveCustomer($customer);
$view = new View($customer, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -228,7 +228,7 @@ final class CustomerController extends BaseApiController
$meta->setValue($value);
$this->repository->saveCustomer($customer);
$this->customerService->saveCustomer($customer);
$view = new View($customer, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -80,7 +80,7 @@ final class InvoiceController extends BaseApiController
}
$data = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($data);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -82,7 +82,7 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'activity', requirements: '\d+', strict: true, nullable: true, description: 'Activity ID to filter timesheets')]
#[Rest\QueryParam(name: 'activities', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of activity IDs to filter, e.g.: activities[]=1&activities[]=2')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50, max: 500)')]
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
@@ -97,7 +97,7 @@ final class TimesheetController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{
$query = new TimesheetQuery(false);
$query->setCurrentUser($this->getUser());
$this->prepareQuery($query, $paramFetcher);
$seeAll = false;
if ($this->isGranted('view_other_timesheet')) {
@@ -169,16 +169,6 @@ final class TimesheetController extends BaseApiController
$query->addActivity($activity);
}
$page = $paramFetcher->get('page');
if (\is_string($page) && $page !== '') {
$query->setPage((int) $page);
}
$size = $paramFetcher->get('size');
if (\is_string($size) && $size !== '') {
$query->setPageSize((int) $size);
}
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
@@ -191,16 +181,6 @@ final class TimesheetController extends BaseApiController
}
}
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
$query->setOrder($order);
}
$orderBy = $paramFetcher->get('orderBy');
if (\is_string($orderBy) && $orderBy !== '') {
$query->setOrderBy($orderBy);
}
$factory = $this->getDateTimeFactory();
$begin = $paramFetcher->get('begin');
@@ -252,12 +232,9 @@ final class TimesheetController extends BaseApiController
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
}
$query->setIsApiCall(true);
$data = $this->repository->getPagerfantaForQuery($query);
$results = (array) $data->getCurrentPageResults();
$view = new View($results, 200);
$this->addPagination($view, $data);
$view = new View($data, 200);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {

78
src/API/ViewHandler.php Normal file
View File

@@ -0,0 +1,78 @@
<?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\API;
use App\Utils\Pagination;
use FOS\RestBundle\View\ConfigurableViewHandlerInterface;
use FOS\RestBundle\View\View;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ViewHandler implements ConfigurableViewHandlerInterface
{
public function __construct(private readonly ConfigurableViewHandlerInterface $baseViewHandler)
{
}
/**
* @param string[]|string $groups
*/
public function setExclusionStrategyGroups($groups): void
{
$this->baseViewHandler->setExclusionStrategyGroups($groups);
}
public function setExclusionStrategyVersion(string $version): void
{
$this->baseViewHandler->setExclusionStrategyVersion($version);
}
public function setSerializeNullStrategy(bool $isEnabled): void
{
$this->baseViewHandler->setSerializeNullStrategy($isEnabled);
}
public function supports(string $format): bool
{
return $this->baseViewHandler->supports($format);
}
public function registerHandler(string $format, callable $callable): void
{
$this->baseViewHandler->registerHandler($format, $callable);
}
public function handle(View $view, ?Request $request = null): Response
{
$data = $view->getData();
if ($data instanceof Pagination) {
$results = (array) $data->getCurrentPageResults();
$view->setData($results);
$view->setHeader('X-Page', (string) $data->getCurrentPage());
$view->setHeader('X-Total-Count', (string) $data->getNbResults());
$view->setHeader('X-Total-Pages', (string) $data->getNbPages());
$view->setHeader('X-Per-Page', (string) $data->getMaxPerPage());
}
return $this->baseViewHandler->handle($view, $request);
}
public function createRedirectResponse(View $view, string $location, string $format): Response
{
return $this->baseViewHandler->createRedirectResponse($view, $location, $format);
}
public function createResponse(View $view, Request $request, string $format): Response
{
return $this->baseViewHandler->createResponse($view, $request, $format);
}
}

View File

@@ -277,7 +277,7 @@ final class InvoiceCreateCommand extends Command
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(\sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
$io->warning('Could not find invoice template for project, skipping.');
continue;
}
$query->setTemplate($tpl);
@@ -295,7 +295,7 @@ final class InvoiceCreateCommand extends Command
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
$io->error(\sprintf('Failed to create invoice for project with: %s', $ex->getMessage()));
}
}
@@ -352,7 +352,7 @@ final class InvoiceCreateCommand extends Command
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(\sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName()));
$io->warning('Could not find invoice template for customer, skipping.');
continue;
}
$query->setTemplate($tpl);
@@ -370,7 +370,7 @@ final class InvoiceCreateCommand extends Command
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
$io->error(\sprintf('Failed to create invoice for customer with: %s', $ex->getMessage()));
}
}
@@ -416,7 +416,7 @@ final class InvoiceCreateCommand extends Command
$file = $this->serviceInvoice->getInvoiceFile($invoice);
if (null === $file) {
$io->warning(
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename())
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId() ?? 'unknown', $invoice->getInvoiceFilename() ?? 'unknown')
);
continue;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.40.0';
public const VERSION = '2.41.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24000;
public const VERSION_ID = 24100;
/**
* The software name
*/

View File

@@ -35,6 +35,7 @@ use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
@@ -146,7 +147,7 @@ final class CustomerController extends AbstractController
{
$customer = $customerService->createNewCustomer('');
return $this->renderCustomerForm($customer, $request, true);
return $this->renderCustomerForm($customer, $request, true, $customerService);
}
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
@@ -281,7 +282,7 @@ final class CustomerController extends AbstractController
$query->setPage($page);
$query->setPageSize(5);
$query->addCustomer($customer);
$query->setShowBoth();
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
$query->addOrderGroup('visible', ProjectQuery::ORDER_DESC);
$query->addOrderGroup('name', ProjectQuery::ORDER_ASC);
@@ -421,9 +422,9 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'customer')]
public function editAction(Customer $customer, Request $request): Response
public function editAction(Customer $customer, Request $request, CustomerService $customerService): Response
{
return $this->renderCustomerForm($customer, $request);
return $this->renderCustomerForm($customer, $request, false, $customerService);
}
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
@@ -496,7 +497,7 @@ final class CustomerController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
private function renderCustomerForm(Customer $customer, Request $request, bool $create = false): Response
private function renderCustomerForm(Customer $customer, Request $request, bool $create, CustomerService $customerService): Response
{
$editForm = $this->createEditForm($customer);
@@ -504,7 +505,7 @@ final class CustomerController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->repository->saveCustomer($customer);
$customerService->saveCustomer($customer);
$this->flashSuccess('action.update.success');
if ($create) {

View File

@@ -17,6 +17,7 @@ use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent;
use App\Event\InvoiceTemplateMetaDefinitionEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
@@ -320,7 +321,7 @@ final class InvoiceController extends AbstractController
if (null === $file) {
throw $this->createNotFoundException(
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
\sprintf('Invoice file could not be found for invoice ID "%s"', $invoice->getId())
);
}
@@ -425,7 +426,7 @@ final class InvoiceController extends AbstractController
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('title', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false, 'title' => 'sending_company']);
$table->addColumn('vat_id', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('tax_rate', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('due_days', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
@@ -728,6 +729,9 @@ final class InvoiceController extends AbstractController
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$event = new InvoiceTemplateMetaDefinitionEvent($template);
$this->dispatcher->dispatch($event);
$editForm = $this->createTemplateEditForm($template);
$editForm->handleRequest($request);

View File

@@ -38,6 +38,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\Utils\Context;
use App\Utils\DataTable;
@@ -313,7 +314,7 @@ final class ProjectController extends AbstractController
$query->setPageSize(5);
$query->addProject($project);
$query->setExcludeGlobals(true);
$query->setShowBoth();
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
$query->addOrderGroup('visible', ActivityQuery::ORDER_DESC);
$query->addOrderGroup('name', ActivityQuery::ORDER_ASC);

View File

@@ -77,6 +77,7 @@ final class QuickEntryController extends AbstractController
$endWeek = $factory->getEndOfWeek($begin);
$tmpDay = clone $startWeek;
/** @var array<string, array{day: \DateTime}> $week */
$week = [];
while ($tmpDay < $endWeek) {
$nextDay = clone $tmpDay;
@@ -125,9 +126,9 @@ final class QuickEntryController extends AbstractController
if ($amount > 0) {
$takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks');
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
if (is_numeric($takeOverWeeks) && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
$startFrom->modify(\sprintf('-%s weeks', (string) $takeOverWeeks));
}
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);

View File

@@ -22,6 +22,7 @@ use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -94,6 +95,10 @@ final class TeamController extends AbstractController
{
$newTeam = clone $team;
if ($team->getName() === null) {
throw new BadRequestHttpException('Team with empty name cannot be duplicated');
}
$i = 1;
do {
$newName = \sprintf('%s (%s)', $team->getName(), $i++);

View File

@@ -92,6 +92,10 @@ final class CustomerFixtures extends Fixture
$entry->setTimezone($faker->timezone());
$entry->setVisible($visible);
$entry->setVatId($faker->creditCardNumber());
$entry->setPostCode($faker->postcode());
$entry->setCity($faker->city());
$entry->setAddressLine1($faker->streetAddress());
$entry->setAddressLine2($faker->streetAddress());
if (rand(0, 3) % 3) {
$entry->setBudget(rand(self::MIN_BUDGET, self::MAX_BUDGET));

View File

@@ -9,6 +9,7 @@
namespace App\DataFixtures;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@@ -29,6 +30,8 @@ final class InvoiceFixtures extends Fixture
public function load(ObjectManager $manager): void
{
$faker = Factory::create('en_US');
/** @var non-empty-array<Customer> $customers */
$customers = $manager->getRepository(Customer::class)->findAll();
foreach ($this->getInvoiceConfigs($faker) as $invoiceConfig) {
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
@@ -38,15 +41,16 @@ final class InvoiceFixtures extends Fixture
$template->setRenderer($invoiceConfig[2]);
$template->setCalculator($invoiceConfig[3]);
$template->setNumberGenerator($invoiceConfig[4]);
$template->setCompany($invoiceConfig[5]);
$template->setCompany($invoiceConfig[5]); // @phpstan-ignore method.deprecated
$template->setCustomer($customers[array_rand($customers)]);
$template->setVat($invoiceConfig[6]);
$template->setDueDays($invoiceConfig[7]);
$template->setPaymentTerms($invoiceConfig[8]);
$template->setLanguage('en');
$template->setAddress($this->generateAddress($faker));
$template->setAddress($this->generateAddress($faker)); // @phpstan-ignore method.deprecated
$template->setContact($this->generateContact($faker));
$template->setPaymentDetails($this->generatePaymentDetails($faker));
$template->setVatId($faker->creditCardNumber());
$template->setVatId($faker->creditCardNumber()); // @phpstan-ignore method.deprecated
$manager->persist($template);
$manager->flush();
@@ -54,8 +58,7 @@ final class InvoiceFixtures extends Fixture
}
/**
* @param Generator $faker
* @return array
* @return array<array{0: string, 1: string, 2: string, 3: string, 4: string, 5: string, 6: int, 7: int, 8: string}>
*/
private function getInvoiceConfigs(Generator $faker): array
{

View File

@@ -37,8 +37,7 @@ final class TeamFixtures extends Fixture
}
/**
* @param ObjectManager $manager
* @return array<int|string, User>
* @return non-empty-array<int|string, User>
*/
private function getAllUsers(ObjectManager $manager): array
{
@@ -46,15 +45,18 @@ final class TeamFixtures extends Fixture
/** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
$all[(string) $temp->getId()] = $temp;
}
if (\count($all) === 0) {
throw new \Exception('Need users to setup teams');
}
return $all;
}
/**
* @param ObjectManager $manager
* @return array<int|string, Project>
* @return non-empty-array<int|string, Project>
*/
private function getAllProjects(ObjectManager $manager): array
{
@@ -63,7 +65,11 @@ final class TeamFixtures extends Fixture
/** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
$all[(string) $temp->getId()] = $temp;
}
if (\count($all) === 0) {
throw new \Exception('Need projects to setup teams');
}
return $all;

View File

@@ -130,10 +130,9 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
/**
* @template T of object
* @param ObjectManager $manager
* @param class-string<T> $class
* @param int $amount
* @return array<int, T>
* @return non-empty-array<int, T>
*/
private function findRandom(ObjectManager $manager, string $class, int $amount): array
{
@@ -157,12 +156,15 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
/** @var array<int, T> $result */
$result = $qb->where($qb->expr()->in('entity.id', $ids))->setMaxResults($amount)->getQuery()->getResult();
if (\count($result) === 0) {
throw new \Exception('Could not find any entity: ' . $class);
}
return $result;
}
/**
* @param ObjectManager $manager
* @return array<int|string, Tag>
* @return non-empty-array<int|string, Tag>
*/
private function getAllTags(ObjectManager $manager): array
{
@@ -170,8 +172,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
}
/**
* @param ObjectManager $manager
* @return array<int|string, User>
* @return non-empty-array<int|string, User>
*/
private function getAllUsers(ObjectManager $manager): array
{
@@ -179,15 +180,18 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
/** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
$all[(string) $temp->getId()] = $temp;
}
if (\count($all) === 0) {
throw new \Exception('Need users to setup timesheets');
}
return $all;
}
/**
* @param ObjectManager $manager
* @return array<int|string, Project>
* @return non-empty-array<int|string, Project>
*/
private function getAllProjects(ObjectManager $manager): array
{
@@ -195,8 +199,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
}
/**
* @param ObjectManager $manager
* @return array<int|string, Activity>
* @return non-empty-array<int|string, Activity>
*/
private function getAllActivities(ObjectManager $manager): array
{

View File

@@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'addressLine1', 'addressLine2', 'addressLine3', 'postCode', 'city', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
@@ -86,13 +86,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'vat_id')]
private ?string $vatId = null;
#[ORM\Column(name: 'contact', type: 'string', length: 100, nullable: true)]
/**
* Contact person (name)
*/
#[ORM\Column(name: 'contact', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'contact')]
private ?string $contact = null;
#[ORM\Column(name: 'address', type: 'text', nullable: true)]
/**
* Unstructured address, better use the fields: address_line1-3, postcode, city, country
*/
#[ORM\Column(name: 'address', type: Types::TEXT, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address')]
@@ -132,7 +138,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'mobile')]
private ?string $mobile = null;
/**
* Customers contact email
* Contact email
*/
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
#[Assert\Length(max: 75)]
@@ -190,6 +196,43 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
private ?InvoiceTemplate $invoiceTemplate = null;
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
private ?string $invoiceText = null;
#[ORM\Column(name: 'address_line1', type: Types::STRING, length: 150, nullable: true)]
#[Assert\Length(max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address_line1')]
private ?string $addressLine1 = null;
#[ORM\Column(name: 'address_line2', type: Types::STRING, length: 150, nullable: true)]
#[Assert\Length(max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address_line2')]
private ?string $addressLine2 = null;
#[ORM\Column(name: 'address_line3', type: Types::STRING, length: 150, nullable: true)]
#[Assert\Length(max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address_line3')]
private ?string $addressLine3 = null;
#[ORM\Column(name: 'postcode', type: Types::STRING, length: 20, nullable: true)]
#[Assert\Length(max: 20)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'postcode')]
private ?string $postCode = null;
// this should be more than enough to cover 99.99% - https://en.wikipedia.org/wiki/List_of_long_place_names
#[ORM\Column(name: 'city', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'city')]
private ?string $city = null;
#[ORM\Column(name: 'buyer_reference', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'buyer_reference')]
private ?string $buyerReference = null;
public function __construct(string $name)
{
@@ -299,6 +342,35 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
return $this->address;
}
public function getFormattedAddress(): ?string
{
$address = $this->getAddressLine1();
if ($this->getAddressLine2() !== null) {
$address .= PHP_EOL;
$address .= $this->getAddressLine2();
}
if ($this->getAddressLine3() !== null) {
$address .= PHP_EOL;
$address .= $this->getAddressLine3();
}
if ($this->getPostCode() !== null || $this->getCity() !== null) {
$address .= PHP_EOL;
if ($this->getPostCode() !== null) {
$address .= $this->getPostCode();
if ($this->getCity() !== null) {
$address .= ' ';
}
}
$address .= $this->getCity() ?? '';
}
if ($address !== null && $address !== '') {
return $address;
}
return $this->getAddress();
}
public function setCountry(?string $country): void
{
$this->country = $country;
@@ -479,6 +551,66 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
return $this->teams;
}
public function getAddressLine1(): ?string
{
return $this->addressLine1;
}
public function setAddressLine1(?string $addressLine1): void
{
$this->addressLine1 = $addressLine1;
}
public function getAddressLine2(): ?string
{
return $this->addressLine2;
}
public function setAddressLine2(?string $addressLine2): void
{
$this->addressLine2 = $addressLine2;
}
public function getAddressLine3(): ?string
{
return $this->addressLine3;
}
public function setAddressLine3(?string $addressLine3): void
{
$this->addressLine3 = $addressLine3;
}
public function getPostCode(): ?string
{
return $this->postCode;
}
public function setPostCode(?string $postCode): void
{
$this->postCode = $postCode;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): void
{
$this->city = $city;
}
public function getBuyerReference(): ?string
{
return $this->buyerReference;
}
public function setBuyerReference(?string $buyerReference): void
{
$this->buyerReference = $buyerReference;
}
public function __toString(): string
{
return $this->getName();

View File

@@ -221,18 +221,12 @@ class Invoice implements EntityWithMetaFields
public function setModel(InvoiceModel $model): Invoice
{
$template = $model->getTemplate();
if ($template === null) {
throw new \InvalidArgumentException('Missing invoice template');
}
if ($template->getDueDays() === null || $template->getVat() === null) {
throw new \InvalidArgumentException('Missing due-days or vat setting');
}
$customer = $model->getCustomer();
if ($customer === null) {
throw new \InvalidArgumentException('Missing invoice customer');
}
$user = $model->getUser();
if ($user === null) {

View File

@@ -10,6 +10,8 @@
namespace App\Entity;
use App\Repository\InvoiceTemplateRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -20,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class InvoiceTemplate
class InvoiceTemplate implements EntityWithMetaFields
{
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
@@ -34,9 +36,8 @@ class InvoiceTemplate
#[Assert\Length(max: 255)]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(name: 'company', type: 'string', length: 255, nullable: false)]
#[ORM\Column(name: 'company', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Length(max: 255)]
#[Assert\NotBlank]
private ?string $company = null;
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
@@ -75,17 +76,32 @@ class InvoiceTemplate
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: false)]
#[Assert\NotBlank]
private ?string $language = 'en';
/**
* Customer for this invoice template
*/
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Assert\NotNull]
private ?Customer $customer = null;
/**
* @var Collection<int, InvoiceTemplateMeta>
*/
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
private Collection $meta;
public function __construct()
{
$this->meta = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function setName(string $name): InvoiceTemplate
public function setName(string $name): void
{
$this->name = $name;
return $this;
}
public function getName(): ?string
@@ -93,6 +109,16 @@ class InvoiceTemplate
return $this->name;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
// ---- trait methods below ---
public function getTitle(): ?string
@@ -100,23 +126,22 @@ class InvoiceTemplate
return $this->title;
}
public function setTitle(?string $title): InvoiceTemplate
public function setTitle(?string $title): void
{
$this->title = $title;
return $this;
}
public function getAddress(): ?string
{
return $this->address;
return $this->customer?->getFormattedAddress() ?? $this->address;
}
public function setAddress(?string $address): InvoiceTemplate
/**
* @deprecated since 2.41
*/
public function setAddress(?string $address): void
{
$this->address = $address;
return $this;
}
public function getNumberGenerator(): string
@@ -124,11 +149,9 @@ class InvoiceTemplate
return $this->numberGenerator;
}
public function setNumberGenerator(string $numberGenerator): InvoiceTemplate
public function setNumberGenerator(string $numberGenerator): void
{
$this->numberGenerator = $numberGenerator;
return $this;
}
public function getDueDays(): ?int
@@ -136,11 +159,9 @@ class InvoiceTemplate
return $this->dueDays;
}
public function setDueDays(?int $dueDays): InvoiceTemplate
public function setDueDays(?int $dueDays): void
{
$this->dueDays = $dueDays;
return $this;
}
public function getVat(): ?float
@@ -148,23 +169,22 @@ class InvoiceTemplate
return $this->vat;
}
public function setVat(?float $vat): InvoiceTemplate
public function setVat(?float $vat): void
{
$this->vat = $vat;
return $this;
}
public function getCompany(): ?string
{
return $this->company;
return $this->customer?->getCompany() ?? $this->customer?->getName() ?? $this->company;
}
public function setCompany(?string $company): InvoiceTemplate
/**
* @deprecated since 2.41
*/
public function setCompany(?string $company): void
{
$this->company = $company;
return $this;
}
public function getRenderer(): string
@@ -172,11 +192,9 @@ class InvoiceTemplate
return $this->renderer;
}
public function setRenderer(string $renderer): InvoiceTemplate
public function setRenderer(string $renderer): void
{
$this->renderer = $renderer;
return $this;
}
public function getCalculator(): string
@@ -184,11 +202,9 @@ class InvoiceTemplate
return $this->calculator;
}
public function setCalculator(string $calculator): InvoiceTemplate
public function setCalculator(string $calculator): void
{
$this->calculator = $calculator;
return $this;
}
public function getPaymentTerms(): ?string
@@ -196,23 +212,22 @@ class InvoiceTemplate
return $this->paymentTerms;
}
public function setPaymentTerms(?string $paymentTerms): InvoiceTemplate
public function setPaymentTerms(?string $paymentTerms): void
{
$this->paymentTerms = $paymentTerms;
return $this;
}
public function getVatId(): ?string
{
return $this->vatId;
return $this->customer?->getVatId() ?? $this->vatId;
}
public function setVatId(?string $vatId): InvoiceTemplate
/**
* @deprecated since 2.41
*/
public function setVatId(?string $vatId): void
{
$this->vatId = $vatId;
return $this;
}
public function getContact(): ?string
@@ -220,11 +235,9 @@ class InvoiceTemplate
return $this->contact;
}
public function setContact(?string $contact): InvoiceTemplate
public function setContact(?string $contact): void
{
$this->contact = $contact;
return $this;
}
public function getPaymentDetails(): ?string
@@ -232,11 +245,9 @@ class InvoiceTemplate
return $this->paymentDetails;
}
public function setPaymentDetails(?string $paymentDetails): InvoiceTemplate
public function setPaymentDetails(?string $paymentDetails): void
{
$this->paymentDetails = $paymentDetails;
return $this;
}
/**
@@ -252,9 +263,78 @@ class InvoiceTemplate
return $this->language;
}
public function setLanguage(?string $language): InvoiceTemplate
public function setLanguage(?string $language): void
{
$this->language = $language;
}
/**
* @return Tax[]
*/
public function getTaxRates(): array
{
// TODO make me configurable via UI
$tax = new Tax(
TaxType::STANDARD,
'VAT',
$this->vat ?? 0.00
);
return [$tax];
}
/**
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if ($field->getName() !== null && strtolower($field->getName()) === strtolower($name)) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if ($meta->getName() === null) {
throw new \InvalidArgumentException('Meta-field needs to have a name');
}
if (!$meta instanceof InvoiceTemplateMeta) {
throw new \InvalidArgumentException('Meta-field needs to be an instanceof InvoiceTemplateMeta');
}
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}

View File

@@ -0,0 +1,49 @@
<?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\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoice_templates_meta')]
#[ORM\UniqueConstraint(columns: ['template_id', 'name'])]
#[ORM\Entity]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
class InvoiceTemplateMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
#[ORM\ManyToOne(targetEntity: InvoiceTemplate::class, inversedBy: 'meta')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?InvoiceTemplate $template = null;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof InvoiceTemplate)) {
throw new \InvalidArgumentException(
\sprintf('Expected instanceof InvoiceTemplate, received "%s"', \get_class($entity))
);
}
$this->template = $entity;
return $this;
}
/**
* @return InvoiceTemplate|null
*/
public function getEntity(): ?EntityWithMetaFields
{
return $this->template;
}
}

36
src/Entity/Tax.php Normal file
View File

@@ -0,0 +1,36 @@
<?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\Entity;
final class Tax
{
public function __construct(
private readonly TaxType $type,
private readonly string $name = 'VAT',
private readonly float $rate = 0.0,
)
{
}
public function getType(): TaxType
{
return $this->type;
}
public function getName(): string
{
return $this->name;
}
public function getRate(): float
{
return $this->rate;
}
}

17
src/Entity/TaxType.php Normal file
View File

@@ -0,0 +1,17 @@
<?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\Entity;
enum TaxType: string
{
case STANDARD = 'standard';
case REVERSE = 'reverse';
case EXEMPT = 'exempt';
}

View File

@@ -0,0 +1,28 @@
<?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\Event;
use App\Entity\InvoiceTemplate;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to invoice templates
*/
final class InvoiceTemplateMetaDefinitionEvent extends Event
{
public function __construct(private readonly InvoiceTemplate $entity)
{
}
public function getEntity(): InvoiceTemplate
{
return $this->entity;
}
}

View File

@@ -236,6 +236,8 @@ final class ColumnConverter
$columns[$column] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
} elseif ($column === 'project.order_number') {
$columns[$column] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
} elseif ($column === 'id') {
$columns[$column] = (new Column('id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getId());
} elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) {
$columns[$column] = $timesheetMeta[$column];
} elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) {

View File

@@ -34,12 +34,14 @@ class CustomerEditForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$isNew = false;
$hasAddress = false;
if (isset($options['data'])) {
/** @var Customer $customer */
$customer = $options['data'];
$isNew = $customer->getId() === null;
$options['currency'] = $customer->getCurrency();
$hasAddress = $customer->getAddress() !== null && $customer->getAddress() !== '';
}
$builder
@@ -69,10 +71,38 @@ class CustomerEditForm extends AbstractType
'label' => 'contact',
'required' => false,
])
->add('address', TextareaType::class, [
;
if ($hasAddress) {
$builder
->add('address', TextareaType::class, [
'label' => 'address',
'help' => 'address_deprecated',
'required' => false,
]);
}
$builder
->add('address_line1', TextType::class, [
'label' => 'address',
'required' => false,
])
->add('address_line2', TextType::class, [
'label' => false,
'required' => false,
])
->add('address_line3', TextType::class, [
'label' => false,
'required' => false,
])
->add('postcode', TextType::class, [
'label' => 'postcode',
'required' => false,
])
->add('city', TextType::class, [
'label' => 'city',
'required' => false,
])
->add('country', CountryType::class, [
'label' => 'country',
])
@@ -117,6 +147,10 @@ class CustomerEditForm extends AbstractType
'help' => 'help.invoiceTemplate_customer',
'required' => false,
])
->add('buyerReference', TextType::class, [
'label' => 'buyerReference',
'required' => false,
])
;
if ($isNew) {

View File

@@ -10,10 +10,12 @@
namespace App\Form;
use App\Entity\InvoiceTemplate;
use App\Form\Type\CustomerType;
use App\Form\Type\InvoiceCalculatorType;
use App\Form\Type\InvoiceNumberGeneratorType;
use App\Form\Type\InvoiceRendererType;
use App\Form\Type\LanguageType;
use App\Form\Type\MetaFieldsCollectionType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
@@ -36,17 +38,6 @@ final class InvoiceTemplateForm extends AbstractType
->add('title', TextType::class, [
'label' => 'title',
])
->add('company', TextType::class, [
'label' => 'company',
])
->add('vatId', TextType::class, [
'label' => 'vat_id',
'required' => false,
])
->add('address', TextareaType::class, [
'label' => 'address',
'required' => false,
])
->add('contact', TextareaType::class, [
'label' => 'contact',
'required' => false,
@@ -70,6 +61,13 @@ final class InvoiceTemplateForm extends AbstractType
->add('calculator', InvoiceCalculatorType::class)
->add('numberGenerator', InvoiceNumberGeneratorType::class)
->add('language', LanguageType::class)
->add('customer', CustomerType::class, [
'required' => true,
'label' => 'sending_company',
'placeholder' => '',
'help' => 'sending_company.help',
])
->add('metaFields', MetaFieldsCollectionType::class)
;
}

View File

@@ -9,7 +9,6 @@
namespace App\Form\MultiUpdate;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
@@ -42,7 +41,12 @@ final class MultiUpdateTable extends AbstractType
return [];
}
return $repository->matching((new Criteria())->where(Criteria::expr()->in('id', explode(',', $ids))));
$temp = explode(',', $ids);
if (\count($temp) === 0) {
return [];
}
return $repository->findBy(['id' => $temp]);
}
)
);

View File

@@ -21,7 +21,6 @@ use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Collections\Criteria;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -253,7 +252,12 @@ final class TimesheetMultiUpdate extends AbstractType
return [];
}
return $this->timesheet->matching((new Criteria())->where(Criteria::expr()->in('id', explode(',', $ids))));
$temp = explode(',', $ids);
if (\count($temp) === 0) {
return [];
}
return $this->timesheet->findBy(['id' => $temp]);
}
)
);

View File

@@ -147,7 +147,7 @@ final class DateRangeType extends AbstractType
}
/**
* @param array{'format': non-empty-string, 'separator': non-empty-string, 'allow_empty': true, 'timezone': non-empty-string} $options
* @param array{'format': non-empty-string, 'separator': non-empty-string, 'allow_empty': true, 'timezone': non-empty-string, 'user': User} $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void // @phpstan-ignore-line
{

View File

@@ -49,6 +49,7 @@ final class DurationType extends AbstractType
$class .= ' ' . $view->vars['attr']['class'];
}
$view->vars['attr']['class'] = $class;
$view->vars['attr']['autocomplete'] = 'off';
$view->vars['toggle'] = $options['toggle'];
if ($options['preset_hours'] !== null && $options['preset_minutes'] !== null) {

View File

@@ -49,6 +49,7 @@ final class ExportColumnsType extends AbstractType
{
$columns = [
'timesheet' => [
'id' => 'id',
'date' => 'date',
'begin' => 'begin',
'end' => 'end',

View File

@@ -11,15 +11,49 @@ namespace App\Invoice\Calculator;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow;
use App\Timesheet\Util;
abstract class AbstractCalculator
{
protected InvoiceModel $model;
/**
* @var InvoiceItem[]
*/
private array $cached = [];
/**
* TODO make this method abstract in 3.0
*
* @return InvoiceItem[]
*/
abstract public function getEntries(): array;
protected function calculateEntries(): array
{
return [];
}
/**
* TODO make this method final in 3.0
*
* @return InvoiceItem[]
*/
public function getEntries(): array
{
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()));
// 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()));
}
$this->cached[] = $entry;
}
}
return $this->cached;
}
/**
* @param array<InvoiceItem> $items
@@ -44,44 +78,58 @@ abstract class AbstractCalculator
public function getSubtotal(): float
{
$amount = 0.00;
foreach ($this->model->getEntries() as $entry) {
// using the entries and not the raw data, so we make sure to use the same base for everything
foreach ($this->getEntries() as $entry) {
$amount += $entry->getRate();
}
return round($amount, 2);
return round($amount, 2, PHP_ROUND_HALF_UP);
}
/**
* @deprecated use getTaxRows() instead
*/
public function getVat(): float
{
return $this->model->getTemplate()->getVat() ?? 0.00;
}
public function getTax(): float
/**
* @return array<TaxRow>
*/
public function getTaxRows(): array
{
$vat = $this->getVat();
if (0.00 === $vat) {
return 0.00;
$rows = [];
foreach ($this->model->getTemplate()->getTaxRates() as $taxRate) {
$rows[] = new TaxRow($taxRate, $this->getSubtotal());
}
$percent = $vat / 100.00;
return $rows;
}
return round($this->getSubtotal() * $percent, 2);
public function getTax(): float
{
$tax = 0.00;
foreach ($this->getTaxRows() as $row) {
$tax += $row->getAmount();
}
return round($tax, 2, PHP_ROUND_HALF_UP);
}
public function getTotal(): float
{
return $this->getSubtotal() + $this->getTax();
return round($this->getSubtotal() + $this->getTax(), 2, PHP_ROUND_HALF_UP);
}
/**
* Returns the total amount of worked time in seconds.
*
* @return int
*/
public function getTimeWorked(): int
{
$time = 0;
foreach ($this->model->getEntries() as $entry) {
// using the entries and not the raw data, so we make sure to use the same base for everything
foreach ($this->getEntries() as $entry) {
if (null !== $entry->getDuration()) {
$time += $entry->getDuration();
}

View File

@@ -43,7 +43,7 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
$invoiceItem->setAmount($invoiceItem->getAmount() + $amount);
$invoiceItem->setUser($entry->getUser());
$invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate());
$invoiceItem->setInternalRate($invoiceItem->getInternalRate() + ($entry->getInternalRate() ?? 0.00));
$invoiceItem->setInternalRate($invoiceItem->getInternalRate() + ($entry->getInternalRate() ?? 0.00)); // @phpstan-ignore method.deprecated,method.deprecated
$invoiceItem->setDuration($duration);
if (null !== $entry->getFixedRate()) {

View File

@@ -47,16 +47,16 @@ abstract class AbstractSumInvoiceCalculator extends AbstractMergedCalculator imp
$prefix = $this->calculateSumIdentifier($entry);
if (null !== $entry->getFixedRate()) {
return $prefix . '_fixed_' . (string) $entry->getFixedRate();
return $prefix . '_fixed_' . $entry->getFixedRate();
}
return $prefix . '_hourly_' . (string) $entry->getHourlyRate();
return $prefix . '_hourly_' . ($entry->getHourlyRate() ?? '__NULL__');
}
/**
* @return InvoiceItem[]
*/
public function getEntries(): array
protected function calculateEntries(): array
{
$entries = $this->model->getEntries();
if (empty($entries)) {

View File

@@ -23,7 +23,7 @@ final class DefaultCalculator extends AbstractMergedCalculator implements Calcul
/**
* @return InvoiceItem[]
*/
public function getEntries(): array
protected function calculateEntries(): array
{
$entries = [];

View File

@@ -23,7 +23,7 @@ final class PriceInvoiceCalculator extends AbstractSumInvoiceCalculator implemen
return ['fixed_' . $invoiceItem->getFixedRate()];
}
return ['hourly_' . $invoiceItem->getHourlyRate()];
return ['hourly_' . ($invoiceItem->getHourlyRate() ?? '__NULL__')];
}
public function getId(): string

View File

@@ -0,0 +1,41 @@
<?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\Calculator;
use App\Entity\ExportableItem;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItem;
/**
* A calculator that sums up the invoice item records by project and activity.
*/
final class ProjectActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements CalculatorInterface
{
public function getIdentifiers(ExportableItem $invoiceItem): array
{
return [
$invoiceItem->getProject()?->getId(),
$invoiceItem->getActivity()?->getId()
];
}
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, ExportableItem $entry): void
{
$project = $entry->getProject();
if ($project !== null) {
$invoiceItem->setDescription($project->getInvoiceText() ?? $project->getName());
}
}
public function getId(): string
{
return 'project_activity';
}
}

View File

@@ -44,14 +44,9 @@ final class ProjectUserInvoiceCalculator extends AbstractSumInvoiceCalculator im
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, ExportableItem $entry): void
{
if ($entry->getProject() === null) {
return;
}
if ($entry->getProject()->getInvoiceText() !== null) {
$invoiceItem->setDescription($entry->getProject()->getInvoiceText());
} else {
$invoiceItem->setDescription($entry->getProject()->getName());
$project = $entry->getProject();
if ($project !== null) {
$invoiceItem->setDescription($project->getInvoiceText() ?? $project->getName());
}
}

View File

@@ -21,7 +21,7 @@ final class ShortInvoiceCalculator extends AbstractMergedCalculator implements C
/**
* @return InvoiceItem[]
*/
public function getEntries(): array
protected function calculateEntries(): array
{
$entries = $this->model->getEntries();
if (empty($entries)) {

View File

@@ -26,39 +26,35 @@ interface CalculatorInterface
/**
* Set the invoice model and can be used to fetch the customer.
*
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model): void;
/**
* Returns the subtotal before taxes.
*
* @return float
*/
public function getSubtotal(): float;
/**
* Returns the tax amount for this invoice.
*
* @return float
*/
public function getTax(): float;
/**
* Returns the total amount for this invoice including taxes.
*
* @return float
*/
public function getTotal(): float;
/**
* Returns the percentage for the value-added tax (VAT) calculation.
*
* @return float
* @deprecated use getTaxRows() instead
*/
public function getVat(): float;
/**
* @return array<TaxRow>
*/
public function getTaxRows(): array;
/**
* Returns the total amount of worked time in seconds.
*
@@ -71,8 +67,6 @@ interface CalculatorInterface
*
* Prefix it with your company name followed by a hyphen (e.g. "acme-"),
* if this is a third-party calculator.
*
* @return string
*/
public function getId(): string;
}

View File

@@ -29,7 +29,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
$formatter = $this->model->getFormatter();
$rate = $item->getRate();
$internalRate = $item->getInternalRate();
$internalRate = $item->getInternalRate(); // @phpstan-ignore method.deprecated
$appliedRate = $item->getHourlyRate();
$amount = $formatter->getFormattedDecimalDuration($item->getDuration());
$description = $item->getDescription();
@@ -63,9 +63,9 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.rate' => $formatter->getFormattedMoney($appliedRate, $currency),
'entry.rate_nc' => $formatter->getFormattedMoney($appliedRate, $currency, false),
'entry.rate_plain' => $appliedRate,
'entry.rate_internal' => $formatter->getFormattedMoney($internalRate, $currency),
'entry.rate_internal_nc' => $formatter->getFormattedMoney($internalRate, $currency, false),
'entry.rate_internal_plain' => $internalRate,
'entry.rate_internal' => $formatter->getFormattedMoney($internalRate, $currency), // @deprecated since 2.41
'entry.rate_internal_nc' => $formatter->getFormattedMoney($internalRate, $currency, false), // @deprecated since 2.41
'entry.rate_internal_plain' => $internalRate, // @deprecated since 2.41
'entry.rate_fixed' => ($item->isFixedRate() ? $item->getFixedRate() : null),
'entry.total' => $formatter->getFormattedMoney($rate, $currency),
'entry.total_nc' => $formatter->getFormattedMoney($rate, $currency, false),

View File

@@ -12,6 +12,7 @@ namespace App\Invoice\Hydrator;
use App\Customer\CustomerStatisticService;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use Symfony\Component\Intl\Countries;
final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
{
@@ -25,22 +26,26 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
{
$customer = $model->getCustomer();
if (null === $customer) {
return [];
}
$prefix = 'customer.';
$language = $model->getTemplate()->getLanguage();
$country = $customer->getCountry();
$values = [
$prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getAddress() ?? '',
$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' => $customer->getVatId() ?? '', // deprecated since 2.0.15
$prefix . 'vat_id' => $customer->getVatId() ?? '',
$prefix . 'number' => $customer->getNumber() ?? '',
$prefix . 'country' => $customer->getCountry(),
$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() ?? '',
@@ -48,6 +53,7 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
$prefix . 'phone' => $customer->getPhone() ?? '',
$prefix . 'mobile' => $customer->getMobile() ?? '',
$prefix . 'invoice_text' => $customer->getInvoiceText() ?? '',
$prefix . 'buyer_reference' => $customer->getBuyerReference() ?? '',
];
$end = $model->getQuery()?->getEnd();

View File

@@ -11,6 +11,7 @@ namespace App\Invoice\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use Symfony\Component\Intl\Countries;
final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
{
@@ -19,16 +20,23 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
public function hydrate(InvoiceModel $model): array
{
$template = $model->getTemplate();
if ($template === null) {
throw new \InvalidArgumentException('InvoiceModel needs a template');
$calculator = $model->getCalculator();
if ($calculator === null) {
throw new \InvalidArgumentException('InvoiceModel needs a calculator');
}
$currency = $model->getCurrency();
$tax = $model->getCalculator()->getTax();
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$tax = $calculator->getTax();
$total = $calculator->getTotal();
$subtotal = $calculator->getSubtotal();
$formatter = $model->getFormatter();
$language = $template->getLanguage();
$taxRows = $calculator->getTaxRows();
$vat = 0.00;
foreach ($taxRows as $taxRow) {
$vat += $taxRow->getTax()->getRate();
}
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
@@ -39,13 +47,13 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.currency' => $currency,
'invoice.language' => $language, // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false),
'invoice.tax_plain' => $tax,
'invoice.total_time' => $formatter->getFormattedDuration($model->getCalculator()->getTimeWorked()),
'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()),
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), // @deprecated, use invoice.tax_rows instead
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead
'invoice.tax_plain' => $tax, // @deprecated, use invoice.tax_rows instead
'invoice.total_time' => $formatter->getFormattedDuration($calculator->getTimeWorked()),
'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($calculator->getTimeWorked()),
'invoice.total' => $formatter->getFormattedMoney($total, $currency),
'invoice.total_nc' => $formatter->getFormattedMoney($total, $currency, false),
'invoice.total_plain' => $total,
@@ -82,6 +90,26 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'user.see_others' => ($model->getQuery()?->getUser() === null),
];
$values['invoice.tax_rows'] = [];
foreach ($taxRows as $taxRow) {
$values['invoice.tax_rows'][] = [
'type' => $taxRow->getTax()->getType()->value,
'name' => $taxRow->getTax()->getName(),
'rate' => $taxRow->getTax()->getRate(),
'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
];
}
$seller = $template->getCustomer();
if ($seller !== null) {
$country = $seller->getCountry();
if ($country !== null) {
$values['template.country'] = $country;
$values['template.country_name'] = Countries::getName($country, $language);
}
}
$query = $model->getQuery();
if ($query !== null) {
$begin = $query->getBegin();

View File

@@ -19,6 +19,9 @@ final class InvoiceItem
private ?float $fixedRate = null;
private ?float $hourlyRate = null;
private float $rate = 0.00;
/**
* @deprecated since 2.41 - internal rate is not needed in invoices
*/
private float $rateInternal = 0.00;
private float $amount = 0.00;
private ?string $description = null;
@@ -85,9 +88,14 @@ final class InvoiceItem
return $this;
}
public function getAppliedRate(): float
{
return $this->fixedRate ?? $this->hourlyRate ?? 0.00;
}
public function isFixedRate(): bool
{
return null !== $this->getFixedRate();
return $this->fixedRate !== null;
}
public function getFixedRate(): ?float
@@ -126,11 +134,17 @@ final class InvoiceItem
return $this;
}
/**
* @deprecated since 2.41 - internal rate is not needed in invoices
*/
public function getInternalRate(): float
{
return $this->rateInternal;
}
/**
* @deprecated since 2.41 - internal rate is not needed in invoices
*/
public function setInternalRate(float $rateInternal): InvoiceItem
{
$this->rateInternal = $rateInternal;

View File

@@ -23,20 +23,20 @@ use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
/**
* InvoiceModel is the ONLY value that a RendererInterface receives for generating the invoice,
* besides the InvoiceDocument which is used as a "template".
*/
#[Exclude]
final class InvoiceModel
{
private ?Customer $customer = null;
private ?InvoiceQuery $query = null;
/**
* @var ExportableItem[]
*/
private array $entries = [];
private ?InvoiceTemplate $template = null;
private ?CalculatorInterface $calculator = null;
private ?NumberGeneratorInterface $generator = null;
private \DateTimeInterface $invoiceDate;
@@ -61,7 +61,14 @@ final class InvoiceModel
/**
* @internal use InvoiceModelFactory
*/
public function __construct(InvoiceFormatter $formatter, CustomerStatisticService $customerStatistic, ProjectStatisticService $projectStatistic, ActivityStatisticService $activityStatistic)
public function __construct(
InvoiceFormatter $formatter,
CustomerStatisticService $customerStatistic,
ProjectStatisticService $projectStatistic,
ActivityStatisticService $activityStatistic,
private readonly Customer $customer,
private readonly InvoiceTemplate $template,
)
{
$this->invoiceDate = new \DateTimeImmutable();
$this->formatter = $formatter;
@@ -138,37 +145,20 @@ final class InvoiceModel
return $this;
}
public function getTemplate(): ?InvoiceTemplate
public function getTemplate(): InvoiceTemplate
{
return $this->template;
}
public function setTemplate(InvoiceTemplate $template): void
{
$this->template = $template;
}
public function getCustomer(): ?Customer
public function getCustomer(): Customer
{
return $this->customer;
}
public function setCustomer(Customer $customer): void
{
$this->customer = $customer;
}
/**
* Requires the template and invoice date to be set
*/
public function getDueDate(): \DateTimeInterface
{
$date = \DateTimeImmutable::createFromInterface($this->getInvoiceDate());
$dueDays = 14;
if ($this->getTemplate() !== null) {
$dueDays = $this->getTemplate()->getDueDays();
}
$dueDays = $this->template->getDueDays();
return $date->add(new \DateInterval('P' . $dueDays . 'D'));
}
@@ -246,8 +236,8 @@ final class InvoiceModel
public function getCurrency(): string
{
if (null !== $this->getCustomer() && $this->getCustomer()->getCurrency() !== null) {
return $this->getCustomer()->getCurrency();
if ($this->customer->getCurrency() !== null) {
return $this->customer->getCurrency();
}
return Customer::DEFAULT_CURRENCY;

View File

@@ -27,10 +27,8 @@ final class InvoiceModelFactory
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService);
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template);
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setQuery($query);
return $model;

View File

@@ -53,11 +53,11 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
throw new \InvalidArgumentException('Missing invoice model, cannot calculate invoice number');
}
if ($format === 'cname' && $this->model->getCustomer()?->getName() === null) {
if ($format === 'cname' && $this->model->getCustomer()->getName() === null) {
throw new \InvalidArgumentException('Customer has no name, replacer {cname} failed evaluation');
}
if ($format === 'cnumber' && $this->model->getCustomer()?->getNumber() === null) {
if ($format === 'cnumber' && $this->model->getCustomer()->getNumber() === null) {
throw new \InvalidArgumentException('Customer has no number, replacer {cnumber} failed evaluation');
}
@@ -87,8 +87,8 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
'cy' => $this->repository->getCounterForYear($invoiceDate) + $increaseBy,
'cm' => $this->repository->getCounterForMonth($invoiceDate) + $increaseBy,
'cd' => $this->repository->getCounterForDay($invoiceDate) + $increaseBy,
'cname' => (string) $this->model->getCustomer()?->getName(),
'cnumber' => (string) $this->model->getCustomer()?->getNumber(),
'cname' => (string) $this->model->getCustomer()->getName(),
'cnumber' => (string) $this->model->getCustomer()->getNumber(),
default => $originalFormat,
};
});

View File

@@ -29,6 +29,11 @@ final class DocxRenderer extends AbstractRenderer implements RendererInterface
$template = new TemplateProcessor($document->getFilename());
foreach ($model->toArray() as $search => $replace) {
if (\is_array($replace)) {
// TODO tax rows
continue;
}
$replace = $xmlEscaper->escape($replace);
$replace = preg_replace('/\n|\r\n?/', '</w:t><w:br /><w:t xml:space="preserve">', $replace);

39
src/Invoice/TaxRow.php Normal file
View File

@@ -0,0 +1,39 @@
<?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;
use App\Entity\Tax;
final class TaxRow
{
public function __construct(
private readonly Tax $tax,
private readonly float $basePrice = 0.0
)
{
}
public function getTax(): Tax
{
return $this->tax;
}
public function getBasePrice(): float
{
return $this->basePrice;
}
public function getAmount(): float
{
$percent = $this->tax->getRate() / 100.00;
return round($this->basePrice * $percent, 4);
}
}

View File

@@ -34,7 +34,7 @@ final class MPdfConverter implements HtmlToPdfConverter
$filtered = array_filter($options, function ($key): bool {
$allowed = [
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files'
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files', 'additional_xmp_rdf'
];
if (!\in_array($key, $allowed)) {
$configs = new ConfigVariables();
@@ -110,6 +110,12 @@ final class MPdfConverter implements HtmlToPdfConverter
unset($options['associated_files']);
}
$additionalXmpRdf = null;
if (\array_key_exists('additional_xmp_rdf', $options) && \is_string($options['additional_xmp_rdf'])) {
$additionalXmpRdf = $options['additional_xmp_rdf'];
unset($options['additional_xmp_rdf']);
}
$mpdf = new Mpdf($options);
$mpdf->creator = Constants::SOFTWARE;
@@ -117,6 +123,10 @@ final class MPdfConverter implements HtmlToPdfConverter
$mpdf->SetAssociatedFiles($associatedFiles);
}
if ($additionalXmpRdf !== null) {
$mpdf->SetAdditionalXmpRdf($additionalXmpRdf);
}
return $mpdf;
}

View File

@@ -9,6 +9,7 @@
namespace App\Project;
use App\Activity\ActivityService;
use App\Entity\Project;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
@@ -21,7 +22,8 @@ final class ProjectDuplicationService
private readonly ProjectService $projectService,
private readonly ActivityRepository $activityRepository,
private readonly ProjectRateRepository $projectRateRepository,
private readonly ActivityRateRepository $activityRateRepository
private readonly ActivityRateRepository $activityRateRepository,
private readonly ActivityService $activityService
) {
}
@@ -68,7 +70,7 @@ final class ProjectDuplicationService
$newActivity->setMetaField($newMetaField);
}
$this->activityRepository->saveActivity($newActivity);
$this->activityService->saveActivity($newActivity);
foreach ($this->activityRateRepository->getRatesForActivity($activity) as $rate) {
$newRate = clone $rate;

View File

@@ -9,7 +9,7 @@
namespace App\Repository\Query;
class TagQuery extends BaseQuery
class TagQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;

View File

@@ -20,10 +20,9 @@ trait VisibilityTrait
public function setVisibility(int $visibility): void
{
if (!\in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
throw new \InvalidArgumentException('Unknown visibility given');
if (\in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
$this->visibility = $visibility;
}
$this->visibility = $visibility;
}
public function isShowHidden(): bool
@@ -36,6 +35,9 @@ trait VisibilityTrait
return $this->visibility === VisibilityInterface::SHOW_VISIBLE;
}
/**
* @deprecated since 2.41
*/
public function setShowBoth(): void
{
$this->setVisibility(VisibilityInterface::SHOW_BOTH);

View File

@@ -43,8 +43,10 @@ final class SearchHelper
$rootAlias = $aliases[0];
$searchAnd = $qb->expr()->andX();
$metaFieldClass = $this->configuration->getMetaFieldClass();
$metaFieldName = $this->configuration->getMetaFieldName();
if ($this->supportsMetaFields()) {
if ($metaFieldClass !== null && $metaFieldName !== null && $this->supportsMetaFields()) {
$metaFieldRef = $rootAlias . '.' . $this->configuration->getEntityFieldName();
$i = 0;
$c = 0;
@@ -69,7 +71,7 @@ final class SearchHelper
$and->add($qb->expr()->isNotNull($field));
} elseif ($metaValue === '~') {
$and->add(
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $this->configuration->getMetaFieldClass(), $subqueryName, $subqueryName, $this->configuration->getMetaFieldName(), $rootAlias, $subqueryName, $paramName)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $metaFieldClass, $subqueryName, $subqueryName, $metaFieldName, $rootAlias, $subqueryName, $paramName)
);
} elseif ($metaValue === '') {
$and->add(
@@ -78,7 +80,7 @@ final class SearchHelper
$qb->expr()->eq($alias . '.name', ':' . $paramName),
$qb->expr()->isNull($field)
),
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $this->configuration->getMetaFieldClass(), $subqueryName, $subqueryName, $this->configuration->getMetaFieldName(), $rootAlias, $subqueryName, $paramName)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $metaFieldClass, $subqueryName, $subqueryName, $metaFieldName, $rootAlias, $subqueryName, $paramName)
)
);
} else {

View File

@@ -54,7 +54,7 @@ final class SamlProvider
} catch (\Exception $ex) {
$this->logger->error($ex->getMessage());
throw new AuthenticationException(
\sprintf('Failed creating or hydrating user "%s": %s', $token->getUserIdentifier(), $ex->getMessage())
\sprintf('Failed creating or hydrating user "%s": %s', $token->getUserIdentifier() ?? '*unknown*', $ex->getMessage())
);
}

View File

@@ -23,8 +23,18 @@ final class Util
*/
public static function calculateRate(float $hourlyRate, int $seconds): float
{
$rate = $hourlyRate * ($seconds / 3600);
$rate = $hourlyRate * round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return round($rate, 4);
return round($rate, 2, PHP_ROUND_HALF_UP);
}
/**
* Makes sure tha the duration is full compatible with decimal format, stripping away overflowing seconds.
*/
public static function decimalizeDuration(int $seconds): int
{
$decimal = round(($seconds / 3600), 2, PHP_ROUND_HALF_UP);
return (int) round(($decimal * 3600), 0, PHP_ROUND_HALF_UP);
}
}

View File

@@ -10,10 +10,12 @@
namespace App\Utils;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
#[Exclude]
final class Context
{
public function __construct(private User $user)
public function __construct(private readonly User $user)
{
}

View File

@@ -22,12 +22,15 @@ class Parsedown extends \Parsedown
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
if (\is_string($text) && $text !== '') {
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
}
return $block;
}
@@ -67,7 +70,7 @@ class Parsedown extends \Parsedown
return $text;
}
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);

View File

@@ -45,7 +45,7 @@ final class WorkingTimeModeFactory
return $this->getMode($user->getWorkContractMode());
} catch (\InvalidArgumentException $ex) {
$this->logger->error(
\sprintf('Unknown mode "%s" requested for user %s', $user->getWorkContractMode(), $user->getId())
\sprintf('Unknown mode "%s" requested for user %s', $user->getWorkContractMode(), $user->getUserIdentifier())
);
return new WorkingTimeModeNone(); // @CloudRequired

View File

@@ -38,10 +38,31 @@
</td>
</tr>
{% endif %}
{% if customer.addressLine1 is not empty %}
<tr>
<th>{{ 'address'|trans }}</th>
<td>
{{ customer.addressLine1 }}<br>
{% if customer.addressLine2 is not empty %}
{{ customer.addressLine2 }}<br>
{% endif %}
{% if customer.addressLine3 is not empty %}
{{ customer.addressLine3 }}<br>
{% endif %}
{{ customer.postCode }} {{ customer.city }}
</td>
</tr>
{% endif %}
{% if customer.address is not empty %}
<tr>
<th>{{ 'address'|trans }}</th>
<td>{{ customer.address|nl2br }}</td>
<td>
{{ widgets.alert('info', 'address_deprecated'|trans) }}
<br>
<i>
{{ customer.address|nl2br }}
</i>
</td>
</tr>
{% endif %}
{% if customer.country is not empty %}

View File

@@ -18,28 +18,16 @@
</div>
</div>
{{ form_row(form.comment) }}
{{ form_row(form.address) }}
<div class="row">
<div class="col-md-4">
{{ form_row(form.company) }}
</div>
<div class="col-md-4">
{{ form_row(form.number) }}
</div>
<div class="col-md-4">
{{ form_row(form.vatId) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
{{ form_row(form.country) }}
</div>
<div class="col-md-4">
{{ form_row(form.currency) }}
</div>
<div class="col-md-4">
{{ form_row(form.timezone) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
@@ -87,21 +75,66 @@
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.visible) }}
</div>
<div class="col-md-6">
{{ form_row(form.billable) }}
</div>
</div>
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_address_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'address'|trans }}{% endblock %}
{% block body %}
<div class="row">
<div class="col-12">
{{ form_row(form.company) }}
</div>
</div>
{% if form.address is defined %}
<div class="row">
<div class="col-12">
{{ form_row(form.address) }}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-12">
{{ form_row(form.address_line1, {attr: {placeholder: 'address_row'|trans({'%row%': 1})}}) }}
</div>
</div>
<div class="row">
<div class="col-12">
{{ form_row(form.address_line2, {attr: {placeholder: 'address_row'|trans({'%row%': 2})}}) }}
</div>
</div>
<div class="row">
<div class="col-12">
{{ form_row(form.address_line3, {attr: {placeholder: 'address_row'|trans({'%row%': 3})}}) }}
</div>
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.postcode) }}
</div>
<div class="col-md-9">
{{ form_row(form.city) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.country) }}
</div>
<div class="col-md-6">
{{ form_row(form.timezone) }}
</div>
</div>
{% endblock %}
{% endembed %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.buyerReference is defined %}
{{ form_row(form.buyerReference) }}
{% endif %}
{% if form.invoiceTemplate is defined %}
{{ form_row(form.invoiceTemplate) }}
{% endif %}
@@ -110,6 +143,14 @@
{% endif %}
{% endblock %}
{% endembed %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.visible) }}
</div>
<div class="col-md-6">
{{ form_row(form.billable) }}
</div>
</div>
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -48,6 +48,10 @@ mpdf-->
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong>
<br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['customer.address'] %}
<br> {{ country }}
{% endif %}
{% if invoice['customer.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
@@ -59,6 +63,12 @@ mpdf-->
<strong>{{ invoice['template.company'] }}</strong>
<br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
<br> {{ country }}
{% endif %}
{% endif %}
{% if invoice['template.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}:

View File

@@ -25,6 +25,10 @@
<address contenteditable="true">
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['customer.address'] %}
<br> {{ country }}
{% endif %}
{% if invoice['customer.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
@@ -45,6 +49,12 @@
<address contenteditable="true">
<strong>{{ invoice['template.company'] }}</strong><br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
<br> {{ country }}
{% endif %}
{% endif %}
{% if invoice['template.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}:

View File

@@ -14,6 +14,12 @@
<tr>
<td class="text-small">
{{ invoice['template.company'] }} &ndash; {{ invoice['template.address']|nl2str(' – ') }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
– {{ country }}
{% endif %}
{% endif %}
{% if invoice['template.vat_id'] is not empty %}
&ndash; {{ 'vat_id'|trans }}: {{ invoice['template.vat_id'] }}
{% endif %}
@@ -33,6 +39,12 @@
<br>
{{ invoice['template.company'] }}<br>
{{ invoice['template.address']|nl2br }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
<br> {{ country }}
{% endif %}
{% endif %}
</td>
<td class="text-center">
<strong>{{ 'invoice_bank_account'|trans }}</strong>
@@ -54,6 +66,10 @@ mpdf-->
<td>
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['customer.address'] %}
<br> {{ country }}
{% endif %}
</td>
<td class="text-right">
{% set classLeft = 'text-left' %}

View File

@@ -18,25 +18,19 @@
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.company) }}
</div>
<div class="col-md-6">
{{ form_row(form.vatId) }}
<div class="col-md-12">
{{ form_row(form.customer) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.address) }}
<div class="col-md-12">
{{ form_row(form.paymentTerms) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.contact) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.paymentTerms) }}
</div>
<div class="col-md-6">
{{ form_row(form.paymentDetails) }}
</div>
@@ -70,6 +64,15 @@
</div>
</div>
</fieldset>
{% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset class="form-fieldset pb-0">
<div class="row">
<div class="col">
{{ form_row(form.metaFields) }}
</div>
</div>
</fieldset>
{% endif %}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -26,14 +26,6 @@
{{ form_row(form.number) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.orderNumber) }}
</div>
<div class="col-md-6">
{{ form_row(form.orderDate) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.start) }}
@@ -66,6 +58,26 @@
</div>
</div>
{% endif %}
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.orderNumber) }}
</div>
<div class="col-md-6">
{{ form_row(form.orderDate) }}
</div>
</div>
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{% endblock %}
{% endembed %}
<div class="row">
<div class="col-md-4">
{{ form_row(form.visible) }}
@@ -77,18 +89,6 @@
{{ form_row(form.globalActivities) }}
</div>
</div>
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{% endblock %}
{% endembed %}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -487,6 +487,12 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'timeBudget' => 'int',
'vatId' => '@string', // since 1.10
'budgetType' => '@string', // since 1.15
'addressLine1' => '@string', // since 2.32
'addressLine2' => '@string', // since 2.32
'addressLine3' => '@string', // since 2.32
'city' => '@string', // since 2.32
'postCode' => '@string', // since 2.32
'buyerReference' => '@string', // since 2.41
];
// if a project is embedded

View File

@@ -422,12 +422,12 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
'begin' => '2020-03-27T14:35:00+1300',
'end' => '2020-03-28T03:30:00+1300',
'description' => "**foo**\nbar",
'duration' => 46500,
'duration' => 46500, // 12,916 => rounded 12,92 * 137,21 = 46512
'exported' => true,
'metaFields' => [],
'hourlyRate' => 137.21,
'rate' => 1772.2958,
'internalRate' => 1772.2958,
'rate' => 1772.75, // 12,92 * 137,21
'internalRate' => 1772.75,
];
foreach ($expected as $key => $value) {

View File

@@ -0,0 +1,72 @@
<?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\Tests\API;
use App\API\ViewHandler;
use App\Utils\Pagination;
use FOS\RestBundle\View\ConfigurableViewHandlerInterface;
use FOS\RestBundle\View\View;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[CoversClass(ViewHandler::class)]
class ViewHandlerTest extends TestCase
{
public function testValues(): void
{
$base = $this->createMock(ConfigurableViewHandlerInterface::class);
$base->expects($this->once())->method('supports')->willReturn(true);
$base->expects($this->exactly(2))->method('setExclusionStrategyGroups');
$base->expects($this->once())->method('setExclusionStrategyVersion');
$base->expects($this->once())->method('setSerializeNullStrategy');
$base->expects($this->once())->method('registerHandler');
$base->expects($this->once())->method('createRedirectResponse')->willReturn(new Response());
$base->expects($this->once())->method('createResponse')->willReturn(new Response());
$base->expects($this->once())->method('handle')->willReturn(new Response());
$sut = new ViewHandler($base);
self::assertTrue($sut->supports('asdf'));
$sut->setExclusionStrategyGroups(['bar', 'test']);
$sut->setExclusionStrategyGroups('foo');
$sut->setExclusionStrategyVersion('1.0');
$sut->setSerializeNullStrategy(true);
$sut->registerHandler('bla', function () {});
$response = $sut->createRedirectResponse(new View('bar123'), 'https://www.example.com', 'json');
self::assertInstanceOf(Response::class, $response);
$response = $sut->createResponse(new View('bar123'), new Request(), 'json');
self::assertInstanceOf(Response::class, $response);
$results = ['foo' => 'bar', 'hello' => 'world'];
$pagination = new Pagination(new ArrayAdapter($results));
$view = new View($pagination);
$headers = $view->getHeaders();
self::assertSame($pagination, $view->getData());
self::assertArrayNotHasKey('x-page', $headers);
self::assertArrayNotHasKey('x-total-count', $headers);
self::assertArrayNotHasKey('x-total-pages', $headers);
self::assertArrayNotHasKey('x-per-page', $headers);
$response = $sut->handle($view, new Request());
self::assertInstanceOf(Response::class, $response);
$headers = $view->getHeaders();
self::assertSame($results, $view->getData());
self::assertArrayHasKey('x-page', $headers);
self::assertArrayHasKey('x-total-count', $headers);
self::assertArrayHasKey('x-total-pages', $headers);
self::assertArrayHasKey('x-per-page', $headers);
}
}

View File

@@ -102,9 +102,9 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
$form = $client->getCrawler()->filter('form[name=invoice_template_form]')->form();
$client->submit($form, [
'invoice_template_form' => [
'name' => 'Test',
'name' => 'FooBar Template',
'title' => 'Test invoice template',
'company' => 'Company name',
'customer' => 1,
'renderer' => 'default',
'calculator' => 'default',
'vat' => '27,937',
@@ -117,9 +117,9 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
$this->assertHasFlashSuccess($client);
$template = $this->getEntityManager()->getRepository(InvoiceTemplate::class)->findAll()[0];
self::assertEquals('Test', $template->getName());
self::assertEquals('FooBar Template', $template->getName());
self::assertEquals('Test invoice template', $template->getTitle());
self::assertEquals('Company name', $template->getCompany());
self::assertEquals('Test', $template->getCompany());
self::assertEquals('default', $template->getRenderer());
self::assertEquals('default', $template->getCalculator());
self::assertEquals('27.937', $template->getVat());
@@ -145,8 +145,6 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
self::assertEquals($template->getCalculator(), $values['calculator']);
self::assertEquals($template->getVat(), $values['vat']);
self::assertEquals($template->getRenderer(), $values['renderer']);
self::assertEquals($template->getCompany(), $values['company']);
self::assertEquals($template->getAddress(), $values['address']);
self::assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
}
@@ -408,7 +406,7 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
'invoice_template_form' => [
'name' => 'Test 2!',
'title' => 'Test invoice template',
'company' => 'Company name',
'customer' => 1,
'renderer' => 'default',
'calculator' => 'default',
]

View File

@@ -9,6 +9,7 @@
namespace App\Tests\DataFixtures;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
@@ -27,10 +28,21 @@ class InvoiceTemplateFixtures implements TestFixture
$faker = Factory::create();
$customer = new Customer($faker->company());
$customer->setCountry($faker->countryCode());
$customer->setTimezone($faker->timezone());
$customer->setCompany('Company name');
$customer->setAddressLine1($faker->streetAddress());
$customer->setAddressLine2($faker->streetAddress());
$customer->setPostCode($faker->postcode());
$customer->setCity($faker->city());
$manager->persist($customer);
$template = new InvoiceTemplate();
$template->setName('Invoice');
$template->setTitle('Your company name');
$template->setCompany($faker->company());
$template->setCustomer($customer);
$template->setVat(19);
$template->setDueDays(14);
$template->setPaymentTerms(
@@ -39,9 +51,7 @@ class InvoiceTemplateFixtures implements TestFixture
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
'as reference.'
);
$template->setAddress(
$faker->streetAddress() . PHP_EOL .
$faker->city() . ' ' . $faker->postcode() . ', ' . $faker->country() . PHP_EOL .
$template->setContact(
'Phone: ' . $faker->phoneNumber() . PHP_EOL .
'Email: ' . $faker->safeEmail()
);

View File

@@ -35,6 +35,13 @@ class CustomerTest extends AbstractEntityTestCase
self::assertNull($sut->getVatId());
self::assertNull($sut->getContact());
self::assertNull($sut->getAddress());
self::assertNull($sut->getAddressLine1());
self::assertNull($sut->getAddressLine2());
self::assertNull($sut->getAddressLine3());
self::assertNull($sut->getFormattedAddress());
self::assertNull($sut->getCity());
self::assertNull($sut->getPostCode());
self::assertNull($sut->getBuyerReference());
self::assertNull($sut->getCountry());
self::assertEquals('EUR', $sut->getCurrency());
self::assertEquals('EUR', Customer::DEFAULT_CURRENCY);
@@ -137,6 +144,51 @@ class CustomerTest extends AbstractEntityTestCase
$sut->setCurrency(null);
self::assertNull($sut->getCurrency());
$sut->setBuyerReference('BR-876876876876');
self::assertEquals('BR-876876876876', $sut->getBuyerReference());
$sut->setBuyerReference(null);
self::assertNull($sut->getBuyerReference());
$sut->setAddressLine1('address line 1');
$sut->setAddressLine2('address line 2');
$sut->setAddressLine3('address line 3');
$sut->setCity('looney toon');
$sut->setPostcode('zip 12345');
$sut->setAddress('foo bar
sdfsadf
sdfarwt34
786876 uitiutziuz');
self::assertEquals('address line 1', $sut->getAddressLine1());
self::assertEquals('address line 2', $sut->getAddressLine2());
self::assertEquals('address line 3', $sut->getAddressLine3());
self::assertEquals('looney toon', $sut->getCity());
self::assertEquals('zip 12345', $sut->getPostCode());
self::assertEquals('foo bar
sdfsadf
sdfarwt34
786876 uitiutziuz', $sut->getAddress());
self::assertEquals('address line 1
address line 2
address line 3
zip 12345 looney toon', $sut->getFormattedAddress());
$sut->setAddressLine1(null);
$sut->setAddressLine2(null);
$sut->setAddressLine3(null);
$sut->setCity(null);
$sut->setPostcode(null);
$sut->setAddress(null);
self::assertNull($sut->getAddress());
self::assertNull($sut->getAddressLine1());
self::assertNull($sut->getAddressLine2());
self::assertNull($sut->getAddressLine3());
self::assertNull($sut->getFormattedAddress());
self::assertNull($sut->getCity());
self::assertNull($sut->getPostCode());
}
public function testMetaFields(): void
@@ -212,6 +264,11 @@ class CustomerTest extends AbstractEntityTestCase
['mobile', 'string'],
['fax', 'string'],
['homepage', 'string'],
['address_line1', 'string'],
['address_line2', 'string'],
['address_line3', 'string'],
['postcode', 'string'],
['city', 'string'],
['country', 'string'],
['currency', 'string'],
['timezone', 'string'],
@@ -222,6 +279,7 @@ class CustomerTest extends AbstractEntityTestCase
['visible', 'boolean'],
['comment', 'string'],
['billable', 'boolean'],
['buyer_reference', 'string'],
];
self::assertCount(\count($expected), $columns);

View File

@@ -0,0 +1,40 @@
<?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\Tests\Entity;
use App\Entity\EntityWithMetaFields;
use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(InvoiceTemplateMeta::class)]
class InvoiceTemplateMetaTest extends AbstractMetaEntityTestCase
{
protected function getEntity(): EntityWithMetaFields
{
return new InvoiceTemplate();
}
protected function getMetaEntity(): MetaTableTypeInterface
{
return new InvoiceTemplateMeta();
}
public function testSetEntityThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Expected instanceof InvoiceTemplate, received "App\Entity\Timesheet"');
$sut = new InvoiceTemplateMeta();
$sut->setEntity(new Timesheet());
}
}

View File

@@ -9,7 +9,11 @@
namespace App\Tests\Entity;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -35,59 +39,118 @@ class InvoiceTemplateTest extends TestCase
self::assertEquals('default', $sut->getRenderer());
self::assertEquals(30, $sut->getDueDays());
self::assertTrue($sut->isDecimalDuration()); // @phpstan-ignore method.deprecated
}
public function testSetNullForOptionalValues(): void
{
$sut = new InvoiceTemplate();
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setAddress(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms(null));
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count());
self::assertNull($sut->getMetaField('foo'));
}
public function testSetterAndGetter(): void
{
$sut = new InvoiceTemplate();
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms('foo bar'));
$sut->setPaymentTerms('foo bar');
self::assertEquals('foo bar', $sut->getPaymentTerms());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf'));
$sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf');
self::assertEquals('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf', $sut->getPaymentDetails());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact('hello world'));
$sut->setContact('hello world');
self::assertEquals('hello world', $sut->getContact());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVat(7.31));
$sut->setVat(7.31);
self::assertEquals(7.31, $sut->getVat());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId('1234567890'));
$sut->setVatId('1234567890'); // @phpstan-ignore method.deprecated
self::assertEquals('1234567890', $sut->getVatId());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
$sut->setLanguage('de');
self::assertEquals('de', $sut->getLanguage());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setNumberGenerator('foo'));
$sut->setNumberGenerator('foo');
self::assertEquals('foo', $sut->getNumberGenerator());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setRenderer('bar'));
$sut->setRenderer('bar');
self::assertEquals('bar', $sut->getRenderer());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setCalculator('fooBar'));
$sut->setCalculator('fooBar');
self::assertEquals('fooBar', $sut->getCalculator());
$sut->setCompany('looney toon'); // @phpstan-ignore method.deprecated
self::assertEquals('looney toon', $sut->getCompany());
$sut->setAddress('acme street, 1234 looney town, rainbow'); // @phpstan-ignore method.deprecated
self::assertEquals('acme street, 1234 looney town, rainbow', $sut->getAddress());
self::assertEquals($sut, clone $sut);
$customer = new Customer('foo');
$customer->setVatId('0987654321');
$customer->setCompany('bar');
$customer->setAddressLine1('elmstreet');
$customer->setAddressLine2('2nd floor');
$customer->setPostCode('4711');
$customer->setCity('Over the rainbow');
$sut->setCustomer($customer);
self::assertEquals('elmstreet
2nd floor
4711 Over the rainbow', $sut->getAddress());
self::assertEquals('bar', $sut->getCompany());
self::assertEquals('0987654321', $sut->getVatId());
}
public function testToString(): void
{
$sut = new InvoiceTemplate();
self::assertInstanceOf(InvoiceTemplate::class, $sut->setName('a template name'));
$sut->setName('a template name');
self::assertEquals('a template name', $sut->__toString());
self::assertEquals('a template name', (string) $sut);
}
public function testMetaFields(): void
{
$sut = new InvoiceTemplate();
$meta = new InvoiceTemplateMeta();
$meta->setName('foo')->setValue('bar2')->setType('test');
self::assertInstanceOf(InvoiceTemplate::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test', $result->getType());
self::assertEquals('bar2', $result->getValue());
$meta2 = new InvoiceTemplateMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
self::assertInstanceOf(InvoiceTemplate::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test2', $result->getType());
$sut->setMetaField((new InvoiceTemplateMeta())->setName('blub')->setIsVisible(true));
$sut->setMetaField((new InvoiceTemplateMeta())->setName('blab')->setIsVisible(true));
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
public function testThrowsOnMetaFieldWithoutName(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Meta-field needs to have a name');
$sut = new InvoiceTemplate();
$sut->setMetaField(new CustomerMeta());
}
public function testThrowsOnMetaFieldsWrongType(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Meta-field needs to be an instanceof InvoiceTemplateMeta');
$sut = new InvoiceTemplate();
$meta = new CustomerMeta();
$meta->setName('foo');
$sut->setMetaField($meta);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Tests\Event;
use App\Entity\InvoiceTemplate;
use App\Event\InvoiceTemplateMetaDefinitionEvent;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoiceTemplateMetaDefinitionEvent::class)]
class InvoiceTemplateMetaDefinitionEventTest extends TestCase
{
public function testGetterAndSetter(): void
{
$invoiceTemplate = new InvoiceTemplate();
$sut = new InvoiceTemplateMetaDefinitionEvent($invoiceTemplate);
self::assertSame($invoiceTemplate, $sut->getEntity());
}
}

View File

@@ -209,6 +209,7 @@ class ColumnConverterTest extends TestCase
$template = new Template('bar', 'foo');
$template->setColumns([
'id',
'date',
'begin',
'end',
@@ -252,6 +253,7 @@ class ColumnConverterTest extends TestCase
$columns = $sut->getColumns($template, $query);
$expected = [
'id',
'date',
'begin',
'end',

View File

@@ -52,6 +52,13 @@ class CustomerExporterTest extends TestCase
$customer->setMetaField((new CustomerMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
$customer->setMetaField((new CustomerMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
$customer->setMetaField((new CustomerMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
$customer->setCountry('AT');
$customer->setAddressLine1('1234 Main Road');
$customer->setAddressLine2('Third Floor');
$customer->setAddressLine3('Golden Office Tower');
$customer->setCity('Acme City');
$customer->setPostCode('MT 543.6');
$customer->setBuyerReference('BR-0987654321');
$sut = new EntityWithMetaFieldsExporter($spreadsheetExporter, $annotationExtractor, $metaFieldExtractor);
$spreadsheet = $sut->export(Customer::class, [$customer], new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT));
@@ -71,7 +78,12 @@ class CustomerExporterTest extends TestCase
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // mobile
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // fax
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // homepage
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // country
self::assertEquals('1234 Main Road', $worksheet->getCell([++$i, 2])->getValue()); // address_line1
self::assertEquals('Third Floor', $worksheet->getCell([++$i, 2])->getValue()); // address_line2
self::assertEquals('Golden Office Tower', $worksheet->getCell([++$i, 2])->getValue()); // address_line3
self::assertEquals('MT 543.6', $worksheet->getCell([++$i, 2])->getValue()); // postcode
self::assertEquals('Acme City', $worksheet->getCell([++$i, 2])->getValue()); // city
self::assertEquals('AT', $worksheet->getCell([++$i, 2])->getValue()); // country
self::assertEquals('EUR', $worksheet->getCell([++$i, 2])->getValue()); // currency
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // timezone
self::assertEquals('123456.789', $worksheet->getCell([++$i, 2])->getValue()); // budget
@@ -81,6 +93,7 @@ class CustomerExporterTest extends TestCase
self::assertFalse($worksheet->getCell([++$i, 2])->getValue()); // visible
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue()); // comment
self::assertTrue($worksheet->getCell([++$i, 2])->getValue()); // billable
self::assertEquals('BR-0987654321', $worksheet->getCell([++$i, 2])->getValue()); // buyer reference
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
}

View File

@@ -17,6 +17,7 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use App\Tests\Mocks\InvoiceModelFactoryFactory;
@@ -43,11 +44,21 @@ abstract class AbstractCalculatorTestCase extends TestCase
$sut->setModel($model);
self::assertEquals(0, $sut->getTotal());
self::assertEquals(0, $sut->getVat());
self::assertEquals(0, $sut->getSubtotal());
self::assertEquals(0, $sut->getTimeWorked());
self::assertEquals(0, \count($sut->getEntries()));
self::assertEquals(0, $sut->getTax());
self::assertTax($sut, 0);
}
protected function assertTax(CalculatorInterface $sut, int $rate): void
{
self::assertEquals($rate, $sut->getVat()); // @phpstan-ignore method.deprecated
$rows = $sut->getTaxRows();
self::assertCount(1, $rows);
self::assertInstanceOf(TaxRow::class, $rows[0]);
self::assertEquals($rate, $rows[0]->getTax()->getRate());
}
private function getEmptyModel(): InvoiceModel

View File

@@ -138,7 +138,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('activity', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -150,7 +150,7 @@ class ActivityUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('activity_user', $sut->getId());
self::assertEquals(3100.09, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2605.12, $sut->getSubtotal());
self::assertEquals(7000, $sut->getTimeWorked());

View File

@@ -114,7 +114,7 @@ class DateInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('date', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -116,7 +116,7 @@ class DateUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('date_user', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -72,7 +72,7 @@ class DefaultCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('default', $sut->getId());
self::assertEquals(581.17, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(488.38, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());

View File

@@ -68,13 +68,16 @@ class PriceInvoiceCalculatorTest extends AbstractCalculatorTestCase
$timesheet2 = new Timesheet();
$timesheet2->setBegin(new DateTime('2018-11-29'));
$timesheet2->setEnd(new DateTime());
$timesheet2->setDuration(400);
$timesheet2->setDuration(400); // 396
$timesheet2->setHourlyRate(293.27);
$timesheet2->setRate(84.75);
$timesheet2->setRate(84.75); // 32,26
$timesheet2->setUser($user);
$timesheet2->setActivity((new Activity())->setName('bar'));
$timesheet2->setProject($project2);
// 325,53
// duration 4000 = 3996
$timesheet3 = new Timesheet();
$timesheet3->setBegin(new DateTime('2018-11-28'));
$timesheet3->setEnd(new DateTime());
@@ -85,25 +88,34 @@ class PriceInvoiceCalculatorTest extends AbstractCalculatorTestCase
$timesheet3->setActivity((new Activity())->setName('foo'));
$timesheet3->setProject($project1);
// 325,53+111,11
// duration 1800
$timesheet4 = new Timesheet();
$timesheet4->setBegin(new DateTime('2018-11-28'));
$timesheet4->setEnd(new DateTime());
$timesheet4->setDuration(400);
$timesheet4->setDuration(400); // 396
$timesheet4->setHourlyRate(0);
$timesheet4->setRate(1947.99);
$timesheet4->setUser($user);
$timesheet4->setActivity((new Activity())->setName('blub'));
$timesheet4->setProject($project2);
// 325,53+111,11+1947,99
// duration 400
$timesheet5 = new Timesheet();
$timesheet5->setBegin(new DateTime('2018-11-28'));
$timesheet5->setEnd(new DateTime());
$timesheet5->setDuration(400);
$timesheet5->setDuration(400); // 396
$timesheet5->setRate(84);
$timesheet5->setUser(new User());
$timesheet5->setActivity(new Activity());
$timesheet5->setProject($project3);
// 325,53+111,11+1947,99+84
// duration 400
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$query = new InvoiceQuery();
@@ -115,12 +127,14 @@ class PriceInvoiceCalculatorTest extends AbstractCalculatorTestCase
$sut = $this->getCalculator();
$sut->setModel($model);
// (325,53+111,11+1947,99+84)*1,19
self::assertEquals('price', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
self::assertEquals(2937.67, $sut->getTotal());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());
self::assertEquals(2468.63, $sut->getSubtotal());
self::assertEquals(6596, $sut->getTimeWorked());
$entries = $sut->getEntries();
self::assertCount(4, $entries);
@@ -130,7 +144,7 @@ class PriceInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('2018-11-28', $entries[2]->getBegin()?->format('Y-m-d'));
self::assertEquals('2018-11-29', $entries[3]->getBegin()?->format('Y-m-d'));
self::assertEquals(378.02, $entries[3]->getRate());
self::assertEquals(325.53, $entries[3]->getRate());
self::assertEquals(111.11, $entries[0]->getRate());
self::assertEquals(1947.99, $entries[1]->getRate());
self::assertEquals(84, $entries[2]->getRate());

View File

@@ -0,0 +1,165 @@
<?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\Tests\Invoice\Calculator;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\Calculator\AbstractCalculator;
use App\Invoice\Calculator\AbstractMergedCalculator;
use App\Invoice\Calculator\AbstractSumInvoiceCalculator;
use App\Invoice\Calculator\ProjectActivityInvoiceCalculator;
use App\Invoice\CalculatorInterface;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use App\Tests\Mocks\InvoiceModelFactoryFactory;
use DateTime;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(ProjectActivityInvoiceCalculator::class)]
#[CoversClass(AbstractSumInvoiceCalculator::class)]
#[CoversClass(AbstractMergedCalculator::class)]
#[CoversClass(AbstractCalculator::class)]
class ProjectActivityInvoiceCalculatorTest extends AbstractCalculatorTestCase
{
protected function getCalculator(): CalculatorInterface
{
return new ProjectActivityInvoiceCalculator();
}
public function testWithMultipleEntries(): void
{
$customer = new Customer('foo');
$template = new InvoiceTemplate();
$template->setVat(19);
$user1 = $this->getMockBuilder(User::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user2 = $this->getMockBuilder(User::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
$user2->method('getId')->willReturn(2);
$project1 = $this->getMockBuilder(Project::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
$project1->method('getId')->willReturn(1);
$project2 = $this->getMockBuilder(Project::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
$project2->method('getId')->willReturn(2);
$project3 = $this->getMockBuilder(Project::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
$project3->method('getId')->willReturn(3);
$activity1 = $this->getMockBuilder(Activity::class)->onlyMethods(['getId', 'getName'])->disableOriginalConstructor()->getMock();
$activity1->method('getId')->willReturn(1);
$activity1->method('getName')->willReturn('sdsd');
$activity2 = $this->getMockBuilder(Activity::class)->onlyMethods(['getId', 'getName'])->disableOriginalConstructor()->getMock();
$activity2->method('getId')->willReturn(2);
$activity2->method('getName')->willReturn('bar');
$activity3 = $this->getMockBuilder(Activity::class)->onlyMethods(['getId', 'getName'])->disableOriginalConstructor()->getMock();
$activity3->method('getId')->willReturn(3);
$activity3->method('getName')->willReturn('foo');
$timesheet = new Timesheet();
$timesheet->setBegin(new DateTime('2018-11-29'));
$timesheet->setEnd(new DateTime());
$timesheet->setDuration(3600);
$timesheet->setRate(293.27);
$timesheet->setUser($user1);
$timesheet->setActivity($activity1);
$timesheet->setProject($project1);
$timesheet2 = new Timesheet();
$timesheet2->setBegin(new DateTime('2018-11-28'));
$timesheet2->setEnd(new DateTime());
$timesheet2->setDuration(400);
$timesheet2->setRate(84.75);
$timesheet2->setUser($user1);
$timesheet2->setActivity($activity2);
$timesheet2->setProject($project2);
$timesheet3 = new Timesheet();
$timesheet3->setBegin(new DateTime('2018-11-29'));
$timesheet3->setEnd(new DateTime());
$timesheet3->setDuration(1800);
$timesheet3->setRate(111.11);
$timesheet3->setUser($user1);
$timesheet3->setActivity($activity1);
$timesheet3->setProject($project1);
$timesheet4 = new Timesheet();
$timesheet4->setBegin(new DateTime('2018-11-08'));
$timesheet4->setEnd(new DateTime());
$timesheet4->setDuration(400);
$timesheet4->setRate(1947.99);
$timesheet4->setUser($user1);
$timesheet4->setActivity($activity3);
$timesheet4->setProject($project3);
$timesheet5 = new Timesheet();
$timesheet5->setBegin(new DateTime('2018-11-28'));
$timesheet5->setEnd(new DateTime());
$timesheet5->setDuration(400);
$timesheet5->setRate(84);
$timesheet5->setUser($user1);
$timesheet5->setActivity($activity2);
$timesheet5->setProject($project3);
$timesheet6 = new Timesheet();
$timesheet6->setBegin(new DateTime('2018-11-27'));
$timesheet6->setEnd(new DateTime());
$timesheet6->setDuration(1000);
$timesheet6->setRate(100);
$timesheet6->setUser($user2);
$timesheet6->setActivity($activity3);
$timesheet6->setProject($project2);
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6];
$query = new InvoiceQuery();
$query->setProjects([$project1]);
$model = (new InvoiceModelFactoryFactory($this))->create()->createModel(new DebugFormatter(), $customer, $template, $query);
$model->addEntries($entries);
$sut = $this->getCalculator();
$sut->setModel($model);
self::assertEquals('project_activity', $sut->getId());
self::assertEquals(3119.13, $sut->getTotal());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2621.12, $sut->getSubtotal());
self::assertEquals(7600, $sut->getTimeWorked());
$entries = $sut->getEntries();
self::assertCount(5, $entries);
self::assertEquals('2018-11-08', $entries[0]->getBegin()?->format('Y-m-d'));
self::assertEquals('2018-11-27', $entries[1]->getBegin()?->format('Y-m-d'));
self::assertEquals('2018-11-28', $entries[2]->getBegin()?->format('Y-m-d'));
self::assertEquals('2018-11-28', $entries[3]->getBegin()?->format('Y-m-d'));
self::assertEquals('2018-11-29', $entries[4]->getBegin()?->format('Y-m-d'));
self::assertEquals(1947.99, $entries[0]->getRate());
self::assertEquals(100, $entries[1]->getRate());
self::assertEquals(84.75, $entries[2]->getRate());
self::assertEquals(84, $entries[3]->getRate());
self::assertEquals(404.38, $entries[4]->getRate());
}
public function testDescriptionByProject(): void
{
$this->assertDescription($this->getCalculator(), true, false);
}
}

View File

@@ -113,7 +113,7 @@ class ProjectInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('project', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -116,7 +116,7 @@ class ProjectUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('project_user', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -94,11 +94,11 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
$sut->setModel($model);
self::assertEquals('short', $sut->getId());
self::assertEquals(562.28, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
self::assertEquals(561.87, $sut->getTotal());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(472.5, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());
self::assertEquals(472.16, $sut->getSubtotal());
self::assertEquals(5796, $sut->getTimeWorked());
self::assertEquals(1, \count($sut->getEntries()));
$entries = $sut->getEntries();
@@ -109,8 +109,8 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('', $result->getDescription());
self::assertEquals(293.27, $result->getHourlyRate());
self::assertNull($result->getFixedRate());
self::assertEquals(472.5, $result->getRate());
self::assertEquals(5800, $result->getDuration());
self::assertEquals(472.16, $result->getRate());
self::assertEquals(5796, $result->getDuration());
self::assertEquals(3, $result->getAmount());
self::assertEquals(['foo', 'bar', 'bar1'], $result->getTags());
}
@@ -171,7 +171,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('short', $sut->getId());
self::assertEquals(581.17, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(488.38, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());
@@ -241,7 +241,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('short', $sut->getId());
self::assertEquals(581.17, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(488.38, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());

View File

@@ -110,7 +110,7 @@ class UserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('user', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -116,7 +116,7 @@ class WeeklyInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('weekly', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -36,6 +36,12 @@ class InvoiceModelCustomerHydratorTest extends TestCase
self::assertEquals([
'customer.id' => null,
'customer.address' => "Foo\nStreet\n1111 City",
'customer.address_line1' => '',
'customer.address_line2' => '',
'customer.address_line3' => '',
'customer.buyer_reference' => '',
'customer.city' => '',
'customer.postcode' => '',
'customer.name' => 'customer,with/special#name',
'customer.contact' => '',
'customer.company' => '',
@@ -43,6 +49,7 @@ class InvoiceModelCustomerHydratorTest extends TestCase
'customer.vat_id' => '',
'customer.number' => '',
'customer.country' => 'AT',
'customer.country_name' => 'Austria',
'customer.homepage' => '',
'customer.comment' => '',
'customer.email' => '',
@@ -63,12 +70,19 @@ class InvoiceModelCustomerHydratorTest extends TestCase
$keys = [
'customer.id',
'customer.address',
'customer.address_line1',
'customer.address_line2',
'customer.address_line3',
'customer.buyer_reference',
'customer.city',
'customer.postcode',
'customer.name',
'customer.contact',
'customer.company',
'customer.vat',
'customer.vat_id',
'customer.country',
'customer.country_name',
'customer.number',
'customer.homepage',
'customer.comment',

Some files were not shown because too many files have changed in this diff Show More