Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)
Playwright Tests / test (pull_request) Has been cancelled

Completes the eRechnung output path: finalized invoices are emitted as a
ZUGFeRD/Factur-X EN 16931 hybrid (DATEV) or, when a Leitweg-ID is present, as
XRechnung 3.0 for B2G. Both are externally validated as ACCEPTED (0 errors) and
PDF/A-3B COMPLIANT against the ProcessWeb eInvoice service.

- ERechnungMapper: FdsInvoiceData -> EN 16931 model. Seller master data now from
  Fuchs:ERechnung:Seller config (VAT id BT-31, Steuernummer BT-32, optional
  Handelsregister BT-30, IBAN/BIC, contact). Adds payment terms BT-20/BT-9
  (BR-CO-25), buyer VAT id, §13b reverse charge, and the service date/period
  (BT-72 / BG-14) parsed from ProvisionPeriod (BR-DE-TMP-32). B2G -> XRechnung
  profile with the required electronic addresses/contact.
- ERechnungValidator: client for POST /validatepdf (EN 16931 XML + PDF/A-3 in
  one call). A pure "scenario not matched" with zero errors is not treated as a
  hard failure; real errors optionally withhold the hybrid (FailOnError).
- ERechnungSettings: seller + validation config; wired in Program.cs; ServiceUrl
  in appsettings.
- Online editor: structured German-only dialogs for the recipient address
  (incl. Leitweg-ID) and the service date/period (single date or range).
- Bumps the eRechnungLib submodule to the CII rsm-namespace / PDF-A subtype fix.

Fuchs.Tests 514/514, eRechnungLib 97/97.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:05:24 +02:00
co-authored by Claude Opus 4.8
parent d94974ce06
commit 00e72c96d4
19 changed files with 847 additions and 80 deletions
+45 -8
View File
@@ -1550,12 +1550,14 @@ $inv.eAddress = function (ev) {
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' }
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' },
{ name: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
];
let hint = $$.dc('adr-conformity').append([
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
+ 'für eine Rechnung an eine Privatperson einfach leer lassen.')
+ 'für eine Rechnung an eine Privatperson einfach leer lassen. Eine Leitweg-ID nur bei '
+ 'Behörden (B2G) — dann wird die Rechnung automatisch als XRechnung erzeugt.')
]);
$ocms.dlgform(flds, {
title: 'Rechnungsempfänger',
@@ -1564,7 +1566,8 @@ $inv.eAddress = function (ev) {
let a = {
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim()
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
leitwegId: ('' + (res.leitwegId || '')).trim()
};
let txt = $inv.composeAddress(a);
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
@@ -1575,12 +1578,46 @@ $inv.eAddress = function (ev) {
});
};
/* Leistungsdatum / -zeitraum: structured German-date entry only (no free text). A single date =
Leistungsdatum (BT-72); a from+to date = Leistungszeitraum (BG-14). The composed value is a
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
backend maps to the eRechnung service date/period. */
$inv.provToIso = function (dmy) {
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
};
$inv.eProvisionPeriod = function (ev) {
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
let flds = [
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
{ name: 'bis', label: 'Leistungszeitraum bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
];
let hint = $$.dc('adr-conformity').append([
$$.dc('hd').text('Leistungsdatum / -zeitraum'),
$$.dc('tx').text('Für ein einzelnes Datum nur „Leistungsdatum" ausfüllen; für einen Zeitraum '
+ 'zusätzlich das Bis-Datum. Nur strukturierte Datumsauswahl — für die E-Rechnung empfohlen.')
]);
$ocms.dlgform(flds, {
title: 'Leistungsdatum / -zeitraum',
addcontent: hint,
success: function (res) {
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
$inv.d.sync({ Target: 'provisionperiod', Value: txt });
}
});
};
$inv.eHtml = function (ev) {
/* The recipient address is edited through the structured dialog whenever an invoice draft is
active; reminders (no invoice draft token) keep the plain free-text editor. */
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
return $inv.eAddress.call(this, ev);
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
free-text editor. */
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
}
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>