feat: create validated ZUGFeRD invoices in one console command
This commit is contained in:
1 parent
e0c15fc7f2
commit
a643cf5227
30 files changed
+1509
-207
No files matched your search
+103
-15
@@ -1,22 +1,110 @@
|
||||
from __future__ import annotations
|
||||
"""UN/CEFACT CII D16B, EN16931: positive, taxable service invoices."""
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
from pycheval import EN16931Invoice, Money, generate_xml
|
||||
import xml.etree.ElementTree as ET
|
||||
from .calc import compute_totals, line_net
|
||||
from .models import Invoice
|
||||
from .calc import compute_totals
|
||||
|
||||
NS = {k: f'urn:un:unece:uncefact:data:standard:{v}:100' for k, v in {
|
||||
'rsm': 'CrossIndustryInvoice', 'ram': 'ReusableAggregateBusinessInformationEntity',
|
||||
'udt': 'UnqualifiedDataType'}.items()}
|
||||
for prefix, uri in NS.items():
|
||||
ET.register_namespace(prefix, uri)
|
||||
|
||||
def node(parent, name, value=None, **attrs):
|
||||
prefix, local = name.split(':')
|
||||
el = ET.SubElement(parent, f'{{{NS[prefix]}}}{local}', attrs)
|
||||
if value is not None:
|
||||
el.text = format(value, "f") if isinstance(value, Decimal) else str(value)
|
||||
return el
|
||||
|
||||
def ram(parent, name, value=None, **attrs):
|
||||
return node(parent, 'ram:' + name, value, **attrs)
|
||||
|
||||
def date_node(parent, name, value):
|
||||
node(ram(parent, name), 'udt:DateTimeString', value.strftime('%Y%m%d'), format='102')
|
||||
|
||||
def party(parent, name, data):
|
||||
p = ram(parent, name)
|
||||
ram(p, 'Name', data.name)
|
||||
address = ram(p, 'PostalTradeAddress')
|
||||
for key, value in [('PostcodeCode', data.address.postal_code), ('LineOne', data.address.street),
|
||||
('CityName', data.address.city), ('CountryID', data.address.country)]:
|
||||
ram(address, key, value)
|
||||
if data.email:
|
||||
ram(ram(p, 'URIUniversalCommunication'), 'URIID', data.email, schemeID='EM')
|
||||
for value, scheme in [(data.vat_id, 'VA'), (data.tax_number, 'FC')]:
|
||||
if value:
|
||||
ram(ram(p, 'SpecifiedTaxRegistration'), 'ID', value, schemeID=scheme)
|
||||
|
||||
def generate_facturx_xml(inv: Invoice, out_xml: Path, logger) -> Path:
|
||||
out_xml.parent.mkdir(parents=True, exist_ok=True)
|
||||
totals = compute_totals(inv)
|
||||
|
||||
invoice = EN16931Invoice(
|
||||
invoice_number=inv.meta.invoice_number,
|
||||
invoice_date=inv.meta.invoice_date,
|
||||
currency=inv.meta.currency,
|
||||
grand_total=Money(str(Decimal(str(totals.gross_total)).quantize(Decimal("0.01"))), inv.meta.currency),
|
||||
)
|
||||
|
||||
xml_string = generate_xml(invoice)
|
||||
out_xml.write_text(xml_string, encoding="utf-8")
|
||||
logger.info("Wrote Factur-X XML: %s", out_xml)
|
||||
root = ET.Element(f'{{{NS["rsm"]}}}CrossIndustryInvoice')
|
||||
context = node(root, 'rsm:ExchangedDocumentContext')
|
||||
ram(ram(context, 'GuidelineSpecifiedDocumentContextParameter'), 'ID', 'urn:cen.eu:en16931:2017')
|
||||
doc = node(root, 'rsm:ExchangedDocument')
|
||||
ram(doc, 'ID', inv.meta.invoice_number)
|
||||
ram(doc, 'TypeCode', '380')
|
||||
date_node(doc, 'IssueDateTime', inv.meta.invoice_date)
|
||||
if inv.meta.notes:
|
||||
ram(ram(doc, 'IncludedNote'), 'Content', inv.meta.notes)
|
||||
transaction = node(root, 'rsm:SupplyChainTradeTransaction')
|
||||
for index, item in enumerate(inv.items, 1):
|
||||
line = ram(transaction, 'IncludedSupplyChainTradeLineItem')
|
||||
ram(ram(line, 'AssociatedDocumentLineDocument'), 'LineID', index)
|
||||
product = ram(line, 'SpecifiedTradeProduct')
|
||||
ram(product, 'Name', item.name)
|
||||
if item.description:
|
||||
ram(product, 'Description', item.description)
|
||||
agreement = ram(line, 'SpecifiedLineTradeAgreement')
|
||||
ram(ram(agreement, 'NetPriceProductTradePrice'), 'ChargeAmount', item.unit_price_net)
|
||||
ram(ram(line, 'SpecifiedLineTradeDelivery'), 'BilledQuantity', item.quantity, unitCode=item.unit)
|
||||
settlement = ram(line, 'SpecifiedLineTradeSettlement')
|
||||
tax = ram(settlement, 'ApplicableTradeTax')
|
||||
ram(tax, 'TypeCode', 'VAT')
|
||||
ram(tax, 'CategoryCode', 'S')
|
||||
ram(tax, 'RateApplicablePercent', item.tax_rate)
|
||||
ram(ram(settlement, 'SpecifiedTradeSettlementLineMonetarySummation'), 'LineTotalAmount', line_net(item))
|
||||
agreement = ram(transaction, 'ApplicableHeaderTradeAgreement')
|
||||
if inv.meta.buyer_reference:
|
||||
ram(agreement, 'BuyerReference', inv.meta.buyer_reference)
|
||||
party(agreement, 'SellerTradeParty', inv.seller)
|
||||
party(agreement, 'BuyerTradeParty', inv.buyer)
|
||||
if inv.meta.order_reference:
|
||||
ram(ram(agreement, 'BuyerOrderReferencedDocument'), 'IssuerAssignedID', inv.meta.order_reference)
|
||||
delivery = ram(transaction, 'ApplicableHeaderTradeDelivery')
|
||||
date_node(ram(delivery, 'ActualDeliverySupplyChainEvent'), 'OccurrenceDateTime', inv.meta.service_date)
|
||||
settlement = ram(transaction, 'ApplicableHeaderTradeSettlement')
|
||||
ram(settlement, 'PaymentReference', inv.payment.reference)
|
||||
ram(settlement, 'InvoiceCurrencyCode', inv.meta.currency)
|
||||
if inv.payment.iban:
|
||||
means = ram(settlement, 'SpecifiedTradeSettlementPaymentMeans')
|
||||
ram(means, 'TypeCode', '58')
|
||||
ram(ram(means, 'PayeePartyCreditorFinancialAccount'), 'IBANID', inv.payment.iban.replace(' ', ''))
|
||||
if inv.payment.bic:
|
||||
ram(ram(means, 'PayeeSpecifiedCreditorFinancialInstitution'), 'BICID', inv.payment.bic)
|
||||
for rate, base in totals.basis_by_rate.items():
|
||||
tax = ram(settlement, 'ApplicableTradeTax')
|
||||
ram(tax, 'CalculatedAmount', totals.tax_by_rate[rate])
|
||||
ram(tax, 'TypeCode', 'VAT')
|
||||
ram(tax, 'BasisAmount', base)
|
||||
ram(tax, 'CategoryCode', 'S')
|
||||
ram(tax, 'RateApplicablePercent', rate)
|
||||
if inv.meta.service_period_start:
|
||||
period = ram(settlement, 'BillingSpecifiedPeriod')
|
||||
date_node(period, 'StartDateTime', inv.meta.service_period_start)
|
||||
date_node(period, 'EndDateTime', inv.meta.service_period_end)
|
||||
terms = ram(settlement, 'SpecifiedTradePaymentTerms')
|
||||
date_node(terms, 'DueDateDateTime', inv.payment.due_date)
|
||||
summary = ram(settlement, 'SpecifiedTradeSettlementHeaderMonetarySummation')
|
||||
for name, amount in [('LineTotalAmount', totals.net_total), ('ChargeTotalAmount', '0.00'),
|
||||
('AllowanceTotalAmount', '0.00'), ('TaxBasisTotalAmount', totals.net_total)]:
|
||||
ram(summary, name, amount)
|
||||
ram(summary, 'TaxTotalAmount', totals.tax_total, currencyID=inv.meta.currency)
|
||||
ram(summary, 'GrandTotalAmount', totals.gross_total)
|
||||
ram(summary, 'DuePayableAmount', totals.gross_total)
|
||||
ET.indent(root)
|
||||
out_xml.parent.mkdir(parents=True, exist_ok=True)
|
||||
ET.ElementTree(root).write(out_xml, encoding='utf-8', xml_declaration=True)
|
||||
logger.info('Wrote EN16931 XML: %s', out_xml)
|
||||
return out_xml
|
||||
Reference in new issue
Block a user