new API endpoint to save invoice meta-fields (#5916)
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceMeta;
|
||||
use App\Invoice\InvoiceService;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
@@ -17,9 +19,11 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
@@ -93,8 +97,7 @@ final class InvoiceController extends BaseApiController
|
||||
/**
|
||||
* Fetch invoice
|
||||
*/
|
||||
#[IsGranted('view_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
#[IsGranted('view_invoice', 'invoice')]
|
||||
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
|
||||
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
|
||||
public function getAction(Invoice $invoice): Response
|
||||
@@ -104,4 +107,48 @@ final class InvoiceController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice custom-fields
|
||||
*/
|
||||
#[IsGranted('edit_invoice', 'invoice')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of configured custom-fields. You cannot create unknown custom-fields.', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
|
||||
#[OA\Parameter(name: 'id', description: 'Invoice ID to set the custom-fields for', in: 'path', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: InvoiceMeta::class))))]
|
||||
#[Route(path: '/{id}/custom-fields', requirements: ['id' => '\d+'], methods: ['PATCH'])]
|
||||
public function updateMetaFields(Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
|
||||
{
|
||||
$invoiceService->loadMetaFields($invoice);
|
||||
$dirty = false;
|
||||
|
||||
foreach ($request->request->all() as $preference) {
|
||||
// why is this not handled by FosRestBundle ?
|
||||
if (!\is_array($preference)) {
|
||||
throw new BadRequestHttpException('Invalid request, array expected');
|
||||
}
|
||||
|
||||
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
|
||||
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
|
||||
}
|
||||
|
||||
$name = $preference['name'];
|
||||
$value = $preference['value'];
|
||||
|
||||
if (null === ($meta = $invoice->getMetaField($name))) {
|
||||
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
$dirty = true;
|
||||
}
|
||||
|
||||
if ($dirty) {
|
||||
$invoiceService->saveInvoice($invoice);
|
||||
}
|
||||
|
||||
$view = new View($invoice, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +232,10 @@ final class UserController extends BaseApiController
|
||||
* Update user preferences
|
||||
*/
|
||||
#[IsGranted('edit', 'profile')]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of a consifgured preference. You cannot create unknown preferences: if the given name is not configured, an exception will be raised.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of a configured preference. You cannot create unknown or updated disabled preferences.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
#[OA\Parameter(name: 'id', description: 'User ID to set the custom-field value for', in: 'path', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
|
||||
#[Route(path: '/{id}/preferences', requirements: ['id' => '\d+'], methods: ['PATCH'])]
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
|
||||
{
|
||||
$event = new PrepareUserEvent($profile, false);
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Entity\Customer;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\Project;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Invoice\InvoiceService;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
@@ -41,7 +41,7 @@ final class InvoiceCreateCommand extends Command
|
||||
private bool $previewUniqueFile = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly ServiceInvoice $serviceInvoice,
|
||||
private readonly InvoiceService $InvoiceService,
|
||||
private readonly CustomerRepository $customerRepository,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly InvoiceTemplateRepository $invoiceTemplateRepository,
|
||||
@@ -283,16 +283,16 @@ final class InvoiceCreateCommand extends Command
|
||||
$query->setTemplate($tpl);
|
||||
|
||||
try {
|
||||
$model = $this->serviceInvoice->createModel($query);
|
||||
$model = $this->InvoiceService->createModel($query);
|
||||
// this check makes sure to only fetch invoices with records
|
||||
if (\count($model->getEntries()) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $this->previewDirectory) {
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($model, $this->eventDispatcher));
|
||||
$invoices[] = $this->saveInvoicePreview($this->InvoiceService->renderInvoice($model, $this->eventDispatcher));
|
||||
} else {
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
|
||||
$invoices[] = $this->InvoiceService->createInvoice($model, $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(\sprintf('Failed to create invoice for project with: %s', $ex->getMessage()));
|
||||
@@ -358,16 +358,16 @@ final class InvoiceCreateCommand extends Command
|
||||
$query->setTemplate($tpl);
|
||||
|
||||
try {
|
||||
$model = $this->serviceInvoice->createModel($query);
|
||||
$model = $this->InvoiceService->createModel($query);
|
||||
// this check makes sure to only fetch invoices with records
|
||||
if (\count($model->getEntries()) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $this->previewDirectory) {
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($model, $this->eventDispatcher));
|
||||
$invoices[] = $this->saveInvoicePreview($this->InvoiceService->renderInvoice($model, $this->eventDispatcher));
|
||||
} else {
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($model, $this->eventDispatcher);
|
||||
$invoices[] = $this->InvoiceService->createInvoice($model, $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(\sprintf('Failed to create invoice for customer with: %s', $ex->getMessage()));
|
||||
@@ -413,7 +413,7 @@ final class InvoiceCreateCommand extends Command
|
||||
$table->setHeaders($columns);
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$file = $this->serviceInvoice->getInvoiceFile($invoice);
|
||||
$file = $this->InvoiceService->getInvoiceFile($invoice);
|
||||
if (null === $file) {
|
||||
$io->warning(
|
||||
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId() ?? 'unknown', $invoice->getInvoiceFilename() ?? 'unknown')
|
||||
@@ -456,7 +456,7 @@ final class InvoiceCreateCommand extends Command
|
||||
*/
|
||||
private function getActiveCustomers(InvoiceQuery $invoiceQuery): array
|
||||
{
|
||||
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
|
||||
$results = $this->InvoiceService->getInvoiceItems($invoiceQuery);
|
||||
|
||||
$customers = [];
|
||||
|
||||
@@ -473,7 +473,7 @@ final class InvoiceCreateCommand extends Command
|
||||
*/
|
||||
private function getActiveProjects(InvoiceQuery $invoiceQuery): array
|
||||
{
|
||||
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
|
||||
$results = $this->InvoiceService->getInvoiceItems($invoiceQuery);
|
||||
|
||||
$projects = [];
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\InvoiceDocumentsEvent;
|
||||
use App\Event\InvoiceMetaDefinitionEvent;
|
||||
use App\Event\InvoiceMetaDisplayEvent;
|
||||
use App\Event\InvoiceTemplateMetaDefinitionEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
@@ -28,7 +27,7 @@ use App\Form\Toolbar\InvoiceArchiveForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
use App\Form\Type\DatePickerType;
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Invoice\InvoiceService;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
@@ -40,7 +39,6 @@ use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
@@ -67,7 +65,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function indexAction(Request $request, InvoiceService $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
if (!$templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
@@ -136,7 +134,7 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted('access', 'customer')]
|
||||
public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response
|
||||
public function previewAction(Customer $customer, string $token, Request $request, InvoiceService $service): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -174,7 +172,7 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted('access', 'customer')]
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, InvoiceService $service): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -216,9 +214,8 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
|
||||
#[IsGranted('edit_invoice', 'invoice')]
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, InvoiceService $InvoiceService): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -232,7 +229,7 @@ final class InvoiceController extends AbstractController
|
||||
$invoice->setIsPaid();
|
||||
}
|
||||
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form = $this->createInvoiceEditForm($invoice, $InvoiceService);
|
||||
$form->handleRequest($request);
|
||||
|
||||
return $this->render('invoice/invoice_edit.html.twig', [
|
||||
@@ -243,7 +240,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
try {
|
||||
$service->changeInvoiceStatus($invoice, $status);
|
||||
$InvoiceService->changeInvoiceStatus($invoice, $status);
|
||||
$this->flashSuccess('action.update.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -253,16 +250,15 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
|
||||
#[IsGranted('edit_invoice', 'invoice')]
|
||||
public function editAction(Invoice $invoice, Request $request, InvoiceService $InvoiceService): Response
|
||||
{
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form = $this->createInvoiceEditForm($invoice, $InvoiceService);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$invoiceRepository->saveInvoice($invoice);
|
||||
$InvoiceService->saveInvoice($invoice);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
@@ -279,9 +275,8 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
|
||||
#[IsGranted('delete_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
|
||||
#[IsGranted('delete_invoice', 'invoice')]
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceService $service): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -302,9 +297,8 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
|
||||
#[IsGranted('view_invoice', 'invoice')]
|
||||
public function downloadAction(Invoice $invoice, InvoiceService $service): Response
|
||||
{
|
||||
$file = $service->getInvoiceFile($invoice);
|
||||
|
||||
@@ -378,7 +372,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository): Response
|
||||
{
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -445,7 +439,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function downloadDocument(string $document, ServiceInvoice $service): Response
|
||||
public function downloadDocument(string $document, InvoiceService $service): Response
|
||||
{
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -461,7 +455,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, InvoiceService $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
$dir = $documentRepository->getUploadDirectory();
|
||||
$invoiceDir = $dir;
|
||||
@@ -794,10 +788,9 @@ final class InvoiceController extends AbstractController
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
private function createInvoiceEditForm(Invoice $invoice): FormInterface
|
||||
private function createInvoiceEditForm(Invoice $invoice, InvoiceService $InvoiceService): FormInterface
|
||||
{
|
||||
$event = new InvoiceMetaDefinitionEvent($invoice);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$InvoiceService->loadMetaFields($invoice);
|
||||
|
||||
return $this->createForm(InvoiceEditForm::class, $invoice, [
|
||||
'action' => $this->generateUrl('admin_invoice_edit', ['id' => $invoice->getId()]),
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\DependencyInjection\Compiler;
|
||||
|
||||
use App\Invoice\CalculatorInterface;
|
||||
use App\Invoice\InvoiceItemRepositoryInterface;
|
||||
use App\Invoice\InvoiceService;
|
||||
use App\Invoice\NumberGeneratorInterface;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
@@ -25,7 +25,7 @@ final class InvoiceServiceCompilerPass implements CompilerPassInterface
|
||||
{
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
$definition = $container->findDefinition(ServiceInvoice::class);
|
||||
$definition = $container->findDefinition(InvoiceService::class);
|
||||
|
||||
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
|
||||
foreach ($taggedRenderer as $id => $tags) {
|
||||
|
||||
@@ -13,16 +13,18 @@ use App\Entity\Invoice;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* This event can be used, to dynamically add meta-fields to invoices
|
||||
* This event can be used, to dynamically add meta-fields to invoices.
|
||||
*
|
||||
* Do not use directly, call InvoiceService::loadMetaFields() instead.
|
||||
*/
|
||||
final class InvoiceMetaDefinitionEvent extends Event
|
||||
{
|
||||
public function __construct(private Invoice $entity)
|
||||
public function __construct(private readonly Invoice $invoice)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEntity(): Invoice
|
||||
{
|
||||
return $this->entity;
|
||||
return $this->invoice;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/Event/InvoiceUpdatePostEvent.php
Normal file
29
src/Event/InvoiceUpdatePostEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Event/InvoiceUpdatePreEvent.php
Normal file
29
src/Event/InvoiceUpdatePreEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Invoice\InvoiceService;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
final class InvoiceCalculatorType extends AbstractType
|
||||
{
|
||||
public function __construct(private ServiceInvoice $service)
|
||||
public function __construct(private InvoiceService $service)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Invoice\InvoiceService;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
final class InvoiceNumberGeneratorType extends AbstractType
|
||||
{
|
||||
public function __construct(private ServiceInvoice $service)
|
||||
public function __construct(private InvoiceService $service)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Invoice\InvoiceService;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
final class InvoiceRendererType extends AbstractType
|
||||
{
|
||||
public function __construct(private ServiceInvoice $service)
|
||||
public function __construct(private InvoiceService $service)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
558
src/Invoice/InvoiceService.php
Normal file
558
src/Invoice/InvoiceService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -9,531 +9,9 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\Invoice;
|
||||
use App\Event\InvoiceCreatedEvent;
|
||||
use App\Event\InvoiceDeleteEvent;
|
||||
use App\Event\InvoicePostRenderEvent;
|
||||
use App\Event\InvoicePreRenderEvent;
|
||||
use App\Export\Base\DispositionInlineInterface;
|
||||
use App\Model\InvoiceDocument;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Utils\FileHelper;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Service to manage invoice dependencies.
|
||||
* @deprecated since 2.56 use InvoiceService instead
|
||||
*/
|
||||
final class ServiceInvoice
|
||||
class ServiceInvoice extends InvoiceService // @phpstan-ignore class.extendsFinalByPhpDoc
|
||||
{
|
||||
/**
|
||||
* @var CalculatorInterface[]
|
||||
*/
|
||||
private array $calculator = [];
|
||||
/**
|
||||
* @var RendererInterface[]
|
||||
*/
|
||||
private array $renderer = [];
|
||||
/**
|
||||
* @var NumberGeneratorInterface[]
|
||||
*/
|
||||
private array $numberGenerator = [];
|
||||
/**
|
||||
* @var array InvoiceItemRepositoryInterface[]
|
||||
*/
|
||||
private array $invoiceItemRepositories = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly InvoiceDocumentRepository $documents,
|
||||
private readonly FileHelper $fileHelper,
|
||||
private readonly InvoiceRepository $invoiceRepository,
|
||||
private readonly LocaleService $formatter,
|
||||
private readonly InvoiceModelFactory $invoiceModelFactory
|
||||
) {
|
||||
}
|
||||
|
||||
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
|
||||
{
|
||||
$this->numberGenerator[] = $generator;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NumberGeneratorInterface[]
|
||||
*/
|
||||
public function getNumberGenerator(): array
|
||||
{
|
||||
return $this->numberGenerator;
|
||||
}
|
||||
|
||||
public function getNumberGeneratorByName(string $name): ?NumberGeneratorInterface
|
||||
{
|
||||
foreach ($this->getNumberGenerator() as $generator) {
|
||||
if ($generator->getId() === $name) {
|
||||
// several models can co-exist at the same time and NumberGeneratorInterface works
|
||||
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
|
||||
return clone $generator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function addCalculator(CalculatorInterface $calculator): ServiceInvoice
|
||||
{
|
||||
$this->calculator[] = $calculator;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CalculatorInterface[]
|
||||
*/
|
||||
public function getCalculator(): array
|
||||
{
|
||||
return $this->calculator;
|
||||
}
|
||||
|
||||
public function getCalculatorByName(string $name): ?CalculatorInterface
|
||||
{
|
||||
foreach ($this->getCalculator() as $calculator) {
|
||||
if ($calculator->getId() === $name) {
|
||||
// several models can co-exist at the same time and CalculatorInterface works
|
||||
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
|
||||
return clone $calculator;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getDocumentByName(string $name): ?InvoiceDocument
|
||||
{
|
||||
return $this->documents->findByName($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of invoice renderer, which will consist of a unique name and a controller action.
|
||||
*
|
||||
* @param bool $customOnly
|
||||
* @return InvoiceDocument[]
|
||||
*/
|
||||
public function getDocuments(bool $customOnly = false): array
|
||||
{
|
||||
if ($customOnly) {
|
||||
return $this->documents->findCustom();
|
||||
}
|
||||
|
||||
return $this->documents->findAll();
|
||||
}
|
||||
|
||||
public function addRenderer(RendererInterface $renderer): ServiceInvoice
|
||||
{
|
||||
$this->renderer[] = $renderer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of invoice renderer.
|
||||
*
|
||||
* @return RendererInterface[]
|
||||
*/
|
||||
public function getRenderer(): array
|
||||
{
|
||||
return $this->renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InvoiceItemRepositoryInterface[]
|
||||
*/
|
||||
public function getInvoiceItemRepositories(): array
|
||||
{
|
||||
return $this->invoiceItemRepositories;
|
||||
}
|
||||
|
||||
public function addInvoiceItemRepository(InvoiceItemRepositoryInterface $invoiceItemRepository): ServiceInvoice
|
||||
{
|
||||
$this->invoiceItemRepositories[] = $invoiceItemRepository;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getInvoicesDirectory(): string
|
||||
{
|
||||
return $this->fileHelper->getDataDirectory('invoices');
|
||||
}
|
||||
|
||||
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
$filename = $invoice->getInvoiceFilename();
|
||||
$full = $invoiceDirectory . $filename;
|
||||
|
||||
if (is_file($full) && is_readable($full)) {
|
||||
return new \SplFileInfo($full);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function saveGeneratedInvoice(InvoicePostRenderEvent $event): string
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
$filename = (string) new InvoiceFilename($event->getModel());
|
||||
|
||||
$response = $event->getResponse();
|
||||
|
||||
if ($event->getResponse()->headers->has('Content-Disposition')) {
|
||||
$disposition = $event->getResponse()->headers->get('Content-Disposition');
|
||||
$parts = explode(';', $disposition);
|
||||
foreach ($parts as $part) {
|
||||
if (stripos($part, 'filename=') === false) {
|
||||
continue;
|
||||
}
|
||||
$tmp = explode('filename=', $part);
|
||||
if (\count($tmp) > 1) {
|
||||
$filename = $tmp[1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$disposition = $event->getResponse()->headers->get('Content-Type');
|
||||
$parts = explode(';', $disposition);
|
||||
$parts = explode('/', $parts[0]);
|
||||
if (\count($parts) > 1) {
|
||||
$filename .= '.' . $parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (mb_strlen($filename) >= 150) {
|
||||
throw new \Exception(\sprintf('Invoice filename "%s" is too long, max. 150 characters allowed', $filename));
|
||||
}
|
||||
|
||||
if (is_file($invoiceDirectory . $filename)) {
|
||||
throw new \Exception(\sprintf('Invoice "%s" already exists', $filename));
|
||||
}
|
||||
|
||||
if ($response instanceof BinaryFileResponse) {
|
||||
$file = $response->getFile();
|
||||
$file->move($invoiceDirectory, $filename);
|
||||
} else {
|
||||
$this->fileHelper->saveFile($invoiceDirectory . $filename, $event->getResponse()->getContent());
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function changeInvoiceStatus(Invoice $invoice, string $status): void
|
||||
{
|
||||
switch ($status) {
|
||||
case Invoice::STATUS_NEW:
|
||||
$invoice->setIsNew();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_PENDING:
|
||||
$invoice->setIsPending();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_PAID:
|
||||
$invoice->setIsPaid();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_CANCELED:
|
||||
$invoice->setIsCanceled();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \InvalidArgumentException('Unknown invoice status');
|
||||
}
|
||||
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ExportableItem[]
|
||||
*/
|
||||
public function getInvoiceItems(InvoiceQuery $query): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($this->getInvoiceItemRepositories() as $repository) {
|
||||
$items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $entries
|
||||
*/
|
||||
private function markEntriesAsExported(array $entries): void
|
||||
{
|
||||
foreach ($this->getInvoiceItemRepositories() as $repository) {
|
||||
$repository->setExported($entries);
|
||||
}
|
||||
}
|
||||
|
||||
public function renderInvoice(InvoiceModel $model, EventDispatcherInterface $dispatcher, bool $dispositionInline = false): Response
|
||||
{
|
||||
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
|
||||
if (null === $document) {
|
||||
throw new \Exception('Please adjust your invoice template, the renderer is invalid: ' . $model->getTemplate()->getRenderer());
|
||||
}
|
||||
|
||||
foreach ($this->getRenderer() as $renderer) {
|
||||
if ($renderer->supports($document)) {
|
||||
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
|
||||
|
||||
if ($renderer instanceof DispositionInlineInterface) {
|
||||
$renderer->setDispositionInline($dispositionInline);
|
||||
}
|
||||
|
||||
$response = $renderer->render($document, $model);
|
||||
|
||||
$dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception(
|
||||
\sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createInvoice(InvoiceModel $model, EventDispatcherInterface $dispatcher): Invoice
|
||||
{
|
||||
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
|
||||
if (null === $document) {
|
||||
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
|
||||
}
|
||||
|
||||
foreach ($this->getRenderer() as $renderer) {
|
||||
if ($renderer->supports($document)) {
|
||||
$preEvent = new InvoicePreRenderEvent($model, $document, $renderer);
|
||||
$dispatcher->dispatch($preEvent);
|
||||
|
||||
if ($preEvent->isPropagationStopped()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->invoiceRepository->hasInvoice($model->getInvoiceNumber())) {
|
||||
throw new DuplicateInvoiceNumberException($model->getInvoiceNumber());
|
||||
}
|
||||
|
||||
$response = $renderer->render($document, $model);
|
||||
|
||||
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$invoiceFilename = $this->saveGeneratedInvoice($event);
|
||||
|
||||
$invoice = new Invoice();
|
||||
$invoice->setModel($model);
|
||||
$invoice->setFilename($invoiceFilename);
|
||||
|
||||
if (!$invoice->getCustomer()->hasInvoiceTemplate()) {
|
||||
$invoice->getCustomer()->setInvoiceTemplate($model->getTemplate());
|
||||
}
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
|
||||
$this->markEntriesAsExported($model->getEntries());
|
||||
$dispatcher->dispatch(new InvoiceCreatedEvent($invoice, $model));
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception(
|
||||
\sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteInvoice(Invoice $invoice, EventDispatcherInterface $dispatcher): void
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
|
||||
if (is_file($invoiceDirectory . $invoice->getInvoiceFilename())) {
|
||||
$this->fileHelper->removeFile($invoiceDirectory . $invoice->getInvoiceFilename());
|
||||
}
|
||||
|
||||
$event = new InvoiceDeleteEvent($invoice);
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$this->invoiceRepository->deleteInvoice($invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createModel(InvoiceQuery $query): InvoiceModel
|
||||
{
|
||||
$model = $this->createModelWithoutEntries($query);
|
||||
$model->addEntries($this->getInvoiceItems($query));
|
||||
|
||||
$this->prepareModelQueryDates($model);
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
private function createModelWithoutEntries(InvoiceQuery $query): InvoiceModel
|
||||
{
|
||||
$customer = $query->getCustomer();
|
||||
if ($customer === null) {
|
||||
throw new \Exception('Cannot create invoice model without customer');
|
||||
}
|
||||
|
||||
$template = $query->getTemplate();
|
||||
|
||||
if ($query->isAllowTemplateOverwrite() && $customer->hasInvoiceTemplate()) {
|
||||
$template = $customer->getInvoiceTemplate();
|
||||
}
|
||||
|
||||
if (null === $template) {
|
||||
throw new \Exception('Cannot create invoice model without template');
|
||||
}
|
||||
|
||||
$formatter = new DefaultInvoiceFormatter($this->formatter, $template->getLanguage());
|
||||
|
||||
$model = $this->invoiceModelFactory->createModel(
|
||||
$formatter,
|
||||
$customer,
|
||||
$template,
|
||||
$query
|
||||
);
|
||||
|
||||
if ($query->getInvoiceDate() !== null) {
|
||||
$model->setInvoiceDate($query->getInvoiceDate());
|
||||
}
|
||||
|
||||
if (null !== $query->getCurrentUser()) {
|
||||
$model->setUser($query->getCurrentUser());
|
||||
}
|
||||
|
||||
$generator = $this->getNumberGeneratorByName($template->getNumberGenerator());
|
||||
if (null === $generator) {
|
||||
throw new \Exception('Please adjust your invoice template, the number generator is invalid: ' . $template->getNumberGenerator());
|
||||
}
|
||||
|
||||
$calculator = $this->getCalculatorByName($template->getCalculator());
|
||||
if (null === $calculator) {
|
||||
throw new \Exception('Please adjust your invoice template, the sum calculator is invalid: ' . $template->getCalculator());
|
||||
}
|
||||
|
||||
$model->setCalculator($calculator);
|
||||
$model->setNumberGenerator($generator);
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
private function prepareModelQueryDates(InvoiceModel $model): void
|
||||
{
|
||||
$begin = $model->getQuery()?->getBegin();
|
||||
$end = $model->getQuery()?->getEnd();
|
||||
|
||||
if ($begin !== null && $end !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (\count($model->getEntries()) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpBegin = null;
|
||||
$tmpEnd = null;
|
||||
|
||||
foreach ($model->getEntries() as $entry) {
|
||||
if ($begin === null) {
|
||||
if ($tmpBegin === null) {
|
||||
$tmpBegin = $entry->getBegin();
|
||||
} else {
|
||||
$tmpBegin = min($entry->getBegin(), $tmpBegin);
|
||||
}
|
||||
}
|
||||
|
||||
if ($end === null) {
|
||||
if ($tmpEnd === null) {
|
||||
$tmpEnd = $entry->getEnd();
|
||||
} else {
|
||||
$tmpEnd = max($entry->getEnd(), $tmpEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($begin === null && $tmpBegin !== null) {
|
||||
$model->getQuery()->setBegin($tmpBegin);
|
||||
}
|
||||
|
||||
if ($end === null && $tmpEnd !== null) {
|
||||
$model->getQuery()->setEnd($tmpEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InvoiceModel[]
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createModels(InvoiceQuery $query): array
|
||||
{
|
||||
$models = [];
|
||||
$customerEntries = [];
|
||||
$items = $this->getInvoiceItems($query);
|
||||
|
||||
foreach ($items as $entry) {
|
||||
$customer = $entry->getProject()->getCustomer();
|
||||
if ($customer === null || !$customer->isVisible()) { // generating invoices for hidden customers does not yet work
|
||||
continue;
|
||||
}
|
||||
$id = $customer->getId();
|
||||
if (!\array_key_exists($id, $customerEntries)) {
|
||||
$customerEntries[$id] = [
|
||||
'customer' => $customer,
|
||||
'entries' => [],
|
||||
];
|
||||
}
|
||||
$customerEntries[$id]['entries'][] = $entry;
|
||||
}
|
||||
|
||||
if (empty($customerEntries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
uasort($customerEntries, function ($a, $b): int {
|
||||
$nameA = $a['customer']->getName();
|
||||
$nameB = $b['customer']->getName();
|
||||
|
||||
if ($nameA === null && $nameB === null) {
|
||||
$result = 0;
|
||||
} elseif ($nameA === null && $nameB !== null) {
|
||||
$result = 1;
|
||||
} elseif ($nameA !== null && $nameB === null) {
|
||||
$result = -1;
|
||||
} else {
|
||||
$result = strcmp($nameA, $nameB);
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
|
||||
foreach ($customerEntries as $settings) {
|
||||
$customerQuery = clone $query;
|
||||
$customerQuery->setCustomers([$settings['customer']]);
|
||||
$model = $this->createModelWithoutEntries($customerQuery);
|
||||
$model->addEntries($settings['entries']);
|
||||
$this->prepareModelQueryDates($model);
|
||||
|
||||
$models[] = $model;
|
||||
}
|
||||
|
||||
return $models;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\User\PermissionService;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
final class RolePermissionManager
|
||||
{
|
||||
@@ -108,4 +113,49 @@ final class RolePermissionManager
|
||||
{
|
||||
return array_keys($this->permissionNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Team> $teams
|
||||
*/
|
||||
private function checkTeamAccess(Collection $teams, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($teams->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($teams as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function checkTeamAccessCustomer(Customer $customer, User $user): bool
|
||||
{
|
||||
return $this->checkTeamAccess($customer->getTeams(), $user);
|
||||
}
|
||||
|
||||
public function checkTeamAccessProject(Project $project, User $user): bool
|
||||
{
|
||||
if ($project->getCustomer() !== null && !$this->checkTeamAccessCustomer($project->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkTeamAccess($project->getTeams(), $user);
|
||||
}
|
||||
|
||||
public function checkTeamAccessActivity(Activity $activity, User $user): bool
|
||||
{
|
||||
if ($activity->getProject() !== null && !$this->checkTeamAccessProject($activity->getProject(), $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->checkTeamAccess($activity->getTeams(), $user);
|
||||
}
|
||||
}
|
||||
|
||||
96
src/Voter/InvoiceVoter.php
Normal file
96
src/Voter/InvoiceVoter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user