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 recipientCountry;
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
private String recipientEmail; // E-Mail für den Rechnungsversand
// Rechnungsdetails
private String description; // Beschreibung der Leistung
@@ -274,6 +275,14 @@ public class CustomerInvoice {
this.recipientVatId = recipientVatId;
}
public String getRecipientEmail() {
return recipientEmail;
}
public void setRecipientEmail(String recipientEmail) {
this.recipientEmail = recipientEmail;
}
public String getDescription() {
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
* address and reports how many mails were (not) sent.
* Sends the invoices as signed ZUGFeRD e-invoice (ZIP) to the respective
* 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) {
// 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 = "";
try {
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()));
// Phase 2: all ZIPs exist, send one mail per invoice with its ZIP
int sent = 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();
if (email == null || email.isBlank()) {
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.body", preview.invoiceNumber(), monthLabel,
issuerName),
preview.pdf(), preview.invoiceNumber() + ".pdf");
signed.zip(), signed.fileName());
recordDispatch(preview, email);
sent++;
} catch (Exception ex) {
@@ -587,6 +622,22 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
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) {
grid.deselectAll();
grid.setItems(loadBillingInfos());
@@ -748,11 +799,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
try {
PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(),
buildZugferdMetadata(preview));
String base64Zip = Base64.getEncoder().encodeToString(signed.zip());
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);");
triggerZipDownload(signed.zip(), signed.fileName());
if (signed.valid()) {
Notification
.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
* 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.InvoiceTemplate;
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.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
@@ -586,6 +587,14 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatAmount(vatAmount);
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);
// 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.
*/

View File

@@ -372,6 +372,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
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)
final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull();

View File

@@ -33,11 +33,15 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.UserInvoiceZugferdService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream;
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)
@RolesAllowed({ "USER", "ADMIN" })
@Slf4j
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
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 UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
private final UserInvoiceZugferdService userInvoiceZugferdService;
private final EmailService emailService;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) {
UserRepository userRepository, UserInvoiceZugferdService userInvoiceZugferdService,
EmailService emailService) {
this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
@@ -77,6 +85,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
this.userInvoiceZugferdService = userInvoiceZugferdService;
this.emailService = emailService;
setSizeFull();
setPadding(true);
@@ -201,6 +211,22 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
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 (invoicePermissionService.canCorrect(currentUser)) {
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) {
try {
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
* 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 {
String metadataJson = objectMapper.writeValueAsString(metadata);
@@ -79,23 +82,30 @@ public class PdfToolService {
}, MediaType.APPLICATION_PDF);
body.part("metadata", metadataJson, MediaType.APPLICATION_JSON);
ResponseEntity<byte[]> response;
try {
response = restClient.post().uri("/api/invoices/sign").contentType(MediaType.MULTIPART_FORM_DATA)
.body(body.build()).retrieve().toEntity(byte[].class);
ResponseEntity<byte[]> response = restClient.post().uri("/api/invoices/sign")
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.toEntity(byte[].class);
return toSignedInvoice(response.getBody(), response.getHeaders(), metadata);
} 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);
}
}
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) {
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()) {
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);
}

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.error=Fehler beim Erstellen der E-Rechnung: {0}
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.error=Viga e-arve loomisel: {0}
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.error=Error creating the e-invoice: {0}
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.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.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.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.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.error=Klaida kuriant e. sąskaitą: {0}
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.error=Kļūda, veidojot e-rēķinu: {0}
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.error=Błąd podczas tworzenia e-faktury: {0}
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.error=Ошибка при создании электронного счёта: {0}
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.error=E-fatura oluşturulurken hata: {0}
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");
}
}