fix: Final-Review-Findings (GUI-Login, FK-Kaskaden, Validierung, Duplikat-Recheck, Views)
F1: GUI-Login-Hash gegen $-Korruption gehaertet. .env-Werte einfach-gequotet
geschrieben + Sanity-Check nach Sourcing. Zusaetzlich systemd-Ebene:
podman escapt $->$$ in Environment=, systemd 252 kollabiert das nicht ->
sed-Post-Processing der Unit-Dateien. Live-.env: GUI-Passwort rotiert.
F2: Explizite Dependent-Deletes gegen FK-500 (delete_scenario/loan), 409 bei
bestaetigtem Import-Delete. Test-Engine erzwingt PRAGMA foreign_keys=ON.
F3: Enum/Range-Validierung (Literal + Field ge/le) fuer Recurring, Loan,
Modifier inkl. PATCH-Pfade.
F4: Confirm nur fuer draft-Statements (sonst 409) + Dedup-Re-Check gegen
bestaetigte Buchungen vor dem Promoten.
F5: v_balance_history/v_balance_total addieren pro Konto den opening_balance
des fruehesten bestaetigten Statements (approved deviation vom Plan-SQL).
F6: patch_scenario Namenskollision -> 409; create_transaction unbekanntes
Konto -> 404 (statt 500).
F7: Generierte systemd-Unit-Dateien chmod 600 (enthalten Env-Secrets).
F8: Globaler htmx:responseError-Handler in base.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,15 +53,26 @@ GRAFANA_DASHBOARDS_DIR="$FINANCE_DIR/grafana/dashboards"
|
|||||||
# unchanged, so re-running this script never rotates secrets under existing
|
# unchanged, so re-running this script never rotates secrets under existing
|
||||||
# data.
|
# data.
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
# Compute all values into variables first, then write them SINGLE-QUOTED.
|
||||||
|
# The GUI password hash has the form salt$digest; if written unquoted, the
|
||||||
|
# later `set -a; . "$ENV_FILE"` would shell-expand the "$digest" part and
|
||||||
|
# corrupt the hash, breaking GUI login. Single quotes make every value a
|
||||||
|
# literal string on sourcing.
|
||||||
GUI_PASSWORD=$(openssl rand -base64 12)
|
GUI_PASSWORD=$(openssl rand -base64 12)
|
||||||
|
POSTGRES_PASSWORD_VAL=$(openssl rand -hex 16)
|
||||||
|
FINANCE_READ_PASSWORD_VAL=$(openssl rand -hex 16)
|
||||||
|
FB_API_KEY_VAL=$(openssl rand -hex 32)
|
||||||
|
FB_SESSION_SECRET_VAL=$(openssl rand -hex 32)
|
||||||
|
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$GUI_PASSWORD")
|
||||||
|
GRAFANA_ADMIN_PASSWORD_VAL=$(openssl rand -base64 12)
|
||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
POSTGRES_PASSWORD='$POSTGRES_PASSWORD_VAL'
|
||||||
FINANCE_READ_PASSWORD=$(openssl rand -hex 16)
|
FINANCE_READ_PASSWORD='$FINANCE_READ_PASSWORD_VAL'
|
||||||
FB_API_KEY=$(openssl rand -hex 32)
|
FB_API_KEY='$FB_API_KEY_VAL'
|
||||||
FB_SESSION_SECRET=$(openssl rand -hex 32)
|
FB_SESSION_SECRET='$FB_SESSION_SECRET_VAL'
|
||||||
FB_GUI_USER=admin
|
FB_GUI_USER='admin'
|
||||||
FB_GUI_PASSWORD_HASH=$(python3 -c "import hashlib,secrets;s=secrets.token_hex(16);print(s+'\$'+hashlib.pbkdf2_hmac('sha256',b'$GUI_PASSWORD',s.encode(),200000).hex())")
|
FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'
|
||||||
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 12)
|
GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD_VAL'
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
echo "NEU ERZEUGT: GUI-Login admin / $GUI_PASSWORD (jetzt notieren!)"
|
echo "NEU ERZEUGT: GUI-Login admin / $GUI_PASSWORD (jetzt notieren!)"
|
||||||
@@ -69,6 +80,16 @@ EOF
|
|||||||
fi
|
fi
|
||||||
set -a; . "$ENV_FILE"; set +a
|
set -a; . "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
# Sanity check: the GUI password hash must survive shell sourcing intact. An
|
||||||
|
# unquoted value containing a literal '$' gets mangled by parameter expansion,
|
||||||
|
# which would silently break GUI login. Require the canonical salt$digest form
|
||||||
|
# (32 hex chars, '$', 64 hex chars).
|
||||||
|
if ! printf '%s' "$FB_GUI_PASSWORD_HASH" | grep -Eq '^[0-9a-f]{32}\$[0-9a-f]{64}$'; then
|
||||||
|
echo "ERROR: FB_GUI_PASSWORD_HASH is malformed after sourcing $ENV_FILE." >&2
|
||||||
|
echo " All values in $ENV_FILE must be single-quoted." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Stop existing systemd-managed pod if present, to avoid conflicts on rerun
|
# Stop existing systemd-managed pod if present, to avoid conflicts on rerun
|
||||||
echo "Stopping systemd-managed pod 'pod-$POD_NAME.service' if it exists..."
|
echo "Stopping systemd-managed pod 'pod-$POD_NAME.service' if it exists..."
|
||||||
if systemctl --user list-units --type=service --all 2>/dev/null | \
|
if systemctl --user list-units --type=service --all 2>/dev/null | \
|
||||||
@@ -169,6 +190,24 @@ cd "$USER_SYSTEMD_DIR"
|
|||||||
podman generate systemd --name --new --files "$POD_NAME"
|
podman generate systemd --name --new --files "$POD_NAME"
|
||||||
echo "Generated systemd service files (rc=$?)"
|
echo "Generated systemd service files (rc=$?)"
|
||||||
|
|
||||||
|
# The --new units embed the full `podman run` commands, including container
|
||||||
|
# environment secrets (POSTGRES_PASSWORD, GF_SECURITY_ADMIN_PASSWORD,
|
||||||
|
# FB_GUI_PASSWORD_HASH, ...). Post-process each generated unit file:
|
||||||
|
# 1. chmod 600 so only the owner can read the embedded secrets.
|
||||||
|
# 2. Collapse '$$' -> '$' on Environment= lines. `podman generate systemd`
|
||||||
|
# escapes every '$' as '$$' (correct for ExecStart command lines, where
|
||||||
|
# systemd performs variable expansion). But systemd does NOT un-escape
|
||||||
|
# '$$' inside Environment= *values* - it passes them through literally.
|
||||||
|
# The GUI password hash has the form salt$digest, so without this fix the
|
||||||
|
# container receives salt$$digest and GUI login fails. Restrict the
|
||||||
|
# substitution to Environment= lines so ExecStart escaping stays intact.
|
||||||
|
for svc in "pod-${POD_NAME}.service" "container-${DB_CTR_NAME}.service" \
|
||||||
|
"container-${API_CTR_NAME}.service" "container-${GRAFANA_CTR_NAME}.service"; do
|
||||||
|
[ -f "$svc" ] || continue
|
||||||
|
chmod 600 "$svc"
|
||||||
|
sed -i '/^Environment=/ s/\$\$/$/g' "$svc"
|
||||||
|
done
|
||||||
|
|
||||||
# Stop & remove live pod and containers
|
# Stop & remove live pod and containers
|
||||||
podman pod stop --ignore --time 15 "$POD_NAME"
|
podman pod stop --ignore --time 15 "$POD_NAME"
|
||||||
podman pod rm -f --ignore "$POD_NAME"
|
podman pod rm -f --ignore "$POD_NAME"
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
# Per-account starting balance: the opening_balance of each account's earliest
|
||||||
|
# confirmed statement (if any). Used to seed the cumulative sums below so the
|
||||||
|
# Grafana balance charts show the real account balance, not just the running
|
||||||
|
# sum of imported transactions (which would start at zero and ignore the
|
||||||
|
# balance that existed before the first imported statement).
|
||||||
|
_ACCOUNT_BASE = """
|
||||||
|
SELECT DISTINCT ON (account_id) account_id, opening_balance
|
||||||
|
FROM statements
|
||||||
|
WHERE status = 'confirmed' AND opening_balance IS NOT NULL
|
||||||
|
ORDER BY account_id, period_start NULLS LAST, id
|
||||||
|
"""
|
||||||
|
|
||||||
VIEWS: dict[str, str] = {
|
VIEWS: dict[str, str] = {
|
||||||
"v_balance_history": """
|
"v_balance_history": f"""
|
||||||
SELECT t.booking_date AS day, a.name AS account,
|
SELECT t.booking_date AS day, a.name AS account,
|
||||||
SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
COALESCE(b.opening_balance, 0)
|
||||||
|
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
||||||
ORDER BY t.booking_date) AS balance
|
ORDER BY t.booking_date) AS balance
|
||||||
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
||||||
|
LEFT JOIN ({_ACCOUNT_BASE}) b ON b.account_id = a.id
|
||||||
WHERE t.status = 'confirmed'
|
WHERE t.status = 'confirmed'
|
||||||
GROUP BY a.id, a.name, t.booking_date""",
|
GROUP BY a.id, a.name, t.booking_date, b.opening_balance""",
|
||||||
"v_balance_total": """
|
"v_balance_total": f"""
|
||||||
SELECT booking_date AS day,
|
SELECT booking_date AS day,
|
||||||
SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
(SELECT COALESCE(SUM(opening_balance), 0)
|
||||||
|
FROM ({_ACCOUNT_BASE}) s)
|
||||||
|
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
||||||
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
||||||
"v_monthly_by_category": """
|
"v_monthly_by_category": """
|
||||||
SELECT date_trunc('month', t.booking_date) AS month,
|
SELECT date_trunc('month', t.booking_date) AS month,
|
||||||
@@ -30,4 +46,13 @@ def create_views(engine: Engine) -> None:
|
|||||||
return
|
return
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for name, body in VIEWS.items():
|
for name, body in VIEWS.items():
|
||||||
conn.execute(text(f"CREATE OR REPLACE VIEW {name} AS {body}"))
|
# CREATE OR REPLACE VIEW fails if the output column set changed
|
||||||
|
# (name/type/order). Fall back to DROP + CREATE in that case so a
|
||||||
|
# redeploy always applies the current definition.
|
||||||
|
try:
|
||||||
|
with conn.begin_nested():
|
||||||
|
conn.execute(
|
||||||
|
text(f"CREATE OR REPLACE VIEW {name} AS {body}"))
|
||||||
|
except Exception:
|
||||||
|
conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
|
||||||
|
conn.execute(text(f"CREATE VIEW {name} AS {body}"))
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ def confirm(statement_id: int, session: Session = Depends(get_session)):
|
|||||||
stmt = session.get(Statement, statement_id)
|
stmt = session.get(Statement, statement_id)
|
||||||
if stmt is None:
|
if stmt is None:
|
||||||
raise HTTPException(404, "Import nicht gefunden")
|
raise HTTPException(404, "Import nicht gefunden")
|
||||||
|
if stmt.status != "draft":
|
||||||
|
raise HTTPException(409, "Import ist nicht im Entwurfsstatus")
|
||||||
txs = session.execute(
|
txs = session.execute(
|
||||||
select(Transaction).where(
|
select(Transaction).where(
|
||||||
Transaction.statement_id == statement_id,
|
Transaction.statement_id == statement_id,
|
||||||
@@ -123,6 +125,19 @@ def confirm(statement_id: int, session: Session = Depends(get_session)):
|
|||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for tx in txs:
|
for tx in txs:
|
||||||
|
# Re-check against currently confirmed transactions: another import
|
||||||
|
# confirmed between preview and now may have introduced the same
|
||||||
|
# booking. Mark late duplicates and skip promoting them.
|
||||||
|
clash = session.execute(
|
||||||
|
select(Transaction).where(
|
||||||
|
Transaction.dedup_hash == tx.dedup_hash,
|
||||||
|
Transaction.status == "confirmed",
|
||||||
|
Transaction.id != tx.id,
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
if clash is not None:
|
||||||
|
tx.is_duplicate = True
|
||||||
|
continue
|
||||||
tx.status = "confirmed"
|
tx.status = "confirmed"
|
||||||
stmt.status = "confirmed"
|
stmt.status = "confirmed"
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -135,6 +150,8 @@ def delete_import(statement_id: int, session: Session = Depends(get_session)):
|
|||||||
stmt = session.get(Statement, statement_id)
|
stmt = session.get(Statement, statement_id)
|
||||||
if stmt is None:
|
if stmt is None:
|
||||||
raise HTTPException(404, "Import nicht gefunden")
|
raise HTTPException(404, "Import nicht gefunden")
|
||||||
|
if stmt.status == "confirmed":
|
||||||
|
raise HTTPException(409, "Bestätigter Import kann nicht gelöscht werden")
|
||||||
drafts = session.execute(
|
drafts = session.execute(
|
||||||
select(Transaction).where(
|
select(Transaction).where(
|
||||||
Transaction.statement_id == statement_id,
|
Transaction.statement_id == statement_id,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.engine.loans import loan_schedule
|
from app.engine.loans import loan_schedule
|
||||||
from app.models.tables import Category, Loan, PlannedItem, RecurringItem
|
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||||
|
ScenarioLoan)
|
||||||
from app.services.suggestions import suggest_recurring
|
from app.services.suggestions import suggest_recurring
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["planning"],
|
router = APIRouter(prefix="/api", tags=["planning"],
|
||||||
@@ -20,8 +22,8 @@ router = APIRouter(prefix="/api", tags=["planning"],
|
|||||||
class RecurringIn(BaseModel):
|
class RecurringIn(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
rhythm: str
|
rhythm: Literal["monthly", "quarterly", "yearly"]
|
||||||
due_day: int
|
due_day: int = Field(ge=1, le=31)
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
@@ -35,8 +37,8 @@ class RecurringOut(RecurringIn):
|
|||||||
class RecurringPatch(BaseModel):
|
class RecurringPatch(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
amount: Decimal | None = None
|
amount: Decimal | None = None
|
||||||
rhythm: str | None = None
|
rhythm: Literal["monthly", "quarterly", "yearly"] | None = None
|
||||||
due_day: int | None = None
|
due_day: int | None = Field(default=None, ge=1, le=31)
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
@@ -170,7 +172,7 @@ class LoanIn(BaseModel):
|
|||||||
annual_rate_pct: Decimal
|
annual_rate_pct: Decimal
|
||||||
term_months: int
|
term_months: int
|
||||||
payout_date: date
|
payout_date: date
|
||||||
repayment_type: str = "annuity"
|
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||||
|
|
||||||
|
|
||||||
class LoanOut(LoanIn):
|
class LoanOut(LoanIn):
|
||||||
@@ -184,7 +186,7 @@ class LoanPatch(BaseModel):
|
|||||||
annual_rate_pct: Decimal | None = None
|
annual_rate_pct: Decimal | None = None
|
||||||
term_months: int | None = None
|
term_months: int | None = None
|
||||||
payout_date: date | None = None
|
payout_date: date | None = None
|
||||||
repayment_type: str | None = None
|
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class InstallmentOut(BaseModel):
|
class InstallmentOut(BaseModel):
|
||||||
@@ -227,6 +229,10 @@ def delete_loan(loan_id: int, session: Session = Depends(get_session)):
|
|||||||
loan = session.get(Loan, loan_id)
|
loan = session.get(Loan, loan_id)
|
||||||
if loan is None:
|
if loan is None:
|
||||||
raise HTTPException(404, "Kredit nicht gefunden")
|
raise HTTPException(404, "Kredit nicht gefunden")
|
||||||
|
# Remove scenario assignments first: no DB-level cascade exists, so a
|
||||||
|
# dangling ScenarioLoan row would raise a foreign-key violation (500 on
|
||||||
|
# Postgres).
|
||||||
|
session.execute(delete(ScenarioLoan).where(ScenarioLoan.loan_id == loan_id))
|
||||||
session.delete(loan)
|
session.delete(loan)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.tables import (Loan, ProjectionPoint, Scenario, ScenarioLoan,
|
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||||
ScenarioModifier)
|
Scenario, ScenarioLoan, ScenarioModifier)
|
||||||
from app.services.projection_service import run_projection
|
from app.services.projection_service import run_projection
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||||
@@ -37,9 +38,9 @@ class ScenarioPatch(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ModifierIn(BaseModel):
|
class ModifierIn(BaseModel):
|
||||||
target_type: str
|
target_type: Literal["category", "recurring"]
|
||||||
target_id: int
|
target_id: int
|
||||||
kind: str
|
kind: Literal["percent", "absolute", "remove"]
|
||||||
value: Decimal = Decimal("0")
|
value: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +91,13 @@ def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
|
|||||||
def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
scenario = _get_scenario(session, scenario_id)
|
scenario = _get_scenario(session, scenario_id)
|
||||||
for key, value in data.model_dump(exclude_unset=True).items():
|
fields = data.model_dump(exclude_unset=True)
|
||||||
|
if "name" in fields and fields["name"] != scenario.name:
|
||||||
|
clash = session.execute(
|
||||||
|
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
||||||
|
if clash is not None:
|
||||||
|
raise HTTPException(409, "Szenario existiert bereits")
|
||||||
|
for key, value in fields.items():
|
||||||
setattr(scenario, key, value)
|
setattr(scenario, key, value)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(scenario)
|
session.refresh(scenario)
|
||||||
@@ -100,6 +107,16 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
|||||||
@router.delete("/{scenario_id}", status_code=204)
|
@router.delete("/{scenario_id}", status_code=204)
|
||||||
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
||||||
scenario = _get_scenario(session, scenario_id)
|
scenario = _get_scenario(session, scenario_id)
|
||||||
|
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
||||||
|
# foreign-key violations (would surface as a 500 on Postgres).
|
||||||
|
session.execute(delete(ProjectionPoint).where(
|
||||||
|
ProjectionPoint.scenario_id == scenario_id))
|
||||||
|
session.execute(delete(ProjectionResult).where(
|
||||||
|
ProjectionResult.scenario_id == scenario_id))
|
||||||
|
session.execute(delete(ScenarioLoan).where(
|
||||||
|
ScenarioLoan.scenario_id == scenario_id))
|
||||||
|
session.execute(delete(ScenarioModifier).where(
|
||||||
|
ScenarioModifier.scenario_id == scenario_id))
|
||||||
session.delete(scenario)
|
session.delete(scenario)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.tables import Category, Transaction
|
from app.models.tables import Account, Category, Transaction
|
||||||
from app.parsers.validate import dedup_hash
|
from app.parsers.validate import dedup_hash
|
||||||
from app.services.categorize import apply_rules
|
from app.services.categorize import apply_rules
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ def list_transactions(
|
|||||||
|
|
||||||
@router.post("", response_model=TransactionOut, status_code=201)
|
@router.post("", response_model=TransactionOut, status_code=201)
|
||||||
def create_transaction(data: TransactionIn, session: Session = Depends(get_session)):
|
def create_transaction(data: TransactionIn, session: Session = Depends(get_session)):
|
||||||
|
if session.get(Account, data.account_id) is None:
|
||||||
|
raise HTTPException(404, "Konto nicht gefunden")
|
||||||
h = dedup_hash(data.account_id, data.booking_date, data.amount, data.purpose)
|
h = dedup_hash(data.account_id, data.booking_date, data.amount, data.purpose)
|
||||||
existing = session.execute(
|
existing = session.execute(
|
||||||
select(Transaction).where(Transaction.dedup_hash == h)).scalar()
|
select(Transaction).where(Transaction.dedup_hash == h)).scalar()
|
||||||
|
|||||||
@@ -21,5 +21,10 @@
|
|||||||
<main>
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
document.body.addEventListener('htmx:responseError', function (e) {
|
||||||
|
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
@@ -14,6 +14,16 @@ def db():
|
|||||||
"sqlite://", poolclass=StaticPool,
|
"sqlite://", poolclass=StaticPool,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SQLite does not enforce foreign keys unless told to per-connection. Turn
|
||||||
|
# enforcement on so the tests catch FK violations (missing cascades) the
|
||||||
|
# same way Postgres would in production.
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _fk_pragma(dbapi_conn, _):
|
||||||
|
cur = dbapi_conn.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
93
finance/tests/test_final_review.py
Normal file
93
finance/tests/test_final_review.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests fuer die Final-Review-Findings F2, F3, F6."""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models.tables import (ProjectionPoint, ProjectionResult, Scenario,
|
||||||
|
Statement, Transaction)
|
||||||
|
|
||||||
|
H = {"Authorization": "Bearer test-key"}
|
||||||
|
|
||||||
|
|
||||||
|
def _account(client, iban="DE01"):
|
||||||
|
return client.post("/api/accounts", headers=H, json={
|
||||||
|
"bank": "DKB", "iban": iban, "name": "G", "type": "giro"}).json()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- F2 cascades
|
||||||
|
def test_delete_projected_scenario_removes_dependent_rows(client, db):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
|
||||||
|
sid = sc["id"]
|
||||||
|
r = client.post(f"/api/scenarios/{sid}/project", headers=H,
|
||||||
|
params={"horizon_days": 10, "start_date": "2026-07-15"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert db.query(ProjectionPoint).filter_by(scenario_id=sid).count() > 0
|
||||||
|
assert db.get(ProjectionResult, sid) is not None
|
||||||
|
|
||||||
|
assert client.delete(f"/api/scenarios/{sid}", headers=H).status_code == 204
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Scenario, sid) is None
|
||||||
|
assert db.query(ProjectionPoint).filter_by(scenario_id=sid).count() == 0
|
||||||
|
assert db.get(ProjectionResult, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_loan_assigned_to_scenario(client):
|
||||||
|
loan = client.post("/api/loans", headers=H, json={
|
||||||
|
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
|
||||||
|
"term_months": 48, "payout_date": "2026-07-20"}).json()
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
|
||||||
|
assert client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}",
|
||||||
|
headers=H).status_code == 204
|
||||||
|
assert client.delete(f"/api/loans/{loan['id']}", headers=H).status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_confirmed_import_returns_409(client, db):
|
||||||
|
acc = _account(client)
|
||||||
|
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc["id"],
|
||||||
|
status="confirmed"))
|
||||||
|
db.commit()
|
||||||
|
sid = db.query(Statement).one().id
|
||||||
|
assert client.delete(f"/api/imports/{sid}", headers=H).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- F3 validation
|
||||||
|
def test_recurring_invalid_rhythm_422(client):
|
||||||
|
r = client.post("/api/recurring", headers=H, json={
|
||||||
|
"name": "X", "amount": "-1.00", "rhythm": "weekly", "due_day": 1})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_recurring_due_day_zero_422(client):
|
||||||
|
r = client.post("/api/recurring", headers=H, json={
|
||||||
|
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 0})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_loan_invalid_repayment_type_422(client):
|
||||||
|
r = client.post("/api/loans", headers=H, json={
|
||||||
|
"name": "K", "principal": "1000.00", "annual_rate_pct": "3.0",
|
||||||
|
"term_months": 12, "payout_date": "2026-08-01",
|
||||||
|
"repayment_type": "balloon"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifier_invalid_kind_422(client):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "S"}).json()
|
||||||
|
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||||
|
"target_type": "category", "target_id": 1, "kind": "double",
|
||||||
|
"value": "10"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- F6 409/404 statt 500
|
||||||
|
def test_patch_scenario_rename_clash_409(client):
|
||||||
|
client.post("/api/scenarios", headers=H, json={"name": "A"})
|
||||||
|
b = client.post("/api/scenarios", headers=H, json={"name": "B"}).json()
|
||||||
|
r = client.patch(f"/api/scenarios/{b['id']}", headers=H, json={"name": "A"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_transaction_unknown_account_404(client):
|
||||||
|
r = client.post("/api/transactions", headers=H, json={
|
||||||
|
"account_id": 9999, "booking_date": "2026-07-01",
|
||||||
|
"amount": "-10.00", "purpose": "X", "counterparty": ""})
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -48,6 +48,26 @@ def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch):
|
|||||||
assert len(client.get("/api/transactions", headers=H).json()) == 1
|
assert len(client.get("/api/transactions", headers=H).json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_rechecks_duplicates_across_two_drafts(client, fake_parse, tmp_path, monkeypatch):
|
||||||
|
# Beide Auszuege werden importiert, bevor einer bestaetigt ist -> zum
|
||||||
|
# Importzeitpunkt ist keine Buchung ein Duplikat (dedup prueft nur gegen
|
||||||
|
# bestaetigte). Erst beim zweiten Confirm faellt auf, dass die Buchung nun
|
||||||
|
# bereits bestaetigt existiert. Der Re-Check muss sie ueberspringen, sonst
|
||||||
|
# wird dieselbe Buchung doppelt bestaetigt.
|
||||||
|
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||||
|
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
|
sid1 = _upload(client, "a1.pdf").json()["id"]
|
||||||
|
sid2 = _upload(client, "a2.pdf").json()["id"]
|
||||||
|
assert client.post(f"/api/imports/{sid1}/confirm", headers=H).status_code == 200
|
||||||
|
assert client.post(f"/api/imports/{sid2}/confirm", headers=H).status_code == 200
|
||||||
|
# Zweites Confirm darf die Buchung nicht erneut bestaetigen.
|
||||||
|
assert len(client.get("/api/transactions", headers=H).json()) == 1
|
||||||
|
# Zweites Confirm eines bereits bestaetigten Imports -> 409.
|
||||||
|
assert client.post(f"/api/imports/{sid1}/confirm", headers=H).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_upload_sanitizes_path_traversal_filename(client, fake_parse, tmp_path, monkeypatch):
|
def test_upload_sanitizes_path_traversal_filename(client, fake_parse, tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||||
|
|||||||
Reference in New Issue
Block a user