From 1381967aaeb536b4c62cef0c4c81429c12a17216 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 19 Aug 2026 20:06:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=93=20CSV-Import=20(T?= =?UTF-8?q?emplates,=20Pr=C3=BCfansicht,=20Duplikat/Transfer-Erkennung,=20?= =?UTF-8?q?Undo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/main.py | 2 + backend/app/models.py | 92 ++++ backend/app/routers/imports.py | 146 +++++++ backend/app/schemas.py | 62 +++ backend/app/services/csv_import.py | 340 +++++++++++++++ .../migrations/versions/0005_csv_import.py | 80 ++++ backend/tests/test_import.py | 103 +++++ docs/ROADMAP.md | 4 +- web/src/App.jsx | 3 + web/src/api.js | 11 + web/src/finance.css | 34 ++ web/src/locales/de.json | 47 +++ web/src/locales/en.json | 47 +++ web/src/pages/Import.jsx | 397 ++++++++++++++++++ 14 files changed, 1366 insertions(+), 2 deletions(-) create mode 100644 backend/app/routers/imports.py create mode 100644 backend/app/services/csv_import.py create mode 100644 backend/migrations/versions/0005_csv_import.py create mode 100644 backend/tests/test_import.py create mode 100644 web/src/pages/Import.jsx diff --git a/backend/app/main.py b/backend/app/main.py index da6f08c..44f8a9b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 3dcca5a..dbed401 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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"), + ) diff --git a/backend/app/routers/imports.py b/backend/app/routers/imports.py new file mode 100644 index 0000000..bd88306 --- /dev/null +++ b/backend/app/routers/imports.py @@ -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} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6b3614a..6145db5 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py new file mode 100644 index 0000000..3ca8d5e --- /dev/null +++ b/backend/app/services/csv_import.py @@ -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 diff --git a/backend/migrations/versions/0005_csv_import.py b/backend/migrations/versions/0005_csv_import.py new file mode 100644 index 0000000..e1509bf --- /dev/null +++ b/backend/migrations/versions/0005_csv_import.py @@ -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") diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py new file mode 100644 index 0000000..226888f --- /dev/null +++ b/backend/tests/test_import.py @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af26fad..d5f465a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,10 +23,10 @@ kein Rollover in v1. Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf, Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen. -## Phase 4 – CSV-Import +## Phase 4 – CSV-Import ✅ Bank-Templates (Trennzeichen/Dezimal/Datum/Spaltenmapping/Vorzeichen), lernende Auto-Kategorisierung, Prüfansicht, Duplikat-Erkennung, interne-Transfer-Erkennung -via IBAN. +via IBAN, Batch-Undo. Vorgefertigte Bank-Templates folgen aus Nutzer-Beispielen. ## Phase 5 – PDF/Bild-Import (Stretch) Lokal (pdfplumber/pypdf + Tesseract-OCR), speist dieselbe Prüfansicht. diff --git a/web/src/App.jsx b/web/src/App.jsx index 6b67602..999f26f 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -11,6 +11,7 @@ import Accounts from "./pages/Accounts"; import Categories from "./pages/Categories"; import Transactions from "./pages/Transactions"; import Budget from "./pages/Budget"; +import Import from "./pages/Import"; import Users from "./pages/Users"; import Settings from "./pages/Settings"; @@ -46,6 +47,7 @@ function Sidebar() { + {isAdmin && ( <> @@ -131,6 +133,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index c5ad9a2..50e3ba8 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -186,4 +186,15 @@ export const api = { cardBalances: () => request("/dashboard/balances"), cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`), cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`), + + // CSV-Import + importTemplates: () => request("/imports/templates"), + createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }), + updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }), + deleteImportTemplate: (id) => request(`/imports/templates/${id}`, { method: "DELETE" }), + importRaw: (formData) => request("/imports/raw", { method: "POST", formData }), + importPreview: (formData) => request("/imports/preview", { method: "POST", formData }), + importCommit: (body) => request("/imports/commit", { method: "POST", body }), + importBatches: () => request("/imports/batches"), + undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }), }; diff --git a/web/src/finance.css b/web/src/finance.css index a406b7d..6951189 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -526,3 +526,37 @@ tr.budget-total-row td { border-right-color: var(--border-strong); border-bottom-color: var(--border-strong); } + +/* ---------- CSV-Import ---------- */ +.table-scroll { + overflow-x: auto; +} +.raw-table { + font-size: 0.8rem; +} +.raw-table th, +.raw-table td { + white-space: nowrap; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} +.raw-table th { + color: var(--muted); + font-weight: 600; +} +.map-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: var(--sp-3); + margin-top: var(--sp-3); +} +.map-grid label { + margin-bottom: 0; +} +tr.row-invalid { + opacity: 0.45; +} +tr.row-dup { + opacity: 0.55; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 2352452..bf30644 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -195,5 +195,52 @@ "incomeLabel": "Bezeichnung", "incomeCategory": "Kategorie", "targetAccount": "Zielkonto" + }, + "import": { + "title": "CSV-Import", + "step1": "1 · Konto & Datei", + "step2": "2 · Format", + "step3": "3 · Spalten zuordnen", + "step4": "4 · Prüfen & buchen", + "account": "Zielkonto", + "file": "CSV-Datei", + "template": "Vorlage", + "templateManual": "Manuell einstellen", + "delimiter": "Trennzeichen", + "decimal": "Dezimaltrenner", + "dateFormat": "Datumsformat", + "encoding": "Kodierung", + "amountMode": "Betragsspalten", + "skipRows": "Zeilen überspringen", + "hasHeader": "Erste Zeile ist Kopfzeile", + "modeSigned": "Ein Betrag (mit Vorzeichen)", + "modeDebitCredit": "Getrennte Soll-/Haben-Spalten", + "modeAbsExpense": "Ein Betrag (immer Ausgabe)", + "readFile": "Datei einlesen", + "makePreview": "Vorschau erstellen", + "saveTemplate": "Als Vorlage speichern", + "templateName": "Vorlagenname", + "colDate": "Datum", + "colAmount": "Betrag", + "colDebit": "Soll (Ausgang)", + "colCredit": "Haben (Eingang)", + "colPayee": "Empfänger", + "colPurpose": "Verwendungszweck", + "colIban": "Gegen-IBAN", + "makePreviewHint": "Ordne mindestens Datum und Betrag zu.", + "commit": "{{n}} Buchungen übernehmen", + "imported": "{{n}} Buchungen importiert.", + "importedCount": "{{n}} gebucht", + "typeCol": "Typ", + "payeeCol": "Empfänger / Zweck", + "badDate": "Datum?", + "duplicate": "Duplikat", + "chooseAccount": "Konto wählen", + "history": "Import-Verlauf", + "undo": "Rückgängig", + "undoConfirm": "Alle Buchungen aus {{file}} wieder entfernen?", + "undone": "rückgängig gemacht", + "needFile": "Bitte zuerst eine CSV-Datei wählen.", + "needFileAccount": "Bitte Konto und Datei wählen." } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 583630f..1942b8a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -195,5 +195,52 @@ "incomeLabel": "Label", "incomeCategory": "Category", "targetAccount": "Target account" + }, + "import": { + "title": "CSV import", + "step1": "1 · Account & file", + "step2": "2 · Format", + "step3": "3 · Map columns", + "step4": "4 · Review & import", + "account": "Target account", + "file": "CSV file", + "template": "Template", + "templateManual": "Configure manually", + "delimiter": "Delimiter", + "decimal": "Decimal separator", + "dateFormat": "Date format", + "encoding": "Encoding", + "amountMode": "Amount columns", + "skipRows": "Skip rows", + "hasHeader": "First row is a header", + "modeSigned": "Single amount (signed)", + "modeDebitCredit": "Separate debit/credit columns", + "modeAbsExpense": "Single amount (always expense)", + "readFile": "Read file", + "makePreview": "Build preview", + "saveTemplate": "Save as template", + "templateName": "Template name", + "colDate": "Date", + "colAmount": "Amount", + "colDebit": "Debit (out)", + "colCredit": "Credit (in)", + "colPayee": "Payee", + "colPurpose": "Purpose", + "colIban": "Counterparty IBAN", + "makePreviewHint": "Map at least date and amount.", + "commit": "Import {{n}} transactions", + "imported": "Imported {{n}} transactions.", + "importedCount": "{{n}} imported", + "typeCol": "Type", + "payeeCol": "Payee / purpose", + "badDate": "Date?", + "duplicate": "Duplicate", + "chooseAccount": "Choose account", + "history": "Import history", + "undo": "Undo", + "undoConfirm": "Remove all transactions from {{file}}?", + "undone": "undone", + "needFile": "Please choose a CSV file first.", + "needFileAccount": "Please choose an account and a file." } } diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx new file mode 100644 index 0000000..d2433e1 --- /dev/null +++ b/web/src/pages/Import.jsx @@ -0,0 +1,397 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../api"; +import { useSettings } from "../settings"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; +import Icon from "../components/Icon"; +import { formatMoney } from "../format"; + +const DATE_FORMATS = [ + { v: "%d.%m.%Y", l: "TT.MM.JJJJ" }, + { v: "%Y-%m-%d", l: "JJJJ-MM-TT" }, + { v: "%d/%m/%Y", l: "TT/MM/JJJJ" }, + { v: "%m/%d/%Y", l: "MM/TT/JJJJ" }, + { v: "%d.%m.%y", l: "TT.MM.JJ" }, +]; + +function defaultConfig() { + return { + delimiter: ";", + decimal_sep: ",", + date_format: "%d.%m.%Y", + encoding: "utf-8", + has_header: true, + skip_rows: 0, + amount_mode: "signed", + column_map: {}, + }; +} + +export default function Import() { + const { t } = useTranslation(); + const { formatDate } = useSettings(); + const confirm = useConfirm(); + const toast = useToast(); + const fileRef = useRef(null); + + const [accounts, setAccounts] = useState([]); + const [groups, setGroups] = useState([]); + const [templates, setTemplates] = useState([]); + const [batches, setBatches] = useState([]); + + const [accountId, setAccountId] = useState(""); + const [file, setFile] = useState(null); + const [config, setConfig] = useState(defaultConfig()); + const [raw, setRaw] = useState(null); + const [rows, setRows] = useState(null); + const [busy, setBusy] = useState(false); + const [saveName, setSaveName] = useState(""); + + const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]); + + async function loadStamm() { + try { + const [a, g, tp, b] = await Promise.all([ + api.listAccounts(), api.listCategories(), api.importTemplates(), api.importBatches(), + ]); + setAccounts(a); setGroups(g); setTemplates(tp); setBatches(b); + if (a[0] && !accountId) setAccountId(String(a[0].id)); + } catch (e) { toast(e.message, "error"); } + } + useEffect(() => { loadStamm(); }, []); + + function setCfg(patch) { setConfig((c) => ({ ...c, ...patch })); } + function setCol(field, value) { + setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } })); + } + + function applyTemplate(id) { + const tpl = templates.find((x) => String(x.id) === String(id)); + if (!tpl) return; + setConfig({ + 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: tpl.column_map || {}, + }); + } + + function baseForm() { + const fd = new FormData(); + fd.append("file", file); + fd.append("delimiter", config.delimiter); + fd.append("encoding", config.encoding); + fd.append("has_header", config.has_header ? "true" : "false"); + fd.append("skip_rows", String(config.skip_rows || 0)); + return fd; + } + + async function loadRaw() { + if (!file) { toast(t("import.needFile"), "error"); return; } + setBusy(true); + try { + setRaw(await api.importRaw(baseForm())); + setRows(null); + } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } + } + + async function loadPreview() { + if (!file || !accountId) { toast(t("import.needFileAccount"), "error"); return; } + setBusy(true); + try { + const fd = baseForm(); + fd.append("account_id", accountId); + fd.append("decimal_sep", config.decimal_sep); + fd.append("date_format", config.date_format); + fd.append("amount_mode", config.amount_mode); + fd.append("column_map", JSON.stringify(config.column_map)); + const res = await api.importPreview(fd); + setRows(res.rows.map((r) => ({ ...r }))); + } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } + } + + async function commit() { + setBusy(true); + try { + const batch = await api.importCommit({ + account_id: Number(accountId), + template_id: null, + filename: file?.name || "", + rows, + }); + toast(t("import.imported", { n: batch.imported_count })); + setRows(null); setRaw(null); setFile(null); + if (fileRef.current) fileRef.current.value = ""; + loadStamm(); + } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } + } + + async function saveTemplate() { + if (!saveName.trim()) { toast(t("import.templateName"), "error"); return; } + try { + await api.createImportTemplate({ name: saveName.trim(), ...config }); + setSaveName(""); + setTemplates(await api.importTemplates()); + toast(t("settings.saved")); + } catch (e) { toast(e.message, "error"); } + } + + async function undo(b) { + if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return; + try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } + } + + const colOptions = useMemo(() => { + const n = raw ? Math.max(raw.header?.length || 0, ...(raw.rows || []).map((r) => r.length), 0) : 0; + return Array.from({ length: n }, (_, i) => ({ + i, label: `${i}${raw?.header?.[i] ? ` · ${raw.header[i]}` : ""}`, + })); + }, [raw]); + + const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]); + const incomeGroups = useMemo(() => groups.filter((g) => g.kind === "income"), [groups]); + + const includeCount = rows ? rows.filter((r) => r.include).length : 0; + + function updateRow(idx, patch) { + setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + } + + const acc = accountById[Number(accountId)]; + + return ( +
+

{t("import.title")}

+ + {/* Schritt 1: Konto + Datei */} +
+

{t("import.step1")}

+
+ + +
+ {templates.length > 0 && ( + + )} +
+ + {/* Schritt 2: Format */} +
+

{t("import.step2")}

+
+ + + +
+
+ + + + +
+ +
+ + {/* Schritt 3: Spaltenzuordnung */} + {raw && ( +
+

{t("import.step3")}

+
+ + + {colOptions.map((c) => )} + + + {raw.rows.map((r, ri) => ( + {colOptions.map((c) => )} + ))} + +
{c.label}
{r[c.i] ?? ""}
+
+ +
+ {[ + ["date", t("import.colDate"), true], + ...(config.amount_mode === "debit_credit" + ? [["debit", t("import.colDebit"), true], ["credit", t("import.colCredit"), true]] + : [["amount", t("import.colAmount"), true]]), + ["payee", t("import.colPayee"), false], + ["purpose", t("import.colPurpose"), false], + ["counterparty_iban", t("import.colIban"), false], + ].map(([field, label]) => ( + + ))} +
+ +
+ +
+ setSaveName(e.target.value)} /> + +
+
+
+ )} + + {/* Schritt 4: Prüfansicht */} + {rows && ( +
+
+

{t("import.step4")}

+ +
+
+ + + + + + + + + + + + + {rows.map((r, idx) => ( + + + + + + + + + ))} + +
{t("transactions.date")}{t("transactions.amount")}{t("import.typeCol")}{t("import.payeeCol")}{t("transactions.category")}
+ updateRow(idx, { include: e.target.checked })} style={{ width: "auto" }} /> + {r.date ? formatDate(r.date) : {t("import.badDate")}} + + {formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")} + + + + + {r.payee || r.purpose || ""} + {r.is_duplicate && {t("import.duplicate")}} + + {r.type === "transfer" ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* Verlauf */} + {batches.length > 0 && ( +
+

{t("import.history")}

+ + + {batches.map((b) => ( + + + + + + + ))} + +
{b.filename || "—"} {accountById[b.account_id]?.name}{formatDate(b.created_at)}{t("import.importedCount", { n: b.imported_count })} + {b.status === "committed" ? ( + + ) : {t("import.undone")}} +
+
+ )} +
+ ); +}