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
@@ -0,0 +1,121 @@
|
||||
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), ('<validation/>', 0),
|
||||
('<validation><summary status="valid"/></validation>', 0),
|
||||
('<validation><summary status="invalid"/></validation>', 0),
|
||||
('<validation><summary status="valid"/></validation>', 1),
|
||||
('<validation><pdf><summary status="invalid"/></pdf><summary status="valid"/></validation>', 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 = '<validation><xml><summary status="valid"/></xml><summary status="valid"/></validation>'
|
||||
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 = '<script>alert(1)</script>'
|
||||
html = render_html(self.inv, ROOT / 'templates/invoice.html.j2')
|
||||
self.assertNotIn('<script>', html)
|
||||
self.assertIn('<script>', html)
|
||||
|
||||
def test_monthly_period_rate_and_duplicate_number(self):
|
||||
# Explicit repository presets, no personal data required.
|
||||
import yaml
|
||||
repo = self.root / 'repo'
|
||||
(repo / 'examples').mkdir(parents=True)
|
||||
(repo / 'examples/invoice.init.yaml').write_text((ROOT / 'examples/invoice.sample.yaml').read_text())
|
||||
(repo / 'presets/customers').mkdir(parents=True)
|
||||
(repo / 'presets/customers/test.yaml').write_text(yaml.safe_dump({'items': [self.inv.items[0].model_dump(mode='json')]}))
|
||||
target = monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08', date(2028, 3, 1), Decimal('85'))
|
||||
inv = load_invoice_yaml(target / 'invoice.yaml')
|
||||
self.assertEqual(inv.meta.service_period_end, date(2028, 2, 29))
|
||||
self.assertEqual(inv.payment.due_date, date(2028, 3, 31))
|
||||
self.assertEqual(compute_totals(inv).gross_total, Decimal('7586.25'))
|
||||
with self.assertRaises(FileExistsError):
|
||||
monthly_invoice(self.root / 'invoices', repo, LOGGER, 'test', '2028-02', Decimal('75'), '2028-08')
|
||||
|
||||
def test_missing_recipient_cannot_leave_stale_draft(self):
|
||||
import shutil
|
||||
shutil.copy(ROOT / 'examples/invoice.sample.yaml', self.root / 'invoice.yaml')
|
||||
(self.root / 'out').mkdir()
|
||||
(self.root / 'out/invoice.eml').write_text('stale')
|
||||
with self.assertRaises(ValueError):
|
||||
prepare_email(self.root, Path('fake.jar'), LOGGER)
|
||||
self.assertFalse((self.root / 'out/invoice.eml').exists())
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user