Compare commits

..

No commits in common. '14f79cf4ffe0ba63b485224b4eb005224e7380b3' and 'b10ae956902825bad6604c3d9bd8580772e2894e' have entirely different histories.

1
.gitignore vendored

@ -180,4 +180,3 @@ cython_debug/
.cursorignore .cursorignore
.cursorindexingignore .cursorindexingignore
*.db *.db
.DS_Store

@ -1,41 +1,17 @@
# SplitBuddy — Flask app with bills/transfer + payer toggle
from __future__ import annotations from __future__ import annotations
import os, sqlite3, csv, io, json, datetime as dt import os, sqlite3, csv, io, datetime as dt
from typing import Optional, Dict, Any from typing import Optional
from flask import Flask, g, request, redirect, url_for, render_template, send_file, jsonify 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 = os.environ.get("SPLITBUDDY_CURRENCY", "") CURRENCY = ""
PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") # you PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") # you
PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # roommate PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # roommate
DEFAULT_ACTOR = os.environ.get("SPLITBUDDY_ACTOR", "anon") # who is using this device (optional)
DEFAULT_A_SHARE_PCT = os.environ.get("SPLITBUDDY_DEFAULT_A_SHARE_PCT", 66.6667) # <-- you pay 2/3 by default
WEBAPP_PORT = os.environ.get("SPLITBUDDY_WEBAPP_PORT", 42069)
# ----- Template filters ----- # ------------------------- DB helpers --------------------------- #
@app.template_filter('human_time')
def human_time(value: Any) -> str:
"""Format ISO-ish timestamps into a concise human-readable string.
Accepts values like '2025-09-14T18:37' or '2025-09-14T18:37:00Z'.
"""
if not value:
return ""
try:
if isinstance(value, dt.datetime):
d = value
else:
s = str(value).strip()
# fromisoformat doesn't accept trailing 'Z' (UTC); strip it if present
if s.endswith('Z'):
s = s[:-1]
d = dt.datetime.fromisoformat(s)
return d.strftime("%b %d, %Y %H:%M")
except Exception:
return str(value)
# ----- 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)
@ -50,79 +26,205 @@ def close_db(_=None):
def init_db(): def init_db():
db = get_db() db = get_db()
# entries
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,
kind TEXT NOT NULL DEFAULT 'bill', -- 'bill' | 'transfer' kind TEXT NOT NULL DEFAULT 'bill', -- 'bill' | 'transfer'
total REAL NOT NULL DEFAULT 0, total REAL NOT NULL DEFAULT 0, -- total amount
payer TEXT NOT NULL DEFAULT 'A', -- 'A' or 'B' 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 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
) )
""") """)
# Migrations for older versions
# audits for col, ddl in [
db.execute(""" ("kind", "ALTER TABLE entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'bill'"),
CREATE TABLE IF NOT EXISTS audits ( ("total", "ALTER TABLE entries ADD COLUMN total REAL"),
id INTEGER PRIMARY KEY AUTOINCREMENT, ("payer", "ALTER TABLE entries ADD COLUMN payer TEXT"),
ts TEXT NOT NULL, -- UTC ISO timestamp ("a_share","ALTER TABLE entries ADD COLUMN a_share REAL"),
action TEXT NOT NULL, -- 'add' | 'edit' | 'delete'
entry_id INTEGER, -- affected entry id (may be null for failures)
actor TEXT, -- who (env/config or future auth)
ip TEXT,
user_agent TEXT,
device TEXT, -- parsed (macOS, Windows, iPhone, Android, etc.)
old_row TEXT, -- JSON of previous row (if any)
new_row TEXT -- JSON of new row (if any)
)
""")
# (best-effort) migrations, ignore if exists
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: try:
db.execute(ddl) db.execute(ddl)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
# Normalize NULLs from legacy rows
db.execute(""" db.execute("""
UPDATE entries UPDATE entries
SET kind = COALESCE(kind, 'bill'), SET kind = COALESCE(kind, 'bill'),
total = COALESCE(total, 0), total = COALESCE(total, 0),
payer = COALESCE(payer, 'A'), payer = COALESCE(payer, 'A'),
a_share= COALESCE(a_share, 0.666667) a_share= COALESCE(a_share, 0.5)
""") """)
db.commit() db.commit()
# ----- Utility & models ----- # --------------------------- Template --------------------------- #
BASE = r"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SplitBuddy</title>
<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">
<style>
: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) }
.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 }
.pill.ok{ background:rgba(25,195,125,.12); border-color:#1f7a58 } .pill.bad{ background:rgba(239,68,68,.12); border-color:#7a1f1f }
.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 var(--edge); border-radius:14px; padding:16px } h2{ font-size:16px; margin:0 0 10px }
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[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; user-select:none }
.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.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 }
th{ color:#aab3c4 } td.num{ font-variant-numeric:tabular-nums; text-align:right }
.pos{ color:#ef9a9a } .neg{ color:#9ae6b4 }
.tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 }
</style>
<script>
function setShare(pct) {
const el = document.getElementById('a_share');
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>
</head>
<body>
<div class="wrap">
<header>
<div class="h1">SplitBuddy</div>
<div>
<span class="pill {{ 'bad' if summary.total>0 else ('ok' if summary.total<0 else '') }}">
{% if summary.total > 0 %}
{{ A }} owes {{ B }} <strong>{{ currency }}{{ '%.2f'|format(summary.total) }}</strong>
{% elif summary.total < 0 %}
{{ B }} owes {{ A }} <strong>{{ currency }}{{ '%.2f'|format(-summary.total) }}</strong>
{% else %}All settled {% endif %}
</span>
<span class="pill muted">Balance: {{ currency }}{{ '%.2f'|format(summary.total) }}</span>
<a class="pill" href="{{ url_for('export_csv') }}">Export CSV</a>
</div>
</header>
<div class="grid">
<section class="card">
<h2>Add entry</h2>
<form method="post" action="{{ url_for('add') }}">
<div class="row">
<div class="seg" title="Entry type">
<input type="radio" id="k_bill" name="kind" value="bill" checked><label for="k_bill">Bill</label>
<input type="radio" id="k_xfer" name="kind" value="transfer"><label for="k_xfer">Transfer</label>
</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>
</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 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 class="row" id="presets" style="margin-top:8px">
<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="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>
</div>
</div>
</form>
</section>
<section class="card">
<h2>Stats</h2>
<div class="muted">Entries: {{ summary.count }}</div>
<div class="muted">Latest: {{ summary.latest or '' }}</div>
<div style="margin-top:8px">
<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">Other Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}</span>
</div>
</section>
</div>
<section class="card" style="margin-top:16px">
<h2>Ledger</h2>
<table>
<thead>
<tr>
<th style="width:160px">Time</th>
<th>Type</th>
<th>Payer/Sender</th>
<th>Reason</th>
<th>Method</th>
<th class="num">Your share %</th>
<th class="num">Amount</th>
<th class="num">Δ Balance</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td>{{ e.created_at }}</td>
<td>{{ e.kind }}</td>
<td>{{ A if e.payer=='A' else B }}</td>
<td>{{ e.note or '' }}</td>
<td>{{ e.method }}</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 {{ '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?');">
<button class="btn danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</body>
</html>
"""
# --------------------------- Utilities -------------------------- #
def _now_local_iso_min() -> str: def _now_local_iso_min() -> str:
return dt.datetime.now().replace(second=0, microsecond=0).isoformat(timespec="minutes") return dt.datetime.now().replace(second=0, microsecond=0).isoformat(timespec="minutes")
def _now_utc_iso() -> str:
return dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def _client_ip() -> str:
# works behind simple proxy too
xff = request.headers.get("X-Forwarded-For")
return xff.split(",")[0].strip() if xff else (request.remote_addr or "")
def _guess_device(ua: str) -> str:
u = (ua or "").lower()
if "iphone" in u: return "iPhone"
if "ipad" in u: return "iPad"
if "android" in u: return "Android"
if "mac os x" in u or "macintosh" in u: return "macOS"
if "windows" in u: return "Windows"
if "linux" in u and "android" not in u: return "Linux"
return "Unknown"
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.transfer = transfer; self.other = other self.cash = cash; self.transfer = transfer; self.other = other
@ -139,33 +241,11 @@ def _delta_for_entry(kind: str, total: float, payer: str, a_share: float) -> flo
""" """
if kind == "transfer": if kind == "transfer":
return -total if payer == "A" else total 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
def _row_to_dict(row: sqlite3.Row | Dict[str, Any] | None) -> Dict[str, Any] | None: # ----------------------------- Routes --------------------------- #
if row is None: return None
return dict(row)
def _audit(action: str, entry_id: Optional[int], old_row: Optional[dict], new_row: Optional[dict]):
db = get_db()
ua = request.headers.get("User-Agent", "")
db.execute("""
INSERT INTO audits (ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
_now_utc_iso(),
action,
entry_id,
DEFAULT_ACTOR,
_client_ip(),
ua,
_guess_device(ua),
json.dumps(old_row, ensure_ascii=False) if old_row is not None else None,
json.dumps(new_row, ensure_ascii=False) if new_row is not None else None,
))
db.commit()
# ----- Routes -----
@app.before_request @app.before_request
def _ensure_db(): def _ensure_db():
init_db() init_db()
@ -178,7 +258,6 @@ def index():
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)
@ -194,14 +273,13 @@ def index():
) )
summary = Summary(total=total_balance, count=len(entries), latest=latest, by_method=bm) summary = Summary(total=total_balance, count=len(entries), latest=latest, by_method=bm)
return render_template( return render_template_string(
"index.html", BASE,
entries=entries, entries=entries,
summary=summary, summary=summary,
A=PERSON_A, B=PERSON_B, A=PERSON_A, B=PERSON_B,
currency=CURRENCY, currency=CURRENCY,
now_local=_now_local_iso_min(), now_local=_now_local_iso_min(),
default_a_share_pct=DEFAULT_A_SHARE_PCT,
) )
@app.post("/add") @app.post("/add")
@ -214,79 +292,28 @@ def add():
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"): payer = "A"
if kind not in ("bill","transfer"): kind = "bill" if kind not in ("bill","transfer"): kind = "bill"
a_share = 0.5
if kind == "bill": if kind == "bill":
if a_share_pct is None: if a_share_pct is None: a_share_pct = 50.0
a_share_pct = DEFAULT_A_SHARE_PCT
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
else:
a_share = DEFAULT_A_SHARE_PCT / 100.0 # stored but ignored for transfers
db = get_db() db = get_db()
cur = db.execute( db.execute(
"INSERT INTO entries (created_at, kind, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO entries (created_at, kind, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?, ?)",
(created_at, kind, total, payer, a_share, method, note), (created_at, kind, total, payer, a_share, method, note),
) )
db.commit() db.commit()
new_id = cur.lastrowid
new_row = _row_to_dict(db.execute("SELECT * FROM entries WHERE id = ?", (new_id,)).fetchone())
_audit("add", new_id, old_row=None, new_row=new_row)
return redirect(url_for("index")) return redirect(url_for("index"))
@app.post("/delete/<int:entry_id>") @app.post("/delete/<int:entry_id>")
def delete(entry_id: int): def delete(entry_id: int):
db = get_db() db = get_db()
old = db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)).fetchone()
db.execute("DELETE FROM entries WHERE id = ?", (entry_id,)) db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
db.commit() db.commit()
_audit("delete", entry_id, old_row=_row_to_dict(old), new_row=None)
return redirect(url_for("index"))
# Minimal edit endpoint (POST-only). Accepts same fields as /add.
@app.post("/edit/<int:entry_id>")
def edit(entry_id: int):
db = get_db()
old = db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)).fetchone()
if not old:
_audit("edit", entry_id, old_row=None, new_row=None)
return redirect(url_for("index"))
kind = (request.form.get("kind") or old["kind"]).strip().lower()
payer = (request.form.get("payer") or old["payer"]).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 old["method"]).strip().lower()
note = (request.form.get("note") or old["note"] or "").strip()
created_at = request.form.get("created_at") or old["created_at"]
if total is None:
total = float(old["total"])
if payer not in ("A","B"):
payer = old["payer"]
if kind not in ("bill","transfer"):
kind = old["kind"]
if kind == "bill":
if a_share_pct is None:
a_share = float(old["a_share"])
else:
a_share = max(0.0, min(100.0, a_share_pct)) / 100.0
else:
a_share = float(old["a_share"]) # ignored but stored
db.execute("""
UPDATE entries
SET created_at = ?, kind = ?, total = ?, payer = ?, a_share = ?, method = ?, note = ?
WHERE id = ?
""", (created_at, kind, total, payer, a_share, method, note, entry_id))
db.commit()
new = db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)).fetchone()
_audit("edit", entry_id, old_row=_row_to_dict(old), new_row=_row_to_dict(new))
return redirect(url_for("index")) return redirect(url_for("index"))
@app.get("/export.csv") @app.get("/export.csv")
@ -309,113 +336,9 @@ def export_csv():
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")
# ----- Audit viewers ----- # --------------------------- Entrypoint -------------------------- #
@app.get("/audit")
def audit_html():
db = get_db()
# --- filters from query params ---
entry_id = request.args.get("entry_id", type=int)
action = request.args.get("action", type=str) # add|edit|delete
actor = request.args.get("actor", type=str)
device = request.args.get("device", type=str)
q = request.args.get("q", type=str) # substring search in old/new JSON
page = max(1, request.args.get("page", default=1, type=int))
per_page = min(200, request.args.get("per_page", default=50, type=int))
offset = (page - 1) * per_page
# --- build WHERE clause dynamically ---
wh, params = [], []
if entry_id is not None:
wh.append("entry_id = ?"); params.append(entry_id)
if action:
wh.append("action = ?"); params.append(action)
if actor:
wh.append("actor LIKE ?"); params.append(f"%{actor}%")
if device:
wh.append("device = ?"); params.append(device)
if q:
wh.append("(COALESCE(old_row,'') LIKE ? OR COALESCE(new_row,'') LIKE ? OR COALESCE(user_agent,'') LIKE ?)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
where_sql = ("WHERE " + " AND ".join(wh)) if wh else ""
# total count for pagination
total = db.execute(f"SELECT COUNT(*) FROM audits {where_sql}", params).fetchone()[0]
rows = db.execute(f"""
SELECT id, ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row
FROM audits
{where_sql}
ORDER BY datetime(ts) DESC, id DESC
LIMIT ? OFFSET ?
""", (*params, per_page, offset)).fetchall()
# pretty JSON (server-side) to avoid giant lines
def pretty(s):
if not s: return ""
try:
obj = json.loads(s)
return json.dumps(obj, ensure_ascii=False, indent=2)
except Exception:
return s
# enrich rows for template
data = []
for r in rows:
d = dict(r)
d["old_pretty"] = pretty(d.get("old_row"))
d["new_pretty"] = pretty(d.get("new_row"))
data.append(d)
# distinct devices/actions for filter dropdowns
devices = [r[0] for r in db.execute("SELECT DISTINCT device FROM audits WHERE device IS NOT NULL ORDER BY device").fetchall()]
actions = ["add", "edit", "delete"]
return render_template(
"audit.html",
rows=data,
total=total,
page=page,
per_page=per_page,
pages=max(1, (total + per_page - 1) // per_page),
# current filters
f_entry_id=entry_id, f_action=action, f_actor=actor, f_device=device, f_q=q,
devices=devices, actions=actions
)
@app.get("/audit.csv")
def audit_csv():
db = get_db()
rows = db.execute("""
SELECT id, ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row
FROM audits
ORDER BY datetime(ts) DESC, id DESC
""").fetchall()
buff = io.StringIO()
w = csv.writer(buff)
w.writerow(["id","ts","action","entry_id","actor","ip","device","user_agent","old_row","new_row"])
for r in rows:
w.writerow([r["id"], r["ts"], r["action"], r["entry_id"], r["actor"], r["ip"], r["device"], r["user_agent"], r["old_row"] or "", r["new_row"] or ""])
buff.seek(0)
return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv",
as_attachment=True, download_name="splitbuddy_audit.csv")
@app.get("/api/audit")
def audit_api():
db = get_db()
rows = db.execute("""
SELECT id, ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row
FROM audits
ORDER BY datetime(ts) DESC, id DESC
LIMIT 200
""").fetchall()
data = [dict(r) for r in rows]
return jsonify(data)
# ----- Main -----
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
with app.app_context(): with app.app_context():
init_db() init_db()
app.run(debug=True, port=WEBAPP_PORT) app.run(debug=True)

@ -1,53 +0,0 @@
function setShare(pct) {
const el = document.getElementById('a_share');
if (el) el.value = Number(pct).toFixed(4);
}
function onKindChange(kind) {
const a_share = document.getElementById('a_share');
const presets = document.getElementById('presets');
if (!a_share || !presets) return;
if (kind === 'transfer') {
a_share.style.display = 'none';
presets.style.display = 'none';
} else {
a_share.style.display = '';
presets.style.display = '';
}
}
document.addEventListener('DOMContentLoaded', () => {
// default your share to 2/3
setShare(66.6667);
// wire presets
const p50 = document.getElementById('p50');
const p66 = document.getElementById('p66');
const p33 = document.getElementById('p33');
if (p50) p50.addEventListener('click', () => setShare(50));
if (p66) p66.addEventListener('click', () => setShare(66.6667));
if (p33) p33.addEventListener('click', () => setShare(33.3333));
// wire kind radios
const bill = document.getElementById('k_bill');
const xfer = document.getElementById('k_xfer');
if (bill) bill.addEventListener('change', () => onKindChange('bill'));
if (xfer) xfer.addEventListener('change', () => onKindChange('transfer'));
onKindChange((xfer && xfer.checked) ? 'transfer' : 'bill');
// inline edit toggles
document.querySelectorAll('[data-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-edit');
const row = document.getElementById(`edit-row-${id}`);
if (row) row.style.display = row.style.display === 'none' ? '' : 'none';
});
});
document.querySelectorAll('[data-cancel-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-cancel-edit');
const row = document.getElementById(`edit-row-${id}`);
if (row) row.style.display = 'none';
});
});
});

@ -1,45 +0,0 @@
: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,-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 }
.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 }
.pill.ok{ background:rgba(25,195,125,.12); border-color:#1f7a58 }
.pill.bad{ background:rgba(239,68,68,.12); border-color:#7a1f1f }
.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 var(--edge); border-radius:14px; padding:16px }
h2{ font-size:16px; margin:0 0 10px }
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[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; user-select:none }
.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.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 }
th{ color:#aab3c4; font-weight:600 }
td.num{ font-variant-numeric:tabular-nums; text-align:right }
.pos{ color:#ef9a9a } /* you owe */
.neg{ color:#9ae6b4 } /* they owe */
.tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 }

@ -1,142 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Audit Log</title>
<style>
:root { color-scheme: dark; }
body { margin:0; font-family:system-ui, Arial; background:#0e0f12; color:#e8e8ea; }
header { padding:16px 20px; border-bottom:1px solid #23242a; display:flex; align-items:center; gap:12px; }
h1 { margin:0; font-size:20px; }
.filters { display:flex; gap:10px; flex-wrap:wrap; margin-left:auto; }
.filters input, .filters select {
background:#14151a; color:#e8e8ea; border:1px solid #2a2c33; border-radius:8px; padding:8px 10px; font-size:14px;
}
.filters button {
background:#2b6efe; color:white; border:none; border-radius:8px; padding:8px 12px; cursor:pointer;
}
.filters a { color:#9aa0a6; text-decoration:none; padding-left:6px; }
.wrap { padding:16px 20px; }
table { width:100%; border-collapse:separate; border-spacing:0 10px; }
thead th { font-weight:600; font-size:12px; color:#a9acb3; text-align:left; padding:0 12px 6px; }
tbody tr { background:#14151a; border:1px solid #23242a; }
tbody td { padding:12px; vertical-align:top; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:600; }
.ad { background:#153e25; color:#67d48a; border:1px solid #1f6b3b; }
.ed { background:#332a16; color:#f0c56a; border:1px solid #6d5a24; }
.del{ background:#3a1e22; color:#f08a94; border:1px solid #6d2b33; }
.meta { color:#a9acb3; font-size:12px; }
.json { background:#0c0d10; border:1px solid #23242a; border-radius:8px; padding:10px; font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; white-space:pre-wrap; max-height:260px; overflow:auto; }
details { margin-top:8px; }
details > summary { cursor:pointer; color:#bfc3cb; }
.pill { display:inline-block; background:#1b1d23; border:1px solid #2a2c33; border-radius:999px; padding:2px 8px; font-size:12px; margin-right:6px; color:#cfd2d8; }
.rowhead { display:flex; align-items:center; gap:10px; }
.pagination { display:flex; gap:8px; align-items:center; margin-top:10px; }
.pagination a, .pagination span {
background:#14151a; border:1px solid #23242a; color:#e8e8ea; padding:6px 10px; border-radius:8px; text-decoration:none; font-size:14px;
}
.pagination .current { background:#1b1d23; }
</style>
</head>
<body>
<header>
<h1>Audit Log</h1>
<form class="filters" method="get">
<input type="number" name="entry_id" placeholder="Entry ID" value="{{ f_entry_id or '' }}">
<select name="action">
<option value="">Any action</option>
{% for a in actions %}
<option value="{{a}}" {% if f_action==a %}selected{% endif %}>{{a}}</option>
{% endfor %}
</select>
<input type="text" name="actor" placeholder="Actor" value="{{ f_actor or '' }}">
<select name="device">
<option value="">Any device</option>
{% for d in devices %}
<option value="{{d}}" {% if f_device==d %}selected{% endif %}>{{d}}</option>
{% endfor %}
</select>
<input type="text" name="q" placeholder="Search old/new/UA" value="{{ f_q or '' }}">
<input type="number" name="per_page" min="10" max="200" value="{{ per_page }}">
<button type="submit">Filter</button>
<a href="{{ url_for('audit_html') }}">Clear</a>
</form>
</header>
<div class="wrap">
<div class="meta">{{ total }} events • page {{ page }} / {{ pages }}</div>
<table>
<thead>
<tr>
<th style="width:180px">Time (UTC)</th>
<th style="width:100px">Action</th>
<th style="width:90px">Entry</th>
<th>Who / Where</th>
<th>Old → New</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.ts }}</td>
<td>
{% if r.action=='add' %}
<span class="badge ad">add</span>
{% elif r.action=='edit' %}
<span class="badge ed">edit</span>
{% else %}
<span class="badge del">delete</span>
{% endif %}
</td>
<td>
{% if r.entry_id %}
<span class="pill">#{{ r.entry_id }}</span>
<a class="pill" href="{{ url_for('audit_html', entry_id=r.entry_id) }}">history</a>
{% else %}
<span class="pill"></span>
{% endif %}
</td>
<td>
<div class="rowhead">
<span class="pill">{{ r.actor or 'anon' }}</span>
<span class="pill">{{ r.device or 'Unknown' }}</span>
<span class="pill">{{ r.ip or '' }}</span>
</div>
<div class="meta" title="{{ r.user_agent }}">{{ r.user_agent[:80] }}{% if r.user_agent and r.user_agent|length>80 %}…{% endif %}</div>
</td>
<td>
<details>
<summary>Old</summary>
<pre class="json">{{ r.old_pretty }}</pre>
</details>
<details>
<summary>New</summary>
<pre class="json">{{ r.new_pretty }}</pre>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('audit_html', entry_id=f_entry_id, action=f_action, actor=f_actor, device=f_device, q=f_q, per_page=per_page, page=page-1) }}">← Prev</a>
{% else %}
<span>← Prev</span>
{% endif %}
<span class="current">Page {{ page }}</span>
{% if page < pages %}
<a href="{{ url_for('audit_html', entry_id=f_entry_id, action=f_action, actor=f_actor, device=f_device, q=f_q, per_page=per_page, page=page+1) }}">Next →</a>
{% else %}
<span>Next →</span>
{% endif %}
</div>
</div>
</body>
</html>

@ -1,144 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SplitBuddy</title>
<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 rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script defer src="{{ url_for('static', filename='app.js') }}"></script>
</head>
<body>
<div class="wrap">
<header>
<div class="h1">SplitBuddy</div>
<div>
<span class="pill {{ 'bad' if summary.total>0 else ('ok' if summary.total<0 else '') }}">
{% if summary.total > 0 %}
{{ A }} owes {{ B }} <strong>{{ currency }}{{ '%.2f'|format(summary.total) }}</strong>
{% elif summary.total < 0 %}
{{ B }} owes {{ A }} <strong>{{ currency }}{{ '%.2f'|format(-summary.total) }}</strong>
{% else %}All settled ✨{% endif %}
</span>
<span class="pill muted">Balance: {{ currency }}{{ '%.2f'|format(summary.total) }}</span>
<a class="pill" href="{{ url_for('export_csv') }}">Export CSV</a>
</div>
</header>
<div class="grid">
<section class="card">
<h2>Add entry</h2>
<form method="post" action="{{ url_for('add') }}">
<div class="row">
<div class="seg" title="Entry type">
<input type="radio" id="k_bill" name="kind" value="bill" checked><label for="k_bill">Bill</label>
<input type="radio" id="k_xfer" name="kind" value="transfer"><label for="k_xfer">Transfer</label>
</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.0001" min="0" name="total" placeholder="Amount" required>
</div>
<div class="row" id="share-wrap" style="margin-top:8px">
<input id="a_share" type="number" step="0.0001" min="0" max="100" name="a_share_pct"
placeholder="{{ A }} share %" value="{{ '%.4f'|format(default_a_share_pct) }}"
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 class="row" id="presets" style="margin-top:8px">
<div class="seg" title="Quick presets">
<input type="radio" id="p50" name="preset"><label for="p50">50/50</label>
<input type="radio" id="p66" name="preset" checked><label for="p66">{{ A }} 2/3</label>
<input type="radio" id="p33" name="preset"><label for="p33">{{ A }} 1/3</label>
</div>
</div>
</form>
</section>
<section class="card">
<h2>Stats</h2>
<div class="muted">Entries: {{ summary.count }}</div>
<div class="muted">Latest: {{ summary.latest|human_time if summary.latest else '—' }}</div>
<div style="margin-top:8px">
<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">Other Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}</span>
</div>
</section>
</div>
<section class="card" style="margin-top:16px">
<h2>Ledger</h2>
<table>
<thead>
<tr>
<th style="width:160px">Time</th>
<th>Type</th>
<th>Payer/Sender</th>
<th>Reason</th>
<th>Method</th>
<th class="num">Your share %</th>
<th class="num">Amount</th>
<th class="num">Δ Balance</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr data-entry-row="{{ e.id }}">
<td>{{ e.created_at|human_time }}</td>
<td>{{ e.kind }}</td>
<td>{{ A if e.payer=='A' else B }}</td>
<td>{{ e.note or '—' }}</td>
<td>{{ e.method }}</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 {{ 'pos' if e.delta>0 else 'neg' if e.delta<0 else '' }}">
{{ currency }}{{ '%.2f'|format(e.delta) }}
</td>
<td>
<div style="display:flex; gap:6px; justify-content:flex-end">
<button class="btn" type="button" data-edit="{{ e.id }}">Edit</button>
<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>
</form>
</div>
</td>
</tr>
<tr id="edit-row-{{ e.id }}" class="edit-row" style="display:none">
<td colspan="9">
<form class="edit-form" method="post" action="{{ url_for('edit', entry_id=e.id) }}" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap">
<div>
<label style="display:block; font-size:12px; color:#9aa0a6">Date</label>
<input type="datetime-local" name="created_at" value="{{ e.created_at }}">
</div>
<div>
<label style="display:block; font-size:12px; color:#9aa0a6">Amount</label>
<input type="number" step="0.0001" min="0" name="total" value="{{ '%.4f'|format(e.total) }}">
</div>
{% if e.kind == 'bill' %}
<div>
<label style="display:block; font-size:12px; color:#9aa0a6">Your share %</label>
<input type="number" step="0.0001" min="0" max="100" name="a_share_pct" value="{{ '%.4f'|format(e.a_share * 100) }}">
</div>
{% endif %}
<div style="margin-left:auto; display:flex; gap:8px">
<button class="btn" type="submit">Save</button>
<button class="btn" type="button" data-cancel-edit="{{ e.id }}">Cancel</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</body>
</html>
Loading…
Cancel
Save