feat: Neutrale Rechnungsvorlage als Kundenstandard

This commit is contained in:
Sven committed 2026-10-05 11:33:54 +02:00
1 parent 8044d82f04
commit 8758c53e5b
16 files changed
+944 -317

No files matched your search

Binary file not shown.
@@ -7,9 +7,40 @@ window.profileInvoiceState = window.profileInvoiceState || {
initialized: false 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 containerId = 'invoice-canvas-container-profile';
var container = document.getElementById(containerId); container = container || document.getElementById(containerId);
if (!container) { if (!container) {
console.error('Canvas container not found:', containerId); console.error('Canvas container not found:', containerId);
@@ -1303,7 +1334,7 @@ window.initProfileInvoiceGenerator = function() {
} else if (el.variable.startsWith('masterdata.')) { } else if (el.variable.startsWith('masterdata.')) {
// Masterdata variables - use actual value from masterdata or placeholder // Masterdata variables - use actual value from masterdata or placeholder
var value = masterdata[el.variable]; var value = masterdata[el.variable];
if (value && value.trim() !== '') { if (typeof value === 'string') {
el.text = value; el.text = value;
} else { } else {
el.text = '[' + el.variable.replace('masterdata.', '') + ']'; el.text = '[' + el.variable.replace('masterdata.', '') + ']';
@@ -33,6 +33,7 @@ import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
import de.assecutor.votianlt.service.EmailService; import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceLifecycleService;
@@ -546,15 +547,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
currentUser = currentUserOpt.get(); currentUser = currentUserOpt.get();
Optional<InvoiceTemplate> templateOpt = invoiceTemplateService InvoiceTemplate template = invoiceTemplateService
.getTemplateByUserId(currentUser.getId().toString()); .getCustomerTemplateByUserId(currentUser.getId().toString());
if (templateOpt.isEmpty()) { String templateData = template.getTemplateData();
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
Notification.Position.BOTTOM_END);
return;
}
String templateData = templateOpt.get().getTemplateData();
if (templateData == null || templateData.isBlank()) { if (templateData == null || templateData.isBlank()) {
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000, Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
Notification.Position.BOTTOM_END); Notification.Position.BOTTOM_END);
@@ -822,15 +817,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
// Build variable substitution map // Build variable substitution map
Map<String, String> variables = new HashMap<>(); Map<String, String> variables = CustomerInvoiceTemplateVariables.create(currentUser,
userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null), invoiceNumber);
// 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()));
// Customer data (from job - look up Customer entity for full address) // Customer data (from job - look up Customer entity for full address)
String customerSelection = currentJob.getCustomerSelection(); String customerSelection = currentJob.getCustomerSelection();
@@ -44,6 +44,7 @@ import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.LanguageService; import de.assecutor.votianlt.service.LanguageService;
import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.grid.Grid;
@@ -78,6 +79,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private Checkbox billingEnabled; private Checkbox billingEnabled;
private NumberField vatRateField; private NumberField vatRateField;
private VerticalLayout propertiesPanelProfile; private VerticalLayout propertiesPanelProfile;
private final Div canvasContainer;
private final ServiceRepository serviceRepository; private final ServiceRepository serviceRepository;
private Grid<Service> servicesGrid; private Grid<Service> 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)"); .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)");
// Mitte: Canvas // Mitte: Canvas
Div canvasContainer = new Div(); canvasContainer = new Div();
canvasContainer.setId("invoice-canvas-container-profile"); canvasContainer.setId("invoice-canvas-container-profile");
canvasContainer.setWidth("100%"); canvasContainer.setWidth("100%");
canvasContainer.setHeight("100%"); canvasContainer.setHeight("100%");
@@ -513,17 +515,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
// Bestehende Rechnungsdaten laden (nur für die Checkbox) // Bestehende Rechnungsdaten laden (nur für die Checkbox)
loadInvoiceData(); loadInvoiceData();
// Initialize invoice generator when the billing tab is selected // Load directly; the client waits until the billing canvas is visible.
// Also register this view instance for JavaScript callbacks
tabSheet.addSelectedChangeListener(e -> { tabSheet.addSelectedChangeListener(e -> {
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) { if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" loadInvoiceTemplate();
+ "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());
} }
}); });
@@ -858,6 +853,16 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(), currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(), prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.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) { private String safe(String value) {
@@ -957,7 +962,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
} }
BigDecimal previewVatRate = getPreviewVatRate(); BigDecimal previewVatRate = getPreviewVatRate();
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData, byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
currentUser, prefixField.getValue(), previewVatRate); currentUser, prefixField.getValue(), previewVatRate, currentInvoiceData);
showPdfInDialog(pdfBytes); showPdfInDialog(pdfBytes);
} catch (Exception ex) { } catch (Exception ex) {
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()), 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 * Load the saved invoice template from database and display it on the canvas
*/ */
private void loadInvoiceTemplate() { private void loadInvoiceTemplate() {
try { try {
java.util.Optional<de.assecutor.votianlt.model.InvoiceTemplate> optionalTemplate = invoiceTemplateService String templateData = invoiceTemplateService.getCustomerTemplateByUserId(currentUser.getId().toString())
.getTemplateByUserId(currentUser.getId().toString()); .getTemplateData();
if (optionalTemplate.isPresent()) { if (templateData == null || templateData.isBlank()) {
String templateData = optionalTemplate.get().getTemplateData(); return;
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 prefix = prefixField.getValue();
String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345"; String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345";
String masterdataJson = "{" + "'masterdata.company_name': '" + company.replace("'", "\\'") + "'," String masterdataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
+ "'masterdata.contact_name': '" + fullName.replace("'", "\\'") + "'," CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData, invoiceNumber));
+ "'masterdata.street': '" + street.replace("'", "\\'") + "'," + "'masterdata.city': '" getElement().executeJs("window.showProfileInvoiceTemplate($0, $1, JSON.parse($2), JSON.parse($3), $4);",
+ city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'") canvasContainer.getElement(), getElement(), templateData, masterdataJson,
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "'," getPreviewVatRate().doubleValue());
+ "'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());
}
} catch (Exception ex) { } catch (Exception ex) {
System.err.println("Fehler beim Laden des Templates: " + ex.getMessage()); System.err.println("Fehler beim Laden des Templates: " + ex.getMessage());
ex.printStackTrace(); ex.printStackTrace();
@@ -263,6 +263,12 @@ public class CustomerInvoiceService {
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user, public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix, BigDecimal vatRate) throws Exception { 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 BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19")); : (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
@@ -270,16 +276,8 @@ public class CustomerInvoiceService {
java.util.Map<String, String> variables = new java.util.HashMap<>(); java.util.Map<String, String> variables = new java.util.HashMap<>();
if (user != null) { if (user != null) {
// Use actual user data variables.putAll(CustomerInvoiceTemplateVariables.create(user, billing,
String company = user.getCompany(); (invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345"));
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");
} else { } else {
// Default values for preview without user // Default values for preview without user
variables.put("masterdata.company_name", "Meine Firma GmbH"); 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("</td>"); .append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>");
html.append("</tr>"); html.append("</tr>");
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
.append(vatLabel).append(" USt:</td>");
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal * vatRate.doubleValue()))
.append("</td></tr>");
// Gesamtsumme - label in col 2, value in col 3 // Gesamtsumme - label in col 2, value in col 3
html.append("<tr>"); html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>"); html.append("<td style='width:55%;padding:2px 0;'></td>");
@@ -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<String, String> create(User user, UserInvoiceData billing, String invoiceNumber) {
Map<String, String> 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();
}
}
@@ -1,12 +1,10 @@
package de.assecutor.votianlt.service; package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus; 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.UserInvoiceDataRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SessionAuthenticationService; import de.assecutor.votianlt.security.SessionAuthenticationService;
import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
@@ -54,7 +50,6 @@ import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -68,7 +63,6 @@ public class DemoModeService {
public static final String DEMO_PASSWORD = "demo"; public static final String DEMO_PASSWORD = "demo";
private static final String DEMO_FIRST_NAME = "Demo"; private static final String DEMO_FIRST_NAME = "Demo";
private static final String DEMO_LAST_NAME = "Benutzer"; 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 DemoSessionRegistry demoSessionRegistry;
private final SessionAuthenticationService sessionAuthenticationService; private final SessionAuthenticationService sessionAuthenticationService;
@@ -194,7 +188,6 @@ public class DemoModeService {
List<Service> services = seedDemoServices(demoUser); List<Service> services = seedDemoServices(demoUser);
List<Customer> customers = seedDemoCustomers(demoUser); List<Customer> customers = seedDemoCustomers(demoUser);
seedDemoJobs(demoUser, customers, services); seedDemoJobs(demoUser, customers, services);
seedDemoInvoiceTemplate(demoUser);
} }
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) { private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
@@ -441,23 +434,6 @@ public class DemoModeService {
return selection; 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) { private void applyCustomerToPickup(Job job, Customer customer) {
if (job == null || customer == null) { if (job == null || customer == null) {
return; return;
@@ -1,16 +1,23 @@
package de.assecutor.votianlt.service; 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.model.InvoiceTemplate;
import de.assecutor.votianlt.repository.InvoiceTemplateRepository; import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional; import java.util.Optional;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class InvoiceTemplateService { public class InvoiceTemplateService {
public static final String CUSTOMER_DEFAULT_USER_ID = "CUSTOMER_DEFAULT";
private final InvoiceTemplateRepository invoiceTemplateRepository; private final InvoiceTemplateRepository invoiceTemplateRepository;
/** /**
@@ -36,6 +43,49 @@ public class InvoiceTemplateService {
return invoiceTemplateRepository.findByUserId(userId); 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 * Check if a template exists for a user
*/ */
@@ -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"
}
]
}
@@ -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
}
]
}
@@ -21,6 +21,12 @@ function createEditor() {
addEventListener() {} addEventListener() {}
}; };
const window = { addEventListener() {} }; const window = { addEventListener() {} };
const resizeObservers = [];
class ResizeObserver {
constructor(callback) { this.callback = callback; resizeObservers.push(this); }
observe() {}
disconnect() { this.disconnected = true; }
}
class Image { class Image {
width = 595; width = 595;
height = 842; height = 842;
@@ -30,6 +36,7 @@ function createEditor() {
window, window,
document: { getElementById: () => container, createElement: () => canvas }, document: { getElementById: () => container, createElement: () => canvas },
Image, Image,
ResizeObserver,
console: { log() {}, error(error) { throw new Error(String(error)); } } console: { log() {}, error(error) { throw new Error(String(error)); } }
}); });
window.initProfileInvoiceGenerator(); 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: '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 } { 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', () => { 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(); pendingImages[0].onload();
assert.deepEqual(painted, [['text', 'Rechnung']]); 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');
});
@@ -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));
}
}
}
@@ -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"));
}
}
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; 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.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.pages.domain.CustomerRepository; import de.assecutor.votianlt.pages.domain.CustomerRepository;
import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.BarcodeRepository; import de.assecutor.votianlt.repository.BarcodeRepository;
@@ -90,8 +90,6 @@ class DemoModeServiceTest {
private ArgumentCaptor<List<Job>> jobsCaptor; private ArgumentCaptor<List<Job>> jobsCaptor;
@Captor @Captor
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor; private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
@Captor
private ArgumentCaptor<InvoiceTemplate> invoiceTemplateCaptor;
private DemoSessionRegistry demoSessionRegistry; private DemoSessionRegistry demoSessionRegistry;
private DemoModeService demoModeService; private DemoModeService demoModeService;
@@ -127,17 +125,8 @@ class DemoModeServiceTest {
assertThat(prepared).isTrue(); assertThat(prepared).isTrue();
verify(serviceRepository).saveAll(servicesCaptor.capture()); verify(serviceRepository).saveAll(servicesCaptor.capture());
assertThat(servicesCaptor.getValue()).hasSize(2); assertThat(servicesCaptor.getValue()).hasSize(2);
verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture()); // Demo customers use the same neutral default as all other customers.
assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString()); verify(invoiceTemplateRepository, never()).save(any());
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");
verify(jobRepository).saveAll(jobsCaptor.capture()); verify(jobRepository).saveAll(jobsCaptor.capture());
assertThat(jobsCaptor.getValue()).hasSize(2); assertThat(jobsCaptor.getValue()).hasSize(2);
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber) assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
@@ -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);
}
}