How I combined ML, Clean UI, and APIs to simplify Personal Finance
Most finance apps ask for too much or feel like they were designed a decade ago. I wanted to build something that could understand my expenses without asking me to. This is how Flutter, Node.js, MongoDB, and a Flask ML microservice came together into something that actually feels good to use.
If you've ever tried keeping track of personal finances, you know how tedious it gets. You enter an expense, select a category from a long list, and hope the app doesn't overwhelm you with charts you didn't ask for.
That's where Velar began. I wanted a tool that could take just a name and an amount, and intelligently guess the category. No dropdowns. No overthinking. Just fast, intuitive tracking with a modern experience.
Type "Starbucks ₹450" and let the system figure out the rest.
Building it required blending Flutter on the front, Node.js and MongoDB at the core, and a Flask ML microservice under the hood. This post walks through the full journey — from training the model to wiring everything together into a working product.
What Makes It Different
Most finance apps either go too deep into categories or too shallow on intelligence. Velar sits between both — minimal input, smart output. Here's what sets it apart:
The Tech Stack
Velar isn't just a finance app — it's a cross-disciplinary project that blends UI/UX with backend reliability and machine learning. Here's what powers it:
System Architecture
The architecture is built around a clean separation of concerns. Each layer does exactly one thing, and the data flows linearly through them:
The ML logic is fully decoupled from the backend. The Flask service is stateless —
it loads the model on startup and exposes a single /api/predict endpoint.
This means it can be containerized, scaled, or swapped out entirely without touching
the Node.js layer.
Auto-Categorization — How the ML Works
The training dataset was a CSV with two columns: description and category. Before anything else, I cleaned it — lowercased everything, stripped punctuation, normalized category names for consistency.
import pandas as pd
df = pd.read_csv("transactions.csv")[['Description', 'Category']]
df['Description'] = df['Description'].str.lower().str.replace('[^a-z\\s]', '', regex=True)
df['Category'] = df['Category'].str.lower().str.replace('[^a-z\\s]', '', regex=True)
Vectorization
Raw text goes through TfidfVectorizer to become numeric vectors the model
can train on. TF-IDF captures how important a word is relative to the whole corpus —
so "Swiggy" carries more signal than "the".
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer()
X_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test)
Model Training
I tested both Logistic Regression and Multinomial Naive Bayes. For this task —
short merchant descriptions with limited vocabulary — Naive Bayes performed well
and trained faster. The model and vectorizer are both exported with joblib
for the Flask service to load at runtime.
from sklearn.naive_bayes import MultinomialNB
import joblib
model = MultinomialNB()
model.fit(X_train_vec, y_train)
joblib.dump(model, 'model.pkl')
joblib.dump(vectorizer, 'vectorizer.pkl')
The Prediction Endpoint
A single Flask route handles all inference. It loads the saved model on startup, accepts a description string, and returns the predicted category:
from flask import Flask, request, jsonify
import joblib
model = joblib.load("model.pkl")
vectorizer = joblib.load("vectorizer.pkl")
app = Flask(__name__)
@app.route("/api/predict", methods=["POST"])
def predict():
description = request.json.get("description", "")
if not description:
return jsonify({"error": "Description is required"}), 400
X = vectorizer.transform([description])
prediction = model.predict(X)[0]
return jsonify({"category": prediction})
"Starbucks" → Output: { "category": "food" }. That's the entire
surface area of the ML service — one endpoint, one job, done.
Node.js — Wiring the Layers Together
The Node.js backend is the orchestrator. When a transaction comes in from Flutter, it forwards the description to Flask, receives the predicted category, and stores everything together in MongoDB:
app.post('/api/transaction/add', async (req, res) => {
try {
const { description, amount } = req.body;
const predictRes = await axios.post('http://localhost:5000/api/predict', {
description,
});
const category = predictRes.data.category || 'Other';
const newTransaction = new Transaction({ description, amount, category });
await newTransaction.save();
res.status(200).json({ message: 'Transaction saved', data: newTransaction });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
The MongoDB schema is intentionally minimal. It stores just what's needed, with a default timestamp and enough flexibility to extend later:
const transactionSchema = new mongoose.Schema({
description : String,
amount : Number,
category : String,
date : { type: Date, default: Date.now },
});
Flutter — Sending Data Without Selecting a Category
From the user's perspective, there is no category step. They type a name and an amount. Flutter sends both to Node.js, and the category comes back attached to the saved transaction:
final response = await http.post(
Uri.parse('http://your-node-server/api/transaction/add'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'description': _expenseName,
'amount' : _amount,
}),
);
The user never sees the model. They just see a categorized transaction appear. That's the product experience the whole stack is built to deliver.
Visualizing Spending with Charts
Data is most useful when it's immediately readable. The Analytics screen uses two approaches: progress bars for category-level breakdowns, and bar charts for time-based spending patterns.
The progress bars keep it simple — each category gets a proportional bar, no pie charts, no confusing legends:
LinearProgressIndicator(
value : category['percentage'] / 100,
backgroundColor : surfaceColor,
valueColor : AlwaysStoppedAnimation(category['color']),
borderRadius : BorderRadius.circular(4),
);
The bar charts use fl_chart for the heavier visualizations on the Analytics
screen. Google Fonts and AnimatedWidgets handle the rest of the visual polish.
What Was Actually Hard
The ML model
Training a model that generalizes beyond merchant names took more iteration than expected. Class imbalance, inconsistent naming conventions, and edge cases like "Netflix electricity bill" (which genuinely could be either) required careful dataset cleaning and multiple rounds of evaluation. This fed directly into the deeper research documented in the Why Accuracy Lied post.
Running two runtimes together
Node.js and Flask running side by side — especially during local development — meant managing port conflicts, CORS issues, and prediction latency that could break the UI if not handled gracefully. Every cross-service call needed proper error handling to ensure Flask failures didn't surface as unhandled exceptions in Flutter.
Flutter's layout system
Building a premium feel in Flutter requires fighting the framework at times.
BackdropFilter for glass navbars, custom animation controllers for
tab transitions, and responsive layout quirks all required iteration to get right.
The effort was worth it — the final UI is clean, fast, and feels native.
What This Project Is Actually About
Velar started as a side project but became a full cross-disciplinary exercise — ML pipelines, backend architecture, responsive UI, and the product thinking that ties them together. Each layer had to work well independently and integrate cleanly with the others.
The core idea is simple: the best interface is the one that asks you for the least. A finance app that makes you think about categories every time you add an expense has already failed at its job. The ML layer exists to remove that friction entirely.
The system should anticipate user behavior rather than react to it.
If you're building something that crosses ML and product — or just want to see how these layers connect in practice — the full code is on GitHub.
lokeshramchand-ctrl / Velar