diff --git a/phpstan.neon b/phpstan.neon index 6f38fb76..1b2c9bbe 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2823,62 +2823,42 @@ parameters: - message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Cannot call method setBegin\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Cannot call method setEnd\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Cannot call method setInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, int\\|null given\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php - - - - message: "#^Parameter \\#1 \\$string1 of function strcmp expects string, string\\|null given\\.$#" - count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Parameter \\#2 \\$locale of class App\\\\Invoice\\\\DefaultInvoiceFormatter constructor expects string, string\\|null given\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php - - - - message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|null given\\.$#" - count: 2 - path: src/Invoice/ServiceInvoice.php - - - - message: "#^Parameter \\#2 \\$string2 of function strcmp expects string, string\\|null given\\.$#" - count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\ given\\.$#" count: 1 - path: src/Invoice/ServiceInvoice.php - - - - message: "#^Property App\\\\Invoice\\\\ServiceInvoice\\:\\:\\$invoiceItemRepositories type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Invoice/ServiceInvoice.php + path: src/Invoice/InvoiceService.php - message: "#^Generator expects value type Symfony\\\\Component\\\\HttpKernel\\\\Bundle\\\\BundleInterface, object given\\.$#" diff --git a/src/API/InvoiceController.php b/src/API/InvoiceController.php index a8b7830c..fb935833 100644 --- a/src/API/InvoiceController.php +++ b/src/API/InvoiceController.php @@ -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); + } } diff --git a/src/API/UserController.php b/src/API/UserController.php index f28164b4..378bd878 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -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); diff --git a/src/Command/InvoiceCreateCommand.php b/src/Command/InvoiceCreateCommand.php index 514a379f..0631a9b7 100644 --- a/src/Command/InvoiceCreateCommand.php +++ b/src/Command/InvoiceCreateCommand.php @@ -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 = []; diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 12559b06..f8cd8f55 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -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()]), diff --git a/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php b/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php index b7f58f8d..7d462f6d 100644 --- a/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php +++ b/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php @@ -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) { diff --git a/src/Event/InvoiceMetaDefinitionEvent.php b/src/Event/InvoiceMetaDefinitionEvent.php index ae8c5a92..69898dcd 100644 --- a/src/Event/InvoiceMetaDefinitionEvent.php +++ b/src/Event/InvoiceMetaDefinitionEvent.php @@ -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; } } diff --git a/src/Event/InvoiceUpdatePostEvent.php b/src/Event/InvoiceUpdatePostEvent.php new file mode 100644 index 00000000..3201b87d --- /dev/null +++ b/src/Event/InvoiceUpdatePostEvent.php @@ -0,0 +1,29 @@ +invoice; + } +} diff --git a/src/Event/InvoiceUpdatePreEvent.php b/src/Event/InvoiceUpdatePreEvent.php new file mode 100644 index 00000000..ec4c9cfd --- /dev/null +++ b/src/Event/InvoiceUpdatePreEvent.php @@ -0,0 +1,29 @@ +invoice; + } +} diff --git a/src/Form/Type/InvoiceCalculatorType.php b/src/Form/Type/InvoiceCalculatorType.php index 8296b31f..eda4ce43 100644 --- a/src/Form/Type/InvoiceCalculatorType.php +++ b/src/Form/Type/InvoiceCalculatorType.php @@ -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) { } diff --git a/src/Form/Type/InvoiceNumberGeneratorType.php b/src/Form/Type/InvoiceNumberGeneratorType.php index 84267013..a8f7c152 100644 --- a/src/Form/Type/InvoiceNumberGeneratorType.php +++ b/src/Form/Type/InvoiceNumberGeneratorType.php @@ -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) { } diff --git a/src/Form/Type/InvoiceRendererType.php b/src/Form/Type/InvoiceRendererType.php index 70913e68..f42bd340 100644 --- a/src/Form/Type/InvoiceRendererType.php +++ b/src/Form/Type/InvoiceRendererType.php @@ -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) { } diff --git a/src/Invoice/InvoiceService.php b/src/Invoice/InvoiceService.php new file mode 100644 index 00000000..2b418d70 --- /dev/null +++ b/src/Invoice/InvoiceService.php @@ -0,0 +1,558 @@ + + */ + private array $calculator = []; + /** + * @var array + */ + private array $renderer = []; + /** + * @var array + */ + private array $numberGenerator = []; + /** + * @var array + */ + 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 + */ + 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)); + } +} diff --git a/src/Invoice/ServiceInvoice.php b/src/Invoice/ServiceInvoice.php index 01a3dbf5..6ba12c13 100644 --- a/src/Invoice/ServiceInvoice.php +++ b/src/Invoice/ServiceInvoice.php @@ -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; - } } diff --git a/src/Security/RolePermissionManager.php b/src/Security/RolePermissionManager.php index 3aac4b8d..43c35208 100644 --- a/src/Security/RolePermissionManager.php +++ b/src/Security/RolePermissionManager.php @@ -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 $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); + } } diff --git a/src/Voter/InvoiceVoter.php b/src/Voter/InvoiceVoter.php new file mode 100644 index 00000000..be1bc368 --- /dev/null +++ b/src/Voter/InvoiceVoter.php @@ -0,0 +1,96 @@ + + */ +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; + } +} diff --git a/tests/API/APIControllerBaseTestCase.php b/tests/API/APIControllerBaseTestCase.php index 0d9d5a85..2d2229cf 100644 --- a/tests/API/APIControllerBaseTestCase.php +++ b/tests/API/APIControllerBaseTestCase.php @@ -136,7 +136,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase ]); } - protected function assertEntityNotFoundForPatch(string $role, string $url, array $data): void + protected function assertEntityNotFoundForPatch(HttpKernelBrowser|string $role, string $url, array $data): void { $this->assertExceptionForPatchAction($role, $url, $data, [ 'code' => Response::HTTP_NOT_FOUND, @@ -152,17 +152,17 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase ]); } - protected function assertExceptionForDeleteAction(string $role, string $url, array $data, array $expectedErrors): void + protected function assertExceptionForDeleteAction(HttpKernelBrowser|string $role, string $url, array $data, array $expectedErrors): void { $this->assertExceptionForRole($role, $url, 'DELETE', $data, $expectedErrors); } - protected function assertExceptionForPatchAction(string $role, string $url, array $data, array $expectedErrors): void + protected function assertExceptionForPatchAction(HttpKernelBrowser|string $role, string $url, array $data, array $expectedErrors): void { $this->assertExceptionForRole($role, $url, 'PATCH', $data, $expectedErrors); } - protected function assertExceptionForPostAction(string $role, string $url, array $data, array $expectedErrors): void + protected function assertExceptionForPostAction(HttpKernelBrowser|string $role, string $url, array $data, array $expectedErrors): void { $this->assertExceptionForRole($role, $url, 'POST', $data, $expectedErrors); } @@ -180,9 +180,9 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase self::assertEquals($expectedErrors, json_decode($response->getContent(), true)); } - protected function assertExceptionForRole(string $role, string $url, string $method, array $data, array $expectedErrors): void + protected function assertExceptionForRole(HttpKernelBrowser|string $role, string $url, string $method, array $data, array $expectedErrors): void { - $client = $this->getClientForAuthenticatedUser($role); + $client = ($role instanceof HttpKernelBrowser) ? $role : $this->getClientForAuthenticatedUser($role); $this->assertExceptionForMethod($client, $url, $method, $data, $expectedErrors); } @@ -321,7 +321,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'total' => 'float', 'vat' => 'float', 'overdue' => 'bool', - 'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], + 'metaFields' => ['result' => 'array', 'type' => 'InvoiceMeta'], ]; case 'PageActionItem': @@ -350,6 +350,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'value' => '@string', ]; + case 'InvoiceMeta': case 'CustomerMeta': case 'ProjectMeta': case 'ActivityMeta': @@ -630,7 +631,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'number' => '@string', 'color' => '@string', 'color-safe' => 'string', - 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45 + 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], // since 2.45 'comment' => '@string', ]; @@ -644,7 +645,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'number' => '@string', 'color' => '@string', 'color-safe' => 'string', - 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45 + 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], // since 2.45 'comment' => '@string', ]; @@ -659,7 +660,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'number' => '@string', 'color' => '@string', 'color-safe' => 'string', - 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], + 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], 'comment' => '@string', 'parentTitle' => '@string', 'teams' => ['result' => 'array', 'type' => 'Team'], @@ -676,7 +677,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'number' => '@string', 'color' => '@string', 'color-safe' => 'string', - 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], + 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], 'comment' => '@string', 'parentTitle' => '@string', 'teams' => ['result' => 'array', 'type' => 'Team'], diff --git a/tests/API/ApiDocControllerTest.php b/tests/API/ApiDocControllerTest.php index 84141121..336ce5bc 100644 --- a/tests/API/ApiDocControllerTest.php +++ b/tests/API/ApiDocControllerTest.php @@ -76,6 +76,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase '/api/export/{id}', '/api/invoices', '/api/invoices/{id}', + '/api/invoices/{id}/custom-fields', '/api/projects', '/api/projects/{id}', '/api/projects/{id}/meta', diff --git a/tests/API/InvoiceControllerTest.php b/tests/API/InvoiceControllerTest.php index bf1d8aff..e375a396 100644 --- a/tests/API/InvoiceControllerTest.php +++ b/tests/API/InvoiceControllerTest.php @@ -15,13 +15,17 @@ use App\Entity\Team; use App\Entity\User; use App\Repository\TeamRepository; use App\Tests\DataFixtures\InvoiceFixtures; +use App\Tests\Mocks\InvoiceTestMetaFieldSubscriberMock; use PHPUnit\Framework\Attributes\Group; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Response; #[Group('integration')] class InvoiceControllerTest extends APIControllerBaseTestCase { /** - * @return Invoice[] + * @param int<1, 999> $amount + * @return non-empty-array */ protected function importInvoiceFixtures(int $amount, ?array $status = null): array { @@ -120,6 +124,8 @@ class InvoiceControllerTest extends APIControllerBaseTestCase self::assertIsArray($result); self::assertApiResponseTypeStructure('Invoice', $result); + self::assertArrayHasKey('metaFields', $result); + self::assertCount(0, $result['metaFields']); } public function testNotFound(): void @@ -181,4 +187,95 @@ class InvoiceControllerTest extends APIControllerBaseTestCase $this->request($client, '/api/invoices', 'GET', $query); $this->assertApiResponseAccessDenied($client->getResponse()); } + + // ------------------------------------- [META FIELDS] ------------------------------------- + + public function testUpdateInvoiceMetaFieldsThrowsNotFound(): void + { + $this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/invoices/42/custom-fields', []); + } + + public function testUpdateInvoiceMetaFieldsThrowsExceptionOnWrongStructure(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $id = $this->importInvoiceFixtures(1)[0]->getId(); + + $this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', ['name' => 'X', 'value' => 'X'], [ + 'code' => Response::HTTP_BAD_REQUEST, + 'message' => 'Bad Request' + ]); + } + + public function testUpdateInvoiceMetaFieldsThrowsExceptionOnMissingName(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $id = $this->importInvoiceFixtures(1)[0]->getId(); + + $this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', [['value' => 'X']], [ + 'code' => Response::HTTP_BAD_REQUEST, + 'message' => 'Bad Request' + ]); + } + + public function testUpdateInvoiceMetaFieldsThrowsExceptionOnMissingValue(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $id = $this->importInvoiceFixtures(1)[0]->getId(); + + $this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', [['name' => 'X']], [ + 'code' => Response::HTTP_BAD_REQUEST, + 'message' => 'Bad Request' + ]); + } + + public function testUpdateInvoiceMetaFieldsThrowsExceptionOnMissingMetafield(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $id = $this->importInvoiceFixtures(1)[0]->getId(); + + $this->assertExceptionForPatchAction($client, '/api/invoices/' . $id . '/custom-fields', [['name' => 'X', 'value' => 'Y']], [ + 'code' => Response::HTTP_NOT_FOUND, + 'message' => 'Not Found' + ]); + } + + public function testUpdateInvoiceMetaFields(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $invoices = $this->importInvoiceFixtures(1); + $id = $invoices[0]->getId(); + /** @var EventDispatcher $dispatcher */ + $dispatcher = static::getContainer()->get('event_dispatcher'); + $dispatcher->addSubscriber(new InvoiceTestMetaFieldSubscriberMock()); + + $data = [ + [ + 'name' => 'metatestmock', + 'value' => 'another,testing,bar' + ], + [ + 'name' => 'foobar', + 'value' => 13081978 + ], + ]; + $this->request($client, '/api/invoices/' . $id . '/custom-fields', 'PATCH', [], (string) json_encode($data)); + + self::assertTrue($client->getResponse()->isSuccessful()); + $content = $client->getResponse()->getContent(); + self::assertIsString($content); + $result = json_decode($content, true); + + self::assertIsArray($result); + self::assertApiResponseTypeStructure('Invoice', $result); + self::assertArrayHasKey('metaFields', $result); + // only visible should be returned + self::assertCount(1, $result['metaFields']); + self::assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $result['metaFields'][0]); + + $em = $this->getEntityManager(); + /** @var Invoice $invoice */ + $invoice = $em->getRepository(Invoice::class)->find($id); + self::assertEquals('another,testing,bar', $invoice->getMetaField('metatestmock')?->getValue()); + self::assertEquals(13081978, $invoice->getMetaField('foobar')?->getValue()); + } } diff --git a/tests/Command/InvoiceCreateCommandTest.php b/tests/Command/InvoiceCreateCommandTest.php index d05e2c9e..9d037f1b 100644 --- a/tests/Command/InvoiceCreateCommandTest.php +++ b/tests/Command/InvoiceCreateCommandTest.php @@ -13,7 +13,7 @@ use App\Command\InvoiceCreateCommand; use App\DataFixtures\UserFixtures; use App\Entity\Customer; use App\Entity\Project; -use App\Invoice\ServiceInvoice; +use App\Invoice\InvoiceService; use App\Repository\CustomerRepository; use App\Repository\InvoiceTemplateRepository; use App\Repository\ProjectRepository; @@ -67,7 +67,7 @@ class InvoiceCreateCommandTest extends KernelTestCase $container = self::getContainer(); $this->application->add(new InvoiceCreateCommand( - $container->get(ServiceInvoice::class), // @phpstan-ignore argument.type + $container->get(InvoiceService::class), // @phpstan-ignore argument.type $container->get(CustomerRepository::class), // @phpstan-ignore argument.type $container->get(ProjectRepository::class), // @phpstan-ignore argument.type $container->get(InvoiceTemplateRepository::class), // @phpstan-ignore argument.type diff --git a/tests/DataFixtures/ActivityFixtures.php b/tests/DataFixtures/ActivityFixtures.php index 93ea23cc..bb8c7f50 100644 --- a/tests/DataFixtures/ActivityFixtures.php +++ b/tests/DataFixtures/ActivityFixtures.php @@ -16,6 +16,7 @@ use Faker\Factory; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ final class ActivityFixtures implements TestFixture { diff --git a/tests/DataFixtures/CustomerFixtures.php b/tests/DataFixtures/CustomerFixtures.php index 1572867c..5e00dd58 100644 --- a/tests/DataFixtures/CustomerFixtures.php +++ b/tests/DataFixtures/CustomerFixtures.php @@ -15,6 +15,7 @@ use Faker\Factory; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ final class CustomerFixtures implements TestFixture { diff --git a/tests/DataFixtures/ExportTemplateFixtures.php b/tests/DataFixtures/ExportTemplateFixtures.php index ccf40a1e..4a2d439b 100644 --- a/tests/DataFixtures/ExportTemplateFixtures.php +++ b/tests/DataFixtures/ExportTemplateFixtures.php @@ -12,6 +12,9 @@ namespace App\Tests\DataFixtures; use App\Entity\ExportTemplate; use Doctrine\Persistence\ObjectManager; +/** + * @implements TestFixture + */ final class ExportTemplateFixtures implements TestFixture { /** diff --git a/tests/DataFixtures/InvoiceFixtures.php b/tests/DataFixtures/InvoiceFixtures.php index e910b12f..eeecd266 100644 --- a/tests/DataFixtures/InvoiceFixtures.php +++ b/tests/DataFixtures/InvoiceFixtures.php @@ -15,6 +15,7 @@ use Faker\Factory; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ class InvoiceFixtures implements TestFixture { diff --git a/tests/DataFixtures/InvoiceTemplateFixtures.php b/tests/DataFixtures/InvoiceTemplateFixtures.php index bb768218..194fc1b9 100644 --- a/tests/DataFixtures/InvoiceTemplateFixtures.php +++ b/tests/DataFixtures/InvoiceTemplateFixtures.php @@ -16,6 +16,7 @@ use Faker\Factory; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ class InvoiceTemplateFixtures implements TestFixture { diff --git a/tests/DataFixtures/ProjectFixtures.php b/tests/DataFixtures/ProjectFixtures.php index f31d72fb..48e4768d 100644 --- a/tests/DataFixtures/ProjectFixtures.php +++ b/tests/DataFixtures/ProjectFixtures.php @@ -16,6 +16,7 @@ use Faker\Factory; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ final class ProjectFixtures implements TestFixture { diff --git a/tests/DataFixtures/TagFixtures.php b/tests/DataFixtures/TagFixtures.php index 326788c4..9037ca86 100644 --- a/tests/DataFixtures/TagFixtures.php +++ b/tests/DataFixtures/TagFixtures.php @@ -14,6 +14,7 @@ use Doctrine\Persistence\ObjectManager; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ final class TagFixtures implements TestFixture { diff --git a/tests/DataFixtures/TeamFixtures.php b/tests/DataFixtures/TeamFixtures.php index b1e4fe32..d6df6eaa 100644 --- a/tests/DataFixtures/TeamFixtures.php +++ b/tests/DataFixtures/TeamFixtures.php @@ -15,6 +15,7 @@ use Doctrine\Persistence\ObjectManager; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ final class TeamFixtures implements TestFixture { diff --git a/tests/DataFixtures/TestFixture.php b/tests/DataFixtures/TestFixture.php index f61dd197..3b2222d4 100644 --- a/tests/DataFixtures/TestFixture.php +++ b/tests/DataFixtures/TestFixture.php @@ -13,11 +13,14 @@ use Doctrine\Persistence\ObjectManager; /** * Defines the sample data to load in during controller tests. + * @template TEntity */ interface TestFixture { /** * Load data fixtures with the passed EntityManager and returns the created objects. + * + * @return non-empty-array */ public function load(ObjectManager $manager): array; } diff --git a/tests/DataFixtures/TimesheetFixtures.php b/tests/DataFixtures/TimesheetFixtures.php index cc665c87..8c8fa865 100644 --- a/tests/DataFixtures/TimesheetFixtures.php +++ b/tests/DataFixtures/TimesheetFixtures.php @@ -22,6 +22,7 @@ use Faker\Factory; /** * Defines the sample data to load in during controller tests. + * @implements TestFixture */ final class TimesheetFixtures implements TestFixture { diff --git a/tests/DependencyInjection/Compiler/InvoiceServiceCompilerPassTest.php b/tests/DependencyInjection/Compiler/InvoiceServiceCompilerPassTest.php index c8db4cc0..c9a4c1a1 100644 --- a/tests/DependencyInjection/Compiler/InvoiceServiceCompilerPassTest.php +++ b/tests/DependencyInjection/Compiler/InvoiceServiceCompilerPassTest.php @@ -15,12 +15,12 @@ use App\Invoice\Calculator\ShortInvoiceCalculator; use App\Invoice\Calculator\UserInvoiceCalculator; use App\Invoice\CalculatorInterface; use App\Invoice\InvoiceItemRepositoryInterface; +use App\Invoice\InvoiceService; use App\Invoice\NumberGenerator\ConfigurableNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGeneratorInterface; use App\Invoice\Renderer\DocxRenderer; use App\Invoice\RendererInterface; -use App\Invoice\ServiceInvoice; use App\Repository\TimesheetInvoiceItemRepository; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -34,8 +34,8 @@ class InvoiceServiceCompilerPassTest extends TestCase { $container = new ContainerBuilder(); - $definition = new Definition(ServiceInvoice::class); - $container->setDefinition(ServiceInvoice::class, $definition); + $definition = new Definition(InvoiceService::class); + $container->setDefinition(InvoiceService::class, $definition); $renderers = [DocxRenderer::class]; foreach ($renderers as $renderer) { @@ -66,7 +66,7 @@ class InvoiceServiceCompilerPassTest extends TestCase $sut = new InvoiceServiceCompilerPass(); $sut->process($container); - $definition = $container->findDefinition(ServiceInvoice::class); + $definition = $container->findDefinition(InvoiceService::class); $methods = $definition->getMethodCalls(); self::assertCount(7, $methods); diff --git a/tests/Event/InvoiceUpdatePostEventTest.php b/tests/Event/InvoiceUpdatePostEventTest.php new file mode 100644 index 00000000..06018d15 --- /dev/null +++ b/tests/Event/InvoiceUpdatePostEventTest.php @@ -0,0 +1,28 @@ +getInvoice()); + } +} diff --git a/tests/Event/InvoiceUpdatePreEventTest.php b/tests/Event/InvoiceUpdatePreEventTest.php new file mode 100644 index 00000000..52e912b1 --- /dev/null +++ b/tests/Event/InvoiceUpdatePreEventTest.php @@ -0,0 +1,28 @@ +getInvoice()); + } +} diff --git a/tests/Invoice/ServiceInvoiceTest.php b/tests/Invoice/InvoiceServiceTest.php similarity index 95% rename from tests/Invoice/ServiceInvoiceTest.php rename to tests/Invoice/InvoiceServiceTest.php index 5e41f68f..461068fb 100644 --- a/tests/Invoice/ServiceInvoiceTest.php +++ b/tests/Invoice/InvoiceServiceTest.php @@ -18,6 +18,7 @@ use App\Entity\Timesheet; use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\InvoiceItemRepositoryInterface; use App\Invoice\InvoiceModel; +use App\Invoice\InvoiceService; use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\Renderer\TwigRenderer; use App\Invoice\ServiceInvoice; @@ -29,12 +30,14 @@ use App\Tests\Mocks\InvoiceModelFactoryFactory; use App\Utils\FileHelper; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Twig\Environment; -#[CoversClass(ServiceInvoice::class)] -class ServiceInvoiceTest extends TestCase +#[CoversClass(InvoiceService::class)] +#[CoversClass(ServiceInvoice::class)] // @phpstan-ignore-line +class InvoiceServiceTest extends TestCase { - private function getSut(array $paths): ServiceInvoice + private function getSut(array $paths): InvoiceService { $languages = [ 'en' => LocaleService::DEFAULT_SETTINGS @@ -45,7 +48,14 @@ class ServiceInvoiceTest extends TestCase $repo = new InvoiceDocumentRepository($paths); $invoiceRepo = $this->createMock(InvoiceRepository::class); - return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')), $invoiceRepo, $formattings, (new InvoiceModelFactoryFactory($this))->create()); + return new InvoiceService( + $repo, + new FileHelper(realpath(__DIR__ . '/../../var/data/')), + $invoiceRepo, + $formattings, + (new InvoiceModelFactoryFactory($this))->create(), + $this->createMock(EventDispatcherInterface::class) + ); } public function testInvalidExceptionOnChangeState(): void diff --git a/tests/KernelTestTrait.php b/tests/KernelTestTrait.php index 5a612e8f..0a738fa5 100644 --- a/tests/KernelTestTrait.php +++ b/tests/KernelTestTrait.php @@ -32,6 +32,12 @@ trait KernelTestTrait return $em; } + /** + * @template TEntity + * @param TestFixture $fixture + * @return non-empty-array + * @throws \Exception + */ protected function importFixture(TestFixture $fixture): array { return $fixture->load($this->getEntityManager()); diff --git a/tests/Mocks/InvoiceTestMetaFieldSubscriberMock.php b/tests/Mocks/InvoiceTestMetaFieldSubscriberMock.php new file mode 100644 index 00000000..585d4c02 --- /dev/null +++ b/tests/Mocks/InvoiceTestMetaFieldSubscriberMock.php @@ -0,0 +1,45 @@ + ['loadMeta', 200], + ]; + } + + public function loadMeta(InvoiceMetaDefinitionEvent $event): void + { + $definition = (new InvoiceMeta()) + ->setName('metatestmock') + ->setType(TextType::class) + ->addConstraint(new Length(['max' => 200])) + ->setIsVisible(true); + + $event->getEntity()->setMetaField($definition); + + $definition = (new InvoiceMeta()) + ->setName('foobar') + ->setType(IntegerType::class) + ->setIsVisible(false); + + $event->getEntity()->setMetaField($definition); + } +} diff --git a/tests/Security/RolePermissionManagerTest.php b/tests/Security/RolePermissionManagerTest.php index af7be132..94635179 100644 --- a/tests/Security/RolePermissionManagerTest.php +++ b/tests/Security/RolePermissionManagerTest.php @@ -9,6 +9,10 @@ namespace App\Tests\Security; +use App\Entity\Activity; +use App\Entity\Customer; +use App\Entity\Project; +use App\Entity\Team; use App\Entity\User; use App\Repository\RolePermissionRepository; use App\Security\RolePermissionManager; @@ -115,4 +119,167 @@ class RolePermissionManagerTest extends TestCase self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'role_permissions')); self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'view_user')); } + + public function testCheckTeamAccessCustomerAllowsUsersWithGlobalAccess(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customer->addTeam(new Team('Support')); + + $user = new User(); + self::assertFalse($sut->checkTeamAccessCustomer($customer, $user)); + $user->initCanSeeAllData(true); + self::assertTrue($sut->checkTeamAccessCustomer($customer, $user)); + } + + public function testCheckTeamAccessCustomerAllowsAccessWithoutAssignedTeams(): void + { + $sut = $this->createSut(); + + self::assertTrue($sut->checkTeamAccessCustomer(new Customer('Acme'), new User())); + } + + public function testCheckTeamAccessCustomerRequiresMembershipForAssignedTeams(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $team = new Team('Support'); + $customer->addTeam($team); + + $user = new User(); + self::assertFalse($sut->checkTeamAccessCustomer($customer, new User())); + + self::assertFalse($sut->checkTeamAccessCustomer($customer, $user)); + $team->addUser($user); + self::assertTrue($sut->checkTeamAccessCustomer($customer, $user)); + } + + public function testCheckTeamAccessProjectDeniesAccessIfCustomerIsDenied(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customerTeam = new Team('Customer team'); + $customer->addTeam($customerTeam); + + $project = new Project(); + $project->setCustomer($customer); + $projectTeam = new Team('Project team'); + $project->addTeam($projectTeam); + + $user = new User(); + $projectTeam->addUser($user); + + self::assertFalse($sut->checkTeamAccessProject($project, $user)); + } + + public function testCheckTeamAccessProjectAllowsUsersWithGlobalAccess(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customer->addTeam(new Team('Customer team')); + + $project = new Project(); + $project->setCustomer($customer); + $project->addTeam(new Team('Project team')); + + $user = new User(); + self::assertFalse($sut->checkTeamAccessProject($project, $user)); + $user->initCanSeeAllData(true); + self::assertTrue($sut->checkTeamAccessProject($project, $user)); + } + + public function testCheckTeamAccessProjectAllowsMatchingProjectTeamAfterCustomerAccess(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customerTeam = new Team('Customer team'); + $customer->addTeam($customerTeam); + + $project = new Project(); + $project->setCustomer($customer); + $projectTeam = new Team('Project team'); + $project->addTeam($projectTeam); + + $user = new User(); + $customerTeam->addUser($user); + $projectTeam->addUser($user); + + self::assertTrue($sut->checkTeamAccessProject($project, $user)); + } + + public function testCheckTeamAccessActivityDeniesAccessIfProjectIsDenied(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customerTeam = new Team('Customer team'); + $customer->addTeam($customerTeam); + + $project = new Project(); + $project->setCustomer($customer); + $projectTeam = new Team('Project team'); + $project->addTeam($projectTeam); + + $activity = new Activity(); + $activity->setProject($project); + $activityTeam = new Team('Activity team'); + $activity->addTeam($activityTeam); + + $user = new User(); + $activityTeam->addUser($user); + + self::assertFalse($sut->checkTeamAccessActivity($activity, $user)); + } + + public function testCheckTeamAccessActivityAllowsUsersWithGlobalAccess(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customer->addTeam(new Team('Customer team')); + + $project = new Project(); + $project->setCustomer($customer); + $project->addTeam(new Team('Project team')); + + $activity = new Activity(); + $activity->setProject($project); + $activity->addTeam(new Team('Activity team')); + + $user = new User(); + self::assertFalse($sut->checkTeamAccessActivity($activity, $user)); + $user->initCanSeeAllData(true); + self::assertTrue($sut->checkTeamAccessActivity($activity, $user)); + } + + public function testCheckTeamAccessActivityAllowsMatchingActivityTeamAfterProjectAccess(): void + { + $sut = $this->createSut(); + $customer = new Customer('Acme'); + $customerTeam = new Team('Customer team'); + $customer->addTeam($customerTeam); + + $project = new Project(); + $project->setCustomer($customer); + $projectTeam = new Team('Project team'); + $project->addTeam($projectTeam); + + $activity = new Activity(); + $activity->setProject($project); + $activityTeam = new Team('Activity team'); + $activity->addTeam($activityTeam); + + $user = new User(); + $customerTeam->addUser($user); + $projectTeam->addUser($user); + $activityTeam->addUser($user); + + self::assertTrue($sut->checkTeamAccessActivity($activity, $user)); + } + + private function createSut(): RolePermissionManager + { + $repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock(); + $repository->method('getAllAsArray')->willReturn([]); + + return new RolePermissionManager(new PermissionService($repository, new ArrayAdapter()), [], []); + } } diff --git a/tests/Voter/InvoiceVoterTest.php b/tests/Voter/InvoiceVoterTest.php new file mode 100644 index 00000000..6c9f38a0 --- /dev/null +++ b/tests/Voter/InvoiceVoterTest.php @@ -0,0 +1,134 @@ +assertVote($user, $subject, $attribute, $result); + } + + public function testVoteDeniesIfTokenHasNoApplicationUser(): void + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(null); + $sut = $this->getVoter(InvoiceVoter::class); + + self::assertEquals(VoterInterface::ACCESS_DENIED, $sut->vote($token, $this->createInvoice(), ['view_invoice'])); + } + + public function testVoteDeniesIfInvoiceHasNoCustomer(): void + { + $this->assertVote(self::getUser(2, User::ROLE_TEAMLEAD), new Invoice(), 'view_invoice', VoterInterface::ACCESS_DENIED); + } + + public function testVoteRequiresCustomerTeamAccess(): void + { + $customer = new Customer('Acme'); + $customer->addTeam(new Team('Accounting')); + $invoice = $this->createInvoice($customer); + + $this->assertVote(self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'view_invoice', VoterInterface::ACCESS_DENIED); + + $team = new Team('Accounting'); + $user = new User(); + $user->addRole(User::ROLE_TEAMLEAD); + $team->addTeamlead($user); + + $customer = new Customer('Acme'); + $customer->addTeam($team); + $invoice = $this->createInvoice($customer); + + $this->assertVote($user, $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED); + $this->assertVote($user, $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED); + } + + public function testDeleteInvoiceRequiresDeletePermission(): void + { + $permissions = [ + 'ROLE_TEAMLEAD' => ['view_invoice', 'create_invoice', 'delete_invoice'], + ]; + + $team = new Team('Accounting'); + $user = new User(); + $user->addRole(User::ROLE_TEAMLEAD); + $team->addTeamlead($user); + + $customer = new Customer('Acme'); + $customer->addTeam($team); + + $token = new UsernamePasswordToken($user, 'bar', $user->getRoles()); + $sut = new InvoiceVoter($this->getRolePermissionManager($permissions, true)); + + self::assertEquals(VoterInterface::ACCESS_GRANTED, $sut->vote($token, $this->createInvoice($customer), ['delete_invoice'])); + } + + public static function getVoteData(): \Generator + { + $invoice = self::createStaticInvoice(); + + yield [self::getUser(0, 'foo'), $invoice, 'view_invoice', VoterInterface::ACCESS_DENIED]; + yield [self::getUser(1, User::ROLE_USER), $invoice, 'view_invoice', VoterInterface::ACCESS_DENIED]; + yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED]; + yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED]; + yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'delete_invoice', VoterInterface::ACCESS_DENIED]; + yield [self::getUser(3, User::ROLE_ADMIN), $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED]; + yield [self::getUser(3, User::ROLE_ADMIN), $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED]; + yield [self::getUser(3, User::ROLE_ADMIN), $invoice, 'delete_invoice', VoterInterface::ACCESS_DENIED]; + yield [self::getUser(4, User::ROLE_SUPER_ADMIN), $invoice, 'view_invoice', VoterInterface::ACCESS_GRANTED]; + yield [self::getUser(4, User::ROLE_SUPER_ADMIN), $invoice, 'edit_invoice', VoterInterface::ACCESS_GRANTED]; + yield [self::getUser(4, User::ROLE_SUPER_ADMIN), $invoice, 'delete_invoice', VoterInterface::ACCESS_DENIED]; + + $result = VoterInterface::ACCESS_ABSTAIN; + yield [self::getUser(2, User::ROLE_TEAMLEAD), $invoice, 'view', $result]; + yield [self::getUser(2, User::ROLE_TEAMLEAD), new \stdClass(), 'view_invoice', $result]; + yield [self::getUser(2, User::ROLE_TEAMLEAD), null, 'edit_invoice', $result]; + } + + private function assertVote(User $user, mixed $subject, string $attribute, int $result): void + { + $token = new UsernamePasswordToken($user, 'bar', $user->getRoles()); + $sut = $this->getVoter(InvoiceVoter::class); + + self::assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + private function createInvoice(?Customer $customer = null): Invoice + { + $invoice = new Invoice(); + if ($customer !== null) { + $invoice->setCustomer($customer); + } + + return $invoice; + } + + private static function createStaticInvoice(): Invoice + { + $invoice = new Invoice(); + $invoice->setCustomer(new Customer('Acme')); + + return $invoice; + } +} diff --git a/tests/phpstan.neon b/tests/phpstan.neon index e3f18926..8f0578ba 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -356,11 +356,6 @@ parameters: count: 1 path: Command/ExportCreateCommandTest.php - - - message: "#^Method App\\\\Tests\\\\Command\\\\ExportCreateCommandTest\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: Command/ExportCreateCommandTest.php - - message: "#^Method App\\\\Tests\\\\Command\\\\InvoiceCreateCommandTest\\:\\:assertCommandErrors\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" count: 1 @@ -371,11 +366,6 @@ parameters: count: 1 path: Command/InvoiceCreateCommandTest.php - - - message: "#^Method App\\\\Tests\\\\Command\\\\InvoiceCreateCommandTest\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: Command/InvoiceCreateCommandTest.php - - message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" count: 1 @@ -526,11 +516,6 @@ parameters: count: 1 path: Controller/AbstractControllerBaseTestCase.php - - - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: Controller/AbstractControllerBaseTestCase.php - - message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" count: 1 @@ -991,11 +976,6 @@ parameters: count: 3 path: Controller/WidgetControllerTest.php - - - message: "#^Method App\\\\Tests\\\\DataFixtures\\\\TestFixture\\:\\:load\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: DataFixtures/TestFixture.php - - message: "#^Cannot access offset 'foo\\-bundle\\.bar' on array\\|bool\\|float\\|int\\|string\\|null\\.$#" count: 1 @@ -1569,17 +1549,17 @@ parameters: - message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#" count: 4 - path: Invoice/ServiceInvoiceTest.php + path: Invoice/InvoiceServiceTest.php - - message: "#^Method App\\\\Tests\\\\Invoice\\\\ServiceInvoiceTest\\:\\:getSut\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#" + message: "#^Method App\\\\Tests\\\\Invoice\\\\InvoiceServiceTest\\:\\:getSut\\(\\) has parameter \\$paths with no value type specified in iterable type array\\.$#" count: 1 - path: Invoice/ServiceInvoiceTest.php + path: Invoice/InvoiceServiceTest.php - message: "#^Parameter \\#1 \\$dataDir of class App\\\\Utils\\\\FileHelper constructor expects string, string\\|false given\\.$#" count: 1 - path: Invoice/ServiceInvoiceTest.php + path: Invoice/InvoiceServiceTest.php - message: "#^Method App\\\\Tests\\\\Ldap\\\\LdapManagerTest\\:\\:getLdapManager\\(\\) has no return type specified\\.$#" @@ -1701,11 +1681,6 @@ parameters: count: 1 path: Repository/AbstractRepositoryTestCase.php - - - message: "#^Method App\\\\Tests\\\\Repository\\\\AbstractRepositoryTestCase\\:\\:importFixture\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: Repository/AbstractRepositoryTestCase.php - - message: "#^Parameter \\#1 \\$directory of method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:addDirectory\\(\\) expects string, string\\|false given\\.$#" count: 1