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 ""}