import logging import tempfile import unittest from datetime import date from decimal import Decimal from pathlib import Path from unittest.mock import patch from subprocess import CompletedProcess import xml.etree.ElementTree as ET from jr_einvoice.calc import compute_totals from jr_einvoice.io import load_invoice_yaml from jr_einvoice.models import Invoice from jr_einvoice.facturx import generate_facturx_xml, NS from jr_einvoice.mustang import validate, MustangError from jr_einvoice.monthly import monthly_invoice from jr_einvoice.render import render_html from jr_einvoice.delivery import prepare_email ROOT = Path(__file__).resolve().parent.parent LOGGER = logging.getLogger('test') class InvoicesTest(unittest.TestCase): def setUp(self): self.inv = load_invoice_yaml(ROOT / 'examples/invoice.sample.yaml') self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) def test_tax_rounding_by_group_and_line(self): self.inv.items = [self.inv.items[0].model_copy(update={ 'quantity': Decimal('1'), 'unit_price_net': Decimal('0.03')}) for _ in range(3)] total = compute_totals(self.inv) self.assertEqual(total.net_total, Decimal('0.09')) self.assertEqual(total.tax_total, Decimal('0.02')) self.inv.items[0].unit_price_net = Decimal('1.005') self.assertEqual(compute_totals(self.inv).net_total, Decimal('1.07')) def test_multiple_rates_and_complete_xml(self): self.inv.items[1].tax_rate = Decimal('7') total = compute_totals(self.inv) self.assertEqual(total.gross_total, Decimal('1335.00')) xml = generate_facturx_xml(self.inv, self.root / 'invoice.xml', LOGGER) root = ET.parse(xml).getroot() self.assertEqual(len(root.findall('.//ram:IncludedSupplyChainTradeLineItem', NS)), 2) self.assertEqual(root.findtext('.//ram:SellerTradeParty/ram:Name', namespaces=NS), self.inv.seller.name) self.assertEqual(root.findtext('.//ram:BuyerTradeParty/ram:Name', namespaces=NS), self.inv.buyer.name) self.assertEqual(root.findtext('.//ram:GrandTotalAmount', namespaces=NS), '1335.00') def test_invalid_inputs(self): for change in [lambda d: d.update(items=[]), lambda d: d['items'][0].update(tax_rate=0), lambda d: d['items'][0].update(quantity='NaN'), lambda d: d['items'][0].update(unit_price_net='Infinity'), lambda d: d['meta'].update(unknown_field='typo'), lambda d: d['meta'].update(service_period_start='2026-01-01'), lambda d: d['payment'].update(iban='DE00000000000000000000'), lambda d: d['payment'].update(due_date='2020-01-01'), lambda d: d['seller'].update(email='a@example.com\nBcc: x@example.com')]: data = self.inv.model_dump() change(data) with self.assertRaises(ValueError): Invoice.model_validate(data) def test_fail_closed_validator_retains_report(self): for content, rc in [('', 0), ('noise', 0), ('', 0), ('', 0), ('', 0), ('', 1), ('', 0)]: with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], rc, content, '')): with self.assertRaises(MustangError): validate(Path('fake.jar'), Path('fake.pdf'), self.root / 'report.xml', LOGGER) self.assertTrue((self.root / 'report.xml').exists()) def test_validator_accepts_valid_summary(self): content = '' with patch('jr_einvoice.mustang._run', return_value=CompletedProcess([], 0, content, '')): validate(Path('fake.jar'), Path('fake.xml'), self.root / 'report.xml', LOGGER) def test_display_preserves_price_and_quantity_precision(self): self.inv.items[0].quantity = Decimal('1.125') self.inv.items[0].unit_price_net = Decimal('0.125') html = render_html(self.inv, ROOT / 'templates/invoice.html.j2') self.assertIn('1,125', html) self.assertIn('0,125 EUR', html) self.assertIn('0,14 EUR', html) def test_html_escapes_user_text(self): self.inv.items[0].name = '' html = render_html(self.inv, ROOT / 'templates/invoice.html.j2') self.assertNotIn('