# Story 1.5: App Factory, WAL Mode & Deployment Configuration

Status: review

## Story

As a user,
I want the Flask application running at localhost with WAL mode enabled and a proper deployment configuration,
So that the app can be started locally for development and deployed to Apache mod_wsgi for stable serving.

## Acceptance Criteria

1. `app/__init__.py` defines `create_app(config=None)` that loads config from `config.py`, initialises `db` and `migrate`, registers all blueprint stubs, and returns the app.
2. `app/extensions.py` registers a `@event.listens_for(engine, "connect")` listener executing `PRAGMA journal_mode=WAL` on every new connection.
3. `app/utils/date_utils.py` wraps `relativedelta` for month-end-safe arithmetic with documented conventions; achieves 100% branch coverage in tests.
4. `app/utils/validators.py` contains `validate_amount(value) -> Decimal` raising `ValueError` on invalid input; achieves 100% branch coverage in tests.
5. `config.py` defines `DevelopmentConfig` and `ProductionConfig`; DB path driven by `DATABASE_URL` environment variable.
6. `wsgi.py` exposes `application = create_app()`.
7. `flask run` starts successfully and `GET /` returns HTTP 200.
8. `pytest tests/` passes with all Stories 1.2–1.4 tests still green (10 passing + 1 skipped).

## Tasks / Subtasks

- [x] **Task 1: Implement `app/utils/date_utils.py`** (AC: 3)
  - [x] Implement `add_months(dt: date, months: int) -> date` — month-end-safe addition using `relativedelta`
  - [x] Implement `month_start(dt: date) -> date` — first day of the month
  - [x] Implement `month_end(dt: date) -> date` — last day of the month using `relativedelta(day=31)`
  - [x] All functions are pure (no Flask/ORM imports) — stdlib + dateutil only
  - [x] Document month-end convention in module docstring: "Jan 31 + 1 month = Feb 28 (not Mar 3)"

- [x] **Task 2: Write tests for `date_utils.py` — TDD green** (AC: 3)
  - [x] Create `tests/test_utils/test_date_utils.py`
  - [x] Test `add_months`: normal case, month-end rollover (Jan 31 → Feb 28/29), leap year, negative months
  - [x] Test `month_start`: mid-month → first of same month
  - [x] Test `month_end`: Feb (28/29), month with 31 days, month with 30 days
  - [x] Run coverage: `pytest tests/test_utils/test_date_utils.py --cov=app.utils.date_utils --cov-report=term-missing` → 100% branch coverage

- [x] **Task 3: Implement `app/utils/validators.py`** (AC: 4)
  - [x] Implement `validate_amount(value) -> Decimal`:
    - Accepts `str`, `int`, `float`, `Decimal`
    - Strips whitespace, leading `$` and commas
    - Raises `ValueError("Amount must be a positive number")` for non-numeric, zero, or negative
    - Returns `Decimal` rounded to 2 decimal places
  - [x] Pure — no Flask/ORM imports

- [x] **Task 4: Write tests for `validators.py` — TDD green** (AC: 4)
  - [x] Create `tests/test_utils/test_validators.py`
  - [x] Test valid cases: `"29.99"` → `Decimal('29.99')`, `"$1,234.56"` → `Decimal('1234.56')`, int `5` → `Decimal('5.00')`, float `3.14` → `Decimal('3.14')`, already-Decimal passthrough
  - [x] Test invalid cases: `""` empty string, `"abc"` non-numeric, `"0"` zero, `"-5"` negative, `None` type error handled
  - [x] Run coverage: `pytest tests/test_utils/test_validators.py --cov=app.utils.validators --cov-report=term-missing` → 100% branch coverage

- [x] **Task 5: Update `config.py`** (AC: 5)
  - [x] `DevelopmentConfig`: `DEBUG=True`, `SQLALCHEMY_DATABASE_URI` from `DATABASE_URL` env var (fallback: `sqlite:///instance/financials.db`), `WTF_CSRF_ENABLED=True`
  - [x] `ProductionConfig`: `DEBUG=False`, same `DATABASE_URL` pattern, `WTF_CSRF_ENABLED=True`
  - [x] Both inherit from `Config` base with `SECRET_KEY`, `SQLALCHEMY_TRACK_MODIFICATIONS=False`
  - [x] `config` dict: `{'development': DevelopmentConfig, 'production': ProductionConfig, 'default': DevelopmentConfig}`

- [x] **Task 6: Update `app/extensions.py` with WAL mode listener** (AC: 2)
  - [x] Add `from sqlalchemy import event` import
  - [x] Used `@event.listens_for(Engine, "connect")` class-level event (NOT `db.engine`) — fires on ALL SQLAlchemy Engine instances without requiring app context
  - [x] Verified WAL active: `PRAGMA journal_mode` returns `wal` on file-based SQLite connections

- [x] **Task 7: Update `app/__init__.py` with full factory** (AC: 1, 2, 7)
  - [x] Load config from `config.py` using `FLASK_ENV` or `APP_ENV` env var (default: `development`)
  - [x] Register all 9 blueprint stubs (dashboard, transactions, budgets, settings, bills, paydown, import_pdf, analytics, api)
  - [x] Register `@app.errorhandler(404)` and `@app.errorhandler(500)` returning simple text/JSON stubs (Story 2.1 adds full templates)
  - [x] Ensure `GET /` returns HTTP 200 via the dashboard blueprint stub

- [x] **Task 8: Update blueprint stubs to register correctly** (AC: 1, 7)
  - [x] Each blueprint `__init__.py` creates a `Blueprint` object
  - [x] Dashboard blueprint `routes.py` defines `GET /` returning HTTP 200 (plain text "Financials App")
  - [x] All other blueprint routes files are minimal stubs with no routes
  - [x] Blueprint URL prefixes: dashboard → `/`, transactions → `/transactions`, budgets → `/budgets`, settings → `/settings`, bills → `/bills`, paydown → `/paydown`, import_pdf → `/import`, analytics → `/analytics`, api → `/api`

- [x] **Task 9: Update `wsgi.py`** (AC: 6)
  - [x] Replaced stub with `from app import create_app` + `application = create_app()`

- [x] **Task 10: Verify `flask run` and full test suite** (AC: 7, 8)
  - [x] `GET /` returns HTTP 200 confirmed via Flask test client
  - [x] `pytest tests/ -v` → 37 passed, 1 skipped, zero failures
  - [x] `pytest tests/test_utils/ --cov=app.utils.date_utils --cov=app.utils.validators --cov-report=term-missing` → 100% coverage on both utils
  - [x] WAL mode verified: `PRAGMA journal_mode` returns `wal` on file-based SQLite (in-memory returns `memory` as expected)

## Dev Notes

### ⚠️ WAL Mode: The `db.engine` Module-Level Trap

The `@event.listens_for(db.engine, "connect")` pattern **cannot** be used at module level in `extensions.py` because `db.engine` raises a `RuntimeError: No application found` error outside an app context.

**Correct pattern for Flask-SQLAlchemy 3.x:**

Use `SQLALCHEMY_ENGINE_OPTIONS` in the app config with a custom `creator` function, OR register the event listener AFTER `db.init_app(app)` is called.

The cleanest approach is to add this to `app/__init__.py` right after `db.init_app(app)`:

```python
from sqlalchemy import event

@event.listens_for(db.engine, "connect")
def set_wal_mode(dbapi_conn, connection_record):
    dbapi_conn.execute("PRAGMA journal_mode=WAL")
```

BUT this has the same problem — `db.engine` requires an active app context.

**The actually-correct pattern** is to use `engine_connect` event registered on the underlying SQLAlchemy engine, which Flask-SQLAlchemy 3.x exposes via `db.get_engine()` within an app context. But the cleanest approach for this project is:

```python
# In app/extensions.py — register on db, not db.engine
from sqlalchemy import event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "connect")
def set_sqlite_wal_mode(dbapi_conn, connection_record):
    """Enable WAL mode on every SQLite connection."""
    dbapi_conn.execute("PRAGMA journal_mode=WAL")
```

This registers the listener on ALL SQLAlchemy `Engine` instances (not just one specific engine). Since this is a single-DB single-process app, this is correct. The listener fires on every new connection from any SQLAlchemy engine — exactly what the architecture requires.

**Alternative (also correct):** Use `SQLALCHEMY_ENGINE_OPTIONS`:
```python
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
    'connect_args': {'check_same_thread': False}
}
```
And register the WAL pragma via the `Engine` event above.

**Use the `Engine` class-level event listener in `extensions.py` — it requires no app context.**

### Exact `app/extensions.py` Implementation

```python
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from sqlalchemy import event
from sqlalchemy.engine import Engine


db = SQLAlchemy()
migrate = Migrate()


@event.listens_for(Engine, "connect")
def set_sqlite_wal_mode(dbapi_conn, connection_record):
    """
    Enable WAL mode on every SQLite connection.
    Architecture constraint: WAL mode is required for this app.
    Connection-per-request pattern; fires on every new connection.
    """
    dbapi_conn.execute("PRAGMA journal_mode=WAL")
```

### Exact `config.py` Implementation

```python
import os


class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    WTF_CSRF_ENABLED = True


class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        'DATABASE_URL',
        'sqlite:///instance/financials.db'
    )


class ProductionConfig(Config):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        'DATABASE_URL',
        'sqlite:///instance/financials.db'
    )


config = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig,
}
```

### Exact `app/__init__.py` Implementation

```python
import os
from flask import Flask
from .extensions import db, migrate


def create_app(config=None):
    """
    Application factory — complete implementation.
    Registers all blueprint stubs, WAL mode (via extensions.py Engine event),
    config classes, and error handlers.
    Story 2.1 adds full template-based error pages.
    """
    app = Flask(__name__)

    # Load config from config.py based on APP_ENV / FLASK_ENV
    env = os.environ.get('APP_ENV', os.environ.get('FLASK_ENV', 'development'))
    from config import config as config_map
    app.config.from_object(config_map.get(env, config_map['default']))

    # Test / override config (dict passed directly — used by conftest.py)
    if config:
        app.config.update(config)

    # Initialize extensions (WAL mode listener already registered in extensions.py)
    db.init_app(app)
    migrate.init_app(app, db)

    # Register ORM models with SQLAlchemy metadata so flask db migrate detects all tables.
    # Use __import__ to avoid rebinding the local 'app' variable (Flask instance).
    __import__('app.models')

    # Register blueprint stubs
    from app.blueprints.dashboard import dashboard_bp
    from app.blueprints.transactions import transactions_bp
    from app.blueprints.budgets import budgets_bp
    from app.blueprints.settings import settings_bp
    from app.blueprints.bills import bills_bp
    from app.blueprints.paydown import paydown_bp
    from app.blueprints.import_pdf import import_pdf_bp
    from app.blueprints.analytics import analytics_bp
    from app.blueprints.api import api_bp

    app.register_blueprint(dashboard_bp)
    app.register_blueprint(transactions_bp, url_prefix='/transactions')
    app.register_blueprint(budgets_bp, url_prefix='/budgets')
    app.register_blueprint(settings_bp, url_prefix='/settings')
    app.register_blueprint(bills_bp, url_prefix='/bills')
    app.register_blueprint(paydown_bp, url_prefix='/paydown')
    app.register_blueprint(import_pdf_bp, url_prefix='/import')
    app.register_blueprint(analytics_bp, url_prefix='/analytics')
    app.register_blueprint(api_bp, url_prefix='/api')

    # Minimal error handlers — Story 2.1 adds full template-based pages
    @app.errorhandler(404)
    def not_found(e):
        return 'Not Found', 404

    @app.errorhandler(500)
    def server_error(e):
        return 'Internal Server Error', 500

    return app
```

### Blueprint Stub Pattern

Each blueprint `__init__.py` must export the Blueprint object. Pattern for every blueprint (except dashboard):

```python
# app/blueprints/<name>/__init__.py
from flask import Blueprint

<name>_bp = Blueprint('<name>', __name__)

from app.blueprints.<name> import routes  # noqa: F401, E402
```

Dashboard is special — it owns `GET /` and must return HTTP 200:

```python
# app/blueprints/dashboard/__init__.py
from flask import Blueprint

dashboard_bp = Blueprint('dashboard', __name__)

from app.blueprints.dashboard import routes  # noqa: F401, E402
```

```python
# app/blueprints/dashboard/routes.py
from app.blueprints.dashboard import dashboard_bp


@dashboard_bp.route('/')
def index():
    """Dashboard placeholder — Story 8 implements the full dashboard."""
    return 'Financials App', 200
```

All other blueprint `routes.py` files stay as empty stubs (no routes defined — just the blueprint import to avoid errors):

```python
# app/blueprints/<name>/routes.py  (all except dashboard)
from app.blueprints.<name> import <name>_bp  # noqa: F401
```

### `app/utils/date_utils.py` Exact Implementation

```python
"""
Date utilities — month-end-safe arithmetic using dateutil.relativedelta.

Convention (documented here and enforced everywhere in this codebase):
  Jan 31 + 1 month = Feb 28 (or Feb 29 in a leap year) — NOT Mar 3.
  This uses dateutil.relativedelta which naturally clamps to month-end.

All budget cycles, bill recurrence, and amortization projections MUST use
these helpers. No per-module date arithmetic.
"""
from __future__ import annotations

from datetime import date

from dateutil.relativedelta import relativedelta


def add_months(dt: date, months: int) -> date:
    """
    Add (or subtract) a whole number of months to a date, clamping to month-end.

    Examples:
        add_months(date(2026, 1, 31), 1)  -> date(2026, 2, 28)
        add_months(date(2026, 3, 31), -1) -> date(2026, 2, 28)
        add_months(date(2026, 2, 28), 1)  -> date(2026, 3, 28)
    """
    return dt + relativedelta(months=months)


def month_start(dt: date) -> date:
    """Return the first day of the month containing `dt`."""
    return dt.replace(day=1)


def month_end(dt: date) -> date:
    """Return the last day of the month containing `dt`."""
    return dt + relativedelta(day=31)
```

### `app/utils/validators.py` Exact Implementation

```python
"""
Input validators for form and API data.

validate_amount() is the canonical money-input parser:
  - Strips whitespace, leading '$', commas
  - Raises ValueError for non-numeric, zero, or negative values
  - Returns Decimal rounded to 2 decimal places
  - Never returns float — Decimal only
"""
from __future__ import annotations

from decimal import Decimal, InvalidOperation


def validate_amount(value) -> Decimal:
    """
    Parse and validate a monetary amount from user input.

    Args:
        value: str, int, float, or Decimal representing a money amount.

    Returns:
        Decimal rounded to 2 decimal places (e.g. Decimal('29.99')).

    Raises:
        ValueError: If value is non-numeric, zero, or negative.
    """
    if value is None:
        raise ValueError("Amount must be a positive number")

    # Normalise string input: strip whitespace, leading $, commas
    if isinstance(value, str):
        value = value.strip().lstrip('$').replace(',', '')
        if not value:
            raise ValueError("Amount must be a positive number")

    try:
        amount = Decimal(str(value))
    except InvalidOperation:
        raise ValueError("Amount must be a positive number")

    if amount <= 0:
        raise ValueError("Amount must be a positive number")

    return round(amount, 2)
```

### `tests/test_utils/test_date_utils.py` — Exact Implementation

```python
"""Tests for app/utils/date_utils.py — 100% branch coverage required."""
from datetime import date
import pytest
from app.utils.date_utils import add_months, month_start, month_end


class TestAddMonths:
    def test_normal_addition(self):
        assert add_months(date(2026, 5, 15), 1) == date(2026, 6, 15)

    def test_month_end_rollover_jan_to_feb(self):
        """Jan 31 + 1 month = Feb 28 (not Mar 3)."""
        assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)

    def test_month_end_rollover_leap_year(self):
        """Jan 31 + 1 month in leap year = Feb 29."""
        assert add_months(date(2024, 1, 31), 1) == date(2024, 2, 29)

    def test_negative_months(self):
        assert add_months(date(2026, 3, 31), -1) == date(2026, 2, 28)

    def test_twelve_months(self):
        assert add_months(date(2026, 5, 15), 12) == date(2027, 5, 15)

    def test_zero_months(self):
        assert add_months(date(2026, 5, 15), 0) == date(2026, 5, 15)


class TestMonthStart:
    def test_mid_month(self):
        assert month_start(date(2026, 5, 15)) == date(2026, 5, 1)

    def test_already_first(self):
        assert month_start(date(2026, 5, 1)) == date(2026, 5, 1)

    def test_last_day(self):
        assert month_start(date(2026, 5, 31)) == date(2026, 5, 1)


class TestMonthEnd:
    def test_february_non_leap(self):
        assert month_end(date(2026, 2, 1)) == date(2026, 2, 28)

    def test_february_leap_year(self):
        assert month_end(date(2024, 2, 1)) == date(2024, 2, 29)

    def test_thirty_day_month(self):
        assert month_end(date(2026, 4, 1)) == date(2026, 4, 30)

    def test_thirty_one_day_month(self):
        assert month_end(date(2026, 5, 1)) == date(2026, 5, 31)

    def test_already_last_day(self):
        assert month_end(date(2026, 5, 31)) == date(2026, 5, 31)
```

### `tests/test_utils/test_validators.py` — Exact Implementation

```python
"""Tests for app/utils/validators.py — 100% branch coverage required."""
from decimal import Decimal
import pytest
from app.utils.validators import validate_amount


class TestValidateAmount:
    # --- Valid inputs ---
    def test_string_decimal(self):
        assert validate_amount("29.99") == Decimal("29.99")

    def test_string_with_dollar_sign(self):
        assert validate_amount("$29.99") == Decimal("29.99")

    def test_string_with_comma(self):
        assert validate_amount("$1,234.56") == Decimal("1234.56")

    def test_integer(self):
        assert validate_amount(5) == Decimal("5.00")

    def test_float(self):
        result = validate_amount(3.14)
        assert result == Decimal("3.14")

    def test_decimal_passthrough(self):
        assert validate_amount(Decimal("9.99")) == Decimal("9.99")

    def test_strips_whitespace(self):
        assert validate_amount("  12.50  ") == Decimal("12.50")

    # --- Invalid inputs ---
    def test_none_raises(self):
        with pytest.raises(ValueError, match="positive number"):
            validate_amount(None)

    def test_empty_string_raises(self):
        with pytest.raises(ValueError, match="positive number"):
            validate_amount("")

    def test_whitespace_only_raises(self):
        with pytest.raises(ValueError, match="positive number"):
            validate_amount("   ")

    def test_non_numeric_raises(self):
        with pytest.raises(ValueError, match="positive number"):
            validate_amount("abc")

    def test_zero_raises(self):
        with pytest.raises(ValueError, match="positive number"):
            validate_amount("0")

    def test_negative_raises(self):
        with pytest.raises(ValueError, match="positive number"):
            validate_amount("-5.00")
```

### `wsgi.py` Exact Implementation

```python
"""
WSGI entry point — Apache mod_wsgi deployment.

Usage:
  Apache config:
    WSGIDaemonProcess financials python-home=/var/www/html/financials/.venv
    WSGIScriptAlias / /var/www/html/financials/wsgi.py

  Local development (preferred):
    export FLASK_APP=wsgi.py FLASK_DEBUG=1
    export DATABASE_URL=sqlite:///instance/financials.db
    flask run
"""
from app import create_app

application = create_app()
```

### `tests/__init__.py` Files

A `tests/test_utils/__init__.py` must exist (empty file). This allows pytest to discover tests in `tests/test_utils/`.

### WAL Mode Verification

To verify WAL mode is actually active:
```bash
source .venv/bin/activate
python - << 'EOF'
from app import create_app
from app.extensions import db
app = create_app()
with app.app_context():
    result = db.session.execute(db.text("PRAGMA journal_mode")).fetchone()
    print("Journal mode:", result[0])  # should print 'wal'
EOF
```

### Files Modified in This Story

| File | Status | Notes |
|------|--------|-------|
| `app/extensions.py` | **MODIFY** | Add WAL mode `Engine`-level event listener |
| `app/__init__.py` | **MODIFY** | Full factory: config loading, blueprint registration, error handlers |
| `config.py` | **MODIFY** | Update to final form with proper `DATABASE_URL` fallback to `instance/` path |
| `wsgi.py` | **MODIFY** | Replace stub with `application = create_app()` |
| `app/utils/date_utils.py` | **MODIFY** | Replace stub with relativedelta functions |
| `app/utils/validators.py` | **MODIFY** | Replace stub with validate_amount |
| `app/utils/__init__.py` | **MODIFY** | Empty init (currently has TODO comment) |
| `app/blueprints/dashboard/__init__.py` | **MODIFY** | Create `dashboard_bp` Blueprint |
| `app/blueprints/dashboard/routes.py` | **MODIFY** | Add `GET /` returning 200 |
| `app/blueprints/transactions/__init__.py` | **MODIFY** | Create `transactions_bp` Blueprint |
| `app/blueprints/transactions/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/budgets/__init__.py` | **MODIFY** | Create `budgets_bp` Blueprint |
| `app/blueprints/budgets/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/settings/__init__.py` | **MODIFY** | Create `settings_bp` Blueprint |
| `app/blueprints/settings/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/bills/__init__.py` | **MODIFY** | Create `bills_bp` Blueprint |
| `app/blueprints/bills/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/paydown/__init__.py` | **MODIFY** | Create `paydown_bp` Blueprint |
| `app/blueprints/paydown/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/import_pdf/__init__.py` | **MODIFY** | Create `import_pdf_bp` Blueprint |
| `app/blueprints/import_pdf/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/analytics/__init__.py` | **MODIFY** | Create `analytics_bp` Blueprint |
| `app/blueprints/analytics/routes.py` | **MODIFY** | Stub import |
| `app/blueprints/api/__init__.py` | **MODIFY** | Create `api_bp` Blueprint |
| `app/blueprints/api/routes.py` | **MODIFY** | Stub import |
| `tests/test_utils/__init__.py` | **NEW** | Empty — enables pytest discovery |
| `tests/test_utils/test_date_utils.py` | **NEW** | 11 tests, 100% branch coverage |
| `tests/test_utils/test_validators.py` | **NEW** | 14 tests, 100% branch coverage |

### Files That Must NOT Be Touched

| File | Why |
|------|-----|
| `app/models/` (all) | Story 1.4 — complete; do not modify |
| `migrations/` | Story 1.4 — complete; do not run new migrations |
| `tests/test_models/test_schema.py` | Story 1.4 — must remain green |
| `tests/test_services/test_duplicate_detector.py` | Story 1.3 — must remain green |
| `tests/conftest.py` | Working — the `app` fixture must still work; test that the `client` fixture gets `GET /` → 200 |
| `app/utils/errors.py` | Story 2.4 — stub only, do not implement |

### Previous Story Learnings

- **Story 1.4**: `__import__('app.models')` pattern is needed to avoid rebinding the local `app` Flask variable. Keep this in the updated `create_app()`.
- **Story 1.4**: All 12 main-DB models registered. Blueprint stubs need to NOT break the existing conftest.py fixtures.
- **Story 1.3**: `tests/test_services/test_duplicate_detector.py` must keep passing — don't break the import chain.
- **Story 1.2**: `tests/conftest.py` pushes the app context with `with app.app_context()`. The `app` fixture uses a config override dict. The updated `create_app()` must still accept `config={}` overrides.

### ⚠️ Critical: Conftest Compatibility

The `tests/conftest.py` `app` fixture passes:
```python
{
    'TESTING': True,
    'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
    'WTF_CSRF_ENABLED': False,
    'SECRET_KEY': 'test-secret-key',
}
```
The updated `create_app()` MUST still accept this override dict and apply it AFTER loading the config class. The `if config: app.config.update(config)` pattern must be preserved.

### Architecture References

- **WAL mode**: Architecture constraint — fires on every connection via `Engine` class-level event
- **Boundary 1 (Flask/Service)**: `create_app()` imports blueprints at call time (not module level) to avoid circular imports
- **`check_same_thread=False`**: Not explicitly required in Story 1.5 (Flask-SQLAlchemy 3.x handles this via connection pool), but `SQLALCHEMY_ENGINE_OPTIONS` can set it if needed

## Dev Agent Record

### Agent Model Used

claude-sonnet-4-6

### Debug Log References

- **WAL mode module-level trap**: `@event.listens_for(db.engine, "connect")` raises RuntimeError outside app context. Solved by using `@event.listens_for(Engine, "connect")` class-level event — fires on ALL SQLAlchemy Engine instances, no app context required.
- **`import app.models` rebind bug** (carried from Story 1.4): `import app.models` inside `create_app()` rebinds the local `app` variable to the `app` MODULE. Fixed: `__import__('app.models')` loads without rebinding.
- **WAL + in-memory SQLite**: In-memory SQLite always reports `memory` journal mode — WAL doesn't apply. Verified WAL mode with a temp file-based DB instead.

### Completion Notes List

- Implemented `app/utils/date_utils.py` with `add_months`, `month_start`, `month_end` using `dateutil.relativedelta` — 100% branch coverage (14 tests).
- Implemented `app/utils/validators.py` with `validate_amount() -> Decimal` — 100% branch coverage (13 tests).
- Updated `config.py` with `DevelopmentConfig`/`ProductionConfig` inheriting from `Config`; `DATABASE_URL` env var with `sqlite:///instance/financials.db` fallback; `WTF_CSRF_ENABLED=True`.
- Updated `app/extensions.py` with `@event.listens_for(Engine, "connect")` WAL pragma listener — verified `wal` returned by `PRAGMA journal_mode` on file-based SQLite.
- Updated `app/__init__.py` to full factory: config loading, `db`/`migrate` init, `__import__('app.models')`, 9 blueprint registrations, error handlers (404/500).
- Created all 9 blueprint stubs; dashboard owns `GET /` → HTTP 200 ("Financials App").
- Updated `wsgi.py` to `application = create_app()`.
- All ACs satisfied. Full suite: 37 passed, 1 skipped, 0 failures.

### File List

- `app/extensions.py` — modified (WAL mode Engine-level event listener)
- `app/__init__.py` — modified (full factory with blueprints and error handlers)
- `config.py` — modified (final form with DATABASE_URL fallback)
- `wsgi.py` — modified (application = create_app())
- `app/utils/__init__.py` — modified (empty init)
- `app/utils/date_utils.py` — modified (add_months, month_start, month_end)
- `app/utils/validators.py` — modified (validate_amount → Decimal)
- `app/blueprints/dashboard/__init__.py` — modified (dashboard_bp Blueprint)
- `app/blueprints/dashboard/routes.py` — modified (GET / → 200)
- `app/blueprints/transactions/__init__.py` — modified (transactions_bp)
- `app/blueprints/transactions/routes.py` — modified (stub)
- `app/blueprints/budgets/__init__.py` — modified (budgets_bp)
- `app/blueprints/budgets/routes.py` — modified (stub)
- `app/blueprints/settings/__init__.py` — modified (settings_bp)
- `app/blueprints/settings/routes.py` — modified (stub)
- `app/blueprints/bills/__init__.py` — modified (bills_bp)
- `app/blueprints/bills/routes.py` — modified (stub)
- `app/blueprints/paydown/__init__.py` — modified (paydown_bp)
- `app/blueprints/paydown/routes.py` — modified (stub)
- `app/blueprints/import_pdf/__init__.py` — modified (import_pdf_bp)
- `app/blueprints/import_pdf/routes.py` — modified (stub)
- `app/blueprints/analytics/__init__.py` — modified (analytics_bp)
- `app/blueprints/analytics/routes.py` — modified (stub)
- `app/blueprints/api/__init__.py` — modified (api_bp)
- `app/blueprints/api/routes.py` — modified (stub)
- `tests/test_utils/__init__.py` — new (empty, enables pytest discovery)
- `tests/test_utils/test_date_utils.py` — new (14 tests, 100% branch coverage)
- `tests/test_utils/test_validators.py` — new (13 tests, 100% branch coverage)

### Change Log

- 2026-05-27: Story 1.5 implemented — app factory, WAL mode, deployment config, utils, all blueprint stubs. 37 passed, 1 skipped.
