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,57 @@
|
||||
"""Fast monthly service billing from existing customer presets."""
|
||||
import calendar
|
||||
import tempfile
|
||||
from .invoice_number import invoice_root_lock
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from .init import init_invoice_folder
|
||||
from .presets import load_yaml
|
||||
from .models import Invoice
|
||||
from .init import _dump_yaml
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
|
||||
MONTHS = ('Januar Februar März April Mai Juni Juli August September Oktober November Dezember').split()
|
||||
|
||||
def _monthly_invoice(root: Path, repo_root: Path, logger, customer: str, month: str,
|
||||
hours: Decimal, number: str, issued: date | None = None,
|
||||
rate: Decimal | None = None, due_days: int = 30) -> Path:
|
||||
if not re.fullmatch(r'\d{4}-\d{2}', month):
|
||||
raise ValueError('Month must be YYYY-MM')
|
||||
year, m = map(int, month.split('-'))
|
||||
start, end = date(year, m, 1), date(year, m, calendar.monthrange(year, m)[1])
|
||||
if not hours.is_finite() or hours <= 0 or due_days < 0:
|
||||
raise ValueError('Hours must be positive and due_days nonnegative')
|
||||
target = root.expanduser().resolve() / default_invoice_folder_name(number, customer)
|
||||
# Explicit number required: existing PDF invoices may live outside this root.
|
||||
if target.exists():
|
||||
raise FileExistsError(f'Invoice folder exists: {target}')
|
||||
for existing in root.expanduser().glob('*/invoice.yaml'):
|
||||
if str(load_yaml(existing).get('meta', {}).get('invoice_number', '')) == number:
|
||||
raise ValueError(f'Invoice number already used: {number}')
|
||||
with tempfile.TemporaryDirectory(prefix='.draft-', dir=root.expanduser().resolve()) as staging:
|
||||
work = Path(staging) / 'invoice'
|
||||
init_invoice_folder(work, repo_root, logger, customer=customer, invoice_number=number, due_days=due_days)
|
||||
path = work / 'invoice.yaml'
|
||||
data = load_yaml(path)
|
||||
if len(data['items']) != 1 or data['items'][0]['unit'] != 'HUR':
|
||||
raise ValueError('Monthly billing requires a preset with one HUR line')
|
||||
issued = issued or date.today()
|
||||
data['meta'].update(invoice_date=str(issued), service_date=str(end),
|
||||
service_period_start=str(start), service_period_end=str(end))
|
||||
data['payment']['due_date'] = str(issued + timedelta(days=due_days))
|
||||
item = data['items'][0]
|
||||
item['quantity'] = str(hours)
|
||||
item['name'] = f'Geleistete Stunden {MONTHS[m-1]} {year}'
|
||||
if rate is not None:
|
||||
item['unit_price_net'] = str(rate)
|
||||
Invoice.model_validate(data)
|
||||
_dump_yaml(data, path)
|
||||
work.rename(target)
|
||||
return target
|
||||
|
||||
|
||||
def monthly_invoice(root: Path, *args, **kwargs) -> Path:
|
||||
with invoice_root_lock(root):
|
||||
return _monthly_invoice(root, *args, **kwargs)
|
||||
Reference in new issue
Block a user