elisynth/
03/Document intelligence · SaaS
Live

FinSight

Turning a messy bank statement into clean rows, for a market bank-linking APIs do not serve.

Period
2025 — 2026
Role
Sole engineer
Status
Live
On my role

Architecture, backend, AI pipeline, frontend integration, payments and deployment. The marketing site predated the build; everything from the API surface inward is mine.

01/

The problem

Bank statements are one of the richest personal datasets most people own and one of the least usable. They arrive as CSVs with inconsistent column names, or as PDFs where the transaction table is a visual artifact rather than structured data. Getting anything out of them means manual tagging in a spreadsheet, which almost nobody sustains past the first month.

For Nigerian users the existing tools are worse than tedious. Western personal-finance apps depend on bank-linking APIs with poor or no coverage for Nigerian banks, price in USD only, and categorize against merchant vocabularies that do not recognize local transaction descriptors.

Why it mattersThe gap is not analytical sophistication. Once transactions are normalized and labeled, category breakdowns, trend lines and outlier detection are cheap. The whole product hinges on turning a messy document into clean rows without asking the user to do it — for a market the incumbent ingestion method cannot reach at all.

02/

What I built

Upload a statement as CSV or PDF and get back categorized transactions, monthly trend charts, anomaly flags on unusual spending, a chat interface answering natural-language questions against your own history, and an on-demand savings report.

Behind it: authentication, per-user data isolation, a freemium paywall with Paystack billing in NGN and USD, and a documented two-platform deployment.

03/Architecture

How it holds together

8 nodes

The ingestion path. The markdown re-serialization step is the one that decides whether the model can reason about the table at all.

A monorepo with backend and frontend as separate deployment targets. Files hit POST /upload and the parser branches on type — CSV through pandas with column normalization, PDF through pdfplumber for structured table extraction with pymupdf as a raw-text fallback when there is no recoverable table structure.

Extracted tables are re-serialized as markdown before reaching the model. It preserves the row and column relationship the model needs in order to know that a number is an amount and not a date.

Isolation lives in the database. Supabase Postgres with row-level security policies, and FastAPI middleware validating Supabase JWTs on every request — the guarantee is not in application code.

Anomaly detection runs z-scores over per-category monthly baselines, so it flags genuine outliers rather than large absolute amounts.

04/

Decisions

  1. 01

    Naive chunking produced plausible garbage

    Fixed-size chunking splits transaction tables mid-row. Retrieved context then contains an amount orphaned from its date and description, and the model answers confidently and wrongly. Chunking semantically around section headers with row boundaries as hard splits fixed it. The failure never surfaced as an error — only as answers that looked right.

  2. 02

    LLM-only categorization, no rule table

    The obvious design is a rule-based first pass on known merchant strings falling back to the model for ambiguous entries. I went model-only. A rule table is a maintenance liability that needs constant curation to stay useful, and with Groq's inference speed and 40-transaction batching the latency difference did not justify it.

    CostA per-upload token cost, permanently, in exchange for zero rule maintenance. A tradeoff, not a free win.

  3. 03

    Currency detection without a geo-IP dependency

    Rather than adding a third-party lookup and its latency and failure mode, currency defaults are inferred client-side from navigator.language and an Africa/Lagos timezone check. The part that makes an imperfect heuristic acceptable is the override toggle sitting next to it.

  4. 04

    Authorization has to resolve before the first byte

    Gating a streamed SSE endpoint is not the same as gating a request. You cannot retroactively return 402 on a response that has already started streaming, so the entitlement check has to complete before the stream opens.

  5. 05

    Cutting weight the architecture never needed

    Deployment broke on ML dependency wheels unbuilt for Python 3.13. I pinned to 3.11 via runtime.txt and, separately, stripped torch and HuggingFace embeddings out of requirements entirely rather than carrying heavyweight local inference for a system that calls a hosted model.

05/

Stack

Backend
  • FastAPI
  • Python 3.11
  • Railway
Model
  • Groq
  • LLaMA 3.3
  • JSON mode
  • LlamaIndex RAG
Parsing
  • pdfplumber
  • pymupdf
  • pandas
Data & auth
  • Supabase Postgres
  • Row-level security
  • Google OAuth
  • JWT middleware
Payments
  • Paystack
  • HMAC-SHA512 webhooks
  • NGN + USD
Interface
  • Next.js App Router
  • Recharts
  • SSE
  • Vercel
What it demonstrates
  • Structured extraction from genuinely unstructured documents
  • RAG over private user data with per-tenant isolation enforced in the database
  • Streaming inference behind an entitlement check
  • Real payment integration with signature-verified webhooks
  • Market-specific engineering where the incumbent approach does not reach