dodil/erp-brick-ar

DODIL ERP brick: accounts receivable

src/brick_ar/domain/einvoice.py

"""The Peppol e-invoice (AR-43): the frozen invoice or credit note rendered as UBL 2.1 to Peppol BIS Billing 3.0, and checked against the
EN 16931 and Peppol BIS 3 business rules BEFORE it leaves. The renderer reads the document as issued (its lines, unit codes, tax per
code, totals) and never recomputes. A failed rule is a finding {rule, text, fix}; nothing with a finding is handed to the access point.

Rules checked here are the ones a document built from our own data can break; the access point's schema validation runs again on its
side. Rule ids are the standard's (BR-xx, BR-CO-xx, PEPPOL-EN16931-Rxxx). PEPPOL-EN16931-R003 (a buyer reference or order reference) is
NOT enforced: the buyer's own demand for a PO number arrives as an RE response (AR-44) and the invoice is credited and re-issued."""
import re
from dataclasses import dataclass
from decimal import Decimal
from xml.etree import ElementTree as ET

CUSTOMIZATION = "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
PROFILE = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
NS_INVOICE = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
NS_CREDIT = "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
NS_CAC = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
NS_CBC = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
CBC, CAC = "{%s}" % NS_CBC, "{%s}" % NS_CAC
_PARTICIPANT = re.compile(r"^\d{4}:\S+$")
_UNIT = re.compile(r"^[A-Z0-9]{2,3}$")
EXEMPT = {"E": "Exempt", "O": "Outside the scope of VAT"}
ZERO = Decimal(0)


@dataclass(frozen=True)
class Finding:
    rule: str
    text: str
    fix: str

    def as_gate(self) -> dict:
        return {"gate": "einvoice_valid", "met": False, "reason": f"{self.rule}: {self.text}", "fix": self.fix}


def category(tax_code: str, rate: Decimal) -> str:
    """The UBL VAT category of one of our tax codes: any positive rate is S, then by what the code means."""
    if rate and rate > 0:
        return "S"
    return {"Z": "Z", "E": "E", "OS": "O", "O": "O"}.get((tax_code or "").upper(), "Z")


def _el(parent, tag, text=None, **attrs):
    e = ET.SubElement(parent, tag)
    if text is not None:
        e.text = str(text)
    for k, v in attrs.items():
        e.set(k, v)
    return e


def _party(root, role: str, p: dict):
    party = _el(_el(root, CAC + role), CAC + "Party")
    scheme, _, value = (p.get("endpoint") or "").partition(":")
    if p.get("endpoint"):
        _el(party, CBC + "EndpointID", value, schemeID=scheme)
    _el(_el(party, CAC + "PartyName"), CBC + "Name", p.get("name", ""))
    addr = _el(party, CAC + "PostalAddress")
    _el(addr, CBC + "StreetName", p.get("address", ""))
    _el(_el(addr, CAC + "Country"), CBC + "IdentificationCode", p.get("country", ""))
    _el(_el(party, CAC + "PartyLegalEntity"), CBC + "RegistrationName", p.get("name", ""))


def _country(address: str, default: str) -> str:
    last = (address or "").replace("\n", ",").split(",")[-1].strip().upper()
    return last if len(last) == 2 and last.isalpha() else (default or "")


def validate(m: dict) -> list:
    """The findings of the EN 16931 / Peppol BIS 3 rules this brick can break; [] when the document may leave."""
    out = []

    def bad(rule, text, fix):
        out.append(Finding(rule, text, fix))
    if not m["number"]:
        bad("BR-02", "an invoice shall have an invoice number", "the document number")
    if not m["issue_date"]:
        bad("BR-03", "an invoice shall have an issue date", "the issue date")
    if not m["currency"]:
        bad("BR-05", "an invoice shall have a currency code", "the currency")
    s, b = m["supplier"], m["buyer"]
    if not s.get("name"):
        bad("BR-06", "the seller name is missing", "Settings: supplier_name")
    if not s.get("address") or not s.get("country"):
        bad("BR-08", "the seller postal address (and its country code) is missing", "Settings: supplier_address and supplier_country")
    if not s.get("endpoint") or not _PARTICIPANT.match(s["endpoint"]):
        bad("PEPPOL-EN16931-R020", "the seller electronic address (Peppol participant id as scheme:id) is missing", "Settings: einvoice_sender_id")
    if not b.get("name"):
        bad("BR-07", "the buyer name is missing", "Customer billing: bill-to name")
    if not b.get("address") or not b.get("country"):
        bad("BR-10", "the buyer postal address (and its country code) is missing", "Customer billing: bill-to address, ending in the country")
    if not b.get("endpoint") or not _PARTICIPANT.match(b["endpoint"]):
        bad("PEPPOL-EN16931-R010", "the buyer electronic address (Peppol participant id as scheme:id) is missing", "Customer billing: peppol_participant_id")
    if not m["lines"]:
        bad("BR-16", "an invoice shall have at least one invoice line", "add a line")
    for ln in m["lines"]:
        n = ln["line_no"]
        if ln["quantity"] is None:
            bad("BR-22", f"line {n}: the invoiced quantity is missing", f"line {n} quantity")
        if not ln["unit_code"] or not _UNIT.match(ln["unit_code"]):
            bad("BR-23", f"line {n}: the invoiced quantity unit of measure code is missing or not a UN/ECE Rec 20 code", f"line {n} unit code")
        if not ln["description"]:
            bad("BR-25", f"line {n}: the item name is missing", f"line {n} description")
        if not ln["category"]:
            bad("BR-CO-04", f"line {n}: the VAT category is missing", f"line {n} tax code")
    if m["tax"]:
        if sum((t["net"] for t in m["tax"]), ZERO) != m["net"]:
            bad("BR-CO-13", "the VAT category taxable amounts do not add up to the invoice net amount", "recompute the tax")
        if sum((t["vat"] for t in m["tax"]), ZERO) != m["vat"]:
            bad("BR-CO-14", "the VAT category tax amounts do not add up to the invoice VAT total", "recompute the tax")
    if m["net"] + m["vat"] != m["gross"]:
        bad("BR-CO-15", "the amount with VAT is not the amount without VAT plus the VAT amount", "recompute the totals")
    if sum((ln["net"] for ln in m["lines"]), ZERO) != m["net"]:
        bad("BR-CO-10", "the sum of the line net amounts is not the invoice net amount", "recompute the totals")
    return out


def render(m: dict) -> str:
    """UBL 2.1 text for the model: an Invoice, or a CreditNote for a credit."""
    credit = m["kind"] == "credit_note"
    ns = NS_CREDIT if credit else NS_INVOICE
    ET.register_namespace("cac", NS_CAC)
    ET.register_namespace("cbc", NS_CBC)
    ET.register_namespace("", ns)
    root = ET.Element("{%s}%s" % (ns, "CreditNote" if credit else "Invoice"))
    cur = m["currency"]
    _el(root, CBC + "CustomizationID", CUSTOMIZATION)
    _el(root, CBC + "ProfileID", PROFILE)
    _el(root, CBC + "ID", m["number"])
    _el(root, CBC + "IssueDate", m["issue_date"])
    if m["due_date"] and not credit:
        _el(root, CBC + "DueDate", m["due_date"])
    _el(root, CBC + ("CreditNoteTypeCode" if credit else "InvoiceTypeCode"), "381" if credit else "380")
    _el(root, CBC + "DocumentCurrencyCode", cur)
    if m["reference"]:
        _el(root, CBC + "BuyerReference", m["reference"])
    _party(root, "AccountingSupplierParty", m["supplier"])
    _party(root, "AccountingCustomerParty", m["buyer"])
    tt = _el(root, CAC + "TaxTotal")
    _el(tt, CBC + "TaxAmount", f"{m['vat']:.2f}", currencyID=cur)
    for t in m["tax"]:
        st = _el(tt, CAC + "TaxSubtotal")
        _el(st, CBC + "TaxableAmount", f"{t['net']:.2f}", currencyID=cur)
        _el(st, CBC + "TaxAmount", f"{t['vat']:.2f}", currencyID=cur)
        tc = _el(st, CAC + "TaxCategory")
        _el(tc, CBC + "ID", t["category"])
        _el(tc, CBC + "Percent", f"{t['rate']:.2f}")
        if t["category"] in EXEMPT:
            _el(tc, CBC + "TaxExemptionReason", EXEMPT[t["category"]])
        _el(_el(tc, CAC + "TaxScheme"), CBC + "ID", "VAT")
    lm = _el(root, CAC + "LegalMonetaryTotal")
    for tag, v in (("LineExtensionAmount", m["net"]), ("TaxExclusiveAmount", m["net"]), ("TaxInclusiveAmount", m["gross"]),
                   ("PayableAmount", m["gross"])):
        _el(lm, CBC + tag, f"{v:.2f}", currencyID=cur)
    for ln in m["lines"]:
        e = _el(root, CAC + ("CreditNoteLine" if credit else "InvoiceLine"))
        _el(e, CBC + "ID", ln["line_no"])
        _el(e, CBC + ("CreditedQuantity" if credit else "InvoicedQuantity"), ln["quantity"], unitCode=ln["unit_code"] or "")
        _el(e, CBC + "LineExtensionAmount", f"{ln['net']:.2f}", currencyID=cur)
        item = _el(e, CAC + "Item")
        _el(item, CBC + "Name", (ln["description"] or "")[:200])
        ct = _el(item, CAC + "ClassifiedTaxCategory")
        _el(ct, CBC + "ID", ln["category"])
        _el(ct, CBC + "Percent", f"{ln['rate']:.2f}")
        _el(_el(ct, CAC + "TaxScheme"), CBC + "ID", "VAT")
        _el(_el(e, CAC + "Price"), CBC + "PriceAmount", f"{ln['unit_price']:.4f}", currencyID=cur)
    return ET.tostring(root, encoding="unicode")


def build(doc, *, kind: str, number: str, issue_date, due_date, supplier: dict, bill_to: dict, buyer_endpoint: str) -> dict:
    """The model of an invoice or credit note (`kind` invoice | credit_note). `supplier` {name, address, country, endpoint}; `bill_to` is
    the frozen bill-to. Quantities, unit codes, tax per code and totals are the document's own."""
    rates = {t.tax_code: t.rate_pct for t in doc.tax}
    lines = [{"line_no": ln.line_no, "description": ln.description, "quantity": ln.quantity, "unit_code": ln.unit_code,
              "unit_price": ln.unit_price, "net": ln.net_amount, "rate": rates.get(ln.tax_code, ZERO),
              "category": category(ln.tax_code, rates.get(ln.tax_code, ZERO)) if ln.tax_code else ""} for ln in doc.lines]
    tax_rows = [{"category": category(t.tax_code, t.rate_pct), "rate": t.rate_pct, "net": t.net_amount, "vat": t.tax_amount} for t in doc.tax]
    addr = bill_to.get("bill_to_address", "")
    buyer = {"name": bill_to.get("bill_to_name", ""), "address": addr, "country": _country(addr, supplier.get("country", "")),
             "endpoint": buyer_endpoint}
    return {"kind": kind, "number": number, "issue_date": issue_date.isoformat() if issue_date else "",
            "due_date": due_date.isoformat() if due_date else "", "currency": doc.currency, "supplier": supplier, "buyer": buyer,
            "lines": lines, "tax": tax_rows, "net": doc.subtotal, "vat": doc.tax_total, "gross": doc.total,
            "reference": getattr(doc, "customer_po_ref", "") or ""}