feat: Phase 4 – CSV-Import (Templates, Prüfansicht, Duplikat/Transfer-Erkennung, Undo)

Backend:
- Modelle + Migration 0005: import_templates, import_batches, categorization_rules;
  transactions um import_batch_id + import_hash erweitert.
- services/csv_import: Datei parsen (Trennzeichen, Dezimal-/Datumsformat, Kodierung,
  Header/Skip, Vorzeichen vs. Soll/Haben), Vorschau mit Duplikat-Erkennung (Hash),
  interner-Transfer-Erkennung via IBAN, lernender Auto-Kategorisierung; Buchen mit
  Batch und Undo.
- routers/imports: Templates (CRUD), Roh-Vorschau, Prüf-Vorschau, Buchen, Verlauf,
  Rückgängig. pytest: Parsing, Duplikate, Transfer, Buchen, Undo, Lernen.

Frontend:
- Import-Seite: Konto+Datei, Format/Spaltenzuordnung (mit Roh-Vorschau),
  editierbare Prüfansicht (Kategorie/Typ/Zielkonto, Duplikate markiert),
  Buchen, Import-Verlauf mit Undo; Vorlagen speichern; i18n DE/EN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 20:06:50 +02:00
co-authored by Claude Opus 4.8
parent a31ba0c785
commit 1381967aae
14 changed files with 1366 additions and 2 deletions
+2
View File
@@ -24,6 +24,7 @@ from .routers import (
budgets,
categories,
dashboard,
imports,
rates,
settings as settings_router,
transactions,
@@ -103,6 +104,7 @@ api.include_router(categories.router)
api.include_router(transactions.router)
api.include_router(budgets.router)
api.include_router(dashboard.router)
api.include_router(imports.router)
api.include_router(rates.router)
app.include_router(api)
+92
View File
@@ -84,6 +84,23 @@ class BudgetTreatment(str, enum.Enum):
neutral = "neutral"
class AmountMode(str, enum.Enum):
"""Wie der Betrag in der CSV steht."""
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
class MatchField(str, enum.Enum):
payee = "payee"
purpose = "purpose"
class ImportStatus(str, enum.Enum):
committed = "committed"
undone = "undone"
class User(Base):
__tablename__ = "users"
@@ -273,6 +290,12 @@ class Transaction(Base):
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
import_batch_id: Mapped[int | None] = mapped_column(
ForeignKey("import_batches.id", ondelete="SET NULL"), nullable=True, index=True
)
import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -394,3 +417,72 @@ class DashboardLayout(Base):
# JSON-Array: [{i, type, x, y, w, h, props}]
layout: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
# ---------------------------------------------------------------------------
# Phase 4: CSV-Import (Templates, Batches für Undo, lernende Kategorie-Regeln)
# ---------------------------------------------------------------------------
class ImportTemplate(Base):
__tablename__ = "import_templates"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
delimiter: Mapped[str] = mapped_column(String(2), nullable=False, default=";")
decimal_sep: Mapped[str] = mapped_column(String(1), nullable=False, default=",")
date_format: Mapped[str] = mapped_column(String(32), nullable=False, default="%d.%m.%Y")
encoding: Mapped[str] = mapped_column(String(16), nullable=False, default="utf-8")
has_header: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
skip_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
amount_mode: Mapped[AmountMode] = mapped_column(_enum(AmountMode), nullable=False, default=AmountMode.signed)
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ImportBatch(Base):
__tablename__ = "import_batches"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
template_id: Mapped[int | None] = mapped_column(
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
)
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[ImportStatus] = mapped_column(_enum(ImportStatus), nullable=False, default=ImportStatus.committed)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class CategorizationRule(Base):
"""Lernende Regel: Empfänger/Zweck enthält Muster -> Kategorie (oder Transfer)."""
__tablename__ = "categorization_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
match_field: Mapped[MatchField] = mapped_column(_enum(MatchField), nullable=False, default=MatchField.payee)
pattern: Mapped[str] = mapped_column(String(255), nullable=False) # normalisiert (lowercase)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=True
)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"),
)
+146
View File
@@ -0,0 +1,146 @@
"""CSV-Import: Templates verwalten, Datei-Vorschau, verbuchen, rückgängig machen."""
import json
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, AmountMode, ImportBatch, ImportTemplate, User
from ..schemas import (
ImportBatchOut,
ImportCommitIn,
ImportTemplateCreate,
ImportTemplateOut,
ImportTemplateUpdate,
)
from ..services import csv_import
router = APIRouter(prefix="/imports", tags=["imports"])
def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
return ImportTemplateOut(
id=tpl.id, name=tpl.name, delimiter=tpl.delimiter, decimal_sep=tpl.decimal_sep,
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
column_map=json.loads(tpl.column_map or "{}"),
)
# --- Templates -------------------------------------------------------------
@router.get("/templates", response_model=list[ImportTemplateOut])
def list_templates(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
rows = db.query(ImportTemplate).filter(ImportTemplate.owner_id == user.id).order_by(ImportTemplate.name).all()
return [_to_out(t) for t in rows]
@router.post("/templates", response_model=ImportTemplateOut, status_code=status.HTTP_201_CREATED)
def create_template(payload: ImportTemplateCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
tpl = ImportTemplate(
owner_id=user.id, name=payload.name.strip(), delimiter=payload.delimiter,
decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding,
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
column_map=json.dumps(payload.column_map),
)
db.add(tpl)
db.commit()
db.refresh(tpl)
return _to_out(tpl)
@router.patch("/templates/{template_id}", response_model=ImportTemplateOut)
def update_template(template_id: int, payload: ImportTemplateUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
tpl = get_owned(db, ImportTemplate, template_id, user)
data = payload.model_dump(exclude_unset=True)
if "column_map" in data and data["column_map"] is not None:
tpl.column_map = json.dumps(data.pop("column_map"))
for key, value in data.items():
if value is not None:
setattr(tpl, key, value)
db.commit()
db.refresh(tpl)
return _to_out(tpl)
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(template_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
tpl = get_owned(db, ImportTemplate, template_id, user)
db.delete(tpl)
db.commit()
# --- Datei-Vorschau --------------------------------------------------------
def _transient(user_id: int, **fields) -> ImportTemplate:
return ImportTemplate(owner_id=user_id, name="_transient", **fields)
@router.post("/raw")
async def raw_preview(
file: UploadFile = File(...),
delimiter: str = Form(";"),
encoding: str = Form("utf-8"),
has_header: bool = Form(True),
skip_rows: int = Form(0),
_: User = Depends(get_current_user),
) -> dict:
data = await file.read()
tpl = _transient(0, delimiter=delimiter, encoding=encoding, has_header=has_header, skip_rows=skip_rows)
return csv_import.preview_raw(data, tpl)
@router.post("/preview")
async def preview(
file: UploadFile = File(...),
account_id: int = Form(...),
delimiter: str = Form(";"),
decimal_sep: str = Form(","),
date_format: str = Form("%d.%m.%Y"),
encoding: str = Form("utf-8"),
has_header: bool = Form(True),
skip_rows: int = Form(0),
amount_mode: str = Form("signed"),
column_map: str = Form("{}"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
account = get_owned(db, Account, account_id, user)
data = await file.read()
tpl = _transient(
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
encoding=encoding, has_header=has_header, skip_rows=skip_rows,
amount_mode=AmountMode(amount_mode), column_map=column_map,
)
rows = csv_import.build_preview(db, user, account, tpl, data)
return {"rows": rows, "filename": file.filename or ""}
# --- Verbuchen / Undo ------------------------------------------------------
@router.post("/commit", response_model=ImportBatchOut)
def commit(payload: ImportCommitIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
account = get_owned(db, Account, payload.account_id, user)
return csv_import.commit_import(db, user, account, payload.template_id, payload.filename, payload.rows)
@router.get("/batches", response_model=list[ImportBatchOut])
def list_batches(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return (
db.query(ImportBatch)
.filter(ImportBatch.owner_id == user.id)
.order_by(ImportBatch.created_at.desc(), ImportBatch.id.desc())
.all()
)
@router.delete("/batches/{batch_id}")
def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
batch = get_owned(db, ImportBatch, batch_id, user)
deleted = csv_import.undo_batch(db, user, batch)
return {"deleted": deleted}
+62
View File
@@ -6,9 +6,11 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models import (
AccountType,
AmountMode,
BudgetTreatment,
CategoryKind,
IdentifierKind,
ImportStatus,
Role,
TxnType,
)
@@ -283,3 +285,63 @@ class PlannedIncomeOut(BaseModel):
target_account_id: int | None
planned_cents: int
position: int
# --- CSV-Import ------------------------------------------------------------
class ImportTemplateCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
delimiter: str = ";"
decimal_sep: str = ","
date_format: str = "%d.%m.%Y"
encoding: str = "utf-8"
has_header: bool = True
skip_rows: int = 0
amount_mode: AmountMode = AmountMode.signed
column_map: dict[str, int | None] = Field(default_factory=dict)
class ImportTemplateUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
delimiter: str | None = None
decimal_sep: str | None = None
date_format: str | None = None
encoding: str | None = None
has_header: bool | None = None
skip_rows: int | None = None
amount_mode: AmountMode | None = None
column_map: dict[str, int | None] | None = None
class ImportTemplateOut(BaseModel):
id: int
name: str
delimiter: str
decimal_sep: str
date_format: str
encoding: str
has_header: bool
skip_rows: int
amount_mode: AmountMode
column_map: dict[str, int | None]
class ImportBatchOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
template_id: int | None
filename: str
row_count: int
imported_count: int
status: ImportStatus
created_at: datetime
class ImportCommitIn(BaseModel):
account_id: int
template_id: int | None = None
filename: str = ""
rows: list[dict] = Field(default_factory=list)
+340
View File
@@ -0,0 +1,340 @@
"""CSV-Import: Datei parsen, Vorschau mit Erkennung (Duplikate, interne Transfers,
Auto-Kategorisierung), und die eigentliche Verbuchung inkl. Batch für Undo.
"""
import csv
import hashlib
import io
import json
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from sqlalchemy.orm import Session
from ..models import (
Account,
AccountIdentifier,
AmountMode,
BudgetTreatment,
CategorizationRule,
Category,
ImportBatch,
ImportStatus,
ImportTemplate,
MatchField,
SAVINGS_ACCOUNT_TYPES,
Transaction,
TxnType,
User,
)
from . import currency
# --- Kleine Parser-Helfer --------------------------------------------------
def normalize_text(s: str | None) -> str:
return (s or "").strip().lower()
def normalize_iban(s: str | None) -> str:
return "".join(ch for ch in (s or "").upper() if ch.isalnum())
def parse_amount(raw: str | None, decimal_sep: str) -> int | None:
"""Betragstext -> Cent (mit Vorzeichen). Erkennt Tausender-Trenner und Klammern."""
s = str(raw or "").strip()
if not s:
return None
neg = ("-" in s) or (s.startswith("(") and s.endswith(")"))
thousands = "." if decimal_sep == "," else ","
s2 = s.replace(thousands, "").replace(decimal_sep, ".")
digits = "".join(ch for ch in s2 if ch.isdigit() or ch == ".")
if digits in ("", "."):
return None
try:
val = Decimal(digits)
except InvalidOperation:
return None
cents = int((val * 100).to_integral_value(rounding=ROUND_HALF_UP))
return -cents if neg else cents
def parse_date(raw: str | None, fmt: str) -> date | None:
s = str(raw or "").strip()
if not s:
return None
try:
return datetime.strptime(s, fmt).date()
except ValueError:
return None
def decode_bytes(data: bytes, encoding: str) -> str:
for enc in (encoding, "utf-8-sig", "utf-8", "latin-1"):
try:
return data.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return data.decode("utf-8", errors="replace")
def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
text = decode_bytes(data, template.encoding)
delim = (template.delimiter or ";")[0]
reader = csv.reader(io.StringIO(text), delimiter=delim)
rows = [r for r in reader if any((c or "").strip() for c in r)]
start = template.skip_rows + (1 if template.has_header else 0)
return rows[start:]
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor)."""
text = decode_bytes(data, template.encoding)
delim = (template.delimiter or ";")[0]
reader = csv.reader(io.StringIO(text), delimiter=delim)
rows = [r for r in reader if any((c or "").strip() for c in r)]
header = None
body_start = template.skip_rows
if template.has_header and len(rows) > body_start:
header = rows[body_start]
body_start += 1
return {"header": header, "rows": rows[body_start: body_start + limit]}
def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str:
key = f"{account_id}|{d}|{amount_cents}|{normalize_text(payee)}"
return hashlib.sha256(key.encode("utf-8")).hexdigest()
# --- Vorschau --------------------------------------------------------------
def _apply_rules(rules: list[CategorizationRule], payee: str, purpose: str) -> int | None:
p = normalize_text(payee)
z = normalize_text(purpose)
for rule in rules: # bereits nach Priorität sortiert
hay = p if rule.match_field == MatchField.payee else z
if rule.pattern and rule.pattern in hay and rule.category_id:
return rule.category_id
return None
def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]:
cmap = json.loads(template.column_map or "{}")
def col(field: str) -> int | None:
idx = cmap.get(field)
return idx if isinstance(idx, int) and idx >= 0 else None
rows = read_rows(data, template)
# Identifikatoren aller Konten des Nutzers -> Konto-ID (für Transfer-Erkennung).
idents: dict[str, int] = {}
for value_norm, acc_id in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id)
.join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id)
):
idents[value_norm] = acc_id
rules = (
db.query(CategorizationRule)
.filter(CategorizationRule.owner_id == user.id)
.order_by(CategorizationRule.priority.desc(), CategorizationRule.hits.desc())
.all()
)
existing = {
h[0]
for h in db.query(Transaction.import_hash).filter(
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
)
}
seen_in_file: set[str] = set()
out: list[dict] = []
for i, r in enumerate(rows):
def cell(field: str) -> str:
c = col(field)
return r[c].strip() if c is not None and c < len(r) else ""
d = parse_date(cell("date"), template.date_format)
if template.amount_mode == AmountMode.debit_credit:
debit = parse_amount(cell("debit"), template.decimal_sep)
credit = parse_amount(cell("credit"), template.decimal_sep)
amount = (credit or 0) - (abs(debit) if debit else 0)
elif template.amount_mode == AmountMode.abs_expense:
a = parse_amount(cell("amount"), template.decimal_sep)
amount = -abs(a) if a is not None else None
else:
amount = parse_amount(cell("amount"), template.decimal_sep)
payee = cell("payee")
purpose = cell("purpose")
iban = normalize_iban(cell("counterparty_iban"))
valid = d is not None and amount is not None and amount != 0
ttype = "transfer" if False else ("expense" if (amount or 0) < 0 else "income")
category_id: int | None = None
to_account_id: int | None = None
other = idents.get(iban) if iban else None
if other is not None and other != account.id:
ttype = "transfer"
to_account_id = other
else:
category_id = _apply_rules(rules, payee, purpose)
h = import_hash(account.id, d, amount, payee)
is_dup = h in existing or h in seen_in_file
seen_in_file.add(h)
out.append({
"index": i,
"date": d.isoformat() if d else None,
"amount_cents": amount,
"payee": payee or None,
"purpose": purpose or None,
"counterparty_iban": iban or None,
"valid": valid,
"type": ttype,
"category_id": category_id,
"to_account_id": to_account_id,
"import_hash": h,
"is_duplicate": is_dup,
"include": valid and not is_dup,
})
return out
# --- Verbuchen -------------------------------------------------------------
def _make_transaction(db: Session, user: User, account: Account, accounts: dict[int, Account], row: dict, batch_id: int) -> Transaction | None:
d = date.fromisoformat(row["date"]) if row.get("date") else None
amount = row.get("amount_cents")
if d is None or not amount:
return None
ttype = row.get("type") or ("expense" if amount < 0 else "income")
payee = row.get("payee")
purpose = row.get("purpose")
notes = None
if ttype == "transfer":
other_id = row.get("to_account_id")
other = accounts.get(other_id) if other_id else None
if other is None:
ttype = "expense" if amount < 0 else "income" # Fallback ohne Zielkonto
if ttype == "transfer":
other = accounts[row["to_account_id"]]
mag = abs(amount)
if amount < 0:
from_acc, to_acc = account, other
from_amount = mag # in Kontowährung (bekannt aus dem Auszug)
else:
from_acc, to_acc = other, account
# from-Betrag in der Währung des anderen Kontos schätzen.
conv = currency.convert(db, mag, account.currency, from_acc.currency, d)
from_amount = conv if conv is not None else mag
to_amount = mag if amount < 0 else mag
# Konsistente to_amount-Berechnung:
if from_acc.currency == to_acc.currency:
to_amount = from_amount
else:
conv = currency.convert(db, from_amount, from_acc.currency, to_acc.currency, d)
to_amount = conv if conv is not None else from_amount
# Sparen/Depot-Ziel budgetieren (überschreibbar über row["budget_treatment"]).
treatment = row.get("budget_treatment")
budget_cat = row.get("budget_category_id")
txn = Transaction(
owner_id=user.id, type=TxnType.transfer, date=d,
account_id=from_acc.id, to_account_id=to_acc.id,
amount_cents=from_amount, to_amount_cents=to_amount,
amount_eur_cents=currency.compute_amount_eur_cents(db, from_amount, from_acc.currency, d),
budget_treatment=BudgetTreatment(treatment) if treatment in ("budgeted", "neutral") else None,
budget_category_id=budget_cat,
description=purpose, payee=payee, notes=notes,
import_batch_id=batch_id, import_hash=row.get("import_hash"),
)
return txn
mag = abs(amount)
txn = Transaction(
owner_id=user.id,
type=TxnType.expense if ttype == "expense" else TxnType.income,
date=d, account_id=account.id, amount_cents=mag,
amount_eur_cents=currency.compute_amount_eur_cents(db, mag, account.currency, d),
category_id=row.get("category_id"),
payee=payee, description=purpose, notes=notes,
import_batch_id=batch_id, import_hash=row.get("import_hash"),
)
return txn
def _learn_rule(db: Session, user: User, row: dict) -> None:
"""Merkt sich Empfänger -> Kategorie für künftige Importe."""
cat = row.get("category_id")
payee = row.get("payee")
if not cat or not payee or row.get("type") == "transfer":
return
pattern = normalize_text(payee)[:255]
if not pattern:
return
existing = (
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.payee,
CategorizationRule.pattern == pattern,
)
.first()
)
if existing is None:
db.add(CategorizationRule(
owner_id=user.id, match_field=MatchField.payee, pattern=pattern,
category_id=cat, priority=1, hits=1,
))
else:
existing.category_id = cat
existing.hits += 1
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch:
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
batch = ImportBatch(
owner_id=user.id, account_id=account.id, template_id=template_id,
filename=(filename or "")[:255], row_count=len(rows),
imported_count=0, status=ImportStatus.committed,
)
db.add(batch)
db.flush()
imported = 0
for row in rows:
if not row.get("include", True):
continue
txn = _make_transaction(db, user, account, accounts, row, batch.id)
if txn is None:
continue
db.add(txn)
_learn_rule(db, user, row)
imported += 1
batch.imported_count = imported
db.commit()
db.refresh(batch)
return batch
def undo_batch(db: Session, user: User, batch: ImportBatch) -> int:
n = (
db.query(Transaction)
.filter(Transaction.owner_id == user.id, Transaction.import_batch_id == batch.id)
.delete()
)
batch.status = ImportStatus.undone
db.commit()
return n
@@ -0,0 +1,80 @@
"""CSV-Import: Templates, Batches, Kategorisierungs-Regeln, Transaktions-Herkunft.
Revision ID: 0005
Revises: 0004
Create Date: 2026-08-19
"""
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"import_templates",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("delimiter", sa.String(length=2), nullable=False, server_default=";"),
sa.Column("decimal_sep", sa.String(length=1), nullable=False, server_default=","),
sa.Column("date_format", sa.String(length=32), nullable=False, server_default="%d.%m.%Y"),
sa.Column("encoding", sa.String(length=16), nullable=False, server_default="utf-8"),
sa.Column("has_header", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("skip_rows", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("amount_mode", sa.String(length=32), nullable=False, server_default="signed"),
sa.Column("column_map", sa.Text(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_import_templates_owner_id", "import_templates", ["owner_id"])
op.create_table(
"import_batches",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("template_id", sa.Integer(), sa.ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True),
sa.Column("filename", sa.String(length=255), nullable=False, server_default=""),
sa.Column("row_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("imported_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("status", sa.String(length=32), nullable=False, server_default="committed"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_import_batches_owner_id", "import_batches", ["owner_id"])
op.create_table(
"categorization_rules",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("match_field", sa.String(length=32), nullable=False, server_default="payee"),
sa.Column("pattern", sa.String(length=255), nullable=False),
sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="CASCADE"), nullable=True),
sa.Column("priority", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("hits", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("owner_id", "match_field", "pattern", name="uq_rule_owner_field_pattern"),
)
op.create_index("ix_categorization_rules_owner_id", "categorization_rules", ["owner_id"])
# Transaktions-Herkunft (für Undo + Duplikaterkennung).
op.add_column("transactions", sa.Column("import_batch_id", sa.Integer(), nullable=True))
op.add_column("transactions", sa.Column("import_hash", sa.String(length=64), nullable=True))
op.create_index("ix_transactions_import_batch_id", "transactions", ["import_batch_id"])
op.create_index("ix_transactions_import_hash", "transactions", ["import_hash"])
def downgrade() -> None:
op.drop_index("ix_transactions_import_hash", table_name="transactions")
op.drop_index("ix_transactions_import_batch_id", table_name="transactions")
with op.batch_alter_table("transactions") as batch:
batch.drop_column("import_hash")
batch.drop_column("import_batch_id")
op.drop_index("ix_categorization_rules_owner_id", table_name="categorization_rules")
op.drop_table("categorization_rules")
op.drop_index("ix_import_batches_owner_id", table_name="import_batches")
op.drop_table("import_batches")
op.drop_index("ix_import_templates_owner_id", table_name="import_templates")
op.drop_table("import_templates")
+103
View File
@@ -0,0 +1,103 @@
import io
import json
def _account(admin, **kw):
body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}
body.update(kw)
r = admin.post("/api/accounts", json=body)
assert r.status_code == 201, r.text
return r.json()
def _leb(admin):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == "Lebensmittel":
return c["id"]
raise AssertionError("Lebensmittel fehlt")
CSV = (
"Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n"
"15.03.2026;-50,00;REWE;Einkauf;\n"
"25.03.2026;3.000,00;Arbeitgeber;Gehalt;\n"
"15.03.2026;-50,00;REWE;Einkauf;\n" # Duplikat der ersten Zeile
"20.03.2026;-200,00;Sparen;Ueberweisung;CH9300762011623852957\n" # interner Transfer
)
COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3, "counterparty_iban": 4})
def _preview(admin, account_id):
return admin.post(
"/api/imports/preview",
files={"file": ("konto.csv", io.BytesIO(CSV.encode("utf-8")), "text/csv")},
data={
"account_id": str(account_id),
"delimiter": ";", "decimal_sep": ",", "date_format": "%d.%m.%Y",
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
"amount_mode": "signed", "column_map": COLMAP,
},
)
def test_preview_parsing_and_detection(admin):
giro = _account(admin, name="Giro Import")
# Zweites Konto mit passender IBAN für die Transfer-Erkennung.
spar = _account(admin, name="Spar", type="sparkonto", currency="EUR",
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
r = _preview(admin, giro["id"])
assert r.status_code == 200, r.text
rows = r.json()["rows"]
assert len(rows) == 4
# Zeile 0: Ausgabe 50 EUR
assert rows[0]["type"] == "expense"
assert rows[0]["amount_cents"] == -5000
assert rows[0]["is_duplicate"] is False
# Zeile 1: Einnahme 3000 (Tausenderpunkt korrekt)
assert rows[1]["type"] == "income"
assert rows[1]["amount_cents"] == 300000
# Zeile 2: Duplikat der Zeile 0
assert rows[2]["is_duplicate"] is True
assert rows[2]["include"] is False
# Zeile 3: interner Transfer (IBAN passt zu 'spar')
assert rows[3]["type"] == "transfer"
assert rows[3]["to_account_id"] == spar["id"]
def test_commit_and_undo_and_learning(admin):
giro = _account(admin, name="Giro C")
leb = _leb(admin)
rows = _preview(admin, giro["id"]).json()["rows"]
# Nutzer kategorisiert die erste (REWE) als Lebensmittel -> soll gelernt werden.
for row in rows:
if row["payee"] == "REWE" and not row["is_duplicate"]:
row["category_id"] = leb
r = admin.post("/api/imports/commit", json={
"account_id": giro["id"], "filename": "konto.csv", "rows": rows,
})
assert r.status_code == 200, r.text
batch = r.json()
# Duplikat wird übersprungen -> 3 gebucht (Ausgabe, Einnahme, Transfer).
assert batch["imported_count"] == 3
# Saldo: 0 + 3000 - 50 - 200(Transfer raus) = 2750,00
bal = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
assert bal == 275000, bal
# Regel gelernt: erneute Vorschau schlägt für REWE Lebensmittel vor.
rows2 = _preview(admin, giro["id"]).json()["rows"]
rewe = next(r for r in rows2 if r["payee"] == "REWE")
assert rewe["category_id"] == leb
# Undo entfernt die Buchungen dieses Batches wieder.
r = admin.delete(f"/api/imports/batches/{batch['id']}")
assert r.status_code == 200
assert r.json()["deleted"] == 3
bal2 = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
assert bal2 == 0, bal2