ZUGFeRD mit horstoeko/zugferd und FileMaker

Nach unzähligen Versuchen mit TCPDF, FPDI und verschiedensten Merge-Strategien, habe ich mich letztlich für einen pragmatischeren Weg entschieden: Ich lasse sowohl das PDF als auch das XML direkt auf dem Server erzeugen – ohne nachträglichen Merge. Die Lösung basiert auf dem PHP-Paket horstoeko/zugferd, welches sich nach einigen Stolpersteinen als zuverlässig herausgestellt hat – sobald man seine Eigenheiten akzeptiert.
Im ersten Szenario habe ich die fertige PDF aus FileMaker auf den Server, dort dann die Daten als POST-Parameter empfangen. Dann versucht die beiden Dateien, XML und PDF zu verschmelzen. Keine Change, bin fast verzweifelt, kenne die Doku zu Horstoeko/Zugferd aus dem FF. Aber es hat nicht geklappt. Also jetzt der pragmatische Ansatz. Die Daten werden übertragen und dann wird auf dem Server alles erzeugt.
Der FileMaker-Teil übergibt die notwendigen Daten per POST an ein PHP-Skript auf dem Server. Dieses Skript generiert daraus die ZUGFeRD-konforme XML, erzeugt gleichzeitig ein einfaches PDF mit den wichtigsten Rechnungsdaten (z. B. Rechnungstitel, Nummer etc.) – und bindet die XML direkt beim Erzeugen ein. Kein nachträgliches Anhängen mehr nötig. Kein Merge-Objekt, kein Zwischenschritt. Die erzeugte Datei ist PDF/A-3B und enthält die eingebettete Rechnung als XML. Dabei wird die PDF gleich im PHP etwas angepasst. Vermutlich wird das ganze noch etwas schicker mit css Implementierung, aber für den Anfang reicht es so.
Hier ist der komplette PHP-Code, den ich aktuell produktiv im Testsystem einsetze:
$_POST['sellerName'] ?? '',
'street' => $_POST['sellerStreet'] ?? '',
'zip' => $_POST['sellerPostalCode'] ?? '',
'city' => $_POST['sellerCity'] ?? '',
'country' => $_POST['sellerCountryCode'] ?? 'DE',
'tax_id' => $_POST['sellerTaxID'] ?? ''
];
// Rechnungsempfänger-Daten
$buyer = [
'name' => $_POST['buyerName'] ?? '',
'street' => $_POST['buyerStreet'] ?? '',
'zip' => $_POST['buyerPostalCode'] ?? '',
'city' => $_POST['buyerCity'] ?? '',
'country' => $_POST['buyerCountryCode'] ?? 'DE',
'tax_id' => $_POST['buyerTaxID'] ?? ''
];
// Zahlungsinformationen
$payment = [
'means_code' => $_POST['paymentMeansCode'] ?? '',
'financial_institution' => $_POST['payeeFinancialInstitution'] ?? '',
'iban' => $_POST['payeeIBAN'] ?? '',
'bic' => $_POST['payeeBIC'] ?? '',
'reference' => $_POST['paymentReference'] ?? ''
];
// Steuerinformationen
$tax = [
'rate' => floatval(str_replace(',', '.', $_POST['taxRate'] ?? '19')),
'amount' => floatval(str_replace(',', '.', $_POST['taxAmount'] ?? '0')),
'taxable_amount' => floatval(str_replace(',', '.', $_POST['taxableAmount'] ?? '0')),
'category_code' => $_POST['taxCategoryCode'] ?? 'S'
];
// Positionen aus lineItemsRaw extrahieren
$positions = [];
$lineItems = explode('|', $_POST['lineItemsRaw'] ?? '');
foreach ($lineItems as $lineItem) {
if (empty($lineItem)) continue;
$parts = explode(';', $lineItem);
if (count($parts) >= 7) {
$quantity = floatval($parts[3]);
$netPrice = floatval(str_replace(',', '.', $parts[5]));
$total = $quantity * $netPrice;
$orderDate = isset($parts[8]) ? date('d.m.Y', strtotime($parts[8])) : '';
$positions[] = [
'position' => $parts[0],
'description' => $parts[1],
'article_number' => $parts[2],
'quantity' => $quantity,
'unit' => $parts[4],
'net_price' => $netPrice,
'tax_rate' => floatval($parts[6]),
'total' => $total,
'order_date' => $orderDate
];
}
}
// Summen neu berechnen
$totalNet = 0;
$totalTax = 0;
foreach ($positions as $position) {
$totalNet += $position['total'];
$totalTax += $position['total'] * ($position['tax_rate'] / 100);
}
$totals = [
'net' => $totalNet,
'tax' => $totalTax,
'gross' => $totalNet + $totalTax
];
// Pfade definieren
$pdfPath = $uploadDir . $invoiceNumber . '.pdf';
$xmlPath = $uploadDir . 'inv_' . $invoiceNumber . '.xml';
$outputPath = $uploadDir . $invoiceNumber . '_ZUGFeRD.pdf';
// PDF-Datei überprüfen
if (!checkFile($pdfPath, "PDF-Datei")) {
throw new Exception("PDF-Datei nicht verfügbar");
}
// ZUGFeRD-Dokument erstellen
logMessage("Erstelle ZUGFeRD-Dokument...");
$document = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_BASIC);
logMessage("DocumentBuilder initialisiert");
// Basisinformationen setzen
logMessage("Setze Basisinformationen...");
$document->setDocumentInformation(
$invoiceTypeCode, // Dokumenttyp (380 = Rechnung)
$invoiceNumber, // Rechnungsnummer
new DateTime($invoiceDate), // Rechnungsdatum
$currency // Währung
);
logMessage("Basisinformationen gesetzt");
// Rechnungssteller setzen
logMessage("Setze Rechnungssteller...");
$document->setDocumentSeller(
$seller['name'], // Name
$seller['zip'], // PLZ
$seller['city'], // Stadt
$seller['street'], // Straße
$seller['country'] // Land
);
logMessage("Rechnungssteller gesetzt");
// Rechnungsempfänger setzen
logMessage("Setze Rechnungsempfänger...");
$document->setDocumentBuyer(
$buyer['name'], // Name
$buyer['zip'], // PLZ
$buyer['city'], // Stadt
$buyer['street'], // Straße
$buyer['country'] // Land
);
logMessage("Rechnungsempfänger gesetzt");
// Positionen hinzufügen
logMessage("Füge Positionen hinzu...");
foreach ($positions as $index => $position) {
$document->addNewPosition((string)($index + 1));
$document->setDocumentPositionProductDetails($position['description']);
$document->setDocumentPositionNetPrice($position['net_price']);
$document->setDocumentPositionQuantity($position['quantity'], $position['unit']);
$document->addDocumentPositionTax("S", "VAT", $position['tax_rate']);
$document->setDocumentPositionLineSummation($position['total']);
logMessage("Position {$position['description']} hinzugefügt");
}
// PDF mit Rechnungsdaten erstellen
logMessage("🔄 Erstelle PDF mit Rechnungsdaten...");
try {
$pdf = new FPDF();
$pdf->AddPage();
$pdf->SetAutoPageBreak(true, 20);
// Schriftarten definieren
$pdf->SetFont('Arial', '', 10);
// Absenderadresse
$pdf->SetXY(20, 20);
$pdf->Cell(0, 5, $seller['name'], 0, 1);
$pdf->Cell(0, 5, 'GmbH & Co. KG', 0, 1);
$pdf->Cell(0, 5, $seller['street'], 0, 1);
$pdf->Cell(0, 5, 'D-' . $seller['zip'] . ' ' . $seller['city'], 0, 1);
// Rechnungsinformationen rechts oben
$pdf->SetXY(120, 20);
$pdf->SetFont('Arial', '', 9);
// Rechte Spalte mit Informationen
$rightColumnData = [
['Nummer', $invoiceNumber],
['Datum', date('d.m.Y', strtotime($invoiceDate))],
['Kunden Nr.', $_POST['customerNumber'] ?? '16105'],
['Lieferschein', $_POST['deliveryNote'] ?? ''],
['Lief. Datum', date('d.m.Y', strtotime($_POST['deliveryDate'] ?? $invoiceDate))]
];
foreach ($rightColumnData as $row) {
$pdf->SetX(120);
$pdf->Cell(30, 5, $row[0], 0, 0);
$pdf->Cell(40, 5, $row[1], 0, 1);
}
// Überschrift "Rechnung"
$pdf->SetFont('Arial', '', 14);
$pdf->SetXY(20, 70);
$pdf->Cell(0, 10, 'Rechnung', 0, 1);
// Positionen Header
$pdf->SetFont('Arial', '', 8);
$pdf->SetFillColor(240, 240, 240);
$pdf->SetY($pdf->GetY() + 5);
// Spaltenbreiten
$colWidths = [
'auftrag' => 25,
'bestellung' => 35,
'kommission' => 35,
'artikel' => 25,
'bezeichnung' => 60,
'menge' => 20,
'preis' => 25,
'gesamt' => 25
];
// Positionen ausgeben
foreach ($positions as $index => $position) {
// Grauer Balken für Auftragskopf
$pdf->SetFillColor(240, 240, 240);
$y = $pdf->GetY();
// Auftragskopf mit Datum
$pdf->Cell($colWidths['auftrag'], 5, 'Auftrag: ' . $position['article_number'] . ' / ' . $position['order_date'], 0, 0, 'L', true);
$pdf->Cell($colWidths['bestellung'], 5, 'Ihre Bestellung: ' . $_POST['orderNumber'], 0, 0, 'L', true);
$pdf->Cell($colWidths['kommission'], 5, 'Ihre Kommission:', 0, 1, 'L', true);
// Positionsdetails
$pdf->SetFont('Arial', '', 8);
$pdf->Cell($colWidths['artikel'], 5, $position['article_number'], 0, 0);
$pdf->Cell($colWidths['bezeichnung'], 5, $position['description'], 0, 0);
$pdf->Cell($colWidths['menge'], 5, $position['quantity'] . ' ' . $position['unit'], 0, 0, 'R');
$pdf->Cell($colWidths['preis'], 5, number_format($position['net_price'], 2, ',', '.') . ' €', 0, 0, 'R');
$pdf->Cell($colWidths['gesamt'], 5, number_format($position['total'], 2, ',', '.') . ' €', 0, 1, 'R');
$pdf->Ln(2);
}
// Summen am Ende
$pdf->SetY(-60);
$pdf->SetFont('Arial', '', 9);
// Zahlungsbedingungen
$pdf->Cell(0, 5, 'Rechnungsbetrag zahlbar bis ' . date('d.m.Y', strtotime($dueDate)), 0, 1);
$pdf->Cell(0, 5, 'Bei Zahlung bis ' . date('d.m.Y', strtotime($dueDate)) . ' Skonto 3 %', 0, 1);
// Summen rechtsbündig
$pdf->SetX(-80);
$pdf->Cell(30, 5, 'Summe netto', 0, 0, 'R');
$pdf->Cell(50, 5, number_format($totals['net'], 2, ',', '.') . ' €', 0, 1, 'R');
$pdf->SetX(-80);
$pdf->Cell(30, 5, 'USt. ' . number_format($tax['rate'], 0) . ' %', 0, 0, 'R');
$pdf->Cell(50, 5, number_format($totals['tax'], 2, ',', '.') . ' €', 0, 1, 'R');
$pdf->SetX(-80);
$pdf->SetFont('Arial', 'B', 9);
$pdf->Cell(30, 5, 'Rechnungsbetrag', 0, 0, 'R');
$pdf->Cell(50, 5, number_format($totals['gross'], 2, ',', '.') . ' €', 0, 1, 'R');
// Bestellnummer
$pdf->SetFont('Arial', '', 9);
$pdf->SetY(-30);
$pdf->Cell(0, 5, 'Zu Kommission: Bestell-Nr.: ' . ($_POST['orderReference'] ?? ''), 0, 1);
// Horizontale Linie am Ende
$pdf->SetY(-20);
$pdf->Line(20, $pdf->GetY(), 190, $pdf->GetY());
$pdf->Output('F', $outputPath);
logMessage("PDF erfolgreich generiert: $outputPath");
if (file_exists($outputPath)) {
logMessage("ZUGFeRD-PDF erfolgreich erstellt: " . basename($outputPath));
logMessage("Dateigröße: " . filesize($outputPath) . " Bytes");
} else {
throw new Exception("ZUGFeRD-PDF wurde nicht erstellt!");
}
} catch (Exception $e) {
logMessage(" Fehler beim PDF-Generieren: " . $e->getMessage());
logMessage(" Stack Trace:\n" . $e->getTraceAsString());
throw $e;
}
} catch (Exception $e) {
logMessage(" Fehler beim Erstellen der ZUGFeRD-PDF: " . $e->getMessage());
logMessage(" Stack Trace:\n" . $e->getTraceAsString());
// Debug-Informationen
logMessage("\nDebug-Informationen:");
logMessage("PHP Version: " . PHP_VERSION);
logMessage("Memory Limit: " . ini_get('memory_limit'));
logMessage("Max Execution Time: " . ini_get('max_execution_time'));
logMessage("Upload Directory Permissions: " . substr(sprintf('%o', fileperms($uploadDir)), -4));
// XML-Datei prüfen
if (file_exists($xmlPath)) {
logMessage("XML-Datei existiert: " . filesize($xmlPath) . " Bytes");
logMessage("XML-Inhalt (erste 200 Zeichen):\n" . substr(file_get_contents($xmlPath), 0, 200));
} else {
logMessage("XML-Datei existiert nicht");
}
}
Natürlich ist das so erst der Anfang, so fehlt die Überprüfung des fertigen PDF-A, aber bei händischen Tests, wurde alles akzeptiert.
Die Vorgehensweise aus FileMaker heraus ist klar. Daten sammeln und als POST übertragen. Der POST schaut in etwa so aus. Aus URL einfügen:
“-X POST " & “–header "Content-Type: application/x-www-form-urlencoded" " & “–data " & Zitat ( “invoiceNumber=” & $invoiceNumber & “&invoiceDate=” & $invoiceDate & “&invoiceCurrencyCode=” & $invoiceCurrencyCode & “&invoiceTypeCode=” & $invoiceTypeCode & “&dueDate=” & $dueDate & “&paymentTerms=” & $paymentTerms & “&deliveryTerms=” & $deliveryTerms &
“&sellerName=” & $sellerName & “&sellerStreet=” & $sellerStreet & “&sellerPostalCode=” & $sellerPostalCode & “&sellerCity=” & $sellerCity & “&sellerCountryCode=” & $sellerCountryCode & “&sellerTaxID=” & $sellerTaxID &
“&buyerName=” & $buyerName & “&buyerStreet=” & $buyerStreet & “&buyerPostalCode=” & $buyerPostalCode & “&buyerCity=” & $buyerCity & “&buyerCountryCode=” & $buyerCountryCode & “&buyerTaxID=” & $buyerTaxID &
“&paymentMeansCode=” & $paymentMeansCode & “&payeeFinancialInstitution=” & $payeeFinancialInstitution & “&payeeIBAN=” & $payeeIBAN & “&payeeBIC=” & $payeeBIC & “&paymentReference=” & $paymentReference &
“&taxRate=” & $taxRate & “&taxAmount=” & $taxAmount & “&taxableAmount=” & $taxableAmount & “&taxCategoryCode=” & $taxCategoryCode &
“&totalNetAmount=” & $totalNetAmount & “&totalTaxAmount=” & $totalTaxAmount & “&totalGrossAmount=” & $totalGrossAmount & “&lineItemsRaw=” & $lineItemsRaw
)
Nun geht es darum, die Form der Rechnung anzupassen, die Rechnung schon auf dem System zu validieren. Im Anschluss muss diese wieder im FileMaker-System zugänglich sein. Was ich aber jetzt schon sagen kann, es geht auch ohne MBS-Plugin und ohne die dort auflaufenden Lizenz-Kosten. Der Weg bis zu diesem Punkt war wirklich sehr steinig, vieles funktionierte nicht so wie dokumentiert. Jetzt bin ich froh,