Invoice tax rates API (#5740)

This commit is contained in:
Kevin Papst
2025-12-24 18:47:42 +01:00
committed by GitHub
parent 806bb97e60
commit 3c3d6379a8
19 changed files with 377 additions and 136 deletions

View File

@@ -1,2 +1,4 @@
/**
* @deprecated use invoice-pdf instead
*/
require('./sass/_invoice.scss'); require('./sass/_invoice.scss');

View File

@@ -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('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('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('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('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('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories') ->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); 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 // Move all keys from source to target
// ========================================================================== // ==========================================================================
@@ -654,6 +671,62 @@ final class TranslationCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} }
/**
* @param array<string> $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<string> $sources * @param array<string> $sources
* @param array<string> $targets * @param array<string> $targets

View File

@@ -61,18 +61,15 @@ use Twig\Environment;
final class InvoiceController extends AbstractController final class InvoiceController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ServiceInvoice $service,
private readonly InvoiceTemplateRepository $templateRepository,
private readonly InvoiceRepository $invoiceRepository,
private readonly EventDispatcherInterface $dispatcher private readonly EventDispatcherInterface $dispatcher
) { ) {
} }
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])] #[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
public function indexAction(Request $request, 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')) { if ($this->isGranted('manage_invoice_template')) {
return $this->redirectToRoute('admin_invoice_template_create'); return $this->redirectToRoute('admin_invoice_template_create');
} }
@@ -95,7 +92,7 @@ final class InvoiceController extends AbstractController
if ($form->isValid() && $query->getTemplate() !== null) { if ($form->isValid() && $query->getTemplate() !== null) {
try { try {
$models = $this->service->createModels($query); $models = $service->createModels($query);
$searched = true; $searched = true;
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
@@ -139,12 +136,8 @@ final class InvoiceController extends AbstractController
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])] #[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')] #[IsGranted('access', 'customer')]
public function previewAction(Customer $customer, string $token, Request $request): 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)) { if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -164,10 +157,10 @@ final class InvoiceController extends AbstractController
if ($form->isValid()) { if ($form->isValid()) {
try { try {
$query->setCustomers([$customer]); $query->setCustomers([$customer]);
$model = $this->service->createModel($query); $model = $service->createModel($query);
$model->setPreview(true); $model->setPreview(true);
return $this->service->renderInvoice($model, $this->dispatcher, true); return $service->renderInvoice($model, $this->dispatcher, true);
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
} }
@@ -181,12 +174,8 @@ final class InvoiceController extends AbstractController
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])] #[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')] #[IsGranted('access', 'customer')]
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): 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)) { if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -203,7 +192,7 @@ final class InvoiceController extends AbstractController
if ($form->isValid()) { if ($form->isValid()) {
try { try {
$query->setCustomers([$customer]); $query->setCustomers([$customer]);
$model = $this->service->createModel($query); $model = $service->createModel($query);
// save default template for customer if not yet set // save default template for customer if not yet set
if ($customer->getInvoiceTemplate() === null) { if ($customer->getInvoiceTemplate() === null) {
@@ -211,7 +200,7 @@ final class InvoiceController extends AbstractController
$customerRepository->saveCustomer($customer); $customerRepository->saveCustomer($customer);
} }
$invoice = $this->service->createInvoice($model, $this->dispatcher); $invoice = $service->createInvoice($model, $this->dispatcher);
$this->flashSuccess('action.update.success'); $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'])] #[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), '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))) { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -254,7 +243,7 @@ final class InvoiceController extends AbstractController
} }
try { try {
$this->service->changeInvoiceStatus($invoice, $status); $service->changeInvoiceStatus($invoice, $status);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
@@ -266,14 +255,14 @@ final class InvoiceController extends AbstractController
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])] #[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')] #[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), '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 = $this->createInvoiceEditForm($invoice);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$this->invoiceRepository->saveInvoice($invoice); $invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list'); 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'])] #[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
#[IsGranted('delete_invoice')] #[IsGranted('delete_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), '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))) { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -303,7 +292,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.status'); $csrfTokenManager->refreshToken('invoice.status');
try { try {
$this->service->deleteInvoice($invoice, $this->dispatcher); $service->deleteInvoice($invoice, $this->dispatcher);
$this->flashSuccess('action.delete.success'); $this->flashSuccess('action.delete.success');
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashDeleteException($ex); $this->flashDeleteException($ex);
@@ -315,9 +304,9 @@ final class InvoiceController extends AbstractController
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])] #[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
#[IsGranted('view_invoice')] #[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), '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) { if (null === $file) {
throw $this->createNotFoundException( 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'])] #[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
#[IsGranted('view_invoice')] #[IsGranted('view_invoice')]
public function showInvoicesAction(Request $request, int $page): Response public function showInvoicesAction(Request $request, int $page, InvoiceRepository $invoiceRepository): Response
{ {
$invoice = null; $invoice = null;
if (null !== ($id = $request->query->get('id'))) { if (null !== ($id = $request->query->get('id'))) {
$invoice = $this->invoiceRepository->find($id); $invoice = $invoiceRepository->find($id);
} }
$query = new InvoiceArchiveQuery(); $query = new InvoiceArchiveQuery();
@@ -347,7 +336,7 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list'); return $this->redirectToRoute('admin_invoice_list');
} }
$entries = $this->invoiceRepository->getPagerfantaForQuery($query); $entries = $invoiceRepository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query); $metaColumns = $this->findMetaColumns($query);
$table = new DataTable('invoices', $query); $table = new DataTable('invoices', $query);
@@ -389,7 +378,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])] #[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[IsGranted('view_invoice')] #[IsGranted('view_invoice')]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
{ {
$query = new InvoiceArchiveQuery(); $query = new InvoiceArchiveQuery();
$query->setCurrentUser($this->getUser()); $query->setCurrentUser($this->getUser());
@@ -398,7 +387,7 @@ final class InvoiceController extends AbstractController
$form->setData($query); $form->setData($query);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);
$entries = $this->invoiceRepository->getInvoicesForQuery($query); $entries = $invoiceRepository->getInvoicesForQuery($query);
$spreadsheet = $exporter->export( $spreadsheet = $exporter->export(
Invoice::class, 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'])] #[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')] #[IsGranted('manage_invoice_template')]
public function listTemplateAction(int $page): Response public function listTemplateAction(int $page, InvoiceTemplateRepository $templateRepository): Response
{ {
$query = new BaseQuery(); $query = new BaseQuery();
$query->setPage($page); $query->setPage($page);
$entries = $this->templateRepository->getPagerfantaForQuery($query); $entries = $templateRepository->getPagerfantaForQuery($query);
$table = new DataTable('invoice_template', $query); $table = new DataTable('invoice_template', $query);
$table->setPagination($entries); $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'])] #[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')] #[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'])] #[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
#[IsGranted('upload_invoice_template')] #[IsGranted('upload_invoice_template')]
public function downloadDocument(string $document, 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); $this->dispatcher->dispatch($event);
foreach ($event->getInvoiceDocuments() as $doc) { 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'])] #[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')] #[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
{ {
$dir = $documentRepository->getUploadDirectory(); $dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir; $invoiceDir = $dir;
@@ -484,11 +473,11 @@ final class InvoiceController extends AbstractController
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$used = []; $used = [];
foreach ($this->templateRepository->findAll() as $template) { foreach ($templateRepository->findAll() as $template) {
$used[$template->getRenderer()] = $template; $used[$template->getRenderer()] = $template;
} }
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true)); $event = new InvoiceDocumentsEvent($service->getDocuments(true));
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
$documents = []; $documents = [];
@@ -606,7 +595,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])] #[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')] #[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); $document = $documentRepository->findByName($id);
if ($document === null) { 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) { if ($template->getRenderer() === $id) {
$this->flashError('Document is used and cannot be deleted.'); $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'])] #[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')] #[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'])] #[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')] #[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 = new InvoiceTemplate();
$template->setLanguage($request->getLocale()); $template->setLanguage($request->getLocale());
@@ -671,12 +660,12 @@ final class InvoiceController extends AbstractController
$template->setName($copyFrom->getName() . ' (1)'); $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'])] #[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')] #[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))) { if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
$this->flashError('action.csrf.error'); $this->flashError('action.csrf.error');
@@ -687,7 +676,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.delete_template'); $csrfTokenManager->refreshToken('invoice.delete_template');
try { try {
$this->templateRepository->removeTemplate($template); $templateRepository->removeTemplate($template);
$this->flashSuccess('action.delete.success'); $this->flashSuccess('action.delete.success');
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashDeleteException($ex); $this->flashDeleteException($ex);
@@ -727,7 +716,7 @@ final class InvoiceController extends AbstractController
$this->flashError('action.update.error', $err); $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); $event = new InvoiceTemplateMetaDefinitionEvent($template);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
@@ -738,7 +727,7 @@ final class InvoiceController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->templateRepository->saveTemplate($template); $templateRepository->saveTemplate($template);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_template'); return $this->redirectToRoute('admin_invoice_template');

View File

@@ -88,6 +88,10 @@ class InvoiceTemplate implements EntityWithMetaFields
*/ */
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
private Collection $meta; private Collection $meta;
/**
* @var array<Tax>
*/
private array $taxRates = [];
public function __construct() public function __construct()
{ {
@@ -268,16 +272,29 @@ class InvoiceTemplate implements EntityWithMetaFields
$this->language = $language; $this->language = $language;
} }
/**
* @param array<Tax> $taxRates
*/
public function setTaxRates(array $taxRates): void
{
$this->taxRates = $taxRates;
}
/** /**
* @return Tax[] * @return Tax[]
*/ */
public function getTaxRates(): array public function getTaxRates(): array
{ {
// TODO make me configurable via UI if (\count($this->taxRates) > 0) {
return $this->taxRates;
}
$tax = new Tax( $tax = new Tax(
TaxType::STANDARD, TaxType::STANDARD,
'VAT', $this->vat ?? 0.00,
$this->vat ?? 0.00 'vat',
true,
null
); );
return [$tax]; return [$tax];

View File

@@ -13,8 +13,10 @@ final class Tax
{ {
public function __construct( public function __construct(
private readonly TaxType $type, private readonly TaxType $type,
private readonly string $name = 'VAT', private readonly float $rate,
private readonly float $rate = 0.0, private readonly string $name,
private readonly bool $show,
private readonly ?string $note,
) )
{ {
} }
@@ -33,4 +35,14 @@ final class Tax
{ {
return $this->rate; return $this->rate;
} }
public function isShow(): bool
{
return $this->rate > 0.0 || $this->show;
}
public function getNote(): ?string
{
return $this->note;
}
} }

View File

@@ -14,6 +14,14 @@ use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument; use App\Model\InvoiceDocument;
use Symfony\Contracts\EventDispatcher\Event; 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 final class InvoicePreRenderEvent extends Event
{ {
public function __construct( public function __construct(

View File

@@ -31,6 +31,10 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$subtotal = $calculator->getSubtotal(); $subtotal = $calculator->getSubtotal();
$formatter = $model->getFormatter(); $formatter = $model->getFormatter();
$language = $template->getLanguage(); $language = $template->getLanguage();
if ($language === null) {
throw new \InvalidArgumentException('InvoiceTemplate needs a language');
}
$taxRows = $calculator->getTaxRows(); $taxRows = $calculator->getTaxRows();
$vat = 0.00; $vat = 0.00;
@@ -48,7 +52,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.language' => $language, // since 1.9 'invoice.language' => $language, // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency), 'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead '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' => $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_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead
'invoice.tax_plain' => $tax, // @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'] = []; $values['invoice.tax_rows'] = [];
$counter = 1;
foreach ($taxRows as $taxRow) { foreach ($taxRows as $taxRow) {
$tax = $taxRow->getTax();
$values['invoice.tax_rows'][] = [ $values['invoice.tax_rows'][] = [
'type' => $taxRow->getTax()->getType()->value, 'counter' => $counter++,
'name' => $taxRow->getTax()->getName(), 'type' => $tax->getType()->value,
'rate' => $taxRow->getTax()->getRate(), '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 'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway 'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
]; ];

View File

@@ -9,6 +9,9 @@
namespace App\Invoice; namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceItemHydrator interface InvoiceItemHydrator
{ {
public function setInvoiceModel(InvoiceModel $model): void; public function setInvoiceModel(InvoiceModel $model): void;

View File

@@ -9,20 +9,10 @@
namespace App\Invoice; namespace App\Invoice;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\User; 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\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode; use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\DependencyInjection\Attribute\Exclude;
@@ -43,7 +33,6 @@ final class InvoiceModel
private ?NumberGeneratorInterface $generator = null; private ?NumberGeneratorInterface $generator = null;
private \DateTimeInterface $invoiceDate; private \DateTimeInterface $invoiceDate;
private ?User $user = null; private ?User $user = null;
private InvoiceFormatter $formatter;
/** /**
* @var InvoiceModelHydrator[] * @var InvoiceModelHydrator[]
*/ */
@@ -64,24 +53,13 @@ final class InvoiceModel
* @internal use InvoiceModelFactory * @internal use InvoiceModelFactory
*/ */
public function __construct( public function __construct(
InvoiceFormatter $formatter, private InvoiceFormatter $formatter,
CustomerStatisticService $customerStatistic,
ProjectStatisticService $projectStatistic,
ActivityStatisticService $activityStatistic,
private readonly Customer $customer, private readonly Customer $customer,
private readonly InvoiceTemplate $template, private readonly InvoiceTemplate $template,
private readonly RateCalculatorMode $rateCalculatorMode private readonly RateCalculatorMode $rateCalculatorMode
) )
{ {
$this->invoiceDate = new \DateTimeImmutable(); $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());
} }
/** /**

View File

@@ -9,27 +9,36 @@
namespace App\Invoice; namespace App\Invoice;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode; use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class InvoiceModelFactory final class InvoiceModelFactory
{ {
/**
* @param iterable<InvoiceModelHydrator> $modelHydrators
* @param iterable<InvoiceItemHydrator> $itemHydrators
*/
public function __construct( public function __construct(
private readonly CustomerStatisticService $customerStatisticService, private readonly RateCalculatorMode $rateCalculatorMode,
private readonly ProjectStatisticService $projectStatisticService, #[TaggedIterator(InvoiceModelHydrator::class)]
private readonly ActivityStatisticService $activityStatisticService, private readonly iterable $modelHydrators,
private readonly RateCalculatorMode $rateCalculatorMode #[TaggedIterator(InvoiceItemHydrator::class)]
private readonly iterable $itemHydrators,
) { ) {
} }
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel 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); $model->setQuery($query);

View File

@@ -9,6 +9,9 @@
namespace App\Invoice; namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceModelHydrator interface InvoiceModelHydrator
{ {
public function hydrate(InvoiceModel $model): array; public function hydrate(InvoiceModel $model): array;

View File

@@ -135,20 +135,25 @@ mpdf-->
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr> <tr>
<td colspan="4" class="text-right"> <td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }} {{ 'invoice.subtotal'|trans }}
</td> </td>
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td> <td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr> </tr>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr> <tr>
<td colspan="4" class="text-right"> <td colspan="4" class="text-right">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%) {{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td> </td>
<td class="last text-right">{{ invoice['invoice.tax'] }}</td> <td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% endfor %}
<tr> <tr>
<td colspan="4" class="text-right text-nowrap"> <td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong> <strong>{{ 'invoice.total'|trans }}</strong>
@@ -160,10 +165,17 @@ mpdf-->
</tfoot> </tfoot>
</table> </table>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %} {% if invoice['template.payment_terms'] is not empty %}
<div class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }} {{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -14,7 +14,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<h2 class="page-header"> <h2 class="page-header">
<span contenteditable="true">{{ invoice['template.title'] }}</span> <span>{{ invoice['template.title'] }}</span>
</h2> </h2>
</div> </div>
</div> </div>
@@ -22,7 +22,7 @@
<div class="row"> <div class="row">
<div class="col-sm-5"> <div class="col-sm-5">
{{ 'invoice.to'|trans }} {{ 'invoice.to'|trans }}
<address contenteditable="true"> <address>
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br> <strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }} {{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %} {% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
@@ -46,7 +46,7 @@
<div class="col-sm-2"></div> <div class="col-sm-2"></div>
<div class="col-sm-5"> <div class="col-sm-5">
{{ 'invoice.from'|trans }} {{ 'invoice.from'|trans }}
<address contenteditable="true"> <address>
<strong>{{ invoice['template.company'] }}</strong><br> <strong>{{ invoice['template.company'] }}</strong><br>
{{ invoice['template.address']|trim|nl2br }} {{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %} {% if invoice['template.country'] is not null %}
@@ -66,7 +66,7 @@
<div class="row"> <div class="row">
<div class="col-sm-5"> <div class="col-sm-5">
<p contenteditable="true"> <p>
<strong>{{ 'date'|trans }}:</strong> <strong>{{ 'date'|trans }}:</strong>
{{ invoice['invoice.date'] }} {{ invoice['invoice.date'] }}
@@ -98,7 +98,7 @@
{% for invoiceLineItem in entries %} {% for invoiceLineItem in entries %}
<tr> <tr>
<td nowrap class="text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td> <td nowrap class="text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td>
<td contenteditable="true"> <td>
{% if invoiceLineItem['entry.description'] is not empty %} {% if invoiceLineItem['entry.description'] is not empty %}
{{ invoiceLineItem['entry.description']|nl2br }} {{ invoiceLineItem['entry.description']|nl2br }}
{% else %} {% else %}
@@ -115,20 +115,25 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr> <tr>
<td colspan="4" class="text-end"> <td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }} {{ 'invoice.subtotal'|trans }}
</td> </td>
<td class="text-end">{{ invoice['invoice.subtotal'] }}</td> <td class="text-end">{{ invoice['invoice.subtotal'] }}</td>
</tr> </tr>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr> <tr>
<td colspan="4" class="text-end"> <td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%) {{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td> </td>
<td class="text-end">{{ invoice['invoice.tax'] }}</td> <td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% endfor %}
<tr> <tr>
<td colspan="4" class="text-end text-nowrap"> <td colspan="4" class="text-end text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong> <strong>{{ 'invoice.total'|trans }}</strong>
@@ -144,10 +149,16 @@
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %} {% if invoice['template.payment_terms'] is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }} {{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -150,20 +150,25 @@ mpdf-->
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr> <tr>
<td colspan="3" class="text-right"> <td colspan="3" class="text-right">
{{ 'invoice.subtotal'|trans }} {{ 'invoice.subtotal'|trans }}
</td> </td>
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td> <td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr> </tr>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr> <tr>
<td colspan="3" class="text-right"> <td colspan="3" class="text-right">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%) {{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td> </td>
<td class="last text-right">{{ invoice['invoice.tax'] }}</td> <td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% endfor %}
<tr> <tr>
<td colspan="3" class="text-right text-nowrap"> <td colspan="3" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong> <strong>{{ 'invoice.total'|trans }}</strong>
@@ -175,10 +180,17 @@ mpdf-->
</tfoot> </tfoot>
</table> </table>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %} {% if invoice['template.payment_terms'] is not empty %}
<div class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }} {{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -13,6 +13,8 @@ use App\Entity\Customer;
use App\Entity\CustomerMeta; use App\Entity\CustomerMeta;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta; use App\Entity\InvoiceTemplateMeta;
use App\Entity\Tax;
use App\Entity\TaxType;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -44,6 +46,48 @@ class InvoiceTemplateTest extends TestCase
self::assertNull($sut->getMetaField('foo')); 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 public function testSetterAndGetter(): void
{ {
$sut = new InvoiceTemplate(); $sut = new InvoiceTemplate();

View File

@@ -5,7 +5,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style type="text/css"> <style type="text/css">
{{ encore_entry_css_source('invoice')|raw }} {{ encore_entry_css_source('invoice-pdf')|raw }}
</style> </style>
</head> </head>
<body class="invoice_print"> <body class="invoice_print">

View File

@@ -11,6 +11,13 @@ namespace App\Tests\Mocks;
use App\Activity\ActivityStatisticService; use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService; 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\Invoice\InvoiceModelFactory;
use App\Project\ProjectStatisticService; use App\Project\ProjectStatisticService;
use App\Timesheet\RateCalculator\DecimalRateCalculator; use App\Timesheet\RateCalculator\DecimalRateCalculator;
@@ -25,8 +32,19 @@ class InvoiceModelFactoryFactory extends AbstractMockFactory
$projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock(); $projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock();
/** @var ActivityStatisticService $activityStatistic */ /** @var ActivityStatisticService $activityStatistic */
$activityStatistic = $this->getMockBuilder(ActivityStatisticService::class)->disableOriginalConstructor()->getMock(); $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);
} }
} }

View File

@@ -2046,6 +2046,26 @@
<source>electronic_invoice</source> <source>electronic_invoice</source>
<target>E-Rechnung</target> <target>E-Rechnung</target>
</trans-unit> </trans-unit>
<trans-unit id="QnN_ONc" resname="invoice_tax_type">
<source>invoice_tax_type</source>
<target>Steuerstatus</target>
</trans-unit>
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt">
<source>tax.type.exempt</source>
<target>Ausgenommen</target>
</trans-unit>
<trans-unit id="lU1Njcy" resname="tax.type.standard">
<source>tax.type.standard</source>
<target>Steuerpflichtig</target>
</trans-unit>
<trans-unit id="E470U8W" resname="tax.type.reverse">
<source>tax.type.reverse</source>
<target>Umkehrung der Steuerschuld</target>
</trans-unit>
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge">
<source>tax.note.reverse_charge</source>
<target>Steuer zahlbar auf Reverse-Charge-Basis</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -2046,6 +2046,26 @@
<source>electronic_invoice</source> <source>electronic_invoice</source>
<target>E-Invoice</target> <target>E-Invoice</target>
</trans-unit> </trans-unit>
<trans-unit id="QnN_ONc" resname="invoice_tax_type">
<source>invoice_tax_type</source>
<target>Tax status</target>
</trans-unit>
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt">
<source>tax.type.exempt</source>
<target>Exempt</target>
</trans-unit>
<trans-unit id="lU1Njcy" resname="tax.type.standard">
<source>tax.type.standard</source>
<target>Taxable</target>
</trans-unit>
<trans-unit id="E470U8W" resname="tax.type.reverse">
<source>tax.type.reverse</source>
<target>Reverse charge</target>
</trans-unit>
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge">
<source>tax.note.reverse_charge</source>
<target>Tax to be paid on reverse charge basis</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>