diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 1d9d3c07..16b0ff7b 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 113c3d6a..80bb6754 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 59a138b2..b0b1cc0c 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 c58196f8..743e1187 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 00000000..d3f9a126 --- /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 a083c0d1..51a5b835 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 ef4de7cc..62c3cfa2 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 b8a290ed..a51cff04 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 50d7cedb..c2f6e3f5 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 2aea99a2..09e35bf6 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 00000000..ae89a8b5 --- /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 da430bf9..26df9141 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 e5c59d31..191335a0 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 256dc4bc..b022df20 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 17b8259d..24ec0a10 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 ca37a2cb..834fb4ed 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 00000000..eb3aafd5 --- /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 11324bae..4ea044c5 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 4f7e2748..e8c83080 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 f50209b4..4a96c126 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 2b998c8d..64d4f20a 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 465dfd32..25285cd6 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 00000000..6f24e756 --- /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 2382dd1d..c27ad021 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 ac147cb4..2672291d 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 00000000..2a429082 --- /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 a9dc2ce6..68a5e529 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 0c26af1d..25809eb8 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 00000000..bd7a1b3b --- /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 70b4194c..ea77be29 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 3872e33a..d84b9e8c 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 00000000..2078f3d6 --- /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 00000000..7e72ec6e --- /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 00000000..ec37a3b5 --- /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 00000000..12b118fc --- /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 00000000..447091ab --- /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 3722270d..b43c6b3a 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 c92aa630..312b7401 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 00000000..d29d1b9b --- /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 00000000..79b029d3 --- /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 b075b17e..19e514bb 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 4d9f60ee..3848af4b 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 d53205bd..e3d32327 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 bd955327..bff62694 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 240a959a..2bd17f61 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 60215405..d1d73489 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 00000000..205bd4e7 --- /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 04e26769..ac302c10 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 00000000..d84f3b3d --- /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 00000000..2d1604ce --- /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 99cc3811..5f49932c 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 00000000..fdca394b --- /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 0e392f8c..e046882f 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 00000000..5bf82acd --- /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 00000000..61bbf003 --- /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 00000000..1abdc033 --- /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 00000000..739b08cb --- /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 00000000..a1a6296c --- /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 00000000..d7507958 --- /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 00000000..39f3bcb6 --- /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 00000000..e69de29b diff --git a/translations/actions.de.xlf b/translations/actions.de.xlf index 4200ef85..1c088361 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 b807989f..d3c9a3ed 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 14712c92..40377dc7 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 9f0dc0ff..bf874f8b 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 7a430687..4fe0b484 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 e8169cab..34be21d5 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 485c7f00..78df64f7 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 26d584ad..77bc9d9c 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 52e38ef8..605a74b6 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 bf3f2c78..08c5bece 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 6b2f6d2e..e84e136a 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 70801463..7334f50a 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 158990c1..4d552b04 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 c109facf..e4c90cc2 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 f2503346..0c4d5119 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 36a9052e..0dc1e7a6 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 e409f3fb..efb28e72 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 d0e131bb..ac1cb4de 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 b874ee2b..8b97af9d 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 3a18c3ef..34caf80b 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 198aa5d3..b938b28b 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 80aaf672..ffc80114 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 e5c23646..9eacd7b5 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 4c1a0588..8c68f666 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 99ebb8bd..ac3c365d 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 d12ac0b4..18665fcf 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 9a4e30f8..44091789 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 e99cc642..3e0758b3 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 4f874269..c74fdf86 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 b08236e7..ca56d06e 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 5aa5f728..022cc0ab 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 3f6b4942..66a5a6c9 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 c57049d7..84c084b5 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 3d1b74bc..5c2878eb 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 f088bb2f..c12a27bd 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 5584b01b..267ec84c 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 ade9f5aa..58e9d17f 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 011529b1..31816501 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 52ad4dae..68f2eca2 100644 Binary files a/var/data/kimai_test.sqlite and b/var/data/kimai_test.sqlite differ