# SplitBuddy — tiny Flask app to track IOUs between two roommates # --------------------------------------------------------------- # Features # - Add entries with + / - amount, reason, and method (cash/transfer/other) # - Auto-summary: who owes whom and how much # - List with delete (with confirm) # - CSV export # - Single-file app; uses SQLite (splitbuddy.db) from __future__ import annotations import os, sqlite3, csv, io, datetime as dt from typing import Optional from flask import Flask, g, request, redirect, url_for, render_template_string, jsonify, send_file app = Flask(__name__) # ---------------------------- Config ---------------------------- # DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db") CURRENCY = "₪" # change if you want PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # Convention: stored amount is signed # > 0 => PERSON_A owes PERSON_B ("I owe Idan") # < 0 => PERSON_B owes PERSON_A ("Idan owes me") # ------------------------- DB helpers --------------------------- # def get_db() -> sqlite3.Connection: if "db" not in g: g.db = sqlite3.connect(DB_PATH) g.db.row_factory = sqlite3.Row return g.db @app.teardown_appcontext def close_db(_=None): db = g.pop("db", None) if db is not None: db.close() def init_db(): db = get_db() db.execute( """ CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, amount REAL NOT NULL, -- signed method TEXT NOT NULL, -- cash | transfer | other note TEXT -- reason / memo ) """ ) db.commit() # --------------------------- Templates -------------------------- # BASE = r""" SplitBuddy
SplitBuddy
{% if summary.total > 0 %} {{ A }} owes {{ B }} {{ currency }}{{ '%.2f'|format(summary.total) }} {% elif summary.total < 0 %} {{ B }} owes {{ A }} {{ currency }}{{ '%.2f'|format(-summary.total) }} {% else %} All settled ✨ {% endif %} Balance: {{ currency }}{{ '%.2f'|format(summary.total) }} Export CSV

Add entry

Stats

Total entries: {{ summary.count }}
Latest: {{ summary.latest or '—' }}
Cash: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }} Transfer: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }} Other: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}

Ledger

{% for e in entries %} {% endfor %}
Time Reason Method Amount
{{ e.created_at }} {{ e.note or '—' }} {{ e.method }} {{ currency }}{{ '%.2f'|format(e.amount) }}
""" # --------------------------- Utilities -------------------------- # def _now_local_iso_min() -> str: # default to local time (no tz), minute precision for nicer input value now = dt.datetime.now().replace(second=0, microsecond=0) return now.isoformat(timespec="minutes") class ByMethod: def __init__(self, cash=0.0, transfer=0.0, other=0.0): self.cash = cash self.transfer = transfer self.other = other class Summary: def __init__(self, total: float, count: int, latest: Optional[str], by_method: ByMethod): self.total = total self.count = count self.latest = latest self.by_method = by_method # ----------------------------- Routes --------------------------- # @app.before_request def _ensure_db(): init_db() @app.get("/") def index(): db = get_db() rows = db.execute("SELECT id, created_at, amount, method, note FROM entries ORDER BY datetime(created_at) DESC, id DESC").fetchall() entries = [dict(r) for r in rows] total = sum(e["amount"] for e in entries) if entries else 0.0 latest = entries[0]["created_at"] if entries else None bm = ByMethod( cash=sum(e["amount"] for e in entries if e["method"] == "cash"), transfer=sum(e["amount"] for e in entries if e["method"] == "transfer"), other=sum(e["amount"] for e in entries if e["method"] == "other"), ) summary = Summary(total=total, count=len(entries), latest=latest, by_method=bm) return render_template_string( BASE, entries=entries, summary=summary, A=PERSON_A, B=PERSON_B, currency=CURRENCY, now_local=_now_local_iso_min(), ) @app.post("/add") def add(): amount_raw = request.form.get("amount", type=float) sign = request.form.get("sign", "+") method = request.form.get("method", "cash").strip().lower() note = request.form.get("note", "").strip() created_at = request.form.get("created_at") or _now_local_iso_min() if amount_raw is None: return redirect(url_for("index")) if sign not in (+1, -1, "+", "-"): sign = "+" signed = amount_raw * (1 if sign in (+1, "+") else -1) db = get_db() db.execute( "INSERT INTO entries (created_at, amount, method, note) VALUES (?, ?, ?, ?)", (created_at, signed, method, note) ) db.commit() return redirect(url_for("index")) @app.post("/delete/") def delete(entry_id: int): db = get_db() db.execute("DELETE FROM entries WHERE id = ?", (entry_id,)) db.commit() return redirect(url_for("index")) @app.get("/export.csv") def export_csv(): db = get_db() rows = db.execute("SELECT id, created_at, amount, method, note FROM entries ORDER BY datetime(created_at) DESC, id DESC").fetchall() buff = io.StringIO() writer = csv.writer(buff) writer.writerow(["id", "created_at", "amount", "method", "note"]) # header for r in rows: writer.writerow([r["id"], r["created_at"], r["amount"], r["method"], r["note"]]) buff.seek(0) return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name="splitbuddy_export.csv") # Compatibility alias for templates export_csv = export_csv # --------------------------- Entrypoint -------------------------- # if __name__ == "__main__": os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) with app.app_context(): init_db() app.run(debug=True)