Compare commits

..

2 Commits

18 changed files with 819 additions and 17 deletions

View File

@@ -66,6 +66,7 @@ public class CustomerInvoice {
private String recipientCity; private String recipientCity;
private String recipientCountry; private String recipientCountry;
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden) private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
private String recipientEmail; // E-Mail für den Rechnungsversand
// Rechnungsdetails // Rechnungsdetails
private String description; // Beschreibung der Leistung private String description; // Beschreibung der Leistung
@@ -274,6 +275,14 @@ public class CustomerInvoice {
this.recipientVatId = recipientVatId; this.recipientVatId = recipientVatId;
} }
public String getRecipientEmail() {
return recipientEmail;
}
public void setRecipientEmail(String recipientEmail) {
this.recipientEmail = recipientEmail;
}
public String getDescription() { public String getDescription() {
return description; return description;
} }

View File

@@ -552,10 +552,42 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
} }
/** /**
* Sends each invoice as PDF attachment to the respective customer's email * Sends the invoices as signed ZUGFeRD e-invoice (ZIP) to the respective
* address and reports how many mails were (not) sent. * customers. First all signed ZIPs are created and cached; only when every
* ZIP could be created and passed the validation the mails are sent.
* Afterwards a combined ZIP containing all individual invoice ZIPs is
* offered as browser download.
*/ */
private void sendInvoices(List<InvoicePreview> previews) { private void sendInvoices(List<InvoicePreview> previews) {
// Phase 1: create and cache the signed ZIP of every invoice; abort
// without sending anything if a single one fails
Map<InvoicePreview, PdfToolService.SignedInvoice> signedZips = new LinkedHashMap<>();
for (InvoicePreview preview : previews) {
try {
signedZips.put(preview, pdfToolService.signInvoice(preview.pdf(), buildZugferdMetadata(preview)));
} catch (Exception ex) {
log.error("Error creating ZUGFeRD e-invoice {} for batch send: {}", preview.invoiceNumber(),
ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.batcherror",
preview.invoiceNumber(), ex.getMessage()), 7000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
}
// Invoices failing the Mustang validation stop the whole batch: no
// mail is sent as long as a single invoice is invalid
String invalidNumbers = signedZips.entrySet().stream().filter(entry -> !entry.getValue().valid())
.map(entry -> entry.getKey().invoiceNumber()).collect(java.util.stream.Collectors.joining(", "));
if (!invalidNumbers.isEmpty()) {
log.warn("Invoice batch send aborted, validation failed for: {}", invalidNumbers);
Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.invalidsend", invalidNumbers),
7000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
String issuerName = ""; String issuerName = "";
try { try {
issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName()); issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName());
@@ -564,9 +596,12 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
} }
String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale())); String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()));
// Phase 2: all ZIPs exist, send one mail per invoice with its ZIP
int sent = 0; int sent = 0;
int failed = 0; int failed = 0;
for (InvoicePreview preview : previews) { for (Map.Entry<InvoicePreview, PdfToolService.SignedInvoice> entry : signedZips.entrySet()) {
InvoicePreview preview = entry.getKey();
PdfToolService.SignedInvoice signed = entry.getValue();
String email = preview.info().user().getEmail(); String email = preview.info().user().getEmail();
if (email == null || email.isBlank()) { if (email == null || email.isBlank()) {
log.warn("Customer {} has no email address, invoice {} not sent", log.warn("Customer {} has no email address, invoice {} not sent",
@@ -579,7 +614,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()), getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()),
getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel, getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel,
issuerName), issuerName),
preview.pdf(), preview.invoiceNumber() + ".pdf"); signed.zip(), signed.fileName());
recordDispatch(preview, email); recordDispatch(preview, email);
sent++; sent++;
} catch (Exception ex) { } catch (Exception ex) {
@@ -587,6 +622,22 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
failed++; failed++;
} }
} }
// Phase 3: bundle all individual invoice ZIPs and offer the download
if (signedZips.size() > 1) {
try {
String archiveName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))
+ "-zugferd.zip";
triggerZipDownload(buildCombinedZip(signedZips.values()), archiveName);
} catch (Exception ex) {
log.error("Error creating combined invoice ZIP: {}", ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.zip.combined.error", ex.getMessage()),
7000, Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
if (sent > 0) { if (sent > 0) {
grid.deselectAll(); grid.deselectAll();
grid.setItems(loadBillingInfos()); grid.setItems(loadBillingInfos());
@@ -748,11 +799,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
try { try {
PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(), PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(),
buildZugferdMetadata(preview)); buildZugferdMetadata(preview));
String base64Zip = Base64.getEncoder().encodeToString(signed.zip()); triggerZipDownload(signed.zip(), signed.fileName());
getElement().executeJs("const link = document.createElement('a');"
+ "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '"
+ signed.fileName().replace("'", "") + "';"
+ "document.body.appendChild(link); link.click(); document.body.removeChild(link);");
if (signed.valid()) { if (signed.valid()) {
Notification Notification
.show(getTranslation("admincreateinvoices.notification.zugferd.created", .show(getTranslation("admincreateinvoices.notification.zugferd.created",
@@ -773,6 +820,32 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
} }
} }
/**
* Packs the individual invoice ZIPs into one combined ZIP so all sent
* e-invoices can be archived with a single download.
*/
private byte[] buildCombinedZip(java.util.Collection<PdfToolService.SignedInvoice> signedInvoices)
throws java.io.IOException {
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zipStream = new java.util.zip.ZipOutputStream(buffer)) {
for (PdfToolService.SignedInvoice signed : signedInvoices) {
zipStream.putNextEntry(new java.util.zip.ZipEntry(signed.fileName()));
zipStream.write(signed.zip());
zipStream.closeEntry();
}
}
return buffer.toByteArray();
}
/** Offers the given ZIP bytes as browser download. */
private void triggerZipDownload(byte[] zip, String fileName) {
String base64Zip = Base64.getEncoder().encodeToString(zip);
getElement().executeJs("const link = document.createElement('a');"
+ "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '"
+ fileName.replace("'", "") + "';"
+ "document.body.appendChild(link); link.click(); document.body.removeChild(link);");
}
/** /**
* Builds the EN16931 metadata for the PDF Tool from the billing data: the * Builds the EN16931 metadata for the PDF Tool from the billing data: the
* system operator as sender, the customer as recipient and the billed * system operator as sender, the customer as recipient and the billed

View File

@@ -24,6 +24,7 @@ import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate; import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
@@ -586,6 +587,14 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatAmount(vatAmount); invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount); invoice.setTotalAmount(totalAmount);
// Pflichtangaben nach §14 UStG: Leistungsdatum, Absender, Empfänger und
// Positionen — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung.
invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate()
: java.time.LocalDate.now());
applySenderData(invoice, user);
applyRecipientData(invoice);
invoice.setItems(buildInvoiceItems(vatRate));
invoice.setPdfData(pdfBytes); invoice.setPdfData(pdfBytes);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36). // Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
@@ -609,6 +618,105 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
} }
} }
/**
* Absenderdaten aus dem Nutzerprofil und den Rechnungs-Stammdaten
* (USt-IdNr./Steuernummer, Bankverbindung, Zahlungsbedingungen) übernehmen.
*/
private void applySenderData(CustomerInvoice invoice, User user) {
invoice.setSenderName(firstNonBlank(user.getCompany(),
(safe(user.getFirstname()) + " " + safe(user.getName())).trim()));
invoice.setSenderAddress((safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
invoice.setSenderPostcode(safe(user.getZip()));
invoice.setSenderCity(safe(user.getCity()));
invoice.setSenderCountry("DE");
invoice.setSenderPhone(safe(user.getPhone()));
invoice.setSenderEmail(safe(user.getEmail()));
userInvoiceDataService.findByUserId(user.getId()).ifPresent(invoiceData -> {
invoice.setSenderVatId(safe(invoiceData.getUstId()));
invoice.setSenderTaxNumber(safe(invoiceData.getTaxNumber()));
invoice.setIban(safe(invoiceData.getIban()));
invoice.setBankAccount(safe(invoiceData.getBankName()));
invoice.setPaymentTerms(safe(invoiceData.getPaymentTerms()));
});
if (invoice.getPaymentDueDate() == null) {
invoice.setPaymentDueDate(invoice.getInvoiceDate().plusDays(14));
}
}
/**
* Empfängerdaten aus der Kundenauswahl des Auftrags übernehmen; die volle
* Anschrift und E-Mail kommen aus dem passenden Adressbuch-Eintrag.
*/
private void applyRecipientData(CustomerInvoice invoice) {
String customerSelection = currentJob.getCustomerSelection();
if (customerSelection == null || customerSelection.isBlank()) {
return;
}
// Format: "Firmenname | Vorname Nachname"
String[] parts = customerSelection.split("\\|");
String companyName = parts.length > 0 ? parts[0].trim() : "";
String contactName = parts.length > 1 ? parts[1].trim() : "";
invoice.setRecipientCompany(companyName);
invoice.setRecipientName(firstNonBlank(contactName, companyName));
Customer matchedCustomer = customerService.findAllForCurrentOwner().stream()
.filter(c -> companyName.equalsIgnoreCase(c.getCompanyName())).findFirst().orElse(null);
if (matchedCustomer != null) {
invoice.setRecipientAddress(
(safe(matchedCustomer.getStreet()) + " " + safe(matchedCustomer.getHouseNumber())).trim());
invoice.setRecipientPostcode(safe(matchedCustomer.getZip()));
invoice.setRecipientCity(safe(matchedCustomer.getCity()));
invoice.setRecipientCountry("DE");
invoice.setRecipientEmail(safe(matchedCustomer.getMail()));
}
}
/**
* Rechnungspositionen aus den ausgewählten Leistungen aufbauen — mit
* denselben Mengen-/Preisregeln wie {@link #calculateNetAmount()}, damit
* die Positionssumme exakt zum Rechnungs-Netto passt.
*/
private List<CustomerInvoiceItem> buildInvoiceItems(BigDecimal vatRate) {
List<CustomerInvoiceItem> items = new ArrayList<>();
for (ServiceRow row : getSelectedServices()) {
Service service = row.getService();
BigDecimal quantity = null;
BigDecimal unitPrice = null;
String unit = null;
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) {
quantity = BigDecimal.ONE;
unitPrice = service.getPrice();
unit = "pauschal";
} else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE
&& service.getPricePerKilometer() != null && getRouteDistanceKm(row.getSelection()) != null) {
quantity = BigDecimal.valueOf(getRouteDistanceKm(row.getSelection()));
unitPrice = service.getPricePerKilometer();
unit = "km";
} else if (service.getCalculationBasis() == Service.CalculationBasis.TIME
&& service.getPricePer15Minutes() != null && getTimeIn15MinUnits(row.getSelection()) != null) {
quantity = new BigDecimal(getTimeIn15MinUnits(row.getSelection()));
unitPrice = service.getPricePer15Minutes();
unit = "x 15 Min.";
}
if (quantity == null || quantity.signum() <= 0 || unitPrice == null) {
continue;
}
items.add(new CustomerInvoiceItem(quantity, unit, service.getName(), unitPrice, vatRate));
}
return items;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
/** /**
* Generates an invoice PDF from the template with actual job data. * Generates an invoice PDF from the template with actual job data.
*/ */

View File

@@ -372,6 +372,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE); billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout); billingTab.add(billingHeaderLayout);
// Steuer- und Bankdaten: Pflichtangaben nach §14 UStG bzw. für die
// ZUGFeRD-E-Rechnung (Absender-USt-IdNr./Steuernummer, IBAN)
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
ustIdField.setPlaceholder("DE123456789");
ustIdField.addBlurListener(e -> saveInvoiceData());
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
taxNumberField.addBlurListener(e -> saveInvoiceData());
bankNameField.setLabel(getTranslation("profile.billing.bankname"));
bankNameField.addBlurListener(e -> saveInvoiceData());
ibanField.setLabel(getTranslation("profile.billing.iban"));
ibanField.setPlaceholder("DE00 0000 0000 0000 0000 00");
ibanField.addBlurListener(e -> saveInvoiceData());
HorizontalLayout billingTaxLayout = new HorizontalLayout(ustIdField, taxNumberField, bankNameField, ibanField);
billingTaxLayout.setSpacing(true);
billingTaxLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingTaxLayout);
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften) // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout(); final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull(); mainLayout.setWidthFull();

View File

@@ -33,11 +33,15 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
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.EmailService;
import de.assecutor.votianlt.service.InvoiceExportService; import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService; import de.assecutor.votianlt.service.InvoicePermissionService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.UserInvoiceZugferdService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.time.LocalDate; import java.time.LocalDate;
@@ -49,6 +53,7 @@ import java.util.Optional;
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
@Slf4j
public class InvoicesView extends VerticalLayout implements HasDynamicTitle { public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
@@ -63,12 +68,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private final InvoicePermissionService invoicePermissionService; private final InvoicePermissionService invoicePermissionService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserInvoiceZugferdService userInvoiceZugferdService;
private final EmailService emailService;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService, public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService, InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService, InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
UserInvoiceDataService userInvoiceDataService, UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) { UserRepository userRepository, UserInvoiceZugferdService userInvoiceZugferdService,
EmailService emailService) {
this.customerInvoiceRepository = customerInvoiceRepository; this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService; this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService; this.invoiceLifecycleService = invoiceLifecycleService;
@@ -77,6 +85,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
this.invoicePermissionService = invoicePermissionService; this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userInvoiceZugferdService = userInvoiceZugferdService;
this.emailService = emailService;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
@@ -201,6 +211,22 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
actions.add(sentBtn); actions.add(sentBtn);
} }
// E-Rechnung (ZUGFeRD) über das externe PDF Tool — analog zu den
// System-Rechnungen der Admin-Seite
if (type == InvoiceType.INVOICE && invoice.getPdfData() != null && invoice.getPdfData().length > 0) {
Button zugferdBtn = new Button(getTranslation("invoices.action.zugferd"),
e -> downloadZugferdZip(invoice));
zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(zugferdBtn);
if (isLiveInvoice && invoicePermissionService.canMarkAsSent(currentUser)) {
Button sendBtn = new Button(getTranslation("invoices.action.sendinvoice"),
e -> sendAsEInvoice(invoice));
sendBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
actions.add(sendBtn);
}
}
if (isLiveInvoice) { if (isLiveInvoice) {
if (invoicePermissionService.canCorrect(currentUser)) { if (invoicePermissionService.canCorrect(currentUser)) {
Button correctBtn = new Button(getTranslation("invoices.action.correct"), Button correctBtn = new Button(getTranslation("invoices.action.correct"),
@@ -318,6 +344,91 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
} }
} }
/**
* Wandelt die Rechnung über das externe PDF Tool in eine signierte
* ZUGFeRD-E-Rechnung um und bietet das ZIP (PDF/A-3, factur-x.xml,
* Prüfbericht) als Download an.
*/
private void downloadZugferdZip(CustomerInvoice invoice) {
try {
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
triggerZipDownload(signed.zip(), signed.fileName());
if (signed.valid()) {
Notification.show(getTranslation("invoices.notification.zugferd.created", invoice.getInvoiceNumber()),
5000, Notification.Position.BOTTOM_END);
} else {
Notification.show(getTranslation("invoices.notification.zugferd.invalid"), 7000,
Notification.Position.MIDDLE);
}
} catch (Exception ex) {
log.error("Fehler beim Erstellen der ZUGFeRD-E-Rechnung {}: {}", invoice.getInvoiceNumber(),
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/**
* Versendet die Rechnung als signierte ZUGFeRD-E-Rechnung (ZIP) per E-Mail
* an den Kunden. Besteht die Rechnung die Validierung nicht (HTTP 422 des
* PDF Tools), wird der Versand abgebrochen — wie beim Sammelversand der
* System-Rechnungen. Nach erfolgreichem Versand wird der Status auf
* "Versendet" gesetzt.
*/
private void sendAsEInvoice(CustomerInvoice invoice) {
String recipientEmail = invoice.getRecipientEmail();
if (recipientEmail == null || recipientEmail.isBlank()) {
Notification.show(getTranslation("invoices.notification.email.missing"), 6000,
Notification.Position.MIDDLE);
return;
}
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
if (!signed.valid()) {
log.warn("Versand der Rechnung {} abgebrochen, ZUGFeRD-Validierung fehlgeschlagen",
invoice.getInvoiceNumber());
Notification.show(
getTranslation("invoices.notification.zugferd.invalidsend", invoice.getInvoiceNumber()), 7000,
Notification.Position.MIDDLE);
return;
}
emailService.sendEmailWithAttachment(recipientEmail,
getTranslation("invoices.mail.subject", invoice.getInvoiceNumber()),
getTranslation("invoices.mail.body", invoice.getInvoiceNumber(),
firstNonBlank(invoice.getSenderName(), "")),
signed.zip(), signed.fileName());
if (invoice.getStatus() == InvoiceStatus.ISSUED) {
invoiceLifecycleService.markAsSent(invoice.getId(),
"Als E-Rechnung per E-Mail versendet an " + recipientEmail);
}
Notification.show(
getTranslation("invoices.notification.emailsent", invoice.getInvoiceNumber(), recipientEmail),
5000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
log.error("Fehler beim Versand der Rechnung {} an {}: {}", invoice.getInvoiceNumber(), recipientEmail,
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/** Bietet die ZIP-Bytes als Browser-Download an. */
private void triggerZipDownload(byte[] zip, String fileName) {
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zip));
resource.setContentType("application/zip");
resource.setCacheTime(0);
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
.registerResource(resource);
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
}
private void markAsSent(CustomerInvoice invoice) { private void markAsSent(CustomerInvoice invoice) {
try { try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser()); invoicePermissionService.requireSend(invoicePermissionService.currentUser());

View File

@@ -65,7 +65,10 @@ public class PdfToolService {
/** /**
* Converts the given invoice PDF into a ZUGFeRD e-invoice. Returns the ZIP * Converts the given invoice PDF into a ZUGFeRD e-invoice. Returns the ZIP
* provided by the PDF Tool; throws with the tool's error message if the * provided by the PDF Tool; throws with the tool's error message if the
* conversion is rejected (e.g. incomplete metadata). * conversion is rejected (e.g. incomplete metadata). A failed Mustang
* validation is reported by the tool as HTTP 422 but still carries the ZIP
* (including the validation report) in the body; it is returned as a
* {@link SignedInvoice} with {@code valid=false} instead of throwing.
*/ */
public SignedInvoice signInvoice(byte[] pdfBytes, InvoiceMetadata metadata) throws Exception { public SignedInvoice signInvoice(byte[] pdfBytes, InvoiceMetadata metadata) throws Exception {
String metadataJson = objectMapper.writeValueAsString(metadata); String metadataJson = objectMapper.writeValueAsString(metadata);
@@ -79,23 +82,30 @@ public class PdfToolService {
}, MediaType.APPLICATION_PDF); }, MediaType.APPLICATION_PDF);
body.part("metadata", metadataJson, MediaType.APPLICATION_JSON); body.part("metadata", metadataJson, MediaType.APPLICATION_JSON);
ResponseEntity<byte[]> response;
try { try {
response = restClient.post().uri("/api/invoices/sign").contentType(MediaType.MULTIPART_FORM_DATA) ResponseEntity<byte[]> response = restClient.post().uri("/api/invoices/sign")
.body(body.build()).retrieve().toEntity(byte[].class); .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.toEntity(byte[].class);
return toSignedInvoice(response.getBody(), response.getHeaders(), metadata);
} catch (RestClientResponseException ex) { } catch (RestClientResponseException ex) {
if (ex.getStatusCode().value() == 422 && ex.getResponseBodyAsByteArray().length > 0) {
return toSignedInvoice(ex.getResponseBodyAsByteArray(), ex.getResponseHeaders(), metadata);
}
throw new IllegalStateException(extractErrorMessage(ex), ex); throw new IllegalStateException(extractErrorMessage(ex), ex);
} }
}
byte[] zip = response.getBody(); /** Builds the result from the response ZIP, file name and validation header. */
private SignedInvoice toSignedInvoice(byte[] zip, org.springframework.http.HttpHeaders headers,
InvoiceMetadata metadata) {
if (zip == null || zip.length == 0) { if (zip == null || zip.length == 0) {
throw new IllegalStateException("PDF Tool returned an empty response"); throw new IllegalStateException("PDF Tool returned an empty response");
} }
String fileName = response.getHeaders().getContentDisposition().getFilename(); String fileName = headers != null ? headers.getContentDisposition().getFilename() : null;
if (fileName == null || fileName.isBlank()) { if (fileName == null || fileName.isBlank()) {
fileName = metadata.invoiceNumber() + "-zugferd.zip"; fileName = metadata.invoiceNumber() + "-zugferd.zip";
} }
boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER)); boolean valid = headers != null && Boolean.parseBoolean(headers.getFirst(VALIDATION_HEADER));
return new SignedInvoice(zip, fileName, valid); return new SignedInvoice(zip, fileName, valid);
} }

View File

@@ -0,0 +1,122 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Wandelt Nutzer-Rechnungen (Rechnungen der Systemnutzer an ihre eigenen
* Kunden) über das externe PDF Tool in signierte ZUGFeRD-E-Rechnungen um —
* dasselbe Verfahren, das {@code AdminCreateInvoicesView} für die
* System-Rechnungen verwendet. Die EN16931-Metadaten werden vollständig aus
* der persistierten {@link CustomerInvoice} aufgebaut (Absender, Empfänger,
* Positionen), die beim Erstellen der Rechnung in {@code CreateInvoiceView}
* befüllt werden.
*/
@Service
public class UserInvoiceZugferdService {
private final PdfToolService pdfToolService;
public UserInvoiceZugferdService(PdfToolService pdfToolService) {
this.pdfToolService = pdfToolService;
}
/**
* Signiert die Rechnung über das PDF Tool. Wirft eine
* {@link IllegalStateException} mit verständlicher Meldung, wenn der
* Rechnung Daten fehlen, die für eine EN16931-E-Rechnung zwingend sind.
*/
public PdfToolService.SignedInvoice sign(CustomerInvoice invoice) throws Exception {
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
throw new IllegalStateException("Für diese Rechnung ist kein PDF gespeichert.");
}
return pdfToolService.signInvoice(invoice.getPdfData(), buildMetadata(invoice));
}
private PdfToolService.InvoiceMetadata buildMetadata(CustomerInvoice invoice) {
if (isBlank(invoice.getSenderName()) || isBlank(invoice.getRecipientName())) {
throw new IllegalStateException(
"Absender- oder Empfängerdaten fehlen — diese Rechnung wurde vor der E-Rechnungs-Umstellung "
+ "erstellt und kann nicht als E-Rechnung aufbereitet werden.");
}
PdfToolService.Party sender = new PdfToolService.Party(invoice.getSenderName(),
safe(invoice.getSenderAddress()), safe(invoice.getSenderPostcode()), safe(invoice.getSenderCity()),
firstNonBlank(invoice.getSenderCountry(), "DE"), blankToNull(invoice.getSenderVatId()),
safe(invoice.getSenderEmail()), safe(invoice.getSenderPhone()));
PdfToolService.Party recipient = new PdfToolService.Party(
firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName()),
safe(invoice.getRecipientAddress()), safe(invoice.getRecipientPostcode()),
safe(invoice.getRecipientCity()), firstNonBlank(invoice.getRecipientCountry(), "DE"),
blankToNull(invoice.getRecipientVatId()), safe(invoice.getRecipientEmail()), null);
List<PdfToolService.LineItem> items = buildLineItems(invoice);
if (items.isEmpty()) {
throw new IllegalStateException("Die Rechnung enthält keine Positionen mit Betrag.");
}
LocalDate issueDate = invoice.getInvoiceDate() != null ? invoice.getInvoiceDate() : LocalDate.now();
LocalDate deliveryDate = invoice.getDeliveryDate() != null ? invoice.getDeliveryDate() : issueDate;
LocalDate dueDate = invoice.getPaymentDueDate() != null ? invoice.getPaymentDueDate() : issueDate.plusDays(14);
return new PdfToolService.InvoiceMetadata(invoice.getInvoiceNumber(), issueDate, deliveryDate, dueDate, "EUR",
safe(invoice.getPaymentTerms()), null, blankToNull(invoice.getIban()), blankToNull(invoice.getBic()),
sender, recipient, items);
}
private List<PdfToolService.LineItem> buildLineItems(CustomerInvoice invoice) {
BigDecimal vatPercent = invoice.getVatRate() != null
? invoice.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
: new BigDecimal("19");
List<PdfToolService.LineItem> items = new ArrayList<>();
if (invoice.getItems() != null) {
for (CustomerInvoiceItem item : invoice.getItems()) {
if (item == null || item.getQuantity() == null || item.getUnitPrice() == null) {
continue;
}
BigDecimal itemVatPercent = item.getVatRate() != null
? item.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
: vatPercent;
items.add(new PdfToolService.LineItem(safe(item.getDescription()), item.getQuantity(),
item.getUnitPrice(), itemVatPercent));
}
}
// Ältere Rechnungen ohne Positionsliste: eine Sammelposition aus dem
// Rechnungs-Netto, damit sie trotzdem als E-Rechnung versendbar bleiben.
if (items.isEmpty() && invoice.getNetAmount() != null && invoice.getNetAmount().signum() > 0) {
items.add(new PdfToolService.LineItem(firstNonBlank(invoice.getDescription(), "Leistung gemäß Rechnung"),
BigDecimal.ONE, invoice.getNetAmount(), vatPercent));
}
return items;
}
private String safe(String value) {
return value != null ? value : "";
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -1153,3 +1153,22 @@ admincreateinvoices.notification.zugferd.created=E-Rechnung {0} erstellt
admincreateinvoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei. admincreateinvoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
admincreateinvoices.notification.zugferd.error=Fehler beim Erstellen der E-Rechnung: {0} admincreateinvoices.notification.zugferd.error=Fehler beim Erstellen der E-Rechnung: {0}
admincreateinvoices.notification.zugferd.nopositions=Keine abrechenbaren Positionen für die E-Rechnung vorhanden admincreateinvoices.notification.zugferd.nopositions=Keine abrechenbaren Positionen für die E-Rechnung vorhanden
admincreateinvoices.notification.zugferd.batcherror=E-Rechnung {0} konnte nicht erstellt werden, es wurden keine Rechnungen versendet: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validierung fehlgeschlagen für: {0}. Es wurden keine Rechnungen versendet.
admincreateinvoices.notification.zip.combined.error=Fehler beim Erstellen der Sammel-ZIP-Datei: {0}
# E-Rechnung (ZUGFeRD) für Nutzer-Rechnungen an ihre Kunden
invoices.action.zugferd=E-Rechnung (ZIP)
invoices.action.sendinvoice=Per E-Mail senden
invoices.notification.zugferd.created=E-Rechnung {0} erstellt
invoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
invoices.notification.zugferd.invalidsend=Versand abgebrochen: Die Validierung der E-Rechnung {0} ist fehlgeschlagen.
invoices.notification.zugferd.error=Fehler bei der E-Rechnung: {0}
invoices.notification.email.missing=Für den Rechnungsempfänger ist keine E-Mail-Adresse hinterlegt.
invoices.notification.emailsent=Rechnung {0} wurde als E-Rechnung an {1} versendet.
invoices.mail.subject=Ihre Rechnung {0}
invoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} als E-Rechnung (ZIP mit PDF und Rechnungsdaten).\n\nMit freundlichen Grüßen\n{1}
profile.billing.ustid=USt-IdNr.
profile.billing.taxnumber=Steuernummer
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -983,3 +983,22 @@ admincreateinvoices.notification.zugferd.created=E-arve {0} loodud
admincreateinvoices.notification.zugferd.invalid=E-arve on loodud, kuid valideerimine ebaõnnestus. Üksikasjad on ZIP-failis olevas valideerimisaruandes. admincreateinvoices.notification.zugferd.invalid=E-arve on loodud, kuid valideerimine ebaõnnestus. Üksikasjad on ZIP-failis olevas valideerimisaruandes.
admincreateinvoices.notification.zugferd.error=Viga e-arve loomisel: {0} admincreateinvoices.notification.zugferd.error=Viga e-arve loomisel: {0}
admincreateinvoices.notification.zugferd.nopositions=E-arve jaoks puuduvad arveldatavad positsioonid admincreateinvoices.notification.zugferd.nopositions=E-arve jaoks puuduvad arveldatavad positsioonid
admincreateinvoices.notification.zugferd.batcherror=E-arvet {0} ei õnnestunud luua, ühtegi arvet ei saadetud: {1}
admincreateinvoices.notification.zugferd.invalidsend=Valideerimine ebaõnnestus: {0}. Ühtegi arvet ei saadetud.
admincreateinvoices.notification.zip.combined.error=Viga koond-ZIP-faili loomisel: {0}
# E-arve (ZUGFeRD) kasutajate arvetele nende klientidele
invoices.action.zugferd=E-arve (ZIP)
invoices.action.sendinvoice=Saada e-postiga
invoices.notification.zugferd.created=E-arve {0} loodud
invoices.notification.zugferd.invalid=E-arve loodi, kuid valideerimine ebaõnnestus. Vaadake kontrolliaruannet ZIP-failis.
invoices.notification.zugferd.invalidsend=Saatmine katkestati: e-arve {0} valideerimine ebaõnnestus.
invoices.notification.zugferd.error=E-arve viga: {0}
invoices.notification.email.missing=Arve saajale pole e-posti aadressi salvestatud.
invoices.notification.emailsent=Arve {0} saadeti e-arvena aadressile {1}.
invoices.mail.subject=Teie arve {0}
invoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} e-arvena (ZIP koos PDF-i ja arveandmetega).\n\nLugupidamisega\n{1}
profile.billing.ustid=KMKR nr
profile.billing.taxnumber=Maksunumber
profile.billing.bankname=Pank
profile.billing.iban=IBAN

View File

@@ -1153,3 +1153,22 @@ admincreateinvoices.notification.zugferd.created=E-invoice {0} created
admincreateinvoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file. admincreateinvoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
admincreateinvoices.notification.zugferd.error=Error creating the e-invoice: {0} admincreateinvoices.notification.zugferd.error=Error creating the e-invoice: {0}
admincreateinvoices.notification.zugferd.nopositions=No billable positions available for the e-invoice admincreateinvoices.notification.zugferd.nopositions=No billable positions available for the e-invoice
admincreateinvoices.notification.zugferd.batcherror=E-invoice {0} could not be created, no invoices were sent: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validation failed for: {0}. No invoices were sent.
admincreateinvoices.notification.zip.combined.error=Error creating the combined ZIP file: {0}
# E-invoice (ZUGFeRD) for user invoices to their customers
invoices.action.zugferd=E-invoice (ZIP)
invoices.action.sendinvoice=Send by email
invoices.notification.zugferd.created=E-invoice {0} created
invoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
invoices.notification.zugferd.invalidsend=Sending aborted: validation of e-invoice {0} failed.
invoices.notification.zugferd.error=E-invoice error: {0}
invoices.notification.email.missing=No email address is stored for the invoice recipient.
invoices.notification.emailsent=Invoice {0} was sent as an e-invoice to {1}.
invoices.mail.subject=Your invoice {0}
invoices.mail.body=Dear Sir or Madam,\n\nPlease find attached your invoice {0} as an e-invoice (ZIP with PDF and invoice data).\n\nKind regards\n{1}
profile.billing.ustid=VAT ID
profile.billing.taxnumber=Tax number
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -1084,3 +1084,22 @@ admincreateinvoices.notification.zugferd.created=Factura electrónica {0} creada
admincreateinvoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP. admincreateinvoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
admincreateinvoices.notification.zugferd.error=Error al crear la factura electrónica: {0} admincreateinvoices.notification.zugferd.error=Error al crear la factura electrónica: {0}
admincreateinvoices.notification.zugferd.nopositions=No hay posiciones facturables disponibles para la factura electrónica admincreateinvoices.notification.zugferd.nopositions=No hay posiciones facturables disponibles para la factura electrónica
admincreateinvoices.notification.zugferd.batcherror=No se pudo crear la factura electrónica {0}, no se envió ninguna factura: {1}
admincreateinvoices.notification.zugferd.invalidsend=La validación falló para: {0}. No se envió ninguna factura.
admincreateinvoices.notification.zip.combined.error=Error al crear el archivo ZIP combinado: {0}
# Factura electrónica (ZUGFeRD) para facturas de usuarios a sus clientes
invoices.action.zugferd=Factura electrónica (ZIP)
invoices.action.sendinvoice=Enviar por correo
invoices.notification.zugferd.created=Factura electrónica {0} creada
invoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
invoices.notification.zugferd.invalidsend=Envío cancelado: la validación de la factura electrónica {0} ha fallado.
invoices.notification.zugferd.error=Error en la factura electrónica: {0}
invoices.notification.email.missing=No hay una dirección de correo electrónico registrada para el destinatario de la factura.
invoices.notification.emailsent=La factura {0} se envió como factura electrónica a {1}.
invoices.mail.subject=Su factura {0}
invoices.mail.body=Estimados señores:\n\nAdjunto encontrará su factura {0} como factura electrónica (ZIP con PDF y datos de la factura).\n\nAtentamente\n{1}
profile.billing.ustid=NIF-IVA
profile.billing.taxnumber=Número fiscal
profile.billing.bankname=Banco
profile.billing.iban=IBAN

View File

@@ -1084,3 +1084,22 @@ admincreateinvoices.notification.zugferd.created=Facture électronique {0} cré
admincreateinvoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Détails dans le rapport de validation du fichier ZIP. admincreateinvoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Détails dans le rapport de validation du fichier ZIP.
admincreateinvoices.notification.zugferd.error=Erreur lors de la création de la facture électronique : {0} admincreateinvoices.notification.zugferd.error=Erreur lors de la création de la facture électronique : {0}
admincreateinvoices.notification.zugferd.nopositions=Aucune position facturable disponible pour la facture électronique admincreateinvoices.notification.zugferd.nopositions=Aucune position facturable disponible pour la facture électronique
admincreateinvoices.notification.zugferd.batcherror=La facture électronique {0} n''a pas pu être créée, aucune facture n''a été envoyée : {1}
admincreateinvoices.notification.zugferd.invalidsend=La validation a échoué pour : {0}. Aucune facture n''a été envoyée.
admincreateinvoices.notification.zip.combined.error=Erreur lors de la création du fichier ZIP combiné : {0}
# Facture électronique (ZUGFeRD) pour les factures des utilisateurs à leurs clients
invoices.action.zugferd=Facture électronique (ZIP)
invoices.action.sendinvoice=Envoyer par e-mail
invoices.notification.zugferd.created=Facture électronique {0} créée
invoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Voir le rapport de validation dans le fichier ZIP.
invoices.notification.zugferd.invalidsend=Envoi annulé : la validation de la facture électronique {0} a échoué.
invoices.notification.zugferd.error=Erreur de facture électronique : {0}
invoices.notification.email.missing=Aucune adresse e-mail n'est enregistrée pour le destinataire de la facture.
invoices.notification.emailsent=La facture {0} a été envoyée en tant que facture électronique à {1}.
invoices.mail.subject=Votre facture {0}
invoices.mail.body=Madame, Monsieur,\n\nVeuillez trouver ci-joint votre facture {0} sous forme de facture électronique (ZIP avec PDF et données de facturation).\n\nCordialement\n{1}
profile.billing.ustid=N° TVA intracommunautaire
profile.billing.taxnumber=Numéro fiscal
profile.billing.bankname=Banque
profile.billing.iban=IBAN

View File

@@ -1086,3 +1086,22 @@ admincreateinvoices.notification.zugferd.created=E. sąskaita {0} sukurta
admincreateinvoices.notification.zugferd.invalid=E. sąskaita sukurta, tačiau patikra nepavyko. Išsami informacija patikros ataskaitoje ZIP faile. admincreateinvoices.notification.zugferd.invalid=E. sąskaita sukurta, tačiau patikra nepavyko. Išsami informacija patikros ataskaitoje ZIP faile.
admincreateinvoices.notification.zugferd.error=Klaida kuriant e. sąskaitą: {0} admincreateinvoices.notification.zugferd.error=Klaida kuriant e. sąskaitą: {0}
admincreateinvoices.notification.zugferd.nopositions=E. sąskaitai nėra apmokestinamų pozicijų admincreateinvoices.notification.zugferd.nopositions=E. sąskaitai nėra apmokestinamų pozicijų
admincreateinvoices.notification.zugferd.batcherror=Nepavyko sukurti e. sąskaitos {0}, sąskaitos nebuvo išsiųstos: {1}
admincreateinvoices.notification.zugferd.invalidsend=Patikrinimas nepavyko: {0}. Sąskaitos nebuvo išsiųstos.
admincreateinvoices.notification.zip.combined.error=Klaida kuriant bendrą ZIP failą: {0}
# E. sąskaita (ZUGFeRD) naudotojų sąskaitoms jų klientams
invoices.action.zugferd=E. sąskaita (ZIP)
invoices.action.sendinvoice=Siųsti el. paštu
invoices.notification.zugferd.created=E. sąskaita {0} sukurta
invoices.notification.zugferd.invalid=E. sąskaita sukurta, bet patikra nepavyko. Žr. patikros ataskaitą ZIP faile.
invoices.notification.zugferd.invalidsend=Siuntimas nutrauktas: e. sąskaitos {0} patikra nepavyko.
invoices.notification.zugferd.error=E. sąskaitos klaida: {0}
invoices.notification.email.missing=Sąskaitos gavėjui nėra išsaugoto el. pašto adreso.
invoices.notification.emailsent=Sąskaita {0} išsiųsta kaip e. sąskaita adresu {1}.
invoices.mail.subject=Jūsų sąskaita {0}
invoices.mail.body=Gerbiamieji,\n\npridedame Jūsų sąskaitą {0} kaip e. sąskaitą (ZIP su PDF ir sąskaitos duomenimis).\n\nPagarbiai\n{1}
profile.billing.ustid=PVM mokėtojo kodas
profile.billing.taxnumber=Mokesčių mokėtojo numeris
profile.billing.bankname=Bankas
profile.billing.iban=IBAN

View File

@@ -1084,3 +1084,22 @@ admincreateinvoices.notification.zugferd.created=E-rēķins {0} izveidots
admincreateinvoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Detaļas skatiet validācijas atskaitē ZIP failā. admincreateinvoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Detaļas skatiet validācijas atskaitē ZIP failā.
admincreateinvoices.notification.zugferd.error=Kļūda, veidojot e-rēķinu: {0} admincreateinvoices.notification.zugferd.error=Kļūda, veidojot e-rēķinu: {0}
admincreateinvoices.notification.zugferd.nopositions=E-rēķinam nav pieejamu norēķinu pozīciju admincreateinvoices.notification.zugferd.nopositions=E-rēķinam nav pieejamu norēķinu pozīciju
admincreateinvoices.notification.zugferd.batcherror=Neizdevās izveidot e-rēķinu {0}, rēķini netika nosūtīti: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validācija neizdevās: {0}. Rēķini netika nosūtīti.
admincreateinvoices.notification.zip.combined.error=Kļūda, veidojot apvienoto ZIP failu: {0}
# E-rēķins (ZUGFeRD) lietotāju rēķiniem viņu klientiem
invoices.action.zugferd=E-rēķins (ZIP)
invoices.action.sendinvoice=Sūtīt pa e-pastu
invoices.notification.zugferd.created=E-rēķins {0} izveidots
invoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Skatiet pārbaudes atskaiti ZIP failā.
invoices.notification.zugferd.invalidsend=Sūtīšana pārtraukta: e-rēķina {0} validācija neizdevās.
invoices.notification.zugferd.error=E-rēķina kļūda: {0}
invoices.notification.email.missing=Rēķina saņēmējam nav saglabāta e-pasta adrese.
invoices.notification.emailsent=Rēķins {0} nosūtīts kā e-rēķins uz {1}.
invoices.mail.subject=Jūsu rēķins {0}
invoices.mail.body=Cienījamie klienti!\n\nPielikumā Jūsu rēķins {0} kā e-rēķins (ZIP ar PDF un rēķina datiem).\n\nAr cieņu\n{1}
profile.billing.ustid=PVN numurs
profile.billing.taxnumber=Nodokļu maksātāja numurs
profile.billing.bankname=Banka
profile.billing.iban=IBAN

View File

@@ -1084,3 +1084,22 @@ admincreateinvoices.notification.zugferd.created=Utworzono e-fakturę {0}
admincreateinvoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Szczegóły w raporcie walidacji w pliku ZIP. admincreateinvoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Szczegóły w raporcie walidacji w pliku ZIP.
admincreateinvoices.notification.zugferd.error=Błąd podczas tworzenia e-faktury: {0} admincreateinvoices.notification.zugferd.error=Błąd podczas tworzenia e-faktury: {0}
admincreateinvoices.notification.zugferd.nopositions=Brak pozycji rozliczeniowych dla e-faktury admincreateinvoices.notification.zugferd.nopositions=Brak pozycji rozliczeniowych dla e-faktury
admincreateinvoices.notification.zugferd.batcherror=Nie udało się utworzyć e-faktury {0}, żadne faktury nie zostały wysłane: {1}
admincreateinvoices.notification.zugferd.invalidsend=Walidacja nie powiodła się dla: {0}. Żadne faktury nie zostały wysłane.
admincreateinvoices.notification.zip.combined.error=Błąd podczas tworzenia zbiorczego pliku ZIP: {0}
# E-faktura (ZUGFeRD) dla faktur użytkowników dla ich klientów
invoices.action.zugferd=E-faktura (ZIP)
invoices.action.sendinvoice=Wyślij e-mailem
invoices.notification.zugferd.created=E-faktura {0} utworzona
invoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Zobacz raport walidacji w pliku ZIP.
invoices.notification.zugferd.invalidsend=Wysyłka przerwana: walidacja e-faktury {0} nie powiodła się.
invoices.notification.zugferd.error=Błąd e-faktury: {0}
invoices.notification.email.missing=Dla odbiorcy faktury nie zapisano adresu e-mail.
invoices.notification.emailsent=Faktura {0} została wysłana jako e-faktura na adres {1}.
invoices.mail.subject=Państwa faktura {0}
invoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} jako e-fakturę (ZIP z plikiem PDF i danymi faktury).\n\nZ poważaniem\n{1}
profile.billing.ustid=NIP UE
profile.billing.taxnumber=Numer podatkowy
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -1084,3 +1084,22 @@ admincreateinvoices.notification.zugferd.created=Электронный счёт
admincreateinvoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. Подробности в отчёте о проверке в ZIP-файле. admincreateinvoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. Подробности в отчёте о проверке в ZIP-файле.
admincreateinvoices.notification.zugferd.error=Ошибка при создании электронного счёта: {0} admincreateinvoices.notification.zugferd.error=Ошибка при создании электронного счёта: {0}
admincreateinvoices.notification.zugferd.nopositions=Нет позиций для выставления электронного счёта admincreateinvoices.notification.zugferd.nopositions=Нет позиций для выставления электронного счёта
admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1}
admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {0}. Счета не были отправлены.
admincreateinvoices.notification.zip.combined.error=Ошибка при создании общего ZIP-файла: {0}
# Электронный счёт (ZUGFeRD) для счетов пользователей их клиентам
invoices.action.zugferd=Электронный счёт (ZIP)
invoices.action.sendinvoice=Отправить по эл. почте
invoices.notification.zugferd.created=Электронный счёт {0} создан
invoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. См. отчёт о проверке в ZIP-файле.
invoices.notification.zugferd.invalidsend=Отправка отменена: проверка электронного счёта {0} не пройдена.
invoices.notification.zugferd.error=Ошибка электронного счёта: {0}
invoices.notification.email.missing=Для получателя счёта не сохранён адрес электронной почты.
invoices.notification.emailsent=Счёт {0} отправлен как электронный счёт на {1}.
invoices.mail.subject=Ваш счёт {0}
invoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Ваш счёт {0} в виде электронного счёта (ZIP с PDF и данными счёта).\n\nС уважением\n{1}
profile.billing.ustid=Идентификатор плательщика НДС
profile.billing.taxnumber=Налоговый номер
profile.billing.bankname=Банк
profile.billing.iban=IBAN

View File

@@ -1084,3 +1084,22 @@ admincreateinvoices.notification.zugferd.created={0} numaralı e-fatura oluştur
admincreateinvoices.notification.zugferd.invalid=E-fatura oluşturuldu ancak doğrulama başarısız oldu. Ayrıntılar ZIP dosyasındaki doğrulama raporunda. admincreateinvoices.notification.zugferd.invalid=E-fatura oluşturuldu ancak doğrulama başarısız oldu. Ayrıntılar ZIP dosyasındaki doğrulama raporunda.
admincreateinvoices.notification.zugferd.error=E-fatura oluşturulurken hata: {0} admincreateinvoices.notification.zugferd.error=E-fatura oluşturulurken hata: {0}
admincreateinvoices.notification.zugferd.nopositions=E-fatura için faturalandırılabilir pozisyon yok admincreateinvoices.notification.zugferd.nopositions=E-fatura için faturalandırılabilir pozisyon yok
admincreateinvoices.notification.zugferd.batcherror=E-fatura {0} oluşturulamadı, hiçbir fatura gönderilmedi: {1}
admincreateinvoices.notification.zugferd.invalidsend=Doğrulama başarısız oldu: {0}. Hiçbir fatura gönderilmedi.
admincreateinvoices.notification.zip.combined.error=Toplu ZIP dosyası oluşturulurken hata: {0}
# Kullanıcıların müşterilerine kestiği faturalar için e-fatura (ZUGFeRD)
invoices.action.zugferd=E-fatura (ZIP)
invoices.action.sendinvoice=E-posta ile gönder
invoices.notification.zugferd.created=E-fatura {0} oluşturuldu
invoices.notification.zugferd.invalid=E-fatura oluşturuldu, ancak doğrulama başarısız oldu. ZIP dosyasındaki doğrulama raporuna bakın.
invoices.notification.zugferd.invalidsend=Gönderim iptal edildi: {0} e-faturasının doğrulaması başarısız oldu.
invoices.notification.zugferd.error=E-fatura hatası: {0}
invoices.notification.email.missing=Fatura alıcısı için kayıtlı bir e-posta adresi yok.
invoices.notification.emailsent={0} numaralı fatura e-fatura olarak {1} adresine gönderildi.
invoices.mail.subject=Faturanız {0}
invoices.mail.body=Sayın yetkili,\n\nekte {0} numaralı faturanızı e-fatura olarak (PDF ve fatura verilerini içeren ZIP) bulabilirsiniz.\n\nSaygılarımızla\n{1}
profile.billing.ustid=KDV kimlik no
profile.billing.taxnumber=Vergi numarası
profile.billing.bankname=Banka
profile.billing.iban=IBAN

View File

@@ -0,0 +1,161 @@
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class UserInvoiceZugferdServiceTest {
@Mock
private PdfToolService pdfToolService;
@Captor
private ArgumentCaptor<PdfToolService.InvoiceMetadata> metadataCaptor;
private UserInvoiceZugferdService service;
@BeforeEach
void setUp() {
service = new UserInvoiceZugferdService(pdfToolService);
}
private CustomerInvoice fullyPopulatedInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setInvoiceNumber("RE-2026-0001");
invoice.setInvoiceDate(LocalDate.of(2026, 7, 14));
invoice.setDeliveryDate(LocalDate.of(2026, 7, 10));
invoice.setPaymentDueDate(LocalDate.of(2026, 7, 28));
invoice.setSenderName("Spedition Muster GmbH");
invoice.setSenderAddress("Musterstraße 1");
invoice.setSenderPostcode("20095");
invoice.setSenderCity("Hamburg");
invoice.setSenderVatId("DE123456789");
invoice.setSenderEmail("info@muster.de");
invoice.setSenderPhone("040 123456");
invoice.setRecipientCompany("Kunde AG");
invoice.setRecipientName("Erika Beispiel");
invoice.setRecipientAddress("Beispielweg 2");
invoice.setRecipientPostcode("10115");
invoice.setRecipientCity("Berlin");
invoice.setRecipientEmail("rechnung@kunde-ag.de");
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setIban("DE02120300000000202051");
invoice.setPaymentTerms("Zahlbar innerhalb von 14 Tagen");
invoice.setItems(List.of(
new CustomerInvoiceItem(new BigDecimal("12"), "km", "Transport", new BigDecimal("5.00"),
new BigDecimal("0.19")),
new CustomerInvoiceItem(BigDecimal.ONE, "pauschal", "Grundpreis", new BigDecimal("40.00"),
new BigDecimal("0.19"))));
invoice.setPdfData(new byte[] { 1, 2, 3 });
return invoice;
}
@Test
void signBuildsMetadataFromInvoice() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
PdfToolService.SignedInvoice signed = new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true);
when(pdfToolService.signInvoice(eq(invoice.getPdfData()), any())).thenReturn(signed);
PdfToolService.SignedInvoice result = service.sign(invoice);
assertThat(result).isSameAs(signed);
verify(pdfToolService).signInvoice(eq(invoice.getPdfData()), metadataCaptor.capture());
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
assertThat(metadata.invoiceNumber()).isEqualTo("RE-2026-0001");
assertThat(metadata.issueDate()).isEqualTo(LocalDate.of(2026, 7, 14));
assertThat(metadata.deliveryDate()).isEqualTo(LocalDate.of(2026, 7, 10));
assertThat(metadata.dueDate()).isEqualTo(LocalDate.of(2026, 7, 28));
assertThat(metadata.currency()).isEqualTo("EUR");
assertThat(metadata.iban()).isEqualTo("DE02120300000000202051");
assertThat(metadata.sender().name()).isEqualTo("Spedition Muster GmbH");
assertThat(metadata.sender().vatId()).isEqualTo("DE123456789");
assertThat(metadata.recipient().name()).isEqualTo("Kunde AG");
assertThat(metadata.recipient().zip()).isEqualTo("10115");
assertThat(metadata.recipient().email()).isEqualTo("rechnung@kunde-ag.de");
assertThat(metadata.items()).hasSize(2);
assertThat(metadata.items().get(0).description()).isEqualTo("Transport");
assertThat(metadata.items().get(0).quantity()).isEqualByComparingTo("12");
assertThat(metadata.items().get(0).unitPriceNet()).isEqualByComparingTo("5.00");
assertThat(metadata.items().get(0).vatPercent()).isEqualByComparingTo("19");
}
@Test
void signFallsBackToSingleLineForLegacyInvoiceWithoutItems() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setItems(null);
invoice.setDescription("Kurierfahrt HamburgBerlin");
when(pdfToolService.signInvoice(any(), any()))
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
service.sign(invoice);
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
List<PdfToolService.LineItem> items = metadataCaptor.getValue().items();
assertThat(items).hasSize(1);
assertThat(items.get(0).description()).isEqualTo("Kurierfahrt HamburgBerlin");
assertThat(items.get(0).quantity()).isEqualByComparingTo("1");
assertThat(items.get(0).unitPriceNet()).isEqualByComparingTo("100.00");
}
@Test
void signUsesDefaultDatesWhenMissing() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setDeliveryDate(null);
invoice.setPaymentDueDate(null);
when(pdfToolService.signInvoice(any(), any()))
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
service.sign(invoice);
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
assertThat(metadata.deliveryDate()).isEqualTo(invoice.getInvoiceDate());
assertThat(metadata.dueDate()).isEqualTo(invoice.getInvoiceDate().plusDays(14));
}
@Test
void signRejectsInvoiceWithoutPdf() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setPdfData(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("kein PDF");
}
@Test
void signRejectsInvoiceWithoutParties() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setSenderName(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Absender");
}
@Test
void signRejectsInvoiceWithoutAnyAmount() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setItems(null);
invoice.setNetAmount(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Positionen");
}
}