diff --git a/backend/app/main.py b/backend/app/main.py index 5bc2abe..348c9f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ from app.routers import hours_payouts from app.routers import public_stamp from app.routers import ical from app.routers import ha +from app.routers import portal from app.routers import reseller, tenants from app.routers import tls_admin @@ -142,6 +143,7 @@ app.include_router(time_entries.router, prefix=API_PREFIX) app.include_router(public_stamp.router, prefix=API_PREFIX) app.include_router(ical.router, prefix=API_PREFIX) app.include_router(ha.router, prefix=API_PREFIX) +app.include_router(portal.router, prefix=API_PREFIX) app.include_router(absences.router, prefix=API_PREFIX) app.include_router(reports.router, prefix=API_PREFIX) app.include_router(ldap.router, prefix=API_PREFIX) diff --git a/backend/app/models/company.py b/backend/app/models/company.py index d78d6f9..b7915d6 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -55,6 +55,10 @@ class Company(Base): busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True) busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Buchhaltungsportal: SHA-256-Hash des Tokens für den read-only Summen-Endpunkt (/portal/summary). + portal_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True) + portal_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Öffentliches QR-Stempeln: statischer firmenweiter QR-Code → /stamp?t=. # Mitarbeiter scannt mit Privat-Handy, meldet sich per Personalnummer + PIN an. # Opt-in (default OFF). Token gehasht in DB (SHA-256), Klartext nur beim Rotieren. diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py new file mode 100644 index 0000000..37c44b6 --- /dev/null +++ b/backend/app/routers/portal.py @@ -0,0 +1,159 @@ +"""Buchhaltungsportal: read-only Summen (Pull-Endpoint + Token-Verwaltung). + +- Pull: GET /portal/summary – Auth via per-Firma Bearer-Token (SHA-256 in DB) +- Verwaltung: GET/POST/DELETE /companies/me/portal-token – COMPANY_ADMIN/SUPER_ADMIN + +Liefert nur Firmensummen zu Resturlaub und Überstundenguthaben. Keine Einzelpersonen, +keine Krankheits- oder Abwesenheitsdaten. +""" +from __future__ import annotations + +import hashlib +import secrets +from datetime import date, datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_client_ip, require_role +from app.core.limiter import limiter +from app.models.audit_log import AuditLog +from app.models.company import Company +from app.models.overtime_balance import OvertimeBalance +from app.models.user import User, UserRole +from app.models.vacation_balance import VacationBalance +from app.schemas.portal import ( + PortalSummary, + PortalTokenRotated, + PortalTokenStatus, + PortalUeberstunden, + PortalUrlaub, +) +from app.services.absence_service import absence_service + +router = APIRouter(tags=["Buchhaltungsportal"]) + +_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) +_pull_bearer = HTTPBearer(auto_error=False) +KLEINE_GRUPPE = 3 + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +@router.get("/companies/me/portal-token", response_model=PortalTokenStatus) +async def get_portal_token_status( + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + company = await db.get(Company, current_user.company_id) + return PortalTokenStatus(configured=company.portal_token_hash is not None, created_at=company.portal_token_created_at) + + +@router.post("/companies/me/portal-token/rotate", response_model=PortalTokenRotated) +async def rotate_portal_token( + request: Request, + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + company = await db.get(Company, current_user.company_id) + token = secrets.token_urlsafe(32) + company.portal_token_hash = _hash_token(token) + company.portal_token_created_at = datetime.now(timezone.utc) + db.add(AuditLog( + company_id=company.id, + user_id=current_user.id, + action="portal_token_rotated", + entity_type="company", + entity_id=company.id, + ip=get_client_ip(request), + )) + await db.commit() + return PortalTokenRotated(token=token, created_at=company.portal_token_created_at) + + +@router.delete("/companies/me/portal-token", status_code=204) +async def delete_portal_token( + request: Request, + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + company = await db.get(Company, current_user.company_id) + if company.portal_token_hash is None: + return + company.portal_token_hash = None + company.portal_token_created_at = None + db.add(AuditLog( + company_id=company.id, + user_id=current_user.id, + action="portal_token_revoked", + entity_type="company", + entity_id=company.id, + ip=get_client_ip(request), + )) + await db.commit() + + +async def _company_from_token(credentials: HTTPAuthorizationCredentials | None, db: AsyncSession) -> Company: + if credentials is None or not credentials.credentials: + raise HTTPException(status_code=401, detail="Missing token") + company = await db.scalar( + select(Company).where(Company.portal_token_hash == _hash_token(credentials.credentials), Company.is_active.is_(True)) + ) + if company is None: + raise HTTPException(status_code=401, detail="Invalid token") + return company + + +@router.get("/portal/summary", response_model=PortalSummary) +@limiter.limit("30/minute") +async def portal_summary( + request: Request, + year: int | None = None, + credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer), + db: AsyncSession = Depends(get_db), +): + company = await _company_from_token(credentials, db) + today = date.today() + jahr = year or today.year + if not 2000 <= jahr <= 2200: + raise HTTPException(status_code=422, detail="Ungültiges Jahr.") + + user_ids = list(await db.scalars(select(User.id).where(User.company_id == company.id, User.is_active.is_(True)))) + + balances = list(await db.scalars( + select(VacationBalance).where(VacationBalance.user_id.in_(user_ids), VacationBalance.year == jahr) + )) if user_ids else [] + _, verfallen = absence_service._carryover_expired(company, jahr) + rest = sum(absence_service.effective_available(b, verfallen) for b in balances) + + overtime = list(await db.scalars( + select(OvertimeBalance).where(OvertimeBalance.company_id == company.id, OvertimeBalance.user_id.in_(user_ids)) + )) if user_ids else [] + verfuegbar = [float(o.available_hours) for o in overtime] + + return PortalSummary( + stichtag=today, + firma=company.name, + mitarbeiter_aktiv=len(user_ids), + kleine_gruppe=len(user_ids) < KLEINE_GRUPPE, + urlaub=PortalUrlaub( + jahr=jahr, + konten=len(balances), + ohne_konto=len(user_ids) - len(balances), + anspruch_tage=float(sum(b.entitled_days + b.special_days for b in balances)), + uebertrag_tage=float(sum(b.carried_over for b in balances)), + genommen_tage=float(sum(b.used_days for b in balances)), + resttage=float(rest), + ), + ueberstunden=PortalUeberstunden( + konten=len(overtime), + guthaben_gesamt_h=round(sum(verfuegbar), 2), + guthaben_positiv_h=round(sum(v for v in verfuegbar if v > 0), 2), + mitarbeiter_mit_guthaben=sum(1 for v in verfuegbar if v > 0), + ), + ) diff --git a/backend/app/schemas/portal.py b/backend/app/schemas/portal.py new file mode 100644 index 0000000..c7e7c8d --- /dev/null +++ b/backend/app/schemas/portal.py @@ -0,0 +1,39 @@ +from datetime import date, datetime + +from pydantic import BaseModel, Field + + +class PortalTokenStatus(BaseModel): + configured: bool + created_at: datetime | None = None + + +class PortalTokenRotated(BaseModel): + token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.") + created_at: datetime + + +class PortalUrlaub(BaseModel): + jahr: int + konten: int = Field(..., description="Aktive Mitarbeiter mit Urlaubskonto im Jahr.") + ohne_konto: int + anspruch_tage: float + uebertrag_tage: float + genommen_tage: float + resttage: float + + +class PortalUeberstunden(BaseModel): + konten: int + guthaben_gesamt_h: float + guthaben_positiv_h: float + mitarbeiter_mit_guthaben: int + + +class PortalSummary(BaseModel): + stichtag: date + firma: str + mitarbeiter_aktiv: int + kleine_gruppe: bool = Field(..., description="Weniger als drei aktive Mitarbeiter: Summen können Einzelwerte verraten.") + urlaub: PortalUrlaub + ueberstunden: PortalUeberstunden diff --git a/backend/migrations/versions/0049_portal_token.py b/backend/migrations/versions/0049_portal_token.py new file mode 100644 index 0000000..eac2852 --- /dev/null +++ b/backend/migrations/versions/0049_portal_token.py @@ -0,0 +1,31 @@ +"""Buchhaltungsportal: companies.portal_token_hash + created_at + +Revision ID: 0049 +Revises: 0048 +Create Date: 2026-10-29 + +Gehashtes, rotierbares Firmen-Token für den read-only Summen-Endpunkt +(/portal/summary). NULL = deaktiviert. Keine RLS-Änderung (nullable Spalten +auf bestehender companies-Policy). +""" +from alembic import op + +revision = "0049" +down_revision = "0048" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS portal_token_hash VARCHAR(64)") + op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS portal_token_created_at TIMESTAMPTZ") + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_companies_portal_token_hash " + "ON companies (portal_token_hash) WHERE portal_token_hash IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_companies_portal_token_hash") + op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS portal_token_created_at") + op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS portal_token_hash") diff --git a/backend/tests/test_portal.py b/backend/tests/test_portal.py new file mode 100644 index 0000000..282850d --- /dev/null +++ b/backend/tests/test_portal.py @@ -0,0 +1,123 @@ +"""Tests für den Summen-Endpunkt des Buchhaltungsportals (router/portal.py).""" +import pytest +import pytest_asyncio +from datetime import date +from httpx import AsyncClient + + +async def _registriere(client: AsyncClient, name: str, mail: str): + resp = await client.post("/api/v1/auth/register", json={ + "company_name": name, + "first_name": "Portal", + "last_name": "Admin", + "email": mail, + "password": "Secret123", + }) + assert resp.status_code == 201, resp.text + return {"Authorization": f"Bearer {resp.json()['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def pt_headers(client: AsyncClient): + return await _registriere(client, "Portal GmbH", "admin@portal-test.de") + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def pt_token(client: AsyncClient, pt_headers): + resp = await client.post("/api/v1/companies/me/portal-token/rotate", headers=pt_headers) + assert resp.status_code == 200, resp.text + return resp.json()["token"] + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_not_configured_by_default(client: AsyncClient): + headers = await _registriere(client, "Portal Leer GmbH", "admin@portal-leer.de") + r = await client.get("/api/v1/companies/me/portal-token", headers=headers) + assert r.status_code == 200 + assert r.json()["configured"] is False + + +@pytest.mark.asyncio(loop_scope="session") +async def test_summary_requires_valid_token(client: AsyncClient, pt_token): + assert (await client.get("/api/v1/portal/summary")).status_code == 401 + assert (await client.get("/api/v1/portal/summary", headers=_bearer("falsch"))).status_code == 401 + assert (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).status_code == 200 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_summary_shape_and_small_group(client: AsyncClient, pt_token): + body = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json() + assert body["stichtag"] == str(date.today()) + assert body["firma"] == "Portal GmbH" + assert body["mitarbeiter_aktiv"] >= 1 + assert body["kleine_gruppe"] is True + assert set(body) == {"stichtag", "firma", "mitarbeiter_aktiv", "kleine_gruppe", "urlaub", "ueberstunden"} + assert set(body["urlaub"]) == {"jahr", "konten", "ohne_konto", "anspruch_tage", "uebertrag_tage", "genommen_tage", "resttage"} + assert set(body["ueberstunden"]) == {"konten", "guthaben_gesamt_h", "guthaben_positiv_h", "mitarbeiter_mit_guthaben"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_summary_contains_no_sickness_data(client: AsyncClient, pt_token, pt_headers): + vorher = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json() + heute = str(date.today()) + sick = await client.post("/api/v1/absences/quick-sick", json={"start_date": heute, "end_date": heute}, headers=pt_headers) + assert sick.status_code in (200, 201), sick.text + nachher = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json() + assert nachher["urlaub"]["genommen_tage"] == vorher["urlaub"]["genommen_tage"] + assert "krank" not in str(nachher).lower() and "sick" not in str(nachher).lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_summary_rejects_invalid_year(client: AsyncClient, pt_token): + assert (await client.get("/api/v1/portal/summary?year=1500", headers=_bearer(pt_token))).status_code == 422 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_tokens_are_scoped_to_their_company(client: AsyncClient, pt_token): + andere = await _registriere(client, "Portal Andere GmbH", "admin@portal-andere.de") + token = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=andere)).json()["token"] + a = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json() + b = (await client.get("/api/v1/portal/summary", headers=_bearer(token))).json() + assert a["firma"] == "Portal GmbH" + assert b["firma"] == "Portal Andere GmbH" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_rotate_invalidates_old_token_and_revoke_disables(client: AsyncClient): + headers = await _registriere(client, "Portal Rotation GmbH", "admin@portal-rotation.de") + alt = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=headers)).json()["token"] + neu = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=headers)).json()["token"] + assert alt != neu + assert (await client.get("/api/v1/portal/summary", headers=_bearer(alt))).status_code == 401 + assert (await client.get("/api/v1/portal/summary", headers=_bearer(neu))).status_code == 200 + assert (await client.get("/api/v1/companies/me/portal-token", headers=headers)).json()["configured"] is True + assert (await client.delete("/api/v1/companies/me/portal-token", headers=headers)).status_code == 204 + assert (await client.get("/api/v1/portal/summary", headers=_bearer(neu))).status_code == 401 + assert (await client.get("/api/v1/companies/me/portal-token", headers=headers)).json()["configured"] is False + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_management_needs_login(client: AsyncClient): + assert (await client.post("/api/v1/companies/me/portal-token/rotate")).status_code in (401, 403) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_summary_matches_own_balances_for_single_user(client: AsyncClient): + headers = await _registriere(client, "Portal Summen GmbH", "admin@portal-summen.de") + token = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=headers)).json()["token"] + jahr = date.today().year + konto = (await client.get(f"/api/v1/absences/balance?year={jahr}", headers=headers)).json() + assert konto["available_days"] > 0 + body = (await client.get(f"/api/v1/portal/summary?year={jahr}", headers=_bearer(token))).json() + assert body["mitarbeiter_aktiv"] == 1 + assert body["urlaub"]["konten"] == 1 + assert body["urlaub"]["ohne_konto"] == 0 + assert body["urlaub"]["resttage"] == konto["available_days"] + assert body["urlaub"]["anspruch_tage"] == konto["entitled_days"] + konto["special_days"] + assert body["urlaub"]["genommen_tage"] == konto["used_days"] + saldo = (await client.get("/api/v1/absences/overtime-balance", headers=headers)).json() + assert body["ueberstunden"]["guthaben_gesamt_h"] == pytest.approx(float(saldo["available_hours"])) diff --git a/docs/api.md b/docs/api.md index c07c7c8..b6c761f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -30,6 +30,7 @@ In der Entwicklungsumgebung ist die interaktive Dokumentation unter `/docs` (Swa 20. [Sondervertretungen](#sondervertretungen) 21. [Admin · TLS](#admin--tls) 22. [Home-Assistant-Status](#home-assistant-status) +23. [Buchhaltungsportal-Summen](#buchhaltungsportal-summen) --- @@ -1882,3 +1883,41 @@ sensor: - stamped_in_since scan_interval: 60 ``` + +--- + +## Buchhaltungsportal-Summen + +Read-only Firmensummen für das Buchhaltungsportal. Nur Summen zu Resturlaub und Überstundenguthaben, **keine Einzelpersonen und keine Krankheits- oder Abwesenheitsdaten**. Auth über ein Firmen-Token (SHA-256 in `companies.portal_token_hash`, Klartext nur beim Erzeugen), Migration `0049`, Rate-Limit 30/Minute. + +### `GET /companies/me/portal-token` + +Status des Tokens: `{ "configured": true, "created_at": "..." }`. Rolle COMPANY_ADMIN, SUPER_ADMIN. + +### `POST /companies/me/portal-token/rotate` + +Erzeugt oder ersetzt das Token (das alte endet sofort). **Response:** `{ "token": "...", "created_at": "..." }`. Das Token wird nur einmal angezeigt. Audit-Eintrag `portal_token_rotated`. + +### `DELETE /companies/me/portal-token` + +Widerruft das Token (`204`). Audit-Eintrag `portal_token_revoked`. + +### `GET /portal/summary` + +Header `Authorization: Bearer `. Query: `year` (optional, Standard laufendes Jahr, 2000 bis 2200). `401` bei fehlendem oder falschem Token oder deaktivierter Firma. + +```json +{ + "stichtag": "2026-10-29", + "firma": "Muster GmbH", + "mitarbeiter_aktiv": 7, + "kleine_gruppe": false, + "urlaub": { "jahr": 2026, "konten": 7, "ohne_konto": 0, "anspruch_tage": 210, "uebertrag_tage": 12, "genommen_tage": 143, "resttage": 79 }, + "ueberstunden": { "konten": 7, "guthaben_gesamt_h": 84.5, "guthaben_positiv_h": 96.0, "mitarbeiter_mit_guthaben": 4 } +} +``` + +- `resttage` berücksichtigt den Verfall des Resturlaubs (`effective_available`). +- `guthaben_positiv_h` zählt nur positive Salden, das ist die Grundlage für eine Rückstellung. +- `kleine_gruppe`: weniger als drei aktive Mitarbeiter, die Summen verraten dann Einzelwerte. +- Nur aktive Mitarbeiter, nur vorhandene Urlaubskonten des Jahres (`ohne_konto` zählt die übrigen).