From b8c5323ecea94f5240ede3ee77e487eaeeb0b0d1 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Fri, 20 Mar 2020 13:14:05 +0100 Subject: [PATCH] added command to create invoices via bash (#1574) --- UPGRADING.md | 4 +- config/packages/test/security.yaml | 2 - src/Command/InvoiceCreateCommand.php | 468 ++++++++++++++++++ src/Controller/InvoiceController.php | 261 ++-------- src/Entity/Invoice.php | 2 +- src/Entity/InvoiceTemplate.php | 7 + src/Event/InvoiceCreatedEvent.php | 31 ++ src/Form/Type/InvoiceTemplateType.php | 1 + .../Hydrator/InvoiceModelCustomerHydrator.php | 8 + .../Hydrator/InvoiceModelDefaultHydrator.php | 2 +- src/Invoice/InvoiceFilename.php | 2 +- src/Invoice/InvoiceModel.php | 24 +- .../ConfigurableNumberGenerator.php | 10 +- src/Invoice/ServiceInvoice.php | 231 ++++++++- src/Utils/FileHelper.php | 42 +- templates/invoice/layout.html.twig | 6 +- templates/invoice/renderer/default.html.twig | 5 +- .../invoice/renderer/freelancer.html.twig | 7 +- .../invoice/renderer/timesheet.html.twig | 3 +- tests/Command/InvoiceCreateCommandTest.php | 248 ++++++++++ tests/Controller/InvoiceControllerTest.php | 72 ++- tests/Entity/InvoiceTemplateTest.php | 2 + tests/Event/InvoiceCreatedEventTest.php | 29 ++ .../InvoiceModelCustomerHydratorTest.php | 4 + tests/Invoice/InvoiceModelTest.php | 19 +- .../IncrementingNumberGenerator.php | 46 ++ tests/Invoice/Renderer/CsvRendererTest.php | 2 +- tests/Invoice/Renderer/DebugRendererTest.php | 4 + tests/Invoice/Renderer/DocxRendererTest.php | 2 +- tests/Invoice/Renderer/OdsRendererTest.php | 2 +- tests/Invoice/Renderer/TwigRendererTest.php | 2 +- tests/Invoice/Renderer/XlsxRendererTest.php | 2 +- tests/Invoice/ServiceInvoiceTest.php | 15 +- tests/Invoice/templates/default.pdf.twig | 2 +- tests/KernelTestTrait.php | 7 + 35 files changed, 1301 insertions(+), 273 deletions(-) create mode 100644 src/Command/InvoiceCreateCommand.php create mode 100644 src/Event/InvoiceCreatedEvent.php create mode 100644 tests/Command/InvoiceCreateCommandTest.php create mode 100644 tests/Event/InvoiceCreatedEventTest.php create mode 100644 tests/Invoice/NumberGenerator/IncrementingNumberGenerator.php diff --git a/UPGRADING.md b/UPGRADING.md index 506011ac..7e63c495 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 162e4fa1..873f9e4c 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 00000000..ba1c29be --- /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 0ffe9b6a..c903b462 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 ae89a8b5..604b99c6 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 26df9141..e07e8634 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 00000000..eedc4404 --- /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 b1484157..14c24214 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 c0e97010..cad891dc 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 ffb44b3e..f92bc063 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 6f24e756..b5612674 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 2672291d..3599ae18 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 2a429082..15eec569 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 d84b9e8c..5cbd021c 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 447091ab..8a803d0e 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 e991d7c6..37f088fb 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) %} + - {% block title %}{{ model.numberGenerator.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %} + {% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %} diff --git a/templates/invoice/renderer/default.html.twig b/templates/invoice/renderer/default.html.twig index 5bf82acd..62491f6e 100644 --- a/templates/invoice/renderer/default.html.twig +++ b/templates/invoice/renderer/default.html.twig @@ -1,5 +1,6 @@ {% 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 %} @@ -51,7 +52,7 @@

{{ '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 2f7a0ba1..0d3e1b70 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.service_date'|trans({}, 'messages', language) }}: - {{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }} + {{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }} {{ 'invoice.number'|trans({}, 'messages', language) }}: - {{ model.numberGenerator.invoiceNumber }} + {{ model.invoiceNumber }} {% if model.query.project is not empty and model.query.project.orderNumber is not empty %} diff --git a/templates/invoice/renderer/timesheet.html.twig b/templates/invoice/renderer/timesheet.html.twig index 8ef065cf..c1a3b3c5 100644 --- a/templates/invoice/renderer/timesheet.html.twig +++ b/templates/invoice/renderer/timesheet.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 %} diff --git a/tests/Command/InvoiceCreateCommandTest.php b/tests/Command/InvoiceCreateCommandTest.php new file mode 100644 index 00000000..9ad0da08 --- /dev/null +++ b/tests/Command/InvoiceCreateCommandTest.php @@ -0,0 +1,248 @@ +clearInvoiceFiles(); + } + + protected function setUp(): void + { + $this->clearInvoiceFiles(); + $kernel = self::bootKernel(); + $this->application = new Application($kernel); + $container = self::$container; + + $this->application->add(new InvoiceCreateCommand( + $container->get(ServiceInvoice::class), + $container->get(TimesheetRepository::class), + $container->get(CustomerRepository::class), + $container->get(InvoiceTemplateRepository::class), + $container->get(UserRepository::class), + $container->get('event_dispatcher') + )); + } + + /** + 'user' + 'start' + 'end' + 'timezone' + 'customer' + 'template' + 'search' + 'exported' + 'by-customer' + 'by-project' + 'set-exported' + 'template-meta' + * @param $user + * @param array $params + * @return CommandTester + */ + protected function createInvoice(array $options = []) + { + $command = $this->application->find('kimai:invoice:create'); + $commandTester = new CommandTester($command); + $commandTester->execute(array_merge($options, [ + 'command' => $command->getName(), + ])); + + return $commandTester; + } + + protected function assertCommandErrors(array $options = [], string $errorMessage = '') + { + $commandTester = $this->createInvoice($options); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[ERROR] ' . $errorMessage, $output); + } + + public function testCreateWithUnknownExportFilter() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--exported' => 'foo'], 'Unknown "exported" filter given'); + } + + public function testCreateWithMissingUser() + { + $this->assertCommandErrors([], 'You must set a "user" to create invoices'); + } + + public function testCreateWithInvalidUser() + { + $this->assertCommandErrors(['--user' => 'assdfd'], 'The given username "assdfd" could not be resolved'); + } + + public function testCreateWithMissingEnd() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--start' => '2020-01-01'], 'You need to supply a end date if a start date was given'); + } + + public function testCreateByCustomerAndByProject() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--by-customer' => null, '--by-project' => null], 'You cannot mix "by-customer" and "by-project"'); + } + + public function testCreateWithMissingGenerationMode() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN], 'Could not determine generation mode'); + } + + public function testCreateWithMissingTemplate() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1], 'You must either pass the "template" or "template-meta" option'); + } + + public function testCreateWithInvalidStart() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--exported' => 'exported', '--template' => 'x', '--start' => 'öäüß', '--end' => '2020-01-01'], 'Invalid start date given'); + } + + public function testCreateWithInvalidEnd() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--template' => 'x', '--start' => '2020-01-01', '--end' => 'öäüß'], 'Invalid end date given'); + } + + public function testCreateWithInvalidCustomer() + { + $this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 3, '--template' => 'x'], 'Unknown customer ID: 3'); + } + + public function testCreateInvoice() + { + $fixture = new InvoiceFixtures(); + $this->importFixture($this, $fixture); + + $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--set-exported' => null, '--customer' => 1, '--template' => 'Invoice', '--start' => '2020-01-01', '--end' => '2020-03-01']); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('+----+----------+-------+------------- Created 1 invoice(s) --------------------------------------+', $output); + $this->assertStringContainsString('| ID | Customer | Total | Filename |', $output); + $this->assertStringContainsString('+----+----------+-------+-------------------------------------------------------------------------+', $output); + $this->assertStringContainsString('| 1 | Test | 0 EUR | /', $output); + $this->assertStringContainsString('/tests/_data/invoices/2020-001-test.html |', $output); + } + + protected function prepareFixtures(\DateTime $start) + { + $em = self::$container->get('doctrine.orm.entity_manager'); + + $fixture = new CustomerFixtures(); + $fixture->setAmount(1); + $fixture->setCallback(function (Customer $customer) { + $meta = new CustomerMeta(); + $meta->setName('template'); + $meta->setValue('Invoice'); + $customer->setMetaField($meta); + }); + $this->importFixture($em, $fixture); + + $fixture = new ProjectFixtures(); + $fixture->setCustomers([$em->getRepository(Customer::class)->find(2)]); + $fixture->setAmount(1); + $this->importFixture($em, $fixture); + + $fixture = new TimesheetFixtures(); + $fixture->setUser($this->getUserByName($em, UserFixtures::USERNAME_SUPER_ADMIN)); + $fixture->setAmount(20); + $fixture->setStartDate($start); + $fixture->setProjects([$em->getRepository(Project::class)->find(2)]); + $this->importFixture($em, $fixture); + + $fixture = new InvoiceFixtures(); + $this->importFixture($em, $fixture); + } + + public function testCreateInvoiceByCustomer() + { + $start = new \DateTime('-2 months'); + $end = new \DateTime(); + + $this->prepareFixtures($start); + + $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--by-customer' => null, '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Created 1 invoice(s) ', $output); + } + + public function testCreateInvoiceByCustomerId() + { + $start = new \DateTime('-2 months'); + $end = new \DateTime(); + + $this->prepareFixtures($start); + + $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => '2,1', '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Created 1 invoice(s) ', $output); + } + + public function testCreateInvoiceByProject() + { + $start = new \DateTime('-2 months'); + $end = new \DateTime(); + + $this->prepareFixtures($start); + + $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--exported' => 'all', '--by-project' => null, '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Created 1 invoice(s) ', $output); + } +} diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php index 9c65d6a5..4f706ea3 100644 --- a/tests/Controller/InvoiceControllerTest.php +++ b/tests/Controller/InvoiceControllerTest.php @@ -26,14 +26,7 @@ class InvoiceControllerTest extends ControllerBaseTest protected function setUp(): void { parent::setUp(); - $path = __DIR__ . '/../_data/invoices/'; - - if (is_dir($path)) { - $files = glob($path . '*'); - foreach ($files as $file) { - unlink($file); - } - } + $this->clearInvoiceFiles(); } protected function tearDown(): void @@ -144,7 +137,7 @@ class InvoiceControllerTest extends ControllerBaseTest $this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']); } - public function testPrintAction() + public function testCreateAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); /** @var EntityManager $em */ @@ -210,7 +203,66 @@ class InvoiceControllerTest extends ControllerBaseTest } } - public function testPrintActionAsAdminWithDownloadAndStatusChange() + public function testPrintAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + /** @var EntityManager $em */ + $em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager'); + + $fixture = new InvoiceFixtures(); + $this->importFixture($client, $fixture); + + $begin = new \DateTime('first day of this month'); + $end = new \DateTime('last day of this month'); + $fixture = new TimesheetFixtures(); + $fixture + ->setUser($this->getUserByRole($em, User::ROLE_TEAMLEAD)) + ->setAmount(20) + ->setStartDate($begin) + ; + $this->importFixture($client, $fixture); + + $this->request($client, '/invoice/'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d'); + + $form = $client->getCrawler()->filter('#invoice-print-form')->form(); + $node = $form->getFormNode(); + $node->setAttribute('action', $this->createUrl('/invoice/?preview=')); + $node->setAttribute('method', 'GET'); + $client->submit($form, [ + 'template' => 1, + 'daterange' => $dateRange, + 'customer' => 1, + ]); + + $this->assertTrue($client->getResponse()->isSuccessful()); + + // no warning should be displayed + $node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); + $this->assertEquals(0, $node->count()); + // but the datatable with all timesheets + 1 row for the total + $this->assertDataTableRowCount($client, 'datatable_invoice', 21); + + $form = $client->getCrawler()->filter('#invoice-print-form')->form(); + $node = $form->getFormNode(); + $node->setAttribute('action', $this->createUrl('/invoice/?print=')); + $node->setAttribute('method', 'GET'); + $client->submit($form, [ + 'template' => 1, + 'daterange' => $dateRange, + 'customer' => 1, + 'projects' => [1], + ]); + + $this->assertTrue($client->getResponse()->isSuccessful()); + $node = $client->getCrawler()->filter('body'); + $this->assertEquals(1, $node->count()); + $this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class')); + } + + public function testCreateActionAsAdminWithDownloadAndStatusChange() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); /** @var EntityManager $em */ diff --git a/tests/Entity/InvoiceTemplateTest.php b/tests/Entity/InvoiceTemplateTest.php index 8c9017c8..97532ed1 100644 --- a/tests/Entity/InvoiceTemplateTest.php +++ b/tests/Entity/InvoiceTemplateTest.php @@ -75,6 +75,8 @@ class InvoiceTemplateTest extends TestCase self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de')); self::assertEquals('de', $sut->getLanguage()); + + self::assertEquals($sut, clone $sut); } public function testToString() diff --git a/tests/Event/InvoiceCreatedEventTest.php b/tests/Event/InvoiceCreatedEventTest.php new file mode 100644 index 00000000..96548a66 --- /dev/null +++ b/tests/Event/InvoiceCreatedEventTest.php @@ -0,0 +1,29 @@ +getInvoice()); + } +} diff --git a/tests/Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php b/tests/Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php index 508c8a8d..241e902a 100644 --- a/tests/Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php +++ b/tests/Invoice/Hydrator/InvoiceModelCustomerHydratorTest.php @@ -47,6 +47,10 @@ class InvoiceModelCustomerHydratorTest extends TestCase 'customer.number', 'customer.homepage', 'customer.comment', + 'customer.email', + 'customer.fax', + 'customer.phone', + 'customer.mobile', 'customer.meta.foo-customer', ]; diff --git a/tests/Invoice/InvoiceModelTest.php b/tests/Invoice/InvoiceModelTest.php index 457d9e09..922d7ac0 100644 --- a/tests/Invoice/InvoiceModelTest.php +++ b/tests/Invoice/InvoiceModelTest.php @@ -14,8 +14,8 @@ use App\Entity\InvoiceTemplate; use App\Entity\Timesheet; use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\InvoiceModel; -use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Repository\Query\InvoiceQuery; +use App\Tests\Invoice\NumberGenerator\IncrementingNumberGenerator; use PHPUnit\Framework\TestCase; /** @@ -43,6 +43,16 @@ class InvoiceModelTest extends TestCase self::assertSame($formatter, $sut->getFormatter()); } + public function testEmptyObjectThrowsExceptionOnNumberGenerator() + { + $formatter = new DebugFormatter(); + $sut = new InvoiceModel($formatter); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('InvoiceModel::getInvoiceNumber() cannot be called before calling setNumberGenerator()'); + $sut->getInvoiceNumber(); + } + public function testSetter() { $sut = new InvoiceModel(new DebugFormatter()); @@ -59,9 +69,14 @@ class InvoiceModelTest extends TestCase self::assertInstanceOf(InvoiceModel::class, $sut->setCalculator($calculator)); self::assertSame($calculator, $sut->getCalculator()); - $generator = new DateNumberGenerator(); + $generator = new IncrementingNumberGenerator(); self::assertInstanceOf(InvoiceModel::class, $sut->setNumberGenerator($generator)); self::assertSame($generator, $sut->getNumberGenerator()); + $number = $sut->getInvoiceNumber(); + $first = $sut->getNumberGenerator()->getInvoiceNumber(); + $second = $sut->getNumberGenerator()->getInvoiceNumber(); + self::assertEquals(((int) $first + 1), $second); + self::assertEquals($number, $sut->getInvoiceNumber()); $template = new InvoiceTemplate(); self::assertNull($sut->getDueDate()); diff --git a/tests/Invoice/NumberGenerator/IncrementingNumberGenerator.php b/tests/Invoice/NumberGenerator/IncrementingNumberGenerator.php new file mode 100644 index 00000000..0c338dfa --- /dev/null +++ b/tests/Invoice/NumberGenerator/IncrementingNumberGenerator.php @@ -0,0 +1,46 @@ +counter++; + } + + /** + * Returns the unique ID of this number generator. + * + * Prefix it with your company name followed by a hyphen (e.g. "acme-"), + * if this is a third-party generator. + * + * @return string + */ + public function getId(): string + { + return 'testing'; + } +} diff --git a/tests/Invoice/Renderer/CsvRendererTest.php b/tests/Invoice/Renderer/CsvRendererTest.php index 7318c2b9..a53b38c8 100644 --- a/tests/Invoice/Renderer/CsvRendererTest.php +++ b/tests/Invoice/Renderer/CsvRendererTest.php @@ -58,7 +58,7 @@ class CsvRendererTest extends TestCase $file = $response->getFile(); $this->assertEquals('text/csv', $response->headers->get('Content-Type')); - $filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.csv'; + $filename = $model->getInvoiceNumber() . '-customer_with_special_name.csv'; $this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition')); $this->assertTrue(file_exists($file->getRealPath())); diff --git a/tests/Invoice/Renderer/DebugRendererTest.php b/tests/Invoice/Renderer/DebugRendererTest.php index baf291e1..18a44b75 100644 --- a/tests/Invoice/Renderer/DebugRendererTest.php +++ b/tests/Invoice/Renderer/DebugRendererTest.php @@ -119,6 +119,10 @@ class DebugRendererTest extends TestCase 'customer.number', 'customer.homepage', 'customer.comment', + 'customer.email', + 'customer.fax', + 'customer.phone', + 'customer.mobile', 'customer.meta.foo-customer', 'activity.id', 'activity.name', diff --git a/tests/Invoice/Renderer/DocxRendererTest.php b/tests/Invoice/Renderer/DocxRendererTest.php index 7914fe35..ef73181f 100644 --- a/tests/Invoice/Renderer/DocxRendererTest.php +++ b/tests/Invoice/Renderer/DocxRendererTest.php @@ -45,7 +45,7 @@ class DocxRendererTest extends TestCase /** @var BinaryFileResponse $response */ $response = $sut->render($document, $model); - $filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.docx'; + $filename = $model->getInvoiceNumber() . '-customer_with_special_name.docx'; $file = $response->getFile(); $this->assertEquals('application/vnd.openxmlformats-officedocument.wordprocessingml.document', $response->headers->get('Content-Type')); $this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition')); diff --git a/tests/Invoice/Renderer/OdsRendererTest.php b/tests/Invoice/Renderer/OdsRendererTest.php index bdc25b49..df090ace 100644 --- a/tests/Invoice/Renderer/OdsRendererTest.php +++ b/tests/Invoice/Renderer/OdsRendererTest.php @@ -56,7 +56,7 @@ class OdsRendererTest extends TestCase /** @var BinaryFileResponse $response */ $response = $sut->render($document, $model); - $filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.ods'; + $filename = $model->getInvoiceNumber() . '-customer_with_special_name.ods'; $file = $response->getFile(); $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type')); $this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition')); diff --git a/tests/Invoice/Renderer/TwigRendererTest.php b/tests/Invoice/Renderer/TwigRendererTest.php index 657fe5c6..317382e3 100644 --- a/tests/Invoice/Renderer/TwigRendererTest.php +++ b/tests/Invoice/Renderer/TwigRendererTest.php @@ -62,7 +62,7 @@ class TwigRendererTest extends KernelTestCase $content = $response->getContent(); - $filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name'; + $filename = $model->getInvoiceNumber() . '-customer_with_special_name'; $this->assertStringContainsString('' . $filename . '', $content); $this->assertStringContainsString('