Optivest [Back-End]: This is the backend sister project of OptiVest Project. To Check out the Frontend, go here
The OptiVest project is a cutting-edge, AI-driven personal financial advisor platform designed to empower users with smarter financial management tools. Built with a focus on automation and real-time data insights, OptiVest combines dynamic portfolio analysis, personalized investment recommendations, and a suite of tools for budgeting, goal setting, and debt tracking. The backend, developed in Go, integrates financial data from sources like Alpha Vantage and FRED to provide up-to-date insights and robust portfolio optimization.
A standout feature of OptiVest is its focus on actionable financial insights. Users receive real-time portfolio alerts, performance metrics, and risk management tips, helping them make well-informed decisions. The platform’s intelligent algorithms highlight top-performing assets and assist in sector diversification, while additional tools for budgeting and debt tracking offer a holistic approach to personal finance. By merging AI-driven recommendations with user-centric design, OptiVest delivers an all-in-one financial advisory experience tailored to individual financial goals and preferences.
- AI-Driven Financial Insights
- Provides intelligent financial advice using pre-trained AI models, enabling users to make data-backed investment decisions.
- Customizable recommendations on portfolio rebalancing, risk management, and asset allocation.
- Real-Time Portfolio Analysis
- Integrates with Alpha Vantage and FRED for up-to-date data, delivering real-time analysis of investments, market trends, and external factors like interest rates and market sentiment.
- Calculates key performance metrics such as ROI, Sharpe ratio, and sector performance.
- Automated Portfolio Management
- Supports automated portfolio rebalancing based on individual risk tolerance and investment goals.
- Uses advanced algorithms to identify top-performing stocks and bonds, updating recommendations regularly.
- Personal Finance Tools
- Budgeting and Goal Setting: Tracks spending, monitors goals, and provides summaries for financial planning.
- Debt Management: Analyzes debt information, including payment history, interest rates, and payoff estimates, and visualizes debt progress.
- Notification Center
- Real-time notifications for market updates, investment alerts, and goal progress. In Progress
- Allows users to view messages with detailed metadata, including links and images, for quick navigation.
- Advanced Security and Integration
- Secure WebSocket connection for real-time updates and data handling.
- Implements Redis caching for efficient data retrieval, reducing load on API calls and improving performance.
- Prediction Capability
- Based on your spending, expense, income and debt rates, OptiVest is able to come up with predictions of future habits using the OptiVest Predictor Micr-Service.
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system.
Before you can run or contribute to this project, you'll need to have the following software installed:
- Go: The project is written in Go, so you'll need to have Go installed to run or modify the code.
- PostgreSQL: The project uses a PostgreSQL database, so you'll need to have PostgreSQL installed and know how to create a database.
- A Go IDE or text editor: While not strictly necessary, a Go IDE or a text editor with Go support can make it easier to work with the code. I use vscode.
- Git: You'll need Git to clone the repo.
- Redis: OptiVest uses Redis for caching to enhance performance and reduce API load.
- OptiVest-Predictor-Microservice: Clone and set up this micro-service, which is esential for financial predictions and recommendations
A step by step series of examples that tell you how to get a development env running.
-
Clone the repository: Start by cloning the repository to your local machine. Open a terminal, navigate to the directory where you want to clone the repository, and run the following command:
git clone https://github.com/Blue-Davinci/OptiVest.git
-
Navigate to the project directory: Use the
cdcommand to navigate to the project directory:cd optivest -
Install the Go dependencies: The Go tools will automatically download and install the dependencies listed in the
go.modfile when you build or run the project. To download the dependencies without building or running the project, you can use thego mod downloadcommand:go mod download
-
Set up the database: The project uses a PostgreSQL database. You'll need to create a new database and update the connection string in your configuration file or environment variables. We use
GOOSEfor all the data migrations andSQLCas the abstraction layer for the DB. To proceed with the migration, navigate to theSchemadirector:
cd internal\sql\schema- Then proceed by using the
goose {connection string} upto execute an Up migration as shown: - Note: You can use your own environment variable or load it from the env file.
goose postgres postgres://aggregate:password@localhost/aggregate upRegenerating the SQLC layer.
sqlcis tracked as a Go tool dependency ingo.mod, so the version is pinned alongside the rest of the toolchain and Dependabot'sgomodecosystem will surface upgrade PRs against it. After editing anything underinternal/sql/queries/or the schema, run:make generate # regenerates internal/database/*.sql.go make verify-generate # fails locally if you forgot to commit the regenCI runs the same
verify-generatecheck as a dedicated job, so a PR that ships a query change without the regenerated Go won't merge. There is intentionally nogo install sqlc@<floating>fallback — pinning via thetooldirective is what kept the previous regen-version drift from repeating.
-
Download and Setup the MIcroService: Follow the instructions highlighted here to get the micro-service up and running.
-
Environment variables: Copy the committed template into the location the API loads at boot, then fill in real values:
cp cmd/api/.env.example cmd/api/.env # then edit cmd/api/.envcmd/api/.env.exampledocuments every variable, marks each one as[REQUIRED]or[OPTIONAL], calls out which ones are tolerated as warnings in-env=developmentand fatal in non-development, and includes a one-liner for generating the AES encryption key withopenssl rand -hex 32. The actualcmd/api/.envyou create is gitignored, so real secrets never enter the repo.godotenv.Loadruns againstcmd/api/.env(or.envif your CWD is alreadycmd/api/); environment variables exported in your shell take precedence over the file, so per-shell overrides work without editing it. -
Build the project: You can build the project using the makefile's command:
make build/api
This will create an executable file in the current directory. Note: The generated executable is for the windows environment - However, You can find the linux build command within the makefile!
-
Run the project: You can run the project using the
go runor useMakeFileand do:make run/api
-
MakeFile Help: For additional supported commands run
make help:
make helpoutput:
Usage:
run/api: Run the API server
run/api/origins: Run the API server with CORS origins
db/psql - connect to the db using psql
build/api - build the cmd/api applicationIf you don't want to install Postgres + Redis on your host, the repo ships
a docker-compose.yml that brings up the entire stack (Postgres 17, Redis 7,
a one-shot goose migration runner, and the API itself) with a single
command. The same compose file works on Docker Desktop and on rootless
Podman with the docker-compatibility shim.
Prerequisites:
- A container runtime (Docker Engine, Docker Desktop, or rootless Podman 5+)
- Two populated
.envfiles (templates committed, real files gitignored):cmd/api/.envfor the API binary's runtime config (smtp, vendor API keys, encryption key, etc) — copycmd/api/.env.example/.envat the repo root for the docker stack's database credentials (POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB) — copy/.env.example
The two files are deliberately separate: cmd/api/.env is loaded by
the binary via godotenv, /.env is loaded by docker compose itself
for ${VAR} interpolation in the compose file. Compose ${VAR:?...}
syntax fails fast if anything in /.env is unset, so a missing variable
shows up as a clear error at make docker/up time rather than as a
"password authentication failed" deep in a migration.
Bring everything up:
cp .env.example .env # first time only — set POSTGRES_PASSWORD
cp cmd/api/.env.example cmd/api/.env # first time only — fill the keys you have
make docker/upThat builds the API image (multi-stage: golang:1.26-alpine builder →
alpine:3.23 runtime, non-root optivest user, static binary), pulls
postgres:17-alpine + redis:7-alpine, runs every migration in
internal/sql/schema/ against the fresh database, and then starts the
API. The API container is wired to a HEALTHCHECK that polls
/healthcheck every 30s, so docker compose ps reflects healthy /
unhealthy state. The first run takes 1-3 minutes (image pulls + Go module
download); subsequent runs are layer-cached and start in seconds.
Once it's up, the same observability surface that's exposed locally is available from the host:
curl http://localhost:4000/healthcheck # JSON liveness payload
curl http://localhost:4000/readyz # JSON readiness payload (postgres + redis)
curl http://localhost:4000/metrics # Prometheus exposition
curl http://localhost:4000/debug/vars # raw JSON expvars
psql "$OPTIVEST_DB_DSN" # interactive DB shellOther useful targets:
| Command | Effect |
|---|---|
make docker/logs |
tail just the API container logs |
make docker/ps |
list the running compose services |
make docker/migrate |
re-run goose up against the running Postgres |
make docker/build |
rebuild the API image without restarting the stack |
make docker/down |
stop and remove containers, preserving the Postgres data volume |
make docker/down/clean |
same as above but drops the Postgres volume too |
How the API in the container finds its config: docker-compose loads
cmd/api/.env via env_file, then overrides the DSN host (localhost
→ postgres) and the Redis password (empty in dev) via an explicit
environment: block on the api service. The DSN itself is built from
the ${POSTGRES_USER} / ${POSTGRES_PASSWORD} / ${POSTGRES_DB} values
that compose interpolates from the root /.env, so there is exactly one
source of truth for the database credential. The binary itself was
already tolerant of a missing .env file at runtime (it falls back to
the process environment), which is what makes the same image work in
cluster deployments where config comes from a Kubernetes Secret rather
than a file. CLI flags overriding -redis-addr=redis:6379 are passed
via the service command: so the binary connects to the in-network
service name rather than the localhost default.
The application accepts command-line flags for configuration, establishes a connection pool to a database, and publishes variables for monitoring the application. The published variables include the application version, the number of active goroutines and the current Unix timestamp.
- This will start the application. You should be able to access it at
http://localhost:4000.
You can view the **parameters** by utilizing the `-help` command. Here is a rundown of the available commands for a quick lookup (INCOMPLETE, use help for full list).
Security note: All
-api-key-*and-encryption-keyflags default to the corresponding environment variable. Never commit real keys here, and never paste them into the example output below. SeeSECURITY.mdfor the rotation runbook and the list of required env vars (OPTIVEST_*). In non-development environments the API refuses to start if any required secret is missing.
- api-author string
API author (default "Blue_Davinci")
-api-default-currency string
Default currency (default "USD")
-api-key-alphavantage string
Alpha Vantage API key (env OPTIVEST_ALPHAVANTAGE_API_KEY)
-api-key-exchangerates string
Exchange-Rate API key (env OPTIVEST_EXCHANGERATE_API_KEY)
-api-key-fmp string
FMP API key (env OPTIVEST_FINANCIALMODELINGPREP_API_KEY)
-api-key-fred string
FRED API key (env OPTIVEST_FRED_API_KEY)
-api-key-ocrspace string
OCR.Space API key (env OPTIVEST_OCRSPACE_API_KEY)
-api-key-optivestmicroservice string
OptiVest predictor microservice API key (env OPTIVEST_PREDICTOR_API_KEY)
-api-key-groq string
Groq (or compatible) chat-completions API key (env OPTIVEST_GROQ_LLM_API_KEY)
-api-name string
API name (default "OptiVest")
-api-url-alphavantage string
Alpha Vantage API URL (default "https://www.alphavantage.co/query?")
-api-url-exchangerates string
Exchange-Rate API URL (default "https://v6.exchangerate-api.com/v6")
-api-url-fmp string
FMP API base URL (the legacy /api/v3 family was retired 2025-08-31) (default "https://financialmodelingprep.com/stable")
-api-url-fred string
FRED API URL (default "https://api.stlouisfed.org/fred/series/observations?")
-api-url-groq string
Groq (or any OpenAI-compatible) chat-completions URL (env OPTIVEST_GROQ_LLM_API_URL) (default "https://api.groq.com/openai/v1/chat/completions")
-api-url-ocrspace string
OCR.Space API URL (default "https://api.ocr.space/parse/image")
-api-url-optivestmicroservice string
OptiVest predictor microservice URL (default "http://127.0.0.1:8000/v1/predict")
-llm-model string
Chat-completions model identifier sent in the request body (env OPTIVEST_LLM_MODEL) (default "llama-3.3-70b-versatile")
-cors-trusted-origins value
Trusted CORS origins (space separated)
-db-dsn string
PostgreSQL DSN (env OPTIVEST_DB_DSN)
-db-max-idle-conns int
PostgreSQL max idle connections (default 25)
-db-max-idle-time string
PostgreSQL max connection idle time (default "15m")
-db-max-open-conns int
PostgreSQL max open connections (default 25)
-encryption-key string
AES-GCM encryption key, hex-encoded, 16/24/32 bytes (env OPTIVEST_DATA_ENCRYPTION_KEY)
-env string
Environment (development|staging|production) (default "development")
-expired-notification-burst-limit int
Batch Limit for Expired Notification Tracker (default 100)
-frontend-activation-url string
Frontend Activation URL (default "http://localhost:5173/verify?token=")
-frontend-callback-url string
Frontend Callback URL (default "https://adapted-healthy-monitor.ngrok-free.app/v1")
-frontend-login-url string
Frontend Login URL (default "http://localhost:5173/login")
-frontend-password-reset-url string
Frontend Password Reset URL (default "http://localhost:5173/passwordreset/password?token=")
-frontend-url string
Frontend URL (default "http://localhost:5173")
-http-client-retrymax int
HTTP client maximum retries (default 3)
-http-client-timeout duration
HTTP client timeout (default 10s)
-limiter-burst int
Rate limiter maximum burst (default 10)
-limiter-enabled
Enable rate limiter (default true)
-limiter-rps float
Rate limiter maximum requests per second (default 5)
-llm-idle-timeout duration
Abort the LLM stream if no chunk arrives within this window (default 15s)
-llm-max-retries int
Max retries for the LLM call before the first SSE chunk; mid-stream errors never retry (default 2)
-llm-total-budget duration
Wallclock cap for an LLM streaming call across retries (default 1m30s)
-portfolio-worker-limit int
Max concurrent per-asset workers in portfolio analysis (1 = serial; tune to upstream API rate limits) (default 6)Rate limiter note: As of P2, the limiter is backed by Redis using
go-redis/redis_rate/v10so the configured-limiter-rpsand-limiter-burstapply across the cluster, not per-instance. The previous in-memory implementation under-counted by a factor of N when running N pods behind a load balancer.If Redis is unreachable the limiter fails open (allows the request through, increments
rate_limiter_redis_errors_totalandrate_limiter_fail_open_totalon/debug/vars, and logs a WARN). Operators should alert on a non-zerorate_limiter_redis_errors_totalrate. Successful and denied requests are counted separately asrate_limiter_allowed_totalandrate_limiter_denied_total.Sub-1-rps configurations are not supported by the underlying GCRA bucket type; values <1 are rounded up to 1 and a warning is logged at startup.
Portfolio analysis concurrency (P3):
performInvestmentPortfolioAnalysisnow fans out per-asset workers (stocks + bonds) into a boundederrgroupcapped at-portfolio-worker-limit(default 6). For a typical 10-15 asset portfolio this drops cache-cold analysis from ~25-30s to ~3-5s on Premium Alpha Vantage tiers. First-error-cancels semantics + the P2 ctx propagation work mean a client disconnect aborts every in-flight upstream HTTP call and DB INSERT.Tune to your vendor plan: Alpha Vantage Free (5 req/min) -> set to 1; Premium (75/min) -> default 6; Premium Plus (600/min) -> up to 16+.
A process-wide
singleflightregistry collapses concurrent identical upstream calls (FMP sector snapshot, per-symbol Alpha Vantage / FRED fetches), so even at higher worker limits we never N-multiply duplicate fetches when the cache is cold.Operational metrics on
/debug/vars:
portfolio_analysis_runs_total(counter, request rate)portfolio_analysis_errors_total(counter, alert on ratio)portfolio_analysis_duration_ms(last run, scrape for p99)portfolio_analysis_workers_active(live in-flight, saturation)portfolio_analysis_workers_max_observed(process-wide high-water mark)portfolio_singleflight_collapsed_total(counter; sustained zero is normal if your cache is warm or workload is sequential)
Structured request logging (P3.B): Every HTTP request emits exactly one structured log line through
zap, after the response is fully written. The middleware sits OUTSIDErecoverPanicso a panic still produces a 500-status log line (the one ops should be paged on); 401s fromauthenticateand 429s from the rate limiter also each get a single line. Long-lived SSE connections deliberately skip per-request logging to avoid misleading "completed" lines on disconnect.Every request is also stamped with a correlation ID. Inbound
X-Request-IDheaders are accepted (cap 128 chars, allowlisted to[A-Za-z0-9._-]to block log injection); anything else is regenerated server-side as 16 hex chars fromcrypto/rand. The chosen ID is echoed back in the response header so clients can include it in bug reports.Per-request log fields (
msg="http request"):
method,path(no query string; tokens often live there)status,bytes,latency_msreq_id- request correlation IDconn_id- per-connection ID stamped byConnContext(P1)user_id-0for unauthenticated, elseuser.IDpopulated byauthenticatevia therequestLogholderremote_ip(viatomasen/realip, same source the limiter uses)user_agent(truncated at 256 bytes)Example line:
{"level":"info","msg":"http request","method":"GET","path":"/v1/investments/analysis","status":200,"bytes":4821,"latency_ms":1843,"remote_ip":"203.0.113.7","req_id":"a1b2c3d4e5f60718","conn_id":42,"user_id":7,"user_agent":"OptiVest-Web/2.1"}Handlers that want their own log lines to participate in the same correlation should call
app.loggerFromRequest(r)instead of usingapp.loggerdirectly: the returned logger is pre-enriched withreq_id,conn_id, anduser_id. Background helpers that receive acontext.Contextbut no*http.Request(the financial helpers underinvestment_operations.goare the canonical example) callapp.loggerFromContext(ctx)for the same enrichment - the ctx must have flowed from the originating request, otherwise the correlation fields fall back to their zero values.Operational metrics on
/debug/vars:
request_log_total- total requests observedrequest_log_4xx_total- subset that returned a client errorrequest_log_5xx_total- subset that returned a server error (alert)request_id_generated_total- inbound requests without an ID headerrequest_id_rejected_total- inbound IDs replaced for failing the sanitization policy (sustained non-zero suggests a misbehaving caller or active log-injection attempt)Outbound calls (Alpha Vantage, FRED, FMP, OCR.Space, the predictor micro-service, the LLM upstream, RSS scraping) are wrapped through
cmd/api/http_clients.go. Each call:
- is bound to the caller's
context.Context, so a client disconnect or timeout cancels the upstream request promptly;- forwards the inbound
X-Request-IDon the outbound when it is set on the ctx (and never clobbers a header the caller already chose);- emits one structured
http outboundlog line per call carryingmethod,host,path,status,bytes,latency_ms,req_id,conn_id,user_id. The full URL is deliberately NOT logged because several upstream providers carry their API key in the query string; seeSECURITY.mdfor the rotation runbook if a leak occurs.Log levels mirror the inbound logger:
5xxand transport errors areError(page on this),ctxcancel/deadline isInfo(expected client behaviour, not an upstream fault), everything else isInfo.
Prometheus
/metricsendpoint: Every operational signal we already publish to/debug/varsis also exposed in Prometheus text exposition format atGET /metrics. The handler walks the existingexpvarregistry on each scrape and emits a curated, allow-listed subset — runtime noise such ascmdlineandmemstatsis deliberately suppressed because neither flattens cleanly into the Prom data model and Prom servers ship their own Go runtime exporters.Currently exposed metrics (counters unless noted):
request_log_total,request_log_4xx_total,request_log_5xx_totalrequest_id_generated_total,request_id_rejected_totalrate_limiter_allowed_total,rate_limiter_denied_total,rate_limiter_redis_errors_total,rate_limiter_fail_open_total,rate_limiter_disabled_totalportfolio_analysis_runs_total,portfolio_analysis_errors_total,portfolio_singleflight_collapsed_totalportfolio_analysis_duration_ms(gauge)portfolio_analysis_workers_active,portfolio_analysis_workers_max_observed(gauges)total_requests_received,total_responses_sent,total_processing_time_us(the underlying expvar uses the non-ASCIIμssuffix, which Prom's metric-name regex rejects; the exposition renames it to_uswhile leaving/debug/varsuntouched)total_responses_sent_by_statuswith acode="<status>"label per bucketgoroutines,timestamp(gauges)optivest_build_info{version="..."} 1— canonical Prom info-pattern for build identityThe
/metrics,/debug/vars,/healthcheck, and/readyzendpoints are all mounted on the base router, deliberately outside the global middleware chain that wraps/v1. Probe and scrape traffic therefore does not flow throughlogRequests(no log-line-per-scrape noise), is not counted by themetricsmiddleware (no circular self-incrementing of the very counters being read), and is not subject to the per-IP rate limiter (a fast or misconfigured scraper cannot lock itself out of the diagnosis endpoint, and a load balancer probing/readyzmore aggressively during a degradation cannot be told the instance is unready by the limiter). All four endpoints are unauthenticated; the deployment is expected to scope reachability to the internal scrape / probe network — seeSECURITY.mdfor the operator-side assumptions.Adding a new metric to the
/metricssurface is one line incmd/api/metrics.go(theknownMetricsallow-list); the friction is intentional, since this is an operator-facing surface, not a debug dump.
LLM streaming + retry budget (P4.A): The chat-completions path is special-cased away from the generic
Optivet_Clientbecause every call holds a connection slot open for 10–30s while the model emits SSE chunks. The default upstream is Groq (free tier, OpenAI-compatible, ~500-1000 tok/s on Llama 3.3 70B); the URL is operator-swappable to any other OpenAI-compatible provider via-api-url-groq/OPTIVEST_GROQ_LLM_API_URL(Cerebras, OpenRouter:freemodels, self-hosted Ollama, the original SambaNova host, etc.) and the model identifier via-llm-model/OPTIVEST_LLM_MODEL.LLMStreamincmd/api/http_clients.goprovides three guarantees the generic path cannot, regardless of which provider is wired in:
- A wallclock budget context (
-llm-total-budget, default90s) caps the entire call across pre-first-byte retries plus the streaming read. The retry layer's backoff sleeps inherit this deadline, so a tight budget naturally compresses the effective retry window.- An idle deadline (
-llm-idle-timeout, default15s) aborts the stream when no chunk has arrived within the window. Protects connection slots from a stalled upstream that has sent headers but no body, and caps the impact of a slowloris-style upstream.- Bounded pre-first-byte retries (
-llm-max-retries, default2). Retries fire only on dial / TLS / non-2xx errors that happen before the response body is streamed. Once a chunk has been read, mid-stream errors return verbatim — replaying a 30s prompt for a transient blip would double user-visible latency and the token bill, with no reliability gain because chat-completions APIs are non-resumable across providers.
LLMRequestis preserved as a thin back-compat wrapper for callers that only need the joined string (buildLLMRequestHelperand friends incmd/api/ai_operations.go).
Streaming portfolio analysis (P4.B):
GET /v1/investments/analysis/streamis the user-facing payoff for the streaming client. It runs the same DB pre-checks as the synchronous/v1/investments/analysisendpoint (goals + non-empty portfolio), and only switches intotext/event-streammode once those 4xx-eligible validations pass — so client-side error handling for "no goals set" stays a normal JSON envelope, not a malformed SSE frame.Wire format (all events are JSON-encoded so the client always parses
JSON.parse(event.data), regardless of which event name fires):data: {"delta": "<token text>"} // one per LLM chunk, default event event: done data: {"finish_reason":"stop","usage":{...},"analyzed":{...}} event: error data: {"error":"upstream stalled"} // terminal failureThe handler reuses one prompt template across the streaming and non-streaming paths via
renderInvestmentPortfolioPromptinai_operations.go, so a user gets the same analysis regardless of which endpoint they hit. On stream completion the analyzed portfolio is persisted to the same table read by/v1/investments/analysis/summary; a persist failure logs but does not abort the user's stream, since the result is already in the browser by then. Mid-stream errors are reported via a singleevent: errorevent because by that point the response status code is already on the wire and unchangeable.A single-file browser smoke-test client lives at
examples/sse-portfolio-analysis/. It usesfetch()+ a manual SSE parser (rather thanEventSource, which cannot sendAuthorizationheaders) and renders deltas live against a running API. Useful for eyeballing CORS, auth, and flushing end-to-end without spinning up the full frontend.
Using make run, will run the API with a default connection string located
in cmd\api\.env. If you're using powershell, you need to load the values otherwise you will get
a cannot load env file error. Use the PS code below to load it or change the env variable:
$env:OPTIVEST_DB_DSN=(Get-Content -Path .\cmd\api\.env | Where-Object { $_ -match "OPTIVEST_DB_DSN" } | ForEach-Object { $($_.Split("=", 2)[1]) })Alternatively, in unix systems you can make a .envrc file and load it directly in the makefile by importing like so:
include .envrcA succesful run will output something like this:
make run/api
Running API server..
go run ./cmd/api
{"level":"info","time":"2024-1","caller":"api/main.go:374","msg":"Loading Environment Variables","path":"cmd\\api\\.env"}
{"level":"info","time":"2024-1]","caller":"api/main.go:268","msg":"Redis connection established","addr":"localhost:6379"}
{"level":"info","time":"2024-1","caller":"api/main.go:277","msg":"database connection pool established","dsn":"postgres://optivest:yourpassword@localhost/optivest?sslmode=disable"}
{"level":"info","time":"2024-1","caller":"api/helpers.go:215","msg":"currency certified, using cached currencies","currency":"USD"}
{"level":"info","time":"2024-1-2","caller":"api/main.go:337","msg":"Starting Schedulers"}
{"level":"info","time":"2024-0-2","caller":"api/schedulers.go:64","msg":"Starting the recurring expenses tracking cron job..","time":"2024-00-2"}
{"level":"info","time":"2024-1","caller":"api/schedulers.go:199","msg":"Tracking recurring expenses","time":"2024-1 EAT m=+0.533360001"}The API surface is split across an unauthenticated operational plane (mounted on the base router so probes bypass auth and rate limiting) and an authenticated /v1 application plane that runs through the full middleware chain (request ID, structured logging, panic recovery, rate limiting, bearer-token auth).
Auth tokens are obtained via POST /v1/api/authentication and sent as Authorization: Bearer <token> on every protected route. Every response carries an X-Request-ID header, propagated through outbound calls for end-to-end tracing.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthcheck |
Liveness probe; returns {status, version, env, uptime_sec}. Always 200 unless the process itself is unable to answer. Wire to k8s livenessProbe or Docker HEALTHCHECK. Failure causes a container restart. |
| GET | /readyz |
Readiness probe; pings Postgres and Redis in parallel, returns 200 {status: "ready", checks: {...}} when both are reachable, 503 {status: "not_ready", checks: {...}} when any required dep is down. Wire to k8s readinessProbe or load-balancer health checks. Failure pulls the instance out of rotation without restarting it. |
| GET | /metrics |
Prometheus text exposition of the curated metric set |
| GET | /debug/vars |
Raw expvar JSON dump for ad-hoc inspection |
| Group | Mount | Endpoints | Auth required |
|---|---|---|---|
| Users & MFA | /users |
9 | Mixed (registration is open) |
| API tokens | /api |
5 | Open (login flow) |
| Budgets | /budgets |
5 | Yes |
| Goals | /goals |
7 | Yes |
| Groups | /groups |
19 | Yes |
| Incomes | /incomes |
3 | Yes |
| Debts | /debts |
5 | Yes |
| Expenses | /expenses |
7 | Yes |
| Investments | /investments |
14 | Yes |
| Personal finance | /personalfinance |
4 | Yes |
| Feeds (RSS) | /feeds |
7 | Yes |
| Awards | /awards |
1 | Yes |
| Search options | /search-options |
3 | Yes |
| Notifications | /notifications |
4 | Yes |
| Comments & reactions | /comments |
6 | Yes |
| Contact | /contact-us |
1 | Yes |
| Server-Sent Events | /sse |
1 | Yes |
Users & MFA — /v1/users
| Method | Path | Description |
|---|---|---|
| POST | /v1/users |
Register a new user |
| PUT | /v1/users/activated |
Activate a newly-registered account |
| PUT | /v1/users/password |
Reset password using a recovery token |
| POST | /v1/users/recovery |
Validate a recovery code |
| POST | /v1/users/mfa |
Enroll in MFA (TOTP) |
| POST | /v1/users/mfa/verify |
Verify the MFA enrollment OTP |
| GET | /v1/users/account |
Read the authenticated user's profile |
| PATCH | /v1/users/account |
Update the authenticated user's profile |
| POST | /v1/users/logout |
Terminate the current session / SSE channel |
API tokens (login flow) — /v1/api
| Method | Path | Description |
|---|---|---|
| POST | /v1/api/authentication |
Issue an authentication token (login) |
| POST | /v1/api/authentication/verify |
Validate the MFA challenge after login |
| POST | /v1/api/password-reset |
Send a password-reset token by email |
| POST | /v1/api/recovery |
Initiate account recovery via recovery codes |
| POST | /v1/api/resend-activation |
Re-send an account activation token |
Budgets — /v1/budgets
| Method | Path | Description |
|---|---|---|
| GET | /v1/budgets |
List all budgets for the authenticated user |
| GET | /v1/budgets/summary |
Aggregate budget / goal / expense summary |
| POST | /v1/budgets |
Create a budget |
| PATCH | /v1/budgets/{budgetID} |
Update a budget |
| DELETE | /v1/budgets/{budgetID} |
Delete a budget |
Goals & goal plans — /v1/goals
| Method | Path | Description |
|---|---|---|
| POST | /v1/goals |
Create a goal |
| PATCH | /v1/goals/{goalID} |
Update a goal |
| GET | /v1/goals/progression |
Goals enriched with progression % |
| GET | /v1/goals/tracking |
Snapshot history of goal progression |
| POST | /v1/goals/plan |
Create a goal plan |
| PATCH | /v1/goals/plan/{goalPlanID} |
Update a goal plan |
| GET | /v1/goals/plan |
List the user's goal plans |
Groups, invitations, group goals, group transactions, group expenses — /v1/groups
| Method | Path | Description |
|---|---|---|
| GET | /v1/groups |
Groups the user is a member of |
| GET | /v1/groups/{groupID} |
Detailed view of one group |
| POST | /v1/groups |
Create a new group |
| PATCH | /v1/groups/{groupID} |
Update a group |
| PATCH | /v1/groups/member/{groupID} |
Update a member's role |
| DELETE | /v1/groups/member/{groupID}/{memberID} |
Admin removes a member |
| DELETE | /v1/groups/member/{groupID} |
User leaves the group |
| GET | /v1/groups/created |
Groups created by the authenticated user |
| POST | /v1/groups/invite |
Send a group invitation |
| PATCH | /v1/groups/invite/{groupID} |
Accept / decline an invitation |
| POST | /v1/groups/goal |
Create a group goal |
| PATCH | /v1/groups/goal/{groupGoalID} |
Update a group goal |
| GET | /v1/groups/transactions/{groupID} |
List transactions for a group |
| POST | /v1/groups/transactions |
Create a group transaction |
| DELETE | /v1/groups/transactions/{groupTransactionID} |
Delete a group transaction |
| GET | /v1/groups/expenses/{groupID} |
List expenses for a group |
| POST | /v1/groups/expenses |
Create a group expense |
| DELETE | /v1/groups/expenses/{groupExpenseID} |
Delete a group expense |
| GET | /v1/groups/public |
Browse public groups |
| POST | /v1/groups/public |
Join a public group |
Incomes — /v1/incomes
| Method | Path | Description |
|---|---|---|
| GET | /v1/incomes |
List all incomes for the authenticated user |
| POST | /v1/incomes |
Record a new income |
| PATCH | /v1/incomes/{incomeID} |
Update an income |
Debts — /v1/debts
| Method | Path | Description |
|---|---|---|
| GET | /v1/debts |
List all debts for the authenticated user |
| POST | /v1/debts |
Create a debt |
| PATCH | /v1/debts/{debtID} |
Update a debt |
| GET | /v1/debts/installment/{debtID} |
Repayment history for a debt |
| PATCH | /v1/debts/installment/{debtID} |
Make a debt payment |
Expenses (one-shot, recurring, OCR receipts) — /v1/expenses
| Method | Path | Description |
|---|---|---|
| GET | /v1/expenses |
List all expenses for the authenticated user |
| POST | /v1/expenses |
Create an expense |
| PATCH | /v1/expenses/{expenseID} |
Update an expense |
| POST | /v1/expenses/recurring |
Create a recurring expense |
| GET | /v1/expenses/recurring |
List recurring expenses |
| PATCH | /v1/expenses/recurring/{expenseID} |
Update a recurring expense |
| POST | /v1/expenses/receipts |
OCR-parse a receipt image and analyze it |
Investments (stocks, bonds, alternatives, transactions, analysis) — /v1/investments
| Method | Path | Description |
|---|---|---|
| GET | /v1/investments/stocks |
List stock holdings |
| POST | /v1/investments/stocks |
Create a stock holding |
| PATCH | /v1/investments/stocks/{stockID} |
Update a stock holding |
| DELETE | /v1/investments/stocks/{stockID} |
Delete a stock holding |
| GET | /v1/investments/bonds |
List bond holdings |
| POST | /v1/investments/bonds |
Create a bond holding |
| PATCH | /v1/investments/bonds/{bondID} |
Update a bond holding |
| DELETE | /v1/investments/bonds/{bondID} |
Delete a bond holding |
| GET | /v1/investments/alternative |
List alternative investments |
| POST | /v1/investments/alternative |
Create an alternative investment |
| PATCH | /v1/investments/alternative/{alternativeID} |
Update an alternative investment |
| DELETE | /v1/investments/alternative/{alternativeID} |
Delete an alternative investment |
| POST | /v1/investments/transactions |
Record an investment transaction |
| DELETE | /v1/investments/transactions/{transactionID} |
Delete an investment transaction |
| GET | /v1/investments/analysis |
Run a portfolio analysis (LLM-backed) |
| GET | /v1/investments/analysis/stream |
Stream a portfolio analysis as Server-Sent Events |
| GET | /v1/investments/analysis/summary |
Latest LLM analysis snapshot |
Personal finance (analysis, predictions, summaries) — /v1/personalfinance
| Method | Path | Description |
|---|---|---|
| GET | /v1/personalfinance/analysis |
Aggregated financial detail for analysis |
| GET | /v1/personalfinance/summary |
Investment summary across all asset types |
| GET | /v1/personalfinance/prediction |
ML-backed personal-finance prediction |
| GET | /v1/personalfinance/expense-income/summary |
Expense vs. income summary report |
Feeds (curated RSS & favorites) — /v1/feeds
| Method | Path | Description |
|---|---|---|
| GET | /v1/feeds |
RSS posts filtered by the user's favorite tags |
| POST | /v1/feeds |
Submit a new feed for ingestion |
| GET | /v1/feeds/{postID} |
Read a single RSS post |
| PATCH | /v1/feeds/{feedID} |
Update a feed |
| DELETE | /v1/feeds/{feedID} |
Delete a feed |
| POST | /v1/feeds/favorites |
Mark a post as a favorite |
| DELETE | /v1/feeds/favorites/{postID} |
Remove a favorite |
Awards, search options, notifications, comments
Awards (/v1/awards)
| Method | Path | Description |
|---|---|---|
| GET | /v1/awards |
All awards earned by the authenticated user |
Search options (/v1/search-options)
| Method | Path | Description |
|---|---|---|
| GET | /v1/search-options/budget-categories |
Distinct budget categories |
| GET | /v1/search-options/currencies |
Supported currency codes |
| GET | /v1/search-options/budget-id-names |
Budget id / name pairs |
Notifications (/v1/notifications)
| Method | Path | Description |
|---|---|---|
| GET | /v1/notifications |
List notifications |
| PATCH | /v1/notifications/{notificationID} |
Mark a notification as read |
| DELETE | /v1/notifications/{notificationID} |
Delete one notification |
| DELETE | /v1/notifications |
Clear all notifications |
Comments & reactions (/v1/comments)
| Method | Path | Description |
|---|---|---|
| GET | /v1/comments |
List comments for an associated entity |
| POST | /v1/comments |
Post a new comment |
| PATCH | /v1/comments/{commentID} |
Edit a comment |
| DELETE | /v1/comments/{commentID} |
Delete a comment |
| POST | /v1/comments/reaction |
React to a comment |
| DELETE | /v1/comments/reaction/{commentID} |
Remove a reaction |
Other — /v1/contact-us, /v1/sse
| Method | Path | Description |
|---|---|---|
| POST | /v1/contact-us |
Submit a contact-us message |
| GET | /v1/sse |
Server-Sent Events stream for live notifications |
A machine-readable OpenAPI/Swagger document is on the roadmap; the source of truth in the meantime is cmd/api/routes.go.
The project has existing tests represented by files ending with the word "_test" e.g internal_helpers_test.go
Each test file contains a myriad of tests to run on various entities mainly functions.
The test files are organized into structs of tests and their corresponding test logic.
You can run them directly from the vscode test UI. Below represents test results for the scraper:
=== RUN Test_generateSecurityKey
=== RUN Test_generateSecurityKey/Valid_AES-128_key_(16_bytes)
--- PASS: Test_generateSecurityKey/Valid_AES-128_key_(16_bytes) (0.00s)
=== RUN Test_generateSecurityKey/Valid_AES-192_key_(24_bytes)
--- PASS: Test_generateSecurityKey/Valid_AES-192_key_(24_bytes) (0.00s)
=== RUN Test_generateSecurityKey/Valid_AES-256_key_(32_bytes)
--- PASS: Test_generateSecurityKey/Valid_AES-256_key_(32_bytes) (0.00s)
=== RUN Test_generateSecurityKey/Invalid_key_length_(0_bytes)
--- PASS: Test_generateSecurityKey/Invalid_key_length_(0_bytes) (0.00s)
=== RUN Test_generateSecurityKey/Invalid_key_length_(-1_bytes)
--- PASS: Test_generateSecurityKey/Invalid_key_length_(-1_bytes) (0.00s)
--- PASS: Test_generateSecurityKey (0.00s)
PASS
ok github.com/Blue-Davinci/OptiVest/internal/data 0.674s- All other tests follow a similar prologue.
Every pull request to main and every push to main triggers the
GitHub Actions workflow at .github/workflows/ci.yml.
The pipeline is parallel where it can be — eight independent jobs fan
out from a single trigger and rejoin in a ci-summary aggregator that
makes branch-protection wiring trivial:
| Job | Tool | What it gates on |
|---|---|---|
lint |
golangci-lint v2 | govet, staticcheck, errcheck, ineffassign, unused, bodyclose, nolintlint, misspell, gofmt, goimports |
test |
go test -race |
Build, vet, race-tested unit tests, coverage summary in the run page |
vuln |
govulncheck | Known CVEs in the dependency graph |
secrets |
gitleaks | Hardcoded secrets in the diff (defense-in-depth alongside GitGuardian) |
dockerfile-lint |
hadolint | Dockerfile + Dockerfile.migrate best practices |
docker-build |
buildx + GHA cache | Builds both images, exports tarballs as artifacts |
image-scan |
trivy | HIGH/CRITICAL CVEs and Dockerfile misconfig; SARIF uploaded to the Security tab |
sbom |
syft | SPDX-JSON SBOM per image, retained 30 days |
e2e |
docker compose | Brings the full stack up in CI, probes /healthcheck, /readyz, /metrics, /debug/vars, tears down |
A new commit on the same PR auto-cancels the in-flight run via the
workflow's concurrency block, so the canonical "ready to merge"
status is always the one on the latest commit.
Caching is layered so warm runs are fast: actions/setup-go caches the
Go module + build cache, the golangci-lint action caches its own
analysis state, and docker/build-push-action uses type=gha so layer
reuse persists across runs and across branches that share a base.
Locally, make audit runs the same lint + vet + govulncheck + race-tested
test suite that CI runs (minus the docker / scan / e2e jobs).
As earlier mentioned, the api uses a myriad of flags which you can use to launch the application.
An example of launching the application with your smtp server's setting includes:
make build/api ## build api using the makefile
./bin/api.exe -smtp-username=pigbearman -smtp-password=algor ## run the built api with your own values
Direct Run:
go run main.go(Will Be Added Soon.)
- Go - Backend
- PostgreSQL - Database
- Redis - Cacheing
- Paystack - Payment processing
- SQLC - Generate type safe Go from SQL
- Goose - Database migration tool
- HTML/CSS - Templates
- @Blue-Davinci - Idea & Initial work
See also the list of contributors who participated in this project.
- Hat tip to anyone whose code was used
- Inspiration
- Go Documentation: Official Go documentation and tutorials.
- PostgreSQL Documentation: Official PostgreSQL documentation.
- SQLC Documentation: Official SQLC documentation and guides.
