58 lines
2.8 KiB
Python
58 lines
2.8 KiB
Python
"""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)
|