update 3.0

main
oscar 2 months ago
parent edad1705bd
commit 5caa35315e

@ -1,4 +1,4 @@
# SplitBuddy — Flask app with ratio-based splits (You vs Idan) # SplitBuddy — Flask app with bills/transfer + payer toggle
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
@ -26,37 +26,35 @@ def close_db(_=None):
def init_db(): def init_db():
db = get_db() db = get_db()
# Base table
db.execute(""" 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,
total REAL NOT NULL DEFAULT 0, -- total bill amount 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) 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) a_share REAL NOT NULL DEFAULT 0.5, -- your share (0..1), only for bills
method TEXT NOT NULL DEFAULT 'cash', method TEXT NOT NULL DEFAULT 'cash',
note TEXT note TEXT
) )
""") """)
# Migrate older schema (from signed amount version) # Migrations for older versions
# If old columns exist, add new if missing 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: try:
db.execute("ALTER TABLE entries ADD COLUMN total REAL") db.execute(ddl)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try: # Normalize NULLs from legacy rows
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(""" db.execute("""
UPDATE entries UPDATE entries
SET total = COALESCE(total, 0), SET kind = COALESCE(kind, 'bill'),
payer = COALESCE(payer, CASE WHEN total IS NOT NULL THEN 'A' ELSE 'A' END), total = COALESCE(total, 0),
payer = COALESCE(payer, 'A'),
a_share= COALESCE(a_share, 0.5) a_share= COALESCE(a_share, 0.5)
""") """)
db.commit() db.commit()
@ -71,7 +69,7 @@ BASE = r"""
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.googleapis.com"><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; --bad:#ef4444; --fg:#e6e7eb; --acc:#4ea1ff; --edge:#202637;} :root{ --bg:#0f1115; --card:#141822; --muted:#8c93a6; --ok:#19c37d; --bad:#ef4444; --fg:#e6e7eb; --edge:#202637;}
*{ box-sizing:border-box } body{ margin:0; font-family:Inter,system-ui; background:var(--bg); color:var(--fg) } *{ box-sizing:border-box } body{ margin:0; font-family:Inter,system-ui; 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 } header{ display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom: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 } .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 }
@ -81,8 +79,11 @@ BASE = r"""
form .row{ display:flex; gap:10px; flex-wrap:wrap } form .row{ display:flex; gap:10px; flex-wrap:wrap }
input, select{ background:#0d1117; border:1px solid #263041; color:var(--fg); border-radius:10px; padding:10px; outline:none } input, select{ background:#0d1117; border:1px solid #263041; color:var(--fg); border-radius:10px; padding:10px; outline:none }
input[type="number"]{ max-width:160px } input[type="text"]{ flex:1 } 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 } .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; user-select:none }
.seg input:checked + label{ background:#182033; color:#b6d1ff } .seg input:checked + label{ background:#182033; color:#b6d1ff }
.toggle{ display:inline-flex; border:1px solid #263041; border-radius:10px; overflow:hidden }
.toggle input{ display:none } .toggle label{ padding:8px 12px; cursor:pointer; background:#0d1117; user-select:none; min-width:100px; text-align:center }
.toggle input:checked + label{ background:#183033; 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 } button.btn.danger{ background:#c92a2a }
table{ width:100%; border-collapse:collapse } th, td{ border-bottom:1px solid #222b3b; padding:10px; text-align:left; font-size:14px } table{ width:100%; border-collapse:collapse } th, td{ border-bottom:1px solid #222b3b; padding:10px; text-align:left; font-size:14px }
@ -95,6 +96,22 @@ BASE = r"""
const el = document.getElementById('a_share'); const el = document.getElementById('a_share');
el.value = pct.toFixed(2); el.value = pct.toFixed(2);
} }
function onKindChange(kind) {
const shareWrap = document.getElementById('share-wrap');
const presets = document.getElementById('presets');
if (kind === 'transfer') {
shareWrap.style.display = 'none';
presets.style.display = 'none';
} else {
shareWrap.style.display = '';
presets.style.display = '';
}
}
document.addEventListener('DOMContentLoaded', () => {
const kindRadios = document.querySelectorAll('input[name="kind"]');
kindRadios.forEach(r => r.addEventListener('change', () => onKindChange(r.value)));
onKindChange(document.querySelector('input[name="kind"]:checked').value);
});
</script> </script>
</head> </head>
<body> <body>
@ -119,25 +136,31 @@ 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">
<input type="number" step="0.01" min="0" name="total" placeholder="Total amount" required> <div class="seg" title="Entry type">
<select name="payer"> <input type="radio" id="k_bill" name="kind" value="bill" checked><label for="k_bill">Bill</label>
<option value="A">{{ A }} paid</option> <input type="radio" id="k_xfer" name="kind" value="transfer"><label for="k_xfer">Transfer</label>
<option value="B">{{ B }} paid</option> </div>
<div class="toggle" title="Who paid / who sent">
<input type="radio" id="payer_a" name="payer" value="A" checked><label for="payer_a">{{ A }}</label>
<input type="radio" id="payer_b" name="payer" value="B"><label for="payer_b">{{ B }}</label>
</div>
<input type="number" step="0.01" min="0" name="total" placeholder="Amount" required>
<select name="method">
<option>cash</option><option>transfer</option><option>other</option>
</select> </select>
</div>
<div class="row" id="share-wrap" style="margin-top:8px">
<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 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="text" name="note" placeholder="Reason (e.g. rent, groceries, washer)" style="flex:1">
<input type="datetime-local" name="created_at" value="{{ now_local }}">
<button class="btn" type="submit">Add</button>
</div> </div>
<div class="row" style="margin-top:8px"> <div class="row" id="presets" style="margin-top:8px">
<div class="seg" title="Quick presets"> <div class="seg" title="Quick presets">
<input type="radio" id="p50" name="preset" onclick="setShare(50)" checked><label for="p50">50/50</label> <input type="radio" id="p50" name="preset" onclick="setShare(50)" checked><label for="p50">50/50</label>
<input type="radio" id="p66" name="preset" onclick="setShare(66.6667)"><label for="p66">{{ A }} 2/3</label> <input type="radio" id="p66" name="preset" onclick="setShare(66.6667)"><label for="p66">{{ A }} 2/3</label>
<input type="radio" id="p33" name="preset" onclick="setShare(33.3333)"><label for="p33">{{ A }} 1/3</label> <input type="radio" id="p33" name="preset" onclick="setShare(33.3333)"><label for="p33">{{ A }} 1/3</label>
</div> </div>
<select name="method">
<option>cash</option><option>transfer</option><option>other</option>
</select>
<input type="text" name="note" placeholder="Reason (e.g. rent, groceries, washer)">
<input type="datetime-local" name="created_at" value="{{ now_local }}">
<button class="btn" type="submit">Add</button>
</div> </div>
</form> </form>
</section> </section>
@ -160,11 +183,12 @@ BASE = r"""
<thead> <thead>
<tr> <tr>
<th style="width:160px">Time</th> <th style="width:160px">Time</th>
<th>Payer</th> <th>Type</th>
<th>Payer/Sender</th>
<th>Reason</th> <th>Reason</th>
<th>Method</th> <th>Method</th>
<th class="num">Your share %</th> <th class="num">Your share %</th>
<th class="num">Total</th> <th class="num">Amount</th>
<th class="num">Δ Balance</th> <th class="num">Δ Balance</th>
<th style="width:120px"></th> <th style="width:120px"></th>
</tr> </tr>
@ -173,10 +197,11 @@ BASE = r"""
{% for e in entries %} {% for e in entries %}
<tr> <tr>
<td>{{ e.created_at }}</td> <td>{{ e.created_at }}</td>
<td>{{ e.kind }}</td>
<td>{{ A if e.payer=='A' else B }}</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="num">{{ '%.2f'|format(e.a_share*100) }}%</td> <td class="num">{{ e.kind == 'bill' and ('%.2f'|format(e.a_share*100) ~ '%') or '' }}</td>
<td class="num">{{ currency }}{{ '%.2f'|format(e.total) }}</td> <td class="num">{{ currency }}{{ '%.2f'|format(e.total) }}</td>
<td class="num {{ 'pos' if e.delta>0 else 'neg' if e.delta<0 else '' }}"> <td class="num {{ 'pos' if e.delta>0 else 'neg' if e.delta<0 else '' }}">
{{ currency }}{{ '%.2f'|format(e.delta) }} {{ currency }}{{ '%.2f'|format(e.delta) }}
@ -208,11 +233,15 @@ 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.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: def _delta_for_entry(kind: str, total: float, payer: str, a_share: float) -> float:
""" """
Positive => YOU owe Idan. Negative => Idan owes YOU. Positive => YOU owe Idan. Negative => Idan owes YOU.
delta = (your_share * total) - (total if YOU paid else 0) - 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 paid_by_a = total if payer == "A" else 0.0
return a_share * total - paid_by_a return a_share * total - paid_by_a
@ -225,14 +254,14 @@ def _ensure_db():
def index(): def index():
db = get_db() db = get_db()
rows = db.execute(""" rows = db.execute("""
SELECT id, created_at, total, payer, a_share, method, note SELECT id, created_at, kind, total, payer, a_share, method, note
FROM entries FROM entries
ORDER BY datetime(created_at) DESC, id DESC ORDER BY datetime(created_at) DESC, id DESC
""").fetchall() """).fetchall()
entries = [] entries = []
for r in rows: for r in rows:
e = dict(r) e = dict(r)
e["delta"] = _delta_for_entry(e["total"], e["payer"], e["a_share"]) e["delta"] = _delta_for_entry(e["kind"], e["total"], e["payer"], e["a_share"])
entries.append(e) entries.append(e)
total_balance = sum(e["delta"] for e in entries) if entries else 0.0 total_balance = sum(e["delta"] for e in entries) if entries else 0.0
@ -255,25 +284,27 @@ def index():
@app.post("/add") @app.post("/add")
def 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) 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) a_share_pct = request.form.get("a_share_pct", type=float)
method = (request.form.get("method") or "cash").strip().lower() method = (request.form.get("method") or "cash").strip().lower()
note = (request.form.get("note") or "").strip() 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 total is None or total < 0: if total is None or total < 0: return redirect(url_for("index"))
return redirect(url_for("index")) if payer not in ("A","B"): payer = "A"
if payer not in ("A", "B"): if kind not in ("bill","transfer"): kind = "bill"
payer = "A"
if a_share_pct is None: a_share = 0.5
a_share_pct = 50.0 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 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, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO entries (created_at, kind, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?, ?)",
(created_at, total, payer, a_share, method, note), (created_at, kind, total, payer, a_share, method, note),
) )
db.commit() db.commit()
return redirect(url_for("index")) return redirect(url_for("index"))
@ -289,17 +320,18 @@ def delete(entry_id: int):
def export_csv(): def export_csv():
db = get_db() db = get_db()
rows = db.execute(""" rows = db.execute("""
SELECT id, created_at, total, payer, a_share, method, note SELECT id, created_at, kind, total, payer, a_share, method, note
FROM entries FROM entries
ORDER BY datetime(created_at) DESC, id DESC ORDER BY datetime(created_at) DESC, id DESC
""").fetchall() """).fetchall()
buff = io.StringIO() buff = io.StringIO()
w = csv.writer(buff) w = csv.writer(buff)
w.writerow(["id","created_at","payer","a_share_pct","total","method","note","delta"]) w.writerow(["id","created_at","kind","payer","a_share_pct","total","method","note","delta"])
for r in rows: for r in rows:
a_share_pct = float(r["a_share"]) * 100.0 a_share_pct = float(r["a_share"]) * 100.0
delta = _delta_for_entry(r["total"], r["payer"], r["a_share"]) delta = _delta_for_entry(r["kind"], 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}"]) 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) buff.seek(0)
return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv", return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv",
as_attachment=True, download_name="splitbuddy_export.csv") as_attachment=True, download_name="splitbuddy_export.csv")

Loading…
Cancel
Save