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"""
-
| {{ 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)
|