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

@@ -2823,62 +2823,42 @@ parameters:
- -
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.php
- -
message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.php
- -
message: "#^Cannot call method setBegin\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#" message: "#^Cannot call method setBegin\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.php
- -
message: "#^Cannot call method setEnd\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#" message: "#^Cannot call method setEnd\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.php
- -
message: "#^Cannot call method setInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" message: "#^Cannot call method setInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1 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\\.$#" message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, int\\|null given\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.php
-
message: "#^Parameter \\#1 \\$string1 of function strcmp expects string, string\\|null given\\.$#"
count: 1
path: src/Invoice/ServiceInvoice.php
- -
message: "#^Parameter \\#2 \\$locale of class App\\\\Invoice\\\\DefaultInvoiceFormatter constructor expects string, string\\|null given\\.$#" message: "#^Parameter \\#2 \\$locale of class App\\\\Invoice\\\\DefaultInvoiceFormatter constructor expects string, string\\|null given\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.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
- -
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\ExportableItem\\> given\\.$#" message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\ExportableItem\\> given\\.$#"
count: 1 count: 1
path: src/Invoice/ServiceInvoice.php path: src/Invoice/InvoiceService.php
-
message: "#^Property App\\\\Invoice\\\\ServiceInvoice\\:\\:\\$invoiceItemRepositories type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Invoice/ServiceInvoice.php
- -
message: "#^Generator expects value type Symfony\\\\Component\\\\HttpKernel\\\\Bundle\\\\BundleInterface, object given\\.$#" message: "#^Generator expects value type Symfony\\\\Component\\\\HttpKernel\\\\Bundle\\\\BundleInterface, object given\\.$#"

View File

@@ -10,6 +10,8 @@
namespace App\API; namespace App\API;
use App\Entity\Invoice; use App\Entity\Invoice;
use App\Entity\InvoiceMeta;
use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\InvoiceRepository; use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceArchiveQuery; use App\Repository\Query\InvoiceArchiveQuery;
@@ -17,9 +19,11 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints; use Symfony\Component\Validator\Constraints;
@@ -93,8 +97,7 @@ final class InvoiceController extends BaseApiController
/** /**
* Fetch invoice * Fetch invoice
*/ */
#[IsGranted('view_invoice')] #[IsGranted('view_invoice', 'invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/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+'])] #[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response public function getAction(Invoice $invoice): Response
@@ -104,4 +107,48 @@ final class InvoiceController extends BaseApiController
return $this->viewHandler->handle($view); 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 * Update user preferences
*/ */
#[IsGranted('edit', 'profile')] #[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\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', in: 'path', description: 'User ID to set the custom-field value for', required: true)] #[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))))] #[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 public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
{ {
$event = new PrepareUserEvent($profile, false); $event = new PrepareUserEvent($profile, false);

View File

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

View File

@@ -15,7 +15,6 @@ use App\Entity\Invoice;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceDocumentsEvent; use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent; use App\Event\InvoiceMetaDisplayEvent;
use App\Event\InvoiceTemplateMetaDefinitionEvent; use App\Event\InvoiceTemplateMetaDefinitionEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -28,7 +27,7 @@ use App\Form\Toolbar\InvoiceArchiveForm;
use App\Form\Toolbar\InvoiceToolbarForm; use App\Form\Toolbar\InvoiceToolbarForm;
use App\Form\Type\DatePickerType; use App\Form\Type\DatePickerType;
use App\Form\Type\InvoiceTemplateType; use App\Form\Type\InvoiceTemplateType;
use App\Invoice\ServiceInvoice; use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\InvoiceDocumentRepository; use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository; use App\Repository\InvoiceRepository;
@@ -40,7 +39,6 @@ use App\Utils\DataTable;
use App\Utils\PageSetup; use App\Utils\PageSetup;
use Exception; use Exception;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -67,7 +65,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])] #[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')] #[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 (!$templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) { 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'])] #[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')] #[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)) { if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
$this->flashError('action.csrf.error'); $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'])] #[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')] #[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)) { if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error'); $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'])] #[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')] #[IsGranted('edit_invoice', 'invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')] public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, InvoiceService $InvoiceService): Response
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
{ {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -232,7 +229,7 @@ final class InvoiceController extends AbstractController
$invoice->setIsPaid(); $invoice->setIsPaid();
} }
$form = $this->createInvoiceEditForm($invoice); $form = $this->createInvoiceEditForm($invoice, $InvoiceService);
$form->handleRequest($request); $form->handleRequest($request);
return $this->render('invoice/invoice_edit.html.twig', [ return $this->render('invoice/invoice_edit.html.twig', [
@@ -243,7 +240,7 @@ final class InvoiceController extends AbstractController
} }
try { try {
$service->changeInvoiceStatus($invoice, $status); $InvoiceService->changeInvoiceStatus($invoice, $status);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
@@ -253,16 +250,15 @@ final class InvoiceController extends AbstractController
} }
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])] #[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')] #[IsGranted('edit_invoice', 'invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')] public function editAction(Invoice $invoice, Request $request, InvoiceService $InvoiceService): Response
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
{ {
$form = $this->createInvoiceEditForm($invoice); $form = $this->createInvoiceEditForm($invoice, $InvoiceService);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$invoiceRepository->saveInvoice($invoice); $InvoiceService->saveInvoice($invoice);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list'); 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'])] #[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
#[IsGranted('delete_invoice')] #[IsGranted('delete_invoice', 'invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')] public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceService $service): Response
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
{ {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -302,9 +297,8 @@ final class InvoiceController extends AbstractController
} }
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])] #[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
#[IsGranted('view_invoice')] #[IsGranted('view_invoice', 'invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')] public function downloadAction(Invoice $invoice, InvoiceService $service): Response
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
{ {
$file = $service->getInvoiceFile($invoice); $file = $service->getInvoiceFile($invoice);
@@ -378,7 +372,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])] #[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[IsGranted('view_invoice')] #[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 = new InvoiceArchiveQuery();
$query->setCurrentUser($this->getUser()); $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'])] #[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
#[IsGranted('upload_invoice_template')] #[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)); $event = new InvoiceDocumentsEvent($service->getDocuments(true));
$this->dispatcher->dispatch($event); $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'])] #[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')] #[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(); $dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir; $invoiceDir = $dir;
@@ -794,10 +788,9 @@ final class InvoiceController extends AbstractController
return $event->getFields(); return $event->getFields();
} }
private function createInvoiceEditForm(Invoice $invoice): FormInterface private function createInvoiceEditForm(Invoice $invoice, InvoiceService $InvoiceService): FormInterface
{ {
$event = new InvoiceMetaDefinitionEvent($invoice); $InvoiceService->loadMetaFields($invoice);
$this->dispatcher->dispatch($event);
return $this->createForm(InvoiceEditForm::class, $invoice, [ return $this->createForm(InvoiceEditForm::class, $invoice, [
'action' => $this->generateUrl('admin_invoice_edit', ['id' => $invoice->getId()]), '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\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface; use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\InvoiceService;
use App\Invoice\NumberGeneratorInterface; use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface; use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Reference;
@@ -25,7 +25,7 @@ final class InvoiceServiceCompilerPass implements CompilerPassInterface
{ {
public function process(ContainerBuilder $container): void public function process(ContainerBuilder $container): void
{ {
$definition = $container->findDefinition(ServiceInvoice::class); $definition = $container->findDefinition(InvoiceService::class);
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class); $taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
foreach ($taggedRenderer as $id => $tags) { foreach ($taggedRenderer as $id => $tags) {

View File

@@ -13,16 +13,18 @@ use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event; 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 final class InvoiceMetaDefinitionEvent extends Event
{ {
public function __construct(private Invoice $entity) public function __construct(private readonly Invoice $invoice)
{ {
} }
public function getEntity(): 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; namespace App\Form\Type;
use App\Invoice\ServiceInvoice; use App\Invoice\InvoiceService;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/ */
final class InvoiceCalculatorType extends AbstractType 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; namespace App\Form\Type;
use App\Invoice\ServiceInvoice; use App\Invoice\InvoiceService;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/ */
final class InvoiceNumberGeneratorType extends AbstractType 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; namespace App\Form\Type;
use App\Invoice\ServiceInvoice; use App\Invoice\InvoiceService;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/ */
final class InvoiceRendererType extends AbstractType 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; 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; 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\Entity\User;
use App\User\PermissionService; use App\User\PermissionService;
use Doctrine\Common\Collections\Collection;
final class RolePermissionManager final class RolePermissionManager
{ {
@@ -108,4 +113,49 @@ final class RolePermissionManager
{ {
return array_keys($this->permissionNames); 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;
}
}

View File

@@ -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, [ $this->assertExceptionForPatchAction($role, $url, $data, [
'code' => Response::HTTP_NOT_FOUND, '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); $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); $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); $this->assertExceptionForRole($role, $url, 'POST', $data, $expectedErrors);
} }
@@ -180,9 +180,9 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
self::assertEquals($expectedErrors, json_decode($response->getContent(), true)); 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); $this->assertExceptionForMethod($client, $url, $method, $data, $expectedErrors);
} }
@@ -321,7 +321,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'total' => 'float', 'total' => 'float',
'vat' => 'float', 'vat' => 'float',
'overdue' => 'bool', 'overdue' => 'bool',
'metaFields' => ['result' => 'array', 'type' => 'CustomerMeta'], 'metaFields' => ['result' => 'array', 'type' => 'InvoiceMeta'],
]; ];
case 'PageActionItem': case 'PageActionItem':
@@ -350,6 +350,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'value' => '@string', 'value' => '@string',
]; ];
case 'InvoiceMeta':
case 'CustomerMeta': case 'CustomerMeta':
case 'ProjectMeta': case 'ProjectMeta':
case 'ActivityMeta': case 'ActivityMeta':
@@ -630,7 +631,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => '@string',
'color-safe' => 'string', 'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], // since 2.45
'comment' => '@string', 'comment' => '@string',
]; ];
@@ -644,7 +645,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => '@string',
'color-safe' => 'string', 'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'], // since 2.45
'comment' => '@string', 'comment' => '@string',
]; ];
@@ -659,7 +660,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => '@string',
'color-safe' => 'string', 'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'],
'comment' => '@string', 'comment' => '@string',
'parentTitle' => '@string', 'parentTitle' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'], 'teams' => ['result' => 'array', 'type' => 'Team'],
@@ -676,7 +677,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'number' => '@string', 'number' => '@string',
'color' => '@string', 'color' => '@string',
'color-safe' => 'string', 'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], 'metaFields' => ['result' => 'array', 'type' => 'ActivityMeta'],
'comment' => '@string', 'comment' => '@string',
'parentTitle' => '@string', 'parentTitle' => '@string',
'teams' => ['result' => 'array', 'type' => 'Team'], 'teams' => ['result' => 'array', 'type' => 'Team'],

View File

@@ -76,6 +76,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/export/{id}', '/api/export/{id}',
'/api/invoices', '/api/invoices',
'/api/invoices/{id}', '/api/invoices/{id}',
'/api/invoices/{id}/custom-fields',
'/api/projects', '/api/projects',
'/api/projects/{id}', '/api/projects/{id}',
'/api/projects/{id}/meta', '/api/projects/{id}/meta',

View File

@@ -15,13 +15,17 @@ use App\Entity\Team;
use App\Entity\User; use App\Entity\User;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Tests\DataFixtures\InvoiceFixtures; use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\Mocks\InvoiceTestMetaFieldSubscriberMock;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Response;
#[Group('integration')] #[Group('integration')]
class InvoiceControllerTest extends APIControllerBaseTestCase class InvoiceControllerTest extends APIControllerBaseTestCase
{ {
/** /**
* @return Invoice[] * @param int<1, 999> $amount
* @return non-empty-array<Invoice>
*/ */
protected function importInvoiceFixtures(int $amount, ?array $status = null): array protected function importInvoiceFixtures(int $amount, ?array $status = null): array
{ {
@@ -120,6 +124,8 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result); self::assertIsArray($result);
self::assertApiResponseTypeStructure('Invoice', $result); self::assertApiResponseTypeStructure('Invoice', $result);
self::assertArrayHasKey('metaFields', $result);
self::assertCount(0, $result['metaFields']);
} }
public function testNotFound(): void public function testNotFound(): void
@@ -181,4 +187,95 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
$this->request($client, '/api/invoices', 'GET', $query); $this->request($client, '/api/invoices', 'GET', $query);
$this->assertApiResponseAccessDenied($client->getResponse()); $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());
}
} }

View File

@@ -13,7 +13,7 @@ use App\Command\InvoiceCreateCommand;
use App\DataFixtures\UserFixtures; use App\DataFixtures\UserFixtures;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Invoice\ServiceInvoice; use App\Invoice\InvoiceService;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository; use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
@@ -67,7 +67,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
$container = self::getContainer(); $container = self::getContainer();
$this->application->add(new InvoiceCreateCommand( $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(CustomerRepository::class), // @phpstan-ignore argument.type
$container->get(ProjectRepository::class), // @phpstan-ignore argument.type $container->get(ProjectRepository::class), // @phpstan-ignore argument.type
$container->get(InvoiceTemplateRepository::class), // @phpstan-ignore argument.type $container->get(InvoiceTemplateRepository::class), // @phpstan-ignore argument.type

View File

@@ -16,6 +16,7 @@ use Faker\Factory;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Activity>
*/ */
final class ActivityFixtures implements TestFixture final class ActivityFixtures implements TestFixture
{ {

View File

@@ -15,6 +15,7 @@ use Faker\Factory;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Customer>
*/ */
final class CustomerFixtures implements TestFixture final class CustomerFixtures implements TestFixture
{ {

View File

@@ -12,6 +12,9 @@ namespace App\Tests\DataFixtures;
use App\Entity\ExportTemplate; use App\Entity\ExportTemplate;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
/**
* @implements TestFixture<ExportTemplate>
*/
final class ExportTemplateFixtures implements TestFixture final class ExportTemplateFixtures implements TestFixture
{ {
/** /**

View File

@@ -15,6 +15,7 @@ use Faker\Factory;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Invoice>
*/ */
class InvoiceFixtures implements TestFixture class InvoiceFixtures implements TestFixture
{ {

View File

@@ -16,6 +16,7 @@ use Faker\Factory;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<InvoiceTemplate>
*/ */
class InvoiceTemplateFixtures implements TestFixture class InvoiceTemplateFixtures implements TestFixture
{ {

View File

@@ -16,6 +16,7 @@ use Faker\Factory;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Project>
*/ */
final class ProjectFixtures implements TestFixture final class ProjectFixtures implements TestFixture
{ {

View File

@@ -14,6 +14,7 @@ use Doctrine\Persistence\ObjectManager;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Tag>
*/ */
final class TagFixtures implements TestFixture final class TagFixtures implements TestFixture
{ {

View File

@@ -15,6 +15,7 @@ use Doctrine\Persistence\ObjectManager;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Team>
*/ */
final class TeamFixtures implements TestFixture final class TeamFixtures implements TestFixture
{ {

View File

@@ -13,11 +13,14 @@ use Doctrine\Persistence\ObjectManager;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @template TEntity
*/ */
interface TestFixture interface TestFixture
{ {
/** /**
* Load data fixtures with the passed EntityManager and returns the created objects. * Load data fixtures with the passed EntityManager and returns the created objects.
*
* @return non-empty-array<TEntity>
*/ */
public function load(ObjectManager $manager): array; public function load(ObjectManager $manager): array;
} }

View File

@@ -22,6 +22,7 @@ use Faker\Factory;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.
* @implements TestFixture<Timesheet>
*/ */
final class TimesheetFixtures implements TestFixture final class TimesheetFixtures implements TestFixture
{ {

View File

@@ -15,12 +15,12 @@ use App\Invoice\Calculator\ShortInvoiceCalculator;
use App\Invoice\Calculator\UserInvoiceCalculator; use App\Invoice\Calculator\UserInvoiceCalculator;
use App\Invoice\CalculatorInterface; use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface; use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\InvoiceService;
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator; use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\NumberGeneratorInterface; use App\Invoice\NumberGeneratorInterface;
use App\Invoice\Renderer\DocxRenderer; use App\Invoice\Renderer\DocxRenderer;
use App\Invoice\RendererInterface; use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice;
use App\Repository\TimesheetInvoiceItemRepository; use App\Repository\TimesheetInvoiceItemRepository;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -34,8 +34,8 @@ class InvoiceServiceCompilerPassTest extends TestCase
{ {
$container = new ContainerBuilder(); $container = new ContainerBuilder();
$definition = new Definition(ServiceInvoice::class); $definition = new Definition(InvoiceService::class);
$container->setDefinition(ServiceInvoice::class, $definition); $container->setDefinition(InvoiceService::class, $definition);
$renderers = [DocxRenderer::class]; $renderers = [DocxRenderer::class];
foreach ($renderers as $renderer) { foreach ($renderers as $renderer) {
@@ -66,7 +66,7 @@ class InvoiceServiceCompilerPassTest extends TestCase
$sut = new InvoiceServiceCompilerPass(); $sut = new InvoiceServiceCompilerPass();
$sut->process($container); $sut->process($container);
$definition = $container->findDefinition(ServiceInvoice::class); $definition = $container->findDefinition(InvoiceService::class);
$methods = $definition->getMethodCalls(); $methods = $definition->getMethodCalls();
self::assertCount(7, $methods); self::assertCount(7, $methods);

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceItemRepositoryInterface; use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceService;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer; use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice; use App\Invoice\ServiceInvoice;
@@ -29,12 +30,14 @@ use App\Tests\Mocks\InvoiceModelFactoryFactory;
use App\Utils\FileHelper; use App\Utils\FileHelper;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Twig\Environment; use Twig\Environment;
#[CoversClass(ServiceInvoice::class)] #[CoversClass(InvoiceService::class)]
class ServiceInvoiceTest extends TestCase #[CoversClass(ServiceInvoice::class)] // @phpstan-ignore-line
class InvoiceServiceTest extends TestCase
{ {
private function getSut(array $paths): ServiceInvoice private function getSut(array $paths): InvoiceService
{ {
$languages = [ $languages = [
'en' => LocaleService::DEFAULT_SETTINGS 'en' => LocaleService::DEFAULT_SETTINGS
@@ -45,7 +48,14 @@ class ServiceInvoiceTest extends TestCase
$repo = new InvoiceDocumentRepository($paths); $repo = new InvoiceDocumentRepository($paths);
$invoiceRepo = $this->createMock(InvoiceRepository::class); $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 public function testInvalidExceptionOnChangeState(): void

View File

@@ -32,6 +32,12 @@ trait KernelTestTrait
return $em; return $em;
} }
/**
* @template TEntity
* @param TestFixture<TEntity> $fixture
* @return non-empty-array<TEntity>
* @throws \Exception
*/
protected function importFixture(TestFixture $fixture): array protected function importFixture(TestFixture $fixture): array
{ {
return $fixture->load($this->getEntityManager()); return $fixture->load($this->getEntityManager());

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Mocks;
use App\Entity\InvoiceMeta;
use App\Event\InvoiceMetaDefinitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Length;
class InvoiceTestMetaFieldSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
InvoiceMetaDefinitionEvent::class => ['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);
}
}

View File

@@ -9,6 +9,10 @@
namespace App\Tests\Security; 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\Entity\User;
use App\Repository\RolePermissionRepository; use App\Repository\RolePermissionRepository;
use App\Security\RolePermissionManager; 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', 'role_permissions'));
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'view_user')); 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()), [], []);
}
} }

View File

@@ -0,0 +1,134 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\Team;
use App\Entity\User;
use App\Voter\InvoiceVoter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
#[CoversClass(InvoiceVoter::class)]
class InvoiceVoterTest extends AbstractVoterTestCase
{
#[DataProvider('getVoteData')]
public function testVote(User $user, mixed $subject, string $attribute, int $result): void
{
$this->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;
}
}

View File

@@ -356,11 +356,6 @@ parameters:
count: 1 count: 1
path: Command/ExportCreateCommandTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\InvoiceCreateCommandTest\\:\\:assertCommandErrors\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -371,11 +366,6 @@ parameters:
count: 1 count: 1
path: Command/InvoiceCreateCommandTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Command\\\\PluginCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -526,11 +516,6 @@ parameters:
count: 1 count: 1
path: Controller/AbstractControllerBaseTestCase.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -991,11 +976,6 @@ parameters:
count: 3 count: 3
path: Controller/WidgetControllerTest.php 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\\.$#" message: "#^Cannot access offset 'foo\\-bundle\\.bar' on array\\|bool\\|float\\|int\\|string\\|null\\.$#"
count: 1 count: 1
@@ -1569,17 +1549,17 @@ parameters:
- -
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#" message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 4 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 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\\.$#" message: "#^Parameter \\#1 \\$dataDir of class App\\\\Utils\\\\FileHelper constructor expects string, string\\|false given\\.$#"
count: 1 count: 1
path: Invoice/ServiceInvoiceTest.php path: Invoice/InvoiceServiceTest.php
- -
message: "#^Method App\\\\Tests\\\\Ldap\\\\LdapManagerTest\\:\\:getLdapManager\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Ldap\\\\LdapManagerTest\\:\\:getLdapManager\\(\\) has no return type specified\\.$#"
@@ -1701,11 +1681,6 @@ parameters:
count: 1 count: 1
path: Repository/AbstractRepositoryTestCase.php 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\\.$#" message: "#^Parameter \\#1 \\$directory of method App\\\\Repository\\\\InvoiceDocumentRepository\\:\\:addDirectory\\(\\) expects string, string\\|false given\\.$#"
count: 1 count: 1