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
526 lines
14 KiB
Go
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")
|
|
}
|