Files
WealthySmart/codebase-review/02-backend.md
Carlos Escalante 5c265129e8 Add five-hat codebase review and phased improvement plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:13:19 -06:00

167 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Backend Review — WealthySmart
> Hat: Senior Backend Engineer (Python / FastAPI / SQLModel). Focus: correctness, money & date math, query efficiency, PDF parsing, agent tools, migrations, tests.
## Executive Summary
The WealthySmart backend is a well-structured FastAPI application with a PostgreSQL database and SQLModel ORM. The codebase demonstrates solid architectural patterns (dependency injection, a real service layer, API tokenization) but contains several correctness and robustness gaps that deserve attention: timezone-naive `datetime.utcnow()` everywhere, `float` for all monetary values, a suspected off-by-one in billing-cycle math, N+1 query patterns in the agent tools, non-transactional multi-file PDF uploads, blocking subprocess calls inside `async` routes, and broad exception handlers that can mask failures. The codebase has no tests at all, which compounds every other risk — especially around the budget-cycle logic.
## Strengths
1. **Clean architecture** — well-organized service layer (`exchange_rate.py`, `budget_projection.py`), clear separation with one endpoint file per domain.
2. **Robust exchange-rate handling** — multi-source fallback (BCCR → ExchangeRate-API → CoinGecko) with in-memory caching and last-known fallbacks.
3. **Flexible budget system** — projection engine handles recurring items, 18th18th billing cycles, and deferred transactions with carryover logic.
4. **Security practices** — SHA-256 token hashing, OAuth2 bearer support, dual auth (JWT + API tokens), credentials hidden from logs.
5. **API token management** — secure generation, expiration support, hash-based lookup.
6. **Graceful degradation** — push-notification failures don't crash the request path; stale rates keep the app usable during API outages.
7. **Comprehensive PDF services** — municipal-receipt and pension parsing with layered regex extraction.
## Findings
### [BE-01] `datetime.utcnow()` — Deprecated and Timezone-Naive — High
**Location:** `auth.py:19`, `auth.py:51`, `budget.py:262`, `services/exchange_rate.py:139+`, 10+ call sites
**Evidence:** `datetime.utcnow()` used throughout; deprecated since Python 3.12 in favor of `datetime.now(timezone.utc)`.
**Why it matters:** Mixing naive and aware datetimes causes silent comparison bugs, and Postgres timestamp semantics depend on consistency. Costa Rica is UTC-6 year-round — naive "UTC" timestamps compared against local dates shift transactions across cycle boundaries near midnight.
**Recommendation:** Mechanical replacement with `datetime.now(timezone.utc)`; audit each comparison site for naive/aware mixing; prefer `TIMESTAMPTZ` columns going forward.
### [BE-02] Float Instead of Decimal for Money — High
**Location:** `models/models.py:102, 104, 131, 184+` (all financial fields)
**Evidence:** `amount: float`, `balance: float`, `buy_rate: float`, `saldo_final: float` across the schema.
**Why it matters:** Binary floats can't represent most decimal amounts exactly; cumulative budget math, projections, and historical audits accumulate error. Industry standard for money is `Decimal`/`NUMERIC`.
**Recommendation:** Migrate monetary fields to `Decimal` (`Field(max_digits=15, decimal_places=2)` → Postgres `NUMERIC`); update `budget_projection.py` and `exchange_rate.py` arithmetic. Do this *after* Alembic exists (one clean migration) and *with* tests in place.
### [BE-03] Savings Accrual Lacks Atomicity — Medium
**Location:** `services/savings_accrual.py:60`
**Evidence:** `session.commit()` with no rollback path; if MEMP or MPAT accounts are missing, the function returns early with partial state possible.
**Recommendation:** Raise early if required accounts are absent; wrap the mutation block so it commits entirely or not at all.
### [BE-04] Pension Snapshot Dedup Done in Python — Medium
**Location:** `agent/tools.py:269278`
**Evidence:** Loads all `PensionSnapshot` rows, then deduplicates by fund in a Python loop.
**Recommendation:** `SELECT DISTINCT ON (fund) ... ORDER BY fund, period_end DESC` (PostgreSQL) or a window-function query.
### [BE-05] N+1 in Municipal Receipts Water Readings — Medium
**Location:** `agent/tools.py:320344`
**Evidence:** Loads receipts (line 324), then queries `WaterMeterReading` per receipt inside the loop (line 328) — 13 queries where 12 would do.
**Recommendation:** Eager-load via `selectinload` or fetch all readings for the receipt IDs in one `IN (...)` query.
### [BE-06] Possible Off-by-One in Billing-Cycle Calculation — High
**Location:** `services/budget_projection.py:7885, 103108`
**Evidence:** `get_cycle_range(year, month)` returns `(month/18, month+1/18)` while the comment says "cycle (M-1): from (M-1)/18 to M/18, paid with month M salary" — comment and code disagree about which cycle a month's budget uses.
**Why it matters:** If wrong, every monthly budget is aligned to the wrong statement period — salary and expenses misreported by a month. If right, the comment is wrong and the next maintainer will "fix" working code.
**Recommendation:** This is the **first thing to pin down with tests**: assert which cycle a Feb-15 transaction lands in for the March budget against known-good real data, then fix code or comment accordingly.
### [BE-07] No Month/Year Validation in Date Helpers — Medium
**Location:** `services/budget_projection.py:7385`
**Evidence:** `datetime(year, month, 18)` with no range check; month 13 raises an unhandled `ValueError` from deep inside projection math.
**Recommendation:** Validate `1 <= month <= 12` and a sane year range at the helper boundary with a clear error message.
### [BE-08] Duplicate Detection Is Incomplete — Medium
**Location:** `endpoints/transactions.py:182191`, `import_transactions.py:118128`
**Evidence:** Dedup keys off the nullable `reference` field; manual transactions and hash collisions/misses can create duplicates that skew analytics.
**Recommendation:** Tighten the reference-hash inputs (include `card_last4`), and consider a unique constraint on `(date, merchant, amount, currency, source)` with an explicit override path for genuine same-day repeat purchases.
### [BE-09] PDF Temp Files Not Cleaned on Timeout — Medium
**Location:** `services/pension_pdf.py:4650`, `services/municipal_receipt_pdf.py:79`
**Evidence:** `subprocess.run(..., timeout=30/10)` is good, but on timeout/exception the `NamedTemporaryFile` cleanup path isn't guaranteed.
**Recommendation:** Use `tempfile.TemporaryDirectory()` as a context manager so cleanup is unconditional.
### [BE-10] Multi-File Uploads Are Not Transactional — High
**Location:** `endpoints/pensions.py:104162`, `municipal_receipts.py:197228`
**Evidence:** The loop commits per file; if file 2 fails after file 1 committed, the batch is half-applied with no undo.
**Why it matters:** Partial uploads create inconsistent state the user can't easily see or revert.
**Recommendation:** Either wrap the batch in one transaction (all-or-nothing) or keep per-file commits but return a precise per-file success/failure manifest (the response models already have `errors[]` — make the frontend show it, see UX-09).
### [BE-11] Nullable FKs Without Delete Behavior — Medium
**Location:** `models/models.py:144` (Transaction.category_id), `:258` (RecurringItem.category_id)
**Evidence:** Optional FKs with no `ondelete` rule; deleting a category strands references.
**Recommendation:** Same as ARCH-09 — explicit `SET NULL`/`CASCADE` per relationship, applied via migration.
### [BE-12] Agent Session ContextVar Failure Is Cryptic — Medium
**Location:** `agent/tools.py:5152`
**Evidence:** `_session_ctx.get()` raises bare `LookupError` if middleware didn't bind a session.
**Recommendation:** Catch and re-raise as `RuntimeError("DB session not bound to agent context — tool called outside request scope")`.
### [BE-13] Locale-Naive Amount Parsing in Paste Import — Low
**Location:** `endpoints/import_transactions.py:77`
**Evidence:** `match.group(1).replace(",", "")` assumes `,` is always a thousands separator.
**Recommendation:** Document the assumed format (BAC statement format) in the docstring; add a sanity check (e.g., reject if the result is 100× off from a `.` interpretation).
### [BE-14] Blocking Subprocess Inside Async Routes — Medium
**Location:** `endpoints/pensions.py:105162`, `municipal_receipts.py:197228`
**Evidence:** `async def upload_...()` calls `parse_pension_pdf()` which runs `subprocess.run()` synchronously — blocks the event loop for up to 30s per file.
**Recommendation:** `await asyncio.to_thread(parse_pension_pdf, pdf_bytes)` (the codebase already uses `asyncio.to_thread` for rate refresh, so the pattern exists).
### [BE-15] Hardcoded Default Credentials — High
**Location:** `config.py:6, 89`
**Evidence:** `SECRET_KEY: str = "change-me-in-production"`, `ADMIN_USERNAME: str = "admin"`, `ADMIN_PASSWORD: str = "admin"` (verified).
**Why it matters:** A deploy that loses its env file boots wide open. See SEC-01 for the exploit framing.
**Recommendation:** Remove defaults; raise at startup if unset or still default.
### [BE-16] Agent Budget Tool Has No Year Bounds — Low
**Location:** `agent/tools.py:212` (vs `endpoints/budget.py:131` which checks MIN_YEAR/MAX_YEAR)
**Evidence:** REST endpoint validates year range; the agent tool calling the same projection logic does not — year 9999 reaches `compute_yearly_projection_with_cumulative()`.
**Recommendation:** Apply the same MIN/MAX_YEAR bounds in the tool.
### [BE-17] Agent Tools Have Fixed Limits, No Pagination — Low
**Location:** `agent/tools.py:269, 327344`
**Evidence:** Hardcoded `limit=12` / `limit=50` with no offset parameter.
**Recommendation:** Add optional `offset`/`limit` tool parameters so the assistant can answer questions about older history.
### [BE-18] `except Exception: pass` Swallows Errors — Low
**Location:** `services/exchange_rate.py:6364, 9394, 108109, 122123`
**Evidence:** Bare pass on all exceptions in each rate-source fetcher.
**Why it matters:** Network failures, schema changes in upstream APIs, and genuine bugs all fail identically and invisibly.
**Recommendation:** Catch specific exceptions (`httpx.TimeoutException`, `json.JSONDecodeError`, `KeyError`) and `logger.warning(...)` per source so a dead source is observable.
### [BE-19] Ad-hoc Migrations Without Atomicity — Medium
**Location:** `db.py:1377`
**Evidence:** Sequential `ALTER TABLE` strings with per-statement try/except/rollback; a mid-sequence failure leaves a partially migrated schema with no version record.
**Recommendation:** Alembic (duplicate of ARCH-02; listed here because the per-statement rollback pattern is itself a hazard).
### [BE-20] Stale-Rate Cache Has No Timestamp — Low
**Location:** `services/exchange_rate.py:3137, 136142`
**Evidence:** `_last_known` caches carry no `last_updated`; consumers can't tell a fresh rate from a week-old one.
**Recommendation:** Store a timestamp with the cached value and expose freshness (`{"rate": X, "as_of": ...}`) — the agent and UI can then warn on stale data (pairs with UX data-trust concerns).
### [BE-21] Projection Runs ~15+ Queries Per Call — Medium
**Location:** `services/budget_projection.py:114222`
**Evidence:** For each `TransactionSource`, 46 separate aggregate queries; 3 sources ≈ 15+ round-trips per month, ×12 for the yearly view.
**Recommendation:** Collapse into one or two `GROUP BY source, transaction_type` aggregate queries.
### [BE-22] PensionSnapshot Upsert Has a Race Window — Low
**Location:** `models/models.py:329335`
**Evidence:** `UniqueConstraint("fund", "period_start", "period_end")` exists, but the upsert is an application-level check-then-insert; concurrent uploads of the same PDF can collide.
**Recommendation:** Use `INSERT ... ON CONFLICT DO UPDATE` (`sqlalchemy.dialects.postgresql.insert`). Low priority — n8n is the only concurrent writer and runs daily.
### [BE-23] Account Labels Not Unique — Low
**Location:** `models/models.py:98110`
**Evidence:** `label: str` without a unique constraint; two accounts named "BAC" are allowed.
**Recommendation:** Unique on `(bank, currency, label)` or at least surface duplicates in the UI.
### [BE-24] Hardcoded Fallback Exchange Rates in Agent — Low
**Location:** `agent/tools.py:7799`
**Evidence:** `sell = rate.sell_rate if rate else 600.0` and a magic `1.08` EUR multiplier in `get_net_worth`.
**Why it matters:** The assistant reports a wrong net worth when the rate service is down — silently.
**Recommendation:** Fall back to the latest DB-persisted rate; if none, say so in the tool output instead of inventing a number.
### [BE-25] No Tests — Critical
**Location:** `backend/` (no `tests/`, no pytest in `requirements.txt`)
**Evidence:** Zero coverage on budget cycles, deferred logic, PDF parsing, auth.
**Recommendation:** pytest + fixtures (in-memory SQLite or a test Postgres). Minimum first targets: cycle boundaries (resolves BE-06), deferred carryover, PDF parser golden files, token auth paths.
## Quick Wins (under 1 hour each)
1. Startup check rejecting default `SECRET_KEY`/admin creds (`config.py`) — BE-15
2. `s/datetime.utcnow()/datetime.now(timezone.utc)/` sweep — BE-01 (verify comparisons after)
3. Month/year bounds in `budget_projection.py` helpers — BE-07
4. `DISTINCT ON` for pension snapshots — BE-04
5. Eager-load water readings — BE-05
6. `TemporaryDirectory()` in both PDF parsers — BE-09
7. Year bounds in agent budget tool — BE-16
8. Specific exceptions + logging in `exchange_rate.py` — BE-18
9. `asyncio.to_thread` around PDF parsing — BE-14
10. Clear error message for unbound agent session — BE-12
**Recommended sequence:** tests for cycle math first (BE-25 → BE-06), then the Decimal migration (BE-02) once Alembic (BE-19/ARCH-02) exists to carry it.