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:

Auto-Categorization
Type "Uber" or "Swiggy" — the model predicts the category. No dropdowns, no extra taps.
Effortless Expense Entry
Just a name and an amount. No forms, no cognitive load, no friction.
Spending Insights
Category breakdowns and bar charts that show where your money goes — without digging.
Polished UI
Built in Flutter with blurred glass navbars, smooth transitions, and clean card layouts.

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:

Frontend
Flutter
Animations, rich UI components, smooth cross-platform performance.
Backend
Node.js + Express
API orchestration — receives requests, calls the ML service, writes to the database.
ML Microservice
Flask + Scikit-learn
Stateless prediction service. Loads a pre-trained model and returns categories on demand.
Database
MongoDB
Schema-less NoSQL store for transactions. Fast querying by category and date.

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:

Flutter Frontend user enters name + amount
Node.js Backend receives request, forwards description to Flask
Flask ML Microservice vectorizes description, returns predicted category
MongoDB stores transaction with category attached

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})
Input: "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.

Stack Layers 4 Flutter · Node · Flask · MongoDB
ML Endpoint 1 Single stateless /api/predict
User Input 2 Fields — name and amount. That's it.

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