# Budget Persistence Templates

**Generated by**: `fin-infra scaffold budgets`

This directory contains templates for scaffolding budget persistence with SQLAlchemy models, Pydantic schemas, and repository pattern.

---

## Files Generated

| File | Purpose | Lines |
|------|---------|-------|
| `budget.py` | SQLAlchemy model with uniqueness constraints | ~200 |
| `budget_schemas.py` | Pydantic schemas (Base/Read/Create/Update) | ~100 |
| `budget_repository.py` | Async repository with CRUD + budget-specific queries | ~250 |
| `__init__.py` | Package marker with re-exports | ~10 |

---

## Template Variables

The following variables are substituted during scaffold generation:

### Core Variables (Always Present)

| Variable | Example Value | Used In | Description |
|----------|---------------|---------|-------------|
| `${Entity}` | `Budget` | All files | PascalCase entity name (class names) |
| `${entity}` | `budget` | All files | lowercase entity name (module names) |
| `${table_name}` | `budgets` | models.py | Plural table name for SQLAlchemy |

### Conditional Variables (Based on Flags)

| Variable | Flag | Example Value | Used In | Description |
|----------|------|---------------|---------|-------------|
| `${tenant_field}` | `--include-tenant` | `tenant_id: Mapped[str] = ...` | models.py, schemas.py | Multi-tenancy support |
| `${soft_delete_field}` | `--include-soft-delete` | `deleted_at: Mapped[Optional[datetime]] = ...` | models.py | Soft delete timestamp |
| `${tenant_arg}` | `--include-tenant` | `, tenant_field="tenant_id"` | models.py | Tenant field name for uniqueness |
| `${tenant_default}` | `--include-tenant` | `"tenant_id"` | models.py | Default tenant field name |
| `${tenant_field_create}` | `--include-tenant` | `tenant_id: str = Field(...)` | schemas.py | Tenant field in Create schema |
| `${tenant_field_update}` | `--include-tenant` | `tenant_id: Optional[str] = Field(...)` | schemas.py | Tenant field in Update schema |
| `${tenant_arg_type}` | `--include-tenant` | `, tenant_id: Optional[str] = None` | repository.py | Tenant arg in method signatures |
| `${tenant_arg_type_comma}` | `--include-tenant` | `, tenant_id: Optional[str] = None` | repository.py | Tenant arg with leading comma |
| `${tenant_arg_val}` | `--include-tenant` | `, tenant_id` | repository.py | Tenant value in method calls |
| `${tenant_doc}` | `--include-tenant` | `tenant_id: Tenant identifier` | repository.py | Tenant docstring |
| `${tenant_filter}` | `--include-tenant` | `stmt = stmt.where(Budget.tenant_id == tenant_id)` | repository.py | Tenant WHERE clause |
| `${soft_delete_filter}` | `--include-soft-delete` | `stmt = stmt.where(Budget.deleted_at.is_(None))` | repository.py | Soft delete WHERE clause |
| `${soft_delete_default}` | `--include-soft-delete` | `True` | repository.py | Default soft delete behavior |
| `${soft_delete_logic}` | `--include-soft-delete` | `budget.deleted_at = datetime.utcnow()` | repository.py | Soft delete implementation |

---

## Usage

### 1. Basic Scaffold (Single-tenant, No Soft Delete)

```bash
fin-infra scaffold budgets --dest-dir app/models/
```

**Generated files**:
```
app/models/
├── budget.py                # SQLAlchemy model
├── budget_schemas.py        # Pydantic schemas
├── budget_repository.py     # Repository pattern
└── __init__.py              # Re-exports
```

**Uniqueness constraint**: `(user_id, name)` case-insensitive

### 2. Multi-tenant with Soft Delete

```bash
fin-infra scaffold budgets \
  --dest-dir app/models/ \
  --include-tenant \
  --include-soft-delete
```

**Generated changes**:
- **models.py**: Adds `tenant_id` and `deleted_at` fields
- **schemas.py**: Adds `tenant_id` to Base/Create/Update
- **repository.py**: Filters by `tenant_id`, soft deletes set `deleted_at`
- **Uniqueness**: `(tenant_id, user_id, name)` case-insensitive

### 3. Custom Filenames

```bash
fin-infra scaffold budgets \
  --dest-dir app/models/ \
  --models-filename my_budget_model.py \
  --schemas-filename my_budget_api.py \
  --repository-filename my_budget_repo.py
```

### 4. Skip Repository (Models + Schemas Only)

```bash
fin-infra scaffold budgets \
  --dest-dir app/models/ \
  --no-repository
```

---

## Customization Guide

### After Generation: Common Customizations

#### 1. Add Custom Fields

**File**: `app/models/budget.py`

```python
# Add after existing fields in Budget class
class Budget(ModelBase):
    # ... existing fields ...
    
    # Custom field: budget notes
    notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    
    # Custom field: budget owner email
    owner_email: Mapped[Optional[str]] = mapped_column(String(255), index=True)
```

**Also update**:
- `budget_schemas.py`: Add fields to `BudgetBase`, `BudgetCreate`, `BudgetUpdate`
- Run migration: `python -m svc_infra.db revision -m "add budget notes and owner_email"`

#### 2. Add Custom Repository Methods

**File**: `app/models/budget_repository.py`

```python
class BudgetRepository:
    # ... existing methods ...
    
    async def get_by_owner_email(self, email: str) -> List[BudgetModel]:
        """Get budgets by owner email."""
        stmt = select(BudgetModel).where(BudgetModel.owner_email == email)
        stmt = stmt.where(BudgetModel.deleted_at.is_(None))  # if soft delete enabled
        result = await self.session.execute(stmt)
        return list(result.scalars().all())
    
    async def get_expired(self, user_id: str) -> List[BudgetModel]:
        """Get budgets that have ended."""
        now = datetime.utcnow()
        stmt = select(BudgetModel).where(
            and_(
                BudgetModel.user_id == user_id,
                BudgetModel.end_date < now,
            )
        )
        result = await self.session.execute(stmt)
        return list(result.scalars().all())
```

#### 3. Change Uniqueness Constraint

**File**: `app/models/budget.py`

```python
# Original (generated): unique on (user_id, name)
for _ix in make_unique_sql_indexes(
    Budget,
    unique_ci=["user_id", "name"]
):
    pass

# Custom: unique on (user_id, period, start_date)
for _ix in make_unique_sql_indexes(
    Budget,
    unique_ci=[("user_id", "period", "start_date")]
):
    pass
```

**Also update**:
- `create_budget_service()`: Change `unique_ci` parameter
- `messages` dict: Update 409 error message
- Run migration: Drop old index, create new index

#### 4. Add Validation Hooks

**File**: `app/models/budget.py`

```python
def validate_budget_dates(data: Dict[str, Any]) -> Dict[str, Any]:
    """Ensure end_date > start_date."""
    if "start_date" in data and "end_date" in data:
        if data["end_date"] <= data["start_date"]:
            from fastapi import HTTPException
            raise HTTPException(400, "end_date must be after start_date")
    return data

# Wire into service factory
service = create_budget_service(
    repo,
    extra_pre_create=validate_budget_dates,
    extra_pre_update=validate_budget_dates,
)
```

---

## Integration with svc-infra Migrations

### 1. Setup Database (First Time)

```bash
# From your project root
python -m svc_infra.db init --project-root .
python -m svc_infra.db revision -m "add budgets table"
python -m svc_infra.db upgrade head
```

### 2. Generate Migration After Schema Changes

```bash
python -m svc_infra.db revision --autogenerate -m "add budget notes field"
```

### 3. Apply Migrations

```bash
# Development
python -m svc_infra.db upgrade head

# Production (via svc-infra easy command)
python -m svc_infra.db setup-and-migrate --project-root /app
```

### 4. Rollback Migrations

```bash
python -m svc_infra.db downgrade -1
```

---

## FastAPI Integration Example

### Minimal API with Generated Models

**File**: `app/api/budgets.py`

```python
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from svc_infra.api.fastapi.dual.protected import user_router
from svc_infra.db.sql.repository import SqlRepository

from app.models.budget import Budget as BudgetModel, create_budget_service
from app.models.budget_schemas import BudgetRead, BudgetCreate, BudgetUpdate
from app.models.budget_repository import BudgetRepository

router = user_router(prefix="/budgets", tags=["Budgets"])

async def get_repo(session: AsyncSession = Depends(get_session)):
    """Dependency: Budget repository."""
    return BudgetRepository(session)

@router.post("/", response_model=BudgetRead, status_code=201)
async def create_budget(
    budget: BudgetCreate,
    repo: BudgetRepository = Depends(get_repo),
):
    """Create a new budget."""
    db_budget = await repo.create(budget.model_dump())
    return repo._to_pydantic(db_budget)

@router.get("/", response_model=list[BudgetRead])
async def list_budgets(
    user_id: str,
    type: str | None = None,
    period: str | None = None,
    active_only: bool = True,
    repo: BudgetRepository = Depends(get_repo),
):
    """List budgets with filters."""
    budgets = await repo.list(user_id, type, period, active_only)
    return [repo._to_pydantic(b) for b in budgets]

@router.get("/{budget_id}", response_model=BudgetRead)
async def get_budget(
    budget_id: UUID,
    repo: BudgetRepository = Depends(get_repo),
):
    """Get budget by ID."""
    budget = await repo.get(budget_id)
    if not budget:
        from fastapi import HTTPException
        raise HTTPException(404, "Budget not found")
    return repo._to_pydantic(budget)

@router.patch("/{budget_id}", response_model=BudgetRead)
async def update_budget(
    budget_id: UUID,
    updates: BudgetUpdate,
    repo: BudgetRepository = Depends(get_repo),
):
    """Update budget fields."""
    budget = await repo.update(budget_id, updates.model_dump(exclude_unset=True))
    return repo._to_pydantic(budget)

@router.delete("/{budget_id}", status_code=204)
async def delete_budget(
    budget_id: UUID,
    soft: bool = True,
    repo: BudgetRepository = Depends(get_repo),
):
    """Delete budget (soft or hard)."""
    await repo.delete(budget_id, soft=soft)
```

---

## Testing Generated Code

### Unit Tests for Repository

**File**: `tests/unit/test_budget_repository.py`

```python
import pytest
from datetime import datetime
from app.models.budget_repository import BudgetRepository

@pytest.mark.asyncio
async def test_create_budget(async_session):
    repo = BudgetRepository(async_session)
    budget_data = {
        "user_id": "user123",
        "name": "November 2025",
        "type": "personal",
        "period": "monthly",
        "categories": {"Groceries": 600.00},
        "start_date": datetime(2025, 11, 1),
        "end_date": datetime(2025, 11, 30),
    }
    budget = await repo.create(budget_data)
    assert budget.id is not None
    assert budget.name == "November 2025"

@pytest.mark.asyncio
async def test_list_budgets_active_only(async_session):
    repo = BudgetRepository(async_session)
    # Create active budget
    await repo.create({...})  # overlaps current date
    # Create expired budget
    await repo.create({...})  # end_date in past
    
    active = await repo.list("user123", active_only=True)
    assert len(active) == 1
```

### Integration Tests with FastAPI

**File**: `tests/integration/test_budget_api.py`

```python
from fastapi.testclient import TestClient

def test_create_budget(client: TestClient):
    response = client.post("/budgets/", json={
        "user_id": "user123",
        "name": "Test Budget",
        "type": "personal",
        "period": "monthly",
        "categories": {"Food": 500.00},
        "start_date": "2025-11-01T00:00:00",
        "end_date": "2025-11-30T23:59:59",
    })
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "Test Budget"
    assert "id" in data
```

---

## Troubleshooting

### Issue: ImportError - cannot import models

**Cause**: `__init__.py` not created or missing re-exports

**Fix**:
```python
# app/models/__init__.py
from .budget import Budget as BudgetModel
from .budget_schemas import BudgetRead, BudgetCreate, BudgetUpdate
from .budget_repository import BudgetRepository

__all__ = ["BudgetModel", "BudgetRead", "BudgetCreate", "BudgetUpdate", "BudgetRepository"]
```

### Issue: IntegrityError on duplicate budget

**Cause**: Uniqueness constraint violated (duplicate user_id + name)

**Fix**: Handle in API layer with 409 response or use `create_budget_service()` which auto-handles duplicates

### Issue: Migration autogenerate doesn't detect model

**Cause**: Model not imported in migrations/env.py

**Fix**:
```python
# migrations/env.py
from svc_infra.db.sql.base import ModelBase
from app.models.budget import Budget  # Import your model
```

### Issue: Soft delete not working

**Cause**: Generated code without `--include-soft-delete` flag

**Fix**: Re-run scaffold with `--include-soft-delete --overwrite` or manually add:
```python
# models.py
deleted_at: Mapped[Optional[datetime]] = mapped_column(
    DateTime(timezone=True), nullable=True, index=True
)

# repository.py (in all query methods)
stmt = stmt.where(Budget.deleted_at.is_(None))
```

---

## Advanced: Custom Template Modifications

If you need to modify the templates themselves (not recommended, but supported):

1. Copy template files from `fin_infra/budgets/templates/` to your project
2. Edit templates with your custom changes
3. Generate code using local templates:

```python
from fin_infra.utils.scaffold import render_template, write
from pathlib import Path

subs = {"Entity": "Budget", "table_name": "budgets", ...}
content = render_template("myapp.templates", "models.py.tmpl", subs)
write(Path("app/models/budget.py"), content, overwrite=True)
```

---

## See Also

- **svc-infra Database Migrations**: `svc-infra/examples/docs/DB_MIGRATE.md`
- **svc-infra Scaffold Patterns**: `svc-infra/src/svc_infra/db/sql/scaffold.py`
- **fin-infra Persistence Strategy**: `fin_infra/docs/presistence-strategy.md`
- **SQLAlchemy 2.0 Async**: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
- **FastAPI Dependency Injection**: https://fastapi.tiangolo.com/tutorial/dependencies/
