feat: einstellbarer Monatsbeginn zum Gehaltstag
Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats, in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint. Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die Kategorienauswertung, der Monatsexport und die Benachrichtigung über überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten. Die Einstellung liegt in einer einzeiligen Tabelle hinter GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich period_start und period_end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
This commit is contained in:
co-authored by
Claude Opus 5
parent
998d5867df
commit
0a6261fc55
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today, utcnow
|
||||
from app.core.clock import add_months, month_end, today, utcnow
|
||||
from app.core.config import settings
|
||||
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
|
||||
from app.models.enums import (
|
||||
@@ -29,7 +29,8 @@ from app.models.enums import (
|
||||
from app.services.channels import Attachment, ChannelError, Notification, get_channel
|
||||
from app.services.occurrences import load_recurrences
|
||||
from app.services.recurrence import contract_term
|
||||
from app.services.reports import budget_status, flows
|
||||
from app.services.reports import budget_status, current_period, flows
|
||||
from app.services.settings import month_start_day
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -169,14 +170,15 @@ async def collect_notice_deadlines(
|
||||
async def collect_budget_exceeded(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Überschrittene Budgets – höchstens einmal je Monat und Kategorie."""
|
||||
monat = month_start(as_of)
|
||||
"""Überschrittene Budgets – höchstens einmal je Abrechnungsmonat und Kategorie."""
|
||||
monatsbeginn = await month_start_day(session)
|
||||
zeitraum = current_period(monatsbeginn, as_of)
|
||||
|
||||
return [
|
||||
Event(
|
||||
ref_type=REF_BUDGET,
|
||||
ref_id=str(eintrag.category_id),
|
||||
dedupe_day=monat,
|
||||
dedupe_day=zeitraum.key,
|
||||
headline=eintrag.category_name,
|
||||
detail=(
|
||||
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
|
||||
@@ -184,9 +186,9 @@ async def collect_budget_exceeded(
|
||||
f"{_money(abs(eintrag.remaining))} zu viel"
|
||||
),
|
||||
amount=eintrag.spent,
|
||||
on=month_end(monat),
|
||||
on=zeitraum.end,
|
||||
)
|
||||
for eintrag in await budget_status(session, monat)
|
||||
for eintrag in await budget_status(session, zeitraum.key, monatsbeginn)
|
||||
if eintrag.state == "exceeded"
|
||||
]
|
||||
|
||||
|
||||
+100
-35
@@ -14,7 +14,14 @@ from typing import Literal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today
|
||||
from app.core.clock import (
|
||||
DEFAULT_MONTH_START_DAY,
|
||||
add_months,
|
||||
month_start,
|
||||
period_bounds,
|
||||
period_key,
|
||||
today,
|
||||
)
|
||||
from app.models import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.balances import total_balance
|
||||
@@ -83,6 +90,33 @@ class Totals:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Period:
|
||||
"""Ein Abrechnungsmonat.
|
||||
|
||||
Er läuft vom eingestellten Monatsbeginn – dem Gehaltstag – bis zum Vortag des
|
||||
nächsten und trägt den Namen des Monats, in dem er beginnt. Bei Monatsbeginn 1
|
||||
ist er deckungsgleich mit dem Kalendermonat.
|
||||
"""
|
||||
|
||||
key: date
|
||||
"""Bezeichner: der Erste des Monats, in dem der Zeitraum beginnt."""
|
||||
start: date
|
||||
end: date
|
||||
|
||||
|
||||
def period_of(month: date, start_day: int = DEFAULT_MONTH_START_DAY) -> Period:
|
||||
"""Der Abrechnungsmonat mit dem Bezeichner `month`."""
|
||||
schluessel = month_start(month)
|
||||
beginn, ende = period_bounds(schluessel, start_day)
|
||||
return Period(key=schluessel, start=beginn, end=ende)
|
||||
|
||||
|
||||
def current_period(start_day: int = DEFAULT_MONTH_START_DAY, as_of: date | None = None) -> Period:
|
||||
"""Der Abrechnungsmonat, in dem `as_of` liegt – Vorgabe ist heute."""
|
||||
return period_of(period_key(as_of or today(), start_day), start_day)
|
||||
|
||||
|
||||
def totals_of(
|
||||
entries: Iterable[FlowEntry],
|
||||
*,
|
||||
@@ -180,9 +214,9 @@ async def flows(
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MonthReport:
|
||||
"""Kennzahlen eines Monats."""
|
||||
"""Kennzahlen eines Abrechnungsmonats."""
|
||||
|
||||
month: date
|
||||
period: Period
|
||||
planned: Totals
|
||||
actual: Totals
|
||||
previous_planned: Totals
|
||||
@@ -194,6 +228,11 @@ class MonthReport:
|
||||
open_count: int = 0
|
||||
skipped_count: int = 0
|
||||
|
||||
@property
|
||||
def month(self) -> date:
|
||||
"""Bezeichner des Zeitraums – immer ein Monatserster."""
|
||||
return self.period.key
|
||||
|
||||
@property
|
||||
def available_after_fixed(self) -> Decimal:
|
||||
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
||||
@@ -213,27 +252,29 @@ class MonthReport:
|
||||
|
||||
|
||||
async def _month_flows(
|
||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
||||
session: AsyncSession, period: Period, fixed_costs: dict[int, bool]
|
||||
) -> list[FlowEntry]:
|
||||
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
|
||||
return await flows(session, period.start, period.end, fixed_costs=fixed_costs)
|
||||
|
||||
|
||||
async def reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
async def reserve_total(session: AsyncSession, period: Period) -> Decimal:
|
||||
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||
total = ZERO
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.reserve_enabled:
|
||||
total += monthly_reserve(
|
||||
recurrence, month_start(month), amount_versions=recurrence.amount_versions
|
||||
recurrence, period.start, amount_versions=recurrence.amount_versions
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
async def month_report(
|
||||
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||
) -> MonthReport:
|
||||
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||
fix = await fixed_cost_map(session)
|
||||
aktuell = month_start(month)
|
||||
vormonat = add_months(aktuell, -1)
|
||||
aktuell = period_of(month, start_day)
|
||||
vormonat = period_of(add_months(aktuell.key, -1), start_day)
|
||||
|
||||
bewegungen = await _month_flows(session, aktuell, fix)
|
||||
vorherige = await _month_flows(session, vormonat, fix)
|
||||
@@ -245,13 +286,13 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
# Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt.
|
||||
ausgelassen = sum(
|
||||
1
|
||||
for item in await due_items(session, month_start(month), month_end(month))
|
||||
for item in await due_items(session, aktuell.start, aktuell.end)
|
||||
if item.planned.status is OccurrenceStatus.SKIPPED
|
||||
)
|
||||
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
|
||||
|
||||
return MonthReport(
|
||||
month=aktuell,
|
||||
period=aktuell,
|
||||
planned=totals_of(bewegungen),
|
||||
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
|
||||
previous_planned=totals_of(vorherige),
|
||||
@@ -270,13 +311,15 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForecastMonth:
|
||||
"""Ein Monat der Vorschau."""
|
||||
"""Ein Abrechnungsmonat der Vorschau."""
|
||||
|
||||
month: date
|
||||
period_start: date
|
||||
period_end: date
|
||||
income: Decimal
|
||||
expenses: Decimal
|
||||
cumulative_balance: Decimal
|
||||
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
|
||||
"""Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."""
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
@@ -284,28 +327,33 @@ class ForecastMonth:
|
||||
|
||||
|
||||
async def forecast(
|
||||
session: AsyncSession, months: int = 12, start: date | None = None
|
||||
session: AsyncSession,
|
||||
months: int = 12,
|
||||
start: date | None = None,
|
||||
start_day: int = DEFAULT_MONTH_START_DAY,
|
||||
) -> list[ForecastMonth]:
|
||||
"""Vorschau über mehrere Monate.
|
||||
|
||||
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
|
||||
aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt.
|
||||
"""
|
||||
beginn = month_start(start or today())
|
||||
erster = period_of(start, start_day) if start is not None else current_period(start_day)
|
||||
fix = await fixed_cost_map(session)
|
||||
|
||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat.
|
||||
laufend = await total_balance(session, beginn - timedelta(days=1))
|
||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
|
||||
laufend = await total_balance(session, erster.start - timedelta(days=1))
|
||||
|
||||
ergebnis: list[ForecastMonth] = []
|
||||
for versatz in range(max(1, months)):
|
||||
monat = add_months(beginn, versatz)
|
||||
bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix)
|
||||
zeitraum = period_of(add_months(erster.key, versatz), start_day)
|
||||
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
|
||||
summen = totals_of(bewegungen, basis="effective")
|
||||
laufend += summen.balance
|
||||
ergebnis.append(
|
||||
ForecastMonth(
|
||||
month=monat,
|
||||
month=zeitraum.key,
|
||||
period_start=zeitraum.start,
|
||||
period_end=zeitraum.end,
|
||||
income=summen.income,
|
||||
expenses=summen.expenses,
|
||||
cumulative_balance=laufend,
|
||||
@@ -567,6 +615,9 @@ class CalendarDay:
|
||||
@dataclass(slots=True)
|
||||
class CalendarMonth:
|
||||
month: date
|
||||
"""Bezeichner des Abrechnungsmonats – immer ein Monatserster."""
|
||||
period_start: date
|
||||
period_end: date
|
||||
days: list[CalendarDay]
|
||||
opening_balance: Decimal
|
||||
closing_balance: Decimal
|
||||
@@ -575,11 +626,16 @@ class CalendarMonth:
|
||||
|
||||
|
||||
async def calendar_month(
|
||||
session: AsyncSession, month: date, *, holiday_region: str = "DE-NW"
|
||||
session: AsyncSession,
|
||||
month: date,
|
||||
*,
|
||||
start_day: int = DEFAULT_MONTH_START_DAY,
|
||||
holiday_region: str = "DE-NW",
|
||||
) -> CalendarMonth:
|
||||
"""Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand."""
|
||||
beginn = month_start(month)
|
||||
ende = month_end(month)
|
||||
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
|
||||
zeitraum = period_of(month, start_day)
|
||||
beginn = zeitraum.start
|
||||
ende = zeitraum.end
|
||||
|
||||
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
|
||||
bewegungen = await flows(session, beginn, ende)
|
||||
@@ -614,7 +670,9 @@ async def calendar_month(
|
||||
tag += timedelta(days=1)
|
||||
|
||||
return CalendarMonth(
|
||||
month=beginn,
|
||||
month=zeitraum.key,
|
||||
period_start=beginn,
|
||||
period_end=ende,
|
||||
days=tage,
|
||||
opening_balance=eroeffnung,
|
||||
closing_balance=laufend,
|
||||
@@ -697,14 +755,17 @@ async def _effective_limits(
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
async def _spent_by_category(
|
||||
session: AsyncSession, month: date, start_day: int
|
||||
) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
kategorien = {
|
||||
kategorie.id: kategorie.parent_id
|
||||
for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
zeitraum = period_of(month, start_day)
|
||||
ergebnis: dict[int, Decimal] = {}
|
||||
for entry in await flows(session, month_start(month), month_end(month)):
|
||||
for entry in await flows(session, zeitraum.start, zeitraum.end):
|
||||
if entry.kind is not EntryKind.EXPENSE:
|
||||
continue
|
||||
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
|
||||
@@ -715,8 +776,10 @@ async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, De
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
|
||||
"""Budgets eines Monats samt Verbrauch und Übertrag."""
|
||||
async def budget_status(
|
||||
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||
) -> list[BudgetStatus]:
|
||||
"""Budgets eines Abrechnungsmonats samt Verbrauch und Übertrag."""
|
||||
monat = month_start(month)
|
||||
limits = await _effective_limits(session, monat)
|
||||
if not limits:
|
||||
@@ -725,7 +788,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
namen = {
|
||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
ausgaben = await _spent_by_category(session, monat)
|
||||
ausgaben = await _spent_by_category(session, monat, start_day)
|
||||
|
||||
ergebnis: list[BudgetStatus] = []
|
||||
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
|
||||
@@ -733,7 +796,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
if kategorie is None:
|
||||
continue
|
||||
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat, start_day) if rollover else ZERO
|
||||
ergebnis.append(
|
||||
BudgetStatus(
|
||||
category_id=kategorie_id,
|
||||
@@ -752,7 +815,9 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
|
||||
async def _carry_over(
|
||||
session: AsyncSession, category_id: int, month: date, start_day: int
|
||||
) -> Decimal:
|
||||
"""Nicht verbrauchtes Budget aus den Vormonaten.
|
||||
|
||||
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
|
||||
@@ -772,7 +837,7 @@ async def _carry_over(session: AsyncSession, category_id: int, month: date) -> D
|
||||
uebertrag = ZERO
|
||||
continue
|
||||
|
||||
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
|
||||
ausgaben = (await _spent_by_category(session, vormonat, start_day)).get(category_id, ZERO)
|
||||
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
|
||||
return uebertrag
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Anwendungsweite Einstellungen.
|
||||
|
||||
Die Tabelle enthält genau eine Zeile. Fehlt sie – etwa direkt nach der
|
||||
Migration –, liefert `load_settings` die Vorgaben, ohne sie zu schreiben.
|
||||
Erst ein Speichern legt die Zeile an.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import DEFAULT_MONTH_START_DAY
|
||||
from app.models import AppSetting
|
||||
|
||||
SETTING_ID = 1
|
||||
|
||||
|
||||
async def load_settings(session: AsyncSession) -> AppSetting:
|
||||
"""Die Einstellungen; ohne gespeicherte Zeile ein Objekt mit den Vorgaben."""
|
||||
vorhanden = await session.get(AppSetting, SETTING_ID)
|
||||
if vorhanden is not None:
|
||||
return vorhanden
|
||||
return AppSetting(id=SETTING_ID, month_start_day=DEFAULT_MONTH_START_DAY)
|
||||
|
||||
|
||||
async def month_start_day(session: AsyncSession) -> int:
|
||||
"""Der eingestellte Gehaltstag – ab ihm beginnt der Abrechnungsmonat."""
|
||||
return (await load_settings(session)).month_start_day
|
||||
|
||||
|
||||
async def save_settings(session: AsyncSession, *, month_start_day: int) -> AppSetting:
|
||||
"""Schreibt die Einstellungen und legt die Zeile bei Bedarf an."""
|
||||
eintrag = await session.get(AppSetting, SETTING_ID)
|
||||
if eintrag is None:
|
||||
eintrag = AppSetting(id=SETTING_ID)
|
||||
session.add(eintrag)
|
||||
eintrag.month_start_day = month_start_day
|
||||
await session.flush()
|
||||
return eintrag
|
||||
Reference in New Issue
Block a user