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,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')), [])
|
||||
Reference in new issue
Block a user