diff --git a/assets/invoice.js b/assets/invoice.js index f8f9c8be..4f608096 100644 --- a/assets/invoice.js +++ b/assets/invoice.js @@ -1,2 +1,4 @@ - +/** + * @deprecated use invoice-pdf instead + */ require('./sass/_invoice.scss'); diff --git a/src/Command/TranslationCommand.php b/src/Command/TranslationCommand.php index 37c890c4..97b96a45 100644 --- a/src/Command/TranslationCommand.php +++ b/src/Command/TranslationCommand.php @@ -47,6 +47,8 @@ final class TranslationCommand extends Command ->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version') ->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all') ->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)') + ->addOption('copy-resname', null, InputOption::VALUE_REQUIRED, 'Copy a resname within one file (needs "source" option)') + ->addOption('target-resname', null, InputOption::VALUE_REQUIRED, 'Target resname when copying (needs "source" option)') ->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)') ->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use') ->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories') @@ -150,6 +152,21 @@ final class TranslationCommand extends Command return $this->moveResname($io, $moveResname, $sources, $targets); } + // ========================================================================== + // Move resname from source to target + // ========================================================================== + $copyResname = $input->getOption('copy-resname'); + $targetResname = $input->getOption('target-resname'); + if (\is_string($copyResname)) { + if (!\is_string($targetResname)) { + $io->error('To copy a resname, we need a target-resname'); + + return Command::FAILURE; + } + + return $this->copyResname($io, $copyResname, $targetResname, $sources); + } + // ========================================================================== // Move all keys from source to target // ========================================================================== @@ -654,6 +671,62 @@ final class TranslationCommand extends Command return Command::SUCCESS; } + /** + * @param array $sources + */ + private function copyResname(SymfonyStyle $io, string $resname, string $target, array $sources): int + { + foreach ($sources as $source) { + $tmp = basename($source); + $pos = strpos($tmp, '.'); + if ($pos === false) { + $io->error('Unexpected filename: ' . $source); + + return Command::FAILURE; + } + + $sourceDocument = new \DOMDocument('1.0'); + $sourceDocument->load($source); + + $copiedNode = false; + + /** @var \DOMElement $element */ + foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) { + if (!$element->hasAttribute('resname')) { + continue; + } + + $key = $element->getAttribute('resname'); + + if ($key === $resname) { + $newElement = clone $element; + $newElement->setAttribute('resname', $target); + foreach ($newElement->childNodes->getIterator() as $child) { + if ($child->nodeName === 'source') { + $child->textContent = $target; + } + } + $newElement->setAttribute('id', $this->generateId($target)); + + $newNode = $sourceDocument->importNode($newElement, true); + $sourceDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line + $copiedNode = true; + break; + } + } + + if ($copiedNode) { + $xmlDocument = new \DOMDocument('1.0'); + $xmlDocument->preserveWhiteSpace = false; + $xmlDocument->formatOutput = true; + $xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line + file_put_contents($source, $xmlDocument->saveXML()); + } + } + + return Command::SUCCESS; + } + /** * @param array $sources * @param array $targets diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 6dcaca66..12559b06 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -61,18 +61,15 @@ use Twig\Environment; final class InvoiceController extends AbstractController { public function __construct( - private readonly ServiceInvoice $service, - private readonly InvoiceTemplateRepository $templateRepository, - private readonly InvoiceRepository $invoiceRepository, private readonly EventDispatcherInterface $dispatcher ) { } #[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])] #[IsGranted('create_invoice')] - public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response + public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response { - if (!$this->templateRepository->hasTemplate()) { + if (!$templateRepository->hasTemplate()) { if ($this->isGranted('manage_invoice_template')) { return $this->redirectToRoute('admin_invoice_template_create'); } @@ -95,7 +92,7 @@ final class InvoiceController extends AbstractController if ($form->isValid() && $query->getTemplate() !== null) { try { - $models = $this->service->createModels($query); + $models = $service->createModels($query); $searched = true; } catch (Exception $ex) { $this->flashUpdateException($ex); @@ -139,12 +136,8 @@ 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): Response + public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response { - if (!$this->templateRepository->hasTemplate()) { - return $this->redirectToRoute('invoice'); - } - if (!$this->isCsrfTokenValid('invoice.preview', $token)) { $this->flashError('action.csrf.error'); @@ -164,10 +157,10 @@ final class InvoiceController extends AbstractController if ($form->isValid()) { try { $query->setCustomers([$customer]); - $model = $this->service->createModel($query); + $model = $service->createModel($query); $model->setPreview(true); - return $this->service->renderInvoice($model, $this->dispatcher, true); + return $service->renderInvoice($model, $this->dispatcher, true); } catch (Exception $ex) { $this->flashUpdateException($ex); } @@ -181,12 +174,8 @@ 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): Response + public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response { - if (!$this->templateRepository->hasTemplate()) { - return $this->redirectToRoute('invoice'); - } - if (!$this->isCsrfTokenValid('invoice.create', $token)) { $this->flashError('action.csrf.error'); @@ -203,7 +192,7 @@ final class InvoiceController extends AbstractController if ($form->isValid()) { try { $query->setCustomers([$customer]); - $model = $this->service->createModel($query); + $model = $service->createModel($query); // save default template for customer if not yet set if ($customer->getInvoiceTemplate() === null) { @@ -211,7 +200,7 @@ final class InvoiceController extends AbstractController $customerRepository->saveCustomer($customer); } - $invoice = $this->service->createInvoice($model, $this->dispatcher); + $invoice = $service->createInvoice($model, $this->dispatcher); $this->flashSuccess('action.update.success'); @@ -229,7 +218,7 @@ 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): 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))) { $this->flashError('action.csrf.error'); @@ -254,7 +243,7 @@ final class InvoiceController extends AbstractController } try { - $this->service->changeInvoiceStatus($invoice, $status); + $service->changeInvoiceStatus($invoice, $status); $this->flashSuccess('action.update.success'); } catch (Exception $ex) { $this->flashUpdateException($ex); @@ -266,14 +255,14 @@ 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): Response + public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response { $form = $this->createInvoiceEditForm($invoice); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { try { - $this->invoiceRepository->saveInvoice($invoice); + $invoiceRepository->saveInvoice($invoice); $this->flashSuccess('action.update.success'); return $this->redirectToRoute('admin_invoice_list'); @@ -292,7 +281,7 @@ 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): Response + public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) { $this->flashError('action.csrf.error'); @@ -303,7 +292,7 @@ final class InvoiceController extends AbstractController $csrfTokenManager->refreshToken('invoice.status'); try { - $this->service->deleteInvoice($invoice, $this->dispatcher); + $service->deleteInvoice($invoice, $this->dispatcher); $this->flashSuccess('action.delete.success'); } catch (Exception $ex) { $this->flashDeleteException($ex); @@ -315,9 +304,9 @@ 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): Response + public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response { - $file = $this->service->getInvoiceFile($invoice); + $file = $service->getInvoiceFile($invoice); if (null === $file) { throw $this->createNotFoundException( @@ -330,12 +319,12 @@ final class InvoiceController extends AbstractController #[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])] #[IsGranted('view_invoice')] - public function showInvoicesAction(Request $request, int $page): Response + public function showInvoicesAction(Request $request, int $page, InvoiceRepository $invoiceRepository): Response { $invoice = null; if (null !== ($id = $request->query->get('id'))) { - $invoice = $this->invoiceRepository->find($id); + $invoice = $invoiceRepository->find($id); } $query = new InvoiceArchiveQuery(); @@ -347,7 +336,7 @@ final class InvoiceController extends AbstractController return $this->redirectToRoute('admin_invoice_list'); } - $entries = $this->invoiceRepository->getPagerfantaForQuery($query); + $entries = $invoiceRepository->getPagerfantaForQuery($query); $metaColumns = $this->findMetaColumns($query); $table = new DataTable('invoices', $query); @@ -389,7 +378,7 @@ final class InvoiceController extends AbstractController #[Route(path: '/export', name: 'invoice_export', methods: ['GET'])] #[IsGranted('view_invoice')] - public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response + public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response { $query = new InvoiceArchiveQuery(); $query->setCurrentUser($this->getUser()); @@ -398,7 +387,7 @@ final class InvoiceController extends AbstractController $form->setData($query); $form->submit($request->query->all(), false); - $entries = $this->invoiceRepository->getInvoicesForQuery($query); + $entries = $invoiceRepository->getInvoicesForQuery($query); $spreadsheet = $exporter->export( Invoice::class, @@ -412,12 +401,12 @@ final class InvoiceController extends AbstractController #[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])] #[IsGranted('manage_invoice_template')] - public function listTemplateAction(int $page): Response + public function listTemplateAction(int $page, InvoiceTemplateRepository $templateRepository): Response { $query = new BaseQuery(); $query->setPage($page); - $entries = $this->templateRepository->getPagerfantaForQuery($query); + $entries = $templateRepository->getPagerfantaForQuery($query); $table = new DataTable('invoice_template', $query); $table->setPagination($entries); @@ -449,16 +438,16 @@ final class InvoiceController extends AbstractController #[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])] #[IsGranted('manage_invoice_template')] - public function editTemplateAction(InvoiceTemplate $template, Request $request): Response + public function editTemplateAction(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response { - return $this->renderTemplateForm($template, $request); + return $this->renderTemplateForm($template, $request, $templateRepository); } #[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])] #[IsGranted('upload_invoice_template')] - public function downloadDocument(string $document, Environment $twig): Response + public function downloadDocument(string $document, ServiceInvoice $service): Response { - $event = new InvoiceDocumentsEvent($this->service->getDocuments(true)); + $event = new InvoiceDocumentsEvent($service->getDocuments(true)); $this->dispatcher->dispatch($event); foreach ($event->getInvoiceDocuments() as $doc) { @@ -472,7 +461,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): Response + public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response { $dir = $documentRepository->getUploadDirectory(); $invoiceDir = $dir; @@ -484,11 +473,11 @@ final class InvoiceController extends AbstractController $invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $used = []; - foreach ($this->templateRepository->findAll() as $template) { + foreach ($templateRepository->findAll() as $template) { $used[$template->getRenderer()] = $template; } - $event = new InvoiceDocumentsEvent($this->service->getDocuments(true)); + $event = new InvoiceDocumentsEvent($service->getDocuments(true)); $this->dispatcher->dispatch($event); $documents = []; @@ -606,7 +595,7 @@ final class InvoiceController extends AbstractController #[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])] #[IsGranted('manage_invoice_template')] - public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response + public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository, InvoiceTemplateRepository $templateRepository): Response { $document = $documentRepository->findByName($id); if ($document === null) { @@ -629,7 +618,7 @@ final class InvoiceController extends AbstractController } } - foreach ($this->templateRepository->findAll() as $template) { + foreach ($templateRepository->findAll() as $template) { if ($template->getRenderer() === $id) { $this->flashError('Document is used and cannot be deleted.'); @@ -649,19 +638,19 @@ final class InvoiceController extends AbstractController #[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])] #[IsGranted('manage_invoice_template')] - public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response + public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom, InvoiceTemplateRepository $templateRepository): Response { - return $this->createTemplate($request, $copyFrom); + return $this->createTemplate($request, $templateRepository, $copyFrom); } #[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])] #[IsGranted('manage_invoice_template')] - public function createTemplateAction(Request $request): Response + public function createTemplateAction(Request $request, InvoiceTemplateRepository $templateRepository): Response { - return $this->createTemplate($request, null); + return $this->createTemplate($request, $templateRepository, null); } - private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response + private function createTemplate(Request $request, InvoiceTemplateRepository $templateRepository, ?InvoiceTemplate $copyFrom = null): Response { $template = new InvoiceTemplate(); $template->setLanguage($request->getLocale()); @@ -671,12 +660,12 @@ final class InvoiceController extends AbstractController $template->setName($copyFrom->getName() . ' (1)'); } - return $this->renderTemplateForm($template, $request); + return $this->renderTemplateForm($template, $request, $templateRepository); } #[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])] #[IsGranted('manage_invoice_template')] - public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response + public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager, InvoiceTemplateRepository $templateRepository): Response { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) { $this->flashError('action.csrf.error'); @@ -687,7 +676,7 @@ final class InvoiceController extends AbstractController $csrfTokenManager->refreshToken('invoice.delete_template'); try { - $this->templateRepository->removeTemplate($template); + $templateRepository->removeTemplate($template); $this->flashSuccess('action.delete.success'); } catch (Exception $ex) { $this->flashDeleteException($ex); @@ -727,7 +716,7 @@ final class InvoiceController extends AbstractController $this->flashError('action.update.error', $err); } - private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response + private function renderTemplateForm(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response { $event = new InvoiceTemplateMetaDefinitionEvent($template); $this->dispatcher->dispatch($event); @@ -738,7 +727,7 @@ final class InvoiceController extends AbstractController if ($editForm->isSubmitted() && $editForm->isValid()) { try { - $this->templateRepository->saveTemplate($template); + $templateRepository->saveTemplate($template); $this->flashSuccess('action.update.success'); return $this->redirectToRoute('admin_invoice_template'); diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php index 4aab7267..73d4bd60 100644 --- a/src/Entity/InvoiceTemplate.php +++ b/src/Entity/InvoiceTemplate.php @@ -88,6 +88,10 @@ class InvoiceTemplate implements EntityWithMetaFields */ #[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])] private Collection $meta; + /** + * @var array + */ + private array $taxRates = []; public function __construct() { @@ -268,16 +272,29 @@ class InvoiceTemplate implements EntityWithMetaFields $this->language = $language; } + /** + * @param array $taxRates + */ + public function setTaxRates(array $taxRates): void + { + $this->taxRates = $taxRates; + } + /** * @return Tax[] */ public function getTaxRates(): array { - // TODO make me configurable via UI + if (\count($this->taxRates) > 0) { + return $this->taxRates; + } + $tax = new Tax( TaxType::STANDARD, - 'VAT', - $this->vat ?? 0.00 + $this->vat ?? 0.00, + 'vat', + true, + null ); return [$tax]; diff --git a/src/Entity/Tax.php b/src/Entity/Tax.php index 1d2ad048..429c87d1 100644 --- a/src/Entity/Tax.php +++ b/src/Entity/Tax.php @@ -13,8 +13,10 @@ final class Tax { public function __construct( private readonly TaxType $type, - private readonly string $name = 'VAT', - private readonly float $rate = 0.0, + private readonly float $rate, + private readonly string $name, + private readonly bool $show, + private readonly ?string $note, ) { } @@ -33,4 +35,14 @@ final class Tax { return $this->rate; } + + public function isShow(): bool + { + return $this->rate > 0.0 || $this->show; + } + + public function getNote(): ?string + { + return $this->note; + } } diff --git a/src/Event/InvoicePreRenderEvent.php b/src/Event/InvoicePreRenderEvent.php index 4838c51f..3664fd10 100644 --- a/src/Event/InvoicePreRenderEvent.php +++ b/src/Event/InvoicePreRenderEvent.php @@ -14,6 +14,14 @@ use App\Invoice\RendererInterface; use App\Model\InvoiceDocument; use Symfony\Contracts\EventDispatcher\Event; +/** + * Triggered right before an invoice is rendered. + * + * You can use this event to: + * - add invoice hydrator + * - read and change invoice model + * - change tax rates + */ final class InvoicePreRenderEvent extends Event { public function __construct( diff --git a/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php b/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php index 1c90e054..55c4f3b8 100644 --- a/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php +++ b/src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php @@ -31,6 +31,10 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator $subtotal = $calculator->getSubtotal(); $formatter = $model->getFormatter(); $language = $template->getLanguage(); + if ($language === null) { + throw new \InvalidArgumentException('InvoiceTemplate needs a language'); + } + $taxRows = $calculator->getTaxRows(); $vat = 0.00; @@ -48,7 +52,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator 'invoice.language' => $language, // since 1.9 'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency), 'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead - 'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00, + 'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00, // @deprecated, use invoice.tax_rows instead 'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), // @deprecated, use invoice.tax_rows instead 'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead 'invoice.tax_plain' => $tax, // @deprecated, use invoice.tax_rows instead @@ -91,11 +95,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator ]; $values['invoice.tax_rows'] = []; + $counter = 1; foreach ($taxRows as $taxRow) { + $tax = $taxRow->getTax(); $values['invoice.tax_rows'][] = [ - 'type' => $taxRow->getTax()->getType()->value, - 'name' => $taxRow->getTax()->getName(), - 'rate' => $taxRow->getTax()->getRate(), + 'counter' => $counter++, + 'type' => $tax->getType()->value, + 'name' => $tax->getName(), + 'rate' => $tax->getRate(), + 'note' => $tax->getNote(), + 'show' => $tax->isShow(), + 'currency' => $currency, 'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway 'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway ]; diff --git a/src/Invoice/InvoiceItemHydrator.php b/src/Invoice/InvoiceItemHydrator.php index 6bcac48d..cbf2b1d5 100644 --- a/src/Invoice/InvoiceItemHydrator.php +++ b/src/Invoice/InvoiceItemHydrator.php @@ -9,6 +9,9 @@ namespace App\Invoice; +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; + +#[AutoconfigureTag] interface InvoiceItemHydrator { public function setInvoiceModel(InvoiceModel $model): void; diff --git a/src/Invoice/InvoiceModel.php b/src/Invoice/InvoiceModel.php index f7ec99c6..7e234846 100644 --- a/src/Invoice/InvoiceModel.php +++ b/src/Invoice/InvoiceModel.php @@ -9,20 +9,10 @@ namespace App\Invoice; -use App\Activity\ActivityStatisticService; -use App\Customer\CustomerStatisticService; use App\Entity\Customer; use App\Entity\ExportableItem; use App\Entity\InvoiceTemplate; use App\Entity\User; -use App\Invoice\Hydrator\InvoiceItemDefaultHydrator; -use App\Invoice\Hydrator\InvoiceModelActivityHydrator; -use App\Invoice\Hydrator\InvoiceModelCustomerHydrator; -use App\Invoice\Hydrator\InvoiceModelDefaultHydrator; -use App\Invoice\Hydrator\InvoiceModelIssuerHydrator; -use App\Invoice\Hydrator\InvoiceModelProjectHydrator; -use App\Invoice\Hydrator\InvoiceModelUserHydrator; -use App\Project\ProjectStatisticService; use App\Repository\Query\InvoiceQuery; use App\Timesheet\RateCalculator\RateCalculatorMode; use Symfony\Component\DependencyInjection\Attribute\Exclude; @@ -43,7 +33,6 @@ final class InvoiceModel private ?NumberGeneratorInterface $generator = null; private \DateTimeInterface $invoiceDate; private ?User $user = null; - private InvoiceFormatter $formatter; /** * @var InvoiceModelHydrator[] */ @@ -64,24 +53,13 @@ final class InvoiceModel * @internal use InvoiceModelFactory */ public function __construct( - InvoiceFormatter $formatter, - CustomerStatisticService $customerStatistic, - ProjectStatisticService $projectStatistic, - ActivityStatisticService $activityStatistic, + private InvoiceFormatter $formatter, private readonly Customer $customer, private readonly InvoiceTemplate $template, private readonly RateCalculatorMode $rateCalculatorMode ) { $this->invoiceDate = new \DateTimeImmutable(); - $this->formatter = $formatter; - $this->addModelHydrator(new InvoiceModelDefaultHydrator()); - $this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic)); - $this->addModelHydrator(new InvoiceModelIssuerHydrator()); - $this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic)); - $this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic)); - $this->addModelHydrator(new InvoiceModelUserHydrator()); - $this->addItemHydrator(new InvoiceItemDefaultHydrator()); } /** diff --git a/src/Invoice/InvoiceModelFactory.php b/src/Invoice/InvoiceModelFactory.php index 60e6d87d..729564a2 100644 --- a/src/Invoice/InvoiceModelFactory.php +++ b/src/Invoice/InvoiceModelFactory.php @@ -9,27 +9,36 @@ namespace App\Invoice; -use App\Activity\ActivityStatisticService; -use App\Customer\CustomerStatisticService; use App\Entity\Customer; use App\Entity\InvoiceTemplate; -use App\Project\ProjectStatisticService; use App\Repository\Query\InvoiceQuery; use App\Timesheet\RateCalculator\RateCalculatorMode; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; final class InvoiceModelFactory { + /** + * @param iterable $modelHydrators + * @param iterable $itemHydrators + */ public function __construct( - private readonly CustomerStatisticService $customerStatisticService, - private readonly ProjectStatisticService $projectStatisticService, - private readonly ActivityStatisticService $activityStatisticService, - private readonly RateCalculatorMode $rateCalculatorMode + private readonly RateCalculatorMode $rateCalculatorMode, + #[TaggedIterator(InvoiceModelHydrator::class)] + private readonly iterable $modelHydrators, + #[TaggedIterator(InvoiceItemHydrator::class)] + private readonly iterable $itemHydrators, ) { } public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel { - $model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode); + $model = new InvoiceModel($formatter, $customer, $template, $this->rateCalculatorMode); + foreach ($this->modelHydrators as $modelHydrator) { + $model->addModelHydrator($modelHydrator); + } + foreach ($this->itemHydrators as $itemHydrator) { + $model->addItemHydrator($itemHydrator); + } $model->setQuery($query); diff --git a/src/Invoice/InvoiceModelHydrator.php b/src/Invoice/InvoiceModelHydrator.php index 8d5b481b..1882dcfe 100644 --- a/src/Invoice/InvoiceModelHydrator.php +++ b/src/Invoice/InvoiceModelHydrator.php @@ -9,6 +9,9 @@ namespace App\Invoice; +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; + +#[AutoconfigureTag] interface InvoiceModelHydrator { public function hydrate(InvoiceModel $model): array; diff --git a/templates/invoice/renderer/default.pdf.twig b/templates/invoice/renderer/default.pdf.twig index 0c4dabd1..343522a9 100644 --- a/templates/invoice/renderer/default.pdf.twig +++ b/templates/invoice/renderer/default.pdf.twig @@ -135,20 +135,25 @@ mpdf--> {% endfor %} - {% if not invoice['invoice.tax_hide'] %} {{ 'invoice.subtotal'|trans }} {{ invoice['invoice.subtotal'] }} - - - {{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%) - - {{ invoice['invoice.tax'] }} - - {% endif %} + {% for taxRow in invoice['invoice.tax_rows'] %} + {% if taxRow['show'] %} + + + {{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%) + {% if taxRow['note'] is not null %} + [{{ taxRow['counter'] }}] + {% endif %} + + {{ taxRow['amount']|money(taxRow['currency']) }} + + {% endif %} + {% endfor %} {{ 'invoice.total'|trans }} @@ -160,10 +165,17 @@ mpdf--> + {% for taxRow in invoice['invoice.tax_rows'] %} + {% if taxRow['note'] is not null %} +

+ [{{ taxRow['counter'] }}] + {{ taxRow['note']|trans }} +

+ {% endif %} + {% endfor %} + {% if invoice['template.payment_terms'] is not empty %} -
- {{ invoice['template.payment_terms']|md2html }} -
+ {{ invoice['template.payment_terms']|md2html }} {% endif %} diff --git a/templates/invoice/renderer/invoice.html.twig b/templates/invoice/renderer/invoice.html.twig index 6ae1eb6b..32b1c515 100644 --- a/templates/invoice/renderer/invoice.html.twig +++ b/templates/invoice/renderer/invoice.html.twig @@ -14,7 +14,7 @@
@@ -22,7 +22,7 @@
{{ 'invoice.to'|trans }} -
+
{{ invoice['customer.company']|default(invoice['customer.name']) }}
{{ invoice['customer.address']|nl2br }} {% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %} @@ -46,7 +46,7 @@
{{ 'invoice.from'|trans }} -
+
{{ invoice['template.company'] }}
{{ invoice['template.address']|trim|nl2br }} {% if invoice['template.country'] is not null %} @@ -66,7 +66,7 @@
-

+

{{ 'date'|trans }}: {{ invoice['invoice.date'] }} @@ -98,7 +98,7 @@ {% for invoiceLineItem in entries %} {{ invoiceLineItem['entry.begin'] }} - + {% if invoiceLineItem['entry.description'] is not empty %} {{ invoiceLineItem['entry.description']|nl2br }} {% else %} @@ -115,20 +115,25 @@ {% endfor %} - {% if not invoice['invoice.tax_hide'] %} {{ 'invoice.subtotal'|trans }} {{ invoice['invoice.subtotal'] }} - - - {{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%) - - {{ invoice['invoice.tax'] }} - - {% endif %} + {% for taxRow in invoice['invoice.tax_rows'] %} + {% if taxRow['show'] %} + + + {{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%) + {% if taxRow['note'] is not null %} + [{{ taxRow['counter'] }}] + {% endif %} + + {{ taxRow['amount']|money(taxRow['currency']) }} + + {% endif %} + {% endfor %} {{ 'invoice.total'|trans }} @@ -144,10 +149,16 @@

+ {% for taxRow in invoice['invoice.tax_rows'] %} + {% if taxRow['note'] is not null %} +

+ [{{ taxRow['counter'] }}] + {{ taxRow['note']|trans }} +

+ {% endif %} + {% endfor %} {% if invoice['template.payment_terms'] is not empty %} -
- {{ invoice['template.payment_terms']|md2html }} -
+ {{ invoice['template.payment_terms']|md2html }} {% endif %}
diff --git a/templates/invoice/renderer/service-date.pdf.twig b/templates/invoice/renderer/service-date.pdf.twig index 9fe08718..d396d23a 100644 --- a/templates/invoice/renderer/service-date.pdf.twig +++ b/templates/invoice/renderer/service-date.pdf.twig @@ -150,20 +150,25 @@ mpdf--> {% endfor %} - {% if not invoice['invoice.tax_hide'] %} {{ 'invoice.subtotal'|trans }} {{ invoice['invoice.subtotal'] }} - - - {{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%) - - {{ invoice['invoice.tax'] }} - - {% endif %} + {% for taxRow in invoice['invoice.tax_rows'] %} + {% if taxRow['show'] %} + + + {{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%) + {% if taxRow['note'] is not null %} + [{{ taxRow['counter'] }}] + {% endif %} + + {{ taxRow['amount']|money(taxRow['currency']) }} + + {% endif %} + {% endfor %} {{ 'invoice.total'|trans }} @@ -175,10 +180,17 @@ mpdf--> + {% for taxRow in invoice['invoice.tax_rows'] %} + {% if taxRow['note'] is not null %} +

+ [{{ taxRow['counter'] }}] + {{ taxRow['note']|trans }} +

+ {% endif %} + {% endfor %} + {% if invoice['template.payment_terms'] is not empty %} -
- {{ invoice['template.payment_terms']|md2html }} -
+ {{ invoice['template.payment_terms']|md2html }} {% endif %} diff --git a/tests/Entity/InvoiceTemplateTest.php b/tests/Entity/InvoiceTemplateTest.php index 02fffb29..dc3a55df 100644 --- a/tests/Entity/InvoiceTemplateTest.php +++ b/tests/Entity/InvoiceTemplateTest.php @@ -13,6 +13,8 @@ use App\Entity\Customer; use App\Entity\CustomerMeta; use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplateMeta; +use App\Entity\Tax; +use App\Entity\TaxType; use Doctrine\Common\Collections\Collection; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -44,6 +46,48 @@ class InvoiceTemplateTest extends TestCase self::assertNull($sut->getMetaField('foo')); } + public function testTaxRates(): void + { + $sut = new InvoiceTemplate(); + + $rates = $sut->getTaxRates(); + self::assertCount(1, $rates); + self::assertEquals(TaxType::STANDARD, $rates[0]->getType()); + self::assertEquals(0.00, $rates[0]->getRate()); + self::assertEquals('vat', $rates[0]->getName()); + self::assertNull($rates[0]->getNote()); + + $tax1 = new Tax( + TaxType::REVERSE, + $this->vat ?? 0.00, + 'tax.name.reverse_charge', + true, + null + ); + $tax2 = new Tax( + TaxType::EXEMPT, + $this->vat ?? 0.00, + 'tax.name.exempt', + true, + null + ); + + $sut->setTaxRates([$tax1, $tax2]); + $rates = $sut->getTaxRates(); + + self::assertCount(2, $rates); + + self::assertEquals(TaxType::REVERSE, $rates[0]->getType()); + self::assertEquals(0.00, $rates[0]->getRate()); + self::assertEquals('tax.name.reverse_charge', $rates[0]->getName()); + self::assertNull($rates[0]->getNote()); + + self::assertEquals(TaxType::EXEMPT, $rates[1]->getType()); + self::assertEquals(0.00, $rates[1]->getRate()); + self::assertEquals('tax.name.exempt', $rates[1]->getName()); + self::assertNull($rates[1]->getNote()); + } + public function testSetterAndGetter(): void { $sut = new InvoiceTemplate(); diff --git a/tests/Invoice/templates/default.pdf.twig b/tests/Invoice/templates/default.pdf.twig index 2d575240..2fbcd941 100644 --- a/tests/Invoice/templates/default.pdf.twig +++ b/tests/Invoice/templates/default.pdf.twig @@ -5,7 +5,7 @@ diff --git a/tests/Mocks/InvoiceModelFactoryFactory.php b/tests/Mocks/InvoiceModelFactoryFactory.php index 7eaab381..1e6c7267 100644 --- a/tests/Mocks/InvoiceModelFactoryFactory.php +++ b/tests/Mocks/InvoiceModelFactoryFactory.php @@ -11,6 +11,13 @@ namespace App\Tests\Mocks; use App\Activity\ActivityStatisticService; use App\Customer\CustomerStatisticService; +use App\Invoice\Hydrator\InvoiceItemDefaultHydrator; +use App\Invoice\Hydrator\InvoiceModelActivityHydrator; +use App\Invoice\Hydrator\InvoiceModelCustomerHydrator; +use App\Invoice\Hydrator\InvoiceModelDefaultHydrator; +use App\Invoice\Hydrator\InvoiceModelIssuerHydrator; +use App\Invoice\Hydrator\InvoiceModelProjectHydrator; +use App\Invoice\Hydrator\InvoiceModelUserHydrator; use App\Invoice\InvoiceModelFactory; use App\Project\ProjectStatisticService; use App\Timesheet\RateCalculator\DecimalRateCalculator; @@ -25,8 +32,19 @@ class InvoiceModelFactoryFactory extends AbstractMockFactory $projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock(); /** @var ActivityStatisticService $activityStatistic */ $activityStatistic = $this->getMockBuilder(ActivityStatisticService::class)->disableOriginalConstructor()->getMock(); - $rateMode = new DecimalRateCalculator(); - return new InvoiceModelFactory($customerStatistic, $projectStatistic, $activityStatistic, $rateMode); + $modelHydrators = [ + new InvoiceModelDefaultHydrator(), + new InvoiceModelCustomerHydrator($customerStatistic), + new InvoiceModelIssuerHydrator(), + new InvoiceModelProjectHydrator($projectStatistic), + new InvoiceModelActivityHydrator($activityStatistic), + new InvoiceModelUserHydrator(), + ]; + $itemHydrators = [ + new InvoiceItemDefaultHydrator(), + ]; + + return new InvoiceModelFactory(new DecimalRateCalculator(), $modelHydrators, $itemHydrators); } } diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 2c8efc11..87a964e4 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -2046,6 +2046,26 @@ electronic_invoice E-Rechnung + + invoice_tax_type + Steuerstatus + + + tax.type.exempt + Ausgenommen + + + tax.type.standard + Steuerpflichtig + + + tax.type.reverse + Umkehrung der Steuerschuld + + + tax.note.reverse_charge + Steuer zahlbar auf Reverse-Charge-Basis + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b3d20429..b40f7081 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -2046,6 +2046,26 @@ electronic_invoice E-Invoice + + invoice_tax_type + Tax status + + + tax.type.exempt + Exempt + + + tax.type.standard + Taxable + + + tax.type.reverse + Reverse charge + + + tax.note.reverse_charge + Tax to be paid on reverse charge basis +