# 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
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
| Time |
Reason |
Method |
Amount |
|
{% for e in entries %}
| {{ e.created_at }} |
{{ e.note or '—' }} |
{{ e.method }} |
{{ currency }}{{ '%.2f'|format(e.amount) }} |
|
{% endfor %}
"""
# --------------------------- 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)