Files
kimai2/src/Invoice/ServiceInvoice.php
2021-04-19 18:32:21 +02:00

584 lines
17 KiB
PHP

<?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 App\Configuration\LanguageFormattings;
use App\Constants;
use App\Entity\Invoice;
use App\Entity\InvoiceDocument;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\FileHelper;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Service to manage invoice dependencies.
*/
final class ServiceInvoice
{
/**
* @var CalculatorInterface[]
*/
private $calculator = [];
/**
* @var RendererInterface[]
*/
private $renderer = [];
/**
* @var NumberGeneratorInterface[]
*/
private $numberGenerator = [];
/**
* @var array InvoiceItemRepositoryInterface[]
*/
private $invoiceItemRepositories = [];
/**
* @var InvoiceDocumentRepository
*/
private $documents;
/**
* @var FileHelper
*/
private $fileHelper;
/**
* @var LanguageFormattings
*/
private $formatter;
/**
* @var InvoiceRepository
*/
private $invoiceRepository;
public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper, InvoiceRepository $invoiceRepository, LanguageFormattings $formatter)
{
$this->documents = $repository;
$this->fileHelper = $fileHelper;
$this->invoiceRepository = $invoiceRepository;
$this->formatter = $formatter;
}
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
{
$this->numberGenerator[] = $generator;
return $this;
}
/**
* @return NumberGeneratorInterface[]
*/
public function getNumberGenerator(): array
{
return $this->numberGenerator;
}
public function getNumberGeneratorByName(string $name): ?NumberGeneratorInterface
{
foreach ($this->getNumberGenerator() as $generator) {
if ($generator->getId() === $name) {
// several models can co-exist at the same time and NumberGeneratorInterface works
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
return clone $generator;
}
}
return null;
}
public function addCalculator(CalculatorInterface $calculator): ServiceInvoice
{
$this->calculator[] = $calculator;
return $this;
}
/**
* @return CalculatorInterface[]
*/
public function getCalculator(): array
{
return $this->calculator;
}
public function getCalculatorByName(string $name): ?CalculatorInterface
{
foreach ($this->getCalculator() as $calculator) {
if ($calculator->getId() === $name) {
// several models can co-exist at the same time and CalculatorInterface works
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
return clone $calculator;
}
}
return null;
}
public function getDocumentByName(string $name): ?InvoiceDocument
{
return $this->documents->findByName($name);
}
/**
* Returns an array of invoice renderer, which will consist of a unique name and a controller action.
*
* @param bool $customOnly
* @return InvoiceDocument[]
*/
public function getDocuments(bool $customOnly = false): array
{
if ($customOnly) {
return $this->documents->findCustom();
}
return $this->documents->findAll();
}
public function addRenderer(RendererInterface $renderer): ServiceInvoice
{
$this->renderer[] = $renderer;
return $this;
}
/**
* Returns an array of invoice renderer.
*
* @return RendererInterface[]
*/
public function getRenderer(): array
{
return $this->renderer;
}
/**
* @return InvoiceItemRepositoryInterface[]
*/
public function getInvoiceItemRepositories(): array
{
return $this->invoiceItemRepositories;
}
public function addInvoiceItemRepository(InvoiceItemRepositoryInterface $invoiceItemRepository): ServiceInvoice
{
$this->invoiceItemRepositories[] = $invoiceItemRepository;
return $this;
}
private function getInvoicesDirectory(): string
{
return $this->fileHelper->getDataDirectory('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;
}
public function changeInvoiceStatus(Invoice $invoice, string $status)
{
if (!\in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
throw new \InvalidArgumentException('Unknown invoice status');
}
switch ($status) {
case Invoice::STATUS_NEW:
$invoice->setIsNew();
break;
case Invoice::STATUS_PENDING:
$invoice->setIsPending();
break;
case Invoice::STATUS_PAID:
$invoice->setIsPaid();
break;
}
$this->invoiceRepository->saveInvoice($invoice);
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
*/
public function findInvoiceItems(InvoiceQuery $query): array
{
@trigger_error('Using findInvoiceItems() is deprecated since 1.14 and will be removed with 2.0', E_USER_DEPRECATED);
// customer needs to be defined, as we need the currency for the invoice
if (!$query->hasCustomers()) {
return [];
}
return $this->getInvoiceItems($query);
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
*/
public function getInvoiceItems(InvoiceQuery $query): array
{
$items = [];
foreach ($this->getInvoiceItemRepositories() as $repository) {
$items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
}
return $items;
}
private function getDateTimeFactory(InvoiceQuery $query): DateTimeFactory
{
$timezone = date_default_timezone_get();
$sunday = false;
if (null !== ($user = $query->getCurrentUser())) {
$timezone = $user->getTimezone();
$sunday = $user->isFirstDayOfWeekSunday();
}
return new DateTimeFactory(new \DateTimeZone($timezone), $sunday);
}
/**
* @param InvoiceItemInterface[] $entries
*/
private function markEntriesAsExported(array $entries)
{
foreach ($this->getInvoiceItemRepositories() as $repository) {
$repository->setExported($entries);
}
}
public function renderInvoiceWithModel(InvoiceModel $model, EventDispatcherInterface $dispatcher): Response
{
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model);
$dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
return $response;
}
}
throw new \Exception(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
}
public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response
{
$model = $this->createModel($query);
return $this->renderInvoiceWithModel($model, $dispatcher);
}
/**
* @param InvoiceModel $model
* @param EventDispatcherInterface $dispatcher
* @return Invoice
* @throws \Exception
*/
public function createInvoiceFromModel(InvoiceModel $model, EventDispatcherInterface $dispatcher): Invoice
{
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model);
if ($model->getQuery()->isMarkAsExported()) {
$this->markEntriesAsExported($model->getEntries());
}
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
$dispatcher->dispatch($event);
$invoiceFilename = $this->saveGeneratedInvoice($event);
$invoice = new Invoice();
$invoice->setModel($model);
$invoice->setFilename($invoiceFilename);
$this->invoiceRepository->saveInvoice($invoice);
$dispatcher->dispatch(new InvoiceCreatedEvent($invoice));
return $invoice;
}
}
throw new \Exception(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
}
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice[]
* @throws \Exception
*/
public function createInvoices(InvoiceQuery $query, EventDispatcherInterface $dispatcher): array
{
$invoices = [];
$models = $this->createModels($query);
foreach ($models as $model) {
$invoices[] = $this->createInvoiceFromModel($model, $dispatcher);
}
return $invoices;
}
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice
* @throws \Exception
*/
public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice
{
$model = $this->createModel($query);
return $this->createInvoiceFromModel($model, $dispatcher);
}
public function deleteInvoice(Invoice $invoice)
{
$invoiceDirectory = $this->getInvoicesDirectory();
if (is_file($invoiceDirectory . $invoice->getInvoiceFilename())) {
$this->fileHelper->removeFile($invoiceDirectory . $invoice->getInvoiceFilename());
}
$this->invoiceRepository->deleteInvoice($invoice);
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel
* @throws \Exception
*/
public function createModel(InvoiceQuery $query): InvoiceModel
{
$model = $this->createModelWithoutEntries($query);
$model->addEntries($this->getInvoiceItems($query));
$this->prepareModelQueryDates($model);
return $model;
}
private function createModelWithoutEntries(InvoiceQuery $query): InvoiceModel
{
$template = $query->getTemplate();
if (!$query->hasCustomers()) {
throw new \Exception('Cannot create invoice model without customer');
}
if (null === $template) {
throw new \Exception('Cannot create invoice model without template');
}
if (null === $template->getLanguage()) {
$template->setLanguage(Constants::DEFAULT_LOCALE);
@trigger_error('Using invoice templates without a language is is deprecated and trigger and will throw an exception with 2.0', E_USER_DEPRECATED);
}
$model = new InvoiceModel(new DefaultInvoiceFormatter($this->formatter, $template->getLanguage()));
$model
->setTemplate($template)
->setInvoiceDate($this->getDateTimeFactory($query)->createDateTime())
->setQuery($query)
;
if (null !== $query->getCurrentUser()) {
$model->setUser($query->getCurrentUser());
}
$model->setCustomer($query->getCustomers()[0]);
$generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) {
throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator());
}
$calculator = $this->getCalculatorByName($query->getTemplate()->getCalculator());
if (null === $calculator) {
throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator());
}
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
return $model;
}
private function prepareModelQueryDates(InvoiceModel $model)
{
$begin = $model->getQuery()->getBegin();
$end = $model->getQuery()->getEnd();
if ($begin !== null && $end !== null) {
return;
}
if (\count($model->getEntries()) === 0) {
return;
}
$tmpBegin = null;
$tmpEnd = null;
foreach ($model->getEntries() as $entry) {
if ($begin === null) {
if ($tmpBegin === null) {
$tmpBegin = $entry->getBegin();
} else {
$tmpBegin = min($entry->getBegin(), $tmpBegin);
}
}
if ($end === null) {
if ($tmpEnd === null) {
$tmpEnd = $entry->getEnd();
} else {
$tmpEnd = max($entry->getEnd(), $tmpEnd);
}
}
}
if ($begin === null && $tmpBegin !== null) {
$model->getQuery()->setBegin($tmpBegin);
}
if ($end === null && $tmpEnd !== null) {
$model->getQuery()->setEnd($tmpEnd);
}
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel[]
* @throws \Exception
*/
public function createModels(InvoiceQuery $query): array
{
$models = [];
$customerEntries = [];
$items = $this->getInvoiceItems($query);
foreach ($items as $entry) {
$customer = $entry->getProject()->getCustomer();
$id = $customer->getId();
if (!\array_key_exists($id, $customerEntries)) {
$customerEntries[$id] = [
'customer' => $customer,
'entries' => [],
];
}
$customerEntries[$id]['entries'][] = $entry;
}
if (empty($customerEntries)) {
return [];
}
uasort($customerEntries, function ($a, $b) {
return strcmp($a['customer']->getName(), $b['customer']->getName());
});
foreach ($customerEntries as $id => $settings) {
if (empty($settings['entries'])) {
continue;
}
$customerQuery = clone $query;
$customerQuery->setCustomers([$settings['customer']]);
$model = $this->createModelWithoutEntries($customerQuery);
$model->addEntries($settings['entries']);
$this->prepareModelQueryDates($model);
$models[] = $model;
}
return $models;
}
}