|
|
|
|
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, send_file
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
|
|
|
|
# ----- Config -----
|
|
|
|
|
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
|
|
|
|
|
DEFAULT_A_SHARE_PCT = 66.6667 # <-- you pay 2/3 by default
|
|
|
|
|
|
|
|
|
|
# ----- 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, -- amount
|
|
|
|
|
payer TEXT NOT NULL DEFAULT 'A', -- 'A' (you) or 'B' (Idan)
|
|
|
|
|
a_share REAL NOT NULL DEFAULT 0.666667, -- your share (0..1) for bills
|
|
|
|
|
method TEXT NOT NULL DEFAULT 'cash',
|
|
|
|
|
note TEXT
|
|
|
|
|
)
|
|
|
|
|
""")
|
|
|
|
|
# Migrations (no-op if columns already exist)
|
|
|
|
|
for ddl in [
|
|
|
|
|
"ALTER TABLE entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'bill'",
|
|
|
|
|
"ALTER TABLE entries ADD COLUMN total REAL",
|
|
|
|
|
"ALTER TABLE entries ADD COLUMN payer TEXT",
|
|
|
|
|
"ALTER TABLE entries ADD COLUMN a_share REAL",
|
|
|
|
|
]:
|
|
|
|
|
try:
|
|
|
|
|
db.execute(ddl)
|
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
|
pass
|
|
|
|
|
db.execute("""
|
|
|
|
|
UPDATE entries
|
|
|
|
|
SET kind = COALESCE(kind, 'bill'),
|
|
|
|
|
total = COALESCE(total, 0),
|
|
|
|
|
payer = COALESCE(payer, 'A'),
|
|
|
|
|
a_share= COALESCE(a_share, 0.666667)
|
|
|
|
|
""")
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
# ----- Utility & models -----
|
|
|
|
|
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
|
|
|
|
|
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(
|
|
|
|
|
"index.html",
|
|
|
|
|
entries=entries,
|
|
|
|
|
summary=summary,
|
|
|
|
|
A=PERSON_A, B=PERSON_B,
|
|
|
|
|
currency=CURRENCY,
|
|
|
|
|
now_local=_now_local_iso_min(),
|
|
|
|
|
default_a_share_pct=DEFAULT_A_SHARE_PCT,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@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"
|
|
|
|
|
|
|
|
|
|
if kind == "bill":
|
|
|
|
|
if a_share_pct is None:
|
|
|
|
|
a_share_pct = DEFAULT_A_SHARE_PCT
|
|
|
|
|
a_share = max(0.0, min(100.0, a_share_pct)) / 100.0
|
|
|
|
|
else:
|
|
|
|
|
a_share = DEFAULT_A_SHARE_PCT / 100.0 # stored but ignored for transfers
|
|
|
|
|
|
|
|
|
|
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/<int:entry_id>")
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
|
|
|
|
|
with app.app_context():
|
|
|
|
|
init_db()
|
|
|
|
|
app.run(debug=True)
|