update 2.0

main
oscar 2 months ago
parent dbd91e4e7f
commit edad1705bd

@ -1,31 +1,17 @@
# SplitBuddy — tiny Flask app to track IOUs between two roommates # SplitBuddy — Flask app with ratio-based splits (You vs Idan)
# ---------------------------------------------------------------
# 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 from __future__ import annotations
import os, sqlite3, csv, io, datetime as dt import os, sqlite3, csv, io, datetime as dt
from typing import Optional 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__) app = Flask(__name__)
# ---------------------------- Config ---------------------------- # DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db")
DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db") CURRENCY = ""
CURRENCY = "" # change if you want PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") # you
PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # roommate
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 --------------------------- # # ------------------------- DB helpers --------------------------- #
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
if "db" not in g: if "db" not in g:
g.db = sqlite3.connect(DB_PATH) g.db = sqlite3.connect(DB_PATH)
@ -40,72 +26,76 @@ def close_db(_=None):
def init_db(): def init_db():
db = get_db() db = get_db()
db.execute( # Base table
""" db.execute("""
CREATE TABLE IF NOT EXISTS entries ( CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
amount REAL NOT NULL, -- signed total REAL NOT NULL DEFAULT 0, -- total bill amount
method TEXT NOT NULL, -- cash | transfer | other payer TEXT NOT NULL DEFAULT 'A', -- 'A' (you) or 'B' (Idan)
note TEXT -- reason / memo 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() db.commit()
# --------------------------- Templates -------------------------- # # --------------------------- Template --------------------------- #
BASE = r""" BASE = r"""
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SplitBuddy</title> <title>SplitBuddy</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style> <style>
:root{ --bg:#0f1115; --card:#141822; --muted:#8c93a6; --ok:#19c37d; --warn:#ffb224; --bad:#ef4444; --fg:#e6e7eb; --acc:#4ea1ff; } :root{ --bg:#0f1115; --card:#141822; --muted:#8c93a6; --ok:#19c37d; --bad:#ef4444; --fg:#e6e7eb; --acc:#4ea1ff; --edge:#202637;}
*{ box-sizing:border-box } *{ box-sizing:border-box } body{ margin:0; font-family:Inter,system-ui; background:var(--bg); color:var(--fg) }
body{ margin:0; font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial; background:var(--bg); color:var(--fg); } .wrap{ max-width:980px; margin:24px auto; padding:0 16px } header{ display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:16px }
.wrap{ max-width:980px; margin:24px auto; padding:0 16px; } .h1{ font-size:24px; font-weight:700 } .pill{ display:inline-flex; gap:8px; padding:8px 12px; border-radius:999px; background:var(--card); border:1px solid var(--edge); font-size:14px }
header{ display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:16px; } .pill.ok{ background:rgba(25,195,125,.12); border-color:#1f7a58 } .pill.bad{ background:rgba(239,68,68,.12); border-color:#7a1f1f }
.h1{ font-size:24px; font-weight:700; letter-spacing:.2px } .muted{ color:var(--muted) } .grid{ display:grid; grid-template-columns:1.2fr .8fr; gap:16px } @media (max-width:900px){ .grid{ grid-template-columns:1fr } }
.pill{ display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:999px; background:var(--card); border:1px solid #202637; font-size:14px; } .card{ background:var(--card); border:1px solid var(--edge); border-radius:14px; padding:16px } h2{ font-size:16px; margin:0 0 10px }
.pill.ok{ border-color:#1f7a58; background:rgba(25,195,125,.12) }
.pill.bad{ border-color:#7a1f1f; background:rgba(239,68,68,.12) }
.muted{ color:var(--muted) }
.grid{ display:grid; grid-template-columns:1.2fr .8fr; gap:16px }
@media (max-width:900px){ .grid{ grid-template-columns:1fr; } }
.card{ background:var(--card); border:1px solid #202637; border-radius:14px; padding:16px }
h2{ font-size:16px; margin:0 0 10px 0 }
form .row{ display:flex; gap:10px; flex-wrap:wrap } form .row{ display:flex; gap:10px; flex-wrap:wrap }
input[type="number"], input[type="text"], select, input[type="datetime-local"]{ input, select{ background:#0d1117; border:1px solid #263041; color:var(--fg); border-radius:10px; padding:10px; outline:none }
background:#0d1117; border:1px solid #263041; color:var(--fg); border-radius:10px; padding:10px; outline:none; width:100%; input[type="number"]{ max-width:160px } input[type="text"]{ flex:1 }
} .seg{ display:inline-flex; border:1px solid #263041; border-radius:10px; overflow:hidden } .seg input{ display:none } .seg label{ padding:8px 10px; cursor:pointer; background:#0d1117 }
input[type="number"]{ max-width:160px }
.seg{ display:inline-flex; border:1px solid #263041; border-radius:10px; overflow:hidden }
.seg input{ display:none }
.seg label{ padding:8px 10px; cursor:pointer; background:#0d1117; }
.seg input:checked + label{ background:#182033; color:#b6d1ff } .seg input:checked + label{ background:#182033; color:#b6d1ff }
button.btn{ background:#1f6fe8; border:none; color:#fff; padding:10px 14px; border-radius:10px; cursor:pointer }
button.btn{ background:#1f6fe8; border:none; color:#fff; padding:10px 14px; border-radius:10px; cursor:pointer; } button.btn.secondary{ background:#273244 } button.btn.danger{ background:#c92a2a }
button.btn.secondary{ background:#273244 } table{ width:100%; border-collapse:collapse } th, td{ border-bottom:1px solid #222b3b; padding:10px; text-align:left; font-size:14px }
button.btn.danger{ background:#c92a2a } th{ color:#aab3c4 } td.num{ font-variant-numeric:tabular-nums; text-align:right }
.pos{ color:#ef9a9a } .neg{ color:#9ae6b4 }
table{ width:100%; border-collapse:collapse; }
th, td{ border-bottom:1px solid #222b3b; padding:10px; text-align:left; font-size:14px }
th{ color:#aab3c4; font-weight:600 }
td.amount{ font-variant-numeric:tabular-nums; text-align:right }
.pos{ color:#ef9a9a } /* I owe (positive) */
.neg{ color:#9ae6b4 } /* they owe me (negative) */
.row-actions{ display:flex; gap:8px; justify-content:flex-end }
.tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 } .tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 }
</style> </style>
<script>
function setShare(pct) {
const el = document.getElementById('a_share');
el.value = pct.toFixed(2);
}
</script>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@ -117,9 +107,7 @@ BASE = r"""
{{ A }} owes {{ B }} <strong>{{ currency }}{{ '%.2f'|format(summary.total) }}</strong> {{ A }} owes {{ B }} <strong>{{ currency }}{{ '%.2f'|format(summary.total) }}</strong>
{% elif summary.total < 0 %} {% elif summary.total < 0 %}
{{ B }} owes {{ A }} <strong>{{ currency }}{{ '%.2f'|format(-summary.total) }}</strong> {{ B }} owes {{ A }} <strong>{{ currency }}{{ '%.2f'|format(-summary.total) }}</strong>
{% else %} {% else %}All settled {% endif %}
All settled
{% endif %}
</span> </span>
<span class="pill muted">Balance: {{ currency }}{{ '%.2f'|format(summary.total) }}</span> <span class="pill muted">Balance: {{ currency }}{{ '%.2f'|format(summary.total) }}</span>
<a class="pill" href="{{ url_for('export_csv') }}">Export CSV</a> <a class="pill" href="{{ url_for('export_csv') }}">Export CSV</a>
@ -131,21 +119,23 @@ BASE = r"""
<h2>Add entry</h2> <h2>Add entry</h2>
<form method="post" action="{{ url_for('add') }}"> <form method="post" action="{{ url_for('add') }}">
<div class="row"> <div class="row">
<div class="seg"> <input type="number" step="0.01" min="0" name="total" placeholder="Total amount" required>
<input type="radio" id="plus" name="sign" value="+" checked> <select name="payer">
<label for="plus">+ ({{ A }} owes {{ B }})</label> <option value="A">{{ A }} paid</option>
<input type="radio" id="minus" name="sign" value="-"> <option value="B">{{ B }} paid</option>
<label for="minus">- ({{ B }} owes {{ A }})</label> </select>
</div> <input id="a_share" type="number" step="0.01" min="0" max="100" name="a_share_pct" placeholder="{{ A }} share %" value="50.00" title="{{ A }}'s share (%)">
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" required>
<input type="text" name="note" placeholder="Reason (e.g. groceries, rent)" maxlength="200">
</div> </div>
<div class="row" style="margin-top:8px"> <div class="row" style="margin-top:8px">
<select name="method" required> <div class="seg" title="Quick presets">
<option value="cash">cash</option> <input type="radio" id="p50" name="preset" onclick="setShare(50)" checked><label for="p50">50/50</label>
<option value="transfer">transfer</option> <input type="radio" id="p66" name="preset" onclick="setShare(66.6667)"><label for="p66">{{ A }} 2/3</label>
<option value="other">other</option> <input type="radio" id="p33" name="preset" onclick="setShare(33.3333)"><label for="p33">{{ A }} 1/3</label>
</div>
<select name="method">
<option>cash</option><option>transfer</option><option>other</option>
</select> </select>
<input type="text" name="note" placeholder="Reason (e.g. rent, groceries, washer)">
<input type="datetime-local" name="created_at" value="{{ now_local }}"> <input type="datetime-local" name="created_at" value="{{ now_local }}">
<button class="btn" type="submit">Add</button> <button class="btn" type="submit">Add</button>
</div> </div>
@ -154,12 +144,12 @@ BASE = r"""
<section class="card"> <section class="card">
<h2>Stats</h2> <h2>Stats</h2>
<div class="muted">Total entries: {{ summary.count }}</div> <div class="muted">Entries: {{ summary.count }}</div>
<div class="muted">Latest: {{ summary.latest or '' }}</div> <div class="muted">Latest: {{ summary.latest or '' }}</div>
<div style="margin-top:8px"> <div style="margin-top:8px">
<span class="tag">Cash: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }}</span> <span class="tag">Cash Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }}</span>
<span class="tag">Transfer: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }}</span> <span class="tag">Transfer Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }}</span>
<span class="tag">Other: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}</span> <span class="tag">Other Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}</span>
</div> </div>
</section> </section>
</div> </div>
@ -170,9 +160,12 @@ BASE = r"""
<thead> <thead>
<tr> <tr>
<th style="width:160px">Time</th> <th style="width:160px">Time</th>
<th>Payer</th>
<th>Reason</th> <th>Reason</th>
<th>Method</th> <th>Method</th>
<th class="amount">Amount</th> <th class="num">Your share %</th>
<th class="num">Total</th>
<th class="num">Δ Balance</th>
<th style="width:120px"></th> <th style="width:120px"></th>
</tr> </tr>
</thead> </thead>
@ -180,10 +173,15 @@ BASE = r"""
{% for e in entries %} {% for e in entries %}
<tr> <tr>
<td>{{ e.created_at }}</td> <td>{{ e.created_at }}</td>
<td>{{ A if e.payer=='A' else B }}</td>
<td>{{ e.note or '' }}</td> <td>{{ e.note or '' }}</td>
<td>{{ e.method }}</td> <td>{{ e.method }}</td>
<td class="amount {{ 'pos' if e.amount>0 else 'neg' if e.amount<0 else '' }}">{{ currency }}{{ '%.2f'|format(e.amount) }}</td> <td class="num">{{ '%.2f'|format(e.a_share*100) }}%</td>
<td class="row-actions"> <td class="num">{{ currency }}{{ '%.2f'|format(e.total) }}</td>
<td class="num {{ 'pos' if e.delta>0 else 'neg' if e.delta<0 else '' }}">
{{ currency }}{{ '%.2f'|format(e.delta) }}
</td>
<td>
<form method="post" action="{{ url_for('delete', entry_id=e.id) }}" onsubmit="return confirm('Delete this entry?');"> <form method="post" action="{{ url_for('delete', entry_id=e.id) }}" onsubmit="return confirm('Delete this entry?');">
<button class="btn danger" type="submit">Delete</button> <button class="btn danger" type="submit">Delete</button>
</form> </form>
@ -199,24 +197,24 @@ BASE = r"""
""" """
# --------------------------- Utilities -------------------------- # # --------------------------- Utilities -------------------------- #
def _now_local_iso_min() -> str: def _now_local_iso_min() -> str:
# default to local time (no tz), minute precision for nicer input value return dt.datetime.now().replace(second=0, microsecond=0).isoformat(timespec="minutes")
now = dt.datetime.now().replace(second=0, microsecond=0)
return now.isoformat(timespec="minutes")
class ByMethod: class ByMethod:
def __init__(self, cash=0.0, transfer=0.0, other=0.0): def __init__(self, cash=0.0, transfer=0.0, other=0.0):
self.cash = cash self.cash = cash; self.transfer = transfer; self.other = other
self.transfer = transfer
self.other = other
class Summary: class Summary:
def __init__(self, total: float, count: int, latest: Optional[str], by_method: ByMethod): def __init__(self, total: float, count: int, latest: Optional[str], by_method: ByMethod):
self.total = total self.total = total; self.count = count; self.latest = latest; self.by_method = by_method
self.count = count
self.latest = latest def _delta_for_entry(total: float, payer: str, a_share: float) -> float:
self.by_method = by_method """
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 --------------------------- # # ----------------------------- Routes --------------------------- #
@app.before_request @app.before_request
@ -226,18 +224,25 @@ def _ensure_db():
@app.get("/") @app.get("/")
def index(): def index():
db = get_db() 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("""
entries = [dict(r) for r in rows] 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 latest = entries[0]["created_at"] if entries else None
bm = ByMethod( bm = ByMethod(
cash=sum(e["amount"] for e in entries if e["method"] == "cash"), cash=sum(e["delta"] for e in entries if e["method"] == "cash"),
transfer=sum(e["amount"] for e in entries if e["method"] == "transfer"), transfer=sum(e["delta"] for e in entries if e["method"] == "transfer"),
other=sum(e["amount"] for e in entries if e["method"] == "other"), 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( return render_template_string(
BASE, BASE,
@ -250,23 +255,25 @@ def index():
@app.post("/add") @app.post("/add")
def add(): def add():
amount_raw = request.form.get("amount", type=float) total = request.form.get("total", type=float)
sign = request.form.get("sign", "+") payer = request.form.get("payer", "A").strip().upper()
method = request.form.get("method", "cash").strip().lower() a_share_pct = request.form.get("a_share_pct", type=float)
note = request.form.get("note", "").strip() 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() 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")) return redirect(url_for("index"))
if sign not in (+1, -1, "+", "-"): if payer not in ("A", "B"):
sign = "+" payer = "A"
if a_share_pct is None:
signed = amount_raw * (1 if sign in (+1, "+") else -1) a_share_pct = 50.0
a_share = max(0.0, min(100.0, a_share_pct)) / 100.0
db = get_db() db = get_db()
db.execute( db.execute(
"INSERT INTO entries (created_at, amount, method, note) VALUES (?, ?, ?, ?)", "INSERT INTO entries (created_at, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?)",
(created_at, signed, method, note) (created_at, total, payer, a_share, method, note),
) )
db.commit() db.commit()
return redirect(url_for("index")) return redirect(url_for("index"))
@ -281,17 +288,21 @@ def delete(entry_id: int):
@app.get("/export.csv") @app.get("/export.csv")
def export_csv(): def export_csv():
db = get_db() 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() buff = io.StringIO()
writer = csv.writer(buff) w = csv.writer(buff)
writer.writerow(["id", "created_at", "amount", "method", "note"]) # header w.writerow(["id","created_at","payer","a_share_pct","total","method","note","delta"])
for r in rows: 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) buff.seek(0)
return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name="splitbuddy_export.csv") 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 -------------------------- # # --------------------------- Entrypoint -------------------------- #
if __name__ == "__main__": if __name__ == "__main__":

Loading…
Cancel
Save