Category overhaul #20
@@ -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", "--"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*$"
|
||||
]
|
||||
|
||||
+17
-63
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user