"""Money rules for invoice lines and totals, and the stay rules behind them — the single source for every page and report. A line's net is `quantity * gross_per_unit / (1 + vat_rate)` at full precision, unless an explicit `line_net_override` pins it. The standard accommodation rate is 10%. A storno line (kind='storno') is *nicht steuerbar* (vat_rate 0): a cancellation fee is damages, not a supply, so it carries no VAT and is kept OUT of the taxable Nettobetrag — it is a separate subtotal on the invoice. Totals sum the full-precision line values and round once. """ from decimal import Decimal, ROUND_HALF_UP VAT_STANDARD = Decimal("0.10") # Beherbergung reduced rate def _d(x): return Decimal(str(x)) def _round2(value): return float(Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) def _rate(vat_rate): return VAT_STANDARD if vat_rate is None else _d(vat_rate) def _net_exact(quantity, gross_per_unit, line_net_override=None, vat_rate=None): if line_net_override is not None: return _d(line_net_override) return _d(quantity) * _d(gross_per_unit) / (Decimal(1) + _rate(vat_rate)) # --- per-line display values --- def net_per_unit(gross_per_unit, vat_rate=None): return _round2(_d(gross_per_unit) / (Decimal(1) + _rate(vat_rate))) def line_net(quantity, gross_per_unit, line_net_override=None, vat_rate=None): return _round2(_net_exact(quantity, gross_per_unit, line_net_override, vat_rate)) def line_vat(quantity, gross_per_unit, line_net_override=None, vat_rate=None): return _round2(_net_exact(quantity, gross_per_unit, line_net_override, vat_rate) * _rate(vat_rate)) def line_gross(quantity, gross_per_unit, line_net_override=None, vat_rate=None): return _round2(_net_exact(quantity, gross_per_unit, line_net_override, vat_rate) * (Decimal(1) + _rate(vat_rate))) # --- invoice totals over charge dicts (keys: quantity, gross_per_unit, line_net_override?, vat_rate?, kind?) --- def _is_storno(c): return c.get("kind") == "storno" def _net_exact_c(c): return _net_exact(c["quantity"], c["gross_per_unit"], c.get("line_net_override"), c.get("vat_rate")) def _storno_amount(c): # nicht steuerbar: the whole amount, no VAT split (rate forced to 0) return _net_exact(c["quantity"], c["gross_per_unit"], c.get("line_net_override"), 0) def invoice_net(charges): """Steuerbarer Nettobetrag — taxable lines only (storno is nicht steuerbar and excluded from the base).""" return _round2(sum((_net_exact_c(c) for c in charges if not _is_storno(c)), Decimal("0"))) def invoice_vat(charges): return _round2(sum((_net_exact_c(c) * _rate(c.get("vat_rate")) for c in charges if not _is_storno(c)), Decimal("0"))) def invoice_vat_breakdown(charges): """Per-rate (net, vat) for the taxable lines, so a mixed-rate invoice (e.g. a Betriebskosten settlement with 0/10/20 %) can print one 'USt X %' row per rate. A plain 10 % invoice yields a single row, unchanged. 0 % lines stay in the Nettobetrag but get no row — 'USt 0 % 0,00' must never be printed (§11 Abs 12).""" buckets = {} for c in charges: if _is_storno(c): continue rate = _rate(c.get("vat_rate")) b = buckets.setdefault(rate, [Decimal("0"), Decimal("0")]) net = _net_exact_c(c) b[0] += net b[1] += net * rate out = [] for rate, (net, vat) in sorted(buckets.items()): if not rate: continue p = float(rate) * 100 pct = str(int(round(p))) if abs(p - round(p)) < 0.005 else f"{p:.2f}".replace(".", ",") out.append({"rate": float(rate), "pct": pct, "net": _round2(net), "vat": _round2(vat)}) return out def invoice_storno(charges): """Sum of the nicht-steuerbare Storno lines (kept out of the VAT base, shown as its own subtotal).""" return _round2(sum((_storno_amount(c) for c in charges if _is_storno(c)), Decimal("0"))) def invoice_gross(charges, tourism_tax=0): # Rounds the exact gross sum ONCE: the Rechnungsbetrag is the summed gross column, even where net + USt, # each rounded on its own, differ from it by a cent. taxable = sum((_net_exact_c(c) * (Decimal(1) + _rate(c.get("vat_rate"))) for c in charges if not _is_storno(c)), Decimal("0")) storno = sum((_storno_amount(c) for c in charges if _is_storno(c)), Decimal("0")) return _round2(taxable + storno + _d(tourism_tax)) def days_between(arrival, departure): """Whole days between two ISO dates (`bis - von`): the nights of a stay.""" from datetime import date if not arrival or not departure: return None a = date.fromisoformat(arrival) d = date.fromisoformat(departure) return (d - a).days def weeks_for_period(period_from, period_to): """The week rule: a stay of >= 4 nights is billed in WHOLE weeks, rounded up (5 nights -> 1 week, 10 nights -> 2). Shorter stays are not a week (billed per day/night) -> None.""" import math nights = days_between(period_from, period_to) if nights is None or nights < 4: return None return math.ceil(nights / 7) # Aufenthaltsabgabe (Ortstaxe) per person per night, by the date it applies from; newest first TOURISM_RATES = (("2026-05-01", 4.0), ("", 3.0)) TOURISM_EXEMPT_OVER_NIGHTS = 10 # professional stays longer than this are exempt (T-AAG §4) def tourism_rate(day): """The Aufenthaltsabgabe per person and night in force on an ISO date.""" return next(r for start, r in TOURISM_RATES if day >= start) def tourism_liable(arrival, departure): """Does this stay owe the Aufenthaltsabgabe? (the guest sheet's 'Abg' column). >10 consecutive nights on a professional stay are exempt (T-AAG §4) — the same rule the TVB filing counts by. None while the departure is still open: nobody can know yet.""" nights = days_between(arrival, departure) if nights is None: return None return 1 if nights <= TOURISM_EXEMPT_OVER_NIGHTS else 0 def tourism_amount(period_from, period_to, total_nights=None): """Ortstaxe for one invoice line: 0 when the guest's WHOLE continuous stay exceeds 10 nights (T-AAG §4 professional-stay exemption), otherwise this line's nights x the rate at the arrival date. Pass `total_nights` = the continuous stay length so a slice billed in a later month stays exempt even though the slice itself is short; it defaults to this line's own nights when the full length isn't known.""" line_nights = days_between(period_from, period_to) if not line_nights or line_nights <= 0: return 0 stay_nights = total_nights if total_nights is not None else line_nights if stay_nights > TOURISM_EXEMPT_OVER_NIGHTS: return 0 return round(line_nights * tourism_rate(period_from), 2) def tourism_for_period(period_from, period_to): """Ortstaxe for a self-contained stay: the exemption is judged on this same period (see tourism_amount for the continuation case where the stay started in an earlier, separately billed month).""" return tourism_amount(period_from, period_to, days_between(period_from, period_to))