From edad1705bd7d0820c273e6e767e284b6b1aa9a61 Mon Sep 17 00:00:00 2001 From: oscar Date: Sun, 31 Aug 2025 23:48:47 +0300 Subject: [PATCH] update 2.0 --- main.py | 271 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 141 insertions(+), 130 deletions(-) diff --git a/main.py b/main.py index 861b5d0..ec24bf4 100644 --- a/main.py +++ b/main.py @@ -1,31 +1,17 @@ -# 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) - +# SplitBuddy — Flask app with ratio-based splits (You vs Idan) 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 +from flask import Flask, g, request, redirect, url_for, render_template_string, 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_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) @@ -40,72 +26,76 @@ def close_db(_=None): def init_db(): db = get_db() - db.execute( - """ + # Base table + 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 + total REAL NOT NULL DEFAULT 0, -- total bill amount + payer TEXT NOT NULL DEFAULT 'A', -- 'A' (you) or 'B' (Idan) + a_share REAL NOT NULL DEFAULT 0.5, -- your share as fraction (0..1) + method TEXT NOT NULL DEFAULT 'cash', + note TEXT ) - """ - ) + """) + # Migrate older schema (from signed amount version) + # If old columns exist, add new if missing + try: + db.execute("ALTER TABLE entries ADD COLUMN total REAL") + except sqlite3.OperationalError: + pass + try: + db.execute("ALTER TABLE entries ADD COLUMN payer TEXT") + except sqlite3.OperationalError: + pass + try: + db.execute("ALTER TABLE entries ADD COLUMN a_share REAL") + except sqlite3.OperationalError: + pass + # If we had old 'amount' signed records and 'total' is NULL, map amount→total and infer payer/a_share=0.5 + db.execute(""" + UPDATE entries + SET total = COALESCE(total, 0), + payer = COALESCE(payer, CASE WHEN total IS NOT NULL THEN 'A' ELSE 'A' END), + a_share = COALESCE(a_share, 0.5) + """) db.commit() -# --------------------------- Templates -------------------------- # +# --------------------------- Template --------------------------- # BASE = r""" - - + SplitBuddy - - + +
@@ -117,9 +107,7 @@ BASE = r""" {{ A }} owes {{ B }} {{ currency }}{{ '%.2f'|format(summary.total) }} {% elif summary.total < 0 %} {{ B }} owes {{ A }} {{ currency }}{{ '%.2f'|format(-summary.total) }} - {% else %} - All settled ✨ - {% endif %} + {% else %}All settled ✨{% endif %} Balance: {{ currency }}{{ '%.2f'|format(summary.total) }} Export CSV @@ -131,21 +119,23 @@ BASE = r"""

Add entry

-
- - - - -
- - + + +
- + + +
+ +
@@ -154,12 +144,12 @@ BASE = r"""

Stats

-
Total entries: {{ summary.count }}
+
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) }} + Cash Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }} + Transfer Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }} + Other Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}
@@ -170,9 +160,12 @@ BASE = r""" Time + Payer Reason Method - Amount + Your share % + Total + Δ Balance @@ -180,10 +173,15 @@ BASE = r""" {% for e in entries %} {{ e.created_at }} + {{ A if e.payer=='A' else B }} {{ e.note or '—' }} {{ e.method }} - {{ currency }}{{ '%.2f'|format(e.amount) }} - + {{ '%.2f'|format(e.a_share*100) }}% + {{ currency }}{{ '%.2f'|format(e.total) }} + + {{ currency }}{{ '%.2f'|format(e.delta) }} + + @@ -199,24 +197,24 @@ BASE = r""" """ # --------------------------- 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") + 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 + 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 + self.total = total; self.count = count; self.latest = latest; self.by_method = by_method + +def _delta_for_entry(total: float, payer: str, a_share: float) -> float: + """ + Positive => YOU owe Idan. Negative => Idan owes YOU. + delta = (your_share * total) - (total if YOU paid else 0) + """ + paid_by_a = total if payer == "A" else 0.0 + return a_share * total - paid_by_a # ----------------------------- Routes --------------------------- # @app.before_request @@ -226,18 +224,25 @@ def _ensure_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] + rows = db.execute(""" + SELECT id, created_at, 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["total"], e["payer"], e["a_share"]) + entries.append(e) - total = sum(e["amount"] for e in entries) if entries else 0.0 + 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["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"), + 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, count=len(entries), latest=latest, by_method=bm) + summary = Summary(total=total_balance, count=len(entries), latest=latest, by_method=bm) return render_template_string( BASE, @@ -250,23 +255,25 @@ def index(): @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() + total = request.form.get("total", type=float) + payer = request.form.get("payer", "A").strip().upper() + 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 amount_raw is None: + if total is None or total < 0: return redirect(url_for("index")) - if sign not in (+1, -1, "+", "-"): - sign = "+" - - signed = amount_raw * (1 if sign in (+1, "+") else -1) + if payer not in ("A", "B"): + payer = "A" + 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, amount, method, note) VALUES (?, ?, ?, ?)", - (created_at, signed, method, note) + "INSERT INTO entries (created_at, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?)", + (created_at, total, payer, a_share, method, note), ) db.commit() return redirect(url_for("index")) @@ -281,21 +288,25 @@ def delete(entry_id: int): @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() + rows = db.execute(""" + SELECT id, created_at, total, payer, a_share, 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 + w = csv.writer(buff) + w.writerow(["id","created_at","payer","a_share_pct","total","method","note","delta"]) for r in rows: - writer.writerow([r["id"], r["created_at"], r["amount"], r["method"], r["note"]]) + a_share_pct = float(r["a_share"]) * 100.0 + delta = _delta_for_entry(r["total"], r["payer"], r["a_share"]) + w.writerow([r["id"], r["created_at"], 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") - -# Compatibility alias for templates -export_csv = export_csv + 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) \ No newline at end of file + app.run(debug=True)