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>
168 lines
7.0 KiB
Python
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))
|