#!/usr/bin/env python3
"""Minimal personal expense tracker. Python 3 stdlib only."""
import os
import random
import re
import sqlite3
import string
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "20"))
DB = os.environ.get("DB", "expenses.db")
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "8000"))
def get_db():
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL REFERENCES groups(id),
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL REFERENCES groups(id),
paid_by INTEGER NOT NULL REFERENCES users(id),
description TEXT NOT NULL,
amount REAL NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS expense_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expense_id INTEGER NOT NULL REFERENCES expenses(id),
user_id INTEGER NOT NULL REFERENCES users(id),
percentage REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_expenses_group ON expenses(group_id);
CREATE INDEX IF NOT EXISTS idx_shares_expense ON expense_shares(expense_id);
""")
conn.commit()
conn.close()
def gen_id():
return "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
def compute_debts(group_id):
"""Compute net balances: positive = owed money, negative = owes money."""
conn = get_db()
users = conn.execute("SELECT * FROM users WHERE group_id=?", (group_id,)).fetchall()
balances = {u["id"]: 0.0 for u in users}
# Credit payers
for row in conn.execute(
"SELECT paid_by, SUM(amount) as total FROM expenses WHERE group_id=? GROUP BY paid_by",
(group_id,),
).fetchall():
balances[row["paid_by"]] += row["total"]
# Debit shares
for row in conn.execute(
"SELECT es.user_id, SUM(e.amount * es.percentage / 100.0) as owed FROM expense_shares es JOIN expenses e ON es.expense_id=e.id WHERE e.group_id=? GROUP BY es.user_id",
(group_id,),
).fetchall():
balances[row["user_id"]] -= row["owed"]
conn.close()
# Simplify debts
user_map = {u["id"]: u["name"] for u in users}
debts = []
creditors = [(uid, bal) for uid, bal in balances.items() if bal > 0.01]
debtors = [(uid, -bal) for uid, bal in balances.items() if bal < -0.01]
creditors.sort(key=lambda x: -x[1])
debtors.sort(key=lambda x: -x[1])
i, j = 0, 0
while i < len(debtors) and j < len(creditors):
amount = min(debtors[i][1], creditors[j][1])
debts.append((user_map[debtors[i][0]], user_map[creditors[j][0]], amount))
debtors[i] = (debtors[i][0], debtors[i][1] - amount)
creditors[j] = (creditors[j][0], creditors[j][1] - amount)
if debtors[i][1] < 0.01:
i += 1
if creditors[j][1] < 0.01:
j += 1
return debts
CSS = """
*{box-sizing:border-box;margin:0;}
body{font-family:system-ui,sans-serif;font-size:14px;max-width:900px;margin:0 auto;padding:1rem;color:#222;background:#fafafa}
h1,h2{margin-bottom:.5rem}
a{color:#0066cc}
form{margin-bottom:1rem}
label{display:block;margin:.3rem 0 .1rem}
input[type=text],input[type=number],input[type=date],input[type=time],select{width:100%;padding:.4rem;border:1px solid #ccc;border-radius:4px}
input[type=date],input[type=time],input[name=amount]{min-width:100%;height:33px;padding:0;}
button{padding:.5rem 1rem;background:#0066cc;color:#fff;border:none;border-radius:4px;cursor:pointer;margin-top:.5rem}
button:hover{background:#0052a3}
.section{background:#fff;border:1px solid #e0e0e0;border-radius:6px;padding:1rem;margin-bottom:1rem}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:.2rem .4rem .2rem 0;white-space:nowrap}
tr{border-bottom:1px solid #eee}
td:nth-child(3),th:nth-child(3){max-width:300px;white-space:normal;word-break:break-word}
.shares{display:flex;flex-direction:column;gap:.3rem}
.share-item{display:flex;align-items:center;gap:.3rem}
.share-item input[type=number]{width:4rem;margin-left:auto}
.row{display:flex;gap:.5rem}.row>div{flex:1}
.btn-del{background:none;border:none;color:#c00;cursor:pointer;padding:0;margin:0;}
.actions{white-space:nowrap;vertical-align:middle;padding-right:0!important}
.actions a,.actions button{padding:4px;font-size:1.2rem;text-decoration:none;}
.actions form{margin:0;}
.page-nav{display:flex;gap:1rem;justify-content:center;margin-top:.5rem}
.debt{padding:.3rem 0}
.nav{margin-bottom:1rem;display:flex;align-items:center;flex-wrap:wrap}
.nav h1{margin:0}
.nav .nav-title{display:flex;flex-direction:column}
.nav .group-id{font-size:.75rem;color:#888;font-weight:normal}
.nav .links{margin-left:auto;display:flex;gap:1rem}
.landing{margin:0 -1rem}
@media(min-width:700px){.landing{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:0}.landing .log{grid-column:1/3};input[type=date],input[type=time]{padding:0 4px;}}
select{height:33px;}
"""
def html(title, body, group_id=None):
nav = ""
if group_id:
nav = f'
'
return f"""{title} - {group_id}{nav}{body}"""
def escape(s):
return (
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
qs = parse_qs(parsed.query)
if path == "/favicon.png":
return self.serve_favicon()
if path == "/new":
return self.new_group()
m = re.match(r"^/([a-z0-9]+)$", path)
if m:
return self.landing(m.group(1), qs)
m = re.match(r"^/([a-z0-9]+)/settings$", path)
if m:
return self.settings(m.group(1))
self.send_response(302)
self.send_header("Location", "/new")
self.end_headers()
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode()
data = parse_qs(body)
path = urlparse(self.path).path.rstrip("/")
m = re.match(r"^/([a-z0-9]+)/add$", path)
if m:
return self.add_expense(m.group(1), data)
m = re.match(r"^/([a-z0-9]+)/add-user$", path)
if m:
return self.add_user(m.group(1), data)
m = re.match(r"^/([a-z0-9]+)/delete/(\d+)$", path)
if m:
return self.delete_expense(m.group(1), int(m.group(2)))
self.send_response(400)
self.end_headers()
def serve_favicon(self):
fpath = os.path.join(os.path.dirname(__file__), "favicon.png")
try:
with open(fpath, "rb") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_response(404)
self.end_headers()
def respond(self, code, content):
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(content.encode())
def new_group(self):
gid = gen_id()
conn = get_db()
conn.execute("INSERT INTO groups(id) VALUES(?)", (gid,))
conn.commit()
conn.close()
self.send_response(302)
self.send_header("Location", f"/{gid}/settings")
self.end_headers()
def landing(self, gid, qs):
conn = get_db()
group = conn.execute("SELECT * FROM groups WHERE id=?", (gid,)).fetchone()
if not group:
conn.close()
return self.respond(404, html("Not Found", "
Group not found
"))
users = conn.execute("SELECT * FROM users WHERE group_id=?", (gid,)).fetchall()
page = int(qs.get("page", ["1"])[0])
offset = (page - 1) * PAGE_SIZE
total = conn.execute(
"SELECT COUNT(*) as c FROM expenses WHERE group_id=?", (gid,)
).fetchone()["c"]
expenses = conn.execute(
"SELECT e.*, u.name as payer FROM expenses e JOIN users u ON e.paid_by=u.id WHERE e.group_id=? ORDER BY e.created_at DESC LIMIT ? OFFSET ?",
(gid, PAGE_SIZE, offset),
).fetchall()
# Get shares for prefilling (from copy param or last expense)
copy_id = qs.get("copy", [None])[0]
copy_exp = None
prefill_shares = {}
prefill_desc = ""
prefill_amount = ""
prefill_payer = None
prefill_date = ""
prefill_time = ""
if copy_id:
copy_exp = conn.execute(
"SELECT * FROM expenses WHERE id=? AND group_id=?", (copy_id, gid)
).fetchone()
if copy_exp:
prefill_desc = copy_exp["description"]
prefill_amount = f"{copy_exp['amount']:.2f}"
prefill_payer = copy_exp["paid_by"]
prefill_date = copy_exp["created_at"][:10]
prefill_time = (
copy_exp["created_at"][11:16]
if len(copy_exp["created_at"]) > 10
else "00:00"
)
for s in conn.execute(
"SELECT user_id, percentage FROM expense_shares WHERE expense_id=?",
(copy_exp["id"],),
).fetchall():
prefill_shares[s["user_id"]] = s["percentage"]
else:
last_exp = conn.execute(
"SELECT id FROM expenses WHERE group_id=? ORDER BY created_at DESC LIMIT 1",
(gid,),
).fetchone()
if last_exp:
for s in conn.execute(
"SELECT user_id, percentage FROM expense_shares WHERE expense_id=?",
(last_exp["id"],),
).fetchall():
prefill_shares[s["user_id"]] = s["percentage"]
# Add expense form
form = '
'
conn.close()
self.respond(200, html("Expenses", body, gid))
def settings(self, gid):
conn = get_db()
group = conn.execute("SELECT * FROM groups WHERE id=?", (gid,)).fetchone()
if not group:
conn.close()
return self.respond(404, html("Not Found", "
Group not found
"))
users = conn.execute("SELECT * FROM users WHERE group_id=?", (gid,)).fetchall()
conn.close()
user_list = ""
for u in users:
user_list += f"
{escape(u['name'])}
"
body = f"""
Members
{user_list if user_list else "
No members yet
"}
Share Link
Anyone with this link can view, add and delete expenses in this group: