added command to create invoices via bash (#1574)

This commit is contained in:
Kevin Papst
2020-03-20 13:14:05 +01:00
committed by GitHub
parent 7b13ea860a
commit b8c5323ece
35 changed files with 1301 additions and 273 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1,468 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Utils\SearchTerm;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class InvoiceCreateCommand extends Command
{
/**
* @var ServiceInvoice
*/
private $serviceInvoice;
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
/**
* @var CustomerRepository
*/
private $customerRepository;
/**
* @var InvoiceTemplateRepository
*/
private $invoiceTemplateRepository;
/**
* @var UserRepository
*/
private $userRepository;
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
public function __construct(
ServiceInvoice $serviceInvoice,
TimesheetRepository $timesheetRepository,
CustomerRepository $customerRepository,
InvoiceTemplateRepository $invoiceTemplateRepository,
UserRepository $userRepository,
EventDispatcherInterface $eventDispatcher
) {
$this->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;
}
}

View File

@@ -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,26 +84,25 @@ 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()) {
if ($this->isGranted('create_invoice') && $form->isValid()) {
try {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query, true);
return $this->renderInvoice($query);
}
/** @var SubmitButton $printButton */
$printButton = $form->get('print');
if ($printButton->isClicked()) {
return $this->renderInvoice($query, false);
return $this->service->renderInvoice($query, $this->dispatcher);
}
} catch (\Exception $ex) {
$this->logException($ex);
@@ -124,15 +113,22 @@ final class InvoiceController extends AbstractController
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$entries = $this->getPreviewEntries($query);
}
}
}
$model = $this->prepareModel($query);
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', [
'query' => $query,
@@ -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);
}
$document = $this->service->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
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);
try {
$invoice = $this->service->createInvoice($query, $this->dispatcher);
$this->flashSuccess('action.update.success');
if ($this->isGranted('history_invoice')) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
}
}
return $response;
}
}
$file = $this->service->getInvoiceFile($invoice);
$this->flashError(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
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);

View File

@@ -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();

View File

@@ -347,4 +347,11 @@ class InvoiceTemplate
{
return $this->getName();
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
final class InvoiceCreatedEvent extends Event
{
/**
* @var Invoice
*/
private $invoice;
public function __construct(Invoice $invoice)
{
$this->invoice = $invoice;
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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(),

View File

@@ -20,7 +20,7 @@ final class InvoiceFilename
public function __construct(InvoiceModel $model)
{
$filename = $model->getNumberGenerator()->getInvoiceNumber();
$filename = $model->getInvoiceNumber();
$filename = str_replace(['/', '\\'], '-', $filename);

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -1,10 +1,12 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
{% set language = model.template.language|default(fallback) %}
<html lang="{{ language }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<title>{% block title %}{{ model.numberGenerator.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
<style type="text/css">
{{ encore_entry_css_source('invoice')|raw }}
</style>

View File

@@ -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 @@
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
{{ model.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>

View File

@@ -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 @@
</tr>
<tr>
<th>{{ 'invoice.service_date'|trans({}, 'messages', language) }}:</th>
<td><span contenteditable="true">{{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }}</td>
<td><span contenteditable="true">{{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }}</span></td>
</tr>
<tr>
<th>{{ 'invoice.number'|trans({}, 'messages', language) }}:</th>
<td><span contenteditable="true">{{ model.numberGenerator.invoiceNumber }}</td>
<td>{{ model.invoiceNumber }}</td>
</tr>
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<tr>

View File

@@ -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 %}

View File

@@ -0,0 +1,248 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Command;
use App\Command\InvoiceCreateCommand;
use App\DataFixtures\UserFixtures;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Project;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\KernelTestTrait;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @covers \App\Command\InvoiceCreateCommand
* @group integration
*/
class InvoiceCreateCommandTest extends KernelTestCase
{
use KernelTestTrait;
/**
* @var Application
*/
protected $application;
private function clearInvoiceFiles()
{
$path = __DIR__ . '/../_data/invoices/';
if (is_dir($path)) {
$files = glob($path . '*');
foreach ($files as $file) {
unlink($file);
}
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->clearInvoiceFiles();
}
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);
}
}

View File

@@ -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 */

View File

@@ -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()

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Invoice;
use App\Event\InvoiceCreatedEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\InvoiceCreatedEvent
*/
class InvoiceCreatedEventTest extends TestCase
{
public function testDefaultValues()
{
$invoice = new Invoice();
$sut = new InvoiceCreatedEvent($invoice);
self::assertSame($invoice, $sut->getInvoice());
}
}

View File

@@ -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',
];

View File

@@ -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());

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\NumberGenerator;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGeneratorInterface;
class IncrementingNumberGenerator implements NumberGeneratorInterface
{
private $counter = 0;
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model)
{
}
/**
* @return string
*/
public function getInvoiceNumber(): string
{
return $this->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';
}
}

View File

@@ -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()));

View File

@@ -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',

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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('<title>' . $filename . '</title>', $content);
$this->assertStringContainsString('<h2 class="page-header">
<span contenteditable="true">a very *long* test invoice / template title with [special] character</span>

View File

@@ -57,7 +57,7 @@ class XlsxRendererTest extends TestCase
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);
$filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.xlsx';
$filename = $model->getInvoiceNumber() . '-customer_with_special_name.xlsx';
$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'));

View File

@@ -9,12 +9,15 @@
namespace App\Tests\Invoice;
use App\Entity\Invoice;
use App\Entity\InvoiceDocument;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Utils\FileHelper;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
@@ -27,8 +30,18 @@ class ServiceInvoiceTest extends TestCase
private function getSut(array $paths): ServiceInvoice
{
$repo = new InvoiceDocumentRepository($paths);
$invoiceRepo = $this->createMock(InvoiceRepository::class);
$userDateTime = (new UserDateTimeFactoryFactory($this))->create();
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')));
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')), $invoiceRepo, $userDateTime, new DebugFormatter());
}
public function testInvalidExceptionOnChangeState()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown invoice status');
$sut = $this->getSut([]);
$sut->changeInvoiceStatus(new Invoice(), 'foo');
}
public function testEmptyObject()

View File

@@ -51,7 +51,7 @@
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
{{ model.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>

View File

@@ -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');
}