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

14 KiB
Raw Blame History

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.