06/05 Optimize app: Dashboard adds Reconcile button and separate Cashes and Investment

This commit is contained in:
2026-06-05 13:20:17 -04:00
parent 3a113c89c4
commit 452374365c
2 changed files with 234 additions and 7 deletions
+81
View File
@@ -81,6 +81,20 @@ def index():
total_liabilities = get_total_liabilities() total_liabilities = get_total_liabilities()
net_worth = total_assets - total_liabilities net_worth = total_assets - total_liabilities
total_cash = float(db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type.in_(['checking', 'savings', 'cash']),
).scalar())
total_investments = float(db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type.in_(['investment', 'crypto']),
).scalar())
# ── Top expense categories ──────────────────────── # ── Top expense categories ────────────────────────
top_categories = db.session.query( top_categories = db.session.query(
Category.name, Category.name,
@@ -158,6 +172,8 @@ def index():
total_assets=total_assets, total_assets=total_assets,
total_liabilities=total_liabilities, total_liabilities=total_liabilities,
net_worth=net_worth, net_worth=net_worth,
total_cash=total_cash,
total_investments=total_investments,
top_categories=top_categories, top_categories=top_categories,
chart_months=chart_months, chart_months=chart_months,
chart_income=chart_income, chart_income=chart_income,
@@ -169,6 +185,71 @@ def index():
schwab_warning=schwab_warning) schwab_warning=schwab_warning)
@dashboard_bp.route('/api/reconcile')
@login_required
def reconcile_api():
"""
Return income/expense totals for the period excluding any categories
whose name contains 'transfer' (case-insensitive).
These are internal account moves that inflate both sides artificially.
"""
period = request.args.get('period', 'this_month')
date_from, date_to, _ = _parse_date_range(period)
# Find all transfer-like category IDs
transfer_cats = Category.query.filter(
Category.name.ilike('%transfer%')
).all()
transfer_ids = [c.id for c in transfer_cats]
def _sum(txn_type):
q = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == txn_type,
Transaction.date >= date_from,
Transaction.date <= date_to,
)
if transfer_ids:
q = q.filter(
db.or_(
Transaction.category_id.notin_(transfer_ids),
Transaction.category_id.is_(None),
)
)
return float(q.scalar())
def _sum_transfer(txn_type):
if not transfer_ids:
return 0.0
return float(db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == txn_type,
Transaction.date >= date_from,
Transaction.date <= date_to,
Transaction.category_id.in_(transfer_ids),
).scalar())
income = _sum('income')
expense = _sum('expense')
net = income - expense
savings = round(net / income * 100, 1) if income else 0
excluded_income = _sum_transfer('income')
excluded_expense = _sum_transfer('expense')
return jsonify({
'income': income,
'expense': expense,
'net_cash_flow': net,
'savings_rate': savings,
'excluded_income': excluded_income,
'excluded_expense': excluded_expense,
'transfer_categories': [c.name for c in transfer_cats],
})
@dashboard_bp.route('/api/fx-history') @dashboard_bp.route('/api/fx-history')
@login_required @login_required
def fx_history_api(): def fx_history_api():
+153 -7
View File
@@ -39,24 +39,35 @@
</div> </div>
<!-- Summary Cards --> <!-- Summary Cards -->
<div class="d-flex align-items-center justify-content-between mb-2">
<div id="reconcile-notice" style="display:none;font-size:11px;color:#6d28d9;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:6px;padding:3px 10px;">
<i class="bi bi-check2-circle me-1"></i><span id="reconcile-notice-text"></span>
</div>
<div class="ms-auto">
<button id="reconcile-btn" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
title="Recalculate income &amp; expenses excluding internal transfers">
<i class="bi bi-calculator me-1"></i>Reconcile
</button>
</div>
</div>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="stat-card"> <div class="stat-card" id="card-income">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<div class="stat-label">Income</div> <div class="stat-label">Income</div>
<div class="stat-value text-income">{{ total_income | currency }}</div> <div id="val-income" class="stat-value text-income">{{ total_income | currency }}</div>
</div> </div>
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-arrow-down-circle"></i></div> <div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-arrow-down-circle"></i></div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="stat-card"> <div class="stat-card" id="card-expense">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<div class="stat-label">Expenses</div> <div class="stat-label">Expenses</div>
<div class="stat-value text-expense">{{ total_expense | currency }}</div> <div id="val-expense" class="stat-value text-expense">{{ total_expense | currency }}</div>
</div> </div>
<div class="stat-icon" style="background:#fee2e2;color:#991b1b;"><i class="bi bi-arrow-up-circle"></i></div> <div class="stat-icon" style="background:#fee2e2;color:#991b1b;"><i class="bi bi-arrow-up-circle"></i></div>
</div> </div>
@@ -67,7 +78,7 @@
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<div class="stat-label">Net Cash Flow</div> <div class="stat-label">Net Cash Flow</div>
<div class="stat-value {% if net_cash_flow >= 0 %}text-income{% else %}text-expense{% endif %}">{{ net_cash_flow | currency }}</div> <div id="val-net" class="stat-value {% if net_cash_flow >= 0 %}text-income{% else %}text-expense{% endif %}">{{ net_cash_flow | currency }}</div>
</div> </div>
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-arrow-left-right"></i></div> <div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-arrow-left-right"></i></div>
</div> </div>
@@ -78,17 +89,41 @@
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<div class="stat-label">Savings Rate</div> <div class="stat-label">Savings Rate</div>
<div class="stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}"> <div id="val-savings" class="stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}">
{% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}% {% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}%
</div> </div>
</div> </div>
<div class="stat-icon" style="background:{% if savings_rate >= 20 %}#d1fae5;color:#065f46{% elif savings_rate > 0 %}#dbeafe;color:#1e40af{% else %}#fee2e2;color:#991b1b{% endif %};"> <div id="icon-savings" class="stat-icon" style="background:{% if savings_rate >= 20 %}#d1fae5;color:#065f46{% elif savings_rate > 0 %}#dbeafe;color:#1e40af{% else %}#fee2e2;color:#991b1b{% endif %};">
<i class="bi bi-piggy-bank"></i> <i class="bi bi-piggy-bank"></i>
</div> </div>
</div> </div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">of income saved this period</div> <div style="font-size:10px;color:var(--muted);margin-top:4px;">of income saved this period</div>
</div> </div>
</div> </div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Checking &amp; Savings</div>
<div class="stat-value {% if total_cash >= 0 %}text-income{% else %}text-expense{% endif %}">{{ total_cash | currency }}</div>
</div>
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-wallet2"></i></div>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">checking · savings · cash</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Investments</div>
<div class="stat-value {% if total_investments >= 0 %}text-invest{% else %}text-expense{% endif %}">{{ total_investments | currency }}</div>
</div>
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-graph-up-arrow"></i></div>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">investment · crypto</div>
</div>
</div>
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="stat-card"> <div class="stat-card">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
@@ -98,6 +133,7 @@
</div> </div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-bank"></i></div> <div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-bank"></i></div>
</div> </div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">assets liabilities</div>
</div> </div>
</div> </div>
</div> </div>
@@ -354,6 +390,116 @@ function refreshFxRate() {
.catch(() => { if (btn) btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>'; }); .catch(() => { if (btn) btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>'; });
} }
// ── Reconcile ─────────────────────────────────────────────────────────────────
(function () {
const btn = document.getElementById('reconcile-btn');
const notice = document.getElementById('reconcile-notice');
const noticeText = document.getElementById('reconcile-notice-text');
if (!btn) return;
const SYM = '{{ current_user.currency_symbol }}';
let reconciled = false;
// Store original values from the rendered HTML so we can restore them
const orig = {
income: document.getElementById('val-income').textContent,
expense: document.getElementById('val-expense').textContent,
net: document.getElementById('val-net').textContent,
savings: document.getElementById('val-savings').textContent,
};
function fmtCurrency(n) {
const abs = Math.abs(n);
let s;
if (abs >= 1000000) s = SYM + (abs / 1000000).toFixed(2) + 'M';
else if (abs >= 1000) s = SYM + (abs / 1000).toFixed(1) + 'K';
else s = SYM + abs.toFixed(2);
return n < 0 ? '-' + s : s;
}
function colorClass(n, positive_class, negative_class) {
return n >= 0 ? positive_class : negative_class;
}
function applyValues(data) {
const income = document.getElementById('val-income');
const expense = document.getElementById('val-expense');
const net = document.getElementById('val-net');
const savings = document.getElementById('val-savings');
income.textContent = fmtCurrency(data.income);
expense.textContent = fmtCurrency(data.expense);
net.textContent = fmtCurrency(data.net_cash_flow);
net.className = 'stat-value ' + colorClass(data.net_cash_flow, 'text-income', 'text-expense');
const sr = data.savings_rate;
savings.textContent = (sr >= 0 ? '+' : '') + sr + '%';
savings.className = 'stat-value ' + (sr >= 20 ? 'text-income' : sr > 0 ? 'text-invest' : 'text-expense');
}
function restoreOriginal() {
document.getElementById('val-income').textContent = orig.income;
document.getElementById('val-expense').textContent = orig.expense;
document.getElementById('val-net').textContent = orig.net;
document.getElementById('val-savings').textContent = orig.savings;
// restore original color classes
document.getElementById('val-net').className = 'stat-value {% if net_cash_flow >= 0 %}text-income{% else %}text-expense{% endif %}';
document.getElementById('val-savings').className = 'stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}';
}
btn.addEventListener('click', function () {
if (reconciled) {
// Toggle back to original
restoreOriginal();
notice.style.display = 'none';
btn.innerHTML = '<i class="bi bi-calculator me-1"></i>Reconcile';
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-secondary');
reconciled = false;
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Calculating…';
const params = new URLSearchParams({ period: '{{ period }}' });
{% if period == 'custom' %}
params.set('date_from', '{{ date_from.strftime("%Y-%m-%d") }}');
params.set('date_to', '{{ date_to.strftime("%Y-%m-%d") }}');
{% endif %}
fetch('/api/reconcile?' + params)
.then(r => r.json())
.then(data => {
applyValues(data);
const excl_i = fmtCurrency(data.excluded_income);
const excl_e = fmtCurrency(data.excluded_expense);
const cats = data.transfer_categories.length
? data.transfer_categories.join(', ')
: 'none found';
if (data.excluded_income === 0 && data.excluded_expense === 0) {
noticeText.textContent = 'No internal transfers found for this period (' + cats + ')';
} else {
noticeText.textContent = 'Excluding transfers — income ' + excl_i + ', expenses ' + excl_e + ' (' + cats + ')';
}
notice.style.display = '';
btn.innerHTML = '<i class="bi bi-calculator-fill me-1"></i>Original';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-primary');
btn.disabled = false;
reconciled = true;
})
.catch(err => {
btn.innerHTML = '<i class="bi bi-calculator me-1"></i>Reconcile';
btn.disabled = false;
alert('Reconcile failed: ' + err);
});
});
})();
function toggleFxChart(){ function toggleFxChart(){
const wrap = document.getElementById('fxChartWrap'); const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none'; wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';