Files
moneyfy/backend/app/api/routes/export.py
T
Jonas MenzelandClaude Opus 5 0a6261fc55
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m26s
Images bauen / build (frontend) (push) Successful in 4m4s
CI / frontend (push) Successful in 6m32s
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
2026-09-10 09:52:51 +02:00

204 lines
6.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Export von Buchungen, Posten und Monatsauswertung als CSV oder XLSX."""
from datetime import date
from typing import Literal
from fastapi import APIRouter, Query, Response, status
from sqlalchemy import select
from app.api.deps import DbSession, MonthStartDay
from app.core.clock import today
from app.core.errors import ValidationError
from app.models import Account, Merchant, Transaction
from app.models.enums import EntryKind
from app.schemas.common import ErrorResponse
from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx
from app.services.reports import (
category_names,
current_period,
flow_rows,
flows,
month_report,
period_of,
recurrence_rows,
reserve_total,
)
router = APIRouter(prefix="/export", tags=["export"])
ExportFormat = Literal["csv", "xlsx"]
TRANSACTION_COLUMNS = (
"Datum",
"Titel",
"Richtung",
"Betrag",
"Kategorie",
"Firma",
"Konto",
"Notiz",
)
RECURRENCE_COLUMNS = (
"Titel",
"Richtung",
"Betrag",
"Wiederholung",
"Beginn",
"Ende",
"Kategorie",
"Firma",
"Jahreskosten",
"Raten",
"Gekündigt zum",
"Aktiv",
)
FLOW_COLUMNS = ("Datum", "Titel", "Kategorie", "Richtung", "Betrag", "Herkunft", "Bestätigt")
SUMMARY_COLUMNS = ("Kennzahl", "Betrag")
# Starlette benennt die 422-Konstante gerade um fester Wert vermeidet die Abhängigkeit.
BAD_REQUEST = {422: {"model": ErrorResponse}}
def _download(content: bytes, name: str, fmt: ExportFormat) -> Response:
"""Antwort mit passendem Typ und Dateinamen."""
return Response(
content=content,
media_type=CONTENT_TYPES[fmt],
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
@router.get(
"/transactions",
responses={
status.HTTP_200_OK: {
"content": {"text/csv": {}, CONTENT_TYPES["xlsx"]: {}},
"description": "Die Buchungen als Datei.",
},
**BAD_REQUEST,
},
summary="Buchungen exportieren",
description="Ohne Zeitraum wird das laufende Jahr ausgegeben.",
)
async def export_transactions(
session: DbSession,
fmt: ExportFormat = Query(default="csv", alias="format"),
date_from: date | None = Query(default=None, alias="from"),
date_to: date | None = Query(default=None, alias="to"),
) -> Response:
heute = today()
start = date_from or date(heute.year, 1, 1)
ende = date_to or date(heute.year, 12, 31)
if ende < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
namen = await category_names(session)
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
konten = await _account_names(session)
stmt = (
select(Transaction)
.where(Transaction.booking_date >= start, Transaction.booking_date <= ende)
.order_by(Transaction.booking_date, Transaction.id)
)
zeilen = [
{
"Datum": buchung.booking_date.isoformat(),
"Titel": buchung.title,
"Richtung": "Einkunft" if buchung.kind is EntryKind.INCOME else "Ausgabe",
"Betrag": buchung.amount,
"Kategorie": namen.get(buchung.category_id, ""),
"Firma": firmen.get(buchung.merchant_id or -1, ""),
"Konto": konten.get(buchung.account_id, ""),
"Notiz": buchung.note or "",
}
for buchung in (await session.execute(stmt)).scalars()
]
zeitraum = f"{start.isoformat()}_{ende.isoformat()}"
if fmt == "csv":
return _download(
to_csv(zeilen, TRANSACTION_COLUMNS), filename("buchungen", "csv", zeitraum), fmt
)
return _download(
to_xlsx({"Buchungen": zeilen}, {"Buchungen": TRANSACTION_COLUMNS}),
filename("buchungen", "xlsx", zeitraum),
fmt,
)
@router.get(
"/recurrences",
responses={status.HTTP_200_OK: {"description": "Die Posten als Datei."}},
summary="Wiederkehrende Posten exportieren",
)
async def export_recurrences(
session: DbSession,
fmt: ExportFormat = Query(default="csv", alias="format"),
) -> Response:
zeilen = await recurrence_rows(session)
if fmt == "csv":
return _download(to_csv(zeilen, RECURRENCE_COLUMNS), filename("posten", "csv"), fmt)
return _download(
to_xlsx({"Posten": zeilen}, {"Posten": RECURRENCE_COLUMNS}),
filename("posten", "xlsx"),
fmt,
)
@router.get(
"/month",
responses={status.HTTP_200_OK: {"description": "Die Monatsauswertung als Datei."}},
summary="Monatsauswertung exportieren",
description="Enthält alle Bewegungen des Monats und eine Kennzahlenübersicht. "
"Im XLSX-Format stehen beide auf getrennten Blättern.",
)
async def export_month(
session: DbSession,
start_day: MonthStartDay,
fmt: ExportFormat = Query(default="xlsx", alias="format"),
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> Response:
zeitraum = period_of(month, start_day) if month else current_period(start_day)
namen = await category_names(session)
bewegungen = await flows(session, zeitraum.start, zeitraum.end)
zeilen = flow_rows(bewegungen, namen)
bericht = await month_report(session, zeitraum.key, start_day)
kennzahlen: list[dict[str, object]] = [
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
{"Kennzahl": "Ausgaben (Plan)", "Betrag": bericht.planned.expenses},
{"Kennzahl": "Saldo (Plan)", "Betrag": bericht.planned.balance},
{"Kennzahl": "Einnahmen (Ist)", "Betrag": bericht.actual.income},
{"Kennzahl": "Ausgaben (Ist)", "Betrag": bericht.actual.expenses},
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs},
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, zeitraum)},
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
]
kennung = zeitraum.key.strftime("%Y-%m")
if fmt == "csv":
# CSV kennt keine Blätter die Kennzahlen folgen nach einer Leerzeile.
inhalt = to_csv(zeilen, FLOW_COLUMNS)
inhalt += b"\r\n" + to_csv(kennzahlen, SUMMARY_COLUMNS).removeprefix("".encode())
return _download(inhalt, filename("monat", "csv", kennung), fmt)
return _download(
to_xlsx(
{"Bewegungen": zeilen, "Kennzahlen": kennzahlen},
{"Bewegungen": FLOW_COLUMNS, "Kennzahlen": SUMMARY_COLUMNS},
),
filename("monat", "xlsx", kennung),
fmt,
)
async def _account_names(session: DbSession) -> dict[int, str]:
return {konto.id: konto.name for konto in (await session.execute(select(Account))).scalars()}