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
curlfor 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
| Variable | Purpose |
|---|---|
APP_ORIGIN | Exact browser origin allowed to change expenses. |
DATABASE_PATH | SQLite file path; relative paths resolve from the project directory. |
CURRENCY | Display 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
| Decision | Why it matters |
|---|---|
| SQLite instead of a separate database server | Simple installation and portable storage for one user. |
| Integer hundredths for money | Exact storage and addition without floating-point drift. |
| One currency per database | Monthly totals remain meaningful without exchange-rate rules. |
| Server-side validation | Browser checks can be bypassed; the API remains responsible for correctness. |
| Date-only values | An expense belongs to a calendar day without timezone conversion. |
| Same-origin UI and API | Straightforward requests with no CORS configuration. |
| Local assets and storage | No third-party service receives expenses or browser requests. |
| Named Docker volume | Records survive container removal and recreation. |
| SQLite backup API | Consistent 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
| Issue | Fix |
|---|---|
| Writes return 403 | Open 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 415 | Send Content-Type: application/json; DELETE requests need it too. |
| Amount returns 400 | Send a string such as "125.50", without currency symbols, commas, scientific notation, or more than two decimals. |
| An expense seems missing | Check the selected month and the database being used. Local runs and Docker have separate storage. |
| Port 5000 is already in use | Stop the other server. If changing the browser port, update APP_ORIGIN and the host port mapping together. |
| SQLite is locked | Keep 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 file | Ensure 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 records | Add new labels safely, but migrate stored records before removing or renaming an existing label. |
| A request loses its response | Refresh 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.