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