added invoice archive & configurable invoice numbers (#1541)

This commit is contained in:
Kevin Papst
2020-03-14 01:16:58 +01:00
committed by GitHub
parent dd64eb98f9
commit e6e6a1eeea
106 changed files with 2587 additions and 339 deletions

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.4']
php: ['7.3']
name: Coverage - PHP ${{ matrix.php }}
steps:

View File

@@ -8,9 +8,27 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [1.9](https://github.com/kevinpapst/kimai2/releases/tag/1.9)
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
- The directory `var/data/invoices/` will be used to store archived invoice files (check file permissions)
- The default invoice number format changed, if you want back the old one, use `{date}` as format - see [invoice documentation](https://www.kimai.org/documentation/invoices.html)
- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including
assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs.
Permission changes:
- `history_invoice` - NEW: grants all features of the new invoice archive (by default for all admins)
### Developer
- BC break: `InvoiceItemInterface` has new methods `getType()` and `getCategory()`
## [1.8](https://github.com/kevinpapst/kimai2/releases/tag/1.8)
- New PHP requirement: `ext-xsl` should be pre-installed in most environments when `ext-xml` is loaded
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
- New PHP requirement: `ext-xsl` - which should be pre-installed in most environments when `ext-xml` is loaded
- New mailer library: check if emails are still working (eg. by using the "password forgotten" function) or if you need to adjust your configuration, [see docs at symfony.com](https://symfony.com/doc/current/components/mailer.html#transport)
- Support for line breaks in multiline invoice fields for spreadsheets (check your invoice templates after the update)

View File

@@ -165,7 +165,9 @@
"vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=4"
],
"kimai:codestyle": "vendor/bin/php-cs-fixer fix --dry-run --verbose --show-progress=none",
"kimai:codestyle-fix": "vendor/bin/php-cs-fixer fix"
"codestyle": "@kimai:codestyle",
"kimai:codestyle-fix": "vendor/bin/php-cs-fixer fix",
"codestyle-fix": "@kimai:codestyle-fix"
},
"conflict": {
"symfony/symfony": "*"

View File

@@ -96,7 +96,7 @@ kimai:
CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','comments_create_team_customer','details_team_customer']
CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','comments_create_teamlead_customer','details_teamlead_customer']
INVOICE: ['view_invoice','create_invoice']
INVOICE_TEMPLATE: ['manage_invoice_template']
INVOICE_ADMIN: ['manage_invoice_template','history_invoice']
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
@@ -115,8 +115,8 @@ kimai:
# link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: ['ROLE_USER']

View File

@@ -0,0 +1,2 @@
kimai:
data_dir: '%kernel.project_dir%/tests/_data'

View File

@@ -83,6 +83,10 @@ services:
arguments:
$localDomains: '%kimai.i18n_domains%'
App\Utils\FileHelper:
arguments:
$dataDir: '%kimai.data_dir%'
# ================================================================================
# DATABASE
# ================================================================================
@@ -226,3 +230,8 @@ services:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ProjectRate']
App\Repository\InvoiceRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Invoice']

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
@@ -20,13 +21,6 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
abstract class AbstractController extends BaseAbstractController implements ServiceSubscriberInterface
{
public const FLASH_SUCCESS = 'success';
public const FLASH_WARNING = 'warning';
public const FLASH_ERROR = 'error';
public const DOMAIN_FLASH = 'flashmessages';
public const DOMAIN_ERROR = 'exceptions';
/**
* @deprecated since 1.6, will be removed with 2.0
*/
@@ -40,6 +34,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return $this->container->get('translator');
}
/**
* @return LoggerInterface $logger
*/
private function getLogger()
{
return $this->container->get('logger');
}
/**
* @return User|null
*/
@@ -56,7 +58,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*/
protected function flashSuccess($translationKey, $parameter = [])
{
$this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter);
$this->addFlashTranslated('success', $translationKey, $parameter);
}
/**
@@ -67,7 +69,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*/
protected function flashWarning($translationKey, $parameter = [])
{
$this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter);
$this->addFlashTranslated('warning', $translationKey, $parameter);
}
/**
@@ -78,7 +80,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
*/
protected function flashError($translationKey, $parameter = [])
{
$this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter);
$this->addFlashTranslated('error', $translationKey, $parameter);
}
/**
@@ -92,22 +94,28 @@ abstract class AbstractController extends BaseAbstractController implements Serv
{
if (!empty($parameter)) {
foreach ($parameter as $key => $value) {
$parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH);
$parameter[$key] = $this->getTranslator()->trans($value, [], 'flashmessages');
}
$message = $this->getTranslator()->trans(
$message,
$parameter,
self::DOMAIN_FLASH
'flashmessages'
);
}
$this->addFlash($type, $message);
}
protected function logException(\Exception $ex)
{
$this->getLogger()->critical($ex->getMessage());
}
public static function getSubscribedServices()
{
return array_merge(parent::getSubscribedServices(), [
'translator' => TranslatorInterface::class
'translator' => TranslatorInterface::class,
'logger' => LoggerInterface::class
]);
}
}

View File

@@ -9,17 +9,21 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Export\ExportItemInterface;
use App\Form\InvoiceDocumentUploadForm;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
use App\Invoice\InvoiceFormatter;
use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceModel;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\InvoiceQuery;
@@ -48,7 +52,7 @@ final class InvoiceController extends AbstractController
/**
* @var InvoiceTemplateRepository
*/
private $invoiceRepository;
private $templateRepository;
/**
* @var UserDateTimeFactory
*/
@@ -61,11 +65,16 @@ final class InvoiceController extends AbstractController
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var InvoiceRepository
*/
private $invoiceRepository;
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
{
$this->service = $service;
$this->invoiceRepository = $invoice;
$this->templateRepository = $templateRepository;
$this->invoiceRepository = $invoiceRepository;
$this->dateTimeFactory = $dateTimeFactory;
$this->formatter = $formatter;
$this->dispatcher = $dispatcher;
@@ -75,9 +84,9 @@ final class InvoiceController extends AbstractController
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view_invoice')")
*/
public function indexAction(Request $request): Response
public function indexAction(Request $request, SystemConfiguration $configuration): Response
{
if (!$this->invoiceRepository->hasTemplate()) {
if (!$this->templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) {
return $this->redirectToRoute('admin_invoice_template_create');
}
@@ -88,16 +97,27 @@ final class InvoiceController extends AbstractController
$entries = [];
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, 'GET');
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->setData($query);
$form->submit($request->query->all(), false);
if ($this->isGranted('create_invoice')) {
if ($form->isValid()) {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query);
try {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query, true);
}
/** @var SubmitButton $printButton */
$printButton = $form->get('print');
if ($printButton->isClicked()) {
return $this->renderInvoice($query, false);
}
} catch (\Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
}
/** @var SubmitButton $previewButton */
@@ -144,7 +164,7 @@ final class InvoiceController extends AbstractController
return $query;
}
protected function renderInvoice(InvoiceQuery $query)
protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false)
{
$entries = $this->getEntries($query);
$model = $this->prepareModel($query);
@@ -162,11 +182,28 @@ final class InvoiceController extends AbstractController
$this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries);
}
$this->dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
if ($saveInvoice) {
if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries);
}
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
$this->dispatcher->dispatch($event);
$invoiceFilename = $this->service->saveGeneratedInvoice($event);
$invoice = new Invoice();
$invoice->setModel($model);
$invoice->setFilename($invoiceFilename);
$this->invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
if ($this->isGranted('history_invoice')) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
}
}
return $response;
}
@@ -180,7 +217,81 @@ final class InvoiceController extends AbstractController
}
/**
* @param InvoiceItemInterface[] $entries
* @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/
public function changeStatusAction(Invoice $invoice, string $status): Response
{
if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
throw $this->createNotFoundException('Unknwon invoice status');
}
switch ($status) {
case Invoice::STATUS_NEW:
$invoice->setIsNew();
break;
case Invoice::STATUS_PENDING:
$invoice->setIsPending();
break;
case Invoice::STATUS_PAID:
$invoice->setIsPaid();
break;
}
$this->invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list');
}
/**
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/
public function downloadAction(Invoice $invoice): Response
{
$file = $this->service->getInvoiceFile($invoice);
if (null === $file) {
throw $this->createNotFoundException(
sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
);
}
return $this->file($file->getRealPath(), $file->getBasename());
}
/**
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/
public function showInvoicesAction(Request $request, int $page): Response
{
$invoice = null;
if (null !== ($id = $request->get('id'))) {
$invoice = $this->invoiceRepository->find($id);
}
$query = new InvoiceQuery();
$query->setOrderBy('date');
$query->setPage($page);
$query->setCurrentUser($this->getUser());
$invoices = $this->invoiceRepository->getPagerfantaForQuery($query);
return $this->render('invoice/listing.html.twig', [
'entries' => $invoices,
'query' => $query,
'download' => $invoice,
]);
}
/**
* @param ExportItemInterface[] $entries
*/
private function markEntriesAsExported(iterable $entries)
{
@@ -197,7 +308,7 @@ final class InvoiceController extends AbstractController
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
* @return ExportItemInterface[]
*/
protected function getEntries(InvoiceQuery $query): array
{
@@ -246,6 +357,7 @@ final class InvoiceController extends AbstractController
{
$model = new InvoiceModel($this->formatter);
$model
->setInvoiceDate($this->dateTimeFactory->createDateTime())
->setQuery($query)
->setUser($this->getUser())
->setCustomer($query->getCustomer())
@@ -276,7 +388,7 @@ final class InvoiceController extends AbstractController
*/
public function listTemplateAction(): Response
{
$templates = $this->invoiceRepository->getPagerfantaForQuery(new BaseQuery());
$templates = $this->templateRepository->getPagerfantaForQuery(new BaseQuery());
return $this->render('invoice/templates.html.twig', [
'entries' => $templates,
@@ -361,7 +473,7 @@ final class InvoiceController extends AbstractController
*/
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
{
if (!$this->invoiceRepository->hasTemplate()) {
if (!$this->templateRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
}
@@ -394,7 +506,7 @@ final class InvoiceController extends AbstractController
public function deleteTemplate(InvoiceTemplate $template, Request $request): Response
{
try {
$this->invoiceRepository->removeTemplate($template);
$this->templateRepository->removeTemplate($template);
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
@@ -411,7 +523,7 @@ final class InvoiceController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->invoiceRepository->saveTemplate($template);
$this->templateRepository->saveTemplate($template);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_template');
@@ -426,11 +538,13 @@ final class InvoiceController extends AbstractController
]);
}
protected function getToolbarForm(InvoiceQuery $query, string $method): FormInterface
protected function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
{
return $this->createForm(InvoiceToolbarForm::class, $query, [
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;
return $this->createForm($form, $query, [
'action' => $this->generateUrl('invoice', []),
'method' => $method,
'method' => 'GET',
'include_user' => $this->isGranted('view_other_timesheet'),
'attr' => [
'id' => 'invoice-print-form'

View File

@@ -20,6 +20,7 @@ use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType;
use App\Form\Type\TrackingModeType;
use App\Form\Type\WeekDaysType;
use App\Form\Type\YesNoType;
use App\Repository\ConfigurationRepository;
use App\Validator\Constraints\DateTimeFormat;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -275,6 +276,22 @@ final class SystemConfigurationController extends AbstractController
->setType(WeekDaysType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_INVOICE)
->setConfiguration([
(new Configuration())
->setName('invoice.number_format')
->setLabel('invoice.number_format')
->setRequired(true)
->setType(TextType::class) // TODO that should be a custom type with validation
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('invoice.simple_form')
->setLabel('simple_form')
->setRequired(false)
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
->setConfiguration([

View File

@@ -224,6 +224,12 @@ class Configuration implements ConfigurationInterface
->scalarPrototype()->end()
->defaultValue([])
->end()
->booleanNode('simple_form')
->defaultTrue()
->end()
->scalarNode('number_format')
->defaultValue('{Y}/{cy,3}')
->end()
->end()
;

290
src/Entity/Invoice.php Normal file
View File

@@ -0,0 +1,290 @@
<?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\Entity;
use App\Invoice\InvoiceModel;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_invoices",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"invoice_number"}),
* @ORM\UniqueConstraint(columns={"invoice_filename"})
* }
* )
* @UniqueEntity("invoiceNumber")
* @UniqueEntity("invoiceFilename")
*
* @ORM\Entity(repositoryClass="App\Repository\InvoiceRepository")
*/
class Invoice
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_NEW = 'new';
/**
* @var int|null
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="invoice_number", type="string", length=50, nullable=false)
* @Assert\NotNull()
*/
private $invoiceNumber;
/**
* @var Customer|null
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
/**
* @var User|null
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $user;
/**
* @var \DateTime
*
* @ORM\Column(name="created_at", type="datetime", nullable=false)
* @Assert\NotNull()
*/
private $createdAt;
/**
* @var string
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
*/
private $timezone;
/**
* @var float
*
* @ORM\Column(name="total", type="float", nullable=false)
* @Assert\NotNull()
*/
private $total = 0.00;
/**
* @var float
*
* @ORM\Column(name="tax", type="float", nullable=false)
* @Assert\NotNull()
*/
private $tax = 0.00;
/**
* @var string
*
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
* @Assert\Length(max=3)
*/
private $currency;
/**
* @var int
*
* @ORM\Column(name="due_days", type="integer", length=3, nullable=false)
* @Assert\Range(min = 0, max = 999)
*/
private $dueDays = 30;
/**
* @var float
*
* @ORM\Column(name="vat", type="float", nullable=false)
* @Assert\Range(min = 0.0, max = 99.99)
*/
private $vat = 0.00;
/**
* @var string
*
* @ORM\Column(name="status", type="string", length=20, nullable=false)
*/
private $status = self::STATUS_NEW;
/**
* @var string
*
* @ORM\Column(name="invoice_filename", type="string", length=100, nullable=false)
*/
private $invoiceFilename;
/**
* @var bool
*/
private $localized = false;
public function getId(): ?int
{
return $this->id;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function getUser(): ?User
{
return $this->user;
}
public function getInvoiceNumber(): ?string
{
return $this->invoiceNumber;
}
public function getTotal(): float
{
return $this->total;
}
public function getCreatedAt(): ?\DateTime
{
if (!$this->localized) {
if (null !== $this->createdAt && null !== $this->timezone) {
$this->createdAt->setTimeZone(new \DateTimeZone($this->timezone));
}
$this->localized = true;
}
return $this->createdAt;
}
public function getDueDate(): ?\DateTime
{
if (null === $this->getCreatedAt()) {
return null;
}
$dueDate = clone $this->getCreatedAt();
$dueDate->modify('+ ' . $this->dueDays . 'days');
return $dueDate;
}
public function isOverdue(): bool
{
if (null === $this->getDueDate()) {
return false;
}
return $this->getDueDate()->getTimestamp() < (new \DateTime('now', new \DateTimeZone($this->timezone)))->getTimestamp();
}
public function setFilename(string $filename): Invoice
{
$this->invoiceFilename = $filename;
return $this;
}
public function setModel(InvoiceModel $model): Invoice
{
$this->customer = $model->getCustomer();
$this->user = $model->getUser();
$this->total = $model->getCalculator()->getTotal();
$this->tax = $model->getCalculator()->getTax();
$this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber();
$this->currency = $model->getCurrency();
$createdAt = $model->getInvoiceDate();
$this->createdAt = $createdAt;
$this->timezone = $createdAt->getTimezone()->getName();
$template = $model->getTemplate();
$this->dueDays = $template->getDueDays();
$this->vat = $template->getVat();
return $this;
}
public function isNew(): bool
{
return $this->status === self::STATUS_NEW;
}
public function setIsNew(): Invoice
{
$this->status = self::STATUS_NEW;
return $this;
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function setIsPending(): Invoice
{
$this->status = self::STATUS_PENDING;
return $this;
}
public function isPaid(): bool
{
return $this->status === self::STATUS_PAID;
}
public function setIsPaid(): Invoice
{
$this->status = self::STATUS_PAID;
return $this;
}
public function getDueDays(): int
{
return $this->dueDays;
}
public function getVat(): float
{
return $this->vat;
}
public function getTax(): float
{
return $this->tax;
}
public function getCurrency(): ?string
{
return $this->currency;
}
public function getInvoiceFilename(): ?string
{
return $this->invoiceFilename;
}
}

View File

@@ -170,6 +170,8 @@ class InvoiceTemplate
return $this->name;
}
// ---- trait methods below ---
public function getTitle(): ?string
{
return $this->title;

View File

@@ -14,20 +14,6 @@ use App\Invoice\InvoiceItemInterface;
interface ExportItemInterface extends InvoiceItemInterface
{
/**
* A name representation for this type of export.
*
* @return string
*/
public function getType(): string;
/**
* A name representation for the category of this type.
*
* @return string
*/
public function getCategory(): string;
/**
* Whether this item was already exported.
*

View File

@@ -13,6 +13,7 @@ class SystemConfiguration
{
public const SECTION_ROUNDING = 'rounding';
public const SECTION_TIMESHEET = 'timesheet';
public const SECTION_FORM_INVOICE = 'invoice';
public const SECTION_FORM_CUSTOMER = 'form_customer';
public const SECTION_FORM_USER = 'form_user';
public const SECTION_THEME = 'theme';

View File

@@ -45,6 +45,9 @@ class SystemConfigurationForm extends AbstractType
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_system_configurations',
'attr' => [
'data-form-event' => 'kimai.systemConfigUpdate'
],
]);
}
}

View File

@@ -9,53 +9,27 @@
namespace App\Form\Toolbar;
use App\Form\Type\InvoiceTemplateType;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering timesheet entries for invoices.
*/
class InvoiceToolbarForm extends AbstractToolbarForm
class InvoiceToolbarForm extends InvoiceToolbarSimpleForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$this->addSearchTermInputField($builder);
$this->addTemplateChoice($builder);
if ($options['include_user']) {
$this->addUsersChoice($builder);
}
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
$this->addProjectChoice($builder, ['ignore_date' => true]);
$this->addActivityChoice($builder);
$this->addTagInputField($builder);
$this->addExportStateChoice($builder);
$builder->add('markAsExported', CheckboxType::class, [
'label' => 'label.mark_as_exported',
'required' => false,
]);
$builder->add('create', SubmitType::class, [
'label' => 'button.print',
'attr' => ['formtarget' => '_blank'],
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',
]);
}
protected function addTemplateChoice(FormBuilderInterface $builder)
{
$builder->add('template', InvoiceTemplateType::class, [
'required' => true,
'placeholder' => null,
]);
}
/**
@@ -63,10 +37,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => InvoiceQuery::class,
'csrf_protection' => false,
'include_user' => true,
]);
parent::configureOptions($resolver);
$resolver->setDefault('include_user', true);
}
}

View File

@@ -0,0 +1,68 @@
<?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\Form\Toolbar;
use App\Form\Type\InvoiceTemplateType;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering timesheet entries for invoices.
*/
class InvoiceToolbarSimpleForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addTemplateChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
$this->addProjectChoice($builder, ['ignore_date' => true]);
$builder->add('markAsExported', CheckboxType::class, [
'label' => 'label.mark_as_exported',
'required' => false,
]);
$builder->add('create', SubmitType::class, [
'label' => 'action.save',
]);
$builder->add('print', SubmitType::class, [
'label' => 'button.preview_print',
'attr' => ['formtarget' => '_blank'],
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',
]);
}
protected function addTemplateChoice(FormBuilderInterface $builder)
{
$builder->add('template', InvoiceTemplateType::class, [
'required' => true,
'placeholder' => null,
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => InvoiceQuery::class,
'csrf_protection' => false,
'include_user' => true,
]);
}
}

View File

@@ -43,6 +43,7 @@ class InvoiceRendererType extends AbstractType
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$documents[$document->getId()] = $document->getName();
break;
}
}
}
@@ -70,13 +71,13 @@ class InvoiceRendererType extends AbstractType
{
$renderer = $label;
return ucfirst(
substr(
$renderer,
1 + strrpos($renderer, '.'),
strrpos($renderer, '.')
)
);
$parts = explode('.', $renderer);
if (count($parts) > 2) {
array_pop($parts);
}
return ucfirst(array_pop($parts));
}
/**

View File

@@ -10,7 +10,6 @@
namespace App\Invoice\Calculator;
use App\Entity\Timesheet;
use App\Export\ExportItemInterface;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceItemWithAmountInterface;
@@ -42,13 +41,8 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
$amount = $entry->getAmount();
}
$type = Timesheet::TYPE_TIMESHEET;
$category = Timesheet::CATEGORY_WORK;
if ($entry instanceof ExportItemInterface) {
$type = $entry->getType();
$category = $entry->getCategory();
}
$type = $entry->getType();
$category = $entry->getCategory();
if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) {
$type = self::TYPE_MIXED;

View File

@@ -47,14 +47,23 @@ abstract class AbstractSumInvoiceCalculator extends AbstractMergedCalculator imp
}
$invoiceItem = $invoiceItems[$id];
$this->mergeInvoiceItems($invoiceItem, $entry);
$this->mergeSumTimesheet($invoiceItem, $entry);
$this->mergeSumInvoiceItem($invoiceItem, $entry);
}
return array_values($invoiceItems);
}
/**
* @deprecated since 1.9 - use mergeSumInvoiceItem() instead
*/
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
{
// allows to set values per calculator after merging the timesheet
}
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
{
@trigger_error('mergeSumTimesheet() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$this->mergeSumTimesheet($invoiceItem, $entry);
}
}

View File

@@ -21,19 +21,18 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements
protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string
{
if (null === $invoiceItem->getActivity()) {
throw new \Exception('Cannot work with invoice items that do not have an activity');
}
if (null === $invoiceItem->getActivity()->getId()) {
throw new \Exception('Cannot handle un-persisted activities');
return '__NULL__';
}
return (string) $invoiceItem->getActivity()->getId();
}
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
{
$invoiceItem->setActivity($entry->getActivity());
if (null === $entry->getActivity()) {
return;
}
$invoiceItem->setDescription($entry->getActivity()->getName());
}

View File

@@ -27,7 +27,7 @@ class ProjectInvoiceCalculator extends AbstractSumInvoiceCalculator implements C
return (string) $invoiceItem->getProject()->getId();
}
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
{
$invoiceItem->setProject($entry->getProject());
$invoiceItem->setDescription($entry->getProject()->getName());

View File

@@ -0,0 +1,49 @@
<?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\Invoice;
use Symfony\Component\String\UnicodeString;
final class InvoiceFilename
{
/**
* @var string
*/
private $filename;
public function __construct(InvoiceModel $model)
{
$filename = $model->getNumberGenerator()->getInvoiceNumber();
$filename = str_replace(['/', '\\'], '-', $filename);
$company = $model->getCustomer()->getCompany();
if (empty($company)) {
$company = $model->getCustomer()->getName();
}
if (!empty($company)) {
$company = new UnicodeString($company);
$filename .= '-' . $company->snake();
}
$this->filename = $filename;
}
public function getFilename()
{
return $this->filename;
}
public function __toString()
{
return $this->getFilename();
}
}

View File

@@ -14,9 +14,6 @@ use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\User;
/**
* @deprecated will be removed with 2.0 - use ExportItemInterface instead
*/
interface InvoiceItemInterface
{
public function getActivity(): ?Activity;
@@ -43,4 +40,18 @@ interface InvoiceItemInterface
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array;
/**
* A name representation for this type of item.
*
* @return string
*/
public function getType(): string;
/**
* A name representation for the category of this item.
*
* @return string
*/
public function getCategory(): string;
}

View File

@@ -190,14 +190,18 @@ final class InvoiceModel
return new \DateTime('+' . $this->getTemplate()->getDueDays() . ' days');
}
/**
* @return \DateTime
*/
public function getInvoiceDate(): \DateTime
{
return $this->invoiceDate;
}
public function setInvoiceDate(\DateTime $date): InvoiceModel
{
$this->invoiceDate = $date;
return $this;
}
public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel
{
$this->generator = $generator;

View File

@@ -0,0 +1,142 @@
<?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\Invoice\NumberGenerator;
use App\Configuration\SystemConfiguration;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGeneratorInterface;
use App\Repository\InvoiceRepository;
final class ConfigurableNumberGenerator implements NumberGeneratorInterface
{
/**
* @var InvoiceModel
*/
private $model;
/**
* @var InvoiceRepository
*/
private $repository;
/**
* @var string
*/
private $format;
/**
* @var string
*/
private $number;
public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration)
{
$this->repository = $repository;
$this->format = $configuration->find('invoice.number_format');
}
/**
* @return string
*/
public function getId(): string
{
return 'default';
}
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model)
{
$this->model = $model;
}
/**
* @return string
*/
public function getInvoiceNumber(): string
{
if (null !== $this->number) {
return $this->number;
}
$format = $this->format;
$invoiceDate = $this->model->getInvoiceDate();
$timestamp = $invoiceDate->getTimestamp();
$result = $format;
preg_match_all('/{[^}]*?}/', $format, $matches);
foreach ($matches[0] as $part) {
$formatter = null;
$tmp = str_replace(['{', '}'], '', $part);
// number format
if (substr_count($tmp, ',') !== 0) {
$formatter = explode(',', $tmp);
$tmp = $formatter[0];
$formatter = $formatter[1];
}
switch ($tmp) {
case 'Y':
$partialResult = date('Y', $timestamp);
break;
case 'y':
$partialResult = date('y', $timestamp);
break;
case 'M':
$partialResult = date('m', $timestamp);
break;
case 'm':
$partialResult = date('n', $timestamp);
break;
case 'D':
$partialResult = date('d', $timestamp);
break;
case 'd':
$partialResult = date('j', $timestamp);
break;
case 'date':
$partialResult = date('ymd', $timestamp);
break;
case 'c':
$partialResult = $this->repository->getCounterForAllTime($invoiceDate) + 1;
break;
case 'cy':
$partialResult = $this->repository->getCounterForYear($invoiceDate) + 1;
break;
case 'cm':
$partialResult = $this->repository->getCounterForMonth($invoiceDate) + 1;
break;
case 'cd':
$partialResult = $this->repository->getCounterForDay($invoiceDate) + 1;
break;
default:
$partialResult = $part;
}
if (null !== $formatter) {
$partialResult = str_pad($partialResult, $formatter, '0', STR_PAD_LEFT);
}
$result = str_replace($part, $partialResult, $result);
}
return $this->number = (string) $result;
}
}

View File

@@ -28,7 +28,7 @@ class DateNumberGenerator implements NumberGeneratorInterface
*/
public function getId(): string
{
return 'default';
return 'date';
}
/**

View File

@@ -10,10 +10,10 @@
namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceFilename;
use App\Invoice\InvoiceModel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\String\UnicodeString;
/**
* @internal
@@ -47,19 +47,7 @@ abstract class AbstractRenderer
protected function buildFilename(InvoiceModel $model): string
{
$filename = $model->getNumberGenerator()->getInvoiceNumber();
$company = $model->getCustomer()->getCompany();
if (empty($company)) {
$company = $model->getCustomer()->getName();
}
if (!empty($company)) {
$company = new UnicodeString($company);
$filename .= '-' . $company->snake();
}
return $filename;
return (string) new InvoiceFilename($model);
}
/**

View File

@@ -0,0 +1,60 @@
<?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\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceFilename;
use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use App\Utils\HtmlToPdfConverter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Twig\Environment;
final class PdfRenderer implements RendererInterface
{
/**
* @var Environment
*/
private $twig;
/**
* @var HtmlToPdfConverter
*/
private $converter;
public function __construct(Environment $twig, HtmlToPdfConverter $converter)
{
$this->twig = $twig;
$this->converter = $converter;
}
public function supports(InvoiceDocument $document): bool
{
return stripos($document->getFilename(), '.pdf.twig') !== false;
}
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [
'model' => $model
]);
$content = $this->converter->convertToPdf($content);
$filename = (string) new InvoiceFilename($model);
$response = new Response($content);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename . '.pdf');
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -40,6 +40,7 @@ final class TwigRenderer implements RendererInterface
$response = new Response();
$response->setContent($content);
$response->headers->set('Content-Type', 'text/html; charset=UTF-8');
return $response;
}

View File

@@ -9,8 +9,12 @@
namespace App\Invoice;
use App\Entity\Invoice;
use App\Entity\InvoiceDocument;
use App\Event\InvoicePostRenderEvent;
use App\Repository\InvoiceDocumentRepository;
use App\Utils\FileHelper;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* Service to manage invoice dependencies.
@@ -37,10 +41,15 @@ final class ServiceInvoice
* @var InvoiceDocumentRepository
*/
private $documents;
/**
* @var FileHelper
*/
private $fileHelper;
public function __construct(InvoiceDocumentRepository $repository)
public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper)
{
$this->documents = $repository;
$this->fileHelper = $fileHelper;
}
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
@@ -141,4 +150,62 @@ final class ServiceInvoice
return $this;
}
private function getInvoicesDirectory(): string
{
return $this->fileHelper->getDataSubdirectory('invoices');
}
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
{
$invoiceDirectory = $this->getInvoicesDirectory();
$filename = $invoice->getInvoiceFilename();
$full = $invoiceDirectory . $filename;
if (is_file($full) && is_readable($full)) {
return new \SplFileInfo($full);
}
return null;
}
public function saveGeneratedInvoice(InvoicePostRenderEvent $event): string
{
$invoiceDirectory = $this->getInvoicesDirectory();
$filename = (string) new InvoiceFilename($event->getModel());
$response = $event->getResponse();
if ($event->getResponse()->headers->has('Content-Disposition')) {
$disposition = $event->getResponse()->headers->get('Content-Disposition');
$parts = explode(';', $disposition);
foreach ($parts as $part) {
if (stripos($part, 'filename=') === false) {
continue;
}
$filename = explode('filename=', $part);
if (count($filename) > 1) {
$filename = $filename[1];
}
}
} else {
$disposition = $event->getResponse()->headers->get('Content-Type');
$parts = explode(';', $disposition);
$parts = explode('/', $parts[0]);
$filename .= '.' . $parts[1];
}
if (is_file($invoiceDirectory . $filename)) {
throw new \Exception(sprintf('Invoice "%s" already exists', $filename));
}
if ($response instanceof BinaryFileResponse) {
$file = $response->getFile();
$file->move($invoiceDirectory, $filename);
} else {
$this->fileHelper->saveFile($invoiceDirectory . $filename, $event->getResponse()->getContent());
}
return $filename;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 1.9
*/
final class Version20200308171950 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the invoice table';
}
public function up(Schema $schema): void
{
$invoices = $schema->createTable('kimai2_invoices');
$invoices->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$invoices->addColumn('invoice_number', 'string', ['length' => 50, 'notnull' => true]);
$invoices->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => true]);
$invoices->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => true]);
$invoices->addColumn('created_at', 'datetime', ['notnull' => true]);
$invoices->addColumn('timezone', 'string', ['length' => 64, 'notnull' => true]);
$invoices->addColumn('total', 'float', ['notnull' => true]);
$invoices->addColumn('tax', 'float', ['notnull' => true]);
$invoices->addColumn('currency', 'string', ['length' => 3, 'notnull' => true]);
$invoices->addColumn('status', 'string', ['length' => 20, 'notnull' => true]);
$invoices->addColumn('due_days', 'integer', ['length' => 11, 'notnull' => true]);
$invoices->addColumn('vat', 'float', ['notnull' => true]);
$invoices->addColumn('invoice_filename', 'string', ['length' => 100, 'notnull' => true]);
$invoices->addUniqueIndex(['invoice_number'], 'UNIQ_76C38E372DA68207');
$invoices->addUniqueIndex(['invoice_filename'], 'UNIQ_76C38E372323B33D');
$invoices->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E37A76ED395');
$invoices->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E379395C3F3');
$invoices->setPrimaryKey(['id']);
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_invoices');
}
}

View File

@@ -0,0 +1,172 @@
<?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\Repository;
use App\Entity\Invoice;
use App\Entity\User;
use App\Repository\Loader\InvoiceLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\InvoiceQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
class InvoiceRepository extends EntityRepository
{
public function saveInvoice(Invoice $invoice)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($invoice);
$entityManager->flush();
}
private function getCounterFor(\DateTime $start, \DateTime $end): int
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('count(i.createdAt) as counter')
->from(Invoice::class, 'i')
->andWhere($qb->expr()->gte('i.createdAt', ':start'))
->andWhere($qb->expr()->lte('i.createdAt', ':end'))
->setParameter('start', $start)
->setParameter('end', $end)
;
$result = $qb->getQuery()->getOneOrNullResult();
if ($result === null) {
return 0;
}
return $result['counter'];
}
public function getCounterForDay(\DateTime $date): int
{
$start = (clone $date)->setTime(0, 0, 0);
$end = (clone $date)->setTime(23, 59, 59);
return $this->getCounterFor($start, $end);
}
public function getCounterForMonth(\DateTime $date): int
{
$start = (clone $date)->setDate($date->format('Y'), $date->format('n'), 1)->setTime(0, 0, 0);
$end = (clone $date)->setDate($date->format('Y'), $date->format('n'), $date->format('t'))->setTime(23, 59, 59);
return $this->getCounterFor($start, $end);
}
public function getCounterForYear(\DateTime $date): int
{
$start = (clone $date)->setDate($date->format('Y'), 1, 1)->setTime(0, 0, 0);
$end = (clone $date)->setDate($date->format('Y'), 12, 31)->setTime(23, 59, 59);
return $this->getCounterFor($start, $end);
}
public function getCounterForAllTime(\DateTime $date): int
{
return $this->count([]);
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all projects
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb
->leftJoin('i.customer', 'c')
->leftJoin('c.teams', 'c_teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('c_teams'));
return;
}
$orCustomer = $qb->expr()->orX(
$qb->expr()->isNull('c_teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$qb->setParameter('teams', $teams);
}
private function getQueryBuilderForQuery(InvoiceQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('i')
->from(Invoice::class, 'i')
;
$orderBy = $query->getOrderBy();
switch ($orderBy) {
case 'date':
$orderBy = 'i.createdAt';
break;
}
$qb->addOrderBy($orderBy, $query->getOrder());
$this->addPermissionCriteria($qb, $query->getCurrentUser());
// this will make sure, that we do not accidentally create results with multiple rows
// => which would result in a wrong LIMIT / pagination results
// the second group by is needed due to SQL standard (even though logically not really required for this query)
$qb->addGroupBy('i.id')->addGroupBy($orderBy);
return $qb;
}
public function countInvoicesForQuery(InvoiceQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('i.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
}
protected function getPaginatorForQuery(InvoiceQuery $query): PaginatorInterface
{
$counter = $this->countInvoicesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
}
public function getPagerfantaForQuery(InvoiceQuery $query): Pagerfanta
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
return $paginator;
}
}

View File

@@ -0,0 +1,55 @@
<?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\Repository\Loader;
use App\Entity\Invoice;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class InvoiceIdLoader implements LoaderInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$customer = $qb->select('PARTIAL i.{id}', 'customer')
->from(Invoice::class, 'i')
->leftJoin('i.customer', 'customer')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$user = $qb->select('PARTIAL i.{id}', 'user')
->from(Invoice::class, 'i')
->leftJoin('i.user', 'user')
->getQuery()
->execute();
}
}

View File

@@ -0,0 +1,38 @@
<?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\Repository\Loader;
use App\Entity\Invoice;
use Doctrine\ORM\EntityManagerInterface;
final class InvoiceLoader implements LoaderInterface
{
/**
* @var InvoiceIdLoader
*/
private $loader;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new InvoiceIdLoader($entityManager);
}
/**
* @param Invoice[] $invoices
*/
public function loadResults(array $invoices): void
{
$ids = array_map(function (Invoice $invoice) {
return $invoice->getId();
}, $invoices);
$this->loader->loadResults($ids);
}
}

59
src/Utils/FileHelper.php Normal file
View File

@@ -0,0 +1,59 @@
<?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\Utils;
final class FileHelper
{
/**
* @var string
*/
private $dataDir;
public function __construct(string $dataDir)
{
$this->dataDir = $dataDir;
}
public function getDataSubdirectory(string $directory): string
{
$subDirectory = $this->dataDir . '/' . rtrim(ltrim($directory, '/'), '/') . '/';
$this->makeDir($subDirectory);
if (!is_dir($subDirectory)) {
throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory));
}
if (!is_writable($subDirectory)) {
throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory));
}
return $subDirectory;
}
public function makeDir(string $directory)
{
if (is_dir($directory)) {
return;
}
if (false === @mkdir($directory)) {
throw new \Exception(sprintf('Failed to create directory "%s", check file permissions', $directory));
}
}
public function saveFile(string $filename, $data)
{
$result = @file_put_contents($filename, $data);
if ($result === false) {
throw new \Exception('File "%s" could not be written');
}
}
}

View File

@@ -4,10 +4,18 @@
{% set actions = {'visibility': '#modal_invoice'} %}
{% if is_granted('history_invoice') %}
{% set actions = actions|merge({'list': path('admin_invoice_list')}) %}
{% endif %}
{% if is_granted('manage_invoice_template') %}
{% set actions = actions|merge({'invoice-template': path('admin_invoice_template')}) %}
{% endif %}
{% if is_granted('system_configuration') %}
{% set actions = actions|merge({'settings': {'url': path('system_configuration_section', {'section': 'invoice'}), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoices', {'actions': actions, 'view': view}) %}
@@ -37,6 +45,46 @@
{{ widgets.page_actions(actions) }}
{% endmacro %}
{% macro invoice(invoice) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('history_invoice') %}
{% if invoice.new %}
{% set actions = actions|merge({'invoice.pending': path('admin_invoice_status', {'id': invoice.id, 'status': 'pending'})}) %}
{% elseif invoice.pending %}
{% set actions = actions|merge({'invoice.paid': path('admin_invoice_status', {'id': invoice.id, 'status': 'paid'})}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% set actions = actions|merge({'download': {'url': path('admin_invoice_download', {'id': invoice.id}), 'target': '_blank'}}) %}
{% endif %}
{% set event = trigger('actions.invoice', {'actions': actions, 'invoice': invoice}) %}
{{ widgets.table_actions(actions) }}
{% endmacro %}
{% macro invoice_listing(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('view_invoice') %}
{% set actions = actions|merge({'back': path('invoice')}) %}
{% endif %}
{% set actions = actions|merge({'visibility': '#modal_invoices'}) %}
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoice_details', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(actions) }}
{% endmacro %}
{% macro invoice_upload(view) %}
{% import "macros/widgets.html.twig" as widgets %}

View File

@@ -5,20 +5,19 @@
{% import "invoice/actions.html.twig" as actions %}
{% set columns = {
'date': 'alwaysVisible',
'user': 'hidden-xs hidden-sm',
'project': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
'unit_price': 'hidden-xs text-center',
'amount': 'text-center',
'duration': 'hidden-xs text-center',
'total_rate': 'text-right alwaysVisible',
'date': {'class': 'alwaysVisible', 'orderBy': false},
'user': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'project': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'description': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'unit_price': {'class': 'hidden-xs text-center', 'orderBy': false},
'amount': {'class': 'text-center', 'orderBy': false},
'duration': {'class': 'hidden-xs text-center', 'orderBy': false},
'total_rate': {'class': 'text-right alwaysVisible', 'orderBy': false},
} %}
{% set tableName = 'invoice' %}
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %}
{% block page_actions %}{{ actions.invoices('index') }}{% endblock %}
{% block main_before %}
@@ -34,21 +33,34 @@
{% block box_before %}{{ form_start(form) }}{% endblock %}
{% block box_body %}
{{ form_errors(form) }}
{{ form_row(form.searchTerm) }}
{% if form.searchTerm is defined %}
{{ form_row(form.searchTerm) }}
{% endif %}
{{ form_row(form.daterange) }}
{{ form_row(form.customer) }}
{{ form_row(form.project) }}
{{ form_row(form.activity) }}
{% if form.activity is defined %}
{{ form_row(form.activity) }}
{% endif %}
{% if form.users is defined %}
{{ form_row(form.users) }}
{% endif %}
{{ form_row(form.tags) }}
{{ form_row(form.exported) }}
{% if form.tags is defined %}
{{ form_row(form.tags) }}
{% endif %}
{% if form.exported is defined %}
{{ form_row(form.exported) }}
{% endif %}
{{ form_row(form.template) }}
{{ form_row(form.markAsExported) }}
{% endblock %}
{% block box_footer%}
{{ form_widget(form.create, {'attr': {'class': 'btn btn-success'}}) }}
{% set createAttr = {'class': 'btn btn-success'} %}
{% if not is_granted('history_invoice') %}
{% set createAttr = createAttr|merge({'formtarget': '_blank'}) %}
{% endif %}
{{ form_widget(form.create, {'attr': createAttr}) }}
{{ form_widget(form.print) }}
{{ form_widget(form.preview) }}
{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
@@ -96,8 +108,26 @@
<td class="text-right text-nowrap">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
<tr>
<th></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'amount') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'duration') }} text-center text-nowrap">{{ model.calculator.timeWorked|duration(isDecimal) }}</th>
<th class="text-right text-nowrap">{{ model.calculator.total|money(model.calculator.currency) }}</th>
</tr>
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.systemConfigUpdate', true);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %}
{% import "invoice/actions.html.twig" as actions %}
{% import "invoice/macros.html.twig" as macros %}
{% set columns = {
'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false},
'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false},
'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false},
'invoice_number': {'class': 'hidden-xs hidden-sm text-nowrap', 'title': 'invoice.number'|trans, 'orderBy': false},
'due_date': {'class': 'hidden-xs text-nowrap', 'title': 'invoice.due_days'|trans, 'orderBy': false},
'status': {'class': 'text-center alwaysVisible text-nowrap', 'orderBy': false},
'tax': {'class': 'hidden-xs text-center text-nowrap hidden', 'title': 'invoice.tax'|trans, 'orderBy': false},
'total_rate': {'class': 'hidden-xs text-center text-nowrap', 'orderBy': false},
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
} %}
{% set tableName = 'invoices' %}
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.invoice_listing('index') }}{% endblock %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block main %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {}) }}
{% for entry in entries %}
<tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.createdAt|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.user_avatar(entry.user) }} {{ widgets.username(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'invoice_number') }}">{{ widgets.label(entry.invoiceNumber, 'default') }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'due_date') }}">{{ macros.invoice_due_date(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'status') }}">{{ macros.invoice_status(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tax') }}">{{ entry.tax|money(entry.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">{{ entry.total|money(entry.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">{{ actions.invoice(entry) }}</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries, 'admin_invoice_list') }}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% if download is not null %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
location.href = '{{ path('admin_invoice_download', {'id': download.id}) }}';
});
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% macro invoice_status(invoice) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set overdue = invoice.overdue %}
{% if invoice.new and overdue %}
{{ widgets.label('status.new'|trans, 'danger') }}
{% elseif invoice.new %}
{{ widgets.label('status.new'|trans, 'primary') }}
{% elseif invoice.pending and overdue %}
{{ widgets.label('status.pending'|trans, 'danger') }}
{% elseif invoice.pending %}
{{ widgets.label('status.pending'|trans, 'warning') }}
{% elseif invoice.paid %}
{{ widgets.label('status.paid'|trans, 'success') }}
{% endif %}
{% endmacro %}
{% macro invoice_due_date(invoice) %}
{% import "macros/widgets.html.twig" as widgets %}
{% if invoice.overdue and not invoice.paid %}
{{ widgets.label(invoice.dueDate|date_short, 'danger') }}
{% else %}
{{ widgets.label(invoice.dueDate|date_short, 'primary') }}
{% endif %}
{% endmacro %}

View File

@@ -62,6 +62,11 @@
{{ form_row(form.numberGenerator) }}
</div>
</div>
<div class="row">
<div class="col-md-12">
{{ form_row(form.decimalDuration) }}
</div>
</div>
{{ form_widget(form) }}
{% endblock %}
{% endembed %}

View File

@@ -321,12 +321,12 @@ abstract class ControllerBaseTest extends WebTestCase
*/
protected function assertIsRedirect(HttpKernelBrowser $client, $url = null)
{
self::assertTrue($client->getResponse()->isRedirect());
self::assertTrue($client->getResponse()->isRedirect(), 'Response is not a redirect');
if (null === $url) {
return;
}
self::assertTrue($client->getResponse()->headers->has('Location'));
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'));
self::assertTrue($client->getResponse()->headers->has('Location'), 'Could not find "Location" header');
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'), 'Redirect URL does not match');
}
}

View File

@@ -16,12 +16,44 @@ use App\Form\Type\DateRangeType;
use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @group integration
*/
class InvoiceControllerTest extends ControllerBaseTest
{
protected function setUp(): void
{
parent::setUp();
$path = __DIR__ . '/../_data/invoices/';
if (is_dir($path)) {
$files = glob($path . '*');
foreach ($files as $file) {
unlink($file);
}
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->clearInvoiceFiles();
}
private function clearInvoiceFiles()
{
$path = __DIR__ . '/../_data/invoices/';
if (is_dir($path)) {
$files = glob($path . '*');
foreach ($files as $file) {
unlink($file);
}
}
}
public function testIsSecure()
{
$this->assertUrlIsSecured('/invoice/');
@@ -76,7 +108,6 @@ class InvoiceControllerTest extends ControllerBaseTest
'company' => 'Company name',
'renderer' => 'default',
'calculator' => 'default',
'numberGenerator' => 'default',
]
]);
@@ -111,7 +142,6 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertEquals($template->getCompany(), $values['company']);
$this->assertEquals($template->getAddress(), $values['address']);
$this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
$this->assertEquals($template->getNumberGenerator(), $values['numberGenerator']);
}
public function testPrintAction()
@@ -153,8 +183,8 @@ class InvoiceControllerTest extends ControllerBaseTest
// no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
// but the datatable with all timesheets
$this->assertDataTableRowCount($client, 'datatable_invoice', 20);
// but the datatable with all timesheets + 1 row for the total
$this->assertDataTableRowCount($client, 'datatable_invoice', 21);
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
@@ -180,6 +210,92 @@ class InvoiceControllerTest extends ControllerBaseTest
}
}
public function testPrintActionAsAdminWithDownloadAndStatusChange()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
$this->importFixture($client, $fixture);
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$fixture = new TimesheetFixtures();
$fixture
->setUser($this->getUserByRole($em, User::ROLE_ADMIN))
->setAmount(20)
->setStartDate($begin)
;
$this->importFixture($client, $fixture);
$this->request($client, '/invoice/');
$this->assertTrue($client->getResponse()->isSuccessful());
$dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d');
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?preview='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => 1,
'daterange' => $dateRange,
'customer' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
// no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
// but the datatable with all timesheets + 1 row for the total
$this->assertDataTableRowCount($client, 'datatable_invoice', 21);
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?create='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => 1,
'daterange' => $dateRange,
'customer' => 1,
'project' => 1,
'markAsExported' => 1,
]);
$this->assertIsRedirect($client, '/invoice/show?id=1');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_invoices', 1);
// make sure the invoice is saved
$this->request($client, '/invoice/download/1');
$response = $client->getResponse();
$this->assertTrue($response->isSuccessful());
self::assertInstanceOf(BinaryFileResponse::class, $response);
self::assertFileExists($response->getFile());
$this->request($client, '/invoice/change-status/1/pending');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->request($client, '/invoice/change-status/1/paid');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->request($client, '/invoice/change-status/1/new');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
}
public function testEditTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -196,7 +312,6 @@ class InvoiceControllerTest extends ControllerBaseTest
'company' => 'Company name',
'renderer' => 'default',
'calculator' => 'default',
'numberGenerator' => 'default',
]
]);

View File

@@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 108);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 109);
$this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),

View File

@@ -68,6 +68,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
{
return [
['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')],
['form[name=system_configuration_form_invoice]', $this->createUrl('/admin/system-config/update/invoice')],
['form[name=system_configuration_form_rounding]', $this->createUrl('/admin/system-config/update/rounding')],
['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')],
['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')],

View File

@@ -289,6 +289,8 @@ class ConfigurationTest extends TestCase
0 => 'var/invoices/',
1 => 'templates/invoice/renderer/',
],
'simple_form' => true,
'number_format' => '{Y}/{cy,3}',
],
'languages' => [],
'calendar' => [

View File

@@ -0,0 +1,169 @@
<?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\Entity;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\Invoice
*/
class InvoiceTest extends TestCase
{
public function testDefaultValues()
{
$sut = new Invoice();
self::assertNull($sut->getCreatedAt());
self::assertNull($sut->getCurrency());
self::assertNull($sut->getCustomer());
self::assertNull($sut->getDueDate());
self::assertEquals(30, $sut->getDueDays());
self::assertNull($sut->getId());
self::assertNull($sut->getInvoiceFilename());
self::assertNull($sut->getInvoiceNumber());
self::assertEquals(0.0, $sut->getTax());
self::assertEquals(0.0, $sut->getTotal());
self::assertNull($sut->getUser());
self::assertEquals(0.0, $sut->getVat());
self::assertTrue($sut->isNew());
self::assertFalse($sut->isPending());
self::assertFalse($sut->isPaid());
self::assertFalse($sut->isOverdue());
}
public function testSetterAndGetter()
{
$date = new \DateTime('-2 months');
$sut = new Invoice();
$sut->setIsPending();
self::assertFalse($sut->isNew());
self::assertTrue($sut->isPending());
self::assertFalse($sut->isPaid());
$sut->setIsPaid();
self::assertFalse($sut->isNew());
self::assertFalse($sut->isPending());
self::assertTrue($sut->isPaid());
$sut->setIsNew();
self::assertTrue($sut->isNew());
self::assertFalse($sut->isPending());
self::assertFalse($sut->isPaid());
self::assertFalse($sut->isOverdue());
$sut->setModel($this->getInvoiceModel($date));
self::assertTrue($sut->isOverdue());
self::assertEquals($date, $sut->getCreatedAt());
self::assertEquals('USD', $sut->getCurrency());
self::assertNotNull($sut->getCustomer());
self::assertNotNull($sut->getDueDate());
self::assertEquals(9, $sut->getDueDays());
self::assertNull($sut->getId());
self::assertNull($sut->getInvoiceFilename());
self::assertEquals(date('ymd', $date->getTimestamp()), $sut->getInvoiceNumber());
self::assertEquals(55.72, $sut->getTax());
self::assertEquals(348.99, $sut->getTotal());
self::assertNotNull($sut->getUser());
self::assertEquals(19, $sut->getVat());
}
protected function getInvoiceModel(\DateTime $created): InvoiceModel
{
$user = new User();
$user->setUsername('one-user');
$user->setTitle('user title');
$user->setAlias('genious alias');
$user->setEmail('fantastic@four');
$user->addPreference((new UserPreference())->setName('kitty')->setValue('kat'));
$user->addPreference((new UserPreference())->setName('hello')->setValue('world'));
$customer = new Customer();
$customer->setName('customer,with/special#name');
$customer->setCurrency('USD');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
$customer->setVatId('kjuo8967');
$template = new InvoiceTemplate();
$template->setTitle('a test invoice template title');
$template->setVat(19);
$template->setDueDays(9);
$project = new Project();
$project->setName('project name');
$project->setCustomer($customer);
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$timesheet = new Timesheet();
$timesheet
->setDuration(3600)
->setRate(293.27)
->setUser($user1)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entries = [$timesheet];
$query = new InvoiceQuery();
$query->setActivity($activity);
$query->setBegin(new \DateTime());
$query->setEnd(new \DateTime());
$model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->addEntries($entries);
$model->setQuery($query);
$model->setUser($user);
$model->setInvoiceDate($created);
$calculator = new DefaultCalculator();
$calculator->setModel($model);
$model->setCalculator($calculator);
$numberGenerator = new DateNumberGenerator();
$numberGenerator->setModel($model);
$model->setNumberGenerator($numberGenerator);
return $model;
}
}

View File

@@ -33,33 +33,6 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$this->assertEmptyModel(new ActivityInvoiceCalculator());
}
public function testExceptionNoActivity()
{
$this->expectException('Exception');
$this->expectExceptionMessage('Cannot work with invoice items that do not have an activity');
$timesheet = new Timesheet();
$sut = new ActivityInvoiceCalculator();
$model = $this->getEmptyModel();
$model->addEntries([$timesheet]);
$sut->setModel($model);
$sut->getEntries();
}
public function testExceptionNoId()
{
$this->expectException('Exception');
$this->expectExceptionMessage('Cannot handle un-persisted activities');
$timesheet = new Timesheet();
$timesheet->setActivity(new Activity());
$sut = new ActivityInvoiceCalculator();
$model = $this->getEmptyModel();
$model->addEntries([$timesheet]);
$sut->setModel($model);
$sut->getEntries();
}
public function testWithMultipleEntries()
{
$customer = new Customer();
@@ -128,7 +101,35 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
->setActivity($activity3)
->setProject((new Project())->setName('bar'));
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$timesheet6 = new Timesheet();
$timesheet6
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setProject((new Project())->setName('bar'));
$timesheet7 = new Timesheet();
$timesheet7
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setActivity(new Activity())
->setProject((new Project())->setName('bar'));
$timesheet8 = new Timesheet();
$timesheet8
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setProject((new Project())->setName('bar'));
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6, $timesheet7, $timesheet8];
$query = new InvoiceQuery();
$query->setActivity($activity1);
@@ -148,7 +149,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$this->assertEquals('EUR', $model->getCurrency());
$this->assertEquals(2521.12, $sut->getSubtotal());
$this->assertEquals(6600, $sut->getTimeWorked());
$this->assertEquals(3, count($sut->getEntries()));
$this->assertEquals(5, count($sut->getEntries()));
$entries = $sut->getEntries();
$this->assertEquals(404.38, $entries[0]->getRate());

View File

@@ -0,0 +1,53 @@
<?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;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Invoice\InvoiceFilename;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\InvoiceFilename
*/
class InvoiceFilenameTest extends TestCase
{
public function testInvoiceFilename()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$model = new InvoiceModel(new DebugFormatter());
$model->setNumberGenerator(new DateNumberGenerator());
$model->setTemplate($template);
$model->setCustomer($customer);
$datePrefix = date('ymd');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix, $sut->getFilename());
self::assertEquals($datePrefix, (string) $sut);
$customer->setName('foo');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix . '-foo', $sut->getFilename());
self::assertEquals($datePrefix . '-foo', (string) $sut);
$customer->setCompany('barß / laölala # ldksjf 123');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', $sut->getFilename());
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', (string) $sut);
}
}

View File

@@ -0,0 +1,100 @@
<?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\Configuration\SystemConfiguration;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
use App\Repository\InvoiceRepository;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\NumberGenerator\ConfigurableNumberGenerator
*/
class ConfigurableNumberGeneratorTest extends TestCase
{
private function getSut(string $format)
{
$config = $this->createMock(SystemConfiguration::class);
$config->expects($this->any())
->method('find')
->willReturn($format);
$repository = $this->createMock(InvoiceRepository::class);
$repository
->expects($this->any())
->method('getCounterForAllTime')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForYear')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForMonth')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForDay')
->willReturn(1);
return new ConfigurableNumberGenerator($repository, $config);
}
public function getTestData()
{
$timestamp = time();
return [
// simple tests for single calls
['{date}', date('ymd'), $timestamp],
['{Y}', date('Y'), $timestamp],
['{y}', date('y'), $timestamp],
['{M}', date('m'), $timestamp],
['{m}', date('n'), $timestamp],
['{D}', date('d'), $timestamp],
['{d}', date('j'), $timestamp],
['{c}', '2', $timestamp],
['{cy}', '2', $timestamp],
['{cm}', '2', $timestamp],
['{cd}', '2', $timestamp],
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
['{date,10}', '0000' . date('ymd'), $timestamp],
['{Y,6}', '00' . date('Y'), $timestamp],
['{M,3}', '0' . date('m'), $timestamp],
['{D,3}', '0' . date('d'), $timestamp],
['{c,2}', '02', $timestamp],
['{cy,2}', '02', $timestamp],
['{cm,2}', '02', $timestamp],
['{cd,2}', '02', $timestamp],
// mixing identifiers
['{Y}{cy}', date('Y') . '2', $timestamp],
['{Y}{cy}{m}', date('Y') . '2' . date('n'), $timestamp],
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
['{Y,5}/{cy,5}', '0' . date('Y') . '/00002', $timestamp],
];
}
/**
* @dataProvider getTestData
*/
public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, int $timestamp)
{
$sut = $this->getSut($format);
$model = new InvoiceModel(new DebugFormatter());
$model->setInvoiceDate((new \DateTime())->setTimestamp($timestamp));
$sut->setModel($model);
$this->assertEquals($expectedInvoiceNumber, $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
}
}

View File

@@ -25,6 +25,6 @@ class DateNumberGeneratorTest extends TestCase
$sut->setModel(new InvoiceModel(new DebugFormatter()));
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
$this->assertEquals('date', $sut->getId());
}
}

View File

@@ -0,0 +1,65 @@
<?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\Renderer;
use App\Invoice\Renderer\PdfRenderer;
use App\Utils\MPdfConverter;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Invoice\Renderer\PdfRenderer
* @group integration
*/
class PdfRendererTest extends KernelTestCase
{
use RendererTestTrait;
public function testSupports()
{
$loader = new FilesystemLoader();
$env = new Environment($loader);
$sut = new PdfRenderer($env, $this->createMock(MPdfConverter::class));
$this->assertTrue($sut->supports($this->getInvoiceDocument('default.pdf.twig', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
$kernel = self::bootKernel();
/** @var Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath(__DIR__ . '/../templates/', 'invoice');
$sut = new PdfRenderer($twig, new MPdfConverter($cacheDir));
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('default.pdf.twig', true);
$response = $sut->render($document, $model);
$this->assertEquals('application/pdf', $response->headers->get('Content-Type'));
}
}

View File

@@ -15,6 +15,7 @@ use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Utils\FileHelper;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
@@ -23,10 +24,16 @@ use Twig\Environment;
*/
class ServiceInvoiceTest extends TestCase
{
private function getSut(array $paths): ServiceInvoice
{
$repo = new InvoiceDocumentRepository($paths);
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')));
}
public function testEmptyObject()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut([]);
$this->assertEmpty($sut->getCalculator());
$this->assertIsArray($sut->getCalculator());
@@ -44,8 +51,7 @@ class ServiceInvoiceTest extends TestCase
public function testWithDocumentDirectory()
{
$repo = new InvoiceDocumentRepository(['templates/invoice/renderer/']);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut(['templates/invoice/renderer/']);
$actual = $sut->getDocuments();
$this->assertNotEmpty($actual);
@@ -59,8 +65,7 @@ class ServiceInvoiceTest extends TestCase
public function testAdd()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut([]);
$sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator(new DateNumberGenerator());
@@ -74,7 +79,7 @@ class ServiceInvoiceTest extends TestCase
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
$this->assertEquals(1, count($sut->getNumberGenerator()));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('default'));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('date'));
$this->assertEquals(1, count($sut->getRenderer()));
}

View File

@@ -0,0 +1,146 @@
{% extends 'invoice/layout.html.twig' %}
{% set language = model.template.language|default(app.request.locale) %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% block invoice %}
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span>
<small class="pull-right">{{ 'label.date'|trans({}, 'messages', language) }}: {{ model.invoiceDate|date_short }}</small>
</h2>
</div>
</div>
<div class="row">
<div class="col-sm-5">
{{ 'invoice.from'|trans({}, 'messages', language) }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.to'|trans({}, 'messages', language) }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'label.number'|trans({}, 'messages', language) }}: {{ model.customer.number }}
{% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<br>
{{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }}
{% endif %}
</address>
</div>
</div>
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items">
<div class="col-xs-12 table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ 'label.date'|trans({}, 'messages', language) }}</th>
<th>{{ 'label.description'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.unit_price'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.amount'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.total_rate'|trans({}, 'messages', language) }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
{% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td nowrap class="text-nowrap text-right">{{ rate|money(model.calculator.currency) }}</td>
<td nowrap class="text-nowrap text-right">{{ duration }}</td>
<td nowrap class="text-nowrap text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans({}, 'messages', language) }}
</td>
<td class="text-right">{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%)
</td>
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans({}, 'messages', language) }}</strong>
</td>
<td class="text-right">
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|nl2br|md2html }}
</div>
{% endif %}
</div>
</div>
<footer class="footer">
<p>
<strong>{{ 'label.address'|trans({}, 'messages', language) }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</p>
</footer>
{% endblock %}

View File

@@ -0,0 +1,43 @@
<?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\Repository\Loader;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;
abstract class AbstractLoaderTest extends TestCase
{
protected function getEntityManagerMock(int $createQueryBuilderCount)
{
$em = $this->createMock(EntityManager::class);
$qb = $this->createMock(QueryBuilder::class);
$query = $this->createMock(AbstractQuery::class);
$expr = $this->createMock(Expr::class);
$expr->expects($this->any())->method('isNotNull')->willReturn('');
$expr->expects($this->any())->method('in')->willReturn('');
$qb->expects($this->any())->method('andWhere')->willReturnSelf();
$qb->expects($this->any())->method('from')->willReturnSelf();
$qb->expects($this->any())->method('expr')->willReturn($expr);
$qb->expects($this->any())->method('from')->willReturnSelf();
$qb->expects($this->any())->method('select')->willReturnSelf();
$qb->expects($this->any())->method('leftJoin')->willReturnSelf();
$qb->expects($this->any())->method('getQuery')->willReturn($query);
$query->expects($this->any())->method('execute')->willReturn(null);
$em->expects($this->exactly($createQueryBuilderCount))->method('createQueryBuilder')->willReturn($qb);
return $em;
}
}

View File

@@ -0,0 +1,33 @@
<?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\Repository\Loader;
use App\Entity\Activity;
use App\Repository\Loader\ActivityLoader;
/**
* @covers \App\Repository\Loader\ActivityLoader
* @covers \App\Repository\Loader\ActivityIdLoader
*/
class ActivityLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
// mock needs improvements, because it should be 5
$em = $this->getEntityManagerMock(2);
$sut = new ActivityLoader($em);
$entity = $this->createMock(Activity::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Customer;
use App\Repository\Loader\CustomerLoader;
/**
* @covers \App\Repository\Loader\CustomerLoader
* @covers \App\Repository\Loader\CustomerIdLoader
*/
class CustomerLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(2);
$sut = new CustomerLoader($em);
$entity = $this->createMock(Customer::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Invoice;
use App\Repository\Loader\InvoiceLoader;
/**
* @covers \App\Repository\Loader\InvoiceLoader
* @covers \App\Repository\Loader\InvoiceIdLoader
*/
class InvoiceLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(2);
$sut = new InvoiceLoader($em);
$entity = $this->createMock(Invoice::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Project;
use App\Repository\Loader\ProjectLoader;
/**
* @covers \App\Repository\Loader\ProjectLoader
* @covers \App\Repository\Loader\ProjectIdLoader
*/
class ProjectLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(4);
$sut = new ProjectLoader($em);
$entity = $this->createMock(Project::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Team;
use App\Repository\Loader\TeamLoader;
/**
* @covers \App\Repository\Loader\TeamLoader
* @covers \App\Repository\Loader\TeamIdLoader
*/
class TeamLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(1);
$sut = new TeamLoader($em);
$entity = $this->createMock(Team::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

0
tests/_data/.gitignore vendored Normal file
View File

View File

@@ -78,6 +78,18 @@
<source>details</source>
<target>Anzeigen</target>
</trans-unit>
<trans-unit id="download">
<source>download</source>
<target>Herunterladen</target>
</trans-unit>
<trans-unit id="invoice.pending">
<source>invoice.pending</source>
<target>Warten auf Zahlungseingang</target>
</trans-unit>
<trans-unit id="invoice.paid">
<source>invoice.paid</source>
<target>Rechnung bezahlt</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -78,6 +78,18 @@
<source>details</source>
<target>Show</target>
</trans-unit>
<trans-unit id="download">
<source>download</source>
<target>Download</target>
</trans-unit>
<trans-unit id="invoice.pending">
<source>invoice.pending</source>
<target>Waiting for payment</target>
</trans-unit>
<trans-unit id="invoice.paid">
<source>invoice.paid</source>
<target>Invoice paid</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>مولد رقم فاتورة </target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>التاريخ (افتراضي)</target>
<trans-unit id="date">
<source>date</source>
<target>التاريخ</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Generátor fakturačních čísel</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Data (Výchozí)</target>
<trans-unit id="date">
<source>date</source>
<target>Data</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Fakturanummergenerator</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Dato (standard)</target>
<trans-unit id="date">
<source>date</source>
<target>Dato</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,13 @@
<source>label.invoice_number_generator</source>
<target>Rechnungsnummern-Generator</target>
</trans-unit>
<trans-unit id="date">
<source>date</source>
<target>Datum</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Datum (Standard)</target>
<target>Konfiguriertes Format</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,13 @@
<source>label.invoice_number_generator</source>
<target>Invoicenumber-Generator</target>
</trans-unit>
<trans-unit id="date">
<source>date</source>
<target>Date</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Date (default)</target>
<target>Configured format</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Generador de número de factura</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Fecha (default)</target>
<trans-unit id="date">
<source>date</source>
<target>Fecha</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Faktura zenbaki sortzailea</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Data (defektuz)</target>
<trans-unit id="date">
<source>date</source>
<target>Data</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Générateur de numéros de facture</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Date (par defaut)</target>
<trans-unit id="date">
<source>date</source>
<target>Date</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Számlaszám-generátor</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Dátum (alapértelmezett)</target>
<trans-unit id="date">
<source>date</source>
<target>Dátum</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Generatore di numerazione fattura</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Data (default)</target>
<trans-unit id="date">
<source>date</source>
<target>Data</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>請求書番号 ジェネレーター</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>日付 (デフォルト)</target>
<trans-unit id="date">
<source>date</source>
<target>日付</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target state="translated">인보이스번호-생성기</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target state="translated">날짜 (초기가)</target>
<trans-unit id="date">
<source>date</source>
<target state="translated">날짜</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Factuurnummergenerator</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Datum (standaard)</target>
<trans-unit id="date">
<source>date</source>
<target>Datum</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Generator numeru faktury</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Data (domyślny)</target>
<trans-unit id="date">
<source>date</source>
<target>Data</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Gerador de Números de Fatura</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Data (Padrão)</target>
<trans-unit id="date">
<source>date</source>
<target>Data</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Генератор номеров счета</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Дата (стандартная)</target>
<trans-unit id="date">
<source>date</source>
<target>Дата</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>Generátor čísiel faktúr</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Dátum (default)</target>
<trans-unit id="date">
<source>date</source>
<target>Dátum</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target state="translated">Fakturanummergenerator</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target state="translated">Datum (standard)</target>
<trans-unit id="date">
<source>date</source>
<target state="translated">Datum</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target state="translated">Faturano-Oluşturucu</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target state="translated">Tarih (varsayılan)</target>
<trans-unit id="date">
<source>date</source>
<target state="translated">Tarih</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,9 @@
<source>label.invoice_number_generator</source>
<target>发票数字生成器</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>日期(默认)</target>
<trans-unit id="date">
<source>date</source>
<target>日期</target>
</trans-unit>
</body>
</file>

View File

@@ -561,10 +561,6 @@
<source>invoice.title</source>
<target>الفواتير</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>م بإنشاء فواتير من بيانات ورقة التوقيت الخاصة بك.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>طباعة</target>

View File

@@ -752,10 +752,6 @@
<source>invoice.title</source>
<target>Faktura</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Vytvářejte faktury z vašich zaznamenaných položek časového záznamu.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Toto je náhled dat, která se zobrazí ve vašem fakturačním dokladu.</target>

View File

@@ -784,10 +784,6 @@
<source>invoice.title</source>
<target>Fakturaer</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Opret faktura for dine tidsregistreringsindlæg.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filtrer fakturadata</target>

View File

@@ -824,10 +824,6 @@
<source>invoice.title</source>
<target>Rechnungen</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Rechnungsdaten filtern</target>
@@ -836,6 +832,10 @@
<source>button.preview</source>
<target>Vorschau</target>
</trans-unit>
<trans-unit id="button.preview_print">
<source>button.preview_print</source>
<target>Druck Vorschau</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Drucken</target>
@@ -952,6 +952,22 @@
<source>label.decimalDuration</source>
<target>Dauer als Dezimalzahl anzeigen</target>
</trans-unit>
<trans-unit id="label.status">
<source>label.status</source>
<target>Status</target>
</trans-unit>
<trans-unit id="status.new">
<source>status.new</source>
<target>Neu</target>
</trans-unit>
<trans-unit id="status.pending">
<source>status.pending</source>
<target>Ausstehend</target>
</trans-unit>
<trans-unit id="status.paid">
<source>status.paid</source>
<target>Bezahlt</target>
</trans-unit>
<!--
Export

View File

@@ -824,10 +824,6 @@
<source>invoice.title</source>
<target>Invoices</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Create invoices from your recorded timesheet entries.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filter invoice data</target>
@@ -836,6 +832,10 @@
<source>button.preview</source>
<target>Preview</target>
</trans-unit>
<trans-unit id="button.preview_print">
<source>button.preview_print</source>
<target>Print preview</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Print</target>
@@ -952,6 +952,22 @@
<source>label.decimalDuration</source>
<target>Display duration as decimal number</target>
</trans-unit>
<trans-unit id="label.status">
<source>label.status</source>
<target>Status</target>
</trans-unit>
<trans-unit id="status.new">
<source>status.new</source>
<target>New</target>
</trans-unit>
<trans-unit id="status.pending">
<source>status.pending</source>
<target>Pending</target>
</trans-unit>
<trans-unit id="status.paid">
<source>status.paid</source>
<target>Paid</target>
</trans-unit>
<!--
Export

View File

@@ -756,10 +756,6 @@
<source>invoice.title</source>
<target>Facturas</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Crear facturas a partir de registros de sus partes de horas.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filtrar datos de factura</target>

View File

@@ -792,10 +792,6 @@
<source>invoice.title</source>
<target>Fakturak</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Faktura sortu ordu sarrerak erabiliaz.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Faktura datuak filtratu</target>

View File

@@ -696,10 +696,6 @@
<source>invoice.title</source>
<target>Factures</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Générer des factures à partir des fiches de temps.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Imprimer</target>

View File

@@ -696,10 +696,6 @@
<source>invoice.title</source>
<target>Számlák</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Számlák készítése az időbejegyzéseidből</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Nyomtatás</target>

View File

@@ -112,7 +112,7 @@
<source>login_required</source>
<target>Permessi insufficenti. Ridirezionare all'accesso?</target>
</trans-unit>
<!--
Menu / Navbar items
-->
@@ -196,7 +196,7 @@
<source>error.no_comments_found</source>
<target>Non ci sono commenti fino'ora.</target>
</trans-unit>
<!--
General labels
-->
@@ -440,7 +440,7 @@
<source>label.appendTags</source>
<target>Aggiungi tags</target>
</trans-unit>
<!--
User profile
-->
@@ -513,7 +513,7 @@
<source>label.timesheet.export_decimal</source>
<target>Usa durata decimale nell'esportazione</target>
</trans-unit>
<!--
User timesheet calendar
-->
@@ -820,10 +820,6 @@
<source>invoice.title</source>
<target>Fatture</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Crea fattura dalle registrazioni.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filtra per data fattura</target>

View File

@@ -696,10 +696,6 @@
<source>invoice.title</source>
<target>請求書</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>タイムシートに記録したエントリから請求書を作成します。</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>出力</target>

View File

@@ -712,10 +712,6 @@
<source>invoice.title</source>
<target state="translated">인보이스</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target state="translated">기록된 시간기록표를 바탕으로 인보이스 생성</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target state="translated">인보이스에 표시되는 정보의 미리보기</target>

View File

@@ -752,10 +752,6 @@
<source>invoice.title</source>
<target>Facturen maken</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Zet uw prestaties om in een factuur</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filter op factuurdatum</target>

View File

@@ -804,10 +804,6 @@
<source>invoice.title</source>
<target>Faktury</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Utwórz fakturę z twoich wpisów do ewidencji.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filtruj po dacie faktury</target>

View File

@@ -696,10 +696,6 @@
<source>invoice.title</source>
<target>Faturas</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Criar faturas a partir de suas entradas de quadro de horários registradas.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Imprimir</target>

View File

@@ -584,10 +584,6 @@
<source>invoice.title</source>
<target>Бланки счета</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Создайте счета для записанных периодов времени.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Печать</target>

View File

@@ -837,10 +837,6 @@
<source>invoice.title</source>
<target>Faktúry</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Vytvorte faktúry z vašich položiek časového rozvrhu.</target>
</trans-unit>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filtrovať dáta faktúr</target>

Some files were not shown because too many files have changed in this diff Show More