diff --git a/backend/src/main/bundles/dev.bundle b/backend/src/main/bundles/dev.bundle index 928496d..345bbd5 100644 Binary files a/backend/src/main/bundles/dev.bundle and b/backend/src/main/bundles/dev.bundle differ diff --git a/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js b/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js index 26ee773..96d3d28 100644 --- a/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js +++ b/backend/src/main/frontend/invoice-generator/profile-invoice-generator.js @@ -7,9 +7,40 @@ window.profileInvoiceState = window.profileInvoiceState || { initialized: false }; -window.initProfileInvoiceGenerator = function() { +// A tab can still be hidden when Vaadin sends its selected-change response. +// Wait for its actual layout instead of relying on fixed delays and a server callback. +window.showProfileInvoiceTemplate = function(container, view, templateData, masterdata, vatRate) { + if (window.pendingProfileInvoiceLoad) { + window.pendingProfileInvoiceLoad.disconnect(); + window.pendingProfileInvoiceLoad = null; + } + function loadWhenVisible() { + var rect = container.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + window.invoiceGeneratorViewProfile = view; + window.profileInvoiceServiceData = null; + window.profileInvoiceVatRate = vatRate; + window.masterdataValues = masterdata; + window.initProfileInvoiceGenerator(container); + window.loadProfileTemplate(templateData); + return true; + } + if (!loadWhenVisible()) { + var observer = new ResizeObserver(function() { + if (window.pendingProfileInvoiceLoad !== observer) return; + if (loadWhenVisible()) { + observer.disconnect(); + if (window.pendingProfileInvoiceLoad === observer) window.pendingProfileInvoiceLoad = null; + } + }); + window.pendingProfileInvoiceLoad = observer; + observer.observe(container); + } +}; + +window.initProfileInvoiceGenerator = function(container) { var containerId = 'invoice-canvas-container-profile'; - var container = document.getElementById(containerId); + container = container || document.getElementById(containerId); if (!container) { console.error('Canvas container not found:', containerId); @@ -1303,7 +1334,7 @@ window.initProfileInvoiceGenerator = function() { } else if (el.variable.startsWith('masterdata.')) { // Masterdata variables - use actual value from masterdata or placeholder var value = masterdata[el.variable]; - if (value && value.trim() !== '') { + if (typeof value === 'string') { el.text = value; } else { el.text = '[' + el.variable.replace('masterdata.', '') + ']'; diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index 61c93c5..23327b1 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -33,6 +33,7 @@ import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.service.CustomerInvoiceService; +import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables; import de.assecutor.votianlt.service.EmailService; import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleService; @@ -546,15 +547,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter currentUser = currentUserOpt.get(); - Optional templateOpt = invoiceTemplateService - .getTemplateByUserId(currentUser.getId().toString()); - if (templateOpt.isEmpty()) { - Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000, - Notification.Position.BOTTOM_END); - return; - } - - String templateData = templateOpt.get().getTemplateData(); + InvoiceTemplate template = invoiceTemplateService + .getCustomerTemplateByUserId(currentUser.getId().toString()); + String templateData = template.getTemplateData(); if (templateData == null || templateData.isBlank()) { Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000, Notification.Position.BOTTOM_END); @@ -822,15 +817,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter BigDecimal totalAmount = netAmount.add(vatAmount); // Build variable substitution map - Map variables = new HashMap<>(); - - // Master data (from user profile) - variables.put("masterdata.company_name", safe(currentUser.getCompany())); - variables.put("masterdata.contact_name", safe(currentUser.getFirstname()) + " " + safe(currentUser.getName())); - variables.put("masterdata.street", safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber())); - variables.put("masterdata.city", safe(currentUser.getZip()) + " " + safe(currentUser.getCity())); - variables.put("masterdata.email", safe(currentUser.getEmail())); - variables.put("masterdata.phone", safe(currentUser.getPhone())); + Map variables = CustomerInvoiceTemplateVariables.create(currentUser, + userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null), invoiceNumber); // Customer data (from job - look up Customer entity for full address) String customerSelection = currentJob.getCustomerSelection(); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index f618931..9e34b34 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -44,6 +44,7 @@ import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.service.CustomerInvoiceService; +import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables; import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.LanguageService; import com.vaadin.flow.component.grid.Grid; @@ -78,6 +79,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle private Checkbox billingEnabled; private NumberField vatRateField; private VerticalLayout propertiesPanelProfile; + private final Div canvasContainer; private final ServiceRepository serviceRepository; private Grid servicesGrid; @@ -414,7 +416,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)"); // Mitte: Canvas - Div canvasContainer = new Div(); + canvasContainer = new Div(); canvasContainer.setId("invoice-canvas-container-profile"); canvasContainer.setWidth("100%"); canvasContainer.setHeight("100%"); @@ -513,17 +515,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle // Bestehende Rechnungsdaten laden (nur für die Checkbox) loadInvoiceData(); - // Initialize invoice generator when the billing tab is selected - // Also register this view instance for JavaScript callbacks + // Load directly; the client waits until the billing canvas is visible. tabSheet.addSelectedChangeListener(e -> { if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) { - getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" - + "window.profileInvoiceServiceData = null;" + "setTimeout(function() { " - + " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); " - + " console.log('Canvas initialized, now loading template...'); " - + " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); " - + " } else { " + " console.error('initProfileInvoiceGenerator not found'); " + " } " - + "}, 300);", getElement()); + loadInvoiceTemplate(); } }); @@ -858,6 +853,16 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(), prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(), ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.getValue()); + try { + String valuesJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString( + CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData, + prefixField.getValue() + "12345")); + getElement().executeJs("if (window.updateProfileMasterdataValue) { " + + "Object.entries(JSON.parse($0)).forEach(([key, value]) => " + + "window.updateProfileMasterdataValue(key, value)); }", valuesJson); + } catch (com.fasterxml.jackson.core.JsonProcessingException ex) { + throw new IllegalStateException("Could not update invoice template values", ex); + } } private String safe(String value) { @@ -957,7 +962,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle } BigDecimal previewVatRate = getPreviewVatRate(); byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData, - currentUser, prefixField.getValue(), previewVatRate); + currentUser, prefixField.getValue(), previewVatRate, currentInvoiceData); showPdfInDialog(pdfBytes); } catch (Exception ex) { Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()), @@ -1479,62 +1484,23 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle })); } - /** - * Called by JavaScript when the canvas is ready - */ - @ClientCallable - public void onCanvasReady() { - System.out.println("Canvas ready, loading template..."); - loadInvoiceTemplate(); - } - /** * Load the saved invoice template from database and display it on the canvas */ private void loadInvoiceTemplate() { try { - java.util.Optional optionalTemplate = invoiceTemplateService - .getTemplateByUserId(currentUser.getId().toString()); - if (optionalTemplate.isPresent()) { - String templateData = optionalTemplate.get().getTemplateData(); - if (templateData != null && !templateData.isEmpty()) { - System.out.println("Loading template data: " - + templateData.substring(0, Math.min(100, templateData.length())) + "..."); - // Escape the JSON string for JavaScript - String escapedJson = templateData.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n") - .replace("\r", "\\r"); - // Build masterdata values JSON - String company = safe(currentUser.getCompany()); - String fullName = safe(currentUser.getFirstname()) + " " + safe(currentUser.getName()); - String street = safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber()); - String city = safe(currentUser.getZip()) + " " + safe(currentUser.getCity()); - String email = safe(currentUser.getEmail()); - String phone = safe(currentUser.getPhone()); - String prefix = prefixField.getValue(); - String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345"; - String masterdataJson = "{" + "'masterdata.company_name': '" + company.replace("'", "\\'") + "'," - + "'masterdata.contact_name': '" + fullName.replace("'", "\\'") + "'," - + "'masterdata.street': '" + street.replace("'", "\\'") + "'," + "'masterdata.city': '" - + city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'") - + "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "'," - + "'masterdata.invoice_number': '" + invoiceNumber.replace("'", "\\'") + "'" + "}"; - BigDecimal rate = currentUser.getVatRate() != null ? currentUser.getVatRate() - : Service.FIXED_VAT_RATE; - double vatRateJs = rate.doubleValue(); - getElement().executeJs("setTimeout(function() { " - + " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { " - + " console.log('Loading template into canvas...'); " - + " window.profileInvoiceServiceData = null; " - + " window.profileInvoiceVatRate = " + vatRateJs + "; " - + " window.masterdataValues = " - + masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); " - + " window.loadProfileTemplate(templateData); " + " } else { " - + " console.error('loadProfileTemplate or canvas not available'); " + " } " - + "}, 300);"); - } - } else { - System.out.println("No template found for user: " + currentUser.getId()); + String templateData = invoiceTemplateService.getCustomerTemplateByUserId(currentUser.getId().toString()) + .getTemplateData(); + if (templateData == null || templateData.isBlank()) { + return; } + String prefix = prefixField.getValue(); + String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345"; + String masterdataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString( + CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData, invoiceNumber)); + getElement().executeJs("window.showProfileInvoiceTemplate($0, $1, JSON.parse($2), JSON.parse($3), $4);", + canvasContainer.getElement(), getElement(), templateData, masterdataJson, + getPreviewVatRate().doubleValue()); } catch (Exception ex) { System.err.println("Fehler beim Laden des Templates: " + ex.getMessage()); ex.printStackTrace(); diff --git a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index 79a153d..a84653b 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -263,6 +263,12 @@ public class CustomerInvoiceService { public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user, String invoicePrefix, BigDecimal vatRate) throws Exception { + return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, vatRate, null); + } + + public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user, + String invoicePrefix, BigDecimal vatRate, de.assecutor.votianlt.model.UserInvoiceData billing) + throws Exception { BigDecimal effectiveVatRate = vatRate != null ? vatRate : (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19")); @@ -270,16 +276,8 @@ public class CustomerInvoiceService { java.util.Map variables = new java.util.HashMap<>(); if (user != null) { - // Use actual user data - String company = user.getCompany(); - variables.put("masterdata.company_name", (company != null && !company.isEmpty()) ? company : "Ihre Firma"); - variables.put("masterdata.contact_name", safe(user.getFirstname()) + " " + safe(user.getName())); - variables.put("masterdata.street", safe(user.getStreet()) + " " + safe(user.getHouseNumber())); - variables.put("masterdata.city", safe(user.getZip()) + " " + safe(user.getCity())); - variables.put("masterdata.email", safe(user.getEmail())); - variables.put("masterdata.phone", safe(user.getPhone())); - variables.put("masterdata.invoice_number", - (invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345"); + variables.putAll(CustomerInvoiceTemplateVariables.create(user, billing, + (invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345")); } else { // Default values for preview without user variables.put("masterdata.company_name", "Meine Firma GmbH"); @@ -575,6 +573,14 @@ public class CustomerInvoiceService { .append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append(""); html.append(""); + html.append(""); + html.append(""); + html.append("zzgl. ") + .append(vatLabel).append(" USt:"); + html.append("") + .append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal * vatRate.doubleValue())) + .append(""); + // Gesamtsumme - label in col 2, value in col 3 html.append(""); html.append(""); diff --git a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateVariables.java b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateVariables.java new file mode 100644 index 0000000..4d6375e --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateVariables.java @@ -0,0 +1,61 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.UserInvoiceData; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** Shared issuer values for the customer editor, PDF preview and actual invoice. */ +public final class CustomerInvoiceTemplateVariables { + private CustomerInvoiceTemplateVariables() { + } + + public static Map create(User user, UserInvoiceData billing, String invoiceNumber) { + Map values = new LinkedHashMap<>(); + String contact = join(" ", user.getFirstname(), user.getName()); + String company = safe(user.getCompany()); + if (company.isEmpty()) { + company = contact; + } + String street = join(" ", user.getStreet(), user.getHouseNumber()); + String city = join(" ", user.getZip(), user.getCity()); + values.put("masterdata.company_name", company); + values.put("masterdata.contact_name", contact); + values.put("masterdata.street", street); + values.put("masterdata.city", city); + values.put("masterdata.email", safe(user.getEmail())); + values.put("masterdata.phone", safe(user.getPhone())); + values.put("masterdata.sender_line", join(" · ", company, street, city)); + values.put("masterdata.contact_details", join("\n", user.getEmail(), user.getPhone())); + values.put("masterdata.invoice_number", safe(invoiceNumber)); + values.put("masterdata.invoice_date", LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))); + values.put("masterdata.tax_details", billing == null ? "" + : join("\n", labeled("USt-IdNr.: ", billing.getUstId()), + labeled("Steuernummer: ", billing.getTaxNumber()))); + values.put("masterdata.bank_details", billing == null ? "" + : join("\n", billing.getBankName(), labeled("IBAN: ", billing.getIban()))); + String intro = billing == null ? "" : safe(billing.getIntroText()); + values.put("masterdata.intro", intro.isEmpty() ? "Wir berechnen Ihnen folgende Leistungen:" : intro); + String terms = billing == null ? "" : safe(billing.getPaymentTerms()); + values.put("masterdata.payment_terms", terms.isEmpty() + ? "Bitte geben Sie bei der Zahlung die Rechnungsnummer an." : terms); + return values; + } + + private static String labeled(String label, String value) { + return safe(value).isEmpty() ? "" : label + safe(value); + } + + private static String join(String separator, String... parts) { + return Stream.of(parts).map(CustomerInvoiceTemplateVariables::safe).filter(s -> !s.isEmpty()) + .collect(Collectors.joining(separator)); + } + + private static String safe(String value) { + return value == null ? "" : value.strip(); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/DemoModeService.java b/backend/src/main/java/de/assecutor/votianlt/service/DemoModeService.java index f432a16..8a90167 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/DemoModeService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/DemoModeService.java @@ -1,12 +1,10 @@ package de.assecutor.votianlt.service; -import com.fasterxml.jackson.databind.ObjectMapper; import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.DeliveryStation; -import de.assecutor.votianlt.model.InvoiceTemplate; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.JobStatus; @@ -37,9 +35,7 @@ import de.assecutor.votianlt.repository.TaskTemplateRepository; import de.assecutor.votianlt.repository.UserInvoiceDataRepository; import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.security.SessionAuthenticationService; -import java.io.IOException; import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -54,7 +50,6 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; -import org.springframework.core.io.ClassPathResource; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; @@ -68,7 +63,6 @@ public class DemoModeService { public static final String DEMO_PASSWORD = "demo"; private static final String DEMO_FIRST_NAME = "Demo"; private static final String DEMO_LAST_NAME = "Benutzer"; - private static final String DEMO_INVOICE_TEMPLATE_RESOURCE = "templates/demo_invoice_template_default.json"; private final DemoSessionRegistry demoSessionRegistry; private final SessionAuthenticationService sessionAuthenticationService; @@ -194,7 +188,6 @@ public class DemoModeService { List services = seedDemoServices(demoUser); List customers = seedDemoCustomers(demoUser); seedDemoJobs(demoUser, customers, services); - seedDemoInvoiceTemplate(demoUser); } private void cleanupDemoAppUserData(List demoAppUsers) { @@ -441,23 +434,6 @@ public class DemoModeService { return selection; } - private void seedDemoInvoiceTemplate(User demoUser) { - InvoiceTemplate template = new InvoiceTemplate(demoUser.getId().toHexString(), "Einfach", - createDemoInvoiceTemplateData()); - invoiceTemplateRepository.save(template); - } - - private String createDemoInvoiceTemplateData() { - try { - String templateJson = new String( - new ClassPathResource(DEMO_INVOICE_TEMPLATE_RESOURCE).getInputStream().readAllBytes(), - StandardCharsets.UTF_8); - return new ObjectMapper().writeValueAsString(templateJson); - } catch (IOException ex) { - throw new IllegalStateException("Could not load demo invoice template", ex); - } - } - private void applyCustomerToPickup(Job job, Customer customer) { if (job == null || customer == null) { return; diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java index 46e9d0d..80b8ced 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java @@ -1,16 +1,23 @@ package de.assecutor.votianlt.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; import de.assecutor.votianlt.model.InvoiceTemplate; import de.assecutor.votianlt.repository.InvoiceTemplateRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.core.io.ClassPathResource; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Optional; @Service @RequiredArgsConstructor public class InvoiceTemplateService { + public static final String CUSTOMER_DEFAULT_USER_ID = "CUSTOMER_DEFAULT"; + private final InvoiceTemplateRepository invoiceTemplateRepository; /** @@ -36,6 +43,49 @@ public class InvoiceTemplateService { return invoiceTemplateRepository.findByUserId(userId); } + /** + * Customer invoices use the neutral default if no usable personal template is + * stored. Reading the default never creates or overwrites a database record. + */ + public InvoiceTemplate getCustomerTemplateByUserId(String userId) { + if (userId == null || userId.isBlank() || "SYSTEM".equals(userId) || CUSTOMER_DEFAULT_USER_ID.equals(userId)) { + throw new IllegalArgumentException("A customer user ID is required"); + } + return invoiceTemplateRepository.findByUserId(userId) + .filter(template -> hasTemplateElements(template.getTemplateData())) + .orElseGet(() -> invoiceTemplateRepository.findByUserId(CUSTOMER_DEFAULT_USER_ID) + .filter(template -> hasTemplateElements(template.getTemplateData())) + // Saving the customer's edits must never change the shared default. + .map(template -> new InvoiceTemplate(userId, template.getName(), template.getTemplateData())) + .orElseGet(() -> loadBundledCustomerDefault(userId))); + } + + private InvoiceTemplate loadBundledCustomerDefault(String userId) { + try (var input = new ClassPathResource("templates/customer_invoice_template_neutral.json").getInputStream()) { + String json = new String(input.readAllBytes(), StandardCharsets.UTF_8); + return new InvoiceTemplate(userId, "Neutral", new ObjectMapper().writeValueAsString(json)); + } catch (IOException ex) { + throw new IllegalStateException("Could not load neutral customer invoice template", ex); + } + } + + private boolean hasTemplateElements(String templateData) { + if (templateData == null || templateData.isBlank()) { + return false; + } + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode data = mapper.readTree(templateData); + // Templates can be stored as an object or as a JSON-encoded string. + if (data != null && data.isTextual()) { + data = mapper.readTree(data.textValue()); + } + return data != null && data.path("elements").isArray() && !data.path("elements").isEmpty(); + } catch (IOException ex) { + return false; + } + } + /** * Check if a template exists for a user */ diff --git a/backend/src/main/resources/templates/customer_invoice_template_neutral.json b/backend/src/main/resources/templates/customer_invoice_template_neutral.json new file mode 100644 index 0000000..2fde8d0 --- /dev/null +++ b/backend/src/main/resources/templates/customer_invoice_template_neutral.json @@ -0,0 +1,420 @@ +{ + "elements": [ + { + "id": "neutral-company", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "5.70071", + "widthPercent": "61.51261", + "heightPercent": "3.56295", + "fontSize": 20, + "fontStyle": "bold", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.company_name" + }, + { + "id": "neutral-document-label", + "type": "text", + "text": "RECHNUNG", + "xPercent": "71.93277", + "yPercent": "6.88836", + "widthPercent": "19.32773", + "heightPercent": "2.13777", + "fontSize": 11, + "fontStyle": "normal", + "textAlign": "right", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-street", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "10.80760", + "widthPercent": "57.14286", + "heightPercent": "1.66271", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.street" + }, + { + "id": "neutral-city", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "12.58907", + "widthPercent": "57.14286", + "heightPercent": "1.66271", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.city" + }, + { + "id": "neutral-header-rule", + "type": "line", + "text": "", + "xPercent": "8.73950", + "yPercent": "16.50831", + "widthPercent": "82.52101", + "heightPercent": "0.11876", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-sender", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "20.30879", + "widthPercent": "82.52101", + "heightPercent": "1.18765", + "fontSize": 7, + "fontStyle": "normal", + "textAlign": "left", + "color": "#555555", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.sender_line" + }, + { + "id": "neutral-recipient-company_name", + "type": "text", + "text": "Kundenfirma GmbH", + "xPercent": "8.73950", + "yPercent": "23.27791", + "widthPercent": "47.89916", + "heightPercent": "1.78147", + "fontSize": 11, + "fontStyle": "bold", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": true, + "variable": "customer.company_name" + }, + { + "id": "neutral-recipient-contact_name", + "type": "text", + "text": "Erika Mustermann", + "xPercent": "8.73950", + "yPercent": "25.05938", + "widthPercent": "47.89916", + "heightPercent": "1.78147", + "fontSize": 11, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": true, + "variable": "customer.contact_name" + }, + { + "id": "neutral-recipient-street", + "type": "text", + "text": "Kundenstraße 456", + "xPercent": "8.73950", + "yPercent": "26.84086", + "widthPercent": "47.89916", + "heightPercent": "1.78147", + "fontSize": 11, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": true, + "variable": "customer.street" + }, + { + "id": "neutral-recipient-city", + "type": "text", + "text": "54321 Kundenstadt", + "xPercent": "8.73950", + "yPercent": "28.62233", + "widthPercent": "47.89916", + "heightPercent": "1.78147", + "fontSize": 11, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": true, + "variable": "customer.city" + }, + { + "id": "neutral-number-label", + "type": "text", + "text": "RECHNUNGSNUMMER", + "xPercent": "63.52941", + "yPercent": "23.15914", + "widthPercent": "27.73109", + "heightPercent": "1.42518", + "fontSize": 8, + "fontStyle": "normal", + "textAlign": "left", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-number", + "type": "text", + "text": "", + "xPercent": "63.52941", + "yPercent": "25.05938", + "widthPercent": "27.73109", + "heightPercent": "1.90024", + "fontSize": 11, + "fontStyle": "bold", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.invoice_number" + }, + { + "id": "neutral-date-label", + "type": "text", + "text": "RECHNUNGSDATUM", + "xPercent": "63.52941", + "yPercent": "28.38480", + "widthPercent": "27.73109", + "heightPercent": "1.42518", + "fontSize": 8, + "fontStyle": "normal", + "textAlign": "left", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-date", + "type": "text", + "text": "", + "xPercent": "63.52941", + "yPercent": "30.28504", + "widthPercent": "27.73109", + "heightPercent": "1.90024", + "fontSize": 11, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.invoice_date" + }, + { + "id": "neutral-services-heading", + "type": "text", + "text": "Leistungen", + "xPercent": "8.73950", + "yPercent": "36.34204", + "widthPercent": "82.52101", + "heightPercent": "2.37530", + "fontSize": 14, + "fontStyle": "bold", + "textAlign": "left", + "color": "#202020", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-intro", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "39.90499", + "widthPercent": "82.52101", + "heightPercent": "3.56295", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.intro" + }, + { + "id": "neutral-services", + "type": "text", + "text": "Rechnungspositionen", + "xPercent": "8.73950", + "yPercent": "45.24941", + "widthPercent": "82.52101", + "heightPercent": "27.67221", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "services.list" + }, + { + "id": "neutral-payment-label", + "type": "text", + "text": "ZAHLUNG", + "xPercent": "8.73950", + "yPercent": "78.02850", + "widthPercent": "82.52101", + "heightPercent": "1.42518", + "fontSize": 8, + "fontStyle": "bold", + "textAlign": "left", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-payment", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "80.40380", + "widthPercent": "82.52101", + "heightPercent": "4.98812", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.payment_terms" + }, + { + "id": "neutral-footer-rule", + "type": "line", + "text": "", + "xPercent": "8.73950", + "yPercent": "88.83610", + "widthPercent": "82.52101", + "heightPercent": "0.11876", + "fontSize": 10, + "fontStyle": "normal", + "textAlign": "left", + "color": "#202020", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-contact-label", + "type": "text", + "text": "KONTAKT", + "xPercent": "8.73950", + "yPercent": "90.61758", + "widthPercent": "26.21849", + "heightPercent": "1.30641", + "fontSize": 8, + "fontStyle": "bold", + "textAlign": "left", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-contact", + "type": "text", + "text": "", + "xPercent": "8.73950", + "yPercent": "92.87411", + "widthPercent": "26.21849", + "heightPercent": "3.32542", + "fontSize": 8, + "fontStyle": "normal", + "textAlign": "left", + "color": "#444444", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.contact_details" + }, + { + "id": "neutral-tax-label", + "type": "text", + "text": "STEUERANGABEN", + "xPercent": "38.65546", + "yPercent": "90.61758", + "widthPercent": "25.37815", + "heightPercent": "1.30641", + "fontSize": 8, + "fontStyle": "bold", + "textAlign": "left", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-tax", + "type": "text", + "text": "", + "xPercent": "38.65546", + "yPercent": "92.87411", + "widthPercent": "25.37815", + "heightPercent": "3.32542", + "fontSize": 8, + "fontStyle": "normal", + "textAlign": "left", + "color": "#444444", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.tax_details" + }, + { + "id": "neutral-bank-label", + "type": "text", + "text": "BANKVERBINDUNG", + "xPercent": "67.22689", + "yPercent": "90.61758", + "widthPercent": "24.03361", + "heightPercent": "1.30641", + "fontSize": 8, + "fontStyle": "bold", + "textAlign": "left", + "color": "#555555", + "isStatic": false, + "isCustomer": false, + "variable": null + }, + { + "id": "neutral-bank", + "type": "text", + "text": "", + "xPercent": "67.22689", + "yPercent": "92.87411", + "widthPercent": "24.03361", + "heightPercent": "3.32542", + "fontSize": 8, + "fontStyle": "normal", + "textAlign": "left", + "color": "#444444", + "isStatic": true, + "isCustomer": false, + "variable": "masterdata.bank_details" + } + ] +} diff --git a/backend/src/main/resources/templates/demo_invoice_template_default.json b/backend/src/main/resources/templates/demo_invoice_template_default.json deleted file mode 100644 index abb6a7b..0000000 --- a/backend/src/main/resources/templates/demo_invoice_template_default.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "elements": [ - { - "id": "demo-title", - "type": "header", - "text": "Rechnung", - "xPercent": "7.56", - "yPercent": "6.53", - "widthPercent": "28.07", - "heightPercent": "4.04", - "fontSize": 24, - "fontStyle": "bold", - "color": "#111111" - }, - { - "id": "demo-number-label", - "type": "text", - "text": "Rechnungsnr.", - "xPercent": "67.06", - "yPercent": "7.48", - "widthPercent": "17.98", - "heightPercent": "2.97", - "fontSize": 11, - "fontStyle": "bold", - "color": "#111111" - }, - { - "id": "demo-number-value", - "type": "text", - "text": "000000", - "xPercent": "84.03", - "yPercent": "7.48", - "widthPercent": "10.08", - "heightPercent": "2.97", - "fontSize": 11, - "fontStyle": "normal", - "color": "#111111", - "isStatic": true, - "variable": "invoice.number" - }, - { - "id": "demo-date-label", - "type": "text", - "text": "Datum", - "xPercent": "67.06", - "yPercent": "11.52", - "widthPercent": "17.98", - "heightPercent": "2.97", - "fontSize": 11, - "fontStyle": "bold", - "color": "#111111" - }, - { - "id": "demo-date-value", - "type": "text", - "text": "2026-01-01", - "xPercent": "84.03", - "yPercent": "11.52", - "widthPercent": "10.08", - "heightPercent": "2.97", - "fontSize": 11, - "fontStyle": "normal", - "color": "#111111", - "isStatic": true, - "variable": "invoice.date" - }, - { - "id": "demo-sender", - "type": "text", - "text": null, - "xPercent": "7.56", - "yPercent": "16.51", - "widthPercent": "30.08", - "heightPercent": "2.97", - "fontSize": 12, - "fontStyle": "bold", - "color": "#111111", - "isStatic": true, - "variable": "masterdata.contact_name" - }, - { - "id": "demo-customer", - "type": "customer", - "text": "Kundenname\nStraße Nr.\nPLZ Ort", - "xPercent": "7.56", - "yPercent": "25.06", - "widthPercent": "33.95", - "heightPercent": "12.00", - "fontSize": 12, - "fontStyle": "normal", - "color": "#111111" - }, - { - "id": "demo-services", - "type": "text", - "text": "Leistungen", - "xPercent": "7.56", - "yPercent": "42.04", - "widthPercent": "85.04", - "heightPercent": "28.03", - "fontSize": 11, - "fontStyle": "normal", - "color": "#111111", - "isStatic": true, - "variable": "services.list" - }, - { - "id": "demo-net-label", - "type": "text", - "text": "Netto", - "xPercent": "67.06", - "yPercent": "73.99", - "widthPercent": "11.93", - "heightPercent": "2.97", - "fontSize": 11, - "fontStyle": "bold", - "color": "#111111" - }, - { - "id": "demo-net-value", - "type": "text", - "text": "0,00 €", - "xPercent": "80.00", - "yPercent": "73.99", - "widthPercent": "13.95", - "heightPercent": "2.97", - "fontSize": 11, - "fontStyle": "normal", - "color": "#111111", - "isStatic": true, - "variable": "invoice.net_total" - }, - { - "id": "demo-total-label", - "type": "text", - "text": "Gesamt", - "xPercent": "67.06", - "yPercent": "78.98", - "widthPercent": "11.93", - "heightPercent": "3.44", - "fontSize": 13, - "fontStyle": "bold", - "color": "#111111" - }, - { - "id": "demo-total-value", - "type": "text", - "text": "0,00 €", - "xPercent": "80.00", - "yPercent": "78.98", - "widthPercent": "13.95", - "heightPercent": "3.44", - "fontSize": 13, - "fontStyle": "bold", - "color": "#111111", - "isStatic": true, - "variable": "invoice.gross_total" - }, - { - "id": "demo-template-note", - "type": "text", - "text": "Hinweis: Dieses Rechnungstemplate kann vom Benutzer ", - "xPercent": "0.00", - "yPercent": "20.78", - "widthPercent": "100.00", - "heightPercent": "4.51", - "fontSize": 20, - "fontStyle": "normal", - "textAlign": "center", - "color": "#555555" - }, - { - "id": "element-1", - "type": "text", - "text": "frei angepasst werden.", - "xPercent": "31.93", - "yPercent": "24.94", - "widthPercent": "25.21", - "heightPercent": "3.56", - "fontSize": 20, - "color": "#333333", - "isStatic": false, - "isCustomer": false, - "variable": null - } - ] -} diff --git a/backend/src/test/frontend/profile-invoice-generator.test.js b/backend/src/test/frontend/profile-invoice-generator.test.js index 3cf038e..ed5d11e 100644 --- a/backend/src/test/frontend/profile-invoice-generator.test.js +++ b/backend/src/test/frontend/profile-invoice-generator.test.js @@ -21,6 +21,12 @@ function createEditor() { addEventListener() {} }; const window = { addEventListener() {} }; + const resizeObservers = []; + class ResizeObserver { + constructor(callback) { this.callback = callback; resizeObservers.push(this); } + observe() {} + disconnect() { this.disconnected = true; } + } class Image { width = 595; height = 842; @@ -30,6 +36,7 @@ function createEditor() { window, document: { getElementById: () => container, createElement: () => canvas }, Image, + ResizeObserver, console: { log() {}, error(error) { throw new Error(String(error)); } } }); window.initProfileInvoiceGenerator(); @@ -37,7 +44,7 @@ function createEditor() { { id: 'letterhead', type: 'image', imageData: 'data:image/svg+xml;base64,test', x: 0, y: 0, width: 595, height: 842 }, { id: 'invoice-text', type: 'text', text: 'Rechnung', x: 50, y: 250, width: 100, height: 30 } ] }); - return { window, painted, pendingImages }; + return { window, painted, pendingImages, container, resizeObservers }; } test('invoice text stays above a letterhead that finishes loading later', () => { @@ -58,3 +65,55 @@ test('a removed letterhead is not painted when its pending image finishes loadin pendingImages[0].onload(); assert.deepEqual(painted, [['text', 'Rechnung']]); }); + +test('optional billing data stays empty while unknown variables retain a placeholder', () => { + const { window } = createEditor(); + window.masterdataValues = { 'masterdata.bank_details': '' }; + window.loadProfileTemplate({ elements: [ + { id: 'bank', type: 'text', variable: 'masterdata.bank_details', text: 'Old bank', x: 0, y: 0 }, + { id: 'unknown', type: 'text', variable: 'masterdata.unknown', x: 0, y: 20 } + ] }); + assert.equal(window.profileInvoiceState.elements[0].text, ''); + assert.equal(window.profileInvoiceState.elements[1].text, '[unknown]'); +}); + +const neutralTemplate = JSON.parse(readFileSync(new URL('../../main/resources/templates/customer_invoice_template_neutral.json', import.meta.url), 'utf8')); + +test('the neutral profile template loads when its initially hidden tab becomes visible', () => { + const { window, container, resizeObservers } = createEditor(); + container.getBoundingClientRect = () => ({ width: 0, height: 0 }); + window.showProfileInvoiceTemplate(container, {}, JSON.stringify(neutralTemplate), { 'masterdata.company_name': 'Kundenfirma' }, 0.19); + assert.equal(resizeObservers.length, 1); + assert.equal(window.profileInvoiceState.elements[0].id, 'letterhead'); + + container.getBoundingClientRect = () => ({ width: 615, height: 862 }); + resizeObservers[0].callback(); + + assert.equal(window.profileInvoiceState.elements.length, neutralTemplate.elements.length); + assert.equal(window.profileInvoiceState.elements[0].id, 'neutral-company'); + assert.equal(window.profileInvoiceState.elements[0].text, 'Kundenfirma'); + assert.equal(resizeObservers[0].disconnected, true); + assert.equal(window.pendingProfileInvoiceLoad, null); +}); + +test('opening another customer profile cancels a pending template load', () => { + const { window, container, resizeObservers } = createEditor(); + container.getBoundingClientRect = () => ({ width: 0, height: 0 }); + window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Vorheriger Kunde' }, 0.19); + window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Aktueller Kunde' }, 0.07); + assert.equal(resizeObservers[0].disconnected, true); + + container.getBoundingClientRect = () => ({ width: 615, height: 862 }); + resizeObservers[0].callback(); + assert.equal(window.profileInvoiceState.elements[0].id, 'letterhead'); + resizeObservers[1].callback(); + assert.equal(window.profileInvoiceState.elements[0].text, 'Aktueller Kunde'); + assert.equal(window.profileInvoiceVatRate, 0.07); +}); + +test('a visible profile canvas loads the template immediately', () => { + const { window, container, resizeObservers } = createEditor(); + window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Kundenfirma' }, 0.19); + assert.equal(resizeObservers.length, 0); + assert.equal(window.profileInvoiceState.elements[0].id, 'neutral-company'); +}); diff --git a/backend/src/test/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateRenderingTest.java b/backend/src/test/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateRenderingTest.java new file mode 100644 index 0000000..c51c0c8 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateRenderingTest.java @@ -0,0 +1,73 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.itextpdf.kernel.pdf.PdfDocument; +import com.itextpdf.kernel.pdf.PdfReader; +import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.UserInvoiceData; +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class CustomerInvoiceTemplateRenderingTest { + @Test + void neutralTemplateResolvesProfilePreviewAndActualInvoiceData() throws Exception { + String template; + try (var stream = new ClassPathResource("templates/customer_invoice_template_neutral.json").getInputStream()) { + template = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + User user = new User(); + user.setCompany("Logistik & Partner"); + user.setStreet("Hafenstraße"); + user.setHouseNumber("12"); + user.setZip("20457"); + user.setCity("Hamburg"); + user.setEmail("rechnung@example.com"); + UserInvoiceData billing = new UserInvoiceData(); + billing.setTaxNumber("12/345/67890"); + billing.setBankName("Musterbank"); + billing.setIban("DE89 3704 0044 0532 0130 00"); + var service = new CustomerInvoiceService(); + + String preview = extractText(service.generatePdfFromCanvasTemplate(template, user, "RE-", + new BigDecimal("0.19"), billing)); + + assertThat(preview).contains("Logistik & Partner", "RE-12345", "Kundenfirma GmbH", "124,45 €", "779,45 €", + "Steuernummer: 12/345/67890", "IBAN: DE89 3704 0044 0532 0130 00"); + var values = CustomerInvoiceTemplateVariables.create(user, billing, "RE-2026-001"); + values.put("customer.company_name", "Kunde & Co."); + values.put("customer.contact_name", "Erika Muster"); + values.put("customer.street", "Kundenstraße 1"); + values.put("customer.city", "10115 Berlin"); + values.put("services.json", "[{\"name\":\"Transport\",\"netAmount\":\"100,00\"}]"); + values.put("invoice.net_total", "100,00 €"); + values.put("invoice.vat_total", "19,00 €"); + values.put("invoice.gross_total", "119,00 €"); + values.put("invoice.vat_rate", "19%"); + ObjectMapper mapper = new ObjectMapper(); + for (var element : mapper.readTree(template).path("elements")) { + String variable = element.path("variable").asText(""); + if (!variable.isBlank() && !"services.list".equals(variable)) { + assertThat(values).containsKey(variable); + } + } + String invoice = extractText(service.generatePdfFromCanvasTemplateWithData( + mapper.writeValueAsString(template), values, user)); + + assertThat(invoice).contains("Logistik & Partner", "Kunde & Co.", "Erika Muster", "RE-2026-001", + "Transport", "119,00 €", "Musterbank", "Steuernummer: 12/345/67890") + .doesNotContain("Kundenfirma GmbH", "Assecutor", "[invoice_date]", "[bank_details]", "null"); + } + + private String extractText(byte[] pdfBytes) throws Exception { + try (var pdf = new PdfDocument(new PdfReader(new ByteArrayInputStream(pdfBytes)))) { + assertThat(pdf.getNumberOfPages()).isEqualTo(1); + return PdfTextExtractor.getTextFromPage(pdf.getPage(1)); + } + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateVariablesTest.java b/backend/src/test/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateVariablesTest.java new file mode 100644 index 0000000..e242c1f --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/CustomerInvoiceTemplateVariablesTest.java @@ -0,0 +1,56 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.UserInvoiceData; +import org.junit.jupiter.api.Test; + +class CustomerInvoiceTemplateVariablesTest { + @Test + void usesCustomerProfileAndBillingDataIncludingMultilineText() { + User user = new User(); + user.setCompany("Müller & Söhne"); + user.setStreet("Hafenstraße"); + user.setHouseNumber("12"); + user.setZip("20457"); + user.setCity("Hamburg"); + user.setEmail("rechnung@example.com"); + user.setPhone("040 12345"); + UserInvoiceData billing = new UserInvoiceData(); + billing.setUstId("DE123456789"); + billing.setTaxNumber("12/345/67890"); + billing.setBankName("Musterbank"); + billing.setIban("DE89 3704 0044 0532 0130 00"); + billing.setIntroText("Vielen Dank für Ihren Auftrag.\nWir berechnen folgende Leistungen:"); + billing.setPaymentTerms("Bitte binnen 14 Tagen zahlen."); + + var values = CustomerInvoiceTemplateVariables.create(user, billing, "RE-2026-0001"); + + assertThat(values).containsEntry("masterdata.company_name", "Müller & Söhne") + .containsEntry("masterdata.sender_line", "Müller & Söhne · Hafenstraße 12 · 20457 Hamburg") + .containsEntry("masterdata.contact_details", "rechnung@example.com\n040 12345") + .containsEntry("masterdata.tax_details", "USt-IdNr.: DE123456789\nSteuernummer: 12/345/67890") + .containsEntry("masterdata.bank_details", "Musterbank\nIBAN: DE89 3704 0044 0532 0130 00") + .containsEntry("masterdata.intro", billing.getIntroText()) + .containsEntry("masterdata.payment_terms", billing.getPaymentTerms()) + .containsEntry("masterdata.invoice_number", "RE-2026-0001"); + assertThat(values.get("masterdata.invoice_date")).matches("\\d{2}\\.\\d{2}\\.\\d{4}"); + } + + @Test + void absentBillingDataDoesNotInventTaxOrBankDetails() { + User user = new User(); + user.setFirstname("Erika"); + user.setName("Beispiel"); + + var values = CustomerInvoiceTemplateVariables.create(user, null, "RE-1"); + + assertThat(values).containsEntry("masterdata.company_name", "Erika Beispiel") + .containsEntry("masterdata.tax_details", "").containsEntry("masterdata.bank_details", "") + .containsEntry("masterdata.contact_details", "") + .containsEntry("masterdata.intro", "Wir berechnen Ihnen folgende Leistungen:") + .containsEntry("masterdata.payment_terms", "Bitte geben Sie bei der Zahlung die Rechnungsnummer an."); + assertThat(values.values()).noneMatch(value -> value.contains("null") || value.contains("Assecutor")); + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/DemoModeServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/DemoModeServiceTest.java index 1d5aeda..590cfde 100644 --- a/backend/src/test/java/de/assecutor/votianlt/service/DemoModeServiceTest.java +++ b/backend/src/test/java/de/assecutor/votianlt/service/DemoModeServiceTest.java @@ -2,6 +2,7 @@ package de.assecutor.votianlt.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -10,7 +11,6 @@ import static org.mockito.Mockito.when; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.User; -import de.assecutor.votianlt.model.InvoiceTemplate; import de.assecutor.votianlt.pages.domain.CustomerRepository; import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.BarcodeRepository; @@ -90,8 +90,6 @@ class DemoModeServiceTest { private ArgumentCaptor> jobsCaptor; @Captor private ArgumentCaptor> servicesCaptor; - @Captor - private ArgumentCaptor invoiceTemplateCaptor; private DemoSessionRegistry demoSessionRegistry; private DemoModeService demoModeService; @@ -127,17 +125,8 @@ class DemoModeServiceTest { assertThat(prepared).isTrue(); verify(serviceRepository).saveAll(servicesCaptor.capture()); assertThat(servicesCaptor.getValue()).hasSize(2); - verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture()); - assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString()); - assertThat(invoiceTemplateCaptor.getValue().getTemplateData()) - .contains("elements") - .contains("services.list") - .contains("invoice.gross_total") - .contains("frei angepasst werden") - .contains("textAlign") - .contains("center") - .contains("fontSize") - .contains("20"); + // Demo customers use the same neutral default as all other customers. + verify(invoiceTemplateRepository, never()).save(any()); verify(jobRepository).saveAll(jobsCaptor.capture()); assertThat(jobsCaptor.getValue()).hasSize(2); assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber) diff --git a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceTemplateServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceTemplateServiceTest.java new file mode 100644 index 0000000..fb1c408 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceTemplateServiceTest.java @@ -0,0 +1,139 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.InvoiceTemplate; +import de.assecutor.votianlt.repository.InvoiceTemplateRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class InvoiceTemplateServiceTest { + @Mock + private InvoiceTemplateRepository repository; + private InvoiceTemplateService service; + + @BeforeEach + void setUp() { + service = new InvoiceTemplateService(repository); + } + + @Test + void missingCustomerTemplateUsesEditableDefaultWithoutWritingToDatabase() throws Exception { + when(repository.findByUserId("customer-1")).thenReturn(Optional.empty()); + + InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1"); + + assertThat(result.getUserId()).isEqualTo("customer-1"); + assertThat(result.getName()).isEqualTo("Neutral"); + assertThat(result.getId()).isNull(); + ObjectMapper mapper = new ObjectMapper(); + var template = mapper.readTree(mapper.readValue(result.getTemplateData(), String.class)); + assertThat(template.path("elements").size()).isGreaterThan(10); + assertThat(template.toString()).contains("masterdata.company_name", "customer.company_name", "services.list", + "masterdata.bank_details", "masterdata.tax_details").doesNotContain("Assecutor", "HHA", "DE6720"); + verify(repository).findByUserId("customer-1"); + verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID); + verifyNoMoreInteractions(repository); + } + + @Test + void personalTemplateTakesPrecedence() { + InvoiceTemplate personal = new InvoiceTemplate("customer-1", "Mein Briefpapier", + "{\"elements\":[{\"type\":\"text\",\"text\":\"Eigene Vorlage\"}]}"); + when(repository.findByUserId("customer-1")).thenReturn(Optional.of(personal)); + + assertThat(service.getCustomerTemplateByUserId("customer-1")).isSameAs(personal); + verify(repository).findByUserId("customer-1"); + verifyNoMoreInteractions(repository); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { " ", "null", "{}", "{\"elements\":[]}", "\"{\\\"elements\\\":[]}\"", "invalid JSON" }) + void unusableSavedTemplateFallsBackWithoutOverwritingIt(String data) { + InvoiceTemplate existing = new InvoiceTemplate("customer-1", "Standard", data); + when(repository.findByUserId("customer-1")).thenReturn(Optional.of(existing)); + + InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1"); + + assertThat(result.getTemplateData()).contains("neutral-company", "services.list"); + assertThat(existing.getTemplateData()).isEqualTo(data); + verify(repository).findByUserId("customer-1"); + verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID); + verifyNoMoreInteractions(repository); + } + + @Test + void jsonEncodedPersonalTemplateTakesPrecedence() throws Exception { + InvoiceTemplate personal = new InvoiceTemplate("customer-1", "Standard", new ObjectMapper() + .writeValueAsString("{\"elements\":[{\"type\":\"text\",\"text\":\"Eigene Vorlage\"}]}")); + when(repository.findByUserId("customer-1")).thenReturn(Optional.of(personal)); + + assertThat(service.getCustomerTemplateByUserId("customer-1")).isSameAs(personal); + } + + @Test + void systemTemplateDoesNotFallBackToCustomerDefault() { + when(repository.findByUserId("SYSTEM")).thenReturn(Optional.empty()); + + assertThat(service.getTemplateByUserId("SYSTEM")).isEmpty(); + assertThatThrownBy(() -> service.getCustomerTemplateByUserId("SYSTEM")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.getCustomerTemplateByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void sharedDefaultIsLoadedAsAnUnsavedCustomerTemplate() { + InvoiceTemplate shared = new InvoiceTemplate(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID, "Neutral", + "{\"elements\":[{\"type\":\"text\",\"text\":\"Gemeinsamer Standard\"}]}"); + shared.setId("shared-template-id"); + when(repository.findByUserId("customer-1")).thenReturn(Optional.empty()); + when(repository.findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID)).thenReturn(Optional.of(shared)); + + InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1"); + + assertThat(result.getUserId()).isEqualTo("customer-1"); + assertThat(result.getId()).isNull(); + assertThat(result.getTemplateData()).isEqualTo(shared.getTemplateData()); + assertThat(shared.getUserId()).isEqualTo(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID); + verify(repository).findByUserId("customer-1"); + verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID); + verifyNoMoreInteractions(repository); + } + + @Test + void invalidSharedDefaultUsesBundledNeutralTemplate() { + when(repository.findByUserId("customer-1")).thenReturn(Optional.empty()); + when(repository.findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID)) + .thenReturn(Optional.of(new InvoiceTemplate(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID, "Neutral", "{}"))); + + assertThat(service.getCustomerTemplateByUserId("customer-1").getTemplateData()).contains("neutral-company"); + } + + @Test + void defaultCanBeSavedAsPersonalTemplate() { + when(repository.findByUserId("customer-1")).thenReturn(Optional.empty()); + String templateData = service.getCustomerTemplateByUserId("customer-1").getTemplateData(); + var saved = org.mockito.ArgumentCaptor.forClass(InvoiceTemplate.class); + + service.saveTemplate("customer-1", templateData); + + verify(repository).save(saved.capture()); + assertThat(saved.getValue().getUserId()).isEqualTo("customer-1"); + assertThat(saved.getValue().getTemplateData()).isEqualTo(templateData); + } +} diff --git a/output/pdf/kunden-rechnungstemplate-neutral-vorschau.pdf b/output/pdf/kunden-rechnungstemplate-neutral-vorschau.pdf new file mode 100644 index 0000000..fcfcb7c Binary files /dev/null and b/output/pdf/kunden-rechnungstemplate-neutral-vorschau.pdf differ