diff --git a/UPGRADING.md b/UPGRADING.md index 506011ace..7e63c4956 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -16,10 +16,10 @@ Perform EACH version specific task between your version and the new one, otherwi - The default invoice number format changed. If you want to use the old one: configure `{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. -- Invoice templates that use query values `activity.X` or `project.X` should be checked, as multi-select is now possible for filtering +- Invoice templates that use the templates variables `${activity.X}` or `${project.X}` should be checked and possibly adapted, as multi-select is now possible for filtering Permission changes: -- `history_invoice` - NEW: grants all features of the new invoice archive (by default for all admins) +- `history_invoice` - NEW: grants all features for the new invoice archive (by default for all admins) ### Developer diff --git a/config/packages/test/security.yaml b/config/packages/test/security.yaml index 162e4fa11..873f9e4c0 100644 --- a/config/packages/test/security.yaml +++ b/config/packages/test/security.yaml @@ -2,8 +2,6 @@ # See https://symfony.com/doc/current/cookbook/testing/http_authentication.html security: encoders: - # to make tests much faster, BCrypt cost is changed to its minimum allowed value (4) - # See https://symfony.com/doc/current/reference/configuration/security.html#using-the-bcrypt-password-encoder App\Entity\User: { algorithm: auto } firewalls: diff --git a/src/Command/InvoiceCreateCommand.php b/src/Command/InvoiceCreateCommand.php new file mode 100644 index 000000000..ba1c29be4 --- /dev/null +++ b/src/Command/InvoiceCreateCommand.php @@ -0,0 +1,468 @@ +serviceInvoice = $serviceInvoice; + $this->timesheetRepository = $timesheetRepository; + $this->customerRepository = $customerRepository; + $this->invoiceTemplateRepository = $invoiceTemplateRepository; + $this->userRepository = $userRepository; + $this->eventDispatcher = $eventDispatcher; + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('kimai:invoice:create') + ->setDescription('Create invoices') + ->setHelp('This command allows to create invoices by several different filters.') + ->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices') + ->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null) + ->addOption('end', null, InputOption::VALUE_OPTIONAL, 'End date (format: 2020-01-31, default: end of the month)', null) + ->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Timezone for start and end date query', date_default_timezone_get()) + ->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of customer IDs', null) + ->addOption('by-customer', null, InputOption::VALUE_NONE, 'If set, one invoice for each active customer in the given timerange is created') + ->addOption('by-project', null, InputOption::VALUE_NONE, 'If set, one invoice for each active project in the given timerange is created') + ->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the invoice items should be marked as exported') + ->addOption('template', null, InputOption::VALUE_OPTIONAL, 'Invoice template', null) + ->addOption('template-meta', null, InputOption::VALUE_OPTIONAL, 'Fetch invoice template from a meta-field', null) + ->addOption('search', null, InputOption::VALUE_OPTIONAL, 'Search term to filter invoice entries', null) + ->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for invoice entries (possible values: exported, all), by default only "not exported" items are fetched', null) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + // =============== VALIDATION START =============== + + $username = $input->getOption('user'); + if (empty($username)) { + $io->error('You must set a "user" to create invoices'); + + return 1; + } + + $exportedFilter = TimesheetQuery::STATE_NOT_EXPORTED; + switch ($input->getOption('exported')) { + case null: + break; + + case 'all': + $exportedFilter = TimesheetQuery::STATE_ALL; + break; + + case 'exported': + $exportedFilter = TimesheetQuery::STATE_EXPORTED; + break; + + default: + $io->error('Unknown "exported" filter given'); + + return 1; + } + + $user = $this->userRepository->loadUserByUsername($username); + if (null === $user) { + $io->error( + sprintf('The given username "%s" could not be resolved', $username) + ); + + return 1; + } + + $timezone = $input->getOption('timezone'); + $timezone = new \DateTimeZone($timezone); + + if (!empty($input->getOption('start')) && empty($input->getOption('end'))) { + $io->error('You need to supply a end date if a start date was given'); + + return 1; + } + + $byActiveCustomer = $input->getOption('by-customer'); + $byActiveProject = $input->getOption('by-project'); + + if ($byActiveCustomer && $byActiveProject) { + $io->error('You cannot mix "by-customer" and "by-project"'); + + return 1; + } + + $customersIDs = $input->getOption('customer'); + if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs)) { + $io->error('Could not determine generation mode, you need to set one of: customer, by-customer, by-project'); + + return 1; + } + + if (null === $input->getOption('template') && null === $input->getOption('template-meta')) { + $io->error('You must either pass the "template" or "template-meta" option'); + + return 1; + } + + $start = $input->getOption('start'); + if (!empty($start)) { + try { + $start = new \DateTime($start, $timezone); + } catch (\Exception $ex) { + $io->error('Invalid start date given'); + + return 1; + } + } + if (!$start instanceof \DateTime) { + $start = new \DateTime('first day of this month', $timezone); + } + $start->setTime(0, 0, 0); + + $end = $input->getOption('end'); + if (!empty($end)) { + try { + $end = new \DateTime($end, $timezone); + } catch (\Exception $ex) { + $io->error('Invalid end date given'); + + return 1; + } + } + if (!$end instanceof \DateTime) { + $end = new \DateTime('last day of this month', $timezone); + } + $end->setTime(23, 59, 59); + + $searchTerm = null; + if (null !== $input->getOption('search')) { + $searchTerm = new SearchTerm($input->getOption('search')); + } + + $markAsExported = false; + if ($input->getOption('set-exported')) { + $markAsExported = true; + } + + // =============== VALIDATION END =============== + + $defaultQuery = new InvoiceQuery(); + $defaultQuery->setBegin($start); + $defaultQuery->setEnd($end); + $defaultQuery->setCurrentUser($user); + $defaultQuery->setSearchTerm($searchTerm); + $defaultQuery->setMarkAsExported($markAsExported); + $defaultQuery->setState($exportedFilter); + + /** @var Invoice[] $invoices */ + $invoices = []; + + /** @var Customer[] $customers */ + $customers = []; + + if (!empty($customersIDs)) { + $customersIDs = explode(',', $customersIDs); + foreach ($customersIDs as $id) { + $tmp = $this->customerRepository->find($id); + if (null === $tmp) { + $io->error('Unknown customer ID: ' . $id); + + return 1; + } + $customers[] = $tmp; + } + $invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output); + } elseif ($byActiveCustomer) { + $customers = $this->getActiveCustomers($start, $end); + $invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output); + } elseif ($byActiveProject) { + $projects = $this->getActiveProjects($start, $end); + $invoices = $this->createInvoicesForProjects($projects, $defaultQuery, $input, $output); + } else { + $io->error('Could not determine generation mode'); //-///9==8=//99/96//////-*/-*//96* <= by Ayumi + + return 1; + } + + return $this->renderInvoiceResult($input, $output, $invoices); + } + + /** + * @param Project[] $projects + * @param InvoiceQuery $defaultQuery + * @param InputInterface $input + * @param OutputInterface $output + * @return Invoice[] + * @throws \Exception + */ + protected function createInvoicesForProjects(array $projects, InvoiceQuery $defaultQuery, InputInterface $input, OutputInterface $output): array + { + $io = new SymfonyStyle($input, $output); + + /** @var Invoice[] $invoices */ + $invoices = []; + + foreach ($projects as $project) { + $query = clone $defaultQuery; + $query->addProject($project); + $query->addCustomer($project->getCustomer()); + + $tpl = $this->getTemplateForProject($input, $project); + if (null === $tpl) { + $io->warning(sprintf('Could not find invoice template for project "%s", skipping!', $project->getName())); + continue; + } + $query->setTemplate($tpl); + + try { + $invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher); + } catch (\Exception $ex) { + $io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage())); + } + } + + return $invoices; + } + + /** + * @param Customer[] $customers + * @param InvoiceQuery $defaultQuery + * @param InputInterface $input + * @return Invoice[] + * @throws \Exception + */ + protected function createInvoicesForCustomer(array $customers, InvoiceQuery $defaultQuery, InputInterface $input, OutputInterface $output): array + { + $io = new SymfonyStyle($input, $output); + + /** @var Invoice[] $invoices */ + $invoices = []; + + foreach ($customers as $customer) { + $query = clone $defaultQuery; + $query->addCustomer($customer); + + $tpl = $this->getTemplateForCustomer($input, $customer); + if (null === $tpl) { + $io->warning(sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName())); + continue; + } + $query->setTemplate($tpl); + + try { + $invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher); + } catch (\Exception $ex) { + $io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage())); + } + } + + return $invoices; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @param Invoice[] $invoices + * @return int + */ + protected function renderInvoiceResult(InputInterface $input, OutputInterface $output, array $invoices): int + { + $io = new SymfonyStyle($input, $output); + + if (empty($invoices)) { + $io->warning('No invoice was generated'); + + return 0; + } + + $columns = ['ID', 'Customer', 'Total', 'Filename']; + + $table = new Table($output); + $table->setHeaderTitle(sprintf('Created %s invoice(s)', count($invoices))); + $table->setHeaders($columns); + + foreach ($invoices as $invoice) { + $file = $this->serviceInvoice->getInvoiceFile($invoice); + if (null === $file) { + $io->warning( + sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename()) + ); + continue; + } + + $table->addRow([ + $invoice->getId(), + $invoice->getCustomer()->getName(), + $invoice->getTotal() . ' ' . $invoice->getCustomer()->getCurrency(), + $file->getRealPath() + ]); + } + + $table->render(); + + return 0; + } + + private function getTemplateForCustomer(InputInterface $input, Customer $customer): ?InvoiceTemplate + { + $template = $input->getOption('template'); + + $meta = $input->getOption('template-meta'); + if (!empty($meta)) { + $metaField = $customer->getMetaField($meta); + if (null !== $metaField && !empty($metaField->getValue())) { + $template = $metaField->getValue(); + } + } + + if (null === $template) { + return null; + } + + return $this->findTemplate($template); + } + + private function findTemplate(string $idOrName): ?InvoiceTemplate + { + $tpl = $this->invoiceTemplateRepository->find($idOrName); + + if (null !== $tpl) { + return $tpl; + } + + return $this->invoiceTemplateRepository->findOneBy(['name' => $idOrName]); + } + + private function getTemplateForProject(InputInterface $input, Project $project): ?InvoiceTemplate + { + $template = $this->getTemplateForCustomer($input, $project->getCustomer()); + + $meta = $input->getOption('template-meta'); + if (!empty($meta)) { + $metaField = $project->getMetaField($meta); + if (null !== $metaField && !empty($metaField->getValue())) { + $template = $metaField->getValue(); + } + } + + if (null === $template) { + return null; + } + + return $this->findTemplate($template); + } + + /** + * @param \DateTime $start + * @param \DateTime $end + * @return Customer[] + */ + private function getActiveCustomers(\DateTime $start, \DateTime $end): array + { + $query = new TimesheetQuery(); + $query->setBegin($start); + $query->setEnd($end); + + $results = $this->timesheetRepository->getTimesheetsForQuery($query); + + $customers = []; + + foreach ($results as $result) { + $customer = $result->getProject()->getCustomer(); + $customers[$customer->getId()] = $customer; + } + + return $customers; + } + + /** + * @param \DateTime $start + * @param \DateTime $end + * @return Project[] + */ + private function getActiveProjects(\DateTime $start, \DateTime $end): array + { + $query = new TimesheetQuery(); + $query->setBegin($start); + $query->setEnd($end); + + $results = $this->timesheetRepository->getTimesheetsForQuery($query); + + $projects = []; + + foreach ($results as $result) { + $project = $result->getProject(); + $projects[$project->getId()] = $project; + } + + return $projects; + } +} diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 0ffe9b6ac..c903b462c 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -12,15 +12,10 @@ 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\InvoiceModel; use App\Invoice\ServiceInvoice; use App\Repository\InvoiceDocumentRepository; use App\Repository\InvoiceRepository; @@ -58,25 +53,20 @@ final class InvoiceController extends AbstractController */ private $dateTimeFactory; /** - * @var InvoiceFormatter + * @var InvoiceRepository */ - private $formatter; + private $invoiceRepository; /** * @var EventDispatcherInterface */ private $dispatcher; - /** - * @var InvoiceRepository - */ - private $invoiceRepository; - public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher) + public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, EventDispatcherInterface $dispatcher) { $this->service = $service; $this->templateRepository = $templateRepository; $this->invoiceRepository = $invoiceRepository; $this->dateTimeFactory = $dateTimeFactory; - $this->formatter = $formatter; $this->dispatcher = $dispatcher; } @@ -94,44 +84,50 @@ final class InvoiceController extends AbstractController } $showPreview = false; - $entries = []; + $model = null; $query = $this->getDefaultQuery(); $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()) { - 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']); + if ($this->isGranted('create_invoice') && $form->isValid()) { + try { + /** @var SubmitButton $createButton */ + $createButton = $form->get('create'); + if ($createButton->isClicked()) { + return $this->renderInvoice($query); } - /** @var SubmitButton $previewButton */ - $previewButton = $form->get('preview'); - if ($previewButton->isClicked()) { - $showPreview = true; - $entries = $this->getPreviewEntries($query); + /** @var SubmitButton $printButton */ + $printButton = $form->get('print'); + if ($printButton->isClicked()) { + return $this->service->renderInvoice($query, $this->dispatcher); } + } catch (\Exception $ex) { + $this->logException($ex); + $this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']); + } + + /** @var SubmitButton $previewButton */ + $previewButton = $form->get('preview'); + if ($previewButton->isClicked()) { + $showPreview = true; } } - $model = $this->prepareModel($query); - if (!empty($entries)) { - $model->addEntries($entries); + try { + $model = $this->service->createModel($query); + if ($showPreview) { + $entries = $this->service->findInvoiceItems($query); + if (!empty($entries)) { + $model->addEntries($entries); + } + } + } catch (\Exception $ex) { + $this->logException($ex); + $this->flashError($ex->getMessage()); + $showPreview = false; } return $this->render('invoice/index.html.twig', [ @@ -164,54 +160,23 @@ final class InvoiceController extends AbstractController return $query; } - protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false) + protected function renderInvoice(InvoiceQuery $query) { - $entries = $this->getEntries($query); - $model = $this->prepareModel($query); - foreach ($entries as $repo => $items) { - $model->addEntries($items); - } + try { + $invoice = $this->service->createInvoice($query, $this->dispatcher); - $document = $this->service->getDocumentByName($model->getTemplate()->getRenderer()); - if (null === $document) { - throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer()); - } + $this->flashSuccess('action.update.success'); - foreach ($this->service->getRenderer() as $renderer) { - if ($renderer->supports($document)) { - $this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer)); - - $response = $renderer->render($document, $model); - - 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; + if ($this->isGranted('history_invoice')) { + return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]); } - } - $this->flashError( - sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName()) - ); + $file = $this->service->getInvoiceFile($invoice); + + return $this->file($file->getRealPath(), $file->getBasename()); + } catch (\Exception $ex) { + $this->flashError($ex->getMessage()); + } return $this->redirectToRoute('invoice'); } @@ -222,26 +187,12 @@ final class InvoiceController extends AbstractController */ 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'); + try { + $this->service->changeInvoiceStatus($invoice, $status); + } catch (\InvalidArgumentException $ex) { + throw $this->createNotFoundException($ex->getMessage()); } - 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'); @@ -290,98 +241,6 @@ final class InvoiceController extends AbstractController ]); } - /** - * @param ExportItemInterface[] $entries - */ - private function markEntriesAsExported(iterable $entries) - { - $repositories = $this->service->getInvoiceItemRepositories(); - - foreach ($entries as $repo => $items) { - foreach ($repositories as $repository) { - if (get_class($repository) === $repo) { - $repository->setExported($items); - } - } - } - } - - /** - * @param InvoiceQuery $query - * @return ExportItemInterface[] - */ - protected function getEntries(InvoiceQuery $query): array - { - // customer needs to be defined, as we need the currency for the invoice - if (!$query->hasCustomers()) { - return []; - } - - if (null === $query->getBegin()) { - $query->setBegin($this->dateTimeFactory->createDateTime('first day of this month')); - } - if (null === $query->getEnd()) { - $query->setEnd($this->dateTimeFactory->createDateTime('last day of this month')); - } - $query->getBegin()->setTime(0, 0, 0); - $query->getEnd()->setTime(23, 59, 59); - - $repositories = $this->service->getInvoiceItemRepositories(); - $items = []; - - foreach ($repositories as $repository) { - $items[get_class($repository)] = $repository->getInvoiceItemsForQuery($query); - } - - return $items; - } - - protected function getPreviewEntries(InvoiceQuery $query): array - { - $entries = []; - $temp = $this->getEntries($query); - - foreach ($temp as $repo => $items) { - $entries = array_merge($entries, $items); - } - - return $entries; - } - - /** - * @param InvoiceQuery $query - * @return InvoiceModel - * @throws \Exception - */ - protected function prepareModel(InvoiceQuery $query): InvoiceModel - { - $model = new InvoiceModel($this->formatter); - $model - ->setInvoiceDate($this->dateTimeFactory->createDateTime()) - ->setQuery($query) - ->setUser($this->getUser()) - ->setCustomer($query->getCustomer()) - ; - - if ($query->getTemplate() !== null) { - $generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator()); - if (null === $generator) { - throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator()); - } - - $calculator = $this->service->getCalculatorByName($query->getTemplate()->getCalculator()); - if (null === $calculator) { - throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator()); - } - - $model->setTemplate($query->getTemplate()); - $model->setCalculator($calculator); - $model->setNumberGenerator($generator); - } - - return $model; - } - /** * @Route(path="/template", name="admin_invoice_template", methods={"GET", "POST"}) * @Security("is_granted('manage_invoice_template')") @@ -478,22 +337,10 @@ final class InvoiceController extends AbstractController } $template = new InvoiceTemplate(); + if (null !== $copyFrom) { - $template - ->setName('Copy of ' . $copyFrom->getName()) - ->setTitle($copyFrom->getTitle()) - ->setDueDays($copyFrom->getDueDays()) - ->setCalculator($copyFrom->getCalculator()) - ->setVat($copyFrom->getVat()) - ->setRenderer($copyFrom->getRenderer()) - ->setCompany($copyFrom->getCompany()) - ->setPaymentTerms($copyFrom->getPaymentTerms()) - ->setAddress($copyFrom->getAddress()) - ->setNumberGenerator($copyFrom->getNumberGenerator()) - ->setContact($copyFrom->getContact()) - ->setPaymentDetails($copyFrom->getPaymentDetails()) - ->setVatId($copyFrom->getVatId()) - ; + $template = clone $copyFrom; + $template->setName('Copy of ' . $copyFrom->getName()); } return $this->renderTemplateForm($template, $request); diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index ae89a8b5d..604b99c60 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -213,7 +213,7 @@ class Invoice $this->user = $model->getUser(); $this->total = $model->getCalculator()->getTotal(); $this->tax = $model->getCalculator()->getTax(); - $this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber(); + $this->invoiceNumber = $model->getInvoiceNumber(); $this->currency = $model->getCurrency(); $createdAt = $model->getInvoiceDate(); diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php index 26df91415..e07e8634f 100644 --- a/src/Entity/InvoiceTemplate.php +++ b/src/Entity/InvoiceTemplate.php @@ -347,4 +347,11 @@ class InvoiceTemplate { return $this->getName(); } + + public function __clone() + { + if ($this->id) { + $this->id = null; + } + } } diff --git a/src/Event/InvoiceCreatedEvent.php b/src/Event/InvoiceCreatedEvent.php new file mode 100644 index 000000000..eedc44041 --- /dev/null +++ b/src/Event/InvoiceCreatedEvent.php @@ -0,0 +1,31 @@ +invoice = $invoice; + } + + public function getInvoice(): Invoice + { + return $this->invoice; + } +} diff --git a/src/Form/Type/InvoiceTemplateType.php b/src/Form/Type/InvoiceTemplateType.php index b14841574..14c242146 100644 --- a/src/Form/Type/InvoiceTemplateType.php +++ b/src/Form/Type/InvoiceTemplateType.php @@ -28,6 +28,7 @@ class InvoiceTemplateType extends AbstractType $resolver->setDefaults([ 'label' => 'label.template', 'class' => InvoiceTemplate::class, + 'choice_label' => 'name', 'query_builder' => function (InvoiceTemplateRepository $repository) { return $repository->getQueryBuilderForFormType(); } diff --git a/src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php b/src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php index c0e970104..cad891dc1 100644 --- a/src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php +++ b/src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php @@ -36,6 +36,14 @@ class InvoiceModelCustomerHydrator implements InvoiceModelHydrator 'customer.country' => $customer->getCountry(), 'customer.homepage' => $customer->getHomepage(), 'customer.comment' => $customer->getComment(), + 'customer.email' => $customer->getEmail(), + 'customer.fax' => $customer->getFax(), + 'customer.phone' => $customer->getPhone(), + 'customer.mobile' => $customer->getMobile(), + // budget + // remaining budget? + // time-budget + // remaining time-budget? ]; foreach ($customer->getVisibleMetaFields() as $metaField) { diff --git a/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php b/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php index ffb44b3e9..f92bc0636 100644 --- a/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php +++ b/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php @@ -25,7 +25,7 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator $values = [ 'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()), 'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()), - 'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(), + 'invoice.number' => $model->getInvoiceNumber(), 'invoice.currency' => $currency, 'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency), 'invoice.vat' => $model->getCalculator()->getVat(), diff --git a/src/Invoice/InvoiceFilename.php b/src/Invoice/InvoiceFilename.php index 6f24e7568..b56126748 100644 --- a/src/Invoice/InvoiceFilename.php +++ b/src/Invoice/InvoiceFilename.php @@ -20,7 +20,7 @@ final class InvoiceFilename public function __construct(InvoiceModel $model) { - $filename = $model->getNumberGenerator()->getInvoiceNumber(); + $filename = $model->getInvoiceNumber(); $filename = str_replace(['/', '\\'], '-', $filename); diff --git a/src/Invoice/InvoiceModel.php b/src/Invoice/InvoiceModel.php index 2672291d8..3599ae184 100644 --- a/src/Invoice/InvoiceModel.php +++ b/src/Invoice/InvoiceModel.php @@ -70,6 +70,10 @@ final class InvoiceModel * @var InvoiceItemHydrator[] */ private $itemHydrator = []; + /** + * @var string + */ + private $invoiceNumber; public function __construct(InvoiceFormatter $formatter) { @@ -103,7 +107,9 @@ final class InvoiceModel } /** - * Do not use this method for rendering the invoice, use InvoiceModel::getCalculator()->getEntries() instead. + * Returns the raw data from the model. + * + * Do not use this method for rendering the invoice, use getItems() instead. * * @return InvoiceItemInterface[] */ @@ -202,6 +208,19 @@ final class InvoiceModel return $this; } + public function getInvoiceNumber(): string + { + if (null === $this->generator) { + throw new \Exception('InvoiceModel::getInvoiceNumber() cannot be called before calling setNumberGenerator()'); + } + + if (null === $this->invoiceNumber) { + $this->invoiceNumber = $this->generator->getInvoiceNumber(); + } + + return $this->invoiceNumber; + } + public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel { $this->generator = $generator; @@ -210,6 +229,9 @@ final class InvoiceModel return $this; } + /** + * @deprecated since 1.9 - will be removed with 2.0 - use getInvoiceNumber() instead + */ public function getNumberGenerator(): ?NumberGeneratorInterface { return $this->generator; diff --git a/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php b/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php index 2a4290828..15eec569a 100644 --- a/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php +++ b/src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php @@ -28,10 +28,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface * @var string */ private $format; - /** - * @var string - */ - private $number; public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration) { @@ -60,10 +56,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface */ public function getInvoiceNumber(): string { - if (null !== $this->number) { - return $this->number; - } - $format = $this->format; $invoiceDate = $this->model->getInvoiceDate(); $timestamp = $invoiceDate->getTimestamp(); @@ -137,6 +129,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface $result = str_replace($part, $partialResult, $result); } - return $this->number = (string) $result; + return (string) $result; } } diff --git a/src/Invoice/ServiceInvoice.php b/src/Invoice/ServiceInvoice.php index d84b9e8c5..5cbd021c2 100644 --- a/src/Invoice/ServiceInvoice.php +++ b/src/Invoice/ServiceInvoice.php @@ -11,10 +11,17 @@ namespace App\Invoice; use App\Entity\Invoice; use App\Entity\InvoiceDocument; +use App\Event\InvoiceCreatedEvent; use App\Event\InvoicePostRenderEvent; +use App\Event\InvoicePreRenderEvent; use App\Repository\InvoiceDocumentRepository; +use App\Repository\InvoiceRepository; +use App\Repository\Query\InvoiceQuery; +use App\Timesheet\UserDateTimeFactory; use App\Utils\FileHelper; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Service to manage invoice dependencies. @@ -45,11 +52,26 @@ final class ServiceInvoice * @var FileHelper */ private $fileHelper; + /** + * @var UserDateTimeFactory + */ + private $dateTimeFactory; + /** + * @var InvoiceFormatter + */ + private $formatter; + /** + * @var InvoiceRepository + */ + private $invoiceRepository; - public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper) + public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter) { $this->documents = $repository; $this->fileHelper = $fileHelper; + $this->invoiceRepository = $invoiceRepository; + $this->dateTimeFactory = $dateTimeFactory; + $this->formatter = $formatter; } public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice @@ -153,7 +175,7 @@ final class ServiceInvoice private function getInvoicesDirectory(): string { - return $this->fileHelper->getDataSubdirectory('invoices'); + return $this->fileHelper->getDataDirectory('invoices'); } public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo @@ -208,4 +230,209 @@ final class ServiceInvoice return $filename; } + + public function changeInvoiceStatus(Invoice $invoice, string $status) + { + if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) { + throw new \InvalidArgumentException('Unknown 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); + } + + /** + * @param InvoiceQuery $query + * @return InvoiceItemInterface[] + */ + private function findInvoiceItemsWithRepository(InvoiceQuery $query): array + { + // customer needs to be defined, as we need the currency for the invoice + if (!$query->hasCustomers()) { + return []; + } + + if (null === $query->getBegin()) { + $query->setBegin($this->dateTimeFactory->createDateTime('first day of this month')); + } + if (null === $query->getEnd()) { + $query->setEnd($this->dateTimeFactory->createDateTime('last day of this month')); + } + $query->getBegin()->setTime(0, 0, 0); + $query->getEnd()->setTime(23, 59, 59); + + $repositories = $this->getInvoiceItemRepositories(); + $items = []; + + foreach ($repositories as $repository) { + $items[get_class($repository)] = $repository->getInvoiceItemsForQuery($query); + } + + return $items; + } + + /** + * @param InvoiceQuery $query + * @return InvoiceItemInterface[] + */ + public function findInvoiceItems(InvoiceQuery $query): array + { + $entries = []; + $temp = $this->findInvoiceItemsWithRepository($query); + + foreach ($temp as $repo => $items) { + $entries = array_merge($entries, $items); + } + + return $entries; + } + + /** + * @param InvoiceItemInterface[] $entries + */ + private function markEntriesAsExported(iterable $entries) + { + $repositories = $this->getInvoiceItemRepositories(); + + foreach ($entries as $repo => $items) { + foreach ($repositories as $repository) { + if (get_class($repository) === $repo) { + $repository->setExported($items); + } + } + } + } + + public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response + { + $entries = $this->findInvoiceItemsWithRepository($query); + $model = $this->createModel($query); + foreach ($entries as $repo => $items) { + $model->addEntries($items); + } + + $document = $this->getDocumentByName($model->getTemplate()->getRenderer()); + if (null === $document) { + throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer()); + } + + foreach ($this->getRenderer() as $renderer) { + if ($renderer->supports($document)) { + $dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer)); + + $response = $renderer->render($document, $model); + + $dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response)); + + return $response; + } + } + + throw new \Exception( + sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName()) + ); + } + + /** + * @param InvoiceQuery $query + * @param EventDispatcherInterface $dispatcher + * @return Invoice + * @throws \Exception + */ + public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice + { + $entries = $this->findInvoiceItemsWithRepository($query); + $model = $this->createModel($query); + foreach ($entries as $repo => $items) { + $model->addEntries($items); + } + + $document = $this->getDocumentByName($model->getTemplate()->getRenderer()); + if (null === $document) { + throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer()); + } + + foreach ($this->getRenderer() as $renderer) { + if ($renderer->supports($document)) { + $dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer)); + + $response = $renderer->render($document, $model); + + if ($query->isMarkAsExported()) { + $this->markEntriesAsExported($entries); + } + + $event = new InvoicePostRenderEvent($model, $document, $renderer, $response); + $dispatcher->dispatch($event); + + $invoiceFilename = $this->saveGeneratedInvoice($event); + + $invoice = new Invoice(); + $invoice->setModel($model); + $invoice->setFilename($invoiceFilename); + $this->invoiceRepository->saveInvoice($invoice); + + $dispatcher->dispatch(new InvoiceCreatedEvent($invoice)); + + return $invoice; + } + } + + throw new \Exception( + sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName()) + ); + } + + /** + * @param InvoiceQuery $query + * @return InvoiceModel + * @throws \Exception + */ + public function createModel(InvoiceQuery $query): InvoiceModel + { + $model = new InvoiceModel($this->formatter); + $model + ->setInvoiceDate($this->dateTimeFactory->createDateTime()) + ->setQuery($query) + ; + + if (null !== $query->getCurrentUser()) { + $model->setUser($query->getCurrentUser()); + } + + if ($query->hasCustomers()) { + $model->setCustomer($query->getCustomers()[0]); + } + + if ($query->getTemplate() !== null) { + $generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator()); + if (null === $generator) { + throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator()); + } + + $calculator = $this->getCalculatorByName($query->getTemplate()->getCalculator()); + if (null === $calculator) { + throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator()); + } + + $model->setTemplate($query->getTemplate()); + $model->setCalculator($calculator); + $model->setNumberGenerator($generator); + } + + return $model; + } } diff --git a/src/Utils/FileHelper.php b/src/Utils/FileHelper.php index 447091ab1..8a803d0e3 100644 --- a/src/Utils/FileHelper.php +++ b/src/Utils/FileHelper.php @@ -9,51 +9,53 @@ namespace App\Utils; +use Symfony\Component\Filesystem\Filesystem; + final class FileHelper { /** * @var string */ private $dataDir; + /** + * @var Filesystem + */ + private $filesystem; public function __construct(string $dataDir) { $this->dataDir = $dataDir; + $this->filesystem = new Filesystem(); } - public function getDataSubdirectory(string $directory): string + public function getDataDirectory(string $subDirectory = null): string { - $subDirectory = $this->dataDir . '/' . rtrim(ltrim($directory, '/'), '/') . '/'; + $directory = $this->dataDir . '/'; - $this->makeDir($subDirectory); - - if (!is_dir($subDirectory)) { - throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory)); + if (!empty($subDirectory)) { + $directory .= rtrim(ltrim($subDirectory, '/'), '/') . '/'; } - if (!is_writable($subDirectory)) { - throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory)); + $this->makeDir($directory); + + if (!is_dir($directory)) { + throw new \Exception(sprintf('Directory "%s" does not exist', $directory)); } - return $subDirectory; + if (!is_writable($directory)) { + throw new \Exception(sprintf('Directory "%s" is not writable', $directory)); + } + + return $directory; } 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)); - } + $this->filesystem->mkdir($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'); - } + $this->filesystem->dumpFile($filename, $data); } } diff --git a/templates/invoice/layout.html.twig b/templates/invoice/layout.html.twig index e991d7c69..37f088fb0 100644 --- a/templates/invoice/layout.html.twig +++ b/templates/invoice/layout.html.twig @@ -1,10 +1,12 @@ - +{% set fallback = app.request is not null ? app.request.locale : 'en' %} +{% set language = model.template.language|default(fallback) %} +
-
{{ 'invoice.number'|trans({}, 'messages', language) }}:
- {{ model.numberGenerator.invoiceNumber }}
+ {{ model.invoiceNumber }}
{{ 'invoice.due_days'|trans({}, 'messages', language) }}:
diff --git a/templates/invoice/renderer/freelancer.html.twig b/templates/invoice/renderer/freelancer.html.twig
index 2f7a0ba17..0d3e1b70b 100644
--- a/templates/invoice/renderer/freelancer.html.twig
+++ b/templates/invoice/renderer/freelancer.html.twig
@@ -1,6 +1,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% extends 'invoice/layout.html.twig' %}
-{% set language = model.template.language|default(app.request.locale) %}
+{% set fallback = app.request is not null ? app.request.locale : 'en' %}
+{% set language = model.template.language|default(fallback) %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% block invoice %}
@@ -30,11 +31,11 @@
{{ 'invoice.number'|trans({}, 'messages', language) }}:
- {{ model.numberGenerator.invoiceNumber }}
+ {{ model.invoiceNumber }}
{{ 'invoice.due_days'|trans({}, 'messages', language) }}:
diff --git a/tests/KernelTestTrait.php b/tests/KernelTestTrait.php
index 24e47835a..1e252c9cb 100644
--- a/tests/KernelTestTrait.php
+++ b/tests/KernelTestTrait.php
@@ -15,6 +15,7 @@ use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\Executor\ORMExecutor;
use Doctrine\Common\DataFixtures\Loader;
use Doctrine\ORM\EntityManager;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\HttpKernelBrowser;
/**
@@ -22,12 +23,18 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
*/
trait KernelTestTrait
{
+ /**
+ * @param $client HttpKernelBrowser|EntityManager|KernelTestCase
+ * @param Fixture $fixture
+ */
protected function importFixture($client, Fixture $fixture)
{
if ($client instanceof HttpKernelBrowser) {
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
} elseif ($client instanceof EntityManager) {
$em = $client;
+ } elseif ($client instanceof KernelTestCase) {
+ $em = $client::$container->get('doctrine.orm.entity_manager');
} else {
throw new \InvalidArgumentException('Fixtures need an EntityManager to be imported');
}