From 9c9aa694c4cbb41847832d9b30e4f3b58a4b4215 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 5 Jun 2026 15:23:23 -0400 Subject: [PATCH] 06/05 Optimize app --- app/routes/dashboard.py | 12 ++ app/routes/settings.py | 17 +- app/routes/transactions.py | 9 + app/services/health_score_service.py | 245 ++++++++++++++++++++++++++ app/services/recurring_service.py | 98 +++++++++++ app/templates/bank_import/index.html | 72 ++++++-- app/templates/base.html | 108 ++++++++++++ app/templates/dashboard/index.html | 98 +++++++++++ app/templates/settings/recurring.html | 141 +++++++++++++++ 9 files changed, 781 insertions(+), 19 deletions(-) create mode 100644 app/services/health_score_service.py diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 4f9ae12..e51d647 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -253,6 +253,18 @@ def reconcile_api(): }) +@dashboard_bp.route('/api/health-score') +@login_required +def health_score_api(): + from app.services.health_score_service import compute_health_score + try: + data = compute_health_score() + except Exception as e: + log.warning('[dashboard] health_score_api failed: %s', e) + return jsonify({'error': str(e)}), 500 + return jsonify(data) + + @dashboard_bp.route('/api/anomalies') @login_required def anomalies_api(): diff --git a/app/routes/settings.py b/app/routes/settings.py index 4b46167..de58080 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -1,7 +1,7 @@ import os import uuid from flask import (Blueprint, render_template, redirect, url_for, flash, - request, current_app, send_from_directory) + request, current_app, send_from_directory, jsonify) from flask_login import login_required, current_user from app.utils.audit import audit from flask_wtf import FlaskForm @@ -208,6 +208,21 @@ def recurring(): return render_template('settings/recurring.html', rules=rules, upcoming=upcoming) +@settings_bp.route('/recurring/projection') +@login_required +def recurring_projection(): + days = request.args.get('days', 30, type=int) + if days not in (30, 60, 90): + days = 30 + from app.services.recurring_service import projected_cash_flow + data = projected_cash_flow(days) + # Serialize events (date objects → string) + data['events'] = [ + {**ev, 'date': ev['date'].strftime('%b %d')} for ev in data['events'] + ] + return jsonify(data) + + @settings_bp.route('/recurring/new', methods=['GET', 'POST']) @login_required def recurring_new(): diff --git a/app/routes/transactions.py b/app/routes/transactions.py index 0fb66bd..4ea4a36 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -443,12 +443,21 @@ def ocr_receipt_file(): from app.models.category import Category from flask import current_app + from app.models.receipt import Receipt + data = request.get_json() if not data or not data.get('filename'): return jsonify({'error': 'No filename provided'}), 400 # Security: only allow basenames, no path traversal filename = os.path.basename(data['filename']) + + # Ownership check: filename must exist in receipts table (single-user, but + # prevents OCR extraction from arbitrary files on disk via a crafted request) + receipt_record = Receipt.query.filter_by(filename=filename).first() + if not receipt_record: + return jsonify({'error': 'Receipt not found'}), 404 + upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads') file_path = os.path.join(upload_dir, filename) diff --git a/app/services/health_score_service.py b/app/services/health_score_service.py new file mode 100644 index 0000000..b147541 --- /dev/null +++ b/app/services/health_score_service.py @@ -0,0 +1,245 @@ +""" +Financial Health Score — synthesises savings rate, budget adherence, goal +progress, and emergency fund coverage into a single 0–100 score. + +Each component is worth 25 points. Returns the total score plus a breakdown +so the UI can show per-component detail and suggestions. +""" + +from datetime import date +from dateutil.relativedelta import relativedelta +from sqlalchemy import func +from app.extensions import db +from app.models.transaction import Transaction +from app.models.goal import Goal + + +# ── Component scorers ──────────────────────────────────────────────────────── + +def _score_savings(max_pts=25): + """ + Avg savings rate over the last 3 full months. + ≥ 20% → full marks; 10–20% → 18; 1–10% → 10; ≤ 0% → 0. + """ + today = date.today() + month_start = today.replace(day=1) + + # Collect last 3 complete months + incomes, expenses = [], [] + for i in range(1, 4): + mo_end = (month_start - relativedelta(days=1)) + mo_start = mo_end.replace(day=1) + month_start = mo_start + + inc = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0)) + .filter(Transaction.transaction_type == 'income', + Transaction.date >= mo_start, + Transaction.date <= mo_end).scalar()) + exp = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0)) + .filter(Transaction.transaction_type == 'expense', + Transaction.date >= mo_start, + Transaction.date <= mo_end).scalar()) + incomes.append(inc) + expenses.append(exp) + + total_inc = sum(incomes) + total_exp = sum(expenses) + rate = ((total_inc - total_exp) / total_inc * 100) if total_inc > 0 else 0 + + if rate >= 20: + pts = max_pts + elif rate >= 10: + pts = round(max_pts * 0.72) # 18/25 + elif rate > 0: + pts = round(max_pts * 0.40) # 10/25 + else: + pts = 0 + + return { + 'points': pts, + 'max': max_pts, + 'value': round(rate, 1), + 'label': f'{rate:+.1f}% savings rate (3-mo avg)', + 'tip': None if rate >= 20 else ( + 'Aim for 20%+ savings rate.' if rate < 10 else + 'Good start — push toward 20%.'), + } + + +def _score_budgets(max_pts=25): + """ + What fraction of budgeted categories are currently under their limit? + All under → full marks; scales linearly. + """ + from app.services.budget_service import get_budget_summary + month_str = date.today().strftime('%Y-%m') + summary = [s for s in get_budget_summary(month_str) if s['has_budget']] + + if not summary: + return { + 'points': max_pts, # no budgets set → not penalised + 'max': max_pts, + 'value': None, + 'label': 'No budgets set', + 'tip': 'Set monthly budgets to track spending limits.', + } + + under = sum(1 for s in summary if not s['is_over']) + ratio = under / len(summary) + pts = round(ratio * max_pts) + + over_cats = [s['category'].name for s in summary if s['is_over']] + tip = None + if over_cats: + tip = f'Over budget: {", ".join(over_cats[:3])}{"…" if len(over_cats) > 3 else ""}.' + + return { + 'points': pts, + 'max': max_pts, + 'value': round(ratio * 100, 1), + 'label': f'{under}/{len(summary)} categories under budget', + 'tip': tip, + } + + +def _score_goals(max_pts=25): + """ + Average completion % across active (non-completed) goals. + 100% avg → full marks; scales linearly. + """ + goals = Goal.query.filter_by(is_completed=False).all() + if not goals: + completed = Goal.query.filter_by(is_completed=True).count() + return { + 'points': max_pts if completed else round(max_pts * 0.5), + 'max': max_pts, + 'value': 100.0 if completed else 0.0, + 'label': 'All goals completed!' if completed else 'No savings goals set', + 'tip': None if completed else 'Create a savings goal to track progress.', + } + + pcts = [] + for g in goals: + target = float(g.target_amount) + if target > 0: + pcts.append(min(float(g.current_amount) / target * 100, 100)) + + avg = (sum(pcts) / len(pcts)) if pcts else 0 + pts = round(avg / 100 * max_pts) + + behind = [g.name for g, p in zip(goals, pcts) if p < 25] + tip = None + if behind: + tip = f'Behind on: {", ".join(behind[:2])}{"…" if len(behind) > 2 else ""}.' + + return { + 'points': pts, + 'max': max_pts, + 'value': round(avg, 1), + 'label': f'{round(avg, 0):.0f}% avg goal progress ({len(goals)} active)', + 'tip': tip, + } + + +def _score_emergency_fund(max_pts=25): + """ + Liquid assets vs 3-month expense target. + ≥ 3 months → full marks; scales linearly up to 3 months. + """ + from app.services.goal_service import get_emergency_fund_status + ef = get_emergency_fund_status() + + liquid = ef['liquid_assets'] + target3 = ef['target_3mo'] + covered = ef['months_covered'] + pct3 = ef['pct_3mo'] # 0–100, capped + + pts = round(pct3 / 100 * max_pts) + + if covered >= 3: + tip = None + elif covered >= 1: + short = target3 - liquid + tip = f'Build to 3-month emergency fund (need ${short:,.0f} more).' + else: + tip = 'Start an emergency fund — aim for 1 month of expenses first.' + + return { + 'points': pts, + 'max': max_pts, + 'value': round(covered, 1), + 'label': f'{covered:.1f} months emergency fund', + 'tip': tip, + } + + +# ── Public API ─────────────────────────────────────────────────────────────── + +def compute_health_score(): + """ + Compute the overall financial health score. + + Returns: + { + score: int 0–100 + grade: str 'A' | 'B' | 'C' | 'D' | 'F' + color: str CSS color + components: list of component dicts + } + """ + components = { + 'savings': _score_savings(), + 'budgets': _score_budgets(), + 'goals': _score_goals(), + 'emergency_fund': _score_emergency_fund(), + } + + score = sum(c['points'] for c in components.values()) + + if score >= 85: + grade, color = 'A', '#10b981' + elif score >= 70: + grade, color = 'B', '#3b82f6' + elif score >= 55: + grade, color = 'C', '#f59e0b' + elif score >= 40: + grade, color = 'D', '#f97316' + else: + grade, color = 'F', '#ef4444' + + # Add display names for the template + labels = { + 'savings': 'Savings Rate', + 'budgets': 'Budget Adherence', + 'goals': 'Goal Progress', + 'emergency_fund': 'Emergency Fund', + } + icons = { + 'savings': 'bi-piggy-bank', + 'budgets': 'bi-pie-chart', + 'goals': 'bi-bullseye', + 'emergency_fund': 'bi-shield-check', + } + + component_list = [ + { + 'key': key, + 'name': labels[key], + 'icon': icons[key], + 'points': c['points'], + 'max': c['max'], + 'value': c['value'], + 'label': c['label'], + 'tip': c['tip'], + 'pct': round(c['points'] / c['max'] * 100), + } + for key, c in components.items() + ] + + return { + 'score': score, + 'grade': grade, + 'color': color, + 'components': component_list, + 'tips': [c['tip'] for c in component_list if c['tip']], + } diff --git a/app/services/recurring_service.py b/app/services/recurring_service.py index d94a213..cf1bc88 100644 --- a/app/services/recurring_service.py +++ b/app/services/recurring_service.py @@ -94,6 +94,104 @@ def process_due_rules(dry_run=False): return created +def projected_cash_flow(days=90): + """ + Build a projected cash flow from all active recurring rules over the next + N days. Returns weekly-bucketed chart data plus a flat event list. + + Returns dict: + labels — list of 'Mon DD' strings (week-start dates) + income — list of floats (income per week bucket) + expense — list of floats (expense per week bucket) + balance — list of floats (running balance at end of each bucket) + events — list of {date, description, amount, type, rule_id} + starting_balance — float + ending_balance — float + total_income — float + total_expense — float + net — float + """ + from app.models.account import Account + from sqlalchemy import func + from app.extensions import db + + today = date.today() + cutoff = today + timedelta(days=days) + + # Starting balance = sum of all active account balances + starting_balance = float( + db.session.query(func.coalesce(func.sum(Account.balance), 0)) + .filter(Account.is_active == True) + .scalar() + ) + + # Enumerate all occurrences of active rules within the window + rules = RecurringRule.query.filter_by(is_active=True).all() + events = [] + for rule in rules: + run_date = rule.next_run or rule.start_date + # Advance to window start if rule fires before today + while run_date < today: + run_date = next_occurrence(run_date, rule.frequency) + while run_date <= cutoff: + if rule.end_date and run_date > rule.end_date: + break + events.append({ + 'date': run_date, + 'description': rule.description, + 'amount': float(rule.amount), + 'type': rule.transaction_type, + 'rule_id': rule.id, + }) + run_date = next_occurrence(run_date, rule.frequency) + + events.sort(key=lambda e: e['date']) + + # Build weekly buckets: each bucket starts on Monday + # Find the Monday on or before today + week_start = today - timedelta(days=today.weekday()) + buckets = [] + ws = week_start + while ws <= cutoff: + buckets.append(ws) + ws += timedelta(weeks=1) + + bucket_income = [0.0] * len(buckets) + bucket_expense = [0.0] * len(buckets) + + for ev in events: + # Find which bucket this event falls in + idx = (ev['date'] - week_start).days // 7 + if 0 <= idx < len(buckets): + if ev['type'] == 'income': + bucket_income[idx] += ev['amount'] + else: + bucket_expense[idx] += ev['amount'] + + # Running balance + running = starting_balance + bucket_balance = [] + for inc, exp in zip(bucket_income, bucket_expense): + running += inc - exp + bucket_balance.append(round(running, 2)) + + total_income = sum(bucket_income) + total_expense = sum(bucket_expense) + + return { + 'labels': [b.strftime('%b %d') for b in buckets], + 'income': [round(v, 2) for v in bucket_income], + 'expense': [round(v, 2) for v in bucket_expense], + 'balance': bucket_balance, + 'events': events, + 'starting_balance': round(starting_balance, 2), + 'ending_balance': round(bucket_balance[-1], 2) if bucket_balance else round(starting_balance, 2), + 'total_income': round(total_income, 2), + 'total_expense': round(total_expense, 2), + 'net': round(total_income - total_expense, 2), + } + + def get_upcoming(days=30): """Return list of upcoming recurring transactions in the next N days.""" today = date.today() diff --git a/app/templates/bank_import/index.html b/app/templates/bank_import/index.html index 93168a5..32a997e 100644 --- a/app/templates/bank_import/index.html +++ b/app/templates/bank_import/index.html @@ -197,6 +197,17 @@ + {# Import progress bar (shown only during chunked import) #} +
+
+ Importing… + 0% +
+
+
+
+
+ {# Parse errors (non-fatal) #}
Warnings — these rows were skipped:
@@ -580,31 +591,56 @@ setImportLoading(true); $('import-error').classList.add('d-none'); - fetch('{{ url_for("bank_import.confirm_import") }}', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': CSRF, - }, - body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }), - }) - .then(r => r.json()) - .then(data => { + const CHUNK = 50; + const useChunks = rows.length > CHUNK; + let totalImported = 0, totalSkipped = 0; + + function setProgress(done, total) { + const pct = Math.round(done / total * 100); + $('import-progress-bar').style.width = pct + '%'; + $('import-progress-pct').textContent = pct + '%'; + $('import-progress-label').textContent = `Importing… ${done} of ${total}`; + } + + async function sendChunks() { + if (useChunks) { + $('import-progress-wrap').classList.remove('d-none'); + setProgress(0, rows.length); + } + + const chunks = []; + for (let i = 0; i < rows.length; i += CHUNK) chunks.push(rows.slice(i, i + CHUNK)); + let sent = 0; + + for (const chunk of chunks) { + const r = await fetch('{{ url_for("bank_import.confirm_import") }}', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF }, + body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows: chunk }), + }); + const data = await r.json(); + if (data.error) throw new Error(data.error); + totalImported += data.imported; + totalSkipped += data.skipped; + sent += chunk.length; + if (useChunks) setProgress(sent, rows.length); + } + } + + sendChunks() + .then(() => { setImportLoading(false); - if (data.error) { - $('import-error').textContent = data.error; - $('import-error').classList.remove('d-none'); - return; - } + $('import-progress-wrap').classList.add('d-none'); $('done-title').textContent = - data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully'; + totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully'; $('done-subtitle').textContent = - data.skipped > 0 ? data.skipped + ' duplicate(s) skipped.' : 'All transactions were new.'; + totalSkipped > 0 ? totalSkipped + ' duplicate(s) skipped.' : 'All transactions were new.'; showStep('step-done'); }) .catch(err => { setImportLoading(false); - $('import-error').textContent = 'Request failed: ' + err; + $('import-progress-wrap').classList.add('d-none'); + $('import-error').textContent = 'Import failed: ' + err; $('import-error').classList.remove('d-none'); }); }); diff --git a/app/templates/base.html b/app/templates/base.html index d13b96e..01850db 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -198,8 +198,88 @@ .sb-overlay.on { display: block; } @keyframes spin { to { transform: rotate(360deg); } } + + /* ── Dark mode ─────────────────────────────────────────────────────── */ + body.dark-mode { + --body-bg: #0f172a; --card-bg: #1e293b; --text: #e2e8f0; + --muted: #94a3b8; --border: #334155; + color-scheme: dark; + } + body.dark-mode #topbar { background: #1e293b; border-color: #334155; } + body.dark-mode .pfm-table tbody tr:hover { background: #263548; } + body.dark-mode .pfm-table th, body.dark-mode .pfm-table td { border-color: #334155; } + body.dark-mode .form-control, body.dark-mode .form-select { + background: #0f172a; border-color: #334155; color: #e2e8f0; + } + body.dark-mode .form-control:focus, body.dark-mode .form-select:focus { + background: #0f172a; border-color: #3b82f6; color: #e2e8f0; + box-shadow: 0 0 0 .2rem rgba(59,130,246,.25); + } + body.dark-mode .form-control::placeholder { color: #475569; } + body.dark-mode .form-check-input { background-color: #334155; border-color: #475569; } + body.dark-mode .form-check-input:checked { background-color: #3b82f6; border-color: #3b82f6; } + body.dark-mode .btn-outline-secondary { color: #94a3b8; border-color: #334155; } + body.dark-mode .btn-outline-secondary:hover, + body.dark-mode .btn-outline-secondary:focus { background: #334155; color: #e2e8f0; border-color: #334155; } + body.dark-mode .btn-outline-primary { color: #93c5fd; border-color: #1e40af; } + body.dark-mode .btn-outline-primary:hover { background: #1e40af; color: #fff; } + body.dark-mode .btn-outline-warning { color: #fcd34d; border-color: #92400e; } + body.dark-mode .btn-outline-warning:hover { background: #92400e; color: #fff; } + body.dark-mode .btn-outline-danger { color: #fca5a5; border-color: #991b1b; } + body.dark-mode .btn-outline-danger:hover { background: #991b1b; color: #fff; } + body.dark-mode .dropdown-menu { background: #1e293b; border-color: #334155; } + body.dark-mode .dropdown-item { color: #e2e8f0; } + body.dark-mode .dropdown-item:hover, body.dark-mode .dropdown-item:focus { background: #334155; color: #f1f5f9; } + body.dark-mode .dropdown-divider { border-color: #334155; } + body.dark-mode .modal-content { background: #1e293b; border-color: #334155; color: #e2e8f0; } + body.dark-mode .modal-header, body.dark-mode .modal-footer { border-color: #334155; } + body.dark-mode .modal-header .btn-close { filter: invert(1) grayscale(1); } + body.dark-mode .alert-warning { background: #451a03; border-color: #92400e; color: #fcd34d; } + body.dark-mode .alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; } + body.dark-mode .alert-success { background: #052e16; border-color: #166534; color: #86efac; } + body.dark-mode .alert-info { background: #0c1a2e; border-color: #1e40af; color: #93c5fd; } + body.dark-mode .progress { background: #334155; } + body.dark-mode .table { color: #e2e8f0; } + body.dark-mode .input-group-text { background: #334155; border-color: #334155; color: #94a3b8; } + body.dark-mode .badge-income { background: #064e3b; color: #6ee7b7; } + body.dark-mode .badge-expense { background: #450a0a; color: #fca5a5; } + body.dark-mode .badge-transfer { background: #1e3a5f; color: #93c5fd; } + body.dark-mode #kbd-modal kbd { background: #334155; border-color: #475569; color: #e2e8f0; } + body.dark-mode .report-tab { background: #1e293b; border-color: #334155; color: #94a3b8; } + body.dark-mode .report-tab:hover { background: #334155; color: #e2e8f0; } + body.dark-mode .report-tab.active { background: #e2e8f0; color: #0f172a; border-color: #e2e8f0; } + /* Override common hardcoded light backgrounds in component inline styles */ + body.dark-mode [style*="background:#f8fafc"], body.dark-mode [style*="background: #f8fafc"], + body.dark-mode [style*="background:#f1f5f9"], body.dark-mode [style*="background: #f1f5f9"] + { background: #263548 !important; } + body.dark-mode [style*="background:#eff6ff"], body.dark-mode [style*="background: #eff6ff"] + { background: #1e3a5f !important; } + body.dark-mode [style*="background:#fef2f2"], body.dark-mode [style*="background: #fef2f2"] + { background: #450a0a !important; } + body.dark-mode [style*="background:#f0fdf4"], body.dark-mode [style*="background: #f0fdf4"] + { background: #052e16 !important; } + body.dark-mode [style*="background:#fff5e6"], body.dark-mode [style*="background:#fffbeb"] + { background: #451a03 !important; } + /* Text color overrides for hardcoded darks */ + body.dark-mode [style*="color:#0f172a"] { color: #e2e8f0 !important; } + body.dark-mode [style*="color:#1e293b"] { color: #94a3b8 !important; } + body.dark-mode [style*="color:#374151"] { color: #94a3b8 !important; } + /* Border overrides */ + body.dark-mode [style*="border-bottom:1px solid #e2e8f0"], + body.dark-mode [style*="border-bottom: 1px solid #e2e8f0"] { border-color: #334155 !important; } + body.dark-mode [style*="border:1px solid #e2e8f0"], + body.dark-mode [style*="border: 1px solid #e2e8f0"] { border-color: #334155 !important; } + {% block extra_css %}{% endblock %} + +
@@ -318,6 +398,11 @@ {% block page_title %}{% endblock %}
{% block topbar_actions %}{% endblock %} +
+ + +
@@ -555,6 +588,71 @@ function refreshFxRate() { }); })(); +// ── Financial health score ─────────────────────────────────────────────────── +(function(){ + var card = document.getElementById('health-score-card'); + var ring = document.getElementById('hs-ring'); + var scoreEl = document.getElementById('hs-score'); + var gradeEl = document.getElementById('hs-grade-badge'); + var compsEl = document.getElementById('hs-components'); + var tipsWrap = document.getElementById('hs-tips'); + var tipsList = document.getElementById('hs-tips-list'); + if (!card) return; + + const SYM = '{{ current_user.currency_symbol }}'; + + fetch('/api/health-score') + .then(r => r.json()) + .then(data => { + if (data.error) return; + card.style.display = ''; + + // Score ring + var circ = 100; + ring.style.strokeDasharray = (data.score / 100 * circ) + ' ' + circ; + ring.style.stroke = data.color; + scoreEl.textContent = data.score; + scoreEl.style.color = data.color; + + // Grade badge + gradeEl.textContent = 'Grade ' + data.grade; + gradeEl.style.background = data.color + '22'; + gradeEl.style.color = data.color; + + // Component bars + var icons = { + savings: 'bi-piggy-bank', + budgets: 'bi-pie-chart', + goals: 'bi-bullseye', + emergency_fund: 'bi-shield-check', + }; + compsEl.innerHTML = data.components.map(c => ` +
+
+ ${c.name} + ${c.points}/${c.max} +
+
+
+
+
+ ${c.label ? `
${c.label}
` : ''} +
`).join(''); + + // Tips + if (data.tips && data.tips.length) { + tipsWrap.style.display = ''; + tipsList.innerHTML = data.tips.map(t => + ` + ${t} + ` + ).join(''); + } + }) + .catch(() => {}); +})(); + // ── Spending anomalies ─────────────────────────────────────────────────────── (function(){ const card = document.getElementById('anomaly-card'); diff --git a/app/templates/settings/recurring.html b/app/templates/settings/recurring.html index a047b71..00b48fb 100644 --- a/app/templates/settings/recurring.html +++ b/app/templates/settings/recurring.html @@ -94,4 +94,145 @@
+ + +
+
+ Projected Cash Flow +
+ + + +
+
+ + +
+
+
+
Income
+
+
+
+
+
+
Expenses
+
+
+
+
+
+
Net
+
+
+
+
+
+
End Balance
+
+
+
+
+ + +
+ +
+ + + +
+
+{% endblock %} + +{% block extra_js %} + + {% endblock %}