18 KiB
Architect Review — WealthySmart
Hat: Staff-level Software Architect. Focus: structure, layering, data model, migration story, API consistency, deploy topology.
Editorial note: ARCH-01 was corrected after verification — see the finding itself and
README.md.
Executive Summary
WealthySmart is a single-user personal finance management application with a modern full-stack architecture: FastAPI + SQLModel backend (PostgreSQL) paired with a React 19 + Vite frontend served through a Hono proxy, plus an LLM-powered AI agent interface. The system handles multi-currency transactions (CRC/USD/EUR/BTC/XMR), budget forecasting with complex billing-cycle logic, pension/municipal bill tracking, and push notifications. The architecture is sound overall — no fundamental redesign is needed — but there are weak defaults in configuration, zero test coverage, an ad-hoc migration story, and consistency gaps in validation and response schemas that should be addressed incrementally.
How the System Fits Together
┌─────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT (Docker Compose) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ Frontend (Node) │ │ Backend (FastAPI) │ │
│ │ ┌─────────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ Vite (port 3000)│ │ │ │ Uvicorn │ │ │
│ │ │ React 19 + TS │ │ │ │ (port 8000) │ │ │
│ │ └─────────────────┘ │ │ └───────────────┘ │ │
│ │ ┌─────────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ Hono Server │ │ │ │ 15 endpoints │ │ │
│ │ │ (port 3001) │ │ │ │ + LLM agent │ │ │
│ │ │ • SPA serving │ │ │ └───────────────┘ │ │
│ │ │ • CopilotKit │ │ │ │ │
│ │ │ runtime │ │ │ │ │
│ │ │ • /api proxy │ │ │ │ │
│ │ └─────────────────┘ │ │ │ │
│ └──────────────────────┘ └─────────────────────┘ │
│ │ │ │
│ └────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────┐ │
│ │ PostgreSQL 16 (port 5432) │ │
│ │ • 15 tables + enums │ │
│ │ • No Alembic migrations │ │
│ └──────────────────────────────────┘ │
│ │
│ Production: nginx-proxy + Let's Encrypt on single VPS │
│ Ingestion: n8n flows POST to /api/v1 with API tokens │
└─────────────────────────────────────────────────────────────────┘
Key data flows:
- Frontend requests
/api/v1/...(proxied by Hono to FastAPI) - Agent requests go to
/api/v1/agent/agui(Microsoft Agent Framework + OpenAI) - Exchange rates refreshed every 6 hours from multiple fallback APIs
- Auth via httpOnly cookies (SPA) or Bearer tokens (API clients / n8n)
Strengths
- Modular endpoint structure — 15 focused routers (accounts, transactions, budget, analytics, etc.) with clear single responsibilities.
- Sophisticated budget projection logic —
budget_projection.pycorrectly handles billing cycles (18th–18th for credit cards vs. 1st–1st calendar month), deferred transactions, recurring items with override amounts, and cumulative balance tracking across years, with a clean API (compute_monthly_projection,compute_yearly_projection_with_cumulative). - Resilient exchange-rate handling — fallback chain (BCCR → ExchangeRate-API → currency-api → FloatRates → CoinGecko) with in-memory and database caching; uses a
_last_known_*pattern to retain previous values during API outages. - Clean auth model — dual-mode authentication (httpOnly cookie for SPA, Bearer token for API clients) with JWT + API-token support. Token revocation marks rows inactive rather than deleting them.
- Seed data for development — comprehensive default categories, accounts, and recurring items; single-user design appropriate for the app.
- Type-safe models — SQLModel schemas enforce some constraints (unique categories, year/month combinations) at the DB level.
- REST conventions mostly respected — POSTs return 201, pagination via
offset/limitis consistently applied. - Consistent error surface —
HTTPExceptionwith clear status codes and detail messages throughout.
Findings
[ARCH-01] Weak secret hygiene around .env files and config defaults
Severity: High (downgraded from Critical after verification)
Location: .env, .env.prod (working tree), backend/app/config.py:6–9, docker-compose.yml
Evidence (corrected): .env and .env.prod exist in the repo root and contain real secrets (OpenAI API key, DB password, admin credentials, VAPID keys), but they are gitignored and were never committed — git ls-files and git log --all --diff-filter=A confirm no history. The genuine issues are: (a) config.py ships working fallback credentials (SECRET_KEY="change-me-in-production", ADMIN_USERNAME/PASSWORD = "admin"), (b) docker-compose.yml hardcodes dev DB credentials, and (c) plaintext secrets on the dev machine with no rotation discipline.
Why it matters: If a deploy ever runs without its env file (typo in compose, CI regression — note reference_gitea_deploy: .env.prod is regenerated each deploy), the app silently boots with admin/admin and a known JWT signing key.
Recommendation: Remove the defaults and fail fast at startup (see SEC-01). Keep .env.example as the documented template. Rotate any key that has been pasted into chats/logs.
[ARCH-02] No Formal Database Migration Strategy
Severity: High
Location: backend/app/db.py:13–76; no alembic/ directory
Evidence: Migrations are ad-hoc SQL strings in run_migrations() with IF NOT EXISTS clauses. Adding a column requires hand-written ALTER TABLE code. Schema evolution is not version-controlled or reversible.
Why it matters: Manual migrations are error-prone and have no rollback. Dev and prod can diverge silently. The project rule "never reset prod DB; use migrations" makes this the single most important infrastructure gap.
Recommendation: Introduce Alembic: alembic init, autogenerate a baseline from current models, convert the existing manual statements, run alembic upgrade head at container startup. Effort: M (1–2 days).
[ARCH-03] Validation & Error-Handling Inconsistencies
Severity: Medium
Location: endpoints/transactions.py:71–90, endpoints/budget.py:108–109, endpoints/analytics.py:56
Evidence: list_transactions() mixes cycle_year/cycle_month and start_date/end_date filters with or_ in a nested query — unclear precedence. budget.py uses positional HTTPException(400, ...) while others use keyword args. analytics.py:56 filters with the string literal "COMPRA" instead of TransactionType.COMPRA.
Why it matters: Mixing filter modes risks undefined behavior; hardcoded enum strings break refactoring safety; inconsistent error style hampers debugging.
Recommendation: Extract a build_transaction_query() helper with explicit precedence; standardize on status_code= kwargs; replace string literals with enum members. Effort: S.
[ARCH-04] Agent Tools Duplicate Service Logic
Severity: Medium
Location: backend/app/agent/tools.py, backend/app/services/budget_projection.py
Evidence: tools.py re-implements queries (get_accounts(), get_net_worth(), get_cycle_summary()) that overlap with the service layer instead of delegating to it.
Why it matters: Budget logic changes must be applied in two places or the assistant and the UI disagree about the user's money.
Recommendation: Make agent tools thin wrappers over service functions. Effort: M (1 day).
[ARCH-05] No Input Limits on Bulk Operations
Severity: Medium
Location: endpoints/import_transactions.py:96–149
Evidence: paste_import() accepts arbitrary text with no size cap; a 10 MB paste is parsed entirely in memory before any validation.
Why it matters: Memory-exhaustion DoS vector; bulk operations should validate request size upfront.
Recommendation: Field(..., max_length=1_000_000) on the request model; pre-validate before opening the write loop. Effort: S.
[ARCH-06] Missing Test Coverage
Severity: High
Location: No tests/ directory anywhere; requirements.txt lacks pytest
Evidence: Zero test files in the repository.
Why it matters: Budget projection is the most intricate logic in the app (billing-cycle edges, deferred carryover, cumulative balances with overrides) and is entirely unguarded against regression.
Recommendation: backend/tests/ with test_budget_projection.py (Feb 28/29, year boundaries, overrides), test_auth.py, test_exchange_rate.py; Vitest for useBudget. Effort: L (3–5 days for meaningful coverage).
[ARCH-07] Single Hono Instance Is a Single Point of Failure
Severity: Low (revised for a single-user app)
Location: frontend/server.ts:1–20, frontend/Dockerfile, docker-compose.prod.yml
Evidence: One Hono container serves SPA + CopilotKit runtime + proxy. The Dockerfile healthcheck probes the backend's /api/health, not Hono's own readiness.
Why it matters: If Hono crashes the whole app is down even with a healthy backend. For a single-user app, replicas are over-engineering; a correct healthcheck + restart policy is the right-sized fix.
Recommendation: Healthcheck Hono itself; restart: unless-stopped; add 5xx retry in the frontend API client. Skip load balancing.
[ARCH-08] Exchange-Rate Refresh Loop Swallows Cancellation
Severity: Low
Location: backend/app/services/exchange_rate.py:348–366, backend/app/main.py:66
Evidence: refresh_rates_periodically() runs while True with a broad except that logs and continues; cancellation during shutdown can be silently swallowed.
Recommendation: Catch asyncio.CancelledError explicitly and break; keep the broad handler for everything else. Effort: S.
[ARCH-09] No Foreign-Key Delete Rules
Severity: Medium
Location: backend/app/models/models.py:144, 258, 427
Evidence: Transaction.category_id and similar FKs declare foreign_key= with no ondelete behavior; deleting a category leaves dangling references.
Why it matters: Silent referential inconsistency — budget rows pointing at categories that no longer exist.
Recommendation: Decide per-FK between SET NULL (transactions should outlive categories) and CASCADE (water readings die with their receipt); apply via Alembic migration. Effort: S.
[ARCH-10] Fragile Cookie Parsing in Agent Middleware
Severity: Medium
Location: backend/app/main.py:88–142
Evidence: Cookie extraction uses regex r"(?:^|;\s*)ws_token=([^;]+)", which mishandles edge cases (multiple cookies of the same name, unusual encodings).
Recommendation: Use stdlib http.cookies.SimpleCookie. Effort: S.
[ARCH-11] deferred_to_next_cycle Implemented in Backend but Not Exposed in UI
Severity: Medium
Location: backend/app/models/models.py:145, endpoints/transactions.py:219–235
Evidence: The field is settable via PATCH and respected by budget projection; the deferral toggle exists in row actions but TransactionModal.tsx has no field for it, and the affordance is undiscoverable (see UX-05).
Recommendation: Add the checkbox to TransactionModal.tsx and make the row-action state visible. Effort: S.
[ARCH-12] Pagination Lacks a Deterministic Tiebreaker
Severity: Low
Location: endpoints/transactions.py:96, endpoints/salarios.py:35–36
Evidence: Paginated queries order by date DESC only; equal dates yield non-deterministic page boundaries.
Recommendation: .order_by(col(...).date.desc(), col(...).id.desc()) everywhere paginated. Effort: S.
[ARCH-13] Mutation → Refetch-Everything Pattern
Severity: Low
Location: frontend/src/hooks/useBudget.ts:61–74
Evidence: Every mutation triggers Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]) regardless of what changed.
Why it matters: Wasteful, and the pattern will multiply as modules grow. Acceptable as a simplicity tradeoff today; the durable fix is a query library (see FE-10 / Plan Phase 3).
[ARCH-14] Hardcoded Dev DB Credentials in Compose
Severity: Medium
Location: docker-compose.yml (db service environment: block)
Evidence: POSTGRES_PASSWORD: wealthy_pass hardcoded in source control (prod compose correctly uses ${POSTGRES_PASSWORD}).
Recommendation: Move dev credentials to the gitignored .env; reference via ${...} in both compose files. Effort: S.
[ARCH-15] N+1 Query in Category Aggregation
Severity: Medium
Location: backend/app/services/budget_projection.py:370–383
Evidence: compute_cc_by_category() calls session.get(Category, cat_id) inside a loop — one query per category with spending.
Recommendation: Prefetch {c.id: c for c in session.exec(select(Category)).all()} and look up in the dict. Effort: S (minutes).
[ARCH-16] API Response Schema Inconsistency
Severity: Low
Location: endpoints/transactions.py:28–33 (Pydantic models) vs endpoints/budget.py:100–157 (raw dicts) vs endpoints/analytics.py:18–23
Evidence: Some endpoints return typed models, others plain dicts; OpenAPI schema generation is incomplete and frontend types are maintained by hand.
Recommendation: Shared models in app/api/schemas.py + response_model= on every route; this also unlocks frontend type codegen (see FE-14). Effort: M.
[ARCH-17] Agent Session via ContextVar Couples Tools to HTTP Middleware
Severity: Low
Location: backend/app/agent/tools.py:40–52, backend/app/main.py:131–141
Evidence: Tools fetch the DB session from a contextvars.ContextVar set by middleware; calling a tool outside a request raises LookupError.
Recommendation: Document the constraint and add a clear error message (see BE-12); full refactor only if tools ever need to run outside HTTP. Effort: S for the guard.
[ARCH-18] No Data Export / Archival Path
Severity: Low Location: All tables are append-only with no export endpoint Why it matters: Acceptable growth for a personal app, but there is no way to get historical data out (taxes, accountant, backup verification). Recommendation: CSV export endpoint per major table (also UX wishlist #5). Effort: M.
[ARCH-19] Hono Proxy Trusts Upstream Blindly
Severity: Low
Location: frontend/server.ts proxy handlers
Evidence: Responses are piped through without shape validation; combined with FE-14 (no runtime validation client-side), schema drift fails confusingly.
Recommendation: Don't validate in the proxy (wrong layer); fix via shared response schemas (ARCH-16) and optional Zod at the API-client boundary.
[ARCH-20] Lazy Imports Papering Over a Circular Dependency
Severity: Low
Location: backend/app/auth.py:39–43
Evidence: _validate_token() imports get_session and APIToken inside the function body to dodge a cycle with db.py.
Recommendation: Split token utilities into their own module to break the cycle properly. Effort: S.
Recommended Refactors (prioritized)
| # | Item | Effort | Phase |
|---|---|---|---|
| 1 | ARCH-01/SEC-01: fail-fast on default secrets, fix dev compose creds | S | 0 |
| 2 | ARCH-02: Alembic migrations | M | 1 |
| 3 | ARCH-06: test suite (budget projection first) | L | 1 |
| 4 | ARCH-15: N+1 fix in compute_cc_by_category |
S | 2 |
| 5 | ARCH-03: standardize validation/error handling/enums | S | 2 |
| 6 | ARCH-09: FK delete rules via migration | S | 2 |
| 7 | ARCH-04: agent tools delegate to services | M | 2 |
| 8 | ARCH-16: shared response schemas (response_model= everywhere) |
M | 3 |
| 9 | ARCH-13/FE-10: query library on the frontend | M | 3 |
| 10 | ARCH-11: expose deferred-transaction UI | S | 4 |
| 11 | ARCH-18: CSV export | M | 4 |
| 12 | ARCH-05/08/10/12/14/17/20: small hardening fixes | S each | 0–2 |
Overall assessment: Production-ready for a single-user personal app, with the caveat that configuration defaults must fail fast before anything else. The architecture needs no redesign — invest in migrations, tests, and consistency.