diff --git a/main.py b/main.py new file mode 100644 index 0000000..861b5d0 --- /dev/null +++ b/main.py @@ -0,0 +1,301 @@ +# 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 %} + +
TimeReasonMethodAmount
{{ 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) \ No newline at end of file