diff --git a/.coverage b/.coverage index 52b0b0a..d22ad80 100644 Binary files a/.coverage and b/.coverage differ diff --git a/AGENTS.md b/AGENTS.md index 9459b04..c20a43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,32 @@ Two sources with shared patterns: - **comdirect**: `ComdirectGiro`, `ComdirectCreditCard`, `ComdirectDepot`, import to `comdirect_giro` table - **unified**: `Transaction`, `TransactionDb` – both ingest pipelines project their staging rows (`import_b4w`, `comdirect_giro`) into one `transactions` table via `src/ingest/transactions.py` (`map_*` mappers + `consolidate_transactions`). Dedup uses a business-key `hash_id` (source + IBAN + value date + amount + text + counterparty) with a UNIQUE constraint and SQLite `INSERT OR IGNORE`, so re-imports are idempotent. Amounts are canonical signed (negative = money out). Depot/credit-card/tagesgeld are not projected yet. +## Category Budget Management (NEW) + +Budget tracking for categories with time-bounded or unbounded periods: + +```python +from src.models.category import Category, CategoryBudget +from datetime import date + +cat = Category(name="Lebenshaltung") +budget = CategoryBudget( + category=cat, + amount_eur=150.00, + valid_from=date(2026, 10, 1), # Optional: None for unbounded start + valid_to=date(2026, 11, 30), # Optional: None for unbounded end +) + +# Set/replace budget - handles overlap automatically: +from src.domain_utils import set_category_budget +set_category_budget(db_session, budget) +``` + +The `set_category_budget()` function intelligently handles three scenarios: +1. **No overlap**: Adds new budget alongside existing ones +2. **Partial overlap**: Splits existing budget into non-overlapping parts + adds new budget +3. **Full overlap**: Replaces existing budget entirely + All models inherit from common base in `src/models/common.py`: - `_hash()`: SHA256-based deduplication key - `_parse_date_german()`: Handles "07.09.2026" format and values like "offen", "--" diff --git a/README.md b/README.md index e69de29..e00be29 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,180 @@ +# finanz-cockpit + +Financial tracking application for German bank exports (b4w, comdirect) with unified transaction processing and category-based budget management. + +## Features + +- **Dual-source ingestion**: Import transactions from b4w and comdirect CSV exports +- **Unified transaction table**: All transactions are consolidated into a single `transactions` table using business-key deduplication +- **Category hierarchy**: Support for nested category structures (e.g., `"Lebenshaltung:Essen:Supermarkt"`) +- **Budget tracking per category**: Set and manage budgets with time-bounded or unbounded validity periods +- **Automatic overlap handling**: Smart budget management handles partial/full overlaps automatically + +## Installation + +```bash +uv sync +alembic upgrade head +``` + +## Usage + +### Import b4w Export + +```python +from src.ingest.b4w import ingest_b4w +from pathlib import Path + +csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv") +ingest_b4w(csv_path) +``` + +### Import comdirect Export + +```python +from src.ingest.comdirect import ingest_comdirect +from pathlib import Path + +csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/comdirect_giro_01.csv") +ingest_comdirect(csv_path) +``` + +### Set Category Budgets + +```python +from src.models.category import Category, CategoryBudget +from datetime import date +from src.domain_utils import set_category_budget + +# Create category hierarchy +cat = Category.from_str("Lebenshaltung:Essen:Supermarkt") + +# Set budget for October-November 2026 +budget = CategoryBudget( + category=cat, + amount_eur=150.00, + valid_from=date(2026, 10, 1), + valid_to=date(2026, 11, 30), +) +set_category_budget(db_session, budget) + +# Budgets can also be unbounded (no dates specified) +budget = CategoryBudget( + category=cat, + amount_eur=200.00, # Monthly average +) +set_category_budget(db_session, budget) +``` + +## Testing + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=term-missing + +# Run a single test file +pytest tests/test_model_b4wexport.py -v +``` + +## Project Structure + +- **`src/`**: Application code + - `main.py`: CLI entrypoint for CSV ingestion + - `db.py`: Database connection management + - `models/`: Pydantic models + SQLAlchemy tables + - `ingest/`: Data ingestion logic per source (b4w, comdirect) + - `domain_utils.py`: Domain-level utilities (budget overlap handling) +- **`tests/`**: Tests with fixtures in `conftest.py` +- **`alembic/versions/`**: Database migrations + +## Data Models + +### Transaction Sources + +- **b4w**: `B4wExport`, `Category` +- **comdirect**: `ComdirectGiro`, `ComdirectCreditCard`, `ComdirectDepot` +- **unified**: `Transaction`, `TransactionDb` – consolidates all sources into one table + +All models inherit from common base in `src/models/common.py`: +- `hash_id()`: SHA256-based deduplication key +- `_parse_date_german()`: Handles "07.09.2026" format and values like "offen", "--" +- `_parse_decimal_german()`: Converts "1,23" to Decimal + +### Category Budgets + +Budget tracking for categories with time-bounded or unbounded periods: + +```python +from src.models.category import Category, CategoryBudget + +cat = Category(name="Lebenshaltung") +budget = CategoryBudget( + category=cat, + amount_eur=150.00, + valid_from=date(2026, 10, 1), # Optional: None for unbounded start + valid_to=date(2026, 11, 30), # Optional: None for unbounded end +) + +set_category_budget(db_session, budget) +``` + +The `set_category_budget()` function handles overlap scenarios automatically: +1. **No overlap**: Adds new budget alongside existing ones +2. **Partial overlap**: Splits existing budget into non-overlapping parts + adds new budget +3. **Full overlap**: Replaces existing budget entirely + +## Development + +### Setup + +```bash +# Install dependencies +uv sync + +# Run migrations +alembic upgrade head + +# Run all tests +pytest + +# Run with coverage report +pytest --cov=src --cov-report=html -c .coverage htmlcov +``` + +### Common Patterns + +**Database Session**: Always use `get_session()` context manager: + +```python +from src.db import get_session + +with get_session() as session: + # query operations here + session.commit() +``` + +**CSV Format**: +- **b4w**: German bank export (CSV) +- **comdirect**: CSV with fields like `Buchungstag`, `Umsatz in EUR`, `Vorgang` + +### Testing Workflow + +Tests automatically run migrations via `conftest.py`. For testing without migrations: + +```bash +export DATABASE_URL="sqlite://" pytest tests/test_db.py +``` + +## Common Mistakes + +1. **Global engine state**: Always use `get_session()` context manager +2. **German date formats**: Use `_parse_date_german()` for CSV parsing +3. **Migration setup**: Tests auto-migrate, but integration tests need explicit `alembic upgrade head` +4. **Faker locale**: Use `Faker("de_DE")` for realistic names/IBANs + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml index e23e2ba..0c551c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,20 @@ dev = [ "polyfactory>=3.3.0", "ruff>=0.16.7", ] + +[tool.pytest.ini_options] +addopts = "--cov=src --cov-report=term-missing" + +[tool.coverage.run] +branch = true +source = ["src"] + +[tool.coverage.report] +fail_under = 85 +skip_covered = true +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "^[a-zA-Z_]+\\s*=\\s*$" +] diff --git a/src/domain_utils.py b/src/domain_utils.py index 5b3096e..9d86750 100644 --- a/src/domain_utils.py +++ b/src/domain_utils.py @@ -6,6 +6,9 @@ from sqlalchemy.orm import Session from src.models.category import Category, CategoryBudget, CategoryBudgetDb, CategoryDb +ONE_DAY = timedelta(days=1) + + def find_category_db(session: Session, category: Category | None) -> CategoryDb | None: """Find a CategoryDb in the database given a Pydantic Category object. @@ -57,6 +60,7 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): new_start = category_budget.valid_from new_end = category_budget.valid_to + new_amount = category_budget.amount_eur # Helper to add a budget row def _add_row(amount_eur: Decimal, valid_from: date | None, valid_to: date | None): @@ -70,13 +74,7 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): # Helper to check if two date ranges overlap def _ranges_overlap(start1: date | None, end1: date | None, start2: date | None, end2: date | None) -> bool: - """Check if two date ranges overlap. - - Two bounded ranges overlap if start2 < end1 and start1 < end2. - An unbounded range (None, None) only overlaps with another fully unbounded range. - Partially unbounded ranges (e.g., valid_from=None but valid_to=date) - only overlap if the bounded ranges themselves would overlap. - """ + """Check if two date ranges overlap.""" # Both completely unbounded - always overlap if start1 is None and end1 is None and start2 is None and end2 is None: return True @@ -94,47 +92,19 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): start2: date | None, end2: date | None) -> tuple[date | None, date | None]: """Return the overlapping sub-range of two ranges.""" - # Handle completely unbounded cases + # Handle completely unbounded cases first if start1 is None and end1 is None: - # First range is completely unbounded - overlap is second range - return (start2, end2) if (start2 is not None or end2 is not None) else (None, None) - + return (start2, end2) if any(x is not None for x in (start2, end2)) else (None, None) if start2 is None and end2 is None: - # Second range is completely unbounded - overlap is first range - return (start1, end1) if (start1 is not None or end1 is not None) else (None, None) + return (start1, end1) if any(x is not None for x in (start1, end1)) else (None, None) - # Initialize with defaults - o_start = None - o_end = None - - # Handle one-sided unbounded cases - if start1 is not None and end1 is not None: - # Range 1 is bounded, range 2 may be unbounded - if start2 is None: # New starts at beginning, so overlap starts at existing_start - o_start = start1 - else: - o_start = max(start1, start2) - - if end2 is None: # New ends at end of time, so overlap ends at existing_end - o_end = end1 - elif o_start is not None: - o_end = min(end1, end2) - - if start2 is not None and end2 is not None: - # Range 2 is bounded, range 1 may be unbounded - if start1 is None: # Existing starts at beginning - o_start = start2 - elif o_start is None: - o_start = max(start1, start2) - - if end1 is None: # Existing ends at end of time - o_end = end2 - elif o_start is not None: - o_end = min(end1, end2) + # Both ranges are bounded or partially unbounded + o_start = max((s for s in (start1, start2) if s is not None), default=None) + o_end = min((e for e in (end1, end2) if e is not None), default=None) # Check for actual overlap if o_start is None or o_start > o_end: - return (None, None) # No overlap + return (None, None) return (o_start, o_end) @@ -163,7 +133,7 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): if overlap_start is None or overlap_end is None: # Full unbounded overlap - replace entirely with new budget - row = _add_row(category_budget.amount_eur, category_budget.valid_from, category_budget.valid_to) + row = _add_row(new_amount, new_start, new_end) session.add(row) return [row] @@ -172,28 +142,16 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): # Part 1: Before the overlap (if any) if budget.valid_from is not None and budget.valid_from < overlap_start: - row = _add_row( - amount_eur=budget.amount_eur, - valid_from=budget.valid_from, - valid_to=overlap_start - timedelta(days=1), - ) + row = _add_row(budget.amount_eur, budget.valid_from, overlap_start - ONE_DAY) parts.append(row) # Part 2: After the overlap (if any) if budget.valid_to is not None and overlap_end < budget.valid_to: - row = _add_row( - amount_eur=budget.amount_eur, - valid_from=overlap_end + timedelta(days=1), - valid_to=budget.valid_to, - ) + row = _add_row(budget.amount_eur, overlap_end + ONE_DAY, budget.valid_to) parts.append(row) # Add the new budget - row = _add_row( - amount_eur=category_budget.amount_eur, - valid_from=category_budget.valid_from, - valid_to=category_budget.valid_to, - ) + row = _add_row(new_amount, new_start, new_end) parts.append(row) # Add all parts to session @@ -220,9 +178,5 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): # Always add the new budget unless it was already added during overlap handling if not new_budget_added: - row = _add_row( - amount_eur=category_budget.amount_eur, - valid_from=category_budget.valid_from, - valid_to=category_budget.valid_to, - ) + row = _add_row(new_amount, new_start, new_end) session.add(row)