PageSourceSearch

https://lamuria.de/assets/js/rules.js

js lamuria.de collected 2026-10-02 01:52:23 UTC 13,602 bytes, 394 lines download raw bytes

1// rules.js - Regel-Engine: prüft ein Rechnungsobjekt gegen Pflichtfelder,
2// XRechnung-Geschäftsregeln (BR-DE-*) und Formatregeln.
3// Liefert eine strukturierte Fehlerliste; wird vom Generator-Formular
4// genutzt und später vom Validator-Modul wiederverwendet.
5//
6// Regel-IDs: offizielle IDs (BT-*/BR-*) wo vorhanden, eigene LAM-*-IDs
7// für pragmatische Zusatzprüfungen.
8
9import { computeTotals, DISCOUNT_MODES, TAX_MODES } from "./calc.js";
10import { round2 } from "./money.js";
11import {
12  isValidBic,
13  isValidEmail,
14  isValidGermanPlz,
15  isValidIban,
16  isValidIsoDate,
17  isValidVatId,
18} from "./formats.js";
19
20/**
21 * @typedef {object} RuleIssue
22 * @property {string} id - Regel-ID (z.B. "BR-DE-6", "LAM-IBAN")
23 * @property {string} field - Feldpfad (z.B. "seller.contactPhone")
24 * @property {string} message - deutschsprachige Meldung
25 * @property {"error"|"warning"} severity
26 */
27
28const err = (id, field, message) => ({ id, field, message, severity: "error" });
29const warn = (id, field, message) => ({ id, field, message, severity: "warning" });
30
31const isBlank = (v) => v == null || String(v).trim() === "";
32
33/**
34 * Prüft die komplette Rechnung.
35 * @param {object} invoice
36 * @returns {RuleIssue[]}
37 */
38export function validateInvoice(invoice) {
39  /** @type {RuleIssue[]} */
40  const issues = [];
41  const inv = invoice || {};
42  const seller = inv.seller || {};
43  const buyer = inv.buyer || {};
44  const payment = inv.payment || {};
45  const lines = inv.lines || [];
46  const taxMode = inv.taxMode || TAX_MODES.STANDARD;
47
48  // --- Rechnungskopf ---------------------------------------------------
49  if (isBlank(inv.invoiceNumber)) {
50    issues.push(err("BT-1", "invoiceNumber", "Rechnungsnummer fehlt."));
51  }
52  if (isBlank(inv.issueDate)) {
53    issues.push(err("BT-2", "issueDate", "Rechnungsdatum fehlt."));
54  } else if (!isValidIsoDate(inv.issueDate)) {
55    issues.push(err("BT-2", "issueDate", "Rechnungsdatum ist kein gültiges Datum."));
56  }
57  if (!isBlank(inv.dueDate) && !isValidIsoDate(inv.dueDate)) {
58    issues.push(err("BT-9", "dueDate", "Fälligkeitsdatum ist kein gültiges Datum."));
59  }
60  if (
61    !isBlank(inv.dueDate) &&
62    !isBlank(inv.issueDate) &&
63    isValidIsoDate(inv.dueDate) &&
64    isValidIsoDate(inv.issueDate) &&
65    inv.dueDate < inv.issueDate
66  ) {
67    issues.push(
68      warn("LAM-DUE", "dueDate", "Fälligkeitsdatum liegt vor dem Rechnungsdatum.")
69    );
70  }
71  // BR-CO-25: Bei offenem Betrag muss Fälligkeitsdatum ODER
72  // Zahlungsbedingungen angegeben sein.
73  if (isBlank(inv.dueDate) && isBlank(inv.paymentTermsText)) {
74    issues.push(
75      err(
76        "BR-CO-25",
77        "dueDate",
78        "Fälligkeitsdatum oder Zahlungsbedingungen angeben (eines von beiden reicht)."
79      )
80    );
81  }
82  // Liefer-/Leistungsdatum (XRechnung-Empfehlung BR-DE-TMP-32, steuerlich
83  // relevant nach § 14 UStG): Datum ODER Zeitraum angeben.
84  const hasDelivery = !isBlank(inv.deliveryDate);
85  const hasPeriodStart = !isBlank(inv.periodStart);
86  const hasPeriodEnd = !isBlank(inv.periodEnd);
87  if (!hasDelivery && !hasPeriodStart && !hasPeriodEnd) {
88    issues.push(
89      warn(
90        "BR-DE-TMP-32",
91        "deliveryDate",
92        "Liefer-/Leistungsdatum oder Leistungszeitraum angeben (steuerlich wichtig, § 14 UStG)."
93      )
94    );
95  }
96  if (hasDelivery && !isValidIsoDate(inv.deliveryDate)) {
97    issues.push(err("BT-72", "deliveryDate", "Leistungsdatum ist kein gültiges Datum."));
98  }
99  if (hasPeriodStart !== hasPeriodEnd) {
100    issues.push(
101      err(
102        "LAM-PERIOD",
103        hasPeriodStart ? "periodEnd" : "periodStart",
104        "Leistungszeitraum braucht Start- UND Enddatum."
105      )
106    );
107  }
108  if (hasPeriodStart && !isValidIsoDate(inv.periodStart)) {
109    issues.push(err("BT-73", "periodStart", "Zeitraum-Beginn ist kein gültiges Datum."));
110  }
111  if (hasPeriodEnd && !isValidIsoDate(inv.periodEnd)) {
112    issues.push(err("BT-74", "periodEnd", "Zeitraum-Ende ist kein gültiges Datum."));
113  }
114  if (
115    hasPeriodStart &&
116    hasPeriodEnd &&
117    isValidIsoDate(inv.periodStart) &&
118    isValidIsoDate(inv.periodEnd) &&
119    inv.periodEnd < inv.periodStart
120  ) {
121    issues.push(err("LAM-PERIOD", "periodEnd", "Zeitraum-Ende liegt vor dem Beginn."));
122  }
123
124  // BR-DE-15: Käuferreferenz (BT-10) ist in der XRechnung Pflicht.
125  if (isBlank(inv.buyerReference)) {
126    issues.push(
127      err(
128        "BR-DE-15",
129        "buyerReference",
130        "Käuferreferenz fehlt. Bei Behörden: Leitweg-ID. Bei Firmenkunden: z.B. Kunden- oder Bestellnummer, sonst mit dem Empfänger abstimmen."
131      )
132    );
133  }
134
135  // --- Verkäufer --------------------------------------------------------
136  if (isBlank(seller.name)) {
137    issues.push(err("BT-27", "seller.name", "Ihr Firmenname/Name fehlt."));
138  }
139  if (isBlank(seller.street)) {
140    issues.push(
141      warn("LAM-S-STREET", "seller.street", "Ihre Straße und Hausnummer fehlen.")
142    );
143  }
144  if (isBlank(seller.city)) {
145    issues.push(err("BR-DE-3", "seller.city", "Ihr Ort fehlt."));
146  }
147  if (isBlank(seller.postalCode)) {
148    issues.push(err("BR-DE-4", "seller.postalCode", "Ihre Postleitzahl fehlt."));
149  } else if (
150    (seller.countryCode || "DE") === "DE" &&
151    !isValidGermanPlz(seller.postalCode)
152  ) {
153    issues.push(
154      err("BR-DE-4", "seller.postalCode", "Ihre Postleitzahl muss 5 Ziffern haben.")
155    );
156  }
157  // BR-DE-16: USt-IdNr. ODER Steuernummer muss vorhanden sein.
158  if (isBlank(seller.vatId) && isBlank(seller.taxNumber)) {
159    issues.push(
160      err(
161        "BR-DE-16",
162        "seller.vatId",
163        "USt-IdNr. oder Steuernummer angeben (mindestens eine)."
164      )
165    );
166  }
167  if (!isBlank(seller.vatId) && !isValidVatId(seller.vatId)) {
168    issues.push(
169      err(
170        "LAM-VATID",
171        "seller.vatId",
172        "USt-IdNr. hat kein gültiges Format (deutsch: DE + 9 Ziffern)."
173      )
174    );
175  }
176  // BR-DE-2/5/6/7: Kontaktangaben des Verkäufers sind Pflicht.
177  if (isBlank(seller.contactName)) {
178    issues.push(err("BR-DE-5", "seller.contactName", "Ansprechpartner fehlt."));
179  }
180  if (isBlank(seller.contactPhone)) {
181    issues.push(err("BR-DE-6", "seller.contactPhone", "Telefonnummer fehlt."));
182  }
183  if (isBlank(seller.contactEmail)) {
184    issues.push(err("BR-DE-7", "seller.contactEmail", "E-Mail-Adresse fehlt."));
185  } else if (!isValidEmail(seller.contactEmail)) {
186    issues.push(
187      err("BR-DE-7", "seller.contactEmail", "E-Mail-Adresse ist ungültig.")
188    );
189  }
190
191  // --- Käufer -----------------------------------------------------------
192  if (isBlank(buyer.name)) {
193    issues.push(err("BT-44", "buyer.name", "Name des Rechnungsempfängers fehlt."));
194  }
195  if (isBlank(buyer.city)) {
196    issues.push(err("BR-DE-8", "buyer.city", "Ort des Empfängers fehlt."));
197  }
198  if (isBlank(buyer.postalCode)) {
199    issues.push(err("BR-DE-9", "buyer.postalCode", "PLZ des Empfängers fehlt."));
200  } else if (
201    (buyer.countryCode || "DE") === "DE" &&
202    !isValidGermanPlz(buyer.postalCode)
203  ) {
204    issues.push(
205      err("BR-DE-9", "buyer.postalCode", "PLZ des Empfängers muss 5 Ziffern haben.")
206    );
207  }
208  if (isBlank(buyer.email)) {
209    issues.push(
210      warn(
211        "LAM-B-MAIL",
212        "buyer.email",
213        "E-Mail des Empfängers empfohlen (dient als elektronische Adresse der XRechnung)."
214      )
215    );
216  } else if (!isValidEmail(buyer.email)) {
217    issues.push(err("LAM-B-MAIL", "buyer.email", "Empfänger-E-Mail ist ungültig."));
218  }
219
220  // --- Zahlung (BR-DE-1: Zahlungsanweisungen Pflicht) --------------------
221  if (isBlank(payment.iban)) {
222    issues.push(
223      err("BR-DE-1", "payment.iban", "IBAN fehlt (Zahlungsangaben sind Pflicht).")
224    );
225  } else if (!isValidIban(payment.iban)) {
226    issues.push(err("LAM-IBAN", "payment.iban", "IBAN ist ungültig (Prüfsumme)."));
227  }
228  if (!isBlank(payment.bic) && !isValidBic(payment.bic)) {
229    issues.push(warn("LAM-BIC", "payment.bic", "BIC sieht ungültig aus."));
230  }
231
232  // --- Positionen ---------------------------------------------------------
233  if (lines.length === 0) {
234    issues.push(err("BR-16", "lines", "Mindestens eine Rechnungsposition angeben."));
235  }
236  lines.forEach((line, i) => {
237    const label = `Position ${i + 1}`;
238    if (isBlank(line.description)) {
239      issues.push(err("BT-153", `lines.${i}.description`, `${label}: Bezeichnung fehlt.`));
240    }
241    const qty = Number(line.quantity);
242    if (!Number.isFinite(qty) || qty <= 0) {
243      issues.push(
244        err("BT-129", `lines.${i}.quantity`, `${label}: Menge muss größer 0 sein.`)
245      );
246    }
247    const price = Number(line.unitPrice);
248    if (!Number.isFinite(price)) {
249      issues.push(err("BT-146", `lines.${i}.unitPrice`, `${label}: Einzelpreis fehlt.`));
250    } else if (price < 0) {
251      // BR-27: Preis darf nicht negativ sein
252      issues.push(
253        err("BR-27", `lines.${i}.unitPrice`, `${label}: Einzelpreis darf nicht negativ sein.`)
254      );
255    }
256    if (taxMode === TAX_MODES.STANDARD) {
257      const rate = Number(line.vatRate);
258      if (![0, 7, 19].includes(rate)) {
259        issues.push(
260          err(
261            "LAM-RATE",
262            `lines.${i}.vatRate`,
263            `${label}: USt-Satz muss 19, 7 oder 0 sein.`
264          )
265        );
266      }
267    }
268
269    // --- Positionsrabatt (BG-27) ----------------------------------------
270    // BT-136 (Betrag) erzeugen die XML-Builder aus calc.js; BT-139/140
271    // (Grund/Code) setzen sie immer -> BR-41/BR-42 strukturell erfüllt.
272    // Hier prüfen wir nur die Nutzereingabe.
273    const lineDiscount = line.discount || {};
274    const lineDiscountMode = lineDiscount.mode || DISCOUNT_MODES.NONE;
275    if (lineDiscountMode !== DISCOUNT_MODES.NONE) {
276      const v = Number(lineDiscount.value);
277      if (!Number.isFinite(v) || v <= 0) {
278        issues.push(
279          err(
280            "LAM-LINE-DISC",
281            `lines.${i}.discount.value`,
282            `${label}: Rabattwert fehlt oder ist nicht größer 0.`
283          )
284        );
285      } else if (lineDiscountMode === DISCOUNT_MODES.PERCENT && v > 100) {
286        issues.push(
287          err(
288            "LAM-LINE-DISC",
289            `lines.${i}.discount.value`,
290            `${label}: Rabatt über 100 % ist nicht möglich.`
291          )
292        );
293      } else {
294        const qty = Number(line.quantity);
295        const price = Number(line.unitPrice);
296        if (Number.isFinite(qty) && Number.isFinite(price)) {
297          const gross = round2(qty * price);
298          const amount =
299            lineDiscountMode === DISCOUNT_MODES.PERCENT
300              ? round2((gross * v) / 100)
301              : round2(v);
302          if (amount > gross) {
303            issues.push(
304              err(
305                "LAM-LINE-DISC",
306                `lines.${i}.discount.value`,
307                `${label}: Rabatt (${amount.toFixed(2).replace(".", ",")} EUR) übersteigt den Positionswert (${gross.toFixed(2).replace(".", ",")} EUR).`
308              )
309            );
310          }
311        }
312      }
313    }
314  });
315
316  // --- Rabatt/Nachlass auf Dokumentebene (BG-20) ---------------------------
317  // BT-95 (USt-Kategorie je Nachlass) und BT-107/BR-CO-11/13 erfüllt
318  // calc.js per Konstruktion (Auto-Split); BT-98 (Reason-Code 95) setzen
319  // die XML-Builder immer -> BR-33 ist strukturell erfüllt.
320  const discount = inv.discount || {};
321  const discountMode = discount.mode || DISCOUNT_MODES.NONE;
322  if (discountMode !== DISCOUNT_MODES.NONE) {
323    const value = Number(discount.value);
324    if (!Number.isFinite(value)) {
325      issues.push(
326        err(
327          "BR-31",
328          "discount.value",
329          discountMode === DISCOUNT_MODES.PERCENT
330            ? "Rabatt: Prozentwert fehlt oder ist keine Zahl."
331            : "Rabatt: Betrag fehlt oder ist keine Zahl."
332        )
333      );
334    } else if (value <= 0) {
335      issues.push(
336        err("BR-31", "discount.value", "Rabatt: Wert muss größer 0 sein (sonst \u201eKein Nachlass\u201c wählen).")
337      );
338    } else if (discountMode === DISCOUNT_MODES.PERCENT && value > 100) {
339      issues.push(
340        err("LAM-DISC-PCT", "discount.value", "Rabatt: Prozentwert darf höchstens 100 sein.")
341      );
342    } else {
343      // Nachlass darf die Positionssumme nicht übersteigen (negative
344      // Steuerbasen wären zwar formal denkbar, sind aber praktisch
345      // immer Eingabefehler).
346      const { totals } = computeTotals(inv);
347      if (totals.lineTotal <= 0) {
348        issues.push(
349          err(
350            "LAM-DISC-MAX",
351            "discount.value",
352            "Rabatt: Zuerst Positionen mit Beträgen erfassen."
353          )
354        );
355      } else if (totals.taxExclusive < 0) {
356        issues.push(
357          err(
358            "LAM-DISC-MAX",
359            "discount.value",
360            "Rabatt: Nachlass ist größer als die Summe der Positionen."
361          )
362        );
363      }
364    }
365  }
366
367  // --- Steuermodus-Regeln -------------------------------------------------
368  if (taxMode === TAX_MODES.REVERSE_CHARGE && isBlank(buyer.vatId)) {
369    // Bei Steuerschuldumkehr braucht die Rechnung die USt-IdNr. des Empfängers.
370    issues.push(
371      err(
372        "BR-AE-2",
373        "buyer.vatId",
374        "Reverse Charge (§ 13b): USt-IdNr. des Empfängers ist erforderlich."
375      )
376    );
377  }
378  if (!isBlank(buyer.vatId) && !isValidVatId(buyer.vatId)) {
379    issues.push(
380      err("LAM-VATID", "buyer.vatId", "USt-IdNr. des Empfängers hat kein gültiges Format.")
381    );
382  }
383
384  return issues;
385}
386
387/**
388 * Bequemer Helfer: nur harte Fehler (keine Warnungen).
389 * @param {object} invoice
390 * @returns {RuleIssue[]}
391 */
392export function validationErrors(invoice) {
393  return validateInvoice(invoice).filter((i) => i.severity === "error");
394}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.