diff --git a/invoice_logic.py b/invoice_logic.py index ca5aee2..305228a 100644 --- a/invoice_logic.py +++ b/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}" diff --git a/main.py b/main.py index 6a3f17f..c35c203 100644 --- a/main.py +++ b/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,40 +295,45 @@ 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) st.markdown("**Gesamtbetrag (€):**") st.markdown(f"**{item_total_amount:.2f} €**") - + edited_items.append({ "name": item_name, "description": item_description, "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)) - - # 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) + # 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)) + + 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, diff --git a/tests/test_invoice_logic.py b/tests/test_invoice_logic.py index 2b9b887..357b85e 100644 --- a/tests/test_invoice_logic.py +++ b/tests/test_invoice_logic.py @@ -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