14 KiB
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
- Clean architecture — well-organized service layer (
exchange_rate.py,budget_projection.py), clear separation with one endpoint file per domain. - Robust exchange-rate handling — multi-source fallback (BCCR → ExchangeRate-API → CoinGecko) with in-memory caching and last-known fallbacks.
- Flexible budget system — projection engine handles recurring items, 18th–18th billing cycles, and deferred transactions with carryover logic.
- Security practices — SHA-256 token hashing, OAuth2 bearer support, dual auth (JWT + API tokens), credentials hidden from logs.
- API token management — secure generation, expiration support, hash-based lookup.
- Graceful degradation — push-notification failures don't crash the request path; stale rates keep the app usable during API outages.
- 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:269–278
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:320–344
Evidence: Loads receipts (line 324), then queries WaterMeterReading per receipt inside the loop (line 328) — 13 queries where 1–2 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:78–85, 103–108
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:73–85
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:182–191, import_transactions.py:118–128
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:46–50, 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:104–162, municipal_receipts.py:197–228
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:51–52
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:105–162, municipal_receipts.py:197–228
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, 8–9
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, 327–344
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:63–64, 93–94, 108–109, 122–123
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:13–77
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:31–37, 136–142
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:114–222
Evidence: For each TransactionSource, 4–6 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:329–335
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:98–110
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:77–99
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)
- Startup check rejecting default
SECRET_KEY/admin creds (config.py) — BE-15 s/datetime.utcnow()/datetime.now(timezone.utc)/sweep — BE-01 (verify comparisons after)- Month/year bounds in
budget_projection.pyhelpers — BE-07 DISTINCT ONfor pension snapshots — BE-04- Eager-load water readings — BE-05
TemporaryDirectory()in both PDF parsers — BE-09- Year bounds in agent budget tool — BE-16
- Specific exceptions + logging in
exchange_rate.py— BE-18 asyncio.to_threadaround PDF parsing — BE-14- 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.