# SplitBuddy — Flask app with bills/transfer + payer toggle 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, send_file app = Flask(__name__) DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db") CURRENCY = "₪" PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") # you PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # roommate # ------------------------- 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, kind TEXT NOT NULL DEFAULT 'bill', -- 'bill' | 'transfer' total REAL NOT NULL DEFAULT 0, -- total amount payer TEXT NOT NULL DEFAULT 'A', -- 'A' (you) or 'B' (Idan) a_share REAL NOT NULL DEFAULT 0.5, -- your share (0..1), only for bills method TEXT NOT NULL DEFAULT 'cash', note TEXT ) """) # Migrations for older versions for col, ddl in [ ("kind", "ALTER TABLE entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'bill'"), ("total", "ALTER TABLE entries ADD COLUMN total REAL"), ("payer", "ALTER TABLE entries ADD COLUMN payer TEXT"), ("a_share","ALTER TABLE entries ADD COLUMN a_share REAL"), ]: try: db.execute(ddl) except sqlite3.OperationalError: pass # Normalize NULLs from legacy rows db.execute(""" UPDATE entries SET kind = COALESCE(kind, 'bill'), total = COALESCE(total, 0), payer = COALESCE(payer, 'A'), a_share= COALESCE(a_share, 0.5) """) db.commit() # --------------------------- Template --------------------------- # 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

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 Type Payer/Sender Reason Method Your share % Amount Δ Balance
{{ e.created_at }} {{ e.kind }} {{ A if e.payer=='A' else B }} {{ e.note or '—' }} {{ e.method }} {{ e.kind == 'bill' and ('%.2f'|format(e.a_share*100) ~ '%') or '—' }} {{ currency }}{{ '%.2f'|format(e.total) }} {{ currency }}{{ '%.2f'|format(e.delta) }}
""" # --------------------------- Utilities -------------------------- # def _now_local_iso_min() -> str: return dt.datetime.now().replace(second=0, microsecond=0).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 def _delta_for_entry(kind: str, total: float, payer: str, a_share: float) -> float: """ Positive => YOU owe Idan. Negative => Idan owes YOU. - bill: delta = (your_share * total) - (total if YOU paid else 0) - transfer: delta = -total if YOU sent; +total if Idan sent """ if kind == "transfer": return -total if payer == "A" else total # bill paid_by_a = total if payer == "A" else 0.0 return a_share * total - paid_by_a # ----------------------------- Routes --------------------------- # @app.before_request def _ensure_db(): init_db() @app.get("/") def index(): db = get_db() rows = db.execute(""" SELECT id, created_at, kind, total, payer, a_share, method, note FROM entries ORDER BY datetime(created_at) DESC, id DESC """).fetchall() entries = [] for r in rows: e = dict(r) e["delta"] = _delta_for_entry(e["kind"], e["total"], e["payer"], e["a_share"]) entries.append(e) total_balance = sum(e["delta"] for e in entries) if entries else 0.0 latest = entries[0]["created_at"] if entries else None bm = ByMethod( cash=sum(e["delta"] for e in entries if e["method"] == "cash"), transfer=sum(e["delta"] for e in entries if e["method"] == "transfer"), other=sum(e["delta"] for e in entries if e["method"] == "other"), ) summary = Summary(total=total_balance, 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(): kind = (request.form.get("kind") or "bill").strip().lower() payer = (request.form.get("payer") or "A").strip().upper() total = request.form.get("total", type=float) a_share_pct = request.form.get("a_share_pct", type=float) method = (request.form.get("method") or "cash").strip().lower() note = (request.form.get("note") or "").strip() created_at = request.form.get("created_at") or _now_local_iso_min() if total is None or total < 0: return redirect(url_for("index")) if payer not in ("A","B"): payer = "A" if kind not in ("bill","transfer"): kind = "bill" a_share = 0.5 if kind == "bill": if a_share_pct is None: a_share_pct = 50.0 a_share = max(0.0, min(100.0, a_share_pct)) / 100.0 db = get_db() db.execute( "INSERT INTO entries (created_at, kind, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?, ?)", (created_at, kind, total, payer, a_share, 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, kind, total, payer, a_share, method, note FROM entries ORDER BY datetime(created_at) DESC, id DESC """).fetchall() buff = io.StringIO() w = csv.writer(buff) w.writerow(["id","created_at","kind","payer","a_share_pct","total","method","note","delta"]) for r in rows: a_share_pct = float(r["a_share"]) * 100.0 delta = _delta_for_entry(r["kind"], r["total"], r["payer"], r["a_share"]) w.writerow([r["id"], r["created_at"], r["kind"], r["payer"], f"{a_share_pct:.2f}", f"{r['total']:.2f}", r["method"], r["note"] or "", f"{delta:.2f}"]) buff.seek(0) return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name="splitbuddy_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)