feat: support mixed VAT rates per line (BG-23) in both XML generators
This commit is contained in:
parent
6ecd59e5ea
commit
d7b851ae36
150
invoice_logic.py
150
invoice_logic.py
|
|
@ -186,6 +186,87 @@ def format_cii_date(date_str):
|
|||
return clean.replace("-", "")
|
||||
|
||||
|
||||
def aggregate_tax_subtotals(items):
|
||||
"""Gruppiert Rechnungspositionen nach Steuersatz (BG-23) und liefert je Gruppe Netto- und
|
||||
Steuerbetrag - Grundlage fuer mehrere cac:TaxSubtotal/ram:ApplicableTradeTax-Bloecke bei
|
||||
gemischten Steuersaetzen auf einer Rechnung."""
|
||||
buckets = {}
|
||||
for item in items:
|
||||
rate = Decimal(str(item.get("vat_rate_percent", 0.0)))
|
||||
net = Decimal(str(item["total_amount"]))
|
||||
buckets[rate] = buckets.get(rate, Decimal("0")) + net
|
||||
|
||||
result = []
|
||||
for rate in sorted(buckets.keys()):
|
||||
taxable = buckets[rate].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
tax_amount = (taxable * rate / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
category = "S" if rate > 0 else "E"
|
||||
result.append({
|
||||
"rate": float(rate),
|
||||
"category": category,
|
||||
"taxable_amount": float(taxable),
|
||||
"tax_amount": float(tax_amount),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def compute_invoice_totals(items, discount_percent=0.0, discount_amount=0.0):
|
||||
"""Berechnet Netto-/Steuer-/Bruttosummen inkl. optionalem dokumentenweiten Rabatt (BT-21).
|
||||
Bei gemischten Steuersaetzen wird der Rabatt anteilig ueber die Steuersatz-Buckets verteilt
|
||||
(Standardverfahren, da EN16931 keinen rabattspezifischen Steuersatz kennt). Ohne discount_*
|
||||
ist das Ergebnis identisch zur reinen Summenbildung von vor Phase 1."""
|
||||
line_net_total = sum((Decimal(str(item["total_amount"])) for item in items), Decimal("0"))
|
||||
line_net_total = line_net_total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
if discount_amount:
|
||||
allowance = Decimal(str(discount_amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
elif discount_percent:
|
||||
allowance = (line_net_total * Decimal(str(discount_percent)) / Decimal("100")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
else:
|
||||
allowance = Decimal("0.00")
|
||||
|
||||
raw_buckets = aggregate_tax_subtotals(items)
|
||||
|
||||
if allowance > 0 and line_net_total > 0:
|
||||
buckets = []
|
||||
allocated = Decimal("0")
|
||||
for i, bucket in enumerate(raw_buckets):
|
||||
taxable = Decimal(str(bucket["taxable_amount"]))
|
||||
rate = Decimal(str(bucket["rate"]))
|
||||
if i == len(raw_buckets) - 1:
|
||||
share = allowance - allocated
|
||||
else:
|
||||
share = (allowance * taxable / line_net_total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
allocated += share
|
||||
new_taxable = (taxable - share).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
tax_amount = (new_taxable * rate / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
buckets.append({
|
||||
"rate": bucket["rate"],
|
||||
"category": bucket["category"],
|
||||
"taxable_amount": float(new_taxable),
|
||||
"tax_amount": float(tax_amount),
|
||||
"allowance_amount": float(share), # anteiliger Rabatt dieser Steuergruppe (BT-92 je Satz)
|
||||
})
|
||||
else:
|
||||
buckets = raw_buckets
|
||||
|
||||
tax_basis_total = sum((Decimal(str(b["taxable_amount"])) for b in buckets), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
total_vat_amount = sum((Decimal(str(b["tax_amount"])) for b in buckets), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
total_gross_amount = (tax_basis_total + total_vat_amount).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
return {
|
||||
"line_net_total": float(line_net_total),
|
||||
"allowance_amount": float(allowance),
|
||||
"tax_basis_total": float(tax_basis_total),
|
||||
"total_vat_amount": float(total_vat_amount),
|
||||
"total_gross_amount": float(total_gross_amount),
|
||||
"tax_buckets": buckets,
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# GENERATOR A: XRECHNUNG (UBL FORMAT)
|
||||
# ==========================================
|
||||
|
|
@ -307,21 +388,34 @@ def generate_compliant_xrechnung(data):
|
|||
tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal")
|
||||
ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
|
||||
|
||||
tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal")
|
||||
ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
|
||||
# BG-23: pro Steuersatz-Bucket ein eigenes cac:TaxSubtotal (Mehrfach-Steuersaetze).
|
||||
# Fallback fuer Aufrufer ohne tax_buckets (Einzelsteuersatz, byte-identisch zu vor Phase 1):
|
||||
# synthetischer Ein-Bucket aus dem globalen Steuersatz und den Gesamtsummen.
|
||||
_tax_buckets = data.get("tax_buckets") or [{
|
||||
"rate": vat_rate, "category": vat_category,
|
||||
"taxable_amount": data["total_net_amount"], "tax_amount": data["total_vat_amount"],
|
||||
}]
|
||||
for bucket in _tax_buckets:
|
||||
tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal")
|
||||
ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{bucket['taxable_amount']:.2f}"
|
||||
ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{bucket['tax_amount']:.2f}"
|
||||
|
||||
tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory")
|
||||
ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = vat_category
|
||||
ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}"
|
||||
tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory")
|
||||
ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = bucket["category"]
|
||||
ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{bucket['rate']:.2f}"
|
||||
|
||||
tax_scheme = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme")
|
||||
ET.SubElement(tax_scheme, f"{{{CBC}}}ID").text = "VAT"
|
||||
tax_scheme = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme")
|
||||
ET.SubElement(tax_scheme, f"{{{CBC}}}ID").text = "VAT"
|
||||
|
||||
monetary_total = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal")
|
||||
ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}"
|
||||
# BT-106 LineExtensionAmount ist die UNRABATTIERTE Positionssumme; BT-109 TaxExclusiveAmount
|
||||
# ("total_net_amount") ist bereits die um einen etwaigen dokumentenweiten Rabatt (BT-107)
|
||||
# reduzierte Steuerbasis - siehe compute_invoice_totals(). Ohne Rabatt sind beide identisch.
|
||||
ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data.get('line_net_total', data['total_net_amount']):.2f}"
|
||||
ET.SubElement(monetary_total, f"{{{CBC}}}TaxExclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
|
||||
if data.get("allowance_amount", 0.0) > 0:
|
||||
ET.SubElement(monetary_total, f"{{{CBC}}}AllowanceTotalAmount", {"currencyID": "EUR"}).text = f"{data['allowance_amount']:.2f}"
|
||||
ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
|
||||
|
||||
for index, item in enumerate(data["items"], start=1):
|
||||
|
|
@ -344,9 +438,13 @@ def generate_compliant_xrechnung(data):
|
|||
|
||||
ET.SubElement(item_elem, f"{{{CBC}}}Name").text = str(item.get("name", "Dienstleistung"))
|
||||
|
||||
# BG-30 Positionssteuer: nutzt den eigenen Steuersatz der Position, falls vorhanden
|
||||
# (Multi-Tax-Support) - fällt sonst auf den globalen Rechnungssteuersatz zurück.
|
||||
line_vat_rate = float(item.get("vat_rate_percent", data['vat_rate_percent']))
|
||||
line_vat_category = "S" if line_vat_rate > 0.0 else "E"
|
||||
item_tax = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory")
|
||||
ET.SubElement(item_tax, f"{{{CBC}}}ID").text = vat_category
|
||||
ET.SubElement(item_tax, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}"
|
||||
ET.SubElement(item_tax, f"{{{CBC}}}ID").text = line_vat_category
|
||||
ET.SubElement(item_tax, f"{{{CBC}}}Percent").text = f"{line_vat_rate:.2f}"
|
||||
item_scheme = ET.SubElement(item_tax, f"{{{CAC}}}TaxScheme")
|
||||
ET.SubElement(item_scheme, f"{{{CBC}}}ID").text = "VAT"
|
||||
|
||||
|
|
@ -421,11 +519,13 @@ def generate_compliant_zugferd_cii(data):
|
|||
u_code = map_unece_unit_code(item.get("unit", "Stk."))
|
||||
ET.SubElement(line_deliv, f"{{{RAM}}}BilledQuantity", {"unitCode": u_code}).text = f"{item['quantity']:.4f}"
|
||||
|
||||
line_vat_rate = float(item.get("vat_rate_percent", data['vat_rate_percent']))
|
||||
line_vat_category = "S" if line_vat_rate > 0.0 else "E"
|
||||
line_settle = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeSettlement")
|
||||
trade_tax = ET.SubElement(line_settle, f"{{{RAM}}}ApplicableTradeTax")
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}TypeCode").text = "VAT"
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}CategoryCode").text = vat_category
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{vat_rate:.2f}"
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}CategoryCode").text = line_vat_category
|
||||
ET.SubElement(trade_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{line_vat_rate:.2f}"
|
||||
|
||||
mon_sum = ET.SubElement(line_settle, f"{{{RAM}}}SpecifiedTradeSettlementLineMonetarySummation")
|
||||
ET.SubElement(mon_sum, f"{{{RAM}}}LineTotalAmount").text = f"{item['total_amount']:.2f}"
|
||||
|
|
@ -522,12 +622,20 @@ def generate_compliant_zugferd_cii(data):
|
|||
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
|
||||
ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "")
|
||||
|
||||
header_tax = ET.SubElement(header_settle, f"{{{RAM}}}ApplicableTradeTax")
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}CalculatedAmount").text = f"{data['total_vat_amount']:.2f}"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}TypeCode").text = "VAT"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}BasisAmount").text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}CategoryCode").text = vat_category
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{vat_rate:.2f}"
|
||||
# BG-23: pro Steuersatz-Bucket ein eigenes ram:ApplicableTradeTax (Mehrfach-Steuersaetze).
|
||||
# Fallback fuer Aufrufer ohne tax_buckets (Einzelsteuersatz, byte-identisch zu vor Phase 1):
|
||||
# synthetischer Ein-Bucket aus dem globalen Steuersatz und den Gesamtsummen.
|
||||
_tax_buckets = data.get("tax_buckets") or [{
|
||||
"rate": vat_rate, "category": vat_category,
|
||||
"taxable_amount": data["total_net_amount"], "tax_amount": data["total_vat_amount"],
|
||||
}]
|
||||
for bucket in _tax_buckets:
|
||||
header_tax = ET.SubElement(header_settle, f"{{{RAM}}}ApplicableTradeTax")
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}CalculatedAmount").text = f"{bucket['tax_amount']:.2f}"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}TypeCode").text = "VAT"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}BasisAmount").text = f"{bucket['taxable_amount']:.2f}"
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}CategoryCode").text = bucket["category"]
|
||||
ET.SubElement(header_tax, f"{{{RAM}}}RateApplicablePercent").text = f"{bucket['rate']:.2f}"
|
||||
|
||||
if data.get("service_start_date") and data.get("service_end_date"):
|
||||
period = ET.SubElement(header_settle, f"{{{RAM}}}BillingSpecifiedPeriod")
|
||||
|
|
@ -547,7 +655,9 @@ def generate_compliant_zugferd_cii(data):
|
|||
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "")
|
||||
|
||||
header_sum = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementHeaderMonetarySummation")
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data.get('line_net_total', data['total_net_amount']):.2f}"
|
||||
if data.get("allowance_amount", 0.0) > 0:
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}AllowanceTotalAmount").text = f"{data['allowance_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}TaxBasisTotalAmount").text = f"{data['total_net_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
|
||||
ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}"
|
||||
|
|
|
|||
38
main.py
38
main.py
|
|
@ -23,6 +23,7 @@ from invoice_logic import (
|
|||
generate_compliant_zugferd_cii,
|
||||
add_pdfa_output_intent,
|
||||
fix_xrechnung_xmp_conformance,
|
||||
compute_invoice_totals,
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
|
|
@ -267,7 +268,10 @@ with col2:
|
|||
st.markdown("### 3. Positionen")
|
||||
|
||||
if st.button("➕ Neue Position hinzufügen"):
|
||||
data["items"].append({"name": "Neue Dienstleistung", "description": "", "quantity": 1.0, "unit": "Stk.", "unit_price": 0.0})
|
||||
data["items"].append({
|
||||
"name": "Neue Dienstleistung", "description": "", "quantity": 1.0, "unit": "Stk.",
|
||||
"unit_price": 0.0, "vat_rate_percent": float(data.get("vat_rate_percent") or 19.0),
|
||||
})
|
||||
st.rerun()
|
||||
|
||||
edited_items = []
|
||||
|
|
@ -291,13 +295,19 @@ with col2:
|
|||
calc_height = max(68, min(400, estimated_lines * 24))
|
||||
item_description = st.text_area("Zusatzbeschreibung (BT-154)", value=desc_text, key=f"item_desc_{inv_id}_{idx}", height=calc_height)
|
||||
|
||||
c_qty, c_unit, c_price, c_total = st.columns(4)
|
||||
c_qty, c_unit, c_price, c_vat, c_total = st.columns(5)
|
||||
with c_qty:
|
||||
item_quantity = st.number_input("Menge (BT-129) :red[*]", value=float(item.get("quantity") or 0.0), format="%.3f", step=0.1, key=f"item_qty_{inv_id}_{idx}")
|
||||
with c_unit:
|
||||
item_unit = st.text_input("Einheit (BT-130) :red[*]", value=item.get("unit", "Stk."), key=f"item_unit_{inv_id}_{idx}")
|
||||
with c_price:
|
||||
item_price = st.number_input("Einzelpreis (BT-146, €) :red[*]", value=float(item.get("unit_price") or 0.0), format="%.2f", key=f"item_price_{inv_id}_{idx}")
|
||||
with c_vat:
|
||||
item_vat_rate = st.number_input(
|
||||
"Steuersatz (BT-152, %) :red[*]",
|
||||
value=float(item.get("vat_rate_percent") or data.get("vat_rate_percent") or 19.0),
|
||||
key=f"item_vat_{inv_id}_{idx}",
|
||||
)
|
||||
with c_total:
|
||||
raw_item_total = item_quantity * item_price
|
||||
item_total_amount = kaufmaennisches_runden(raw_item_total)
|
||||
|
|
@ -310,21 +320,20 @@ with col2:
|
|||
"quantity": item_quantity,
|
||||
"unit": item_unit,
|
||||
"unit_price": item_price,
|
||||
"total_amount": item_total_amount
|
||||
"total_amount": item_total_amount,
|
||||
"vat_rate_percent": item_vat_rate,
|
||||
})
|
||||
|
||||
st.markdown("---")
|
||||
vat_rate = st.number_input("Steuersatz (BT-152, %) :red[*]", value=float(data.get("vat_rate_percent") or 19.0))
|
||||
# Der frühere globale Steuersatz dient jetzt nur noch als Vorbelegung für neu
|
||||
# hinzugefügte Positionen (siehe "Neue Position hinzufügen" oben) - abgerechnet wird
|
||||
# pro Position mit dem jeweils eingetragenen Steuersatz (Multi-Tax-Support).
|
||||
vat_rate = st.number_input("Standard-Steuersatz für neue Positionen (%)", value=float(data.get("vat_rate_percent") or 19.0))
|
||||
|
||||
# Summen mit Decimal bilden, damit die Netto-Summe exakt der Summe der (bereits gerundeten)
|
||||
# Positionsbeträge entspricht. Reine float-Summation kann minimal driften und dann zu
|
||||
# BR-CO-10-Validierungsfehlern führen (Summe der Zeilen != TaxExclusiveAmount).
|
||||
net_dec = sum((Decimal(str(item["total_amount"])) for item in edited_items), Decimal("0"))
|
||||
vat_dec = (net_dec * Decimal(str(vat_rate)) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
gross_dec = (net_dec + vat_dec).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
computed_net_amount = float(net_dec.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
computed_vat_amount = float(vat_dec)
|
||||
computed_gross_amount = float(gross_dec)
|
||||
totals = compute_invoice_totals(edited_items)
|
||||
computed_net_amount = totals["tax_basis_total"]
|
||||
computed_vat_amount = totals["total_vat_amount"]
|
||||
computed_gross_amount = totals["total_gross_amount"]
|
||||
|
||||
st.markdown("**Berechnete Summen:**")
|
||||
c_net_ui, c_vat_ui, c_gross_ui = st.columns(3)
|
||||
|
|
@ -348,6 +357,9 @@ with col2:
|
|||
"items": edited_items, "total_net_amount": computed_net_amount, "total_vat_amount": computed_vat_amount,
|
||||
"vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN,
|
||||
"payment_means_code": selected_payment_means_code,
|
||||
"tax_buckets": totals["tax_buckets"],
|
||||
"line_net_total": totals["line_net_total"],
|
||||
"allowance_amount": totals["allowance_amount"],
|
||||
"delivery_note_id": dn_id,
|
||||
"order_id": o_id,
|
||||
"contract_id": c_id,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from invoice_logic import (
|
|||
generate_compliant_zugferd_cii,
|
||||
VERKAEUFER_IBAN,
|
||||
PAYMENT_MEANS_CODES,
|
||||
aggregate_tax_subtotals,
|
||||
compute_invoice_totals,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -130,3 +132,86 @@ def test_generate_compliant_zugferd_cii_uses_dynamic_payment_means_code():
|
|||
ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"}
|
||||
code = root.find(".//ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode", ns).text
|
||||
assert code == "48"
|
||||
|
||||
|
||||
def test_aggregate_tax_subtotals_single_rate():
|
||||
items = [{"total_amount": 200.0, "vat_rate_percent": 19.0}]
|
||||
buckets = aggregate_tax_subtotals(items)
|
||||
assert buckets == [{"rate": 19.0, "category": "S", "taxable_amount": 200.0, "tax_amount": 38.0}]
|
||||
|
||||
|
||||
def test_aggregate_tax_subtotals_mixed_rates():
|
||||
items = [
|
||||
{"total_amount": 100.0, "vat_rate_percent": 19.0},
|
||||
{"total_amount": 50.0, "vat_rate_percent": 7.0},
|
||||
{"total_amount": 30.0, "vat_rate_percent": 0.0},
|
||||
]
|
||||
buckets = aggregate_tax_subtotals(items)
|
||||
assert buckets == [
|
||||
{"rate": 0.0, "category": "E", "taxable_amount": 30.0, "tax_amount": 0.0},
|
||||
{"rate": 7.0, "category": "S", "taxable_amount": 50.0, "tax_amount": 3.5},
|
||||
{"rate": 19.0, "category": "S", "taxable_amount": 100.0, "tax_amount": 19.0},
|
||||
]
|
||||
|
||||
|
||||
def test_compute_invoice_totals_mixed_rates_no_discount():
|
||||
items = [
|
||||
{"total_amount": 100.0, "vat_rate_percent": 19.0},
|
||||
{"total_amount": 50.0, "vat_rate_percent": 7.0},
|
||||
]
|
||||
totals = compute_invoice_totals(items)
|
||||
assert totals["line_net_total"] == 150.0
|
||||
assert totals["allowance_amount"] == 0.0
|
||||
assert totals["tax_basis_total"] == 150.0
|
||||
assert totals["total_vat_amount"] == 22.5
|
||||
assert totals["total_gross_amount"] == 172.5
|
||||
|
||||
|
||||
def test_compute_invoice_totals_single_rate_matches_old_behavior():
|
||||
items = [{"total_amount": 200.0, "vat_rate_percent": 19.0}]
|
||||
totals = compute_invoice_totals(items)
|
||||
assert totals["tax_basis_total"] == 200.0
|
||||
assert totals["total_vat_amount"] == 38.0
|
||||
assert totals["total_gross_amount"] == 238.0
|
||||
|
||||
|
||||
def _mixed_rate_invoice():
|
||||
data = _single_rate_invoice()
|
||||
items = [
|
||||
{"name": "Beratung", "description": "", "quantity": 2.0, "unit": "Std.",
|
||||
"unit_price": 100.0, "total_amount": 200.0, "vat_rate_percent": 19.0},
|
||||
{"name": "Buch", "description": "", "quantity": 1.0, "unit": "Stk.",
|
||||
"unit_price": 50.0, "total_amount": 50.0, "vat_rate_percent": 7.0},
|
||||
]
|
||||
totals = compute_invoice_totals(items)
|
||||
data["items"] = items
|
||||
data["tax_buckets"] = totals["tax_buckets"]
|
||||
data["line_net_total"] = totals["line_net_total"]
|
||||
data["allowance_amount"] = totals["allowance_amount"]
|
||||
data["total_net_amount"] = totals["tax_basis_total"]
|
||||
data["total_vat_amount"] = totals["total_vat_amount"]
|
||||
data["total_gross_amount"] = totals["total_gross_amount"]
|
||||
return data
|
||||
|
||||
|
||||
def test_generate_compliant_xrechnung_mixed_rates_two_tax_subtotals():
|
||||
xml_str = generate_compliant_xrechnung(_mixed_rate_invoice())
|
||||
root = ET.fromstring(xml_str.encode("utf-8"))
|
||||
ns = {"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
|
||||
"cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"}
|
||||
subtotals = root.findall(".//cac:TaxTotal/cac:TaxSubtotal", ns)
|
||||
assert len(subtotals) == 2
|
||||
percents = sorted(st.find("cac:TaxCategory/cbc:Percent", ns).text for st in subtotals)
|
||||
assert percents == ["19.00", "7.00"] or percents == ["7.00", "19.00"]
|
||||
lines = root.findall(".//cac:InvoiceLine", ns)
|
||||
line_percents = [ln.find(".//cbc:Percent", ns).text for ln in lines]
|
||||
assert set(line_percents) == {"19.00", "7.00"}
|
||||
|
||||
|
||||
def test_generate_compliant_zugferd_cii_mixed_rates_two_header_taxes():
|
||||
xml_str = generate_compliant_zugferd_cii(_mixed_rate_invoice())
|
||||
root = ET.fromstring(xml_str.encode("utf-8"))
|
||||
ns = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"}
|
||||
header_taxes = root.findall(
|
||||
".//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax", ns)
|
||||
assert len(header_taxes) == 2
|
||||
|
|
|
|||
Loading…
Reference in New Issue