diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 2850ca9..e3e843a 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -81,6 +81,20 @@ def index(): total_liabilities = get_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_categories = db.session.query( Category.name, @@ -158,6 +172,8 @@ def index(): total_assets=total_assets, total_liabilities=total_liabilities, net_worth=net_worth, + total_cash=total_cash, + total_investments=total_investments, top_categories=top_categories, chart_months=chart_months, chart_income=chart_income, @@ -169,6 +185,71 @@ def index(): 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') @login_required def fx_history_api(): diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index b7ee510..15df79a 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -39,24 +39,35 @@ +
+ +
+ +
+
-
+
Income
-
{{ total_income | currency }}
+
{{ total_income | currency }}
-
+
Expenses
-
{{ total_expense | currency }}
+
{{ total_expense | currency }}
@@ -67,7 +78,7 @@
Net Cash Flow
-
{{ net_cash_flow | currency }}
+
{{ net_cash_flow | currency }}
@@ -78,17 +89,41 @@
Savings Rate
-
+
{% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}%
-
+
of income saved this period
+
+
+
+
+
Checking & Savings
+
{{ total_cash | currency }}
+
+
+
+
checking · savings · cash
+
+
+
+
+
+
+
Investments
+
{{ total_investments | currency }}
+
+
+
+
investment · crypto
+
+
@@ -98,6 +133,7 @@
+
assets − liabilities
@@ -354,6 +390,116 @@ function refreshFxRate() { .catch(() => { if (btn) btn.innerHTML = ''; }); } +// ── 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 = 'Reconcile'; + btn.classList.remove('btn-primary'); + btn.classList.add('btn-outline-secondary'); + reconciled = false; + return; + } + + btn.disabled = true; + btn.innerHTML = '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 = 'Original'; + btn.classList.remove('btn-outline-secondary'); + btn.classList.add('btn-primary'); + btn.disabled = false; + reconciled = true; + }) + .catch(err => { + btn.innerHTML = 'Reconcile'; + btn.disabled = false; + alert('Reconcile failed: ' + err); + }); + }); +})(); + function toggleFxChart(){ const wrap = document.getElementById('fxChartWrap'); wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';