feat: create validated ZUGFeRD invoices in one console command

This commit is contained in:
admin committed 2026-10-16 09:29:08 +02:00
1 parent e0c15fc7f2
commit a643cf5227
30 files changed
+1509 -207

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
import io
import logging
import os
from datetime import date
from decimal import Decimal
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import yaml
from jr_einvoice.create import decimal_input, save_customer, find_customer, service_period, create_invoice
from jr_einvoice.quick_cli import main
from jr_einvoice.pipeline import BuildResult
from jr_einvoice.presets import load_yaml
ROOT = Path(__file__).resolve().parent.parent
class CreateTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.presets = self.root / 'presets'
self.presets.mkdir()
sample = load_yaml(ROOT / 'examples/invoice.sample.yaml')
(self.presets / 'seller.default.yaml').write_text(yaml.safe_dump({
'seller': sample['seller'], 'payment': sample['payment']}))
self.key, _ = save_customer(self.presets, 'Neuer / alter Kunde GmbH', sample['buyer'] | {'name': 'Neuer / alter Kunde GmbH'})
self.jar = self.root / 'test.jar'
self.jar.touch()
self.kwargs = dict(root=self.root / 'invoices', resources=ROOT, presets=self.presets,
customer='Neuer / alter Kunde GmbH', entry='Geleistete Stunden Oktober 2026',
volume=Decimal('120'), price=Decimal('85'), jar=self.jar,
issued=date(2026, 10, 31), logger=logging.getLogger('test'))
def fake_build(self, target, *args):
out = target / 'out'
(out / 'validation').mkdir(parents=True, exist_ok=True)
paths = [out / 'invoice.pdf', out / 'factur-x.xml', out / 'invoice.facturx.pdf', out / 'validation/report.xml']
for path in paths:
path.write_bytes(b'test artifact')
return BuildResult(target, out, *paths)
def test_german_numbers(self):
for text, expected in [('85,00 €', '85'), ('1.234,56 EUR', '1234.56'),
('120', '120'), ('72,5', '72.5'), ('85.00', '85'), ('0.125', '0.125')]:
self.assertEqual(decimal_input(text), Decimal(expected))
for text in ['NaN', 'Infinity', '1,2,3', '12.34,56', '85 USD', '', '1e9']:
with self.assertRaises(ValueError):
decimal_input(text)
def test_customer_reuse_and_no_overwrite(self):
self.assertEqual(find_customer(self.presets, 'NEUER / ALTER KUNDE GMBH')[0], self.key)
self.assertEqual(find_customer(self.presets, self.key)[0], self.key)
with self.assertRaises(FileExistsError):
save_customer(self.presets, 'Neuer / alter Kunde GmbH', find_customer(self.presets, self.key)[1]['buyer'])
self.assertEqual((self.presets / 'customers' / f'{self.key}.yaml').stat().st_mode & 0o777, 0o600)
def test_service_period_inference_and_override(self):
self.assertEqual(service_period('Geleistete Stunden Februar 2028', None, None, date(2028, 3, 1)),
(date(2028, 2, 29), date(2028, 2, 1), date(2028, 2, 29)))
self.assertEqual(service_period('Oktober 2026', None, date(2026, 10, 15), date.today()),
(date(2026, 10, 15), None, None))
with self.assertRaises(ValueError):
service_period('Test', '2026-13', None, date.today())
with self.assertRaises(ValueError):
service_period('Test', '2026-10', date.today(), date.today())
def test_create_allocates_numbers_and_attachment(self):
with patch('jr_einvoice.create.build_in_folder', side_effect=self.fake_build):
first = create_invoice(**self.kwargs)
second = create_invoice(**self.kwargs)
with self.assertRaises(ValueError):
create_invoice(**self.kwargs, number=first.invoice.meta.invoice_number)
self.assertEqual(first.invoice.meta.invoice_number, 'JR-2026-0001')
self.assertEqual(second.invoice.meta.invoice_number, 'JR-2026-0002')
self.assertEqual(first.invoice.meta.service_period_end, date(2026, 10, 31))
self.assertEqual(first.invoice.payment.due_date, date(2026, 11, 30))
self.assertEqual(first.attachment.read_bytes(), first.build.facturx_pdf.read_bytes())
self.assertEqual(first.build.invoice_dir.stat().st_mode & 0o777, 0o700)
self.assertTrue((first.build.out_dir / 'manifest.json').is_file())
def test_failed_build_retains_number_but_publishes_no_attachment(self):
with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('invalid XML')):
with self.assertRaises(RuntimeError):
create_invoice(**self.kwargs)
self.assertEqual(len(list((self.root / 'invoices').glob('*/invoice.yaml'))), 1)
self.assertEqual(list((self.root / 'invoices').glob('*/Rechnung_*.pdf')), [])
with patch('jr_einvoice.create.build_in_folder', side_effect=self.fake_build):
second = create_invoice(**self.kwargs)
self.assertEqual(second.invoice.meta.invoice_number, 'JR-2026-0002')
def test_unknown_customer_noninteractive_is_actionable(self):
with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(self.presets)}), patch('sys.stderr', new_callable=io.StringIO) as err:
with self.assertRaises(SystemExit) as result:
main(['--create', '--Customer', 'Unbekannt', '--Entry', 'Beratung', '--Volume', '1', '--Price', '85,00 €', '--non-interactive'])
self.assertEqual(result.exception.code, 1)
self.assertIn('--Street', err.getvalue())
self.assertIsNone(find_customer(self.presets, 'Unbekannt'))
def test_interactive_customer_is_saved_once(self):
with patch.dict(os.environ, {'JR_EINVOICE_PRESETS': str(self.presets)}), \
patch('sys.stdin.isatty', return_value=True), \
patch('builtins.input', side_effect=['Neuer Weg 5', '12345', 'Berlin']) as ask, \
patch('jr_einvoice.quick_cli.create_invoice', side_effect=RuntimeError('stop before build')), \
patch('sys.stderr', new_callable=io.StringIO), patch('sys.stdout', new_callable=io.StringIO):
with self.assertRaises(SystemExit):
main(['--create', '--Customer', 'Neue Firma', '--Entry', 'Beratung', '--Volume', '1', '--Price', '85'])
self.assertEqual(ask.call_count, 3)
self.assertEqual(find_customer(self.presets, 'Neue Firma')[1]['buyer']['address']['street'], 'Neuer Weg 5')
def test_invalid_amount_creates_no_invoice(self):
with self.assertRaises(ValueError):
create_invoice(**(self.kwargs | {'volume': Decimal('-1')}))
self.assertEqual(list((self.root / 'invoices').glob('*/invoice.yaml')), [])