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
+
+
@@ -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';