budgeting app

This commit is contained in:
OusmBlueNinja 2025-04-26 20:13:35 -05:00
parent 72fed6e258
commit 233083fa4f
7 changed files with 2909 additions and 0 deletions

304
budgeting_website/app.py Normal file
View File

@ -0,0 +1,304 @@
from flask import Flask, render_template, request, redirect, url_for, flash, session
import pandas as pd
import os
import json
import uuid
from datetime import datetime
from dateutil.relativedelta import relativedelta
from plotly.utils import PlotlyJSONEncoder
import plotly.express as px
import plotly.graph_objects as go
app = Flask(__name__)
app.secret_key = "your-secret-key"
app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['DATA_FOLDER'] = 'data'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(app.config['DATA_FOLDER'], exist_ok=True)
REQUIRED_COLUMNS = [
"Date", "Transaction ID", "Transaction Type", "Currency",
"Amount", "Fee", "Net Amount", "Asset Type", "Asset Price",
"Asset Amount", "Status", "Notes", "Name of sender/receiver", "Account"
]
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() == 'csv'
MERCHANT_MAP = {
"fast_food": ["mcdonald", "wendy", "kfc", "taco", "popeyes", "burger", "subway", "chipotle"],
"restaurant": ["olive garden", "applebee", "chili", "roadhouse", "ihop", "panda express", "buffalo wild wings"],
"shopping": ["walmart", "target", "amazon", "ebay", "costco", "best buy"],
"subscription": ["netflix", "spotify", "hulu", "apple", "youtube", "icloud", "google", "xbox"],
"gas": ["kwik trip", "shell", "bp", "exxon", "chevron"],
"banking": ["transfer", "add cash", "savings internal transfer", "interest"],
}
def classify_merchant(name):
if not isinstance(name, str) or not name.strip():
return "unknown"
name = name.strip().lower()
for category, keywords in MERCHANT_MAP.items():
if any(k in name for k in keywords):
return category
words = name.title().split()
if len(words) in [2, 3] and all(w.isalpha() and w[0].isupper() for w in words):
return "person"
return "unknown"
def generate_recommendations(df):
tips = []
now = datetime.now()
current_month = now.strftime('%Y-%m')
last_month = (now - relativedelta(months=1)).strftime('%Y-%m')
df['Month'] = df['Date'].dt.strftime('%Y-%m')
recurring = (
df[df['Amount'] < 0]
.groupby('Name of sender/receiver')['Month']
.nunique()
.reset_index()
)
recurring = recurring[recurring['Month'] >= 2]
for _, row in recurring.iterrows():
name = row['Name of sender/receiver']
kind = classify_merchant(name)
if kind == "subscription":
tips.append(f"You're regularly paying {name}, which looks like a subscription.")
elif kind == "person":
tips.append(f"You've been sending money to {name} frequently — consider if it's necessary.")
elif kind == "unknown":
tips.append(f"Youve made payments to {name} in {row['Month']} different months. Review if it's essential.")
potential_subs = df[
(df['Amount'] < 0) &
(df['Amount'].abs() <= 20) &
(df['Name of sender/receiver'].str.lower().str.contains("netflix|spotify|hulu|apple|google|amazon|prime", na=False))
]
for name in potential_subs['Name of sender/receiver'].unique():
total = abs(potential_subs[potential_subs['Name of sender/receiver'] == name]['Amount'].sum())
tips.append(f"You may be subscribed to {name} — you've spent ${total:.2f} there this month.")
# --- Fast food / Restaurant alerts ---
for kind in ['fast_food', 'restaurant']:
matches = df[
df['Name of sender/receiver']
.str.lower()
.fillna('')
.apply(lambda x: any(k in x for k in MERCHANT_MAP[kind])) & (df['Amount'] < 0)
]
if not matches.empty:
totals = matches.groupby('Name of sender/receiver')['Amount'].sum().abs()
for vendor, amt in totals.items():
if kind == "fast_food":
tips.append(f"You spent ${amt:.2f} at {vendor} this month. Maybe try cooking more?")
else:
tips.append(f"You spent ${amt:.2f} dining at {vendor} this month.")
# --- Month-to-month trend per vendor ---
top_this_month = (
df[(df['Month'] == current_month) & (df['Amount'] < 0)]
.groupby('Name of sender/receiver')['Amount']
.sum()
.abs()
.sort_values(ascending=False)
)
top_last_month = (
df[(df['Month'] == last_month) & (df['Amount'] < 0)]
.groupby('Name of sender/receiver')['Amount']
.sum()
.abs()
)
for vendor, this_amt in top_this_month.head(5).items():
last_amt = top_last_month.get(vendor, 0)
if this_amt > last_amt and last_amt > 0:
increase = this_amt - last_amt
tips.append(f"You're spending ${increase:.2f} more at {vendor} this month than last month.")
if not tips:
tips.append("You're doing great this month! 🎉 Keep up the mindful spending.")
return tips
def clean_dataframe(df):
# Strip trailing timezone for parsing
df['Date'] = df['Date'].astype(str).str.replace(r'\s+[A-Z]{3,4}$', '', regex=True)
df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
# Convert amounts to numeric
df['Amount'] = df['Amount'].replace('[\$,]', '', regex=True).astype(float)
df['Fee'] = df['Fee'].replace('[\$,]', '', regex=True).astype(float)
df['Net Amount'] = df['Net Amount'].replace('[\$,]', '', regex=True).astype(float)
df = df.dropna(subset=['Date', 'Amount'])
return df
def get_dataframe():
if 'data_id' in session:
path = os.path.join(app.config['DATA_FOLDER'], f"{session['data_id']}.csv")
if os.path.exists(path):
df = pd.read_csv(path)
if all(col in df.columns for col in REQUIRED_COLUMNS):
return clean_dataframe(df)
return None
def save_dataframe(df):
if 'data_id' not in session:
session['data_id'] = str(uuid.uuid4())
path = os.path.join(app.config['DATA_FOLDER'], f"{session['data_id']}.csv")
df.to_csv(path, index=False)
def get_monthly_summary(df):
df['Month'] = df['Date'].dt.strftime('%Y-%m')
expenses = df[df['Amount'] < 0]
income = df[df['Amount'] > 0]
monthly = pd.DataFrame()
monthly['Total_Expenses'] = expenses.groupby('Month')['Amount'].sum().abs()
monthly['Total_Income'] = income.groupby('Month')['Amount'].sum()
monthly = monthly.fillna(0).reset_index()
monthly['Savings'] = monthly['Total_Income'] - monthly['Total_Expenses']
monthly['Savings_Rate'] = (monthly['Savings'] / monthly['Total_Income'].replace(0, 1) * 100).round(1)
return monthly
def get_category_spending(df):
expenses = df[df['Amount'] < 0]
grouped = expenses.groupby('Name of sender/receiver')['Amount'].sum().abs().reset_index()
grouped.columns = ['Category', 'Total_Spent']
return grouped.sort_values(by='Total_Spent', ascending=False)
def generate_monthly_trend_chart(summary):
fig = go.Figure()
fig.add_trace(go.Bar(x=summary['Month'], y=summary['Total_Income'], name='Income', marker_color='green'))
fig.add_trace(go.Bar(x=summary['Month'], y=summary['Total_Expenses'], name='Expenses', marker_color='red'))
fig.add_trace(go.Scatter(x=summary['Month'], y=summary['Savings'], mode='lines+markers', name='Net Savings', line=dict(color='blue')))
fig.update_layout(title='Monthly Trends', barmode='group', height=400)
return json.dumps(fig, cls=PlotlyJSONEncoder)
def generate_category_pie_chart(category_spending):
top = category_spending.head(10)
if len(category_spending) > 10:
other_sum = category_spending.iloc[10:]['Total_Spent'].sum()
top = pd.concat([top, pd.DataFrame([{'Category': 'Other', 'Total_Spent': other_sum}])])
fig = px.pie(top, names='Category', values='Total_Spent', hole=0.4)
fig.update_layout(title='Spending by Category', height=400)
return json.dumps(fig, cls=PlotlyJSONEncoder)
def calculate_metrics(df):
metrics = {}
metrics['total_income'] = df[df['Amount'] > 0]['Amount'].sum()
metrics['total_expenses'] = abs(df[df['Amount'] < 0]['Amount'].sum())
metrics['net_worth'] = df['Net Amount'].iloc[-1] if not df.empty else 0
today = datetime.now()
start = datetime(today.year, today.month, 1)
last_month = start - relativedelta(months=1)
current = df[df['Date'] >= start]
prev = df[(df['Date'] >= last_month) & (df['Date'] < start)]
current_expenses = abs(current[current['Amount'] < 0]['Amount'].sum())
prev_expenses = abs(prev[prev['Amount'] < 0]['Amount'].sum())
metrics['current_month_expenses'] = current_expenses
metrics['last_month_expenses'] = prev_expenses
metrics['expense_change'] = round(((current_expenses - prev_expenses) / prev_expenses * 100) if prev_expenses else 0, 1)
return metrics
@app.route('/')
def index():
df = get_dataframe()
if df is not None:
summary = get_monthly_summary(df)
categories = get_category_spending(df)
trend = generate_monthly_trend_chart(summary)
pie = generate_category_pie_chart(categories)
metrics = calculate_metrics(df)
recent = df.sort_values(by='Date', ascending=False).head(10).to_dict('records')
tips = generate_recommendations(df)
return render_template("dashboard.html",
monthly_trend_chart=trend,
category_pie_chart=pie,
metrics=metrics,
monthly_summary=summary.to_dict('records'),
recent_transactions=recent,
recommendations=tips
)
return render_template("upload.html")
@app.route('/transactions')
def transactions():
df = get_dataframe()
if df is not None:
df = df.sort_values(by='Date', ascending=False)
return render_template("transactions.html", transactions=df.to_dict('records'))
flash("No data available.")
return redirect(url_for('index'))
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
try:
df = pd.read_csv(file)
if not all(col in df.columns for col in REQUIRED_COLUMNS):
flash("CSV is missing required columns.")
return redirect(url_for('index'))
save_dataframe(df)
flash("Upload successful.")
return redirect(url_for('index'))
except Exception as e:
flash(f"Error: {e}")
return redirect(url_for('index'))
flash("Only CSVs allowed.")
return redirect(url_for('index'))
@app.route('/clear')
def clear_data():
if 'data_id' in session:
path = os.path.join(app.config['DATA_FOLDER'], f"{session['data_id']}.csv")
if os.path.exists(path):
os.remove(path)
session.pop('data_id')
flash("Data cleared.")
return redirect(url_for('index'))
if __name__ == '__main__':
app.run(debug=True)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,183 @@
<!-- templates/dashboard.html -->
{% extends "layout.html" %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h2 class="mb-4">Financial Overview</h2>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="card metric-card h-100">
<div class="metric-label">Total Income</div>
<div class="metric-value positive">${{ "%0.2f"|format(metrics['total_income']) }}</div>
</div>
</div>
<div class="col-md-3">
<div class="card metric-card h-100">
<div class="metric-label">Total Expenses</div>
<div class="metric-value negative">${{ "%0.2f"|format(metrics['total_expenses']) }}</div>
</div>
</div>
<div class="col-md-3">
<div class="card metric-card h-100">
<div class="metric-label">Net Worth</div>
<div class="metric-value neutral">${{ "%0.2f"|format(metrics['net_worth']) }}</div>
</div>
</div>
<div class="col-md-3">
<div class="card metric-card h-100">
<div class="metric-label">Current Month Expenses</div>
<div class="metric-value negative">${{ "%0.2f"|format(metrics['current_month_expenses']) }}</div>
<div class="small {% if metrics['expense_change'] < 0 %}positive{% elif metrics['expense_change'] > 0 %}negative{% else %}neutral{% endif %}">
{% if metrics['expense_change'] < 0 %}
▼ {{ metrics['expense_change']|abs }}% from last month
{% elif metrics['expense_change'] > 0 %}
▲ {{ metrics['expense_change'] }}% from last month
{% else %}
No change from last month
{% endif %}
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-light">
<h5 class="mb-0">Smart Recommendations</h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for tip in recommendations %}
<li class="list-group-item">💡 {{ tip | safe }}</li>
{% endfor %}
</ul>
</div>
</div>
<div class="row mt-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">Monthly Income and Expenses</div>
<div class="card-body">
<div class="chart-container" id="monthly-trend-chart"></div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Spending by Category</div>
<div class="card-body">
<div class="chart-container" id="category-pie-chart"></div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">Monthly Summary</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Month</th>
<th>Income</th>
<th>Expenses</th>
<th>Savings</th>
<th>Savings Rate</th>
</tr>
</thead>
<tbody>
{% for month in monthly_summary %}
<tr>
<td>{{ month['Month'] }}</td>
<td class="positive">${{ "%0.2f"|format(month['Total_Income']) }}</td>
<td class="negative">${{ "%0.2f"|format(month['Total_Expenses']) }}</td>
<td class="{% if month['Savings'] >= 0 %}positive{% else %}negative{% endif %}">
${{ "%0.2f"|format(month['Savings']) }}
</td>
<td>
{% if month['Total_Income'] > 0 %}
{{ month['Savings_Rate'] }}%
{% else %}
N/A
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">Recent Transactions</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Amount</th>
<th>Account</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for transaction in recent_transactions %}
<tr>
<td>{{ transaction['Date'].strftime('%Y-%m-%d') }}</td>
<td>{{ transaction['Name of sender/receiver'] }}</td>
<td class="{% if transaction['Amount'] >= 0 %}positive{% else %}negative{% endif %}">
${{ "%0.2f"|format(transaction['Amount']) }}
</td>
<td>{{ transaction['Account'] }}</td>
<td>{{ transaction['Status'] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="text-center mt-3">
<a href="/transactions" class="btn btn-outline-primary">View All Transactions</a>
</div>
</div>
</div>
</div>
</div>
<script>
// Render monthly trend chart
var monthlyTrendData = {{ monthly_trend_chart|safe }};
Plotly.newPlot('monthly-trend-chart', monthlyTrendData.data, monthlyTrendData.layout);
// Render category pie chart
var categoryPieData = {{ category_pie_chart|safe }};
Plotly.newPlot('category-pie-chart', categoryPieData.data, categoryPieData.layout);
// Resize charts when window is resized
window.addEventListener('resize', function() {
Plotly.relayout('monthly-trend-chart', {
'width': document.getElementById('monthly-trend-chart').offsetWidth
});
Plotly.relayout('category-pie-chart', {
'width': document.getElementById('category-pie-chart').offsetWidth
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,115 @@
<!-- templates/layout.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Financial Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/plotly.js-dist@2.12.1/plotly.min.js"></script>
<style>
body {
background-color: #f5f5f5;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.navbar {
background-color: #3f51b5;
}
.navbar-brand {
font-weight: bold;
}
.card {
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.card-header {
background-color: rgba(63, 81, 181, 0.1);
font-weight: bold;
}
.metric-card {
text-align: center;
padding: 20px;
transition: transform 0.3s;
}
.metric-card:hover {
transform: translateY(-5px);
}
.metric-value {
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.metric-label {
font-size: 14px;
color: #666;
}
.positive {
color: #4CAF50;
}
.negative {
color: #FF5252;
}
.neutral {
color: #2196F3;
}
.chart-container {
min-height: 400px;
}
.table-responsive {
overflow-x: auto;
}
.footer {
background-color: #f1f1f1;
padding: 20px 0;
margin-top: 40px;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark mb-4">
<div class="container">
<a class="navbar-brand" href="/">Financial Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/transactions">Transactions</a>
</li>
</ul>
<div class="d-flex">
<a href="/clear" class="btn btn-outline-light">Clear Data</a>
</div>
</div>
</div>
</nav>
<div class="container mb-5">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<footer class="footer">
<div class="container text-center">
<p>Financial Dashboard - Powered by Flask and Plotly</p>
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,90 @@
<!-- templates/transactions.html -->
{% extends "layout.html" %}
{% block content %}
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<h2>All Transactions</h2>
<a href="/" class="btn btn-outline-primary">Back to Dashboard</a>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col-md-6">
Transaction History
</div>
<div class="col-md-6">
<input type="text" class="form-control" id="searchInput" placeholder="Search transactions...">
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped" id="transactionsTable">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Amount</th>
<th>Currency</th>
<th>Fee</th>
<th>Net Amount</th>
<th>Type</th>
<th>Account</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for transaction in transactions %}
<tr>
<td>
{% if transaction['Date'] is string %}
{{ transaction['Date'] }}
{% else %}
{{ transaction['Date'].strftime('%Y-%m-%d') }}
{% endif %}
</td>
<td>{{ transaction['Name of sender/receiver'] }}</td>
<td class="{% if transaction['Amount'] >= 0 %}positive{% else %}negative{% endif %}">
{{ "%0.2f"|format(transaction['Amount']) }}
</td>
<td>{{ transaction['Currency'] }}</td>
<td>{{ "%0.2f"|format(transaction['Fee']) }}</td>
<td>{{ "%0.2f"|format(transaction['Net Amount']) }}</td>
<td>{{ transaction['Transaction Type'] }}</td>
<td>{{ transaction['Account'] }}</td>
<td>{{ transaction['Status'] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
// Simple search functionality
document.getElementById('searchInput').addEventListener('keyup', function() {
const searchTerm = this.value.toLowerCase();
const tableRows = document.querySelectorAll('#transactionsTable tbody tr');
tableRows.forEach(row => {
const rowText = row.textContent.toLowerCase();
if (rowText.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,31 @@
<!-- templates/upload.html -->
{% extends "layout.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
Upload Your Financial Data
</div>
<div class="card-body">
<p class="card-text">
Upload a CSV file with your financial transactions to get started. The file should have the following columns:
</p>
<pre class="bg-light p-3 rounded">
"Date","Transaction ID","Transaction Type","Currency","Amount","Fee","Net Amount",
"Asset Type","Asset Price","Asset Amount","Status","Notes","Name of sender/receiver","Account"
</pre>
<form action="/upload" method="post" enctype="multipart/form-data" class="mt-4">
<div class="mb-3">
<label for="fileInput" class="form-label">Select CSV File</label>
<input class="form-control" type="file" id="fileInput" name="file" accept=".csv">
</div>
<button type="submit" class="btn btn-primary">Upload and Analyze</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}