Initialer Stand des PDF Tools
ZUGFeRD-E-Rechnungs-Konverter mit Vaadin-UI: E-Rechnung aus PDF oder Rechnungstemplate erzeugen, Template-Designer, Adressbuch und speicherbare Formulareingaben (MongoDB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package de.assecutor.pdftool.invoice;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VatIdValidatorTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"DE261094748", // Assecutor Data Service GmbH
|
||||
"DE136695976", // gültige Prüfziffer
|
||||
"de 261 094 748", // Leerzeichen und Kleinschreibung werden normalisiert
|
||||
"ATU12345678",
|
||||
"NL123456789B01",
|
||||
"FRXX123456789"
|
||||
})
|
||||
void accepts_valid_vat_ids(String vatId) {
|
||||
assertThat(VatIdValidator.validate(vatId)).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"DE261094749", // falsche Prüfziffer
|
||||
"DE12345678", // zu kurz
|
||||
"DE1234567890", // zu lang
|
||||
"XX123456789", // unbekanntes Länderpräfix
|
||||
"123456789", // Länderpräfix fehlt
|
||||
"ATU1234567", // falsches Format für AT
|
||||
"DE" // nur Präfix
|
||||
})
|
||||
void rejects_invalid_vat_ids(String vatId) {
|
||||
assertThat(VatIdValidator.validate(vatId)).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejects_blank_values() {
|
||||
assertThat(VatIdValidator.validate(null)).isPresent();
|
||||
assertThat(VatIdValidator.validate(" ")).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizes_separators_and_case() {
|
||||
assertThat(VatIdValidator.normalize("de 261-094.748")).isEqualTo("DE261094748");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.assecutor.pdftool.template;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TemplatePdfServiceTest {
|
||||
|
||||
private final TemplatePdfService service = new TemplatePdfService();
|
||||
|
||||
@Test
|
||||
void rendersCanvasTemplateWithVariablesAndPositionsToPdf() {
|
||||
String template = """
|
||||
{"elements": [
|
||||
{"id": "e1", "type": "text", "variable": "masterdata.company_name",
|
||||
"xPercent": 10, "yPercent": 5, "widthPercent": 40, "heightPercent": 3},
|
||||
{"id": "e2", "type": "text", "text": "Freier Text",
|
||||
"xPercent": 10, "yPercent": 10, "widthPercent": 40, "heightPercent": 3},
|
||||
{"id": "e3", "type": "line", "xPercent": 10, "yPercent": 15, "widthPercent": 80, "heightPercent": 0},
|
||||
{"id": "e4", "type": "text", "variable": "services.list",
|
||||
"xPercent": 10, "yPercent": 20, "widthPercent": 80, "heightPercent": 20}
|
||||
]}""";
|
||||
Map<String, String> variables = Map.of(
|
||||
"masterdata.company_name", "Assecutor Data Service GmbH",
|
||||
"services.json",
|
||||
"[{\"name\":\"Beratung\",\"netAmount\":\"100,00\",\"vat\":\"19%\"},"
|
||||
+ "{\"name\":\"Fachbuch\",\"netAmount\":\"50,00\",\"vat\":\"7%\"}]",
|
||||
"invoice.net_total", "150,00 €",
|
||||
"invoice.vat_total", "22,50 €",
|
||||
"invoice.gross_total", "172,50 €",
|
||||
"invoice.vat_rate", "");
|
||||
|
||||
byte[] pdf = service.generatePdf(template, variables, new BigDecimal("0.19"));
|
||||
|
||||
assertTrue(pdf.length > 500, "PDF sollte Inhalt haben");
|
||||
assertArrayEquals("%PDF-".getBytes(StandardCharsets.US_ASCII),
|
||||
java.util.Arrays.copyOfRange(pdf, 0, 5), "Ergebnis muss ein PDF sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPixelCoordinatesAreConverted() {
|
||||
String template = """
|
||||
{"elements": [
|
||||
{"id": "e1", "type": "text", "text": "Legacy", "x": 100, "y": 100, "width": 200, "height": 30}
|
||||
]}""";
|
||||
|
||||
byte[] pdf = service.generatePdf(template, Map.of(), null);
|
||||
|
||||
assertTrue(pdf.length > 500);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidJsonThrowsTemplateRenderException() {
|
||||
assertThrows(TemplateRenderException.class,
|
||||
() -> service.generatePdf("kein json", Map.of(), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.assecutor.pdftool.template;
|
||||
|
||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TemplateVariablesTest {
|
||||
|
||||
private static InvoiceMetadata.LineItem item(String description, String quantity, String unitPrice, String vat) {
|
||||
return new InvoiceMetadata.LineItem(description, new BigDecimal(quantity), new BigDecimal(unitPrice),
|
||||
new BigDecimal(vat));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addPositionsComputesTotalsFromQuantityAndUnitPrice() {
|
||||
Map<String, String> variables = new HashMap<>();
|
||||
|
||||
TemplateVariables.addPositions(variables, List.of(
|
||||
item("Beratung", "2", "100.00", "19"),
|
||||
item("Fachbuch", "1", "50.00", "19")));
|
||||
|
||||
assertEquals("250,00 €", variables.get("invoice.net_total"));
|
||||
assertEquals("47,50 €", variables.get("invoice.vat_total"));
|
||||
assertEquals("297,50 €", variables.get("invoice.gross_total"));
|
||||
assertEquals("19%", variables.get("invoice.vat_rate"));
|
||||
assertEquals("2", variables.get("services.count"));
|
||||
assertTrue(variables.get("services.json").contains("\"netAmount\":\"200,00\""),
|
||||
"Nettobetrag der Position muss Menge x Einzelpreis sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedVatRatesYieldEmptyUniformLabelAndPerRowVat() {
|
||||
Map<String, String> variables = new HashMap<>();
|
||||
|
||||
TemplateVariables.addPositions(variables, List.of(
|
||||
item("Beratung", "1", "100.00", "19"),
|
||||
item("Fachbuch", "1", "50.00", "7")));
|
||||
|
||||
assertEquals("", variables.get("invoice.vat_rate"));
|
||||
assertEquals("22,50 €", variables.get("invoice.vat_total"));
|
||||
assertTrue(variables.get("services.json").contains("\"vat\":\"19%\""));
|
||||
assertTrue(variables.get("services.json").contains("\"vat\":\"7%\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void canvasPositionsJsonContainsRowsAndTotals() {
|
||||
String json = TemplateVariables.canvasPositionsJson(List.of(item("Beratung", "1", "100.00", "19")));
|
||||
|
||||
assertTrue(json.contains("\"rows\""));
|
||||
assertTrue(json.contains("\"netTotal\":\"100,00 €\""));
|
||||
assertTrue(json.contains("\"grossTotal\":\"119,00 €\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package de.assecutor.pdftool.zugferd;
|
||||
|
||||
import de.assecutor.pdftool.invoice.InvoiceMetadata;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ZugferdServiceTest {
|
||||
|
||||
private final ZugferdService service = new ZugferdService(new ZugferdValidationService());
|
||||
|
||||
@Test
|
||||
void createsZipWithZugferdPdfAndFacturXml() throws Exception {
|
||||
byte[] sourcePdf = createSamplePdf();
|
||||
|
||||
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
|
||||
|
||||
assertEquals("RE-2026-0815.zip", result.zipFileName());
|
||||
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
|
||||
|
||||
Map<String, byte[]> entries = readZip(result.zip());
|
||||
assertEquals(3, entries.size());
|
||||
byte[] pdf = entries.get("RE-2026-0815-zugferd.pdf");
|
||||
byte[] xml = entries.get("factur-x.xml");
|
||||
byte[] report = entries.get("validation-report.xml");
|
||||
assertNotNull(pdf, "ZUGFeRD-PDF fehlt im ZIP");
|
||||
assertNotNull(xml, "Factur-X-XML fehlt im ZIP");
|
||||
assertNotNull(report, "Prüfbericht fehlt im ZIP");
|
||||
|
||||
String xmlText = new String(xml, StandardCharsets.UTF_8);
|
||||
assertTrue(xmlText.contains("RE-2026-0815"), "Rechnungsnummer fehlt im XML");
|
||||
assertTrue(xmlText.contains("CrossIndustryInvoice"), "Kein CII-XML");
|
||||
assertTrue(xmlText.contains("urn:cen.eu:en16931:2017"), "EN16931-Profil fehlt");
|
||||
|
||||
// Das erzeugte PDF muss das XML als eingebettete Datei enthalten
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
String names = doc.getDocumentCatalog().getNames().getEmbeddedFiles().getNames().keySet().toString();
|
||||
assertTrue(names.contains("factur-x.xml"), "factur-x.xml nicht ins PDF eingebettet: " + names);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsPdfWhosePageHasNoResources() throws Exception {
|
||||
// Mustang 2.17 stürzt ohne Resources-Dictionary mit einer NPE ab —
|
||||
// der Service muss solche PDFs vorab reparieren.
|
||||
byte[] sourcePdf;
|
||||
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(out);
|
||||
sourcePdf = out.toByteArray();
|
||||
}
|
||||
|
||||
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
|
||||
|
||||
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPdfInput() {
|
||||
assertThrows(ZugferdConversionException.class,
|
||||
() -> service.createZugferdZip("kein pdf".getBytes(StandardCharsets.UTF_8), sampleMetadata()));
|
||||
}
|
||||
|
||||
private static InvoiceMetadata sampleMetadata() {
|
||||
return new InvoiceMetadata(
|
||||
"RE-2026-0815",
|
||||
LocalDate.of(2026, 7, 8),
|
||||
LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2026, 7, 22),
|
||||
"EUR",
|
||||
"Zahlbar innerhalb von 14 Tagen ohne Abzug.",
|
||||
"KR-2026-042",
|
||||
"DE75512108001245126199",
|
||||
null,
|
||||
new InvoiceMetadata.Party("Assecutor Data Service GmbH", "Gerhart-Hauptmann-Weg 14",
|
||||
"21502", "Geesthacht", "DE", "DE261094748", "rechnung@example.com",
|
||||
"+49 40 18 123 771 0"),
|
||||
new InvoiceMetadata.Party("Kunde AG", "Beispielweg 2", "10115", "Berlin", "DE", null,
|
||||
"einkauf@example.com", null),
|
||||
List.of(new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE,
|
||||
new BigDecimal("1500.00"), new BigDecimal("19")))
|
||||
);
|
||||
}
|
||||
|
||||
private static byte[] createSamplePdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
// PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei
|
||||
PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream(
|
||||
"/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true);
|
||||
cs.beginText();
|
||||
cs.setFont(font, 12);
|
||||
cs.newLineAtOffset(50, 700);
|
||||
cs.showText("Rechnung RE-2026-0815");
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, byte[]> readZip(byte[] zip) throws Exception {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
entries.put(entry.getName(), zis.readAllBytes());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user