Savvy
Production-grade personal finance microservices platform — Claude AI bank statement analysis, 7-node LangGraph recommendation engine, Islamic finance features (Zakat/Qurbani/Shariah screening), Kafka event bus, 6 independent FastAPI services, and Kubernetes-ready Docker orchestration.
Solo. I own everything in this repo: the seven-service backend (115 routed endpoints across api-gateway, user-service, finance-service, bank-service, statement-analysis-service, ai-recommendation-service, notification-service), the schema and Alembic migrations for the four PostgreSQL databases, the PostgreSQL row-level-security policies, the Kafka event contracts in microservices/shared/events/, the Next.js frontend (16 dashboard pages, 2 Zustand stores), the Docker Compose topology, the Kubernetes manifests and EKS bootstrap scripts, and the four GitHub Actions workflows. 264 Python files, ~22,250 lines of Python, 479 tests in 21 test files. No one else committed to it.
// the problem
What was broken
Someone who wants to manage money according to Islamic obligation has to do it on paper or in a spreadsheet. Mainstream budgeting apps have no concept of zakat — you cannot ask them what 2.5% of your zakatable wealth is, because they do not know which of your assets are zakatable or what the nisab threshold is this year. They have no way to mark a loan as interest-bearing and treat that differently from an interest-free one. Planning for qurbani, hajj, or a cousin's wedding means keeping a separate goal-tracking sheet, then manually reconciling it against a bank statement at the end of the month. The reconciliation step is the expensive one: reading a PDF statement line by line and typing each transaction into a category is an hour of work that people simply stop doing after the second month.
// constraints
What made it hard
Solo build, no budget for paid infrastructure during development, so everything had to run on a laptop via Docker Compose and still be deployable to a real cluster without a rewrite. Bank statements are the input and there is no aggregator API for Pakistani banks — statements arrive as PDF, CSV, or XLSX exports with wildly inconsistent formats, so parsing had to be tolerant rather than strict. The data is financial and personally identifying (CNIC numbers, IBANs, account numbers), which pushed encryption, PII log masking, and per-user database isolation from "later" into the first pass. And the AI path costs real money per call, so the design had to assume an adversarial user trying to burn API credits.
// architecture
How it fits together
Seven FastAPI services, each with its own PostgreSQL database and its own Docker network. The obvious alternative was a modular monolith, which for a solo project would have been faster to build — I chose services anyway because the statement-analysis path has a completely different resource profile from CRUD: it is long-running, memory-heavy, and calls a paid external API, and I wanted to be able to scale and rate-limit that independently. The Kubernetes HPAs reflect this — api-gateway scales 2→8, finance-service and bank-service 2→6, ai-recommendation-service 1→4, all at 70% CPU target. The cost is real: finance-service cannot join against bank-service tables, so anything cross-domain goes through Kafka events rather than SQL.
Auth is centralised at the gateway and nowhere else. The gateway validates the JWT, injects X-User-ID, and signs the forwarded request with HMAC-SHA256 (shared/utils/service_auth.py, ±30s replay tolerance) so a downstream service can prove the request came from the gateway rather than from a compromised sibling container. Downstream, finance-service takes that header, sets SET LOCAL app.user_id on the transaction, and lets PostgreSQL RLS policies enforce ownership on all 11 user-owned tables — so a missing WHERE user_id = ... in application code returns zero rows instead of another user's expenses. Defence in depth, deliberately: the ORM filters, and the database refuses. Statement processing is fully asynchronous — the upload endpoint returns as soon as the file is in S3 and the event is published, because AI extraction of a 12-month PDF is not something to hold an HTTP connection open for.
Next.js client
Uploads a PDF/CSV/XLSX statement to /api/v1/statements
api-gateway
Validates JWT, injects X-User-ID, applies the 10-req/hour AI rate limit, HMAC-signs, proxies
bank-service
Validates type and size, writes the file to S3, persists a BankStatement row, publishes bank_statement.uploaded
Kafka
financial_bank_statement.uploaded, partitioned by user_id so one user's events stay ordered
statement-analysis-service
Consumer thread downloads from S3, parses, sanitises the text, sends it to Claude with a cached system prompt
Categorisation pipeline
ChromaDB vector match first, regex rules as fallback, confidence blended and written back as a learned pattern
Redis
Holds live progress status the client polls while processing runs
finance-service + notification-service
Consume statement.processed, create categorised expenses and an in-app/email notification
// challenges
Where I got stuck
Categorisation had to work when the AI was unavailable
My first pass sent every transaction to Claude for categorisation, which was slow and expensive per statement. I replaced it with a three-tier chain in statement_processor._categorize_one: ChromaDB similarity vote first (5 neighbours, cosine), regex rules if the vector store returns nothing, and a hard-coded "Other" fallback if both throw. Every categorised transaction is then written back into ChromaDB as a pattern, so the vector tier improves as it runs. The honest weakness is that the regex tier in rule_categorizer.py is US-merchant biased (Starbucks, Walgreens, Verizon) while the Claude prompt is tuned for Pakistani and Gulf merchants (Total Parco, Careem, LESCO, Easypaisa) — so the fallback path is worst exactly where the target user lives. That is the next thing I would fix.
Prompt injection through an uploaded file
A bank statement is untrusted text that goes straight into an LLM prompt, and a PDF can carry invisible Unicode or HTML that a human reviewer never sees. app/ai/input_sanitizer.py strips control and zero-width characters, strips HTML/XML tags, truncates to 50,000 chars, and rejects the document outright on 13 injection patterns. I paired that with an anti-disclosure clause in the system prompt itself — if the model detects an override attempt it returns [] rather than prose. Belt and braces, because regex alone will not catch a paraphrase.
Refresh-token theft detection without a session table
Rotating refresh tokens is easy; detecting that a stolen one was replayed is not. rotate_refresh_token stores each token's jti in Redis and deletes it on use — if a valid JWT arrives whose jti is already gone, the token was used twice, which means theft, and it raises rather than issuing new credentials. The same Redis sorted set enforces a 5-session cap per user and evicts the oldest. The tradeoff I accepted: if Redis is down the rotation fails open and issues tokens anyway, because locking every user out of the product when a cache dies is the worse failure.
Kafka consumers that lose work on crash
The shared EventConsumer in shared/utils/kafka_client.py sets enable_auto_commit=False and only commits after the handler returns, so a crash reprocesses the message. The statement-analysis consumer in app/events/consumer.py builds its own KafkaConsumer without that flag, so it auto-commits — a container restart mid-extraction silently drops that statement, and the user sees a job stuck at 50%. I know about it, the fix is to route it through the shared client, and it is not done.
RLS versus Alembic
Turning on FORCE ROW LEVEL SECURITY broke migrations, because Alembic runs outside any request and has no app.user_id set. The fix was current_setting('app.user_id', true) with the missing-ok flag so it returns NULL instead of raising, plus a documented BYPASSRLS admin role for data migrations. Getting SET LOCAL rather than SET was the other half — with a connection pool, a session-scoped variable leaks one user's ID into the next request on the same connection.
// new ground
What I hadn't used before
database/rls/finance_rls.sql forced me to understand that RLS is not a WHERE clause helper — it changes what UPDATE and DELETE silently no-op on, interacts badly with connection pooling unless you use SET LOCAL, and needs an explicit bypass path for migrations. It is the only control in this system that still works if my ORM code is wrong.ai-recommendation-service/app/workflows/ to model recommendation generation as an explicit state graph — profile lookup, AI generation, rule-based fallback, merge — rather than nested try/except. What I understand now that I did not before: the value is not orchestration (four sequential nodes is not hard), it is that the fallback becomes a node in the graph instead of an exception handler, so "AI returned one weak suggestion" and "AI threw" are the same code path.app/ai/prompts.py is deliberately long — full category taxonomy, merchant-cleaning rules, 12 worked examples — because it must clear the 1024-token threshold for cache_control: ephemeral to engage. Learning that a *bigger* static prompt plus a tiny dynamic user turn is cheaper than a compact prompt rebuilt every call inverted how I write prompts.// takeaways
What I took away
- Fail-open and fail-closed are per-control decisions, not a system-wide policy — rate limiting fails open because availability matters more than a perfect limit, while the HMAC check fails closed in production because a forged internal request is worse than an outage.
- Designing the AI rate limit (10 requests/hour, separate key namespace) before writing the AI feature was the single highest-leverage decision, because cost-exhaustion is the one attack that hurts before you notice it.
- Splitting into services made every cross-domain read an event, and I underestimated how much of the codebase would end up being producer/consumer plumbing rather than product logic.
- Documentation and code drift apart faster than either drifts from reality — the README quotes nisab as 87.48g gold / 612.36g silver while
zakat_service.pyuses 85g / 595g, and nothing caught it because no test asserts the constants. - A "learned pattern" store is only as good as its write path: because ChromaDB writes happen sequentially after a parallel categorisation pass, that loop is the throughput ceiling of the whole pipeline, and I did not see it until I profiled.
// the journey
How it actually went
It started as a specific irritation rather than a product idea. I wanted to plan for zakat, qurbani, and a set of upcoming family expenses in the same place I tracked ordinary spending, and every app I tried treated the religious obligations as if they did not exist. The original brief I wrote for myself is still in the repo as Project Brief.txt — it is one long paragraph, half feature list and half wishful thinking, and reading it now the thing that stands out is how much of it survived.
I got the AI part backwards at first. My assumption was that Claude would do the categorisation and the deterministic code would just be glue. That is expensive and it is fragile — one API outage and the core feature returns nothing. The rewrite inverted it: deterministic categorisation is the default path, the vector store makes it better over time, and the model is used for the one thing rules genuinely cannot do, which is turning a wall of ragged PDF text into structured rows. The confidence score in confidence_scorer.combine exists because of that inversion — I needed a single number that says how much to trust a row regardless of which tier produced it.
The moment it clicked was writing the RLS policies. Up to that point security was a checklist I was working through. Running a query with the wrong app.user_id and watching PostgreSQL return zero rows for data I knew was there reframed it: the question stopped being "did I remember the filter" and became "what still holds when I forget". Everything after that — HMAC between services, the six-network topology where database containers are unreachable by non-owning services, the PII masking filter attached at the logging layer so no future logger.info can leak a CNIC — came out of that same question.
What I would do differently: I would not have committed this as two commits. The whole build happened locally and landed in one drop, which means the git history records nothing about where the difficulty actually was — the three rewrites of the categorisation pipeline, the RLS-versus-Alembic fight, the switch from SET to SET LOCAL. All of that is reconstructable from the code and the comments I left, but it should have been in the log. I would also have written the integration tests earlier. 479 unit tests is a reasonable number, and they are concentrated exactly where I was least confident — 46 in the recommendation service, 43 on gateway auth, 33 on categorisation — but they all mock Kafka, Redis, S3, and Claude, which is why a live bug like the health-score endpoint querying ZakatRecord.calculated_at and is_paid when the model actually has calculation_date and payment_status sat there undetected. The unit tests cannot see it. A single end-to-end run would have.
// outcome
What it changed
Module 1 shipped: 115 routed API endpoints across 7 services, 4 PostgreSQL databases with Alembic migrations, RLS on all 11 user-owned finance tables, a Kafka event bus with 5 typed event families, ChromaDB-backed categorisation, and a Next.js frontend covering 16 dashboard pages including zakat, qurbani, sadaqah, hajj/umrah, net worth, and the Islamic financial health score. 479 tests run in CI across a 7-service matrix, with pip-audit, bandit, npm audit, and Trivy image scanning on every push plus a weekly CVE sweep.
Not shipped: production deployment. The EKS manifests, HPAs, cert-manager issuer, external-secrets bindings, and the 11 numbered bootstrap scripts are written and the repo's own status section marks it as pending cloud provisioning — nothing has run against a real cluster. The four security items the catalogue marks partial or deferred (K8s Secrets among them) are still open.
// overview
In more detail
Savvy is a distributed, event-driven financial management system with 6 independent FastAPI microservices (User, Finance, Bank, Statement Analysis, AI Recommendation, Notification), each with a dedicated PostgreSQL database. Services communicate exclusively via Kafka (20+ event types) — no synchronous inter-service HTTP.
The Statement Analysis service uses Claude AI to extract and categorize every transaction from uploaded PDF/CSV/Excel bank statements with confidence scoring. The AI Recommendation service runs a 7-node LangGraph workflow: Expense Pattern Analysis → Income Assessment → Goal Gap Analysis → Market Research (yfinance) → Shariah Compliance Screening → Risk Assessment → Claude synthesis. Islamic finance features include a full Zakat calculator (Nisab threshold + 2.5% on zakatable wealth), Qurbani savings goals, and Shariah-compliant investment filtering. The frontend is a Next.js glassmorphism UI with Framer Motion and Recharts.
// features
What it does
- Claude AI bank statement analysis — PDF/CSV/Excel upload → S3 → extract transactions → categorize with confidence score
- 7-node LangGraph recommendation workflow — expense analysis, income assessment, goal gap, market research, Shariah screening, risk assessment, Claude synthesis
- Islamic finance — Zakat calculator (Nisab + 2.5% on zakatable wealth), Qurbani savings, Shariah-compliant investment filter
- 6 independent microservices — each with dedicated PostgreSQL database, no shared DB
- Kafka event bus (20+ event types) — no synchronous inter-service HTTP calls
- Budget enforcement via Kafka events — alert on threshold, notification on overspend
- Recurring expenses — auto-created monthly via scheduler
- 16+ expense categories — including Zakat, Qurbani, Eid, Emergency
- Kubernetes manifests + Docker Compose (~15 containers) for local and production orchestration
// stack