An expense tracker should make it easy to record spending and understand where your money went. It should also let you keep that information under your control.

In this tutorial, you’ll build a browser-based tracker with Python, Flask, and SQLite. It includes editable expenses, monthly totals, category breakdowns, and CSV exports. Docker gives you a repeatable way to run it with persistent storage.

What you’ll build

  • Add, edit, and delete expenses with dates, categories, and notes.
  • Review a selected month and see totals by category.
  • Export that month to a spreadsheet-friendly CSV file.
  • Store records locally and create consistent database backups.
  • Run the application directly with Python or through Docker Compose.

This is a single-user application for your own computer. It listens locally and has no login system. Add authentication and HTTPS before adapting it for remote access.

Prerequisites

  • Python 3.12 or newer
  • Basic Python, HTML, JavaScript, and HTTP knowledge
  • Docker with Docker Compose v2 for the container setup
  • curl for API checks

The shell examples use Bash. On Windows, use WSL or adapt the activation command to PowerShell.

Project structure

expenses-tracker/
├── app.py
├── backup.py
├── requirements.txt
├── .env.example
├── .gitignore
├── .dockerignore
├── Dockerfile
├── compose.yaml
├── templates/
│   └── index.html
├── static/
│   ├── app.js
│   └── style.css
└── data/                     # Created automatically for local runs
    └── expenses.sqlite3

The browser talks to Flask over HTTP. Flask validates requests and reads or writes SQLite. Everything runs without external APIs, CDNs, or cloud accounts.

1. Set up the project and environment

mkdir -p expenses-tracker/templates expenses-tracker/static
cd expenses-tracker
python3 -m venv .venv
source .venv/bin/activate

For PowerShell, activate with .\.venv\Scripts\Activate.ps1.

Create requirements.txt:

Flask==3.1.2
gunicorn==23.0.0
python-dotenv==1.1.1

These pin the direct dependencies. SQLite is included with Python.

Create .env.example:

APP_ORIGIN=http://localhost:5000
DATABASE_PATH=./data/expenses.sqlite3
CURRENCY=NPR

Then create your local configuration and install the dependencies:

cp .env.example .env
python -m pip install -r requirements.txt
VariablePurpose
APP_ORIGINExact browser origin allowed to change expenses.
DATABASE_PATHSQLite file path; relative paths resolve from the project directory.
CURRENCYDisplay and export currency; defaults to Nepalese rupees.

Supported currencies are NPR, INR, USD, EUR, GBP, AUD, and CAD. All use two decimal places in this application. Choose one before recording expenses and keep it fixed: changing the setting relabels existing amounts; it does not convert them.

Create .gitignore:

.venv/
__pycache__/
.env
data/
backups/
*.csv

2. Build the Flask API

Money needs a clear representation. The API accepts amounts as decimal strings, such as "350.50", and stores them as integer hundredths: 35050. This avoids binary floating-point errors in calculations.

The API also validates actual calendar dates, uses parameterized SQL, and opens one database connection per request. SQLite’s write-ahead log improves reader/writer concurrency; the connection timeout lets short writes wait rather than fail immediately.

Create app.py:

import calendar
import csv
import io
import os
import re
import sqlite3
from contextlib import closing
from datetime import date
from decimal import Decimal
from pathlib import Path

from dotenv import load_dotenv
from flask import Flask, Response, abort, g, jsonify, render_template, request
from werkzeug.exceptions import HTTPException

load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
DATABASE = Path(os.getenv("DATABASE_PATH", "./data/expenses.sqlite3"))
if not DATABASE.is_absolute():
    DATABASE = BASE_DIR / DATABASE
ORIGIN = os.getenv("APP_ORIGIN", "http://localhost:5000").rstrip("/")
CURRENCY = os.getenv("CURRENCY", "NPR").upper()
CATEGORIES = ("Food", "Transport", "Housing", "Utilities", "Health", "Other")
if CURRENCY not in {"NPR", "INR", "USD", "EUR", "GBP", "AUD", "CAD"}:
    raise ValueError("Choose a supported currency with two decimal places.")

app = Flask(__name__)
app.config.update(
    MAX_CONTENT_LENGTH=16 * 1024,
    TRUSTED_HOSTS=["localhost", "127.0.0.1", "[::1]"],
)

DATABASE.parent.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(DATABASE, timeout=10)) as db:
    db.execute("PRAGMA journal_mode=WAL")
    with db:
        db.executescript("""
            CREATE TABLE IF NOT EXISTS expenses (
                id INTEGER PRIMARY KEY,
                expense_date TEXT NOT NULL,
                amount_cents INTEGER NOT NULL CHECK (
                    typeof(amount_cents) = 'integer'
                    AND amount_cents BETWEEN 1 AND 999999999
                ),
                category TEXT NOT NULL,
                note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 500)
            );
            CREATE INDEX IF NOT EXISTS expenses_date_idx
                ON expenses(expense_date);
        """)


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DATABASE, timeout=10)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(_error):
    db = g.pop("db", None)
    if db is not None:
        db.close()


@app.before_request
def protect_writes():
    if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
        if request.headers.get("Origin") != ORIGIN:
            abort(403, description=f"Use the configured origin: {ORIGIN}")
        if not request.is_json:
            abort(415, description="Send Content-Type: application/json.")


@app.after_request
def response_headers(response):
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["Referrer-Policy"] = "no-referrer"
    response.headers["Content-Security-Policy"] = (
        "default-src 'self'; base-uri 'none'; "
        "frame-ancestors 'none'; form-action 'self'"
    )
    if request.path.startswith("/api/"):
        response.headers["Cache-Control"] = "no-store"
    return response


@app.errorhandler(HTTPException)
def http_error(error):
    return jsonify(error=error.description), error.code


@app.errorhandler(sqlite3.OperationalError)
def database_error(error):
    app.logger.error("Database operation failed: %s", error)
    return jsonify(error="Database unavailable; check the server logs."), 503


def validate_expense():
    body = request.get_json()
    required = {"expense_date", "amount", "category"}
    if (not isinstance(body, dict) or not required <= body.keys()
            or body.keys() - (required | {"note"})):
        abort(400, description="Send expense_date, amount, category, and optional note.")
    day, amount, category = (body[key] for key in ("expense_date", "amount", "category"))
    note = body.get("note", "")
    if not isinstance(day, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", day):
        abort(400, description="expense_date must use YYYY-MM-DD.")
    try:
        date.fromisoformat(day)
    except ValueError:
        abort(400, description="expense_date must be a real calendar date.")
    if not isinstance(amount, str) or not re.fullmatch(
        r"(?:0|[1-9][0-9]{0,6})(?:\.[0-9]{1,2})?", amount
    ):
        abort(400, description="amount must be a decimal string, e.g. 350.50.")
    cents = int(Decimal(amount) * 100)
    if cents < 1:
        abort(400, description="amount must be greater than zero.")
    if not isinstance(category, str) or category not in CATEGORIES:
        abort(400, description="Choose one of the configured categories.")
    if not isinstance(note, str) or len(note) > 500:
        abort(400, description="note must be text with at most 500 characters.")
    return day, cents, category, note.strip()


def month_rows():
    month = request.args.get("month", date.today().strftime("%Y-%m"))
    if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", month):
        abort(400, description="month must use YYYY-MM.")
    try:
        first = date.fromisoformat(f"{month}-01")
    except ValueError:
        abort(400, description="month must be a real calendar month.")
    last_day = calendar.monthrange(first.year, first.month)[1]
    last = f"{month}-{last_day:02d}"
    rows = get_db().execute(
        "SELECT * FROM expenses WHERE expense_date BETWEEN ? AND ? "
        "ORDER BY expense_date DESC, id DESC",
        (f"{month}-01", last),
    ).fetchall()
    return month, [dict(row) for row in rows]


@app.get("/")
def index():
    return render_template("index.html")


@app.get("/health")
def health():
    get_db().execute("SELECT id FROM expenses LIMIT 1").fetchone()
    return jsonify(status="ok")


@app.get("/api/config")
def config():
    return jsonify(currency=CURRENCY, categories=CATEGORIES)


@app.get("/api/expenses")
def list_expenses():
    month, rows = month_rows()
    totals = {category: 0 for category in CATEGORIES}
    for row in rows:
        totals[row["category"]] += row["amount_cents"]
    return jsonify(
        month=month, expenses=rows,
        summary={"total_cents": sum(totals.values()), "count": len(rows),
                 "by_category": totals},
    )


@app.post("/api/expenses")
def create_expense():
    values = validate_expense()
    db = get_db()
    with db:
        cursor = db.execute(
            "INSERT INTO expenses (expense_date, amount_cents, category, note) "
            "VALUES (?, ?, ?, ?)", values,
        )
    return jsonify(id=cursor.lastrowid), 201


@app.put("/api/expenses/<int:expense_id>")
def update_expense(expense_id):
    values = validate_expense()
    db = get_db()
    with db:
        cursor = db.execute(
            "UPDATE expenses SET expense_date=?, amount_cents=?, category=?, note=? "
            "WHERE id=?", (*values, expense_id),
        )
        if cursor.rowcount == 0:
            abort(404, description="Expense not found.")
    return jsonify(id=expense_id)


@app.delete("/api/expenses/<int:expense_id>")
def delete_expense(expense_id):
    db = get_db()
    with db:
        cursor = db.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
        if cursor.rowcount == 0:
            abort(404, description="Expense not found.")
    return "", 204


@app.get("/api/export")
def export_csv():
    month, rows = month_rows()
    output = io.StringIO(newline="")
    writer = csv.writer(output)
    writer.writerow(["id", "date", "amount", "currency", "category", "note"])
    for row in rows:
        note = row["note"]
        if note.lstrip().startswith(("=", "+", "-", "@")):
            note = "'" + note
        cents = row["amount_cents"]
        writer.writerow([
            row["id"], row["expense_date"], f"{cents // 100}.{cents % 100:02d}",
            CURRENCY, row["category"], note,
        ])
    return Response(
        output.getvalue(), mimetype="text/csv",
        headers={"Content-Disposition": f'attachment; filename="expenses-{month}.csv"'},
    )

PUT replaces the complete expense, so send the date, amount, and category each time. Notes are optional. Amounts must be positive and cannot exceed 9,999,999.99; refunds and mixed currencies are outside this version’s scope.

Monthly queries use an indexed date range. ISO dates sort chronologically, and the inclusive range includes every day of the selected month, including leap days.

The Origin check, trusted local hosts, and JSON requirement protect against unwanted browser requests. CORS remains disabled. These protections support local use; they do not authenticate users.

3. Build the browser interface

Keep HTML, CSS, and JavaScript in separate files. The Content Security Policy permits assets from this application and blocks inline scripts.

Create templates/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Personal Expenses</title>
  <link rel="stylesheet" href="/static/style.css">
  <script src="/static/app.js" defer></script>
</head>
<body>
  <main>
    <h1>Personal Expenses</h1>
    <p>Record spending, review your month, and keep your data locally.</p>
    <p id="message" role="status" aria-live="polite"></p>

    <section>
      <h2 id="editor-title">Add an expense</h2>
      <form id="expense-form">
        <label>Date <input id="expense-date" type="date" required></label>
        <label>Amount <input id="amount" type="number" min="0.01"
          max="9999999.99" step="0.01" required></label>
        <label>Category <select id="category" required></select></label>
        <label class="wide">Note <textarea id="note" maxlength="500" rows="2"></textarea></label>
        <div class="wide">
          <button id="save" type="submit" disabled>Save expense</button>
          <button id="cancel" type="button" hidden>Cancel edit</button>
        </div>
      </form>
    </section>

    <section>
      <div class="toolbar">
        <h2>Monthly overview</h2>
        <label>Month <input id="month" type="month" required></label>
        <a id="export" href="/api/export">Download CSV</a>
      </div>
      <p id="total"></p>
      <ul id="breakdown"></ul>
      <div class="table-wrap">
        <table>
          <thead><tr><th>Date</th><th>Category</th><th>Note</th><th>Amount</th><th>Actions</th></tr></thead>
          <tbody id="expenses"></tbody>
        </table>
      </div>
      <p id="empty" hidden>No expenses for this month.</p>
    </section>
  </main>
</body>
</html>

Create static/style.css:

:root { color-scheme: light; font-family: system-ui, sans-serif; color: #172033; }
body { margin: 0; background: #f3f5f8; }
main { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
section { background: white; padding: 1.5rem; margin: 1rem 0; border-radius: 12px; }
h1, h2 { margin-top: 0; }
form { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
label { display: grid; gap: .4rem; }
input, select, textarea, button { font: inherit; padding: .65rem; }
input, select, textarea { border: 1px solid #8d99aa; border-radius: 6px; min-width: 0; }
button { cursor: pointer; border: 0; border-radius: 6px; background: #174bc1; color: white; }
button:disabled { opacity: .6; cursor: wait; }
[hidden] { display: none !important; }
.wide { grid-column: 1 / -1; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
.toolbar h2 { margin: 0; margin-right: auto; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .75rem .5rem; text-align: left; border-bottom: 1px solid #e0e5ec; }
td:nth-child(3) { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 260px; }
td:last-child { white-space: nowrap; }
td button { margin-right: .4rem; padding: .4rem .6rem; }
#message { min-height: 1.5rem; }
#total { font-weight: 700; }
@media (max-width: 600px) { form { grid-template-columns: 1fr; } }

Create static/app.js:

const $ = (id) => document.getElementById(id);
let editingId = null;
let formatMoney;
let loadVersion = 0;

function localDate() {
  const now = new Date();
  return `${String(now.getFullYear()).padStart(4, "0")}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}

function amountString(cents) {
  return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
}

async function api(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: options.body ? { "Content-Type": "application/json" } : {},
  });
  const data = response.status === 204 ? null : await response.json();
  if (!response.ok) throw new Error(data.error || "Request failed.");
  return data;
}

function resetEditor() {
  editingId = null;
  $("expense-form").reset();
  $("expense-date").value = localDate();
  $("editor-title").textContent = "Add an expense";
  $("save").textContent = "Save expense";
  $("cancel").hidden = true;
}

function startEdit(expense) {
  editingId = expense.id;
  $("expense-date").value = expense.expense_date;
  $("amount").value = amountString(expense.amount_cents);
  $("category").value = expense.category;
  $("note").value = expense.note;
  $("editor-title").textContent = "Edit expense";
  $("save").textContent = "Update expense";
  $("cancel").hidden = false;
  $("amount").focus();
}

async function reload() {
  const version = ++loadVersion;
  const query = new URLSearchParams({ month: $("month").value });
  const data = await api(`/api/expenses?${query}`);
  if (version !== loadVersion) return;

  const count = data.summary.count;
  $("total").textContent = `${formatMoney(data.summary.total_cents)} · ${count} ${count === 1 ? "expense" : "expenses"}`;
  $("export").href = `/api/export?${query}`;
  $("breakdown").replaceChildren();
  for (const [category, cents] of Object.entries(data.summary.by_category)) {
    if (cents === 0) continue;
    const item = document.createElement("li");
    item.textContent = `${category}: ${formatMoney(cents)}`;
    $("breakdown").append(item);
  }

  $("expenses").replaceChildren();
  $("empty").hidden = data.expenses.length !== 0;
  for (const expense of data.expenses) {
    const row = document.createElement("tr");
    for (const value of [expense.expense_date, expense.category, expense.note,
                         formatMoney(expense.amount_cents)]) {
      const cell = document.createElement("td");
      cell.textContent = value;
      row.append(cell);
    }
    const actions = document.createElement("td");
    const edit = document.createElement("button");
    edit.textContent = "Edit";
    edit.addEventListener("click", () => startEdit(expense));
    const remove = document.createElement("button");
    remove.textContent = "Delete";
    remove.addEventListener("click", async () => {
      if (!confirm("Delete this expense?")) return;
      remove.disabled = true;
      try {
        await api(`/api/expenses/${expense.id}`, { method: "DELETE", body: "{}" });
        if (editingId === expense.id) resetEditor();
        await reload();
        $("message").textContent = "Expense deleted.";
      } catch (error) {
        $("message").textContent = error.message;
      } finally {
        remove.disabled = false;
      }
    });
    actions.append(edit, remove);
    row.append(actions);
    $("expenses").append(row);
  }
}

$("expense-form").addEventListener("submit", async (event) => {
  event.preventDefault();
  const payload = {
    expense_date: $("expense-date").value,
    amount: $("amount").value,
    category: $("category").value,
    note: $("note").value,
  };
  const url = editingId === null ? "/api/expenses" : `/api/expenses/${editingId}`;
  const method = editingId === null ? "POST" : "PUT";
  $("save").disabled = true;
  try {
    await api(url, { method, body: JSON.stringify(payload) });
    resetEditor();
    $("month").value = payload.expense_date.slice(0, 7);
    await reload();
    $("message").textContent = "Expense saved.";
  } catch (error) {
    $("message").textContent = error.message;
  } finally {
    $("save").disabled = false;
  }
});

$("cancel").addEventListener("click", resetEditor);
$("month").addEventListener("change", () => {
  reload().catch((error) => { $("message").textContent = error.message; });
});

async function init() {
  const config = await api("/api/config");
  const formatter = new Intl.NumberFormat("en", {
    style: "currency", currency: config.currency,
  });
  formatMoney = (cents) => formatter.format(cents / 100);
  for (const category of config.categories) {
    $("category").add(new Option(category, category));
  }
  resetEditor();
  $("month").value = localDate().slice(0, 7);
  await reload();
  $("save").disabled = false;
}

init().catch((error) => { $("message").textContent = error.message; });

The browser sends amounts as strings and displays totals calculated on the server. textContent renders notes as text, so a note containing HTML cannot create elements or execute scripts.

The default expense date uses local calendar components. Using toISOString() here could shift the date because it converts to UTC. The reload counter also prevents a slower response from replacing a newer month selection.

4. Run the tracker locally

python -m flask --app app run --host 127.0.0.1 --port 5000

Open http://localhost:5000. Use that exact address: http://127.0.0.1:5000 has a different Origin and will fail write requests with the default configuration.

Add a few expenses, change the month, edit an entry, and download its CSV. The first run creates the database and index automatically. Restarting the server keeps your records.

The Flask development server is convenient while building. The Docker setup below runs Gunicorn for regular use.

5. Test the API

Keep the server running and open another terminal in the project directory. Activate the virtual environment there too.

Check health and create an expense

curl -fsS http://localhost:5000/health

EXPENSE_ID=$(curl -fsS http://localhost:5000/api/expenses \
  -H 'Origin: http://localhost:5000' \
  -H 'Content-Type: application/json' \
  --data '{"expense_date":"2026-10-14","amount":"350.50","category":"Food","note":"Lunch"}' \
  | python -c 'import json, sys; print(json.load(sys.stdin)["id"])')

echo "Created expense $EXPENSE_ID"

The health response is {"status":"ok"}. Creating an expense returns HTTP 201 with its ID.

List a month and inspect its summary

curl -fsS 'http://localhost:5000/api/expenses?month=2026-10' \
  | python -m json.tool

Update and export

curl -fsS -X PUT "http://localhost:5000/api/expenses/$EXPENSE_ID" \
  -H 'Origin: http://localhost:5000' \
  -H 'Content-Type: application/json' \
  --data '{"expense_date":"2026-10-14","amount":"375.75","category":"Food","note":"Lunch and tea"}'

curl -fsS 'http://localhost:5000/api/export?month=2026-10' \
  --output october-expenses.csv

Confirm invalid requests are rejected

# Expected: HTTP 400 because the amount is zero.
curl -i http://localhost:5000/api/expenses \
  -H 'Origin: http://localhost:5000' \
  -H 'Content-Type: application/json' \
  --data '{"expense_date":"2026-10-14","amount":"0","category":"Food"}'

# Expected: HTTP 403 because the Origin header is missing.
curl -i http://localhost:5000/api/expenses \
  -H 'Content-Type: application/json' \
  --data '{"expense_date":"2026-10-14","amount":"10.00","category":"Food"}'

Delete the test expense

curl -i -X DELETE "http://localhost:5000/api/expenses/$EXPENSE_ID" \
  -H 'Origin: http://localhost:5000' \
  -H 'Content-Type: application/json' \
  --data '{}'

Expect HTTP 204 with no response body.

6. Add database backups

A monthly CSV is useful for analysis, but a database backup preserves all records. Use SQLite’s backup API rather than copying a live database file.

Create backup.py:

import os
import sqlite3
import sys
from contextlib import closing
from pathlib import Path

from dotenv import load_dotenv

load_dotenv()
if len(sys.argv) != 2:
    raise SystemExit("Usage: python backup.py OUTPUT_PATH")
source = Path(os.getenv("DATABASE_PATH", "./data/expenses.sqlite3"))
if not source.is_absolute():
    source = Path(__file__).resolve().parent / source
target = Path(sys.argv[1]).resolve()
if target.exists():
    raise SystemExit("Output already exists; choose a new backup filename.")
target.parent.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(source.resolve().as_uri() + "?mode=ro", uri=True)) as src:
    with closing(sqlite3.connect(target)) as dst:
        src.backup(dst)
print(f"Backup saved to {target}")

Make a local backup while the tracker is running:

python backup.py backups/expenses.sqlite3

7. Run with Docker Compose

Create .dockerignore:

.venv
__pycache__
.env
.git
data
backups
*.csv

Create Dockerfile:

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home --uid 10001 tracker \
    && mkdir /app/data && chown tracker:tracker /app/data
COPY --chown=tracker:tracker app.py backup.py ./
COPY --chown=tracker:tracker templates ./templates
COPY --chown=tracker:tracker static ./static
USER tracker
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "4", "app:app"]

Create compose.yaml:

services:
  tracker:
    build: .
    env_file: .env
    environment:
      DATABASE_PATH: /app/data/expenses.sqlite3
    ports:
      - "127.0.0.1:5000:5000"
    volumes:
      - expenses_data:/app/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health', timeout=2)"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s

volumes:
  expenses_data:

Stop the local Flask server with Ctrl+C before starting Docker.

docker compose up --build -d --wait
docker compose ps
docker compose logs --tail 50 tracker

Open http://localhost:5000 again. The published port binds to your computer’s loopback interface. SQLite lives in a named volume, so replacing the container keeps its records.

Verify persistence

Add an expense through the browser, then recreate the container:

docker compose down
docker compose up -d --wait

Refresh the browser. Your expense should still be present. Avoid docker compose down -v when retaining records.

Back up the Docker database

mkdir -p backups
docker compose exec -T tracker python backup.py /tmp/expenses-backup.sqlite3
docker compose cp tracker:/tmp/expenses-backup.sqlite3 ./backups/expenses-docker.sqlite3

Design decisions

DecisionWhy it matters
SQLite instead of a separate database serverSimple installation and portable storage for one user.
Integer hundredths for moneyExact storage and addition without floating-point drift.
One currency per databaseMonthly totals remain meaningful without exchange-rate rules.
Server-side validationBrowser checks can be bypassed; the API remains responsible for correctness.
Date-only valuesAn expense belongs to a calendar day without timezone conversion.
Same-origin UI and APIStraightforward requests with no CORS configuration.
Local assets and storageNo third-party service receives expenses or browser requests.
Named Docker volumeRecords survive container removal and recreation.
SQLite backup APIConsistent snapshots without manually managing WAL files.

The monthly list is intentionally unpaginated. That keeps the code manageable for a personal tracker. If you add pagination, calculate summaries and exports over the entire month, rather than only the current page.

Common issues and troubleshooting

IssueFix
Writes return 403Open the exact APP_ORIGIN. Curl mutations must include the matching Origin header. After editing .env, restart a local server or recreate Docker containers with docker compose up -d --force-recreate.
Writes return 415Send Content-Type: application/json; DELETE requests need it too.
Amount returns 400Send a string such as "125.50", without currency symbols, commas, scientific notation, or more than two decimals.
An expense seems missingCheck the selected month and the database being used. Local runs and Docker have separate storage.
Port 5000 is already in useStop the other server. If changing the browser port, update APP_ORIGIN and the host port mapping together.
SQLite is lockedKeep write transactions short and avoid long-running external writers. The app waits up to ten seconds before reporting a database error.
SQLite cannot open its fileEnsure the database directory is writable. The provided named volume gets the container user’s ownership; custom bind mounts need matching permissions.
Category changes break existing recordsAdd new labels safely, but migrate stored records before removing or renaming an existing label.
A request loses its responseRefresh before submitting again; the first write may already have committed. Idempotency keys are a useful later improvement.

For container errors, inspect docker compose logs tracker. Schema creation only initializes missing tables; future schema changes should use explicit migrations.

What you learned

  • Build a complete Flask API and connect it to a small browser interface.
  • Represent money precisely and validate data at the API boundary.
  • Query monthly spending and export usable CSV files.
  • Persist SQLite data with Docker volumes and create consistent backups.
  • Apply browser protections to a locally hosted application.

Possible next steps

  • Add monthly budgets and highlight categories approaching their limits.
  • Add recurring expenses and an explicit refund model.
  • Import bank CSVs with a preview, validation, and duplicate detection.
  • Add search and pagination while preserving full-month totals.
  • Persist currency metadata and manage categories through migrations.
  • Add authentication, per-user ownership, and HTTPS for remote access.
  • Add idempotency keys and automated scheduled backups.