diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 1d9d3c073..16b0ff7ba 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['7.4'] + php: ['7.3'] name: Coverage - PHP ${{ matrix.php }} steps: diff --git a/UPGRADING.md b/UPGRADING.md index 113c3d6af..80bb67543 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,9 +8,27 @@ you can upgrade your Kimai installation to the latest stable release. Check below if there are more version specific steps required, which need to be executed after the normal update process. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. +## [1.9](https://github.com/kevinpapst/kimai2/releases/tag/1.9) + +**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).** + +- The directory `var/data/invoices/` will be used to store archived invoice files (check file permissions) +- The default invoice number format changed, if you want back the old one, use `{date}` as format - see [invoice documentation](https://www.kimai.org/documentation/invoices.html) +- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including +assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs. + +Permission changes: +- `history_invoice` - NEW: grants all features of the new invoice archive (by default for all admins) + +### Developer + +- BC break: `InvoiceItemInterface` has new methods `getType()` and `getCategory()` + ## [1.8](https://github.com/kevinpapst/kimai2/releases/tag/1.8) -- New PHP requirement: `ext-xsl` should be pre-installed in most environments when `ext-xml` is loaded +**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).** + +- New PHP requirement: `ext-xsl` - which should be pre-installed in most environments when `ext-xml` is loaded - New mailer library: check if emails are still working (eg. by using the "password forgotten" function) or if you need to adjust your configuration, [see docs at symfony.com](https://symfony.com/doc/current/components/mailer.html#transport) - Support for line breaks in multiline invoice fields for spreadsheets (check your invoice templates after the update) diff --git a/composer.json b/composer.json index 59a138b2b..b0b1cc0c8 100644 --- a/composer.json +++ b/composer.json @@ -165,7 +165,9 @@ "vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=4" ], "kimai:codestyle": "vendor/bin/php-cs-fixer fix --dry-run --verbose --show-progress=none", - "kimai:codestyle-fix": "vendor/bin/php-cs-fixer fix" + "codestyle": "@kimai:codestyle", + "kimai:codestyle-fix": "vendor/bin/php-cs-fixer fix", + "codestyle-fix": "@kimai:codestyle-fix" }, "conflict": { "symfony/symfony": "*" diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index c58196f80..743e11878 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -96,7 +96,7 @@ kimai: CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','comments_create_team_customer','details_team_customer'] CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','comments_create_teamlead_customer','details_teamlead_customer'] INVOICE: ['view_invoice','create_invoice'] - INVOICE_TEMPLATE: ['manage_invoice_template'] + INVOICE_ADMIN: ['manage_invoice_template','history_invoice'] TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet'] TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet'] PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile'] @@ -115,8 +115,8 @@ kimai: # link above sets to one complete set for each user role ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER'] ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD'] - ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN'] - ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN'] + ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN'] + ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN'] # mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) maps: ROLE_USER: ['ROLE_USER'] diff --git a/config/packages/test/kimai.yaml b/config/packages/test/kimai.yaml new file mode 100644 index 000000000..d3f9a126e --- /dev/null +++ b/config/packages/test/kimai.yaml @@ -0,0 +1,2 @@ +kimai: + data_dir: '%kernel.project_dir%/tests/_data' diff --git a/config/services.yaml b/config/services.yaml index a083c0d1b..51a5b835d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -83,6 +83,10 @@ services: arguments: $localDomains: '%kimai.i18n_domains%' + App\Utils\FileHelper: + arguments: + $dataDir: '%kimai.data_dir%' + # ================================================================================ # DATABASE # ================================================================================ @@ -226,3 +230,8 @@ services: class: Doctrine\ORM\EntityRepository factory: ['@doctrine.orm.entity_manager', getRepository] arguments: ['App\Entity\ProjectRate'] + + App\Repository\InvoiceRepository: + class: Doctrine\ORM\EntityRepository + factory: ['@doctrine.orm.entity_manager', getRepository] + arguments: ['App\Entity\Invoice'] diff --git a/src/Controller/AbstractController.php b/src/Controller/AbstractController.php index ef4de7cc3..62c3cfa2e 100644 --- a/src/Controller/AbstractController.php +++ b/src/Controller/AbstractController.php @@ -10,6 +10,7 @@ namespace App\Controller; use App\Entity\User; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController; use Symfony\Component\Translation\DataCollectorTranslator; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -20,13 +21,6 @@ use Symfony\Contracts\Translation\TranslatorInterface; */ abstract class AbstractController extends BaseAbstractController implements ServiceSubscriberInterface { - public const FLASH_SUCCESS = 'success'; - public const FLASH_WARNING = 'warning'; - public const FLASH_ERROR = 'error'; - - public const DOMAIN_FLASH = 'flashmessages'; - public const DOMAIN_ERROR = 'exceptions'; - /** * @deprecated since 1.6, will be removed with 2.0 */ @@ -40,6 +34,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv return $this->container->get('translator'); } + /** + * @return LoggerInterface $logger + */ + private function getLogger() + { + return $this->container->get('logger'); + } + /** * @return User|null */ @@ -56,7 +58,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv */ protected function flashSuccess($translationKey, $parameter = []) { - $this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter); + $this->addFlashTranslated('success', $translationKey, $parameter); } /** @@ -67,7 +69,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv */ protected function flashWarning($translationKey, $parameter = []) { - $this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter); + $this->addFlashTranslated('warning', $translationKey, $parameter); } /** @@ -78,7 +80,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv */ protected function flashError($translationKey, $parameter = []) { - $this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter); + $this->addFlashTranslated('error', $translationKey, $parameter); } /** @@ -92,22 +94,28 @@ abstract class AbstractController extends BaseAbstractController implements Serv { if (!empty($parameter)) { foreach ($parameter as $key => $value) { - $parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH); + $parameter[$key] = $this->getTranslator()->trans($value, [], 'flashmessages'); } $message = $this->getTranslator()->trans( $message, $parameter, - self::DOMAIN_FLASH + 'flashmessages' ); } $this->addFlash($type, $message); } + protected function logException(\Exception $ex) + { + $this->getLogger()->critical($ex->getMessage()); + } + public static function getSubscribedServices() { return array_merge(parent::getSubscribedServices(), [ - 'translator' => TranslatorInterface::class + 'translator' => TranslatorInterface::class, + 'logger' => LoggerInterface::class ]); } } diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index b8a290ed1..a51cff047 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -9,17 +9,21 @@ namespace App\Controller; +use App\Configuration\SystemConfiguration; +use App\Entity\Invoice; use App\Entity\InvoiceTemplate; use App\Event\InvoicePostRenderEvent; use App\Event\InvoicePreRenderEvent; +use App\Export\ExportItemInterface; use App\Form\InvoiceDocumentUploadForm; use App\Form\InvoiceTemplateForm; use App\Form\Toolbar\InvoiceToolbarForm; +use App\Form\Toolbar\InvoiceToolbarSimpleForm; use App\Invoice\InvoiceFormatter; -use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceModel; use App\Invoice\ServiceInvoice; use App\Repository\InvoiceDocumentRepository; +use App\Repository\InvoiceRepository; use App\Repository\InvoiceTemplateRepository; use App\Repository\Query\BaseQuery; use App\Repository\Query\InvoiceQuery; @@ -48,7 +52,7 @@ final class InvoiceController extends AbstractController /** * @var InvoiceTemplateRepository */ - private $invoiceRepository; + private $templateRepository; /** * @var UserDateTimeFactory */ @@ -61,11 +65,16 @@ final class InvoiceController extends AbstractController * @var EventDispatcherInterface */ private $dispatcher; + /** + * @var InvoiceRepository + */ + private $invoiceRepository; - public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher) + public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher) { $this->service = $service; - $this->invoiceRepository = $invoice; + $this->templateRepository = $templateRepository; + $this->invoiceRepository = $invoiceRepository; $this->dateTimeFactory = $dateTimeFactory; $this->formatter = $formatter; $this->dispatcher = $dispatcher; @@ -75,9 +84,9 @@ final class InvoiceController extends AbstractController * @Route(path="/", name="invoice", methods={"GET", "POST"}) * @Security("is_granted('view_invoice')") */ - public function indexAction(Request $request): Response + public function indexAction(Request $request, SystemConfiguration $configuration): Response { - if (!$this->invoiceRepository->hasTemplate()) { + if (!$this->templateRepository->hasTemplate()) { if ($this->isGranted('manage_invoice_template')) { return $this->redirectToRoute('admin_invoice_template_create'); } @@ -88,16 +97,27 @@ final class InvoiceController extends AbstractController $entries = []; $query = $this->getDefaultQuery(); - $form = $this->getToolbarForm($query, 'GET'); + $form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form')); $form->setData($query); $form->submit($request->query->all(), false); if ($this->isGranted('create_invoice')) { if ($form->isValid()) { - /** @var SubmitButton $createButton */ - $createButton = $form->get('create'); - if ($createButton->isClicked()) { - return $this->renderInvoice($query); + try { + /** @var SubmitButton $createButton */ + $createButton = $form->get('create'); + if ($createButton->isClicked()) { + return $this->renderInvoice($query, true); + } + + /** @var SubmitButton $printButton */ + $printButton = $form->get('print'); + if ($printButton->isClicked()) { + return $this->renderInvoice($query, false); + } + } catch (\Exception $ex) { + $this->logException($ex); + $this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']); } /** @var SubmitButton $previewButton */ @@ -144,7 +164,7 @@ final class InvoiceController extends AbstractController return $query; } - protected function renderInvoice(InvoiceQuery $query) + protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false) { $entries = $this->getEntries($query); $model = $this->prepareModel($query); @@ -162,11 +182,28 @@ final class InvoiceController extends AbstractController $this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer)); $response = $renderer->render($document, $model); - if ($query->isMarkAsExported()) { - $this->markEntriesAsExported($entries); - } - $this->dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response)); + if ($saveInvoice) { + if ($query->isMarkAsExported()) { + $this->markEntriesAsExported($entries); + } + + $event = new InvoicePostRenderEvent($model, $document, $renderer, $response); + $this->dispatcher->dispatch($event); + + $invoiceFilename = $this->service->saveGeneratedInvoice($event); + + $invoice = new Invoice(); + $invoice->setModel($model); + $invoice->setFilename($invoiceFilename); + $this->invoiceRepository->saveInvoice($invoice); + + $this->flashSuccess('action.update.success'); + + if ($this->isGranted('history_invoice')) { + return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]); + } + } return $response; } @@ -180,7 +217,81 @@ final class InvoiceController extends AbstractController } /** - * @param InvoiceItemInterface[] $entries + * @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"}) + * @Security("is_granted('history_invoice')") + */ + public function changeStatusAction(Invoice $invoice, string $status): Response + { + if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) { + throw $this->createNotFoundException('Unknwon invoice status'); + } + + switch ($status) { + case Invoice::STATUS_NEW: + $invoice->setIsNew(); + break; + + case Invoice::STATUS_PENDING: + $invoice->setIsPending(); + break; + + case Invoice::STATUS_PAID: + $invoice->setIsPaid(); + break; + } + + $this->invoiceRepository->saveInvoice($invoice); + + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_invoice_list'); + } + + /** + * @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"}) + * @Security("is_granted('history_invoice')") + */ + public function downloadAction(Invoice $invoice): Response + { + $file = $this->service->getInvoiceFile($invoice); + + if (null === $file) { + throw $this->createNotFoundException( + sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId()) + ); + } + + return $this->file($file->getRealPath(), $file->getBasename()); + } + + /** + * @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"}) + * @Security("is_granted('history_invoice')") + */ + public function showInvoicesAction(Request $request, int $page): Response + { + $invoice = null; + + if (null !== ($id = $request->get('id'))) { + $invoice = $this->invoiceRepository->find($id); + } + + $query = new InvoiceQuery(); + $query->setOrderBy('date'); + $query->setPage($page); + $query->setCurrentUser($this->getUser()); + + $invoices = $this->invoiceRepository->getPagerfantaForQuery($query); + + return $this->render('invoice/listing.html.twig', [ + 'entries' => $invoices, + 'query' => $query, + 'download' => $invoice, + ]); + } + + /** + * @param ExportItemInterface[] $entries */ private function markEntriesAsExported(iterable $entries) { @@ -197,7 +308,7 @@ final class InvoiceController extends AbstractController /** * @param InvoiceQuery $query - * @return InvoiceItemInterface[] + * @return ExportItemInterface[] */ protected function getEntries(InvoiceQuery $query): array { @@ -246,6 +357,7 @@ final class InvoiceController extends AbstractController { $model = new InvoiceModel($this->formatter); $model + ->setInvoiceDate($this->dateTimeFactory->createDateTime()) ->setQuery($query) ->setUser($this->getUser()) ->setCustomer($query->getCustomer()) @@ -276,7 +388,7 @@ final class InvoiceController extends AbstractController */ public function listTemplateAction(): Response { - $templates = $this->invoiceRepository->getPagerfantaForQuery(new BaseQuery()); + $templates = $this->templateRepository->getPagerfantaForQuery(new BaseQuery()); return $this->render('invoice/templates.html.twig', [ 'entries' => $templates, @@ -361,7 +473,7 @@ final class InvoiceController extends AbstractController */ public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response { - if (!$this->invoiceRepository->hasTemplate()) { + if (!$this->templateRepository->hasTemplate()) { $this->flashWarning('invoice.first_template'); } @@ -394,7 +506,7 @@ final class InvoiceController extends AbstractController public function deleteTemplate(InvoiceTemplate $template, Request $request): Response { try { - $this->invoiceRepository->removeTemplate($template); + $this->templateRepository->removeTemplate($template); $this->flashSuccess('action.delete.success'); } catch (\Exception $ex) { $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); @@ -411,7 +523,7 @@ final class InvoiceController extends AbstractController if ($editForm->isSubmitted() && $editForm->isValid()) { try { - $this->invoiceRepository->saveTemplate($template); + $this->templateRepository->saveTemplate($template); $this->flashSuccess('action.update.success'); return $this->redirectToRoute('admin_invoice_template'); @@ -426,11 +538,13 @@ final class InvoiceController extends AbstractController ]); } - protected function getToolbarForm(InvoiceQuery $query, string $method): FormInterface + protected function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface { - return $this->createForm(InvoiceToolbarForm::class, $query, [ + $form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class; + + return $this->createForm($form, $query, [ 'action' => $this->generateUrl('invoice', []), - 'method' => $method, + 'method' => 'GET', 'include_user' => $this->isGranted('view_other_timesheet'), 'attr' => [ 'id' => 'invoice-print-form' diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php index 50d7cedb3..c2f6e3f5e 100644 --- a/src/Controller/SystemConfigurationController.php +++ b/src/Controller/SystemConfigurationController.php @@ -20,6 +20,7 @@ use App\Form\Type\RoundingModeType; use App\Form\Type\SkinType; use App\Form\Type\TrackingModeType; use App\Form\Type\WeekDaysType; +use App\Form\Type\YesNoType; use App\Repository\ConfigurationRepository; use App\Validator\Constraints\DateTimeFormat; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; @@ -275,6 +276,22 @@ final class SystemConfigurationController extends AbstractController ->setType(WeekDaysType::class) ->setTranslationDomain('system-configuration'), ]), + (new SystemConfigurationModel()) + ->setSection(SystemConfigurationModel::SECTION_FORM_INVOICE) + ->setConfiguration([ + (new Configuration()) + ->setName('invoice.number_format') + ->setLabel('invoice.number_format') + ->setRequired(true) + ->setType(TextType::class) // TODO that should be a custom type with validation + ->setTranslationDomain('system-configuration'), + (new Configuration()) + ->setName('invoice.simple_form') + ->setLabel('simple_form') + ->setRequired(false) + ->setType(YesNoType::class) + ->setTranslationDomain('system-configuration'), + ]), (new SystemConfigurationModel()) ->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER) ->setConfiguration([ diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2aea99a2b..09e35bf66 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -224,6 +224,12 @@ class Configuration implements ConfigurationInterface ->scalarPrototype()->end() ->defaultValue([]) ->end() + ->booleanNode('simple_form') + ->defaultTrue() + ->end() + ->scalarNode('number_format') + ->defaultValue('{Y}/{cy,3}') + ->end() ->end() ; diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php new file mode 100644 index 000000000..ae89a8b5d --- /dev/null +++ b/src/Entity/Invoice.php @@ -0,0 +1,290 @@ +id; + } + + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function getInvoiceNumber(): ?string + { + return $this->invoiceNumber; + } + + public function getTotal(): float + { + return $this->total; + } + + public function getCreatedAt(): ?\DateTime + { + if (!$this->localized) { + if (null !== $this->createdAt && null !== $this->timezone) { + $this->createdAt->setTimeZone(new \DateTimeZone($this->timezone)); + } + + $this->localized = true; + } + + return $this->createdAt; + } + + public function getDueDate(): ?\DateTime + { + if (null === $this->getCreatedAt()) { + return null; + } + + $dueDate = clone $this->getCreatedAt(); + $dueDate->modify('+ ' . $this->dueDays . 'days'); + + return $dueDate; + } + + public function isOverdue(): bool + { + if (null === $this->getDueDate()) { + return false; + } + + return $this->getDueDate()->getTimestamp() < (new \DateTime('now', new \DateTimeZone($this->timezone)))->getTimestamp(); + } + + public function setFilename(string $filename): Invoice + { + $this->invoiceFilename = $filename; + + return $this; + } + + public function setModel(InvoiceModel $model): Invoice + { + $this->customer = $model->getCustomer(); + $this->user = $model->getUser(); + $this->total = $model->getCalculator()->getTotal(); + $this->tax = $model->getCalculator()->getTax(); + $this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber(); + $this->currency = $model->getCurrency(); + + $createdAt = $model->getInvoiceDate(); + $this->createdAt = $createdAt; + $this->timezone = $createdAt->getTimezone()->getName(); + + $template = $model->getTemplate(); + $this->dueDays = $template->getDueDays(); + $this->vat = $template->getVat(); + + return $this; + } + + public function isNew(): bool + { + return $this->status === self::STATUS_NEW; + } + + public function setIsNew(): Invoice + { + $this->status = self::STATUS_NEW; + + return $this; + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function setIsPending(): Invoice + { + $this->status = self::STATUS_PENDING; + + return $this; + } + + public function isPaid(): bool + { + return $this->status === self::STATUS_PAID; + } + + public function setIsPaid(): Invoice + { + $this->status = self::STATUS_PAID; + + return $this; + } + + public function getDueDays(): int + { + return $this->dueDays; + } + + public function getVat(): float + { + return $this->vat; + } + + public function getTax(): float + { + return $this->tax; + } + + public function getCurrency(): ?string + { + return $this->currency; + } + + public function getInvoiceFilename(): ?string + { + return $this->invoiceFilename; + } +} diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php index da430bf97..26df91415 100644 --- a/src/Entity/InvoiceTemplate.php +++ b/src/Entity/InvoiceTemplate.php @@ -170,6 +170,8 @@ class InvoiceTemplate return $this->name; } + // ---- trait methods below --- + public function getTitle(): ?string { return $this->title; diff --git a/src/Export/ExportItemInterface.php b/src/Export/ExportItemInterface.php index e5c59d316..191335a0d 100644 --- a/src/Export/ExportItemInterface.php +++ b/src/Export/ExportItemInterface.php @@ -14,20 +14,6 @@ use App\Invoice\InvoiceItemInterface; interface ExportItemInterface extends InvoiceItemInterface { - /** - * A name representation for this type of export. - * - * @return string - */ - public function getType(): string; - - /** - * A name representation for the category of this type. - * - * @return string - */ - public function getCategory(): string; - /** * Whether this item was already exported. * diff --git a/src/Form/Model/SystemConfiguration.php b/src/Form/Model/SystemConfiguration.php index 256dc4bc6..b022df200 100644 --- a/src/Form/Model/SystemConfiguration.php +++ b/src/Form/Model/SystemConfiguration.php @@ -13,6 +13,7 @@ class SystemConfiguration { public const SECTION_ROUNDING = 'rounding'; public const SECTION_TIMESHEET = 'timesheet'; + public const SECTION_FORM_INVOICE = 'invoice'; public const SECTION_FORM_CUSTOMER = 'form_customer'; public const SECTION_FORM_USER = 'form_user'; public const SECTION_THEME = 'theme'; diff --git a/src/Form/SystemConfigurationForm.php b/src/Form/SystemConfigurationForm.php index 17b8259d1..24ec0a104 100644 --- a/src/Form/SystemConfigurationForm.php +++ b/src/Form/SystemConfigurationForm.php @@ -45,6 +45,9 @@ class SystemConfigurationForm extends AbstractType 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'edit_system_configurations', + 'attr' => [ + 'data-form-event' => 'kimai.systemConfigUpdate' + ], ]); } } diff --git a/src/Form/Toolbar/InvoiceToolbarForm.php b/src/Form/Toolbar/InvoiceToolbarForm.php index ca37a2cb0..834fb4ed6 100644 --- a/src/Form/Toolbar/InvoiceToolbarForm.php +++ b/src/Form/Toolbar/InvoiceToolbarForm.php @@ -9,53 +9,27 @@ namespace App\Form\Toolbar; -use App\Form\Type\InvoiceTemplateType; -use App\Repository\Query\InvoiceQuery; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** * Defines the form used for filtering timesheet entries for invoices. */ -class InvoiceToolbarForm extends AbstractToolbarForm +class InvoiceToolbarForm extends InvoiceToolbarSimpleForm { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { + parent::buildForm($builder, $options); $this->addSearchTermInputField($builder); - $this->addTemplateChoice($builder); if ($options['include_user']) { $this->addUsersChoice($builder); } - $this->addDateRangeChoice($builder); - $this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']); - $this->addProjectChoice($builder, ['ignore_date' => true]); $this->addActivityChoice($builder); $this->addTagInputField($builder); $this->addExportStateChoice($builder); - $builder->add('markAsExported', CheckboxType::class, [ - 'label' => 'label.mark_as_exported', - 'required' => false, - ]); - $builder->add('create', SubmitType::class, [ - 'label' => 'button.print', - 'attr' => ['formtarget' => '_blank'], - ]); - $builder->add('preview', SubmitType::class, [ - 'label' => 'button.preview', - ]); - } - - protected function addTemplateChoice(FormBuilderInterface $builder) - { - $builder->add('template', InvoiceTemplateType::class, [ - 'required' => true, - 'placeholder' => null, - ]); } /** @@ -63,10 +37,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm */ public function configureOptions(OptionsResolver $resolver) { - $resolver->setDefaults([ - 'data_class' => InvoiceQuery::class, - 'csrf_protection' => false, - 'include_user' => true, - ]); + parent::configureOptions($resolver); + $resolver->setDefault('include_user', true); } } diff --git a/src/Form/Toolbar/InvoiceToolbarSimpleForm.php b/src/Form/Toolbar/InvoiceToolbarSimpleForm.php new file mode 100644 index 000000000..eb3aafd55 --- /dev/null +++ b/src/Form/Toolbar/InvoiceToolbarSimpleForm.php @@ -0,0 +1,68 @@ +addTemplateChoice($builder); + $this->addDateRangeChoice($builder); + $this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']); + $this->addProjectChoice($builder, ['ignore_date' => true]); + $builder->add('markAsExported', CheckboxType::class, [ + 'label' => 'label.mark_as_exported', + 'required' => false, + ]); + $builder->add('create', SubmitType::class, [ + 'label' => 'action.save', + ]); + $builder->add('print', SubmitType::class, [ + 'label' => 'button.preview_print', + 'attr' => ['formtarget' => '_blank'], + ]); + $builder->add('preview', SubmitType::class, [ + 'label' => 'button.preview', + ]); + } + + protected function addTemplateChoice(FormBuilderInterface $builder) + { + $builder->add('template', InvoiceTemplateType::class, [ + 'required' => true, + 'placeholder' => null, + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => InvoiceQuery::class, + 'csrf_protection' => false, + 'include_user' => true, + ]); + } +} diff --git a/src/Form/Type/InvoiceRendererType.php b/src/Form/Type/InvoiceRendererType.php index 11324bae6..4ea044c56 100644 --- a/src/Form/Type/InvoiceRendererType.php +++ b/src/Form/Type/InvoiceRendererType.php @@ -43,6 +43,7 @@ class InvoiceRendererType extends AbstractType foreach ($this->service->getRenderer() as $renderer) { if ($renderer->supports($document)) { $documents[$document->getId()] = $document->getName(); + break; } } } @@ -70,13 +71,13 @@ class InvoiceRendererType extends AbstractType { $renderer = $label; - return ucfirst( - substr( - $renderer, - 1 + strrpos($renderer, '.'), - strrpos($renderer, '.') - ) - ); + $parts = explode('.', $renderer); + + if (count($parts) > 2) { + array_pop($parts); + } + + return ucfirst(array_pop($parts)); } /** diff --git a/src/Invoice/Calculator/AbstractMergedCalculator.php b/src/Invoice/Calculator/AbstractMergedCalculator.php index 4f7e2748c..e8c830809 100644 --- a/src/Invoice/Calculator/AbstractMergedCalculator.php +++ b/src/Invoice/Calculator/AbstractMergedCalculator.php @@ -10,7 +10,6 @@ namespace App\Invoice\Calculator; use App\Entity\Timesheet; -use App\Export\ExportItemInterface; use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceItemWithAmountInterface; @@ -42,13 +41,8 @@ abstract class AbstractMergedCalculator extends AbstractCalculator $amount = $entry->getAmount(); } - $type = Timesheet::TYPE_TIMESHEET; - $category = Timesheet::CATEGORY_WORK; - - if ($entry instanceof ExportItemInterface) { - $type = $entry->getType(); - $category = $entry->getCategory(); - } + $type = $entry->getType(); + $category = $entry->getCategory(); if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) { $type = self::TYPE_MIXED; diff --git a/src/Invoice/Calculator/AbstractSumInvoiceCalculator.php b/src/Invoice/Calculator/AbstractSumInvoiceCalculator.php index f50209b49..4a96c126d 100644 --- a/src/Invoice/Calculator/AbstractSumInvoiceCalculator.php +++ b/src/Invoice/Calculator/AbstractSumInvoiceCalculator.php @@ -47,14 +47,23 @@ abstract class AbstractSumInvoiceCalculator extends AbstractMergedCalculator imp } $invoiceItem = $invoiceItems[$id]; $this->mergeInvoiceItems($invoiceItem, $entry); - $this->mergeSumTimesheet($invoiceItem, $entry); + $this->mergeSumInvoiceItem($invoiceItem, $entry); } return array_values($invoiceItems); } + /** + * @deprecated since 1.9 - use mergeSumInvoiceItem() instead + */ protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) { - // allows to set values per calculator after merging the timesheet + } + + protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) + { + @trigger_error('mergeSumTimesheet() is deprecated and will be removed with 2.0', E_USER_DEPRECATED); + + $this->mergeSumTimesheet($invoiceItem, $entry); } } diff --git a/src/Invoice/Calculator/ActivityInvoiceCalculator.php b/src/Invoice/Calculator/ActivityInvoiceCalculator.php index 2b998c8d3..64d4f20ac 100644 --- a/src/Invoice/Calculator/ActivityInvoiceCalculator.php +++ b/src/Invoice/Calculator/ActivityInvoiceCalculator.php @@ -21,19 +21,18 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string { if (null === $invoiceItem->getActivity()) { - throw new \Exception('Cannot work with invoice items that do not have an activity'); - } - - if (null === $invoiceItem->getActivity()->getId()) { - throw new \Exception('Cannot handle un-persisted activities'); + return '__NULL__'; } return (string) $invoiceItem->getActivity()->getId(); } - protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) + protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) { - $invoiceItem->setActivity($entry->getActivity()); + if (null === $entry->getActivity()) { + return; + } + $invoiceItem->setDescription($entry->getActivity()->getName()); } diff --git a/src/Invoice/Calculator/ProjectInvoiceCalculator.php b/src/Invoice/Calculator/ProjectInvoiceCalculator.php index 465dfd320..25285cd63 100644 --- a/src/Invoice/Calculator/ProjectInvoiceCalculator.php +++ b/src/Invoice/Calculator/ProjectInvoiceCalculator.php @@ -27,7 +27,7 @@ class ProjectInvoiceCalculator extends AbstractSumInvoiceCalculator implements C return (string) $invoiceItem->getProject()->getId(); } - protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) + protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) { $invoiceItem->setProject($entry->getProject()); $invoiceItem->setDescription($entry->getProject()->getName()); diff --git a/src/Invoice/InvoiceFilename.php b/src/Invoice/InvoiceFilename.php new file mode 100644 index 000000000..6f24e7568 --- /dev/null +++ b/src/Invoice/InvoiceFilename.php @@ -0,0 +1,49 @@ +getNumberGenerator()->getInvoiceNumber(); + + $filename = str_replace(['/', '\\'], '-', $filename); + + $company = $model->getCustomer()->getCompany(); + if (empty($company)) { + $company = $model->getCustomer()->getName(); + } + + if (!empty($company)) { + $company = new UnicodeString($company); + $filename .= '-' . $company->snake(); + } + + $this->filename = $filename; + } + + public function getFilename() + { + return $this->filename; + } + + public function __toString() + { + return $this->getFilename(); + } +} diff --git a/src/Invoice/InvoiceItemInterface.php b/src/Invoice/InvoiceItemInterface.php index 2382dd1d1..c27ad0219 100644 --- a/src/Invoice/InvoiceItemInterface.php +++ b/src/Invoice/InvoiceItemInterface.php @@ -14,9 +14,6 @@ use App\Entity\MetaTableTypeInterface; use App\Entity\Project; use App\Entity\User; -/** - * @deprecated will be removed with 2.0 - use ExportItemInterface instead - */ interface InvoiceItemInterface { public function getActivity(): ?Activity; @@ -43,4 +40,18 @@ interface InvoiceItemInterface * @return MetaTableTypeInterface[] */ public function getVisibleMetaFields(): array; + + /** + * A name representation for this type of item. + * + * @return string + */ + public function getType(): string; + + /** + * A name representation for the category of this item. + * + * @return string + */ + public function getCategory(): string; } diff --git a/src/Invoice/InvoiceModel.php b/src/Invoice/InvoiceModel.php index ac147cb41..2672291d8 100644 --- a/src/Invoice/InvoiceModel.php +++ b/src/Invoice/InvoiceModel.php @@ -190,14 +190,18 @@ final class InvoiceModel return new \DateTime('+' . $this->getTemplate()->getDueDays() . ' days'); } - /** - * @return \DateTime - */ public function getInvoiceDate(): \DateTime { return $this->invoiceDate; } + public function setInvoiceDate(\DateTime $date): InvoiceModel + { + $this->invoiceDate = $date; + + return $this; + } + public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel { $this->generator = $generator; diff --git a/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php b/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php new file mode 100644 index 000000000..2a4290828 --- /dev/null +++ b/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php @@ -0,0 +1,142 @@ +repository = $repository; + $this->format = $configuration->find('invoice.number_format'); + } + + /** + * @return string + */ + public function getId(): string + { + return 'default'; + } + + /** + * @param InvoiceModel $model + */ + public function setModel(InvoiceModel $model) + { + $this->model = $model; + } + + /** + * @return string + */ + public function getInvoiceNumber(): string + { + if (null !== $this->number) { + return $this->number; + } + + $format = $this->format; + $invoiceDate = $this->model->getInvoiceDate(); + $timestamp = $invoiceDate->getTimestamp(); + $result = $format; + + preg_match_all('/{[^}]*?}/', $format, $matches); + foreach ($matches[0] as $part) { + $formatter = null; + $tmp = str_replace(['{', '}'], '', $part); + + // number format + if (substr_count($tmp, ',') !== 0) { + $formatter = explode(',', $tmp); + $tmp = $formatter[0]; + $formatter = $formatter[1]; + } + + switch ($tmp) { + case 'Y': + $partialResult = date('Y', $timestamp); + break; + + case 'y': + $partialResult = date('y', $timestamp); + break; + + case 'M': + $partialResult = date('m', $timestamp); + break; + + case 'm': + $partialResult = date('n', $timestamp); + break; + + case 'D': + $partialResult = date('d', $timestamp); + break; + + case 'd': + $partialResult = date('j', $timestamp); + break; + + case 'date': + $partialResult = date('ymd', $timestamp); + break; + + case 'c': + $partialResult = $this->repository->getCounterForAllTime($invoiceDate) + 1; + break; + + case 'cy': + $partialResult = $this->repository->getCounterForYear($invoiceDate) + 1; + break; + + case 'cm': + $partialResult = $this->repository->getCounterForMonth($invoiceDate) + 1; + break; + + case 'cd': + $partialResult = $this->repository->getCounterForDay($invoiceDate) + 1; + break; + + default: + $partialResult = $part; + } + + if (null !== $formatter) { + $partialResult = str_pad($partialResult, $formatter, '0', STR_PAD_LEFT); + } + + $result = str_replace($part, $partialResult, $result); + } + + return $this->number = (string) $result; + } +} diff --git a/src/Invoice/NumberGenerator/DateNumberGenerator.php b/src/Invoice/NumberGenerator/DateNumberGenerator.php index a9dc2ce67..68a5e529a 100644 --- a/src/Invoice/NumberGenerator/DateNumberGenerator.php +++ b/src/Invoice/NumberGenerator/DateNumberGenerator.php @@ -28,7 +28,7 @@ class DateNumberGenerator implements NumberGeneratorInterface */ public function getId(): string { - return 'default'; + return 'date'; } /** diff --git a/src/Invoice/Renderer/AbstractRenderer.php b/src/Invoice/Renderer/AbstractRenderer.php index 0c26af1df..25809eb80 100644 --- a/src/Invoice/Renderer/AbstractRenderer.php +++ b/src/Invoice/Renderer/AbstractRenderer.php @@ -10,10 +10,10 @@ namespace App\Invoice\Renderer; use App\Entity\InvoiceDocument; +use App\Invoice\InvoiceFilename; use App\Invoice\InvoiceModel; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\String\UnicodeString; /** * @internal @@ -47,19 +47,7 @@ abstract class AbstractRenderer protected function buildFilename(InvoiceModel $model): string { - $filename = $model->getNumberGenerator()->getInvoiceNumber(); - - $company = $model->getCustomer()->getCompany(); - if (empty($company)) { - $company = $model->getCustomer()->getName(); - } - - if (!empty($company)) { - $company = new UnicodeString($company); - $filename .= '-' . $company->snake(); - } - - return $filename; + return (string) new InvoiceFilename($model); } /** diff --git a/src/Invoice/Renderer/PdfRenderer.php b/src/Invoice/Renderer/PdfRenderer.php new file mode 100644 index 000000000..bd7a1b3b7 --- /dev/null +++ b/src/Invoice/Renderer/PdfRenderer.php @@ -0,0 +1,60 @@ +twig = $twig; + $this->converter = $converter; + } + + public function supports(InvoiceDocument $document): bool + { + return stripos($document->getFilename(), '.pdf.twig') !== false; + } + + public function render(InvoiceDocument $document, InvoiceModel $model): Response + { + $content = $this->twig->render('@invoice/' . basename($document->getFilename()), [ + 'model' => $model + ]); + $content = $this->converter->convertToPdf($content); + $filename = (string) new InvoiceFilename($model); + + $response = new Response($content); + + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename . '.pdf'); + + $response->headers->set('Content-Type', 'application/pdf'); + $response->headers->set('Content-Disposition', $disposition); + + return $response; + } +} diff --git a/src/Invoice/Renderer/TwigRenderer.php b/src/Invoice/Renderer/TwigRenderer.php index 70b4194c8..ea77be29d 100644 --- a/src/Invoice/Renderer/TwigRenderer.php +++ b/src/Invoice/Renderer/TwigRenderer.php @@ -40,6 +40,7 @@ final class TwigRenderer implements RendererInterface $response = new Response(); $response->setContent($content); + $response->headers->set('Content-Type', 'text/html; charset=UTF-8'); return $response; } diff --git a/src/Invoice/ServiceInvoice.php b/src/Invoice/ServiceInvoice.php index 3872e33a4..d84b9e8c5 100644 --- a/src/Invoice/ServiceInvoice.php +++ b/src/Invoice/ServiceInvoice.php @@ -9,8 +9,12 @@ namespace App\Invoice; +use App\Entity\Invoice; use App\Entity\InvoiceDocument; +use App\Event\InvoicePostRenderEvent; use App\Repository\InvoiceDocumentRepository; +use App\Utils\FileHelper; +use Symfony\Component\HttpFoundation\BinaryFileResponse; /** * Service to manage invoice dependencies. @@ -37,10 +41,15 @@ final class ServiceInvoice * @var InvoiceDocumentRepository */ private $documents; + /** + * @var FileHelper + */ + private $fileHelper; - public function __construct(InvoiceDocumentRepository $repository) + public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper) { $this->documents = $repository; + $this->fileHelper = $fileHelper; } public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice @@ -141,4 +150,62 @@ final class ServiceInvoice return $this; } + + private function getInvoicesDirectory(): string + { + return $this->fileHelper->getDataSubdirectory('invoices'); + } + + public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo + { + $invoiceDirectory = $this->getInvoicesDirectory(); + $filename = $invoice->getInvoiceFilename(); + $full = $invoiceDirectory . $filename; + + if (is_file($full) && is_readable($full)) { + return new \SplFileInfo($full); + } + + return null; + } + + public function saveGeneratedInvoice(InvoicePostRenderEvent $event): string + { + $invoiceDirectory = $this->getInvoicesDirectory(); + $filename = (string) new InvoiceFilename($event->getModel()); + + $response = $event->getResponse(); + + if ($event->getResponse()->headers->has('Content-Disposition')) { + $disposition = $event->getResponse()->headers->get('Content-Disposition'); + $parts = explode(';', $disposition); + foreach ($parts as $part) { + if (stripos($part, 'filename=') === false) { + continue; + } + $filename = explode('filename=', $part); + if (count($filename) > 1) { + $filename = $filename[1]; + } + } + } else { + $disposition = $event->getResponse()->headers->get('Content-Type'); + $parts = explode(';', $disposition); + $parts = explode('/', $parts[0]); + $filename .= '.' . $parts[1]; + } + + if (is_file($invoiceDirectory . $filename)) { + throw new \Exception(sprintf('Invoice "%s" already exists', $filename)); + } + + if ($response instanceof BinaryFileResponse) { + $file = $response->getFile(); + $file->move($invoiceDirectory, $filename); + } else { + $this->fileHelper->saveFile($invoiceDirectory . $filename, $event->getResponse()->getContent()); + } + + return $filename; + } } diff --git a/src/Migrations/Version20200308171950.php b/src/Migrations/Version20200308171950.php new file mode 100644 index 000000000..2078f3d6f --- /dev/null +++ b/src/Migrations/Version20200308171950.php @@ -0,0 +1,55 @@ +createTable('kimai2_invoices'); + + $invoices->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]); + $invoices->addColumn('invoice_number', 'string', ['length' => 50, 'notnull' => true]); + $invoices->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => true]); + $invoices->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => true]); + $invoices->addColumn('created_at', 'datetime', ['notnull' => true]); + $invoices->addColumn('timezone', 'string', ['length' => 64, 'notnull' => true]); + $invoices->addColumn('total', 'float', ['notnull' => true]); + $invoices->addColumn('tax', 'float', ['notnull' => true]); + $invoices->addColumn('currency', 'string', ['length' => 3, 'notnull' => true]); + $invoices->addColumn('status', 'string', ['length' => 20, 'notnull' => true]); + $invoices->addColumn('due_days', 'integer', ['length' => 11, 'notnull' => true]); + $invoices->addColumn('vat', 'float', ['notnull' => true]); + $invoices->addColumn('invoice_filename', 'string', ['length' => 100, 'notnull' => true]); + $invoices->addUniqueIndex(['invoice_number'], 'UNIQ_76C38E372DA68207'); + $invoices->addUniqueIndex(['invoice_filename'], 'UNIQ_76C38E372323B33D'); + $invoices->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E37A76ED395'); + $invoices->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E379395C3F3'); + $invoices->setPrimaryKey(['id']); + } + + public function down(Schema $schema): void + { + $schema->dropTable('kimai2_invoices'); + } +} diff --git a/src/Repository/InvoiceRepository.php b/src/Repository/InvoiceRepository.php new file mode 100644 index 000000000..7e72ec6ea --- /dev/null +++ b/src/Repository/InvoiceRepository.php @@ -0,0 +1,172 @@ +getEntityManager(); + $entityManager->persist($invoice); + $entityManager->flush(); + } + + private function getCounterFor(\DateTime $start, \DateTime $end): int + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('count(i.createdAt) as counter') + ->from(Invoice::class, 'i') + ->andWhere($qb->expr()->gte('i.createdAt', ':start')) + ->andWhere($qb->expr()->lte('i.createdAt', ':end')) + ->setParameter('start', $start) + ->setParameter('end', $end) + ; + + $result = $qb->getQuery()->getOneOrNullResult(); + + if ($result === null) { + return 0; + } + + return $result['counter']; + } + + public function getCounterForDay(\DateTime $date): int + { + $start = (clone $date)->setTime(0, 0, 0); + $end = (clone $date)->setTime(23, 59, 59); + + return $this->getCounterFor($start, $end); + } + + public function getCounterForMonth(\DateTime $date): int + { + $start = (clone $date)->setDate($date->format('Y'), $date->format('n'), 1)->setTime(0, 0, 0); + $end = (clone $date)->setDate($date->format('Y'), $date->format('n'), $date->format('t'))->setTime(23, 59, 59); + + return $this->getCounterFor($start, $end); + } + + public function getCounterForYear(\DateTime $date): int + { + $start = (clone $date)->setDate($date->format('Y'), 1, 1)->setTime(0, 0, 0); + $end = (clone $date)->setDate($date->format('Y'), 12, 31)->setTime(23, 59, 59); + + return $this->getCounterFor($start, $end); + } + + public function getCounterForAllTime(\DateTime $date): int + { + return $this->count([]); + } + + private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) + { + // make sure that all queries without a user see all projects + if (null === $user && empty($teams)) { + return; + } + + // make sure that admins see all projects + if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { + return; + } + + if (null !== $user) { + $teams = array_merge($teams, $user->getTeams()->toArray()); + } + + $qb + ->leftJoin('i.customer', 'c') + ->leftJoin('c.teams', 'c_teams'); + + if (empty($teams)) { + $qb->andWhere($qb->expr()->isNull('c_teams')); + + return; + } + + $orCustomer = $qb->expr()->orX( + $qb->expr()->isNull('c_teams'), + $qb->expr()->isMemberOf(':teams', 'c.teams') + ); + $qb->andWhere($orCustomer); + + $qb->setParameter('teams', $teams); + } + + private function getQueryBuilderForQuery(InvoiceQuery $query): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb + ->select('i') + ->from(Invoice::class, 'i') + ; + + $orderBy = $query->getOrderBy(); + switch ($orderBy) { + case 'date': + $orderBy = 'i.createdAt'; + break; + } + + $qb->addOrderBy($orderBy, $query->getOrder()); + + $this->addPermissionCriteria($qb, $query->getCurrentUser()); + + // this will make sure, that we do not accidentally create results with multiple rows + // => which would result in a wrong LIMIT / pagination results + // the second group by is needed due to SQL standard (even though logically not really required for this query) + $qb->addGroupBy('i.id')->addGroupBy($orderBy); + + return $qb; + } + + public function countInvoicesForQuery(InvoiceQuery $query): int + { + $qb = $this->getQueryBuilderForQuery($query); + $qb + ->resetDQLPart('select') + ->resetDQLPart('orderBy') + ->resetDQLPart('groupBy') + ->select($qb->expr()->countDistinct('i.id')) + ; + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + protected function getPaginatorForQuery(InvoiceQuery $query): PaginatorInterface + { + $counter = $this->countInvoicesForQuery($query); + $qb = $this->getQueryBuilderForQuery($query); + + return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter); + } + + public function getPagerfantaForQuery(InvoiceQuery $query): Pagerfanta + { + $paginator = new Pagerfanta($this->getPaginatorForQuery($query)); + $paginator->setMaxPerPage($query->getPageSize()); + $paginator->setCurrentPage($query->getPage()); + + return $paginator; + } +} diff --git a/src/Repository/Loader/InvoiceIdLoader.php b/src/Repository/Loader/InvoiceIdLoader.php new file mode 100644 index 000000000..ec37a3b55 --- /dev/null +++ b/src/Repository/Loader/InvoiceIdLoader.php @@ -0,0 +1,55 @@ +entityManager = $entityManager; + } + + /** + * @param int[] $ids + */ + public function loadResults(array $ids): void + { + if (empty($ids)) { + return; + } + + $em = $this->entityManager; + + $qb = $em->createQueryBuilder(); + $customer = $qb->select('PARTIAL i.{id}', 'customer') + ->from(Invoice::class, 'i') + ->leftJoin('i.customer', 'customer') + ->getQuery() + ->execute(); + + $qb = $em->createQueryBuilder(); + $user = $qb->select('PARTIAL i.{id}', 'user') + ->from(Invoice::class, 'i') + ->leftJoin('i.user', 'user') + ->getQuery() + ->execute(); + } +} diff --git a/src/Repository/Loader/InvoiceLoader.php b/src/Repository/Loader/InvoiceLoader.php new file mode 100644 index 000000000..12b118fca --- /dev/null +++ b/src/Repository/Loader/InvoiceLoader.php @@ -0,0 +1,38 @@ +loader = new InvoiceIdLoader($entityManager); + } + + /** + * @param Invoice[] $invoices + */ + public function loadResults(array $invoices): void + { + $ids = array_map(function (Invoice $invoice) { + return $invoice->getId(); + }, $invoices); + + $this->loader->loadResults($ids); + } +} diff --git a/src/Utils/FileHelper.php b/src/Utils/FileHelper.php new file mode 100644 index 000000000..447091ab1 --- /dev/null +++ b/src/Utils/FileHelper.php @@ -0,0 +1,59 @@ +dataDir = $dataDir; + } + + public function getDataSubdirectory(string $directory): string + { + $subDirectory = $this->dataDir . '/' . rtrim(ltrim($directory, '/'), '/') . '/'; + + $this->makeDir($subDirectory); + + if (!is_dir($subDirectory)) { + throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory)); + } + + if (!is_writable($subDirectory)) { + throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory)); + } + + return $subDirectory; + } + + public function makeDir(string $directory) + { + if (is_dir($directory)) { + return; + } + + if (false === @mkdir($directory)) { + throw new \Exception(sprintf('Failed to create directory "%s", check file permissions', $directory)); + } + } + + public function saveFile(string $filename, $data) + { + $result = @file_put_contents($filename, $data); + if ($result === false) { + throw new \Exception('File "%s" could not be written'); + } + } +} diff --git a/templates/invoice/actions.html.twig b/templates/invoice/actions.html.twig index 3722270da..b43c6b3a7 100644 --- a/templates/invoice/actions.html.twig +++ b/templates/invoice/actions.html.twig @@ -4,10 +4,18 @@ {% set actions = {'visibility': '#modal_invoice'} %} + {% if is_granted('history_invoice') %} + {% set actions = actions|merge({'list': path('admin_invoice_list')}) %} + {% endif %} + {% if is_granted('manage_invoice_template') %} {% set actions = actions|merge({'invoice-template': path('admin_invoice_template')}) %} {% endif %} + {% if is_granted('system_configuration') %} + {% set actions = actions|merge({'settings': {'url': path('system_configuration_section', {'section': 'invoice'}), 'class': 'modal-ajax-form'}}) %} + {% endif %} + {% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %} {% set event = trigger('actions.invoices', {'actions': actions, 'view': view}) %} @@ -37,6 +45,46 @@ {{ widgets.page_actions(actions) }} {% endmacro %} +{% macro invoice(invoice) %} + {% import "macros/widgets.html.twig" as widgets %} + + {% set actions = {} %} + + {% if is_granted('history_invoice') %} + {% if invoice.new %} + {% set actions = actions|merge({'invoice.pending': path('admin_invoice_status', {'id': invoice.id, 'status': 'pending'})}) %} + {% elseif invoice.pending %} + {% set actions = actions|merge({'invoice.paid': path('admin_invoice_status', {'id': invoice.id, 'status': 'paid'})}) %} + {% endif %} + + {% if actions|length > 0 %} + {% set actions = actions|merge({'divider': null}) %} + {% endif %} + + {% set actions = actions|merge({'download': {'url': path('admin_invoice_download', {'id': invoice.id}), 'target': '_blank'}}) %} + {% endif %} + + {% set event = trigger('actions.invoice', {'actions': actions, 'invoice': invoice}) %} + {{ widgets.table_actions(actions) }} +{% endmacro %} + +{% macro invoice_listing(view) %} + {% import "macros/widgets.html.twig" as widgets %} + + {% set actions = {} %} + + {% if is_granted('view_invoice') %} + {% set actions = actions|merge({'back': path('invoice')}) %} + {% endif %} + + {% set actions = actions|merge({'visibility': '#modal_invoices'}) %} + + {% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %} + + {% set event = trigger('actions.invoice_details', {'actions': actions, 'view': view}) %} + {{ widgets.page_actions(actions) }} +{% endmacro %} + {% macro invoice_upload(view) %} {% import "macros/widgets.html.twig" as widgets %} diff --git a/templates/invoice/index.html.twig b/templates/invoice/index.html.twig index c92aa6303..312b74013 100644 --- a/templates/invoice/index.html.twig +++ b/templates/invoice/index.html.twig @@ -5,20 +5,19 @@ {% import "invoice/actions.html.twig" as actions %} {% set columns = { - 'date': 'alwaysVisible', - 'user': 'hidden-xs hidden-sm', - 'project': 'hidden-xs hidden-sm', - 'description': 'hidden-xs hidden-sm', - 'unit_price': 'hidden-xs text-center', - 'amount': 'text-center', - 'duration': 'hidden-xs text-center', - 'total_rate': 'text-right alwaysVisible', + 'date': {'class': 'alwaysVisible', 'orderBy': false}, + 'user': {'class': 'hidden-xs hidden-sm', 'orderBy': false}, + 'project': {'class': 'hidden-xs hidden-sm', 'orderBy': false}, + 'description': {'class': 'hidden-xs hidden-sm', 'orderBy': false}, + 'unit_price': {'class': 'hidden-xs text-center', 'orderBy': false}, + 'amount': {'class': 'text-center', 'orderBy': false}, + 'duration': {'class': 'hidden-xs text-center', 'orderBy': false}, + 'total_rate': {'class': 'text-right alwaysVisible', 'orderBy': false}, } %} {% set tableName = 'invoice' %} {% block page_title %}{{ 'invoice.title'|trans }}{% endblock %} -{% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %} {% block page_actions %}{{ actions.invoices('index') }}{% endblock %} {% block main_before %} @@ -34,21 +33,34 @@ {% block box_before %}{{ form_start(form) }}{% endblock %} {% block box_body %} {{ form_errors(form) }} - {{ form_row(form.searchTerm) }} + {% if form.searchTerm is defined %} + {{ form_row(form.searchTerm) }} + {% endif %} {{ form_row(form.daterange) }} {{ form_row(form.customer) }} {{ form_row(form.project) }} - {{ form_row(form.activity) }} + {% if form.activity is defined %} + {{ form_row(form.activity) }} + {% endif %} {% if form.users is defined %} {{ form_row(form.users) }} {% endif %} - {{ form_row(form.tags) }} - {{ form_row(form.exported) }} + {% if form.tags is defined %} + {{ form_row(form.tags) }} + {% endif %} + {% if form.exported is defined %} + {{ form_row(form.exported) }} + {% endif %} {{ form_row(form.template) }} {{ form_row(form.markAsExported) }} {% endblock %} {% block box_footer%} - {{ form_widget(form.create, {'attr': {'class': 'btn btn-success'}}) }} + {% set createAttr = {'class': 'btn btn-success'} %} + {% if not is_granted('history_invoice') %} + {% set createAttr = createAttr|merge({'formtarget': '_blank'}) %} + {% endif %} + {{ form_widget(form.create, {'attr': createAttr}) }} + {{ form_widget(form.print) }} {{ form_widget(form.preview) }} {% endblock %} {% block box_after %}{{ form_end(form) }}{% endblock %} @@ -96,8 +108,26 @@ {{ entry.rate|money(model.calculator.currency) }} {% endfor %} + + + + + + + + {{ model.calculator.timeWorked|duration(isDecimal) }} + {{ model.calculator.total|money(model.calculator.currency) }} + {{ tables.data_table_footer(entries) }} {% endif %} {% endif %} - +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {% endblock %} diff --git a/templates/invoice/listing.html.twig b/templates/invoice/listing.html.twig new file mode 100644 index 000000000..d29d1b9b7 --- /dev/null +++ b/templates/invoice/listing.html.twig @@ -0,0 +1,62 @@ +{% extends 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/toolbar.html.twig" as toolbar %} +{% import "macros/datatables.html.twig" as tables %} +{% import "invoice/actions.html.twig" as actions %} +{% import "invoice/macros.html.twig" as macros %} + +{% set columns = { + 'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false}, + 'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false}, + 'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false}, + 'invoice_number': {'class': 'hidden-xs hidden-sm text-nowrap', 'title': 'invoice.number'|trans, 'orderBy': false}, + 'due_date': {'class': 'hidden-xs text-nowrap', 'title': 'invoice.due_days'|trans, 'orderBy': false}, + 'status': {'class': 'text-center alwaysVisible text-nowrap', 'orderBy': false}, + 'tax': {'class': 'hidden-xs text-center text-nowrap hidden', 'title': 'invoice.tax'|trans, 'orderBy': false}, + 'total_rate': {'class': 'hidden-xs text-center text-nowrap', 'orderBy': false}, + 'actions': {'class': 'actions alwaysVisible', 'orderBy': false}, +} %} + +{% set tableName = 'invoices' %} + +{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %} +{% block page_actions %}{{ actions.invoice_listing('index') }}{% endblock %} + +{% block main_before %} + {{ tables.data_table_column_modal(tableName, columns) }} +{% endblock %} + +{% block main %} + + {% if entries is empty %} + {{ widgets.callout('warning', 'error.no_entries_found') }} + {% else %} + {{ tables.datatable_header(tableName, columns, query, {}) }} + {% for entry in entries %} + + {{ entry.createdAt|date_short }} + {{ widgets.user_avatar(entry.user) }} {{ widgets.username(entry.user) }} + {{ widgets.label_customer(entry.customer) }} + {{ widgets.label(entry.invoiceNumber, 'default') }} + {{ macros.invoice_due_date(entry) }} + {{ macros.invoice_status(entry) }} + {{ entry.tax|money(entry.currency) }} + {{ entry.total|money(entry.currency) }} + {{ actions.invoice(entry) }} + + {% endfor %} + {{ tables.data_table_footer(entries, 'admin_invoice_list') }} + {% endif %} + +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {% if download is not null %} + + {% endif %} +{% endblock %} diff --git a/templates/invoice/macros.html.twig b/templates/invoice/macros.html.twig new file mode 100644 index 000000000..79b029d3d --- /dev/null +++ b/templates/invoice/macros.html.twig @@ -0,0 +1,25 @@ + +{% macro invoice_status(invoice) %} + {% import "macros/widgets.html.twig" as widgets %} + {% set overdue = invoice.overdue %} + {% if invoice.new and overdue %} + {{ widgets.label('status.new'|trans, 'danger') }} + {% elseif invoice.new %} + {{ widgets.label('status.new'|trans, 'primary') }} + {% elseif invoice.pending and overdue %} + {{ widgets.label('status.pending'|trans, 'danger') }} + {% elseif invoice.pending %} + {{ widgets.label('status.pending'|trans, 'warning') }} + {% elseif invoice.paid %} + {{ widgets.label('status.paid'|trans, 'success') }} + {% endif %} +{% endmacro %} + +{% macro invoice_due_date(invoice) %} + {% import "macros/widgets.html.twig" as widgets %} + {% if invoice.overdue and not invoice.paid %} + {{ widgets.label(invoice.dueDate|date_short, 'danger') }} + {% else %} + {{ widgets.label(invoice.dueDate|date_short, 'primary') }} + {% endif %} +{% endmacro %} diff --git a/templates/invoice/template_edit.html.twig b/templates/invoice/template_edit.html.twig index b075b17eb..19e514bbd 100644 --- a/templates/invoice/template_edit.html.twig +++ b/templates/invoice/template_edit.html.twig @@ -62,6 +62,11 @@ {{ form_row(form.numberGenerator) }} +
+
+ {{ form_row(form.decimalDuration) }} +
+
{{ form_widget(form) }} {% endblock %} {% endembed %} diff --git a/tests/Controller/ControllerBaseTest.php b/tests/Controller/ControllerBaseTest.php index 4d9f60ee8..3848af4ba 100644 --- a/tests/Controller/ControllerBaseTest.php +++ b/tests/Controller/ControllerBaseTest.php @@ -321,12 +321,12 @@ abstract class ControllerBaseTest extends WebTestCase */ protected function assertIsRedirect(HttpKernelBrowser $client, $url = null) { - self::assertTrue($client->getResponse()->isRedirect()); + self::assertTrue($client->getResponse()->isRedirect(), 'Response is not a redirect'); if (null === $url) { return; } - self::assertTrue($client->getResponse()->headers->has('Location')); - self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location')); + self::assertTrue($client->getResponse()->headers->has('Location'), 'Could not find "Location" header'); + self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'), 'Redirect URL does not match'); } } diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php index d53205bda..e3d323271 100644 --- a/tests/Controller/InvoiceControllerTest.php +++ b/tests/Controller/InvoiceControllerTest.php @@ -16,12 +16,44 @@ use App\Form\Type\DateRangeType; use App\Tests\DataFixtures\InvoiceFixtures; use App\Tests\DataFixtures\TimesheetFixtures; use Doctrine\ORM\EntityManager; +use Symfony\Component\HttpFoundation\BinaryFileResponse; /** * @group integration */ class InvoiceControllerTest extends ControllerBaseTest { + protected function setUp(): void + { + parent::setUp(); + $path = __DIR__ . '/../_data/invoices/'; + + if (is_dir($path)) { + $files = glob($path . '*'); + foreach ($files as $file) { + unlink($file); + } + } + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->clearInvoiceFiles(); + } + + private function clearInvoiceFiles() + { + $path = __DIR__ . '/../_data/invoices/'; + + if (is_dir($path)) { + $files = glob($path . '*'); + foreach ($files as $file) { + unlink($file); + } + } + } + public function testIsSecure() { $this->assertUrlIsSecured('/invoice/'); @@ -76,7 +108,6 @@ class InvoiceControllerTest extends ControllerBaseTest 'company' => 'Company name', 'renderer' => 'default', 'calculator' => 'default', - 'numberGenerator' => 'default', ] ]); @@ -111,7 +142,6 @@ class InvoiceControllerTest extends ControllerBaseTest $this->assertEquals($template->getCompany(), $values['company']); $this->assertEquals($template->getAddress(), $values['address']); $this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']); - $this->assertEquals($template->getNumberGenerator(), $values['numberGenerator']); } public function testPrintAction() @@ -153,8 +183,8 @@ class InvoiceControllerTest extends ControllerBaseTest // no warning should be displayed $node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); $this->assertEquals(0, $node->count()); - // but the datatable with all timesheets - $this->assertDataTableRowCount($client, 'datatable_invoice', 20); + // but the datatable with all timesheets + 1 row for the total + $this->assertDataTableRowCount($client, 'datatable_invoice', 21); $form = $client->getCrawler()->filter('#invoice-print-form')->form(); $node = $form->getFormNode(); @@ -180,6 +210,92 @@ class InvoiceControllerTest extends ControllerBaseTest } } + public function testPrintActionAsAdminWithDownloadAndStatusChange() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + /** @var EntityManager $em */ + $em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager'); + + $fixture = new InvoiceFixtures(); + $this->importFixture($client, $fixture); + + $begin = new \DateTime('first day of this month'); + $end = new \DateTime('last day of this month'); + $fixture = new TimesheetFixtures(); + $fixture + ->setUser($this->getUserByRole($em, User::ROLE_ADMIN)) + ->setAmount(20) + ->setStartDate($begin) + ; + $this->importFixture($client, $fixture); + + $this->request($client, '/invoice/'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d'); + + $form = $client->getCrawler()->filter('#invoice-print-form')->form(); + $node = $form->getFormNode(); + $node->setAttribute('action', $this->createUrl('/invoice/?preview=')); + $node->setAttribute('method', 'GET'); + $client->submit($form, [ + 'template' => 1, + 'daterange' => $dateRange, + 'customer' => 1, + ]); + + $this->assertTrue($client->getResponse()->isSuccessful()); + + // no warning should be displayed + $node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); + $this->assertEquals(0, $node->count()); + // but the datatable with all timesheets + 1 row for the total + $this->assertDataTableRowCount($client, 'datatable_invoice', 21); + + $form = $client->getCrawler()->filter('#invoice-print-form')->form(); + $node = $form->getFormNode(); + $node->setAttribute('action', $this->createUrl('/invoice/?create=')); + $node->setAttribute('method', 'GET'); + $client->submit($form, [ + 'template' => 1, + 'daterange' => $dateRange, + 'customer' => 1, + 'project' => 1, + 'markAsExported' => 1, + ]); + + $this->assertIsRedirect($client, '/invoice/show?id=1'); + $client->followRedirect(); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $this->assertHasFlashSuccess($client); + + $this->assertHasDataTable($client); + $this->assertDataTableRowCount($client, 'datatable_invoices', 1); + + // make sure the invoice is saved + $this->request($client, '/invoice/download/1'); + $response = $client->getResponse(); + $this->assertTrue($response->isSuccessful()); + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertFileExists($response->getFile()); + + $this->request($client, '/invoice/change-status/1/pending'); + $this->assertIsRedirect($client, '/invoice/show'); + $client->followRedirect(); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $this->request($client, '/invoice/change-status/1/paid'); + $this->assertIsRedirect($client, '/invoice/show'); + $client->followRedirect(); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $this->request($client, '/invoice/change-status/1/new'); + $this->assertIsRedirect($client, '/invoice/show'); + $client->followRedirect(); + $this->assertTrue($client->getResponse()->isSuccessful()); + } + public function testEditTemplateAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -196,7 +312,6 @@ class InvoiceControllerTest extends ControllerBaseTest 'company' => 'Company name', 'renderer' => 'default', 'calculator' => 'default', - 'numberGenerator' => 'default', ] ]); diff --git a/tests/Controller/PermissionControllerTest.php b/tests/Controller/PermissionControllerTest.php index bd9553274..bff62694b 100644 --- a/tests/Controller/PermissionControllerTest.php +++ b/tests/Controller/PermissionControllerTest.php @@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertHasDataTable($client); - $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 108); + $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 109); $this->assertPageActions($client, [ 'back' => $this->createUrl('/admin/user/'), 'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), diff --git a/tests/Controller/SystemConfigurationControllerTest.php b/tests/Controller/SystemConfigurationControllerTest.php index 240a959a9..2bd17f61b 100644 --- a/tests/Controller/SystemConfigurationControllerTest.php +++ b/tests/Controller/SystemConfigurationControllerTest.php @@ -68,6 +68,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest { return [ ['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')], + ['form[name=system_configuration_form_invoice]', $this->createUrl('/admin/system-config/update/invoice')], ['form[name=system_configuration_form_rounding]', $this->createUrl('/admin/system-config/update/rounding')], ['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')], ['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')], diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 602154055..d1d73489b 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -289,6 +289,8 @@ class ConfigurationTest extends TestCase 0 => 'var/invoices/', 1 => 'templates/invoice/renderer/', ], + 'simple_form' => true, + 'number_format' => '{Y}/{cy,3}', ], 'languages' => [], 'calendar' => [ diff --git a/tests/Entity/InvoiceTest.php b/tests/Entity/InvoiceTest.php new file mode 100644 index 000000000..205bd4e7d --- /dev/null +++ b/tests/Entity/InvoiceTest.php @@ -0,0 +1,169 @@ +getCreatedAt()); + self::assertNull($sut->getCurrency()); + self::assertNull($sut->getCustomer()); + self::assertNull($sut->getDueDate()); + self::assertEquals(30, $sut->getDueDays()); + self::assertNull($sut->getId()); + self::assertNull($sut->getInvoiceFilename()); + self::assertNull($sut->getInvoiceNumber()); + self::assertEquals(0.0, $sut->getTax()); + self::assertEquals(0.0, $sut->getTotal()); + self::assertNull($sut->getUser()); + self::assertEquals(0.0, $sut->getVat()); + self::assertTrue($sut->isNew()); + self::assertFalse($sut->isPending()); + self::assertFalse($sut->isPaid()); + self::assertFalse($sut->isOverdue()); + } + + public function testSetterAndGetter() + { + $date = new \DateTime('-2 months'); + $sut = new Invoice(); + + $sut->setIsPending(); + self::assertFalse($sut->isNew()); + self::assertTrue($sut->isPending()); + self::assertFalse($sut->isPaid()); + + $sut->setIsPaid(); + self::assertFalse($sut->isNew()); + self::assertFalse($sut->isPending()); + self::assertTrue($sut->isPaid()); + + $sut->setIsNew(); + self::assertTrue($sut->isNew()); + self::assertFalse($sut->isPending()); + self::assertFalse($sut->isPaid()); + self::assertFalse($sut->isOverdue()); + + $sut->setModel($this->getInvoiceModel($date)); + self::assertTrue($sut->isOverdue()); + + self::assertEquals($date, $sut->getCreatedAt()); + self::assertEquals('USD', $sut->getCurrency()); + self::assertNotNull($sut->getCustomer()); + self::assertNotNull($sut->getDueDate()); + self::assertEquals(9, $sut->getDueDays()); + self::assertNull($sut->getId()); + self::assertNull($sut->getInvoiceFilename()); + self::assertEquals(date('ymd', $date->getTimestamp()), $sut->getInvoiceNumber()); + self::assertEquals(55.72, $sut->getTax()); + self::assertEquals(348.99, $sut->getTotal()); + self::assertNotNull($sut->getUser()); + self::assertEquals(19, $sut->getVat()); + } + + protected function getInvoiceModel(\DateTime $created): InvoiceModel + { + $user = new User(); + $user->setUsername('one-user'); + $user->setTitle('user title'); + $user->setAlias('genious alias'); + $user->setEmail('fantastic@four'); + $user->addPreference((new UserPreference())->setName('kitty')->setValue('kat')); + $user->addPreference((new UserPreference())->setName('hello')->setValue('world')); + + $customer = new Customer(); + $customer->setName('customer,with/special#name'); + $customer->setCurrency('USD'); + $customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true)); + $customer->setVatId('kjuo8967'); + + $template = new InvoiceTemplate(); + $template->setTitle('a test invoice template title'); + $template->setVat(19); + $template->setDueDays(9); + + $project = new Project(); + $project->setName('project name'); + $project->setCustomer($customer); + $project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true)); + + $activity = new Activity(); + $activity->setName('activity description'); + $activity->setProject($project); + $activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true)); + + $userMethods = ['getId', 'getPreferenceValue', 'getUsername']; + $user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock(); + $user1->method('getId')->willReturn(1); + $user1->method('getPreferenceValue')->willReturn('50'); + $user1->method('getUsername')->willReturn('foo-bar'); + + $timesheet = new Timesheet(); + $timesheet + ->setDuration(3600) + ->setRate(293.27) + ->setUser($user1) + ->setActivity($activity) + ->setProject($project) + ->setBegin(new \DateTime()) + ->setEnd(new \DateTime()) + ; + + $entries = [$timesheet]; + + $query = new InvoiceQuery(); + $query->setActivity($activity); + $query->setBegin(new \DateTime()); + $query->setEnd(new \DateTime()); + + $model = new InvoiceModel(new DebugFormatter()); + $model->setCustomer($customer); + $model->setTemplate($template); + $model->addEntries($entries); + $model->setQuery($query); + $model->setUser($user); + $model->setInvoiceDate($created); + + $calculator = new DefaultCalculator(); + $calculator->setModel($model); + + $model->setCalculator($calculator); + + $numberGenerator = new DateNumberGenerator(); + $numberGenerator->setModel($model); + + $model->setNumberGenerator($numberGenerator); + + return $model; + } +} diff --git a/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php b/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php index 04e267696..ac302c10b 100644 --- a/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php +++ b/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php @@ -33,33 +33,6 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest $this->assertEmptyModel(new ActivityInvoiceCalculator()); } - public function testExceptionNoActivity() - { - $this->expectException('Exception'); - $this->expectExceptionMessage('Cannot work with invoice items that do not have an activity'); - $timesheet = new Timesheet(); - - $sut = new ActivityInvoiceCalculator(); - $model = $this->getEmptyModel(); - $model->addEntries([$timesheet]); - $sut->setModel($model); - $sut->getEntries(); - } - - public function testExceptionNoId() - { - $this->expectException('Exception'); - $this->expectExceptionMessage('Cannot handle un-persisted activities'); - $timesheet = new Timesheet(); - $timesheet->setActivity(new Activity()); - - $sut = new ActivityInvoiceCalculator(); - $model = $this->getEmptyModel(); - $model->addEntries([$timesheet]); - $sut->setModel($model); - $sut->getEntries(); - } - public function testWithMultipleEntries() { $customer = new Customer(); @@ -128,7 +101,35 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest ->setActivity($activity3) ->setProject((new Project())->setName('bar')); - $entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5]; + $timesheet6 = new Timesheet(); + $timesheet6 + ->setBegin(new \DateTime()) + ->setEnd(new \DateTime()) + ->setDuration(0) + ->setRate(0) + ->setUser(new User()) + ->setProject((new Project())->setName('bar')); + + $timesheet7 = new Timesheet(); + $timesheet7 + ->setBegin(new \DateTime()) + ->setEnd(new \DateTime()) + ->setDuration(0) + ->setRate(0) + ->setUser(new User()) + ->setActivity(new Activity()) + ->setProject((new Project())->setName('bar')); + + $timesheet8 = new Timesheet(); + $timesheet8 + ->setBegin(new \DateTime()) + ->setEnd(new \DateTime()) + ->setDuration(0) + ->setRate(0) + ->setUser(new User()) + ->setProject((new Project())->setName('bar')); + + $entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6, $timesheet7, $timesheet8]; $query = new InvoiceQuery(); $query->setActivity($activity1); @@ -148,7 +149,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest $this->assertEquals('EUR', $model->getCurrency()); $this->assertEquals(2521.12, $sut->getSubtotal()); $this->assertEquals(6600, $sut->getTimeWorked()); - $this->assertEquals(3, count($sut->getEntries())); + $this->assertEquals(5, count($sut->getEntries())); $entries = $sut->getEntries(); $this->assertEquals(404.38, $entries[0]->getRate()); diff --git a/tests/Invoice/InvoiceFilenameTest.php b/tests/Invoice/InvoiceFilenameTest.php new file mode 100644 index 000000000..d84f3b3d1 --- /dev/null +++ b/tests/Invoice/InvoiceFilenameTest.php @@ -0,0 +1,53 @@ +setNumberGenerator(new DateNumberGenerator()); + $model->setTemplate($template); + $model->setCustomer($customer); + + $datePrefix = date('ymd'); + + $sut = new InvoiceFilename($model); + + self::assertEquals($datePrefix, $sut->getFilename()); + self::assertEquals($datePrefix, (string) $sut); + + $customer->setName('foo'); + $sut = new InvoiceFilename($model); + + self::assertEquals($datePrefix . '-foo', $sut->getFilename()); + self::assertEquals($datePrefix . '-foo', (string) $sut); + + $customer->setCompany('barß / laölala # ldksjf 123'); + $sut = new InvoiceFilename($model); + + self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', $sut->getFilename()); + self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', (string) $sut); + } +} diff --git a/tests/Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php b/tests/Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php new file mode 100644 index 000000000..2d1604ce8 --- /dev/null +++ b/tests/Invoice/NumberGenerator/ConfigurableNumberGeneratorTest.php @@ -0,0 +1,100 @@ +createMock(SystemConfiguration::class); + $config->expects($this->any()) + ->method('find') + ->willReturn($format); + + $repository = $this->createMock(InvoiceRepository::class); + $repository + ->expects($this->any()) + ->method('getCounterForAllTime') + ->willReturn(1); + $repository + ->expects($this->any()) + ->method('getCounterForYear') + ->willReturn(1); + $repository + ->expects($this->any()) + ->method('getCounterForMonth') + ->willReturn(1); + $repository + ->expects($this->any()) + ->method('getCounterForDay') + ->willReturn(1); + + return new ConfigurableNumberGenerator($repository, $config); + } + + public function getTestData() + { + $timestamp = time(); + + return [ + // simple tests for single calls + ['{date}', date('ymd'), $timestamp], + ['{Y}', date('Y'), $timestamp], + ['{y}', date('y'), $timestamp], + ['{M}', date('m'), $timestamp], + ['{m}', date('n'), $timestamp], + ['{D}', date('d'), $timestamp], + ['{d}', date('j'), $timestamp], + ['{c}', '2', $timestamp], + ['{cy}', '2', $timestamp], + ['{cm}', '2', $timestamp], + ['{cd}', '2', $timestamp], + // number formatting (not testing the lower case versions, as the tests might break depending on the date) + ['{date,10}', '0000' . date('ymd'), $timestamp], + ['{Y,6}', '00' . date('Y'), $timestamp], + ['{M,3}', '0' . date('m'), $timestamp], + ['{D,3}', '0' . date('d'), $timestamp], + ['{c,2}', '02', $timestamp], + ['{cy,2}', '02', $timestamp], + ['{cm,2}', '02', $timestamp], + ['{cd,2}', '02', $timestamp], + // mixing identifiers + ['{Y}{cy}', date('Y') . '2', $timestamp], + ['{Y}{cy}{m}', date('Y') . '2' . date('n'), $timestamp], + ['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp], + ['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp], + ['{Y,5}/{cy,5}', '0' . date('Y') . '/00002', $timestamp], + ]; + } + + /** + * @dataProvider getTestData + */ + public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, int $timestamp) + { + $sut = $this->getSut($format); + $model = new InvoiceModel(new DebugFormatter()); + $model->setInvoiceDate((new \DateTime())->setTimestamp($timestamp)); + $sut->setModel($model); + + $this->assertEquals($expectedInvoiceNumber, $sut->getInvoiceNumber()); + $this->assertEquals('default', $sut->getId()); + } +} diff --git a/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php b/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php index 99cc3811a..5f49932cf 100644 --- a/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php +++ b/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php @@ -25,6 +25,6 @@ class DateNumberGeneratorTest extends TestCase $sut->setModel(new InvoiceModel(new DebugFormatter())); $this->assertEquals(date('ymd'), $sut->getInvoiceNumber()); - $this->assertEquals('default', $sut->getId()); + $this->assertEquals('date', $sut->getId()); } } diff --git a/tests/Invoice/Renderer/PdfRendererTest.php b/tests/Invoice/Renderer/PdfRendererTest.php new file mode 100644 index 000000000..fdca394b2 --- /dev/null +++ b/tests/Invoice/Renderer/PdfRendererTest.php @@ -0,0 +1,65 @@ +createMock(MPdfConverter::class)); + $this->assertTrue($sut->supports($this->getInvoiceDocument('default.pdf.twig', true))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig'))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig'))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig'))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx'))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv'))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx'))); + $this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods'))); + } + + public function testRender() + { + $kernel = self::bootKernel(); + /** @var Environment $twig */ + $twig = $kernel->getContainer()->get('twig'); + $stack = $kernel->getContainer()->get('request_stack'); + $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); + + $request = new Request(); + $request->setLocale('en'); + $stack->push($request); + + /** @var FilesystemLoader $loader */ + $loader = $twig->getLoader(); + $loader->addPath(__DIR__ . '/../templates/', 'invoice'); + + $sut = new PdfRenderer($twig, new MPdfConverter($cacheDir)); + $model = $this->getInvoiceModel(); + $document = $this->getInvoiceDocument('default.pdf.twig', true); + + $response = $sut->render($document, $model); + $this->assertEquals('application/pdf', $response->headers->get('Content-Type')); + } +} diff --git a/tests/Invoice/ServiceInvoiceTest.php b/tests/Invoice/ServiceInvoiceTest.php index 0e392f8c0..e046882f2 100644 --- a/tests/Invoice/ServiceInvoiceTest.php +++ b/tests/Invoice/ServiceInvoiceTest.php @@ -15,6 +15,7 @@ use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\Renderer\TwigRenderer; use App\Invoice\ServiceInvoice; use App\Repository\InvoiceDocumentRepository; +use App\Utils\FileHelper; use PHPUnit\Framework\TestCase; use Twig\Environment; @@ -23,10 +24,16 @@ use Twig\Environment; */ class ServiceInvoiceTest extends TestCase { + private function getSut(array $paths): ServiceInvoice + { + $repo = new InvoiceDocumentRepository($paths); + + return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/'))); + } + public function testEmptyObject() { - $repo = new InvoiceDocumentRepository([]); - $sut = new ServiceInvoice($repo); + $sut = $this->getSut([]); $this->assertEmpty($sut->getCalculator()); $this->assertIsArray($sut->getCalculator()); @@ -44,8 +51,7 @@ class ServiceInvoiceTest extends TestCase public function testWithDocumentDirectory() { - $repo = new InvoiceDocumentRepository(['templates/invoice/renderer/']); - $sut = new ServiceInvoice($repo); + $sut = $this->getSut(['templates/invoice/renderer/']); $actual = $sut->getDocuments(); $this->assertNotEmpty($actual); @@ -59,8 +65,7 @@ class ServiceInvoiceTest extends TestCase public function testAdd() { - $repo = new InvoiceDocumentRepository([]); - $sut = new ServiceInvoice($repo); + $sut = $this->getSut([]); $sut->addCalculator(new DefaultCalculator()); $sut->addNumberGenerator(new DateNumberGenerator()); @@ -74,7 +79,7 @@ class ServiceInvoiceTest extends TestCase $this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default')); $this->assertEquals(1, count($sut->getNumberGenerator())); - $this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('default')); + $this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('date')); $this->assertEquals(1, count($sut->getRenderer())); } diff --git a/tests/Invoice/templates/default.pdf.twig b/tests/Invoice/templates/default.pdf.twig new file mode 100644 index 000000000..5bf82acd7 --- /dev/null +++ b/tests/Invoice/templates/default.pdf.twig @@ -0,0 +1,146 @@ +{% extends 'invoice/layout.html.twig' %} +{% set language = model.template.language|default(app.request.locale) %} +{% set isDecimal = model.template.decimalDuration|default(false) %} + +{% block invoice %} +
+
+ +
+
+ +
+
+ {{ 'invoice.from'|trans({}, 'messages', language) }} +
+ {{ model.template.company }}
+ {{ model.template.address|trim|nl2br }} + {% if model.template.vatId is not empty %} +
+ {{ 'label.vat_id'|trans({}, 'messages', language) }}: + {{ model.template.vatId }} + {% endif %} +
+
+
+
+ {{ 'invoice.to'|trans({}, 'messages', language) }} +
+ {{ model.customer.company|default(model.customer.name) }}
+ {{ model.customer.address|nl2br }} + {% if model.customer.vatId is not empty %} +
+ {{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }} + {% endif %} + {% if model.customer.number is not empty %} +
+ {{ 'label.number'|trans({}, 'messages', language) }}: {{ model.customer.number }} + {% endif %} + {% if model.query.project is not empty and model.query.project.orderNumber is not empty %} +
+ {{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }} + {% endif %} +
+
+
+ +
+
+

+ {{ 'invoice.number'|trans({}, 'messages', language) }}: + {{ model.numberGenerator.invoiceNumber }} + +
+ {{ 'invoice.due_days'|trans({}, 'messages', language) }}: + {{ model.dueDate|date_short }} +

+
+
+
+ +
+
+ + + + + + + + + + + + {% for entry in model.calculator.entries %} + {% set duration = entry.duration|duration(isDecimal) %} + {% if entry.fixedRate %} + {% set rate = entry.fixedRate %} + {% set duration = entry.amount|amount %} + {% else %} + {% set rate = entry.hourlyRate %} + {% endif %} + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + +
{{ 'label.date'|trans({}, 'messages', language) }}{{ 'label.description'|trans({}, 'messages', language) }}{{ 'label.unit_price'|trans({}, 'messages', language) }}{{ 'label.amount'|trans({}, 'messages', language) }}{{ 'label.total_rate'|trans({}, 'messages', language) }}
{{ entry.begin|date_short }} + {% if entry.description is not empty %} + {{ entry.description|nl2br }} + {% else %} + {% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }} + {% endif %} + {{ rate|money(model.calculator.currency) }}{{ duration }}{{ entry.rate|money(model.calculator.currency) }}
+ {{ 'invoice.subtotal'|trans({}, 'messages', language) }} + {{ model.calculator.subtotal|money(model.calculator.currency) }}
+ {{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%) + {{ model.calculator.tax|money(model.calculator.currency) }}
+ {{ 'invoice.total'|trans({}, 'messages', language) }} + + {{ model.calculator.total|money(model.calculator.currency) }} +
+
+
+ +
+
+ {% if model.template.paymentTerms is not empty %} +
+ {{ model.template.paymentTerms|nl2br|md2html }} +
+ {% endif %} +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/tests/Repository/Loader/AbstractLoaderTest.php b/tests/Repository/Loader/AbstractLoaderTest.php new file mode 100644 index 000000000..61bbf003e --- /dev/null +++ b/tests/Repository/Loader/AbstractLoaderTest.php @@ -0,0 +1,43 @@ +createMock(EntityManager::class); + $qb = $this->createMock(QueryBuilder::class); + $query = $this->createMock(AbstractQuery::class); + $expr = $this->createMock(Expr::class); + + $expr->expects($this->any())->method('isNotNull')->willReturn(''); + $expr->expects($this->any())->method('in')->willReturn(''); + + $qb->expects($this->any())->method('andWhere')->willReturnSelf(); + $qb->expects($this->any())->method('from')->willReturnSelf(); + $qb->expects($this->any())->method('expr')->willReturn($expr); + $qb->expects($this->any())->method('from')->willReturnSelf(); + $qb->expects($this->any())->method('select')->willReturnSelf(); + $qb->expects($this->any())->method('leftJoin')->willReturnSelf(); + $qb->expects($this->any())->method('getQuery')->willReturn($query); + $query->expects($this->any())->method('execute')->willReturn(null); + + $em->expects($this->exactly($createQueryBuilderCount))->method('createQueryBuilder')->willReturn($qb); + + return $em; + } +} diff --git a/tests/Repository/Loader/ActivityLoaderTest.php b/tests/Repository/Loader/ActivityLoaderTest.php new file mode 100644 index 000000000..1abdc0339 --- /dev/null +++ b/tests/Repository/Loader/ActivityLoaderTest.php @@ -0,0 +1,33 @@ +getEntityManagerMock(2); + + $sut = new ActivityLoader($em); + + $entity = $this->createMock(Activity::class); + $entity->expects($this->once())->method('getId')->willReturn(1); + + $sut->loadResults([$entity]); + } +} diff --git a/tests/Repository/Loader/CustomerLoaderTest.php b/tests/Repository/Loader/CustomerLoaderTest.php new file mode 100644 index 000000000..739b08cba --- /dev/null +++ b/tests/Repository/Loader/CustomerLoaderTest.php @@ -0,0 +1,32 @@ +getEntityManagerMock(2); + + $sut = new CustomerLoader($em); + + $entity = $this->createMock(Customer::class); + $entity->expects($this->once())->method('getId')->willReturn(1); + + $sut->loadResults([$entity]); + } +} diff --git a/tests/Repository/Loader/InvoiceLoaderTest.php b/tests/Repository/Loader/InvoiceLoaderTest.php new file mode 100644 index 000000000..a1a6296cd --- /dev/null +++ b/tests/Repository/Loader/InvoiceLoaderTest.php @@ -0,0 +1,32 @@ +getEntityManagerMock(2); + + $sut = new InvoiceLoader($em); + + $entity = $this->createMock(Invoice::class); + $entity->expects($this->once())->method('getId')->willReturn(1); + + $sut->loadResults([$entity]); + } +} diff --git a/tests/Repository/Loader/ProjectLoaderTest.php b/tests/Repository/Loader/ProjectLoaderTest.php new file mode 100644 index 000000000..d7507958f --- /dev/null +++ b/tests/Repository/Loader/ProjectLoaderTest.php @@ -0,0 +1,32 @@ +getEntityManagerMock(4); + + $sut = new ProjectLoader($em); + + $entity = $this->createMock(Project::class); + $entity->expects($this->once())->method('getId')->willReturn(1); + + $sut->loadResults([$entity]); + } +} diff --git a/tests/Repository/Loader/TeamLoaderTest.php b/tests/Repository/Loader/TeamLoaderTest.php new file mode 100644 index 000000000..39f3bcb6d --- /dev/null +++ b/tests/Repository/Loader/TeamLoaderTest.php @@ -0,0 +1,32 @@ +getEntityManagerMock(1); + + $sut = new TeamLoader($em); + + $entity = $this->createMock(Team::class); + $entity->expects($this->once())->method('getId')->willReturn(1); + + $sut->loadResults([$entity]); + } +} diff --git a/tests/_data/.gitignore b/tests/_data/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/translations/actions.de.xlf b/translations/actions.de.xlf index 4200ef85b..1c088361d 100644 --- a/translations/actions.de.xlf +++ b/translations/actions.de.xlf @@ -78,6 +78,18 @@ details Anzeigen + + download + Herunterladen + + + invoice.pending + Warten auf Zahlungseingang + + + invoice.paid + Rechnung bezahlt + diff --git a/translations/actions.en.xlf b/translations/actions.en.xlf index b807989fd..d3c9a3ed9 100644 --- a/translations/actions.en.xlf +++ b/translations/actions.en.xlf @@ -78,6 +78,18 @@ details Show + + download + Download + + + invoice.pending + Waiting for payment + + + invoice.paid + Invoice paid + diff --git a/translations/invoice-numbergenerator.ar.xlf b/translations/invoice-numbergenerator.ar.xlf index 14712c92d..40377dc7c 100755 --- a/translations/invoice-numbergenerator.ar.xlf +++ b/translations/invoice-numbergenerator.ar.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator مولد رقم فاتورة - - default - التاريخ (افتراضي) + + date + التاريخ diff --git a/translations/invoice-numbergenerator.cs.xlf b/translations/invoice-numbergenerator.cs.xlf index 9f0dc0ffa..bf874f8b8 100644 --- a/translations/invoice-numbergenerator.cs.xlf +++ b/translations/invoice-numbergenerator.cs.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Generátor fakturačních čísel - - default - Data (Výchozí) + + date + Data diff --git a/translations/invoice-numbergenerator.da.xlf b/translations/invoice-numbergenerator.da.xlf index 7a4306871..4fe0b484e 100644 --- a/translations/invoice-numbergenerator.da.xlf +++ b/translations/invoice-numbergenerator.da.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Fakturanummergenerator - - default - Dato (standard) + + date + Dato diff --git a/translations/invoice-numbergenerator.de.xlf b/translations/invoice-numbergenerator.de.xlf index e8169cab8..34be21d5c 100644 --- a/translations/invoice-numbergenerator.de.xlf +++ b/translations/invoice-numbergenerator.de.xlf @@ -6,9 +6,13 @@ label.invoice_number_generator Rechnungsnummern-Generator + + date + Datum + default - Datum (Standard) + Konfiguriertes Format diff --git a/translations/invoice-numbergenerator.en.xlf b/translations/invoice-numbergenerator.en.xlf index 485c7f009..78df64f70 100644 --- a/translations/invoice-numbergenerator.en.xlf +++ b/translations/invoice-numbergenerator.en.xlf @@ -6,9 +6,13 @@ label.invoice_number_generator Invoicenumber-Generator + + date + Date + default - Date (default) + Configured format diff --git a/translations/invoice-numbergenerator.es.xlf b/translations/invoice-numbergenerator.es.xlf index 26d584adf..77bc9d9ce 100644 --- a/translations/invoice-numbergenerator.es.xlf +++ b/translations/invoice-numbergenerator.es.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Generador de número de factura - - default - Fecha (default) + + date + Fecha diff --git a/translations/invoice-numbergenerator.eu.xlf b/translations/invoice-numbergenerator.eu.xlf index 52e38ef81..605a74b69 100644 --- a/translations/invoice-numbergenerator.eu.xlf +++ b/translations/invoice-numbergenerator.eu.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Faktura zenbaki sortzailea - - default - Data (defektuz) + + date + Data diff --git a/translations/invoice-numbergenerator.fr.xlf b/translations/invoice-numbergenerator.fr.xlf index bf3f2c787..08c5bece6 100644 --- a/translations/invoice-numbergenerator.fr.xlf +++ b/translations/invoice-numbergenerator.fr.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Générateur de numéros de facture - - default - Date (par defaut) + + date + Date diff --git a/translations/invoice-numbergenerator.hu.xlf b/translations/invoice-numbergenerator.hu.xlf index 6b2f6d2e6..e84e136a7 100644 --- a/translations/invoice-numbergenerator.hu.xlf +++ b/translations/invoice-numbergenerator.hu.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Számlaszám-generátor - - default - Dátum (alapértelmezett) + + date + Dátum diff --git a/translations/invoice-numbergenerator.it.xlf b/translations/invoice-numbergenerator.it.xlf index 708014633..7334f50a0 100644 --- a/translations/invoice-numbergenerator.it.xlf +++ b/translations/invoice-numbergenerator.it.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Generatore di numerazione fattura - - default - Data (default) + + date + Data diff --git a/translations/invoice-numbergenerator.ja.xlf b/translations/invoice-numbergenerator.ja.xlf index 158990c18..4d552b042 100644 --- a/translations/invoice-numbergenerator.ja.xlf +++ b/translations/invoice-numbergenerator.ja.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator 請求書番号 ジェネレーター - - default - 日付 (デフォルト) + + date + 日付 diff --git a/translations/invoice-numbergenerator.ko.xlf b/translations/invoice-numbergenerator.ko.xlf index c109facfa..e4c90cc2f 100755 --- a/translations/invoice-numbergenerator.ko.xlf +++ b/translations/invoice-numbergenerator.ko.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator 인보이스번호-생성기 - - default - 날짜 (초기가) + + date + 날짜 diff --git a/translations/invoice-numbergenerator.nl.xlf b/translations/invoice-numbergenerator.nl.xlf index f25033469..0c4d51192 100644 --- a/translations/invoice-numbergenerator.nl.xlf +++ b/translations/invoice-numbergenerator.nl.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Factuurnummergenerator - - default - Datum (standaard) + + date + Datum diff --git a/translations/invoice-numbergenerator.pl.xlf b/translations/invoice-numbergenerator.pl.xlf index 36a9052ed..0dc1e7a6a 100644 --- a/translations/invoice-numbergenerator.pl.xlf +++ b/translations/invoice-numbergenerator.pl.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Generator numeru faktury - - default - Data (domyślny) + + date + Data diff --git a/translations/invoice-numbergenerator.pt_BR.xlf b/translations/invoice-numbergenerator.pt_BR.xlf index e409f3fb1..efb28e72a 100644 --- a/translations/invoice-numbergenerator.pt_BR.xlf +++ b/translations/invoice-numbergenerator.pt_BR.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Gerador de Números de Fatura - - default - Data (Padrão) + + date + Data diff --git a/translations/invoice-numbergenerator.ru.xlf b/translations/invoice-numbergenerator.ru.xlf index d0e131bbe..ac1cb4ded 100644 --- a/translations/invoice-numbergenerator.ru.xlf +++ b/translations/invoice-numbergenerator.ru.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Генератор номеров счета - - default - Дата (стандартная) + + date + Дата diff --git a/translations/invoice-numbergenerator.sk.xlf b/translations/invoice-numbergenerator.sk.xlf index b874ee2b5..8b97af9db 100644 --- a/translations/invoice-numbergenerator.sk.xlf +++ b/translations/invoice-numbergenerator.sk.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Generátor čísiel faktúr - - default - Dátum (default) + + date + Dátum diff --git a/translations/invoice-numbergenerator.sv.xlf b/translations/invoice-numbergenerator.sv.xlf index 3a18c3efb..34caf80bd 100644 --- a/translations/invoice-numbergenerator.sv.xlf +++ b/translations/invoice-numbergenerator.sv.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Fakturanummergenerator - - default - Datum (standard) + + date + Datum diff --git a/translations/invoice-numbergenerator.tr.xlf b/translations/invoice-numbergenerator.tr.xlf index 198aa5d30..b938b28be 100644 --- a/translations/invoice-numbergenerator.tr.xlf +++ b/translations/invoice-numbergenerator.tr.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator Faturano-Oluşturucu - - default - Tarih (varsayılan) + + date + Tarih diff --git a/translations/invoice-numbergenerator.zh_CN.xlf b/translations/invoice-numbergenerator.zh_CN.xlf index 80aaf672d..ffc801144 100644 --- a/translations/invoice-numbergenerator.zh_CN.xlf +++ b/translations/invoice-numbergenerator.zh_CN.xlf @@ -6,9 +6,9 @@ label.invoice_number_generator 发票数字生成器 - - default - 日期(默认) + + date + 日期 diff --git a/translations/messages.ar.xlf b/translations/messages.ar.xlf index e5c236466..9eacd7b58 100755 --- a/translations/messages.ar.xlf +++ b/translations/messages.ar.xlf @@ -561,10 +561,6 @@ invoice.title الفواتير - - invoice.subtitle - م بإنشاء فواتير من بيانات ورقة التوقيت الخاصة بك. - button.print طباعة diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf index 4c1a05883..8c68f6662 100644 --- a/translations/messages.cs.xlf +++ b/translations/messages.cs.xlf @@ -752,10 +752,6 @@ invoice.title Faktura - - invoice.subtitle - Vytvářejte faktury z vašich zaznamenaných položek časového záznamu. - invoice.preview Toto je náhled dat, která se zobrazí ve vašem fakturačním dokladu. diff --git a/translations/messages.da.xlf b/translations/messages.da.xlf index 99ebb8bdc..ac3c365d9 100644 --- a/translations/messages.da.xlf +++ b/translations/messages.da.xlf @@ -784,10 +784,6 @@ invoice.title Fakturaer - - invoice.subtitle - Opret faktura for dine tidsregistreringsindlæg. - invoice.filter Filtrer fakturadata diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index d12ac0b42..18665fcfa 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -824,10 +824,6 @@ invoice.title Rechnungen - - invoice.subtitle - Erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten - invoice.filter Rechnungsdaten filtern @@ -836,6 +832,10 @@ button.preview Vorschau + + button.preview_print + Druck Vorschau + button.print Drucken @@ -952,6 +952,22 @@ label.decimalDuration Dauer als Dezimalzahl anzeigen + + label.status + Status + + + status.new + Neu + + + status.pending + Ausstehend + + + status.paid + Bezahlt + @@ -196,7 +196,7 @@ error.no_comments_found Non ci sono commenti fino'ora. - + @@ -440,7 +440,7 @@ label.appendTags Aggiungi tags - + @@ -513,7 +513,7 @@ label.timesheet.export_decimal Usa durata decimale nell'esportazione - + @@ -820,10 +820,6 @@ invoice.title Fatture - - invoice.subtitle - Crea fattura dalle registrazioni. - invoice.filter Filtra per data fattura diff --git a/translations/messages.ja.xlf b/translations/messages.ja.xlf index 9a4e30f86..440917893 100644 --- a/translations/messages.ja.xlf +++ b/translations/messages.ja.xlf @@ -696,10 +696,6 @@ invoice.title 請求書 - - invoice.subtitle - タイムシートに記録したエントリから請求書を作成します。 - button.print 出力 diff --git a/translations/messages.ko.xlf b/translations/messages.ko.xlf index e99cc6426..3e0758b3a 100755 --- a/translations/messages.ko.xlf +++ b/translations/messages.ko.xlf @@ -712,10 +712,6 @@ invoice.title 인보이스 - - invoice.subtitle - 기록된 시간기록표를 바탕으로 인보이스 생성 - invoice.preview 인보이스에 표시되는 정보의 미리보기 diff --git a/translations/messages.nl.xlf b/translations/messages.nl.xlf index 4f8742697..c74fdf863 100644 --- a/translations/messages.nl.xlf +++ b/translations/messages.nl.xlf @@ -752,10 +752,6 @@ invoice.title Facturen maken - - invoice.subtitle - Zet uw prestaties om in een factuur - invoice.filter Filter op factuurdatum diff --git a/translations/messages.pl.xlf b/translations/messages.pl.xlf index b08236e7f..ca56d06ea 100644 --- a/translations/messages.pl.xlf +++ b/translations/messages.pl.xlf @@ -804,10 +804,6 @@ invoice.title Faktury - - invoice.subtitle - Utwórz fakturę z twoich wpisów do ewidencji. - invoice.filter Filtruj po dacie faktury diff --git a/translations/messages.pt_BR.xlf b/translations/messages.pt_BR.xlf index 5aa5f728e..022cc0ab2 100644 --- a/translations/messages.pt_BR.xlf +++ b/translations/messages.pt_BR.xlf @@ -696,10 +696,6 @@ invoice.title Faturas - - invoice.subtitle - Criar faturas a partir de suas entradas de quadro de horários registradas. - button.print Imprimir diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf index 3f6b4942c..66a5a6c9f 100644 --- a/translations/messages.ru.xlf +++ b/translations/messages.ru.xlf @@ -584,10 +584,6 @@ invoice.title Бланки счета - - invoice.subtitle - Создайте счета для записанных периодов времени. - button.print Печать diff --git a/translations/messages.sk.xlf b/translations/messages.sk.xlf index c57049d70..84c084b51 100644 --- a/translations/messages.sk.xlf +++ b/translations/messages.sk.xlf @@ -837,10 +837,6 @@ invoice.title Faktúry - - invoice.subtitle - Vytvorte faktúry z vašich položiek časového rozvrhu. - invoice.filter Filtrovať dáta faktúr diff --git a/translations/messages.sv.xlf b/translations/messages.sv.xlf index 3d1b74bcb..5c2878eba 100755 --- a/translations/messages.sv.xlf +++ b/translations/messages.sv.xlf @@ -691,10 +691,6 @@ För närvarande har %user% användare %records% tidsrekord som räknas upp till invoice.title Fakturor - - invoice.subtitle - Skapa fakturor från dina inspelade tidskriftsuppgifter. - button.print Skriv ut diff --git a/translations/messages.tr.xlf b/translations/messages.tr.xlf index f088bb2f2..c12a27bdb 100644 --- a/translations/messages.tr.xlf +++ b/translations/messages.tr.xlf @@ -756,10 +756,6 @@ invoice.title Faturalar - - invoice.subtitle - Çizelge kayıtlarınızdan fatura oluştur. - invoice.filter Fatura verilerini filtrele diff --git a/translations/messages.zh_CN.xlf b/translations/messages.zh_CN.xlf index 5584b01b1..267ec84c6 100644 --- a/translations/messages.zh_CN.xlf +++ b/translations/messages.zh_CN.xlf @@ -783,10 +783,6 @@ invoice.title 发票 - - invoice.subtitle - 从你的时间记录表条目中创建发票。 - invoice.filter 筛选发票信息 diff --git a/translations/system-configuration.de.xlf b/translations/system-configuration.de.xlf index ade9f5aab..58e9d17f4 100644 --- a/translations/system-configuration.de.xlf +++ b/translations/system-configuration.de.xlf @@ -14,6 +14,10 @@ timesheet Zeiterfassung + + invoice + Rechnungen + form_customer Kunden anlegen - Standard Werte @@ -194,6 +198,14 @@ label.timesheet.default_begin Standard Startzeit (wird nicht in jedem Zeiterfassungs Modus verwendet) + + label.simple_form + Einfache Suche + + + label.invoice.number_format + Rechnungsnummer Format + diff --git a/translations/system-configuration.en.xlf b/translations/system-configuration.en.xlf index 011529b12..318165017 100644 --- a/translations/system-configuration.en.xlf +++ b/translations/system-configuration.en.xlf @@ -14,6 +14,10 @@ timesheet Timesheet + + invoice + Invoices + form_customer Create customer—default values @@ -194,6 +198,14 @@ label.timesheet.default_begin Default start-time (not used in all timetracking modes) + + label.simple_form + Simple search + + + label.invoice.number_format + Invoice number format + diff --git a/var/data/kimai_test.sqlite b/var/data/kimai_test.sqlite index 52ad4dae8..68f2eca26 100644 Binary files a/var/data/kimai_test.sqlite and b/var/data/kimai_test.sqlite differ