Files
nexarch/dms/internal/einvoice/parse.go
patrick 3b2897402c
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
MET-06: gofmt
2026-09-20 20:47:29 +02:00

526 lines
14 KiB
Go

// Package einvoice implementiert MET-06: E-Rechnungen (XRechnung als UBL/CII,
// ZUGFeRD/Factur-X als eingebettetes XML) werden erkannt, ihre Kerndaten
// ausgelesen und als Metadatenfelder abgelegt. Das DMS liest E-Rechnungen
// ausschliesslich; das Original bleibt unveraendert.
package einvoice
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"math/big"
"regexp"
"strings"
"time"
)
const MaxXMLBytes = 8 << 20
type Format string
const (
FormatUBLInvoice Format = "ubl-invoice"
FormatUBLCreditNote Format = "ubl-creditnote"
FormatCII Format = "cii"
FormatUnknown Format = "unknown"
)
const (
nsUBLInvoice = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
nsUBLCreditNote = "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
nsCII = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
)
var (
ErrNotEInvoice = errors.New("einvoice: keine e-rechnung")
ErrUnreadable = errors.New("einvoice: e-rechnung nicht lesbar")
)
func unreadable(reason string) error {
return fmt.Errorf("%w: %s", ErrUnreadable, reason)
}
type Finding struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Invoice struct {
Format Format
Number string
IssueDate string
DueDate string
Seller string
Buyer string
Currency string
IBAN string
LeitwegID string
Net *big.Rat
Tax *big.Rat
Gross *big.Rat
}
type Result struct {
Invoice Invoice
Findings []Finding
}
func (r *Result) Status() string {
if len(r.Findings) == 0 {
return "valid"
}
return "invalid"
}
type raw struct {
number, issue, due, seller, buyer, currency, iban, leitweg string
issueFormat, dueFormat string
net, tax, gross, lineTotal string
lines []string
}
type amountAttr struct {
Value string `xml:",chardata"`
Currency string `xml:"currencyID,attr"`
}
type ublLine struct {
Amount string `xml:"LineExtensionAmount"`
}
type ublParty struct {
LegalName string `xml:"PartyLegalEntity>RegistrationName"`
Name string `xml:"PartyName>Name"`
}
type ublDoc struct {
XMLName xml.Name
ID string `xml:"ID"`
IssueDate string `xml:"IssueDate"`
DueDate string `xml:"DueDate"`
Currency string `xml:"DocumentCurrencyCode"`
BuyerReference string `xml:"BuyerReference"`
Supplier ublParty `xml:"AccountingSupplierParty>Party"`
Customer ublParty `xml:"AccountingCustomerParty>Party"`
PaymentMeans []struct {
IBAN string `xml:"PayeeFinancialAccount>ID"`
DueDate string `xml:"PaymentDueDate"`
} `xml:"PaymentMeans"`
TaxTotals []struct {
TaxAmount amountAttr `xml:"TaxAmount"`
} `xml:"TaxTotal"`
Totals struct {
LineExtension string `xml:"LineExtensionAmount"`
TaxExclusive string `xml:"TaxExclusiveAmount"`
TaxInclusive string `xml:"TaxInclusiveAmount"`
} `xml:"LegalMonetaryTotal"`
InvoiceLines []ublLine `xml:"InvoiceLine"`
CreditNoteLines []ublLine `xml:"CreditNoteLine"`
}
type ciiDate struct {
Value string `xml:",chardata"`
Format string `xml:"format,attr"`
}
type ciiDoc struct {
XMLName xml.Name
Document struct {
ID string `xml:"ID"`
Issue ciiDate `xml:"IssueDateTime>DateTimeString"`
} `xml:"ExchangedDocument"`
Transaction struct {
Lines []struct {
Total string `xml:"SpecifiedLineTradeSettlement>SpecifiedTradeSettlementLineMonetarySummation>LineTotalAmount"`
} `xml:"IncludedSupplyChainTradeLineItem"`
Agreement struct {
Seller struct {
Name string `xml:"Name"`
} `xml:"SellerTradeParty"`
Buyer struct {
Name string `xml:"Name"`
} `xml:"BuyerTradeParty"`
BuyerReference string `xml:"BuyerReference"`
} `xml:"ApplicableHeaderTradeAgreement"`
Settlement struct {
Currency string `xml:"InvoiceCurrencyCode"`
PaymentMeans []struct {
IBAN string `xml:"PayeePartyCreditorFinancialAccount>IBANID"`
} `xml:"SpecifiedTradeSettlementPaymentMeans"`
Terms []struct {
Due ciiDate `xml:"DueDateDateTime>DateTimeString"`
} `xml:"SpecifiedTradePaymentTerms"`
Summation struct {
LineTotal string `xml:"LineTotalAmount"`
TaxBasis string `xml:"TaxBasisTotalAmount"`
TaxTotals []amountAttr `xml:"TaxTotalAmount"`
Grand string `xml:"GrandTotalAmount"`
} `xml:"SpecifiedTradeSettlementHeaderMonetarySummation"`
} `xml:"ApplicableHeaderTradeSettlement"`
} `xml:"SupplyChainTradeTransaction"`
}
func newDecoder(data []byte) *xml.Decoder {
d := xml.NewDecoder(bytes.NewReader(data))
d.Strict = true
return d
}
// detect liest nur bis zum Wurzelelement und weist jede Direktive (DOCTYPE,
// ENTITY) ab: ohne Deklarationen gibt es weder externe Entitaeten noch
// Entity-Expansion.
func detect(data []byte) (Format, error) {
d := newDecoder(data)
for {
tok, err := d.Token()
if err != nil {
if errors.Is(err, io.EOF) {
return "", unreadable("kein wurzelelement")
}
return "", unreadable("xml fehlerhaft: " + err.Error())
}
switch t := tok.(type) {
case xml.Directive:
return "", unreadable("dtd- und entity-deklarationen sind nicht zulaessig")
case xml.StartElement:
switch {
case t.Name.Space == nsUBLInvoice && t.Name.Local == "Invoice":
return FormatUBLInvoice, nil
case t.Name.Space == nsUBLCreditNote && t.Name.Local == "CreditNote":
return FormatUBLCreditNote, nil
case t.Name.Space == nsCII && t.Name.Local == "CrossIndustryInvoice":
return FormatCII, nil
}
return "", ErrNotEInvoice
}
}
}
func ParseXML(data []byte) (*Result, error) {
data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))
if len(data) > MaxXMLBytes {
return nil, unreadable(fmt.Sprintf("groesser als %d bytes", MaxXMLBytes))
}
format, err := detect(data)
if err != nil {
return nil, err
}
var r raw
if format == FormatCII {
var doc ciiDoc
if err := newDecoder(data).Decode(&doc); err != nil {
return nil, unreadable("xml fehlerhaft: " + err.Error())
}
r = rawFromCII(&doc)
} else {
var doc ublDoc
if err := newDecoder(data).Decode(&doc); err != nil {
return nil, unreadable("xml fehlerhaft: " + err.Error())
}
r = rawFromUBL(&doc)
}
return build(format, r), nil
}
func rawFromUBL(d *ublDoc) raw {
r := raw{
number: d.ID,
issue: d.IssueDate,
due: d.DueDate,
currency: d.Currency,
leitweg: d.BuyerReference,
seller: firstNonEmpty(d.Supplier.LegalName, d.Supplier.Name),
buyer: firstNonEmpty(d.Customer.LegalName, d.Customer.Name),
net: d.Totals.TaxExclusive,
gross: d.Totals.TaxInclusive,
lineTotal: d.Totals.LineExtension,
}
for _, pm := range d.PaymentMeans {
if r.iban == "" {
r.iban = pm.IBAN
}
if r.due == "" {
r.due = pm.DueDate
}
}
r.tax = pickTax(d.TaxTotals, d.Currency, func(i int) amountAttr { return d.TaxTotals[i].TaxAmount })
for _, l := range append(append([]ublLine{}, d.InvoiceLines...), d.CreditNoteLines...) {
r.lines = append(r.lines, l.Amount)
}
return r
}
func pickTax[T any](items []T, currency string, at func(int) amountAttr) string {
chosen := ""
for i := range items {
a := at(i)
if chosen == "" {
chosen = strings.TrimSpace(a.Value)
}
if currency != "" && strings.EqualFold(strings.TrimSpace(a.Currency), strings.TrimSpace(currency)) {
return strings.TrimSpace(a.Value)
}
}
return chosen
}
func rawFromCII(d *ciiDoc) raw {
t := &d.Transaction
r := raw{
number: d.Document.ID,
issue: d.Document.Issue.Value,
issueFormat: d.Document.Issue.Format,
seller: t.Agreement.Seller.Name,
buyer: t.Agreement.Buyer.Name,
leitweg: t.Agreement.BuyerReference,
currency: t.Settlement.Currency,
net: t.Settlement.Summation.TaxBasis,
gross: t.Settlement.Summation.Grand,
lineTotal: t.Settlement.Summation.LineTotal,
}
for _, pm := range t.Settlement.PaymentMeans {
if r.iban == "" {
r.iban = pm.IBAN
}
}
for _, term := range t.Settlement.Terms {
if r.due == "" {
r.due, r.dueFormat = term.Due.Value, term.Due.Format
}
}
sum := t.Settlement.Summation.TaxTotals
r.tax = pickTax(sum, t.Settlement.Currency, func(i int) amountAttr { return sum[i] })
for _, l := range t.Lines {
r.lines = append(r.lines, l.Total)
}
return r
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v = strings.TrimSpace(v); v != "" {
return v
}
}
return ""
}
var (
amountPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
digits8Pattern = regexp.MustCompile(`^[0-9]{8}$`)
)
func parseAmount(s string) (*big.Rat, bool) {
s = strings.TrimSpace(s)
if !amountPattern.MatchString(s) {
return nil, false
}
v, ok := new(big.Rat).SetString(s)
return v, ok
}
func normDate(v, format string) (string, bool) {
v = strings.TrimSpace(v)
layout := "2006-01-02"
if format == "102" || digits8Pattern.MatchString(v) {
layout = "20060102"
}
t, err := time.Parse(layout, v)
if err != nil {
return "", false
}
return t.Format("2006-01-02"), true
}
func validIBAN(s string) bool {
if len(s) < 15 || len(s) > 34 {
return false
}
rem := 0
for _, c := range s[4:] + s[:4] {
switch {
case c >= '0' && c <= '9':
rem = (rem*10 + int(c-'0')) % 97
case c >= 'A' && c <= 'Z':
rem = (rem*100 + int(c-'A') + 10) % 97
default:
return false
}
}
return rem == 1
}
func withinCent(a, b *big.Rat) bool {
d := new(big.Rat).Sub(a, b)
d.Abs(d)
return d.Cmp(big.NewRat(1, 100)) <= 0
}
func build(format Format, r raw) *Result {
res := &Result{}
inv := &res.Invoice
inv.Format = format
add := func(code, msg string) {
res.Findings = append(res.Findings, Finding{Code: code, Message: msg})
}
inv.Number = strings.TrimSpace(r.number)
if inv.Number == "" {
add("E001", "Rechnungsnummer fehlt")
}
if issue := strings.TrimSpace(r.issue); issue == "" {
add("E002", "Rechnungsdatum fehlt")
} else if d, ok := normDate(issue, r.issueFormat); ok {
inv.IssueDate = d
} else {
add("E007", "Rechnungsdatum ist kein gueltiges Datum: "+issue)
}
if due := strings.TrimSpace(r.due); due != "" {
if d, ok := normDate(due, r.dueFormat); ok {
inv.DueDate = d
} else {
add("E007", "Faelligkeitsdatum ist kein gueltiges Datum: "+due)
}
}
inv.Seller = strings.TrimSpace(r.seller)
if inv.Seller == "" {
add("E003", "Name des Rechnungsstellers fehlt")
}
inv.Buyer = strings.TrimSpace(r.buyer)
if inv.Buyer == "" {
add("E004", "Name des Rechnungsempfaengers fehlt")
}
if cur := strings.ToUpper(strings.TrimSpace(r.currency)); cur == "" {
add("E005", "Waehrung fehlt")
} else if currencyPattern.MatchString(cur) {
inv.Currency = cur
} else {
add("E010", "Waehrung ist kein dreistelliger Code: "+cur)
}
if iban := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.iban), " ", "")); iban != "" {
if validIBAN(iban) {
inv.IBAN = iban
} else {
add("E011", "IBAN hat eine ungueltige Pruefsumme")
}
}
inv.LeitwegID = strings.TrimSpace(r.leitweg)
amounts := []struct {
label string
raw string
dst **big.Rat
}{
{"Nettobetrag", r.net, &inv.Net},
{"Steuerbetrag", r.tax, &inv.Tax},
{"Bruttobetrag", r.gross, &inv.Gross},
}
for _, a := range amounts {
if strings.TrimSpace(a.raw) == "" {
continue
}
if v, ok := parseAmount(a.raw); ok {
*a.dst = v
} else {
add("E012", a.label+" ist keine gueltige Zahl: "+strings.TrimSpace(a.raw))
}
}
if inv.Gross == nil && strings.TrimSpace(r.gross) == "" {
add("E006", "Bruttobetrag fehlt")
}
if inv.Net != nil && inv.Gross != nil {
tax := inv.Tax
if tax == nil {
tax = new(big.Rat)
}
if !withinCent(new(big.Rat).Add(inv.Net, tax), inv.Gross) {
add("E008", fmt.Sprintf("Summenpruefung: Netto %s + Steuer %s ergibt nicht Brutto %s",
inv.Net.FloatString(2), tax.FloatString(2), inv.Gross.FloatString(2)))
}
}
if lt, ok := parseAmount(r.lineTotal); ok && len(r.lines) > 0 {
sum := new(big.Rat)
valid := true
for _, l := range r.lines {
v, ok := parseAmount(l)
if !ok {
valid = false
add("E012", "Positionsbetrag ist keine gueltige Zahl: "+strings.TrimSpace(l))
break
}
sum.Add(sum, v)
}
if valid && !withinCent(sum, lt) {
add("E009", fmt.Sprintf("Summenpruefung: Positionen ergeben %s, Summe der Positionen %s",
sum.FloatString(2), lt.FloatString(2)))
}
}
return res
}
func amountString(r *big.Rat) string {
if r == nil {
return ""
}
return r.FloatString(2)
}
const (
KeyNumber = "einvoice_number"
KeyIssueDate = "einvoice_issue_date"
KeyDueDate = "einvoice_due_date"
KeySeller = "einvoice_seller"
KeyBuyer = "einvoice_buyer"
KeyNet = "einvoice_net_amount"
KeyTax = "einvoice_tax_amount"
KeyGross = "einvoice_gross_amount"
KeyCurrency = "einvoice_currency"
KeyIBAN = "einvoice_iban"
KeyLeitweg = "einvoice_leitweg_id"
KeyFormat = "einvoice_format"
KeyStatus = "einvoice_status"
)
// Values liefert die Kerndaten als Feldschluessel -> Wert; nicht ermittelte
// Werte fehlen (nie geraten).
func (r *Result) Values() map[string]string {
inv := &r.Invoice
all := map[string]string{
KeyNumber: inv.Number,
KeyIssueDate: inv.IssueDate,
KeyDueDate: inv.DueDate,
KeySeller: inv.Seller,
KeyBuyer: inv.Buyer,
KeyNet: amountString(inv.Net),
KeyTax: amountString(inv.Tax),
KeyGross: amountString(inv.Gross),
KeyCurrency: inv.Currency,
KeyIBAN: inv.IBAN,
KeyLeitweg: inv.LeitwegID,
KeyFormat: string(inv.Format),
KeyStatus: r.Status(),
}
out := make(map[string]string, len(all))
for k, v := range all {
if v != "" {
out[k] = v
}
}
return out
}
// SearchText ist der Volltext, der die Rechnung ueber die Suche auffindbar macht.
func (r *Result) SearchText() string {
inv := &r.Invoice
var parts []string
for _, v := range []string{inv.Number, inv.Seller, inv.Buyer, inv.LeitwegID, inv.IBAN, inv.IssueDate, inv.DueDate, amountString(inv.Gross), inv.Currency} {
if v != "" {
parts = append(parts, v)
}
}
return strings.Join(parts, "\n")
}