Files
BlackMarkandClaude Opus 5.5 45424e6fd6 Vermietungs-Tool
A local web app for a small room-rental business in Austria: guest register,
room ledger, occupancy plan, invoices, the monthly tourist-tax report and the
quarterly VAT list. Application code only; it ships no data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:52:27 +01:00

168 lines
7.0 KiB
Python

"""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))