new API endpoint to save invoice meta-fields (#5916)

This commit is contained in:
Kevin Papst
2026-04-25 18:14:10 +02:00
committed by GitHub
parent 087350ab72
commit 3eebb02ad3
39 changed files with 1427 additions and 659 deletions

View File

@@ -10,6 +10,8 @@
namespace App\API;
use App\Entity\Invoice;
use App\Entity\InvoiceMeta;
use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceArchiveQuery;
@@ -17,9 +19,11 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
@@ -93,8 +97,7 @@ final class InvoiceController extends BaseApiController
/**
* Fetch invoice
*/
#[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
#[IsGranted('view_invoice', 'invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response
@@ -104,4 +107,48 @@ final class InvoiceController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Update invoice custom-fields
*/
#[IsGranted('edit_invoice', 'invoice')]
#[OA\Response(response: 200, description: 'Sets the value of configured custom-fields. You cannot create unknown custom-fields.', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[OA\Parameter(name: 'id', description: 'Invoice ID to set the custom-fields for', in: 'path', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: InvoiceMeta::class))))]
#[Route(path: '/{id}/custom-fields', requirements: ['id' => '\d+'], methods: ['PATCH'])]
public function updateMetaFields(Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
{
$invoiceService->loadMetaFields($invoice);
$dirty = false;
foreach ($request->request->all() as $preference) {
// why is this not handled by FosRestBundle ?
if (!\is_array($preference)) {
throw new BadRequestHttpException('Invalid request, array expected');
}
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
}
$name = $preference['name'];
$value = $preference['value'];
if (null === ($meta = $invoice->getMetaField($name))) {
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
}
$meta->setValue($value);
$dirty = true;
}
if ($dirty) {
$invoiceService->saveInvoice($invoice);
}
$view = new View($invoice, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -232,10 +232,10 @@ final class UserController extends BaseApiController
* Update user preferences
*/
#[IsGranted('edit', 'profile')]
#[OA\Response(response: 200, description: 'Sets the value of a consifgured preference. You cannot create unknown preferences: if the given name is not configured, an exception will be raised.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
#[OA\Response(response: 200, description: 'Sets the value of a configured preference. You cannot create unknown or updated disabled preferences.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[OA\Parameter(name: 'id', description: 'User ID to set the custom-field value for', in: 'path', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
#[Route(path: '/{id}/preferences', requirements: ['id' => '\d+'], methods: ['PATCH'])]
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
{
$event = new PrepareUserEvent($profile, false);

View File

@@ -13,7 +13,7 @@ use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Invoice\ServiceInvoice;
use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository;
@@ -41,7 +41,7 @@ final class InvoiceCreateCommand extends Command
private bool $previewUniqueFile = false;
public function __construct(
private readonly ServiceInvoice $serviceInvoice,
private readonly InvoiceService $InvoiceService,
private readonly CustomerRepository $customerRepository,
private readonly ProjectRepository $projectRepository,
private readonly InvoiceTemplateRepository $invoiceTemplateRepository,
@@ -283,16 +283,16 @@ final class InvoiceCreateCommand extends Command
$query->setTemplate($tpl);
try {
$model = $this->serviceInvoice->createModel($query);
$model = $this->InvoiceService->createModel($query);
// this check makes sure to only fetch invoices with records
if (\count($model->getEntries()) === 0) {
continue;
}
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($model, $this->eventDispatcher));
$invoices[] = $this->saveInvoicePreview($this->InvoiceService->renderInvoice($model, $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
$invoices[] = $this->InvoiceService->createInvoice($model, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for project with: %s', $ex->getMessage()));
@@ -358,16 +358,16 @@ final class InvoiceCreateCommand extends Command
$query->setTemplate($tpl);
try {
$model = $this->serviceInvoice->createModel($query);
$model = $this->InvoiceService->createModel($query);
// this check makes sure to only fetch invoices with records
if (\count($model->getEntries()) === 0) {
continue;
}
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($model, $this->eventDispatcher));
$invoices[] = $this->saveInvoicePreview($this->InvoiceService->renderInvoice($model, $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
$invoices[] = $this->InvoiceService->createInvoice($model, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(\sprintf('Failed to create invoice for customer with: %s', $ex->getMessage()));
@@ -413,7 +413,7 @@ final class InvoiceCreateCommand extends Command
$table->setHeaders($columns);
foreach ($invoices as $invoice) {
$file = $this->serviceInvoice->getInvoiceFile($invoice);
$file = $this->InvoiceService->getInvoiceFile($invoice);
if (null === $file) {
$io->warning(
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId() ?? 'unknown', $invoice->getInvoiceFilename() ?? 'unknown')
@@ -456,7 +456,7 @@ final class InvoiceCreateCommand extends Command
*/
private function getActiveCustomers(InvoiceQuery $invoiceQuery): array
{
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$results = $this->InvoiceService->getInvoiceItems($invoiceQuery);
$customers = [];
@@ -473,7 +473,7 @@ final class InvoiceCreateCommand extends Command
*/
private function getActiveProjects(InvoiceQuery $invoiceQuery): array
{
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$results = $this->InvoiceService->getInvoiceItems($invoiceQuery);
$projects = [];

View File

@@ -15,7 +15,6 @@ use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent;
use App\Event\InvoiceTemplateMetaDefinitionEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -28,7 +27,7 @@ use App\Form\Toolbar\InvoiceArchiveForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Form\Type\DatePickerType;
use App\Form\Type\InvoiceTemplateType;
use App\Invoice\ServiceInvoice;
use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
@@ -40,7 +39,6 @@ use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -67,7 +65,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
public function indexAction(Request $request, InvoiceService $service, InvoiceTemplateRepository $templateRepository): Response
{
if (!$templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) {
@@ -136,7 +134,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
#[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')]
public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response
public function previewAction(Customer $customer, string $token, Request $request, InvoiceService $service): Response
{
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
$this->flashError('action.csrf.error');
@@ -174,7 +172,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
#[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')]
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, InvoiceService $service): Response
{
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error');
@@ -216,9 +214,8 @@ final class InvoiceController extends AbstractController
}
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
#[IsGranted('edit_invoice', 'invoice')]
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, InvoiceService $InvoiceService): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
@@ -232,7 +229,7 @@ final class InvoiceController extends AbstractController
$invoice->setIsPaid();
}
$form = $this->createInvoiceEditForm($invoice);
$form = $this->createInvoiceEditForm($invoice, $InvoiceService);
$form->handleRequest($request);
return $this->render('invoice/invoice_edit.html.twig', [
@@ -243,7 +240,7 @@ final class InvoiceController extends AbstractController
}
try {
$service->changeInvoiceStatus($invoice, $status);
$InvoiceService->changeInvoiceStatus($invoice, $status);
$this->flashSuccess('action.update.success');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
@@ -253,16 +250,15 @@ final class InvoiceController extends AbstractController
}
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
#[IsGranted('edit_invoice', 'invoice')]
public function editAction(Invoice $invoice, Request $request, InvoiceService $InvoiceService): Response
{
$form = $this->createInvoiceEditForm($invoice);
$form = $this->createInvoiceEditForm($invoice, $InvoiceService);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$invoiceRepository->saveInvoice($invoice);
$InvoiceService->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list');
@@ -279,9 +275,8 @@ final class InvoiceController extends AbstractController
}
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
#[IsGranted('delete_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
#[IsGranted('delete_invoice', 'invoice')]
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceService $service): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
@@ -302,9 +297,8 @@ final class InvoiceController extends AbstractController
}
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
#[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
#[IsGranted('view_invoice', 'invoice')]
public function downloadAction(Invoice $invoice, InvoiceService $service): Response
{
$file = $service->getInvoiceFile($invoice);
@@ -378,7 +372,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[IsGranted('view_invoice')]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository): Response
{
$query = new InvoiceArchiveQuery();
$query->setCurrentUser($this->getUser());
@@ -445,7 +439,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
#[IsGranted('upload_invoice_template')]
public function downloadDocument(string $document, ServiceInvoice $service): Response
public function downloadDocument(string $document, InvoiceService $service): Response
{
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
$this->dispatcher->dispatch($event);
@@ -461,7 +455,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, InvoiceService $service, InvoiceTemplateRepository $templateRepository): Response
{
$dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir;
@@ -794,10 +788,9 @@ final class InvoiceController extends AbstractController
return $event->getFields();
}
private function createInvoiceEditForm(Invoice $invoice): FormInterface
private function createInvoiceEditForm(Invoice $invoice, InvoiceService $InvoiceService): FormInterface
{
$event = new InvoiceMetaDefinitionEvent($invoice);
$this->dispatcher->dispatch($event);
$InvoiceService->loadMetaFields($invoice);
return $this->createForm(InvoiceEditForm::class, $invoice, [
'action' => $this->generateUrl('admin_invoice_edit', ['id' => $invoice->getId()]),

View File

@@ -11,9 +11,9 @@ namespace App\DependencyInjection\Compiler;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\InvoiceService;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
@@ -25,7 +25,7 @@ final class InvoiceServiceCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$definition = $container->findDefinition(ServiceInvoice::class);
$definition = $container->findDefinition(InvoiceService::class);
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
foreach ($taggedRenderer as $id => $tags) {

View File

@@ -13,16 +13,18 @@ use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta-fields to invoices
* This event can be used, to dynamically add meta-fields to invoices.
*
* Do not use directly, call InvoiceService::loadMetaFields() instead.
*/
final class InvoiceMetaDefinitionEvent extends Event
{
public function __construct(private Invoice $entity)
public function __construct(private readonly Invoice $invoice)
{
}
public function getEntity(): Invoice
{
return $this->entity;
return $this->invoice;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Triggered right after an invoice was saved.
* This event is triggered for new and updated invoices.
*/
final class InvoiceUpdatePostEvent extends Event
{
public function __construct(private readonly Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Triggered right before an invoice is saved.
* This event is triggered for new and updated invoices.
*/
final class InvoiceUpdatePreEvent extends Event
{
public function __construct(private readonly Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -9,7 +9,7 @@
namespace App\Form\Type;
use App\Invoice\ServiceInvoice;
use App\Invoice\InvoiceService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class InvoiceCalculatorType extends AbstractType
{
public function __construct(private ServiceInvoice $service)
public function __construct(private InvoiceService $service)
{
}

View File

@@ -9,7 +9,7 @@
namespace App\Form\Type;
use App\Invoice\ServiceInvoice;
use App\Invoice\InvoiceService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class InvoiceNumberGeneratorType extends AbstractType
{
public function __construct(private ServiceInvoice $service)
public function __construct(private InvoiceService $service)
{
}

View File

@@ -9,7 +9,7 @@
namespace App\Form\Type;
use App\Invoice\ServiceInvoice;
use App\Invoice\InvoiceService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class InvoiceRendererType extends AbstractType
{
public function __construct(private ServiceInvoice $service)
public function __construct(private InvoiceService $service)
{
}

View File

@@ -0,0 +1,558 @@
<?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\LocaleService;
use App\Entity\ExportableItem;
use App\Entity\Invoice;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoiceDeleteEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Event\InvoiceUpdatePostEvent;
use App\Event\InvoiceUpdatePreEvent;
use App\Export\Base\DispositionInlineInterface;
use App\Model\InvoiceDocument;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceQuery;
use App\Utils\FileHelper;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Service to manage invoice dependencies.
* @final will be made final in 3.0
*/
class InvoiceService
{
/**
* @var array<int, CalculatorInterface>
*/
private array $calculator = [];
/**
* @var array<int, RendererInterface>
*/
private array $renderer = [];
/**
* @var array<int, NumberGeneratorInterface>
*/
private array $numberGenerator = [];
/**
* @var array<int, InvoiceItemRepositoryInterface>
*/
private array $invoiceItemRepositories = [];
public function __construct(
private readonly InvoiceDocumentRepository $documents,
private readonly FileHelper $fileHelper,
private readonly InvoiceRepository $invoiceRepository,
private readonly LocaleService $formatter,
private readonly InvoiceModelFactory $invoiceModelFactory,
private readonly EventDispatcherInterface $dispatcher
) {
}
public function addNumberGenerator(NumberGeneratorInterface $generator): InvoiceService
{
$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): InvoiceService
{
$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): InvoiceService
{
$this->renderer[] = $renderer;
return $this;
}
/**
* Returns an array of invoice renderer.
*
* @return RendererInterface[]
*/
public function getRenderer(): array
{
return $this->renderer;
}
/**
* @return array<int, InvoiceItemRepositoryInterface>
*/
public function getInvoiceItemRepositories(): array
{
return $this->invoiceItemRepositories;
}
public function addInvoiceItemRepository(InvoiceItemRepositoryInterface $invoiceItemRepository): InvoiceService
{
$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();
$disposition = $event->getResponse()->headers->get('Content-Disposition');
if ($disposition !== null) {
$parts = explode(';', $disposition);
foreach ($parts as $part) {
if (stripos($part, 'filename=') === false) {
continue;
}
$tmp = explode('filename=', $part);
if (\count($tmp) > 1) {
$filename = $tmp[1];
}
}
} else {
$disposition = $event->getResponse()->headers->get('Content-Type');
if ($disposition !== null) {
$parts = explode(';', $disposition);
$parts = explode('/', $parts[0]);
if (\count($parts) > 1) {
$filename .= '.' . $parts[1];
}
}
}
if (mb_strlen($filename) >= 150) {
throw new \Exception(\sprintf('Invoice filename "%s" is too long, max. 150 characters allowed', $filename));
}
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): void
{
switch ($status) {
case Invoice::STATUS_NEW:
$invoice->setIsNew();
break;
case Invoice::STATUS_PENDING:
$invoice->setIsPending();
break;
case Invoice::STATUS_PAID:
$invoice->setIsPaid();
break;
case Invoice::STATUS_CANCELED:
$invoice->setIsCanceled();
break;
default:
throw new \InvalidArgumentException('Unknown invoice status');
}
$this->saveInvoice($invoice);
}
/**
* @return ExportableItem[]
*/
public function getInvoiceItems(InvoiceQuery $query): array
{
$items = [];
foreach ($this->getInvoiceItemRepositories() as $repository) {
$items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
}
return $items;
}
/**
* @param ExportableItem[] $entries
*/
private function markEntriesAsExported(array $entries): void
{
foreach ($this->getInvoiceItemRepositories() as $repository) {
$repository->setExported($entries);
}
}
public function renderInvoice(InvoiceModel $model, EventDispatcherInterface $dispatcher, bool $dispositionInline = false): Response
{
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Please adjust your invoice template, the renderer is invalid: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
if ($renderer instanceof DispositionInlineInterface) {
$renderer->setDispositionInline($dispositionInline);
}
$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())
);
}
/**
* @throws \Exception
*/
public function createInvoice(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)) {
$preEvent = new InvoicePreRenderEvent($model, $document, $renderer);
$dispatcher->dispatch($preEvent);
if ($preEvent->isPropagationStopped()) {
continue;
}
if ($this->invoiceRepository->hasInvoice($model->getInvoiceNumber())) {
throw new DuplicateInvoiceNumberException($model->getInvoiceNumber());
}
$response = $renderer->render($document, $model);
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
$dispatcher->dispatch($event);
$invoiceFilename = $this->saveGeneratedInvoice($event);
$invoice = new Invoice();
$invoice->setModel($model);
$invoice->setFilename($invoiceFilename);
if (!$invoice->getCustomer()->hasInvoiceTemplate()) {
$invoice->getCustomer()->setInvoiceTemplate($model->getTemplate());
}
$this->saveInvoice($invoice);
$this->markEntriesAsExported($model->getEntries());
$dispatcher->dispatch(new InvoiceCreatedEvent($invoice, $model));
return $invoice;
}
}
throw new \Exception(
\sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
}
public function deleteInvoice(Invoice $invoice, EventDispatcherInterface $dispatcher): void
{
$invoiceDirectory = $this->getInvoicesDirectory();
if (is_file($invoiceDirectory . $invoice->getInvoiceFilename())) {
$this->fileHelper->removeFile($invoiceDirectory . $invoice->getInvoiceFilename());
}
$event = new InvoiceDeleteEvent($invoice);
$dispatcher->dispatch($event);
$this->invoiceRepository->deleteInvoice($invoice);
}
/**
* @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
{
$customer = $query->getCustomer();
if ($customer === null) {
throw new \Exception('Cannot create invoice model without customer');
}
$template = $query->getTemplate();
if ($query->isAllowTemplateOverwrite() && $customer->hasInvoiceTemplate()) {
$template = $customer->getInvoiceTemplate();
}
if (null === $template) {
throw new \Exception('Cannot create invoice model without template');
}
$formatter = new DefaultInvoiceFormatter($this->formatter, $template->getLanguage());
$model = $this->invoiceModelFactory->createModel(
$formatter,
$customer,
$template,
$query
);
if ($query->getInvoiceDate() !== null) {
$model->setInvoiceDate($query->getInvoiceDate());
}
if (null !== $query->getCurrentUser()) {
$model->setUser($query->getCurrentUser());
}
$generator = $this->getNumberGeneratorByName($template->getNumberGenerator());
if (null === $generator) {
throw new \Exception('Please adjust your invoice template, the number generator is invalid: ' . $template->getNumberGenerator());
}
$calculator = $this->getCalculatorByName($template->getCalculator());
if (null === $calculator) {
throw new \Exception('Please adjust your invoice template, the sum calculator is invalid: ' . $template->getCalculator());
}
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
return $model;
}
private function prepareModelQueryDates(InvoiceModel $model): void
{
$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);
}
}
/**
* @return InvoiceModel[]
* @throws \Exception
*/
public function createModels(InvoiceQuery $query): array
{
$models = [];
$customerEntries = [];
$items = $this->getInvoiceItems($query);
foreach ($items as $entry) {
$customer = $entry->getProject()->getCustomer();
if ($customer === null || !$customer->isVisible()) { // generating invoices for hidden customers does not yet work
continue;
}
$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): int {
$nameA = $a['customer']->getName();
$nameB = $b['customer']->getName();
if ($nameA === null && $nameB === null) {
return 0;
}
if ($nameA === null) {
return 1;
}
if ($nameB === null) {
return -1;
}
return strcmp($nameA, $nameB);
});
foreach ($customerEntries as $settings) {
$customerQuery = clone $query;
$customerQuery->setCustomers([$settings['customer']]);
$model = $this->createModelWithoutEntries($customerQuery);
$model->addEntries($settings['entries']);
$this->prepareModelQueryDates($model);
$models[] = $model;
}
return $models;
}
public function saveInvoice(Invoice $invoice): void
{
$this->dispatcher->dispatch(new InvoiceUpdatePreEvent($invoice));
$this->invoiceRepository->saveInvoice($invoice);
$this->dispatcher->dispatch(new InvoiceUpdatePostEvent($invoice));
}
public function loadMetaFields(Invoice $invoice): void
{
$this->dispatcher->dispatch(new InvoiceMetaDefinitionEvent($invoice));
}
}

View File

@@ -9,531 +9,9 @@
namespace App\Invoice;
use App\Configuration\LocaleService;
use App\Entity\ExportableItem;
use App\Entity\Invoice;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoiceDeleteEvent;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Export\Base\DispositionInlineInterface;
use App\Model\InvoiceDocument;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceQuery;
use App\Utils\FileHelper;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Service to manage invoice dependencies.
* @deprecated since 2.56 use InvoiceService instead
*/
final class ServiceInvoice
class ServiceInvoice extends InvoiceService // @phpstan-ignore class.extendsFinalByPhpDoc
{
/**
* @var CalculatorInterface[]
*/
private array $calculator = [];
/**
* @var RendererInterface[]
*/
private array $renderer = [];
/**
* @var NumberGeneratorInterface[]
*/
private array $numberGenerator = [];
/**
* @var array InvoiceItemRepositoryInterface[]
*/
private array $invoiceItemRepositories = [];
public function __construct(
private readonly InvoiceDocumentRepository $documents,
private readonly FileHelper $fileHelper,
private readonly InvoiceRepository $invoiceRepository,
private readonly LocaleService $formatter,
private readonly InvoiceModelFactory $invoiceModelFactory
) {
}
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;
}
$tmp = explode('filename=', $part);
if (\count($tmp) > 1) {
$filename = $tmp[1];
}
}
} else {
$disposition = $event->getResponse()->headers->get('Content-Type');
$parts = explode(';', $disposition);
$parts = explode('/', $parts[0]);
if (\count($parts) > 1) {
$filename .= '.' . $parts[1];
}
}
if (mb_strlen($filename) >= 150) {
throw new \Exception(\sprintf('Invoice filename "%s" is too long, max. 150 characters allowed', $filename));
}
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): void
{
switch ($status) {
case Invoice::STATUS_NEW:
$invoice->setIsNew();
break;
case Invoice::STATUS_PENDING:
$invoice->setIsPending();
break;
case Invoice::STATUS_PAID:
$invoice->setIsPaid();
break;
case Invoice::STATUS_CANCELED:
$invoice->setIsCanceled();
break;
default:
throw new \InvalidArgumentException('Unknown invoice status');
}
$this->invoiceRepository->saveInvoice($invoice);
}
/**
* @return ExportableItem[]
*/
public function getInvoiceItems(InvoiceQuery $query): array
{
$items = [];
foreach ($this->getInvoiceItemRepositories() as $repository) {
$items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
}
return $items;
}
/**
* @param ExportableItem[] $entries
*/
private function markEntriesAsExported(array $entries): void
{
foreach ($this->getInvoiceItemRepositories() as $repository) {
$repository->setExported($entries);
}
}
public function renderInvoice(InvoiceModel $model, EventDispatcherInterface $dispatcher, bool $dispositionInline = false): Response
{
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Please adjust your invoice template, the renderer is invalid: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
if ($renderer instanceof DispositionInlineInterface) {
$renderer->setDispositionInline($dispositionInline);
}
$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())
);
}
/**
* @throws \Exception
*/
public function createInvoice(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)) {
$preEvent = new InvoicePreRenderEvent($model, $document, $renderer);
$dispatcher->dispatch($preEvent);
if ($preEvent->isPropagationStopped()) {
continue;
}
if ($this->invoiceRepository->hasInvoice($model->getInvoiceNumber())) {
throw new DuplicateInvoiceNumberException($model->getInvoiceNumber());
}
$response = $renderer->render($document, $model);
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
$dispatcher->dispatch($event);
$invoiceFilename = $this->saveGeneratedInvoice($event);
$invoice = new Invoice();
$invoice->setModel($model);
$invoice->setFilename($invoiceFilename);
if (!$invoice->getCustomer()->hasInvoiceTemplate()) {
$invoice->getCustomer()->setInvoiceTemplate($model->getTemplate());
}
$this->invoiceRepository->saveInvoice($invoice);
$this->markEntriesAsExported($model->getEntries());
$dispatcher->dispatch(new InvoiceCreatedEvent($invoice, $model));
return $invoice;
}
}
throw new \Exception(
\sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
}
public function deleteInvoice(Invoice $invoice, EventDispatcherInterface $dispatcher): void
{
$invoiceDirectory = $this->getInvoicesDirectory();
if (is_file($invoiceDirectory . $invoice->getInvoiceFilename())) {
$this->fileHelper->removeFile($invoiceDirectory . $invoice->getInvoiceFilename());
}
$event = new InvoiceDeleteEvent($invoice);
$dispatcher->dispatch($event);
$this->invoiceRepository->deleteInvoice($invoice);
}
/**
* @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
{
$customer = $query->getCustomer();
if ($customer === null) {
throw new \Exception('Cannot create invoice model without customer');
}
$template = $query->getTemplate();
if ($query->isAllowTemplateOverwrite() && $customer->hasInvoiceTemplate()) {
$template = $customer->getInvoiceTemplate();
}
if (null === $template) {
throw new \Exception('Cannot create invoice model without template');
}
$formatter = new DefaultInvoiceFormatter($this->formatter, $template->getLanguage());
$model = $this->invoiceModelFactory->createModel(
$formatter,
$customer,
$template,
$query
);
if ($query->getInvoiceDate() !== null) {
$model->setInvoiceDate($query->getInvoiceDate());
}
if (null !== $query->getCurrentUser()) {
$model->setUser($query->getCurrentUser());
}
$generator = $this->getNumberGeneratorByName($template->getNumberGenerator());
if (null === $generator) {
throw new \Exception('Please adjust your invoice template, the number generator is invalid: ' . $template->getNumberGenerator());
}
$calculator = $this->getCalculatorByName($template->getCalculator());
if (null === $calculator) {
throw new \Exception('Please adjust your invoice template, the sum calculator is invalid: ' . $template->getCalculator());
}
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
return $model;
}
private function prepareModelQueryDates(InvoiceModel $model): void
{
$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);
}
}
/**
* @return InvoiceModel[]
* @throws \Exception
*/
public function createModels(InvoiceQuery $query): array
{
$models = [];
$customerEntries = [];
$items = $this->getInvoiceItems($query);
foreach ($items as $entry) {
$customer = $entry->getProject()->getCustomer();
if ($customer === null || !$customer->isVisible()) { // generating invoices for hidden customers does not yet work
continue;
}
$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): int {
$nameA = $a['customer']->getName();
$nameB = $b['customer']->getName();
if ($nameA === null && $nameB === null) {
$result = 0;
} elseif ($nameA === null && $nameB !== null) {
$result = 1;
} elseif ($nameA !== null && $nameB === null) {
$result = -1;
} else {
$result = strcmp($nameA, $nameB);
}
return $result;
});
foreach ($customerEntries as $settings) {
$customerQuery = clone $query;
$customerQuery->setCustomers([$settings['customer']]);
$model = $this->createModelWithoutEntries($customerQuery);
$model->addEntries($settings['entries']);
$this->prepareModelQueryDates($model);
$models[] = $model;
}
return $models;
}
}

View File

@@ -9,8 +9,13 @@
namespace App\Security;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\User\PermissionService;
use Doctrine\Common\Collections\Collection;
final class RolePermissionManager
{
@@ -108,4 +113,49 @@ final class RolePermissionManager
{
return array_keys($this->permissionNames);
}
/**
* @param Collection<int, Team> $teams
*/
private function checkTeamAccess(Collection $teams, User $user): bool
{
if ($user->canSeeAllData()) {
return true;
}
if ($teams->count() === 0) {
return true;
}
foreach ($teams as $team) {
if ($user->isInTeam($team)) {
return true;
}
}
return false;
}
public function checkTeamAccessCustomer(Customer $customer, User $user): bool
{
return $this->checkTeamAccess($customer->getTeams(), $user);
}
public function checkTeamAccessProject(Project $project, User $user): bool
{
if ($project->getCustomer() !== null && !$this->checkTeamAccessCustomer($project->getCustomer(), $user)) {
return false;
}
return $this->checkTeamAccess($project->getTeams(), $user);
}
public function checkTeamAccessActivity(Activity $activity, User $user): bool
{
if ($activity->getProject() !== null && !$this->checkTeamAccessProject($activity->getProject(), $user)) {
return false;
}
return $this->checkTeamAccess($activity->getTeams(), $user);
}
}

View File

@@ -0,0 +1,96 @@
<?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\Voter;
use App\Entity\Invoice;
use App\Entity\User;
use App\Security\RolePermissionManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Invoice.
*
* @extends Voter<string, Invoice>
*/
final class InvoiceVoter extends Voter
{
/**
* support rules based on the given invoice
*/
private const ALLOWED_ATTRIBUTES = [
'view_invoice',
'edit_invoice',
'delete_invoice',
];
public function __construct(private readonly RolePermissionManager $rolePermissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, Invoice::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
return $subject instanceof Invoice && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if (!$subject instanceof Invoice) {
return false;
}
// every user needs to be able to view-invoices in order to perform any action
if (!$this->rolePermissionManager->hasRolePermission($user, 'view_invoice')) {
return false;
}
// this should never happen
if ($subject->getCustomer() === null) {
return false;
}
// check if the user is allowed to see the invoice customer
if (!$this->rolePermissionManager->checkTeamAccessCustomer($subject->getCustomer(), $user)) {
return false;
}
// all good here
if ($attribute === 'view_invoice') {
return true;
}
// there is no edit_invoice, so we only check if the user can create an invoice for the customer
if ($attribute === 'edit_invoice') {
return $this->rolePermissionManager->hasRolePermission($user, 'create_invoice');
}
if ($attribute === 'delete_invoice') {
return $this->rolePermissionManager->hasRolePermission($user, 'delete_invoice');
}
return false;
}
}