|  |  | @ -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 | 
			
		
	
	
		
		
			
				
					|  |  | @ -134,38 +236,16 @@ class Summary: | 
			
		
	
		
		
			
				
					
					|  |  |  | def _delta_for_entry(kind: str, 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. | 
			
		
	
		
		
			
				
					
					|  |  |  |     - bill:     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 |  |  |  |     - transfer: delta = -total if YOU sent; +total if Idan sent | 
			
		
	
		
		
			
				
					
					|  |  |  |     """ |  |  |  |     """ | 
			
		
	
		
		
			
				
					
					|  |  |  |     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) | 
			
				
				
			
		
	
		
		
	
	
		
		
			
				
					|  |  | 
 |