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.