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 @@
+ {{ 'invoice.number'|trans({}, 'messages', language) }}:
+ {{ model.numberGenerator.invoiceNumber }}
+
+
+ {{ 'invoice.due_days'|trans({}, 'messages', language) }}:
+ {{ model.dueDate|date_short }}
+
| {{ '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) }} + | +|||