147 lines
4.9 KiB
Markdown
147 lines
4.9 KiB
Markdown
# finanz-cockpit Agent Instructions
|
||
|
||
## Setup Commands
|
||
|
||
```bash
|
||
# Install dependencies
|
||
uv sync
|
||
|
||
# Run migrations
|
||
alembic upgrade head
|
||
|
||
# Run all tests
|
||
pytest
|
||
|
||
# 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 (uses global `_engine` pattern)
|
||
- `models/`: Pydantic models + SQLAlchemy tables for b4w and comdirect imports
|
||
- `ingest/`: Data ingestion logic per source
|
||
- **`tests/`**: Tests with fixtures in `conftest.py`
|
||
- **`alembic/versions/`**: Database migrations
|
||
|
||
## Key Patterns & Gotchas
|
||
|
||
### Database Configuration
|
||
|
||
```python
|
||
from src.db import get_engine, get_session, set_engine
|
||
|
||
# Development: use sqlite://
|
||
os.environ["DATABASE_URL"] = "sqlite://"
|
||
|
||
# Production/default: sqlite:///finanz.db
|
||
engine = get_engine() # reads DATABASE_URL or uses default
|
||
with get_session() as session:
|
||
session.query(...).all()
|
||
```
|
||
|
||
**Critical**: Tests override engine via `conftest.py`. Never assume global state - always use `get_session()` context manager.
|
||
|
||
### CSV Format Expectations
|
||
|
||
- **b4w**: German bank export (CSV) from `/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv`
|
||
- **comdirect**: German bank export (CSV) with fields like `Buchungstag`, `Umsatz in EUR`, `Vorgang`
|
||
|
||
### Testing Workflow
|
||
|
||
```bash
|
||
# Tests automatically run migrations via conftest fixture
|
||
pytest tests/ -v --tb=short
|
||
|
||
# To test without migrations (in-memory SQLite), use DATABASE_URL="sqlite://"
|
||
export DATABASE_URL="sqlite://" pytest tests/test_db.py
|
||
```
|
||
|
||
### Environment Variables
|
||
|
||
- `DATABASE_URL`: Database connection string (default: `sqlite:///finanz.db`)
|
||
|
||
## Data Models
|
||
|
||
Two sources with shared patterns:
|
||
|
||
- **b4w**: `B4wExport`, `Category`, transactional import to `import_b4w` table + `category` table
|
||
- **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", "--"
|
||
- `_parse_decimal_german()`: Converts "1,23" to Decimal
|
||
|
||
## Factory Pattern for Testing
|
||
|
||
Import from `tests/factories.py`:
|
||
|
||
```python
|
||
from tests.factories import B4wExportFactory, ComdirectGiroFactory
|
||
from src.constants import Currency
|
||
|
||
B4wExportFactory.__faker__ = Faker("de_DE")
|
||
ComdirectGiroFactory.__faker__ = Faker("de_DE")
|
||
|
||
# Use in tests:
|
||
item = B4wExportFactory.build( # build() for partial, create() for full
|
||
Amt="50.00",
|
||
RmtInf="Supermarket",
|
||
)
|
||
```
|
||
|
||
## Common Mistakes to Avoid
|
||
|
||
1. **Assuming global engine state**: Always use `get_session()` context manager
|
||
2. **Ignoring German date formats**: Use `_parse_date_german()` for CSV parsing
|
||
3. **Forgetting migration setup in tests**: Tests auto-migrate, but integration tests with real db need explicit upgrade
|
||
4. **Wrong Faker locale**: Use `Faker("de_DE")` for realistic names/IBANs
|
||
|
||
## Testing Tips
|
||
|
||
- Run single test: `pytest tests/test_model_b4wexport.py -v`
|
||
- View migrations: `ls alembic/versions/`
|
||
- Test data location: `/scripts/dummy_data/`
|
||
|
||
### Type Checking (mypy)
|
||
|
||
```bash
|
||
# Static type checking with mypy
|
||
uv run mypy src/
|
||
|
||
# With test coverage
|
||
uv run pytest --cov=src --cov-report=term-missing
|
||
```
|
||
|
||
**Note**: Some comparisons between `date | None` types require explicit guards or type assertions to satisfy mypy, even when the logic is correct. See `src/domain_utils.py` for examples.
|