Initial commit

This commit is contained in:
iulian 2025-12-26 00:52:56 +00:00
commit 983cee0320
322 changed files with 57174 additions and 0 deletions

View file

@ -0,0 +1,91 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
import redis
import os
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
redis_client = None
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///finance.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
@app.after_request
def set_csp(response):
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.jsdelivr.net"
return response
db.init_app(app)
csrf.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
global redis_client
redis_host = os.environ.get('REDIS_HOST', 'redis')
redis_port = int(os.environ.get('REDIS_PORT', 6369))
redis_client = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
from app.models.user import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Register currency filter for templates
from app.utils import format_currency
from app.translations import get_translation
@app.template_filter('currency')
def currency_filter(amount, currency_code=None):
from flask_login import current_user
if currency_code is None and current_user.is_authenticated:
currency_code = current_user.currency
return format_currency(amount, currency_code or 'USD')
# Register translation function for templates
@app.template_global('_')
def translate(key):
from flask_login import current_user
lang = 'en'
if current_user.is_authenticated and hasattr(current_user, 'language'):
lang = current_user.language or 'en'
return get_translation(key, lang)
# Make get_translation available in templates
@app.context_processor
def utility_processor():
from flask_login import current_user
def get_lang():
if current_user.is_authenticated and hasattr(current_user, 'language'):
return current_user.language or 'en'
return 'en'
return dict(get_lang=get_lang)
from app.routes import auth, main, settings, language, subscriptions
app.register_blueprint(auth.bp)
app.register_blueprint(main.bp)
app.register_blueprint(settings.bp)
app.register_blueprint(language.bp)
app.register_blueprint(subscriptions.bp)
# Register PWA routes
from app.pwa import register_pwa_routes
register_pwa_routes(app)
# Initialize budget alert system
from app.budget_alerts import init_mail
init_mail(app)
with app.app_context():
db.create_all()
return app

View file

@ -0,0 +1,480 @@
"""
Bank Statement Import Module for FINA Finance Tracker
Parses PDF and CSV bank statements and extracts transactions
"""
import re
import csv
import io
from datetime import datetime
from decimal import Decimal
import PyPDF2
class BankStatementParser:
"""Base parser class for bank statements"""
def __init__(self):
self.transactions = []
self.detected_format = None
self.total_transactions = 0
self.parse_errors = []
def parse(self, file_content, file_type):
"""
Main parse method - detects format and extracts transactions
Args:
file_content: File content (bytes for PDF, string for CSV)
file_type: 'pdf' or 'csv'
Returns:
dict with transactions and metadata
"""
if file_type == 'pdf':
return self.parse_pdf(file_content)
elif file_type == 'csv':
return self.parse_csv(file_content)
else:
return {'success': False, 'error': 'Unsupported file type'}
def parse_pdf(self, pdf_bytes):
"""
Parse PDF bank statement
Extracts transactions using pattern matching
"""
try:
pdf_reader = PyPDF2.PdfReader(io.BytesIO(pdf_bytes))
text = ""
# Extract text from all pages
for page in pdf_reader.pages:
text += page.extract_text() + "\n"
# Detect bank format
bank_format = self.detect_bank_format(text)
# Parse transactions based on detected format
if bank_format == 'generic':
transactions = self.parse_generic_pdf(text)
else:
transactions = self.parse_generic_pdf(text) # Fallback to generic
return {
'success': True,
'transactions': transactions,
'total_found': len(transactions),
'bank_format': bank_format,
'parse_errors': self.parse_errors
}
except Exception as e:
return {
'success': False,
'error': f'PDF parsing failed: {str(e)}',
'transactions': []
}
def parse_csv(self, csv_string):
"""
Parse CSV bank statement
Auto-detects column mapping
"""
try:
# Try different delimiters
delimiter = self.detect_csv_delimiter(csv_string)
stream = io.StringIO(csv_string)
csv_reader = csv.DictReader(stream, delimiter=delimiter)
# Auto-detect column names
fieldnames = csv_reader.fieldnames
column_map = self.detect_csv_columns(fieldnames)
transactions = []
row_num = 0
for row in csv_reader:
row_num += 1
try:
transaction = self.extract_transaction_from_csv_row(row, column_map)
if transaction:
transactions.append(transaction)
except Exception as e:
self.parse_errors.append(f"Row {row_num}: {str(e)}")
return {
'success': True,
'transactions': transactions,
'total_found': len(transactions),
'column_mapping': column_map,
'parse_errors': self.parse_errors
}
except Exception as e:
return {
'success': False,
'error': f'CSV parsing failed: {str(e)}',
'transactions': []
}
def detect_bank_format(self, text):
"""Detect which bank format the PDF uses"""
text_lower = text.lower()
# Add patterns for specific banks
if 'revolut' in text_lower:
return 'revolut'
elif 'ing' in text_lower or 'ing bank' in text_lower:
return 'ing'
elif 'bcr' in text_lower or 'banca comercială' in text_lower:
return 'bcr'
elif 'brd' in text_lower:
return 'brd'
else:
return 'generic'
def parse_generic_pdf(self, text):
"""
Parse PDF using generic patterns
Looks for common transaction patterns across banks
"""
transactions = []
lines = text.split('\n')
# Common patterns for transactions
# Date patterns: DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD
date_patterns = [
r'(\d{2}[/-]\d{2}[/-]\d{4})', # DD/MM/YYYY or DD-MM-YYYY
r'(\d{4}[/-]\d{2}[/-]\d{2})', # YYYY-MM-DD
]
# Amount patterns: -123.45, 123.45, 123,45, -123,45
amount_patterns = [
r'[-]?\d{1,10}[.,]\d{2}', # With 2 decimals
r'[-]?\d{1,10}\s*(?:RON|EUR|USD|GBP|LEI)', # With currency
]
for i, line in enumerate(lines):
# Skip header lines
if any(word in line.lower() for word in ['sold', 'balance', 'iban', 'account', 'statement']):
continue
# Look for date in line
date_match = None
for pattern in date_patterns:
match = re.search(pattern, line)
if match:
date_match = match.group(1)
break
if not date_match:
continue
# Parse date
trans_date = self.parse_date(date_match)
if not trans_date:
continue
# Look for amount in this line and nearby lines
amount = None
description = line
# Check current line and next 2 lines for amount
for j in range(i, min(i + 3, len(lines))):
amounts_found = re.findall(r'[-]?\d{1,10}[.,]\d{2}', lines[j])
if amounts_found:
# Take the last amount (usually the transaction amount)
amount_str = amounts_found[-1]
amount = self.parse_amount(amount_str)
break
if not amount or amount == 0:
continue
# Clean description
description = self.clean_description(line, date_match, str(amount))
if description:
transactions.append({
'date': trans_date,
'description': description,
'amount': abs(amount), # Always positive, type determined by sign
'type': 'expense' if amount < 0 else 'income',
'original_amount': amount
})
# Deduplicate based on date + amount + description similarity
transactions = self.deduplicate_transactions(transactions)
return transactions
def detect_csv_delimiter(self, csv_string):
"""Detect CSV delimiter (comma, semicolon, tab)"""
first_line = csv_string.split('\n')[0]
comma_count = first_line.count(',')
semicolon_count = first_line.count(';')
tab_count = first_line.count('\t')
if semicolon_count > comma_count and semicolon_count > tab_count:
return ';'
elif tab_count > comma_count:
return '\t'
else:
return ','
def detect_csv_columns(self, fieldnames):
"""
Auto-detect which columns contain date, description, amount
Returns mapping of column indices
"""
fieldnames_lower = [f.lower() if f else '' for f in fieldnames]
column_map = {
'date': None,
'description': None,
'amount': None,
'debit': None,
'credit': None
}
# Date column keywords
date_keywords = ['date', 'data', 'fecha', 'datum', 'transaction date']
for idx, name in enumerate(fieldnames_lower):
if any(keyword in name for keyword in date_keywords):
column_map['date'] = fieldnames[idx]
break
# Description column keywords
desc_keywords = ['description', 'descriere', 'descripción', 'details', 'detalii', 'merchant', 'comerciant']
for idx, name in enumerate(fieldnames_lower):
if any(keyword in name for keyword in desc_keywords):
column_map['description'] = fieldnames[idx]
break
# Amount columns
amount_keywords = ['amount', 'suma', 'monto', 'valoare']
debit_keywords = ['debit', 'withdrawal', 'retragere', 'retiro', 'spent']
credit_keywords = ['credit', 'deposit', 'depunere', 'ingreso', 'income']
for idx, name in enumerate(fieldnames_lower):
if any(keyword in name for keyword in amount_keywords):
column_map['amount'] = fieldnames[idx]
elif any(keyword in name for keyword in debit_keywords):
column_map['debit'] = fieldnames[idx]
elif any(keyword in name for keyword in credit_keywords):
column_map['credit'] = fieldnames[idx]
return column_map
def extract_transaction_from_csv_row(self, row, column_map):
"""Extract transaction data from CSV row using column mapping"""
# Get date
date_col = column_map.get('date')
if not date_col or date_col not in row:
return None
trans_date = self.parse_date(row[date_col])
if not trans_date:
return None
# Get description
desc_col = column_map.get('description')
description = row.get(desc_col, 'Transaction') if desc_col else 'Transaction'
# Get amount
amount = 0
trans_type = 'expense'
# Check if we have separate debit/credit columns
if column_map.get('debit') and column_map.get('credit'):
debit_val = self.parse_amount(row.get(column_map['debit'], '0'))
credit_val = self.parse_amount(row.get(column_map['credit'], '0'))
if debit_val > 0:
amount = debit_val
trans_type = 'expense'
elif credit_val > 0:
amount = credit_val
trans_type = 'income'
elif column_map.get('amount'):
amount_val = self.parse_amount(row.get(column_map['amount'], '0'))
amount = abs(amount_val)
trans_type = 'expense' if amount_val < 0 else 'income'
if amount == 0:
return None
return {
'date': trans_date,
'description': description.strip(),
'amount': amount,
'type': trans_type
}
def parse_date(self, date_str):
"""Parse date string in various formats"""
date_str = date_str.strip()
# Try different date formats
formats = [
'%d/%m/%Y',
'%d-%m-%Y',
'%Y-%m-%d',
'%Y/%m/%d',
'%d.%m.%Y',
'%m/%d/%Y',
'%d %b %Y',
'%d %B %Y'
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
continue
return None
def parse_amount(self, amount_str):
"""Parse amount string to float"""
if not amount_str:
return 0.0
# Remove currency symbols and whitespace
amount_str = str(amount_str).strip()
amount_str = re.sub(r'[^\d.,-]', '', amount_str)
if not amount_str:
return 0.0
# Handle comma as decimal separator (European format)
if ',' in amount_str and '.' in amount_str:
# Format: 1.234,56 -> remove dots, replace comma with dot
amount_str = amount_str.replace('.', '').replace(',', '.')
elif ',' in amount_str:
# Format: 1234,56 -> replace comma with dot
amount_str = amount_str.replace(',', '.')
try:
return float(amount_str)
except ValueError:
return 0.0
def clean_description(self, text, date_str, amount_str):
"""Clean transaction description by removing date and amount"""
# Remove date
text = text.replace(date_str, '')
# Remove amount
text = text.replace(amount_str, '')
# Remove extra whitespace
text = ' '.join(text.split())
# Remove common keywords
remove_words = ['transaction', 'payment', 'transfer', 'tranzactie', 'plata']
for word in remove_words:
text = re.sub(word, '', text, flags=re.IGNORECASE)
text = text.strip()
# If too short, return generic
if len(text) < 3:
return 'Bank Transaction'
return text[:200] # Limit length
def deduplicate_transactions(self, transactions):
"""Remove duplicate transactions"""
seen = set()
unique = []
for trans in transactions:
# Create signature: date + amount + first 20 chars of description
signature = (
trans['date'].isoformat(),
round(trans['amount'], 2),
trans['description'][:20].lower()
)
if signature not in seen:
seen.add(signature)
unique.append(trans)
return unique
def validate_file(self, file_content, file_type, max_size_mb=10):
"""
Validate uploaded file
Args:
file_content: File content bytes
file_type: 'pdf' or 'csv'
max_size_mb: Maximum file size in MB
Returns:
(is_valid, error_message)
"""
# Check file size
size_mb = len(file_content) / (1024 * 1024)
if size_mb > max_size_mb:
return False, f'File too large. Maximum size is {max_size_mb}MB'
# Check file type
if file_type == 'pdf':
# Check PDF header
if not file_content.startswith(b'%PDF'):
return False, 'Invalid PDF file'
elif file_type == 'csv':
# Try to decode as text
try:
file_content.decode('utf-8')
except UnicodeDecodeError:
try:
file_content.decode('latin-1')
except:
return False, 'Invalid CSV file encoding'
else:
return False, 'Unsupported file type. Use PDF or CSV'
return True, None
def parse_bank_statement(file_content, filename):
"""
Main entry point for bank statement parsing
Args:
file_content: File content as bytes
filename: Original filename
Returns:
Parse results dictionary
"""
parser = BankStatementParser()
# Determine file type
file_ext = filename.lower().split('.')[-1]
if file_ext == 'pdf':
file_type = 'pdf'
content = file_content
elif file_ext == 'csv':
file_type = 'csv'
# Try to decode
try:
content = file_content.decode('utf-8')
except UnicodeDecodeError:
content = file_content.decode('latin-1', errors='ignore')
else:
return {
'success': False,
'error': 'Unsupported file type. Please upload PDF or CSV files.'
}
# Validate file
is_valid, error_msg = parser.validate_file(file_content, file_type)
if not is_valid:
return {'success': False, 'error': error_msg}
# Parse file
result = parser.parse(content, file_type)
return result

View file

@ -0,0 +1,287 @@
"""
Budget Alert System
Monitors spending and sends email alerts when budget limits are exceeded
"""
from flask import render_template_string
from flask_mail import Mail, Message
from app.models.category import Category
from app.models.user import User
from app import db
from datetime import datetime
import os
mail = None
def init_mail(app):
"""Initialize Flask-Mail with app configuration"""
global mail
# Email configuration from environment variables
app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587))
app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() == 'true'
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')
app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@fina.app')
mail = Mail(app)
return mail
def check_budget_alerts():
"""Check all categories for budget overruns and send alerts"""
if not mail:
print("[Budget Alerts] Mail not configured")
return 0
alerts_sent = 0
# Get all categories with budgets that need checking
categories = Category.query.filter(
Category.monthly_budget.isnot(None),
Category.monthly_budget > 0
).all()
for category in categories:
if category.should_send_budget_alert():
user = User.query.get(category.user_id)
if user and user.budget_alerts_enabled:
if send_budget_alert(user, category):
category.budget_alert_sent = True
category.last_budget_check = datetime.now()
alerts_sent += 1
db.session.commit()
return alerts_sent
def send_budget_alert(user, category):
"""Send budget alert email to user"""
if not mail:
print(f"[Budget Alert] Mail not configured, skipping alert for {user.email}")
return False
try:
status = category.get_budget_status()
alert_email = user.alert_email or user.email
# Get user's language
lang = user.language or 'en'
# Email templates in multiple languages
subjects = {
'en': f'⚠️ Budget Alert: {category.name}',
'ro': f'⚠️ Alertă Buget: {category.name}',
'es': f'⚠️ Alerta de Presupuesto: {category.name}'
}
# Email body template
html_template = """
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #4c1d95 0%, #3b0764 100%);
color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px; }}
.alert-box {{ background: #fef2f2; border-left: 4px solid #ef4444;
padding: 20px; margin: 20px 0; border-radius: 5px; }}
.budget-details {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; }}
.stat {{ display: inline-block; margin: 10px 20px 10px 0; }}
.stat-label {{ color: #6b7280; font-size: 14px; }}
.stat-value {{ font-size: 24px; font-weight: bold; color: #1f2937; }}
.over-budget {{ color: #ef4444; }}
.progress-bar {{ background: #e5e7eb; height: 30px; border-radius: 15px;
overflow: hidden; margin: 20px 0; }}
.progress-fill {{ background: {progress_color}; height: 100%;
transition: width 0.3s; display: flex; align-items: center;
justify-content: center; color: white; font-weight: bold; }}
.button {{ display: inline-block; background: #5b5fc7; color: white;
padding: 12px 30px; text-decoration: none; border-radius: 8px;
margin: 20px 0; }}
.footer {{ text-align: center; color: #6b7280; font-size: 14px; margin-top: 30px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔔 {title}</h1>
</div>
<div class="content">
<div class="alert-box">
<h2 style="margin-top: 0;"> {alert_message}</h2>
<p>{alert_description}</p>
</div>
<div class="budget-details">
<h3>{details_title}</h3>
<div class="stat">
<div class="stat-label">{spent_label}</div>
<div class="stat-value over-budget">{currency}{spent:.2f}</div>
</div>
<div class="stat">
<div class="stat-label">{budget_label}</div>
<div class="stat-value">{currency}{budget:.2f}</div>
</div>
<div class="stat">
<div class="stat-label">{remaining_label}</div>
<div class="stat-value" style="color: #ef4444;">{currency}{remaining:.2f}</div>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {percentage}%;">
{percentage:.1f}%
</div>
</div>
<p style="margin-top: 20px; color: #6b7280;">
<strong>{category_label}:</strong> {category_name}<br>
<strong>{threshold_label}:</strong> {threshold}%
</p>
</div>
<center>
<a href="{dashboard_url}" class="button">{button_text}</a>
</center>
<div class="footer">
<p>{footer_text}</p>
<p style="font-size: 12px;">{disable_text}</p>
</div>
</div>
</div>
</body>
</html>
"""
# Translations
translations = {
'en': {
'title': 'Budget Alert',
'alert_message': 'Budget Limit Exceeded!',
'alert_description': f'Your spending in the "{category.name}" category has exceeded {int(category.budget_alert_threshold * 100)}% of your monthly budget.',
'details_title': 'Budget Overview',
'spent_label': 'Spent This Month',
'budget_label': 'Monthly Budget',
'remaining_label': 'Over Budget',
'category_label': 'Category',
'threshold_label': 'Alert Threshold',
'button_text': 'View Dashboard',
'footer_text': 'This is an automated budget alert from FINA Finance Tracker.',
'disable_text': 'To disable budget alerts, go to Settings > Profile.'
},
'ro': {
'title': 'Alertă Buget',
'alert_message': 'Limită buget depășită!',
'alert_description': f'Cheltuielile în categoria "{category.name}" au depășit {int(category.budget_alert_threshold * 100)}% din bugetul lunar.',
'details_title': 'Rezumat Buget',
'spent_label': 'Cheltuit Luna Aceasta',
'budget_label': 'Buget Lunar',
'remaining_label': 'Peste Buget',
'category_label': 'Categorie',
'threshold_label': 'Prag Alertă',
'button_text': 'Vezi Tabloul de Bord',
'footer_text': 'Aceasta este o alertă automată de buget de la FINA Finance Tracker.',
'disable_text': 'Pentru a dezactiva alertele de buget, mergi la Setări > Profil.'
},
'es': {
'title': 'Alerta de Presupuesto',
'alert_message': '¡Límite de presupuesto excedido!',
'alert_description': f'Tus gastos en la categoría "{category.name}" han superado el {int(category.budget_alert_threshold * 100)}% de tu presupuesto mensual.',
'details_title': 'Resumen de Presupuesto',
'spent_label': 'Gastado Este Mes',
'budget_label': 'Presupuesto Mensual',
'remaining_label': 'Sobre Presupuesto',
'category_label': 'Categoría',
'threshold_label': 'Umbral de Alerta',
'button_text': 'Ver Panel',
'footer_text': 'Esta es una alerta automática de presupuesto de FINA Finance Tracker.',
'disable_text': 'Para desactivar las alertas de presupuesto, ve a Configuración > Perfil.'
}
}
t = translations.get(lang, translations['en'])
# Determine progress bar color
if status['percentage'] >= 100:
progress_color = '#ef4444' # Red
elif status['percentage'] >= 90:
progress_color = '#f59e0b' # Orange
else:
progress_color = '#10b981' # Green
# Dashboard URL (adjust based on your deployment)
dashboard_url = os.environ.get('APP_URL', 'http://localhost:5001') + '/dashboard'
html_body = html_template.format(
title=t['title'],
alert_message=t['alert_message'],
alert_description=t['alert_description'],
details_title=t['details_title'],
spent_label=t['spent_label'],
budget_label=t['budget_label'],
remaining_label=t['remaining_label'],
category_label=t['category_label'],
threshold_label=t['threshold_label'],
button_text=t['button_text'],
footer_text=t['footer_text'],
disable_text=t['disable_text'],
currency=user.currency,
spent=status['spent'],
budget=status['budget'],
remaining=abs(status['remaining']),
percentage=min(status['percentage'], 100),
progress_color=progress_color,
category_name=category.name,
threshold=int(category.budget_alert_threshold * 100),
dashboard_url=dashboard_url
)
msg = Message(
subject=subjects.get(lang, subjects['en']),
recipients=[alert_email],
html=html_body
)
mail.send(msg)
print(f"[Budget Alert] Sent to {alert_email} for category {category.name}")
return True
except Exception as e:
print(f"[Budget Alert] Error sending email: {e}")
return False
def send_test_budget_alert(user_email):
"""Send a test budget alert email"""
if not mail:
return False, "Mail not configured"
try:
msg = Message(
subject='Test Budget Alert - FINA',
recipients=[user_email],
body='This is a test email from FINA budget alert system. If you received this, email alerts are working correctly!',
html='''
<html>
<body style="font-family: Arial, sans-serif;">
<h2> Test Email Successful</h2>
<p>This is a test email from the FINA budget alert system.</p>
<p>If you received this message, your email configuration is working correctly!</p>
<hr>
<p style="color: #666; font-size: 12px;">FINA Finance Tracker</p>
</body>
</html>
'''
)
mail.send(msg)
return True, "Test email sent successfully"
except Exception as e:
return False, str(e)

View file

@ -0,0 +1,59 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
import os
from dotenv import load_dotenv
import redis
load_dotenv()
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
redis_client = None
def create_app():
app = Flask(__name__)
# Security configurations
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', os.urandom(32))
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///finance.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
app.config['UPLOAD_FOLDER'] = 'app/static/uploads'
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'pdf', 'gif'}
# Security headers
@app.after_request
def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:;"
return response
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
csrf.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
# Initialize Redis
global redis_client
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6369/0')
redis_client = redis.from_url(redis_url, decode_responses=True)
# Register blueprints
from app.routes import auth, main
app.register_blueprint(auth.bp)
app.register_blueprint(main.bp)
# Create tables
with app.app_context():
db.create_all()
return app

View file

@ -0,0 +1,3 @@
from app.models.user import User
from app.models.category import Category, Expense
__all__ = ['User', 'Category', 'Expense']

View file

@ -0,0 +1,120 @@
from app import db
from datetime import datetime
from sqlalchemy import func, extract
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(7), default='#6366f1')
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Budget fields
monthly_budget = db.Column(db.Float, nullable=True)
budget_alert_sent = db.Column(db.Boolean, default=False)
budget_alert_threshold = db.Column(db.Float, default=1.0) # 1.0 = 100%
last_budget_check = db.Column(db.DateTime, nullable=True)
expenses = db.relationship('Expense', backref='category', lazy=True, cascade='all, delete-orphan')
def get_total_spent(self):
return sum(expense.amount for expense in self.expenses)
def get_monthly_totals(self, year=None):
"""Get expenses grouped by month for the year"""
if year is None:
year = datetime.now().year
monthly_data = db.session.query(
extract('month', Expense.date).label('month'),
func.sum(Expense.amount).label('total')
).filter(
Expense.category_id == self.id,
extract('year', Expense.date) == year
).group_by('month').all()
# Create array with all 12 months
result = [0] * 12
for month, total in monthly_data:
result[int(month) - 1] = float(total) if total else 0
return result
def get_yearly_total(self, year):
"""Get total expenses for a specific year"""
total = db.session.query(func.sum(Expense.amount)).filter(
Expense.category_id == self.id,
extract('year', Expense.date) == year
).scalar()
return float(total) if total else 0
def get_current_month_spending(self):
"""Get total spending for current month"""
now = datetime.now()
total = db.session.query(func.sum(Expense.amount)).filter(
Expense.category_id == self.id,
extract('year', Expense.date) == now.year,
extract('month', Expense.date) == now.month
).scalar()
return float(total) if total else 0
def get_budget_status(self):
"""Get budget status: percentage used and over budget flag"""
if not self.monthly_budget or self.monthly_budget <= 0:
return {'percentage': 0, 'over_budget': False, 'remaining': 0}
spent = self.get_current_month_spending()
percentage = (spent / self.monthly_budget) * 100
over_budget = percentage >= (self.budget_alert_threshold * 100)
remaining = self.monthly_budget - spent
return {
'spent': spent,
'budget': self.monthly_budget,
'percentage': round(percentage, 1),
'over_budget': over_budget,
'remaining': remaining
}
def should_send_budget_alert(self):
"""Check if budget alert should be sent"""
if not self.monthly_budget:
return False
status = self.get_budget_status()
# Only send if over threshold and not already sent this month
if status['over_budget'] and not self.budget_alert_sent:
return True
# Reset alert flag at start of new month
now = datetime.now()
if self.last_budget_check:
if (self.last_budget_check.month != now.month or
self.last_budget_check.year != now.year):
self.budget_alert_sent = False
return False
def __repr__(self):
return f'<Category {self.name}>'
class Expense(db.Model):
__tablename__ = 'expenses'
id = db.Column(db.Integer, primary_key=True)
description = db.Column(db.String(200), nullable=False)
amount = db.Column(db.Float, nullable=False)
date = db.Column(db.DateTime, default=datetime.utcnow)
paid_by = db.Column(db.String(100))
tags = db.Column(db.String(500))
file_path = db.Column(db.String(500))
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Expense {self.description}: ${self.amount}>'

View file

@ -0,0 +1,4 @@
from app.models.user import User
from app.models.category import Category, Expense
__all__ = ['User', 'Category', 'Expense']

View file

@ -0,0 +1,124 @@
from app import db
from datetime import datetime
from sqlalchemy import func
class Subscription(db.Model):
__tablename__ = 'subscriptions'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
amount = db.Column(db.Float, nullable=False)
frequency = db.Column(db.String(20), nullable=False) # monthly, weekly, yearly, quarterly, custom
custom_interval_days = db.Column(db.Integer, nullable=True) # For custom frequency
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
next_due_date = db.Column(db.Date, nullable=True)
start_date = db.Column(db.Date, nullable=True) # First occurrence date
end_date = db.Column(db.Date, nullable=True) # Optional end date
total_occurrences = db.Column(db.Integer, nullable=True) # Optional limit
occurrences_count = db.Column(db.Integer, default=0) # Current count
is_active = db.Column(db.Boolean, default=True)
is_confirmed = db.Column(db.Boolean, default=False) # User confirmed this subscription
auto_detected = db.Column(db.Boolean, default=False) # System detected this pattern
auto_create_expense = db.Column(db.Boolean, default=False) # Auto-create expenses on due date
confidence_score = db.Column(db.Float, default=0.0) # 0-100 confidence of detection
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_reminded = db.Column(db.DateTime, nullable=True)
last_auto_created = db.Column(db.Date, nullable=True) # Last auto-created expense date
def __repr__(self):
return f'<Subscription {self.name}>'
def get_frequency_days(self):
"""Get number of days between payments"""
if self.frequency == 'custom' and self.custom_interval_days:
return self.custom_interval_days
frequency_map = {
'weekly': 7,
'biweekly': 14,
'monthly': 30,
'quarterly': 90,
'yearly': 365
}
return frequency_map.get(self.frequency, 30)
def should_create_expense_today(self):
"""Check if an expense should be auto-created today"""
if not self.auto_create_expense or not self.is_active:
return False
if not self.next_due_date:
return False
today = datetime.now().date()
# Check if today is the due date
if self.next_due_date != today:
return False
# Check if already created today
if self.last_auto_created == today:
return False
# Check if we've reached the occurrence limit
if self.total_occurrences and self.occurrences_count >= self.total_occurrences:
return False
# Check if past end date
if self.end_date and today > self.end_date:
return False
return True
def advance_next_due_date(self):
"""Move to the next due date"""
if not self.next_due_date:
return
from datetime import timedelta
interval_days = self.get_frequency_days()
self.next_due_date = self.next_due_date + timedelta(days=interval_days)
self.occurrences_count += 1
# Check if subscription should end
if self.total_occurrences and self.occurrences_count >= self.total_occurrences:
self.is_active = False
if self.end_date and self.next_due_date > self.end_date:
self.is_active = False
def get_annual_cost(self):
"""Calculate annual cost based on frequency"""
frequency_multiplier = {
'weekly': 52,
'biweekly': 26,
'monthly': 12,
'quarterly': 4,
'yearly': 1
}
return self.amount * frequency_multiplier.get(self.frequency, 12)
class RecurringPattern(db.Model):
"""Detected recurring patterns (suggestions before confirmation)"""
__tablename__ = 'recurring_patterns'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
suggested_name = db.Column(db.String(100), nullable=False)
average_amount = db.Column(db.Float, nullable=False)
detected_frequency = db.Column(db.String(20), nullable=False)
confidence_score = db.Column(db.Float, nullable=False) # 0-100
expense_ids = db.Column(db.Text, nullable=False) # JSON array of expense IDs
first_occurrence = db.Column(db.Date, nullable=False)
last_occurrence = db.Column(db.Date, nullable=False)
occurrence_count = db.Column(db.Integer, default=0)
is_dismissed = db.Column(db.Boolean, default=False)
is_converted = db.Column(db.Boolean, default=False) # Converted to subscription
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<RecurringPattern {self.suggested_name}>'

View file

@ -0,0 +1,71 @@
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from datetime import datetime
import pyotp
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
currency = db.Column(db.String(3), default='USD')
language = db.Column(db.String(2), default='en') # en, ro, es
# Budget alert preferences
budget_alerts_enabled = db.Column(db.Boolean, default=True)
alert_email = db.Column(db.String(120), nullable=True) # Optional separate alert email
# 2FA fields
totp_secret = db.Column(db.String(32), nullable=True)
is_2fa_enabled = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
categories = db.relationship('Category', backref='user', lazy=True, cascade='all, delete-orphan')
expenses = db.relationship('Expense', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_totp_secret(self):
"""Generate a new TOTP secret"""
self.totp_secret = pyotp.random_base32()
return self.totp_secret
def get_totp_uri(self):
"""Get TOTP URI for QR code"""
if not self.totp_secret:
self.generate_totp_secret()
return pyotp.totp.TOTP(self.totp_secret).provisioning_uri(
name=self.email,
issuer_name='FINA'
)
def verify_totp(self, token):
"""Verify TOTP token"""
if not self.totp_secret:
return False
totp = pyotp.TOTP(self.totp_secret)
return totp.verify(token, valid_window=1)
def __repr__(self):
return f'<User {self.username}>'
class Tag(db.Model):
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
color = db.Column(db.String(7), default='#6366f1')
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Tag {self.name}>'

View file

@ -0,0 +1,311 @@
"""
Receipt OCR Module
Extracts amount, date, and merchant information from receipt images using Tesseract OCR
"""
import pytesseract
from PIL import Image
import re
from datetime import datetime
from dateutil import parser as date_parser
import os
def extract_receipt_data(image_path):
"""
Extract structured data from receipt image
Args:
image_path: Path to the receipt image file
Returns:
dict with extracted data: {
'amount': float or None,
'date': datetime or None,
'merchant': str or None,
'raw_text': str,
'confidence': str ('high', 'medium', 'low')
}
"""
try:
# Open and preprocess image
image = Image.open(image_path)
# Convert to grayscale for better OCR
if image.mode != 'L':
image = image.convert('L')
# Perform OCR
text = pytesseract.image_to_string(image, config='--psm 6')
# Extract structured data
amount = extract_amount(text)
date = extract_date(text)
merchant = extract_merchant(text)
# Determine confidence level
confidence = calculate_confidence(amount, date, merchant, text)
return {
'amount': amount,
'date': date,
'merchant': merchant,
'raw_text': text,
'confidence': confidence,
'success': True
}
except Exception as e:
return {
'amount': None,
'date': None,
'merchant': None,
'raw_text': '',
'confidence': 'none',
'success': False,
'error': str(e)
}
def extract_amount(text):
"""
Extract monetary amount from text
Supports multiple formats: $10.99, 10.99, 10,99, etc.
"""
# Common patterns for amounts
patterns = [
r'(?:total|suma|amount|subtotal|plata)[\s:]*[\$€£]?\s*(\d{1,6}[.,]\d{2})', # Total: $10.99
r'[\$€£]\s*(\d{1,6}[.,]\d{2})', # $10.99
r'(\d{1,6}[.,]\d{2})\s*(?:RON|USD|EUR|GBP|lei)', # 10.99 RON
r'(?:^|\s)(\d{1,6}[.,]\d{2})(?:\s|$)', # Standalone 10.99
]
amounts = []
for pattern in patterns:
matches = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE)
for match in matches:
# Normalize comma to dot
amount_str = match.replace(',', '.')
try:
amount = float(amount_str)
if 0.01 <= amount <= 999999: # Reasonable range
amounts.append(amount)
except ValueError:
continue
if amounts:
# Return the largest amount (usually the total)
return max(amounts)
return None
def extract_date(text):
"""
Extract date from text
Supports multiple formats: DD/MM/YYYY, MM-DD-YYYY, DD.MM.YYYY, etc.
"""
# Common date patterns
date_patterns = [
r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', # DD/MM/YYYY, MM-DD-YYYY
r'\d{1,2}\.\d{1,2}\.\d{2,4}', # DD.MM.YYYY
r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', # YYYY-MM-DD
r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},?\s+\d{4}', # Jan 15, 2024
r'\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}', # 15 Jan 2024
]
dates = []
for pattern in date_patterns:
matches = re.findall(pattern, text, re.IGNORECASE)
for match in matches:
try:
# Try to parse the date
parsed_date = date_parser.parse(match, fuzzy=True)
# Only accept dates within reasonable range
if datetime(2000, 1, 1) <= parsed_date <= datetime.now():
dates.append(parsed_date)
except (ValueError, date_parser.ParserError):
continue
if dates:
# Return the most recent date (likely the transaction date)
return max(dates)
return None
def extract_merchant(text):
"""
Extract merchant/store name from text
Usually appears at the top of the receipt
"""
lines = text.strip().split('\n')
# Look at first few lines for merchant name
for i, line in enumerate(lines[:5]):
line = line.strip()
# Skip very short lines
if len(line) < 3:
continue
# Skip lines that look like addresses or numbers
if re.match(r'^[\d\s\.,]+$', line):
continue
# Skip common keywords
if re.match(r'^(receipt|factura|bon|total|date|time)', line, re.IGNORECASE):
continue
# If line has letters and reasonable length, likely merchant
if re.search(r'[a-zA-Z]{3,}', line) and 3 <= len(line) <= 50:
# Clean up the line
cleaned = re.sub(r'[^\w\s-]', ' ', line)
cleaned = ' '.join(cleaned.split())
if cleaned:
return cleaned
return None
def calculate_confidence(amount, date, merchant, text):
"""
Calculate confidence level of extraction
Returns: 'high', 'medium', 'low', or 'none'
"""
found_count = sum([
amount is not None,
date is not None,
merchant is not None
])
# Check text quality
text_quality = len(text.strip()) > 50 and len(text.split()) > 10
if found_count == 3 and text_quality:
return 'high'
elif found_count >= 2:
return 'medium'
elif found_count >= 1:
return 'low'
else:
return 'none'
def preprocess_image_for_ocr(image_path, output_path=None):
"""
Preprocess image to improve OCR accuracy
Args:
image_path: Path to original image
output_path: Path to save preprocessed image (optional)
Returns:
PIL Image object
"""
from PIL import ImageEnhance, ImageFilter
image = Image.open(image_path)
# Convert to grayscale
image = image.convert('L')
# Increase contrast
enhancer = ImageEnhance.Contrast(image)
image = enhancer.enhance(2.0)
# Sharpen image
image = image.filter(ImageFilter.SHARPEN)
# Apply threshold (binarization)
threshold = 128
image = image.point(lambda p: 255 if p > threshold else 0)
if output_path:
image.save(output_path)
return image
def is_valid_receipt_image(image_path):
"""
Validate that uploaded file is a valid image
Security check to prevent malicious files
"""
try:
image = Image.open(image_path)
image.verify()
# Check file size (max 10MB)
file_size = os.path.getsize(image_path)
if file_size > 10 * 1024 * 1024:
return False, "File too large (max 10MB)"
# Check image dimensions (reasonable receipt size)
image = Image.open(image_path)
width, height = image.size
if width < 100 or height < 100:
return False, "Image too small"
if width > 8000 or height > 8000:
return False, "Image too large"
# Check format
if image.format not in ['JPEG', 'PNG', 'JPG']:
return False, "Unsupported format (use JPEG or PNG)"
return True, "Valid"
except Exception as e:
return False, f"Invalid image: {str(e)}"
def extract_receipt_data_batch(image_paths):
"""
Process multiple receipt images in batch
Args:
image_paths: List of image file paths
Returns:
List of extraction results
"""
results = []
for path in image_paths:
result = extract_receipt_data(path)
result['file_path'] = path
results.append(result)
return results
def format_extraction_summary(data):
"""
Format extracted data for display
Returns: Human-readable string
"""
lines = []
if data.get('merchant'):
lines.append(f"🏪 Merchant: {data['merchant']}")
if data.get('amount'):
lines.append(f"💰 Amount: {data['amount']:.2f}")
if data.get('date'):
lines.append(f"📅 Date: {data['date'].strftime('%Y-%m-%d')}")
if data.get('confidence'):
confidence_emoji = {
'high': '',
'medium': '⚠️',
'low': '',
'none': ''
}
emoji = confidence_emoji.get(data['confidence'], '')
lines.append(f"{emoji} Confidence: {data['confidence'].title()}")
return '\n'.join(lines) if lines else "No data extracted"

View file

@ -0,0 +1,373 @@
"""
Spending Predictions Module
Analyzes historical spending patterns and predicts future expenses
"""
from app import db
from app.models.category import Category, Expense
from sqlalchemy import extract, func
from datetime import datetime, timedelta
from collections import defaultdict
import statistics
def get_spending_predictions(user_id, months_ahead=3):
"""
Predict spending for the next X months based on historical data
Args:
user_id: User ID to generate predictions for
months_ahead: Number of months to predict (default: 3)
Returns:
dict with predictions per category and total
"""
categories = Category.query.filter_by(user_id=user_id).all()
predictions = {
'by_category': {},
'total_months': 0,
'insights': []
}
current_date = datetime.now()
total_predicted = 0
total_months_data = []
for category in categories:
category_prediction = predict_category_spending(
category,
current_date,
months_ahead
)
if category_prediction['predicted_amount'] > 0:
# Add category_id for API calls
category_prediction['category_id'] = category.id
predictions['by_category'][category.name] = category_prediction
total_predicted += category_prediction['predicted_amount']
total_months_data.append(category_prediction['historical_months'])
# Calculate overall statistics
if predictions['by_category']:
avg_months = sum(total_months_data) / len(total_months_data)
predictions['total_months'] = int(avg_months)
# Determine overall confidence
if avg_months >= 6:
overall_confidence = 'high'
elif avg_months >= 3:
overall_confidence = 'medium'
else:
overall_confidence = 'low'
# Determine overall trend
increasing = sum(1 for p in predictions['by_category'].values() if p['trend'] == 'increasing')
decreasing = sum(1 for p in predictions['by_category'].values() if p['trend'] == 'decreasing')
if increasing > decreasing:
overall_trend = 'increasing'
elif decreasing > increasing:
overall_trend = 'decreasing'
else:
overall_trend = 'stable'
predictions['total'] = {
'amount': round(total_predicted, 2),
'confidence': overall_confidence,
'trend': overall_trend,
'months_of_data': int(avg_months)
}
else:
predictions['total_months'] = 0
predictions['total'] = {
'amount': 0,
'confidence': 'none',
'trend': 'stable',
'months_of_data': 0
}
# Generate insights
predictions['insights'] = generate_insights(predictions['by_category'], current_date)
return predictions
def predict_category_spending(category, current_date, months_ahead=3):
"""
Predict spending for a specific category
Uses weighted average with more recent months having higher weight
"""
# Get last 12 months of data
twelve_months_ago = current_date - timedelta(days=365)
monthly_spending = db.session.query(
extract('year', Expense.date).label('year'),
extract('month', Expense.date).label('month'),
func.sum(Expense.amount).label('total')
).filter(
Expense.category_id == category.id,
Expense.date >= twelve_months_ago
).group_by('year', 'month').all()
if not monthly_spending:
return {
'predicted_amount': 0,
'historical_average': 0,
'trend': 'none',
'historical_months': 0,
'confidence': 'none'
}
# Extract amounts and calculate statistics
amounts = [float(row.total) for row in monthly_spending]
historical_months = len(amounts)
# Calculate weighted average (recent months have more weight)
weights = list(range(1, len(amounts) + 1))
weighted_avg = sum(a * w for a, w in zip(amounts, weights)) / sum(weights)
# Calculate trend
if len(amounts) >= 3:
first_half = sum(amounts[:len(amounts)//2]) / (len(amounts)//2)
second_half = sum(amounts[len(amounts)//2:]) / (len(amounts) - len(amounts)//2)
if second_half > first_half * 1.1:
trend = 'increasing'
elif second_half < first_half * 0.9:
trend = 'decreasing'
else:
trend = 'stable'
else:
trend = 'stable'
# Adjust prediction based on trend
if trend == 'increasing':
predicted_amount = weighted_avg * 1.05 # 5% increase
elif trend == 'decreasing':
predicted_amount = weighted_avg * 0.95 # 5% decrease
else:
predicted_amount = weighted_avg
# Multiply by months ahead
predicted_total = predicted_amount * months_ahead
# Calculate confidence based on data consistency
if len(amounts) >= 3:
std_dev = statistics.stdev(amounts)
avg = statistics.mean(amounts)
coefficient_of_variation = std_dev / avg if avg > 0 else 1
if coefficient_of_variation < 0.3:
confidence = 'high'
elif coefficient_of_variation < 0.6:
confidence = 'medium'
else:
confidence = 'low'
else:
confidence = 'low'
return {
'predicted_amount': round(predicted_total, 2),
'monthly_average': round(predicted_amount, 2),
'historical_average': round(statistics.mean(amounts), 2),
'trend': trend,
'historical_months': historical_months,
'confidence': confidence,
'min': round(min(amounts), 2),
'max': round(max(amounts), 2)
}
def generate_insights(category_predictions, current_date):
"""Generate human-readable insights from predictions"""
insights = []
# Find categories with increasing trends
increasing = [
name for name, pred in category_predictions.items()
if pred['trend'] == 'increasing'
]
if increasing:
insights.append({
'type': 'warning',
'message': f"Spending is increasing in: {', '.join(increasing)}"
})
# Find categories with high spending
sorted_by_amount = sorted(
category_predictions.items(),
key=lambda x: x[1]['predicted_amount'],
reverse=True
)
if sorted_by_amount:
top_category = sorted_by_amount[0]
insights.append({
'type': 'info',
'message': f"Highest predicted spending: {top_category[0]}"
})
# Find categories with high confidence
high_confidence = [
name for name, pred in category_predictions.items()
if pred['confidence'] == 'high'
]
if len(high_confidence) >= 3:
insights.append({
'type': 'success',
'message': f"High prediction accuracy for {len(high_confidence)} categories"
})
# Seasonal insight (simple check)
current_month = current_date.month
if current_month in [11, 12]: # November, December
insights.append({
'type': 'info',
'message': "Holiday season - spending typically increases"
})
elif current_month in [1, 2]: # January, February
insights.append({
'type': 'info',
'message': "Post-holiday period - spending may decrease"
})
return insights
def get_category_forecast(category_id, user_id, months=6):
"""
Get detailed forecast for a specific category
Returns monthly predictions for next N months
"""
category = Category.query.filter_by(
id=category_id,
user_id=user_id
).first()
if not category:
return None
current_date = datetime.now()
# Get historical monthly data
twelve_months_ago = current_date - timedelta(days=365)
monthly_data = db.session.query(
extract('year', Expense.date).label('year'),
extract('month', Expense.date).label('month'),
func.sum(Expense.amount).label('total')
).filter(
Expense.category_id == category_id,
Expense.date >= twelve_months_ago
).group_by('year', 'month').order_by('year', 'month').all()
if not monthly_data:
return {
'category_name': category.name,
'forecast': [],
'message': 'Not enough data for predictions'
}
# Calculate base prediction
amounts = [float(row.total) for row in monthly_data]
avg_spending = statistics.mean(amounts)
# Generate forecast for next months
forecast = []
for i in range(1, months + 1):
future_date = current_date + timedelta(days=30 * i)
# Simple seasonal adjustment based on month
seasonal_factor = get_seasonal_factor(future_date.month)
predicted = avg_spending * seasonal_factor
forecast.append({
'month': future_date.strftime('%B %Y'),
'month_num': future_date.month,
'year': future_date.year,
'predicted_amount': round(predicted, 2)
})
return {
'category_name': category.name,
'category_color': category.color,
'historical_average': round(avg_spending, 2),
'forecast': forecast
}
def get_seasonal_factor(month):
"""
Get seasonal adjustment factor based on month
This is a simplified version - could be made more sophisticated
with actual historical data analysis
"""
# Holiday months (Nov, Dec) typically have higher spending
# Summer months might vary by category
factors = {
1: 0.9, # January - post-holiday slowdown
2: 0.95, # February
3: 1.0, # March
4: 1.0, # April
5: 1.05, # May
6: 1.05, # June - summer
7: 1.05, # July - summer
8: 1.0, # August
9: 1.0, # September - back to school
10: 1.05, # October
11: 1.1, # November - holidays starting
12: 1.15 # December - peak holiday
}
return factors.get(month, 1.0)
def compare_with_predictions(user_id, month=None, year=None):
"""
Compare actual spending with predictions
Useful for showing accuracy of predictions
"""
if month is None:
month = datetime.now().month
if year is None:
year = datetime.now().year
categories = Category.query.filter_by(user_id=user_id).all()
comparison = {
'month': month,
'year': year,
'categories': {}
}
for category in categories:
# Get actual spending for the month
actual = db.session.query(func.sum(Expense.amount)).filter(
Expense.category_id == category.id,
extract('year', Expense.date) == year,
extract('month', Expense.date) == month
).scalar()
actual = float(actual) if actual else 0
# Get predicted value (simplified - using average)
prediction = predict_category_spending(category, datetime.now(), 1)
predicted = prediction['monthly_average']
if predicted > 0:
accuracy = (1 - abs(actual - predicted) / predicted) * 100
else:
accuracy = 0 if actual == 0 else 0
comparison['categories'][category.name] = {
'actual': round(actual, 2),
'predicted': round(predicted, 2),
'difference': round(actual - predicted, 2),
'accuracy': round(accuracy, 1)
}
return comparison

View file

@ -0,0 +1,22 @@
"""
Add route to serve service worker from root
"""
from flask import send_from_directory
import os
def register_pwa_routes(app):
@app.route('/service-worker.js')
def service_worker():
return send_from_directory(
os.path.join(app.root_path, 'static', 'js'),
'service-worker.js',
mimetype='application/javascript'
)
@app.route('/manifest.json')
def manifest():
return send_from_directory(
os.path.join(app.root_path, 'static'),
'manifest.json',
mimetype='application/json'
)

View file

@ -0,0 +1 @@
# Routes package

View file

@ -0,0 +1,115 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models.user import User
bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
totp_token = request.form.get('totp_token')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
# Check if 2FA is enabled
if user.is_2fa_enabled:
if not totp_token:
# Store user ID in session for 2FA verification
session['2fa_user_id'] = user.id
return render_template('auth/verify_2fa.html')
else:
# Verify 2FA token
if user.verify_totp(totp_token):
login_user(user)
session.pop('2fa_user_id', None)
flash('Login successful!', 'success')
return redirect(url_for('main.dashboard'))
else:
flash('Invalid 2FA code', 'error')
return render_template('auth/verify_2fa.html')
else:
# No 2FA, login directly
login_user(user)
flash('Login successful!', 'success')
return redirect(url_for('main.dashboard'))
flash('Invalid username or password', 'error')
return render_template('auth/login.html')
@bp.route('/verify-2fa', methods=['POST'])
def verify_2fa():
user_id = session.get('2fa_user_id')
if not user_id:
flash('Session expired. Please login again.', 'error')
return redirect(url_for('auth.login'))
user = User.query.get(user_id)
if not user:
flash('User not found', 'error')
return redirect(url_for('auth.login'))
token = request.form.get('token')
if user.verify_totp(token):
login_user(user)
session.pop('2fa_user_id', None)
flash('Login successful!', 'success')
return redirect(url_for('main.dashboard'))
else:
flash('Invalid 2FA code. Please try again.', 'error')
return render_template('auth/verify_2fa.html')
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if password != confirm_password:
flash('Passwords do not match', 'error')
return redirect(url_for('auth.register'))
if User.query.filter_by(username=username).first():
flash('Username already exists', 'error')
return redirect(url_for('auth.register'))
if User.query.filter_by(email=email).first():
flash('Email already exists', 'error')
return redirect(url_for('auth.register'))
is_first_user = User.query.count() == 0
user = User(
username=username,
email=email,
is_admin=is_first_user
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Registration successful! Please login.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
flash('Logged out successfully', 'success')
return redirect(url_for('auth.login'))

View file

@ -0,0 +1 @@
# This file makes routes a proper Python package

View file

@ -0,0 +1,18 @@
from flask import Blueprint, request, redirect, url_for
from flask_login import login_required, current_user
from app import db
bp = Blueprint('language', __name__, url_prefix='/language')
@bp.route('/switch/<lang>')
@login_required
def switch_language(lang):
"""Switch user's language preference"""
allowed_languages = ['en', 'ro', 'es']
if lang in allowed_languages:
current_user.language = lang
db.session.commit()
# Redirect back to the referring page or dashboard
return redirect(request.referrer or url_for('main.dashboard'))

View file

@ -0,0 +1,810 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify, current_app
from flask_login import login_required, current_user
from app import db
from app.models.category import Category, Expense
from app.models.user import Tag
from werkzeug.utils import secure_filename
import os
from datetime import datetime
from sqlalchemy import extract, func
import json
bp = Blueprint('main', __name__)
def allowed_file(filename):
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'pdf', 'gif'}
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@bp.route('/')
def index():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return redirect(url_for('auth.login'))
@bp.route('/dashboard')
@login_required
def dashboard():
from app.models.subscription import Subscription
from datetime import timedelta, date
today = date.today()
categories = Category.query.filter_by(user_id=current_user.id).all()
total_spent = sum(cat.get_total_spent() for cat in categories)
total_expenses = Expense.query.filter_by(user_id=current_user.id).count()
years_query = db.session.query(
extract('year', Expense.date).label('year')
).filter(
Expense.user_id == current_user.id
).distinct().all()
available_years = sorted([int(year[0]) for year in years_query if year[0]], reverse=True)
if not available_years:
available_years = [datetime.now().year]
current_year = datetime.now().year
chart_data = []
for cat in categories:
spent = cat.get_total_spent()
if spent > 0:
chart_data.append({
'name': cat.name,
'value': spent,
'color': cat.color
})
categories_json = [
{
'id': cat.id,
'name': cat.name,
'color': cat.color
}
for cat in categories
]
# Get upcoming subscriptions (next 30 days)
end_date = datetime.now().date() + timedelta(days=30)
upcoming_subscriptions = Subscription.query.filter(
Subscription.user_id == current_user.id,
Subscription.is_active == True,
Subscription.next_due_date <= end_date
).order_by(Subscription.next_due_date).limit(5).all()
# Get suggestions count
from app.smart_detection import get_user_suggestions
suggestions_count = len(get_user_suggestions(current_user.id))
return render_template('dashboard.html',
categories=categories,
total_spent=total_spent,
total_expenses=total_expenses,
chart_data=chart_data,
categories_json=categories_json,
available_years=available_years,
current_year=current_year,
upcoming_subscriptions=upcoming_subscriptions,
suggestions_count=suggestions_count,
today=today)
@bp.route('/api/metrics')
@login_required
def get_metrics():
category_id = request.args.get('category', 'all')
year = int(request.args.get('year', datetime.now().year))
if category_id == 'all':
categories = Category.query.filter_by(user_id=current_user.id).all()
monthly_data = [0] * 12
for cat in categories:
cat_monthly = cat.get_monthly_totals(year)
monthly_data = [monthly_data[i] + cat_monthly[i] for i in range(12)]
pie_data = [cat.get_yearly_total(year) for cat in categories]
pie_labels = [cat.name for cat in categories]
pie_colors = [cat.color for cat in categories]
return jsonify({
'category_name': 'All Categories',
'monthly_data': monthly_data,
'color': '#6366f1',
'pie_data': pie_data,
'pie_labels': pie_labels,
'pie_colors': pie_colors
})
else:
category = Category.query.filter_by(
id=int(category_id),
user_id=current_user.id
).first_or_404()
monthly_data = category.get_monthly_totals(year)
categories = Category.query.filter_by(user_id=current_user.id).all()
pie_data = [cat.get_yearly_total(year) for cat in categories]
pie_labels = [cat.name for cat in categories]
pie_colors = [cat.color for cat in categories]
return jsonify({
'category_name': category.name,
'monthly_data': monthly_data,
'color': category.color,
'pie_data': pie_data,
'pie_labels': pie_labels,
'pie_colors': pie_colors
})
@bp.route('/category/create', methods=['GET', 'POST'])
@login_required
def create_category():
if request.method == 'POST':
name = request.form.get('name')
description = request.form.get('description')
color = request.form.get('color', '#6366f1')
if not name:
flash('Category name is required', 'error')
return redirect(url_for('main.create_category'))
category = Category(
name=name,
description=description,
color=color,
user_id=current_user.id
)
db.session.add(category)
db.session.commit()
flash('Category created successfully!', 'success')
return redirect(url_for('main.dashboard'))
return render_template('create_category.html')
@bp.route('/category/<int:category_id>')
@login_required
def view_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
expenses = Expense.query.filter_by(category_id=category_id, user_id=current_user.id).order_by(Expense.date.desc()).all()
total_spent = category.get_total_spent()
return render_template('view_category.html',
category=category,
expenses=expenses,
total_spent=total_spent)
@bp.route('/category/<int:category_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
if request.method == 'POST':
category.name = request.form.get('name')
category.description = request.form.get('description')
category.color = request.form.get('color')
# Budget settings
monthly_budget = request.form.get('monthly_budget', '').strip()
if monthly_budget:
try:
category.monthly_budget = float(monthly_budget)
if category.monthly_budget < 0:
category.monthly_budget = None
except ValueError:
category.monthly_budget = None
else:
category.monthly_budget = None
# Budget alert threshold (default 100%)
threshold = request.form.get('budget_alert_threshold', '100').strip()
try:
category.budget_alert_threshold = float(threshold) / 100
if category.budget_alert_threshold < 0.5 or category.budget_alert_threshold > 2.0:
category.budget_alert_threshold = 1.0
except ValueError:
category.budget_alert_threshold = 1.0
db.session.commit()
flash('Category updated successfully!', 'success')
return redirect(url_for('main.view_category', category_id=category.id))
return render_template('edit_category.html', category=category)
@bp.route('/category/<int:category_id>/delete', methods=['POST'])
@login_required
def delete_category(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
for expense in category.expenses:
if expense.file_path:
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass
db.session.delete(category)
db.session.commit()
flash('Category deleted successfully!', 'success')
return redirect(url_for('main.dashboard'))
@bp.route('/expense/create/<int:category_id>', methods=['GET', 'POST'])
@login_required
def create_expense(category_id):
category = Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()
user_tags = Tag.query.filter_by(user_id=current_user.id).all()
if request.method == 'POST':
description = request.form.get('description')
amount = request.form.get('amount')
date_str = request.form.get('date')
paid_by = request.form.get('paid_by')
tags = request.form.get('tags')
if not all([description, amount]):
flash('Description and amount are required', 'error')
return redirect(url_for('main.create_expense', category_id=category_id))
try:
amount = float(amount)
if amount <= 0:
raise ValueError()
except ValueError:
flash('Please enter a valid amount', 'error')
return redirect(url_for('main.create_expense', category_id=category_id))
file_path = None
if 'file' in request.files:
file = request.files['file']
if file and file.filename and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{current_user.id}_{timestamp}_{filename}"
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join('uploads', filename)
file.save(os.path.join(current_app.root_path, 'static', file_path))
expense_date = datetime.strptime(date_str, '%Y-%m-%d') if date_str else datetime.utcnow()
expense = Expense(
description=description,
amount=amount,
date=expense_date,
paid_by=paid_by,
tags=tags,
file_path=file_path,
category_id=category_id,
user_id=current_user.id
)
db.session.add(expense)
db.session.commit()
# Check budget after adding expense
from app.budget_alerts import check_budget_alerts
try:
check_budget_alerts()
except Exception as e:
print(f"[Budget Check] Error: {e}")
flash('Expense added successfully!', 'success')
return redirect(url_for('main.view_category', category_id=category_id))
today = datetime.now().strftime('%Y-%m-%d')
return render_template('create_expense.html', category=category, today=today, user_tags=user_tags)
@bp.route('/expense/<int:expense_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
user_tags = Tag.query.filter_by(user_id=current_user.id).all()
if request.method == 'POST':
expense.description = request.form.get('description')
expense.amount = float(request.form.get('amount'))
expense.date = datetime.strptime(request.form.get('date'), '%Y-%m-%d')
expense.paid_by = request.form.get('paid_by')
expense.tags = request.form.get('tags')
if 'file' in request.files:
file = request.files['file']
if file and file.filename and allowed_file(file.filename):
if expense.file_path:
old_file = os.path.join(current_app.root_path, 'static', expense.file_path)
if os.path.exists(old_file):
try:
os.remove(old_file)
except:
pass
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{current_user.id}_{timestamp}_{filename}"
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join('uploads', filename)
file.save(os.path.join(current_app.root_path, 'static', file_path))
expense.file_path = file_path
db.session.commit()
flash('Expense updated successfully!', 'success')
return redirect(url_for('main.view_category', category_id=expense.category_id))
return render_template('edit_expense.html', expense=expense, user_tags=user_tags)
@bp.route('/expense/<int:expense_id>/delete', methods=['POST'])
@login_required
def delete_expense(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
category_id = expense.category_id
if expense.file_path:
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except:
pass
db.session.delete(expense)
db.session.commit()
flash('Expense deleted successfully!', 'success')
return redirect(url_for('main.view_category', category_id=category_id))
@bp.route('/expense/<int:expense_id>/download')
@login_required
def download_file(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
if not expense.file_path:
flash('No file attached to this expense', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
# Use current_app.root_path to get correct path
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if not os.path.exists(file_path):
flash('File not found', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
return send_file(file_path, as_attachment=True)
@bp.route('/expense/<int:expense_id>/view')
@login_required
def view_file(expense_id):
expense = Expense.query.filter_by(id=expense_id, user_id=current_user.id).first_or_404()
if not expense.file_path:
flash('No file attached to this expense', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
file_path = os.path.join(current_app.root_path, 'static', expense.file_path)
if not os.path.exists(file_path):
flash('File not found', 'error')
return redirect(url_for('main.view_category', category_id=expense.category_id))
# Return file for inline viewing
return send_file(file_path, as_attachment=False)
@bp.route('/api/ocr/process', methods=['POST'])
@login_required
def process_receipt_ocr():
"""
Process uploaded receipt image with OCR
Returns extracted data as JSON
"""
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file uploaded'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'success': False, 'error': 'Invalid file type'}), 400
try:
from app.ocr import extract_receipt_data, is_valid_receipt_image
# Save temp file
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
temp_filename = f"temp_{current_user.id}_{timestamp}_{filename}"
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads')
os.makedirs(upload_folder, exist_ok=True)
temp_path = os.path.join(upload_folder, temp_filename)
file.save(temp_path)
# Validate image
is_valid, message = is_valid_receipt_image(temp_path)
if not is_valid:
os.remove(temp_path)
return jsonify({'success': False, 'error': message}), 400
# Extract data with OCR
extracted_data = extract_receipt_data(temp_path)
# Format response
response = {
'success': extracted_data['success'],
'amount': extracted_data['amount'],
'merchant': extracted_data['merchant'],
'confidence': extracted_data['confidence'],
'temp_file': temp_filename
}
if extracted_data['date']:
response['date'] = extracted_data['date'].strftime('%Y-%m-%d')
# Don't delete temp file - will be used if user confirms
return jsonify(response)
except Exception as e:
if os.path.exists(temp_path):
os.remove(temp_path)
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/predictions')
@login_required
def predictions():
"""Display spending predictions dashboard"""
from app.predictions import get_spending_predictions, generate_insights
from datetime import datetime
# Get predictions for next 3 months
predictions_data = get_spending_predictions(current_user.id, months_ahead=3)
# Generate insights
insights = generate_insights(
predictions_data['by_category'],
datetime.now()
)
return render_template('predictions.html',
predictions=predictions_data,
insights=insights)
@bp.route('/api/predictions')
@login_required
def api_predictions():
"""Return JSON predictions for charts"""
from app.predictions import get_spending_predictions
months_ahead = request.args.get('months', 3, type=int)
# Limit to reasonable range
if months_ahead < 1 or months_ahead > 12:
return jsonify({'error': 'months must be between 1 and 12'}), 400
predictions = get_spending_predictions(current_user.id, months_ahead)
return jsonify(predictions)
@bp.route('/api/predictions/category/<int:category_id>')
@login_required
def api_category_forecast(category_id):
"""Get detailed forecast for specific category"""
from app.predictions import get_category_forecast
from app.models.category import Category
# Security check: ensure category belongs to user
category = Category.query.filter_by(
id=category_id,
user_id=current_user.id
).first()
if not category:
return jsonify({'error': 'Category not found'}), 404
forecast = get_category_forecast(category, months=6)
return jsonify({
'category': category.name,
'forecast': forecast
})
@bp.route('/api/search')
@login_required
def api_search():
"""Global search API endpoint"""
from app.search import search_all
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return jsonify({
'success': False,
'error': 'Query must be at least 2 characters',
'results': {
'expenses': [],
'categories': [],
'subscriptions': [],
'tags': [],
'total': 0
}
})
# Perform search with user isolation
results = search_all(query, current_user.id, limit=50)
return jsonify({
'success': True,
'results': results
})
@bp.route('/api/search/suggestions')
@login_required
def api_search_suggestions():
"""Quick search suggestions for autocomplete"""
from app.search import quick_search_suggestions
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return jsonify({'suggestions': []})
suggestions = quick_search_suggestions(query, current_user.id, limit=5)
return jsonify({'suggestions': suggestions})
@bp.route('/search')
@login_required
def search_page():
"""Search results page"""
from app.search import search_all
query = request.args.get('q', '').strip()
if not query:
return render_template('search.html', results=None, query='')
results = search_all(query, current_user.id, limit=100)
return render_template('search.html', results=results, query=query)
@bp.route('/bank-import', methods=['GET', 'POST'])
@login_required
def bank_import():
"""Bank statement import page"""
if request.method == 'GET':
# Get user's categories for mapping
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('bank_import.html', categories=categories)
# POST: Store uploaded file temporarily and redirect to review
if 'file' not in request.files:
flash('No file uploaded', 'error')
return redirect(url_for('main.bank_import'))
file = request.files['file']
if not file or not file.filename:
flash('No file selected', 'error')
return redirect(url_for('main.bank_import'))
# Save temporarily for processing
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
temp_filename = f"bank_{current_user.id}_{timestamp}_{filename}"
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
os.makedirs(temp_folder, exist_ok=True)
temp_path = os.path.join(temp_folder, temp_filename)
file.save(temp_path)
# Redirect to parse API then review
return redirect(url_for('main.bank_import_review', filename=temp_filename))
@bp.route('/bank-import/review/<filename>')
@login_required
def bank_import_review(filename):
"""Review parsed transactions before importing"""
from app.bank_import import parse_bank_statement
# Security: Verify filename belongs to current user
if not filename.startswith(f"bank_{current_user.id}_"):
flash('Invalid file', 'error')
return redirect(url_for('main.bank_import'))
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
temp_path = os.path.join(temp_folder, filename)
if not os.path.exists(temp_path):
flash('File not found. Please upload again.', 'error')
return redirect(url_for('main.bank_import'))
try:
# Read file
with open(temp_path, 'rb') as f:
file_content = f.read()
# Parse bank statement
result = parse_bank_statement(file_content, filename)
if not result['success']:
flash(f"Parsing failed: {result.get('error', 'Unknown error')}", 'error')
# Clean up temp file
try:
os.remove(temp_path)
except:
pass
return redirect(url_for('main.bank_import'))
# Get user's categories
categories = Category.query.filter_by(user_id=current_user.id).all()
# Store temp filename in session for confirmation
from flask import session
session['bank_import_file'] = filename
return render_template('bank_import_review.html',
transactions=result['transactions'],
total_found=result['total_found'],
categories=categories,
bank_format=result.get('bank_format', 'Unknown'),
parse_errors=result.get('parse_errors', []))
except Exception as e:
flash(f'Error processing file: {str(e)}', 'error')
# Clean up temp file
try:
os.remove(temp_path)
except:
pass
return redirect(url_for('main.bank_import'))
@bp.route('/bank-import/confirm', methods=['POST'])
@login_required
def bank_import_confirm():
"""Confirm and import selected transactions"""
from flask import session
# Get temp filename from session
filename = session.get('bank_import_file')
if not filename or not filename.startswith(f"bank_{current_user.id}_"):
flash('Invalid session. Please try again.', 'error')
return redirect(url_for('main.bank_import'))
temp_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'temp')
temp_path = os.path.join(temp_folder, filename)
# Get selected transactions from form
selected_indices = request.form.getlist('selected_transactions')
category_mappings = {} # Map transaction index to category_id
for idx in selected_indices:
category_id = request.form.get(f'category_{idx}')
if category_id:
category_mappings[int(idx)] = int(category_id)
if not selected_indices:
flash('No transactions selected for import', 'warning')
return redirect(url_for('main.bank_import_review', filename=filename))
try:
# Re-parse to get transactions
with open(temp_path, 'rb') as f:
file_content = f.read()
from app.bank_import import parse_bank_statement
result = parse_bank_statement(file_content, filename)
if not result['success']:
raise Exception('Re-parsing failed')
# Import selected transactions
imported_count = 0
skipped_count = 0
for idx_str in selected_indices:
idx = int(idx_str)
if idx >= len(result['transactions']):
continue
trans = result['transactions'][idx]
category_id = category_mappings.get(idx)
if not category_id:
skipped_count += 1
continue
# Check if transaction already exists (deduplication)
existing = Expense.query.filter_by(
user_id=current_user.id,
date=trans['date'],
amount=trans['amount'],
description=trans['description'][:50] # Partial match
).first()
if existing:
skipped_count += 1
continue
# Create expense
expense = Expense(
description=trans['description'],
amount=trans['amount'],
date=datetime.combine(trans['date'], datetime.min.time()),
category_id=category_id,
user_id=current_user.id,
tags='imported, bank-statement'
)
db.session.add(expense)
imported_count += 1
db.session.commit()
# Clean up temp file
try:
os.remove(temp_path)
session.pop('bank_import_file', None)
except:
pass
if imported_count > 0:
flash(f'Successfully imported {imported_count} transactions!', 'success')
if skipped_count > 0:
flash(f'{skipped_count} transactions were skipped (duplicates or no category)', 'info')
else:
flash('No transactions were imported', 'warning')
return redirect(url_for('main.dashboard'))
except Exception as e:
db.session.rollback()
flash(f'Import failed: {str(e)}', 'error')
return redirect(url_for('main.bank_import'))
@bp.route('/api/bank-import/parse', methods=['POST'])
@login_required
def api_bank_import_parse():
"""API endpoint for parsing bank statement (AJAX)"""
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'No file uploaded'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'success': False, 'error': 'No file selected'}), 400
try:
from app.bank_import import parse_bank_statement
# Read file content
file_content = file.read()
filename = secure_filename(file.filename)
# Parse
result = parse_bank_statement(file_content, filename)
if not result['success']:
return jsonify(result), 400
# Convert dates to strings for JSON
for trans in result['transactions']:
trans['date'] = trans['date'].isoformat()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500

View file

@ -0,0 +1,281 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.user import User, Tag
from app.models.category import Category, Expense
from werkzeug.security import generate_password_hash
import csv
import io
from datetime import datetime
import json
bp = Blueprint('settings', __name__, url_prefix='/settings')
def admin_required(f):
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_admin:
flash('Admin access required', 'error')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
@bp.route('/')
@login_required
def index():
users = User.query.all() if current_user.is_admin else []
tags = Tag.query.filter_by(user_id=current_user.id).all()
return render_template('settings/index.html', users=users, tags=tags)
# USER MANAGEMENT
@bp.route('/profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
if request.method == 'POST':
current_user.username = request.form.get('username')
current_user.email = request.form.get('email')
current_user.currency = request.form.get('currency', 'USD')
current_user.language = request.form.get('language', 'en')
# Budget alert preferences
current_user.budget_alerts_enabled = request.form.get('budget_alerts_enabled') == 'on'
alert_email = request.form.get('alert_email', '').strip()
current_user.alert_email = alert_email if alert_email else None
new_password = request.form.get('new_password')
if new_password:
current_user.set_password(new_password)
db.session.commit()
flash('Profile updated successfully!', 'success')
return redirect(url_for('settings.index'))
from app.translations import get_available_languages
languages = get_available_languages()
return render_template('settings/edit_profile.html', languages=languages)
@bp.route('/users/create', methods=['GET', 'POST'])
@login_required
@admin_required
def create_user():
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
is_admin = request.form.get('is_admin') == 'on'
if User.query.filter_by(username=username).first():
flash('Username already exists', 'error')
return redirect(url_for('settings.create_user'))
if User.query.filter_by(email=email).first():
flash('Email already exists', 'error')
return redirect(url_for('settings.create_user'))
user = User(username=username, email=email, is_admin=is_admin)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(f'User {username} created successfully!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/create_user.html')
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_user(user_id):
user = User.query.get_or_404(user_id)
if request.method == 'POST':
user.username = request.form.get('username')
user.email = request.form.get('email')
user.is_admin = request.form.get('is_admin') == 'on'
new_password = request.form.get('new_password')
if new_password:
user.set_password(new_password)
db.session.commit()
flash(f'User {user.username} updated!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/edit_user.html', user=user)
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
if user_id == current_user.id:
flash('Cannot delete your own account', 'error')
return redirect(url_for('settings.index'))
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
flash(f'User {user.username} deleted', 'success')
return redirect(url_for('settings.index'))
# TAG MANAGEMENT
@bp.route('/tags/create', methods=['GET', 'POST'])
@login_required
def create_tag():
if request.method == 'POST':
name = request.form.get('name')
color = request.form.get('color', '#6366f1')
tag = Tag(name=name, color=color, user_id=current_user.id)
db.session.add(tag)
db.session.commit()
flash(f'Tag "{name}" created!', 'success')
return redirect(url_for('settings.index'))
return render_template('settings/create_tag.html')
@bp.route('/tags/<int:tag_id>/delete', methods=['POST'])
@login_required
def delete_tag(tag_id):
tag = Tag.query.filter_by(id=tag_id, user_id=current_user.id).first_or_404()
db.session.delete(tag)
db.session.commit()
flash(f'Tag "{tag.name}" deleted', 'success')
return redirect(url_for('settings.index'))
# IMPORT/EXPORT
@bp.route('/export')
@login_required
def export_data():
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Category', 'Description', 'Amount', 'Date', 'Paid By', 'Tags'])
expenses = Expense.query.filter_by(user_id=current_user.id).all()
for expense in expenses:
writer.writerow([
expense.category.name,
expense.description,
expense.amount,
expense.date.strftime('%Y-%m-%d'),
expense.paid_by or '',
expense.tags or ''
])
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name=f'expenses_{datetime.now().strftime("%Y%m%d")}.csv'
)
@bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_data():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file uploaded', 'error')
return redirect(url_for('settings.import_data'))
file = request.files['file']
if file.filename == '':
flash('No file selected', 'error')
return redirect(url_for('settings.import_data'))
if not file.filename.endswith('.csv'):
flash('Only CSV files are supported', 'error')
return redirect(url_for('settings.import_data'))
try:
stream = io.StringIO(file.stream.read().decode('UTF8'), newline=None)
csv_reader = csv.DictReader(stream)
imported = 0
for row in csv_reader:
category_name = row.get('Category')
category = Category.query.filter_by(name=category_name, user_id=current_user.id).first()
if not category:
category = Category(name=category_name, user_id=current_user.id)
db.session.add(category)
db.session.flush()
expense = Expense(
description=row.get('Description'),
amount=float(row.get('Amount', 0)),
date=datetime.strptime(row.get('Date'), '%Y-%m-%d'),
paid_by=row.get('Paid By'),
tags=row.get('Tags'),
category_id=category.id,
user_id=current_user.id
)
db.session.add(expense)
imported += 1
db.session.commit()
flash(f'Successfully imported {imported} expenses!', 'success')
return redirect(url_for('main.dashboard'))
except Exception as e:
db.session.rollback()
flash(f'Import failed: {str(e)}', 'error')
return redirect(url_for('settings.import_data'))
return render_template('settings/import.html')
# 2FA Management
@bp.route('/2fa/setup', methods=['GET', 'POST'])
@login_required
def setup_2fa():
if request.method == 'POST':
token = request.form.get('token')
if not current_user.totp_secret:
flash('2FA setup not initiated', 'error')
return redirect(url_for('settings.setup_2fa'))
if current_user.verify_totp(token):
current_user.is_2fa_enabled = True
db.session.commit()
flash('2FA enabled successfully!', 'success')
return redirect(url_for('settings.index'))
else:
flash('Invalid code. Please try again.', 'error')
# Generate QR code
if not current_user.totp_secret:
current_user.generate_totp_secret()
db.session.commit()
import qrcode
import io
import base64
uri = current_user.get_totp_uri()
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
return render_template('settings/setup_2fa.html',
qr_code=qr_base64,
secret=current_user.totp_secret)
@bp.route('/2fa/disable', methods=['POST'])
@login_required
def disable_2fa():
current_user.is_2fa_enabled = False
current_user.totp_secret = None
db.session.commit()
flash('2FA disabled successfully', 'success')
return redirect(url_for('settings.index'))

View file

@ -0,0 +1,304 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.subscription import Subscription, RecurringPattern
from app.models.category import Category
from app.smart_detection import (
detect_recurring_expenses,
save_detected_patterns,
get_user_suggestions,
convert_pattern_to_subscription,
dismiss_pattern
)
from datetime import datetime, timedelta
bp = Blueprint('subscriptions', __name__, url_prefix='/subscriptions')
@bp.route('/')
@login_required
def index():
"""View all subscriptions and suggestions"""
subscriptions = Subscription.query.filter_by(
user_id=current_user.id,
is_active=True
).order_by(Subscription.next_due_date).all()
suggestions = get_user_suggestions(current_user.id)
# Calculate total monthly cost
monthly_cost = sum(
sub.amount if sub.frequency == 'monthly' else
sub.amount / 4 if sub.frequency == 'quarterly' else
sub.amount / 12 if sub.frequency == 'yearly' else
sub.amount * 4 if sub.frequency == 'weekly' else
sub.amount * 2 if sub.frequency == 'biweekly' else
sub.amount
for sub in subscriptions
)
yearly_cost = sum(sub.get_annual_cost() for sub in subscriptions)
return render_template('subscriptions/index.html',
subscriptions=subscriptions,
suggestions=suggestions,
monthly_cost=monthly_cost,
yearly_cost=yearly_cost)
@bp.route('/detect', methods=['POST'])
@login_required
def detect():
"""Run detection algorithm to find recurring expenses"""
patterns = detect_recurring_expenses(current_user.id)
if patterns:
saved = save_detected_patterns(patterns)
flash(f'Found {saved} potential subscription(s)!', 'success')
else:
flash('No recurring patterns detected. Add more expenses to improve detection.', 'info')
return redirect(url_for('subscriptions.index'))
@bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
"""Manually create a subscription"""
if request.method == 'POST':
name = request.form.get('name')
amount = float(request.form.get('amount', 0))
frequency = request.form.get('frequency')
custom_interval_days = request.form.get('custom_interval_days')
category_id = request.form.get('category_id')
start_date = request.form.get('start_date')
end_date = request.form.get('end_date')
total_occurrences = request.form.get('total_occurrences')
auto_create_expense = request.form.get('auto_create_expense') == 'on'
notes = request.form.get('notes')
# Validate custom interval
if frequency == 'custom':
if not custom_interval_days:
flash('Custom interval is required when using custom frequency', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/create.html', categories=categories)
interval_value = int(custom_interval_days)
if interval_value < 1 or interval_value > 365:
flash('Custom interval must be between 1 and 365 days', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/create.html', categories=categories)
# Parse dates
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else datetime.now().date()
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
subscription = Subscription(
name=name,
amount=amount,
frequency=frequency,
custom_interval_days=int(custom_interval_days) if custom_interval_days and frequency == 'custom' else None,
category_id=category_id,
user_id=current_user.id,
start_date=start_date_obj,
next_due_date=start_date_obj,
end_date=end_date_obj,
total_occurrences=int(total_occurrences) if total_occurrences else None,
auto_create_expense=auto_create_expense,
notes=notes,
is_confirmed=True,
auto_detected=False
)
db.session.add(subscription)
db.session.commit()
flash(f'Subscription "{name}" added successfully!', 'success')
return redirect(url_for('subscriptions.index'))
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/create.html', categories=categories)
@bp.route('/<int:subscription_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(subscription_id):
"""Edit a subscription"""
subscription = Subscription.query.filter_by(
id=subscription_id,
user_id=current_user.id
).first_or_404()
if request.method == 'POST':
frequency = request.form.get('frequency')
custom_interval_days = request.form.get('custom_interval_days')
# Validate custom interval
if frequency == 'custom':
if not custom_interval_days:
flash('Custom interval is required when using custom frequency', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/edit.html', subscription=subscription, categories=categories)
interval_value = int(custom_interval_days)
if interval_value < 1 or interval_value > 365:
flash('Custom interval must be between 1 and 365 days', 'error')
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/edit.html', subscription=subscription, categories=categories)
subscription.name = request.form.get('name')
subscription.amount = float(request.form.get('amount', 0))
subscription.frequency = frequency
subscription.custom_interval_days = int(custom_interval_days) if custom_interval_days and frequency == 'custom' else None
subscription.category_id = request.form.get('category_id')
subscription.auto_create_expense = request.form.get('auto_create_expense') == 'on'
subscription.notes = request.form.get('notes')
next_due_date = request.form.get('next_due_date')
if next_due_date:
subscription.next_due_date = datetime.strptime(next_due_date, '%Y-%m-%d').date()
end_date = request.form.get('end_date')
subscription.end_date = datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
total_occurrences = request.form.get('total_occurrences')
subscription.total_occurrences = int(total_occurrences) if total_occurrences else None
db.session.commit()
flash(f'Subscription "{subscription.name}" updated!', 'success')
return redirect(url_for('subscriptions.index'))
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('subscriptions/edit.html',
subscription=subscription,
categories=categories)
@bp.route('/<int:subscription_id>/delete', methods=['POST'])
@login_required
def delete(subscription_id):
"""Delete a subscription"""
subscription = Subscription.query.filter_by(
id=subscription_id,
user_id=current_user.id
).first_or_404()
name = subscription.name
db.session.delete(subscription)
db.session.commit()
flash(f'Subscription "{name}" deleted!', 'success')
return redirect(url_for('subscriptions.index'))
@bp.route('/<int:subscription_id>/toggle', methods=['POST'])
@login_required
def toggle(subscription_id):
"""Toggle subscription active status"""
subscription = Subscription.query.filter_by(
id=subscription_id,
user_id=current_user.id
).first_or_404()
subscription.is_active = not subscription.is_active
db.session.commit()
status = 'activated' if subscription.is_active else 'paused'
flash(f'Subscription "{subscription.name}" {status}!', 'success')
return redirect(url_for('subscriptions.index'))
@bp.route('/suggestion/<int:pattern_id>/accept', methods=['POST'])
@login_required
def accept_suggestion(pattern_id):
"""Accept a detected pattern and convert to subscription"""
subscription = convert_pattern_to_subscription(pattern_id, current_user.id)
if subscription:
flash(f'Subscription "{subscription.name}" added!', 'success')
else:
flash('Could not add subscription.', 'error')
return redirect(url_for('subscriptions.index'))
@bp.route('/suggestion/<int:pattern_id>/dismiss', methods=['POST'])
@login_required
def dismiss_suggestion(pattern_id):
"""Dismiss a detected pattern"""
if dismiss_pattern(pattern_id, current_user.id):
flash('Suggestion dismissed.', 'info')
else:
flash('Could not dismiss suggestion.', 'error')
return redirect(url_for('subscriptions.index'))
@bp.route('/api/upcoming')
@login_required
def api_upcoming():
"""API endpoint for upcoming subscriptions"""
days = int(request.args.get('days', 30))
end_date = datetime.now().date() + timedelta(days=days)
upcoming = Subscription.query.filter(
Subscription.user_id == current_user.id,
Subscription.is_active == True,
Subscription.next_due_date <= end_date
).order_by(Subscription.next_due_date).all()
return jsonify({
'subscriptions': [{
'id': sub.id,
'name': sub.name,
'amount': float(sub.amount),
'next_due_date': sub.next_due_date.isoformat(),
'days_until': (sub.next_due_date - datetime.now().date()).days
} for sub in upcoming]
})
@bp.route('/auto-create', methods=['POST'])
@login_required
def auto_create_expenses():
"""Auto-create expenses for due subscriptions (can be run via cron)"""
from app.models.category import Expense
subscriptions = Subscription.query.filter_by(
user_id=current_user.id,
is_active=True,
auto_create_expense=True
).all()
created_count = 0
for sub in subscriptions:
if sub.should_create_expense_today():
# Create the expense
expense = Expense(
amount=sub.amount,
description=f"{sub.name} (Auto-created)",
date=datetime.now().date(),
category_id=sub.category_id,
user_id=current_user.id
)
db.session.add(expense)
# Update subscription
sub.last_auto_created = datetime.now().date()
sub.advance_next_due_date()
created_count += 1
db.session.commit()
if created_count > 0:
flash(f'Auto-created {created_count} expense(s) from subscriptions!', 'success')
else:
flash('No expenses due for auto-creation today.', 'info')
return redirect(url_for('subscriptions.index'))

View file

@ -0,0 +1,313 @@
"""
Global Search Module for FINA Finance Tracker
Provides comprehensive search across all user data with security isolation
"""
from app.models.category import Category, Expense
from app.models.subscription import Subscription
from app.models.user import User, Tag
from sqlalchemy import or_, and_, func, cast, String
from datetime import datetime
import re
def search_all(query, user_id, limit=50):
"""
Comprehensive search across all user data
Args:
query: Search string
user_id: Current user ID for security filtering
limit: Maximum results per category
Returns:
Dictionary with categorized results
"""
if not query or not query.strip():
return {
'expenses': [],
'categories': [],
'subscriptions': [],
'tags': [],
'total': 0
}
query = query.strip()
search_term = f'%{query}%'
# Try to parse as amount (e.g., "45.99", "45", "45.9")
amount_value = None
try:
amount_value = float(query.replace(',', '.'))
except ValueError:
pass
# Try to parse as date (YYYY-MM-DD, DD/MM/YYYY, etc.)
date_value = None
date_patterns = [
r'(\d{4})-(\d{1,2})-(\d{1,2})', # YYYY-MM-DD
r'(\d{1,2})/(\d{1,2})/(\d{4})', # DD/MM/YYYY
r'(\d{1,2})-(\d{1,2})-(\d{4})', # DD-MM-YYYY
]
for pattern in date_patterns:
match = re.search(pattern, query)
if match:
try:
groups = match.groups()
if len(groups[0]) == 4: # YYYY-MM-DD
date_value = datetime(int(groups[0]), int(groups[1]), int(groups[2])).date()
else: # DD/MM/YYYY or DD-MM-YYYY
date_value = datetime(int(groups[2]), int(groups[1]), int(groups[0])).date()
break
except (ValueError, IndexError):
pass
# Search Expenses
expense_conditions = [
Expense.user_id == user_id,
or_(
Expense.description.ilike(search_term),
Expense.paid_by.ilike(search_term),
Expense.tags.ilike(search_term),
)
]
# Add amount search if valid number
if amount_value is not None:
expense_conditions[1] = or_(
expense_conditions[1],
Expense.amount == amount_value,
# Also search for amounts close to the value (±0.01)
and_(Expense.amount >= amount_value - 0.01, Expense.amount <= amount_value + 0.01)
)
# Add date search if valid date
if date_value:
expense_conditions[1] = or_(
expense_conditions[1],
func.date(Expense.date) == date_value
)
expenses = Expense.query.filter(
and_(*expense_conditions)
).order_by(Expense.date.desc()).limit(limit).all()
# Search Categories
categories = Category.query.filter(
Category.user_id == user_id,
or_(
Category.name.ilike(search_term),
Category.description.ilike(search_term)
)
).limit(limit).all()
# Search Subscriptions
subscription_conditions = [
Subscription.user_id == user_id,
or_(
Subscription.name.ilike(search_term),
Subscription.notes.ilike(search_term),
)
]
# Add amount search for subscriptions
if amount_value is not None:
subscription_conditions[1] = or_(
subscription_conditions[1],
Subscription.amount == amount_value,
and_(Subscription.amount >= amount_value - 0.01, Subscription.amount <= amount_value + 0.01)
)
subscriptions = Subscription.query.filter(
and_(*subscription_conditions)
).limit(limit).all()
# Search Tags
tags = Tag.query.filter(
Tag.user_id == user_id,
Tag.name.ilike(search_term)
).limit(limit).all()
# Format results
expense_results = []
for exp in expenses:
expense_results.append({
'id': exp.id,
'type': 'expense',
'description': exp.description,
'amount': float(exp.amount),
'date': exp.date.strftime('%Y-%m-%d'),
'category_name': exp.category.name if exp.category else '',
'category_id': exp.category_id,
'category_color': exp.category.color if exp.category else '#6366f1',
'paid_by': exp.paid_by or '',
'tags': exp.tags or '',
'has_receipt': bool(exp.file_path),
'url': f'/expense/{exp.id}/edit'
})
category_results = []
for cat in categories:
spent = cat.get_total_spent()
category_results.append({
'id': cat.id,
'type': 'category',
'name': cat.name,
'description': cat.description or '',
'color': cat.color,
'total_spent': float(spent),
'expense_count': len(cat.expenses),
'url': f'/category/{cat.id}'
})
subscription_results = []
for sub in subscriptions:
subscription_results.append({
'id': sub.id,
'type': 'subscription',
'name': sub.name,
'amount': float(sub.amount),
'frequency': sub.frequency,
'next_due': sub.next_due_date.strftime('%Y-%m-%d') if sub.next_due_date else None,
'is_active': sub.is_active,
'category_name': Category.query.get(sub.category_id).name if sub.category_id else '',
'url': f'/subscriptions/edit/{sub.id}'
})
tag_results = []
for tag in tags:
# Count expenses with this tag
tag_expense_count = Expense.query.filter(
Expense.user_id == user_id,
Expense.tags.ilike(f'%{tag.name}%')
).count()
tag_results.append({
'id': tag.id,
'type': 'tag',
'name': tag.name,
'color': tag.color,
'expense_count': tag_expense_count,
'url': f'/settings' # Tags management is in settings
})
total = len(expense_results) + len(category_results) + len(subscription_results) + len(tag_results)
return {
'expenses': expense_results,
'categories': category_results,
'subscriptions': subscription_results,
'tags': tag_results,
'total': total,
'query': query
}
def search_expenses_by_filters(user_id, category_id=None, date_from=None, date_to=None,
min_amount=None, max_amount=None, tags=None, paid_by=None):
"""
Advanced expense filtering with multiple criteria
Args:
user_id: Current user ID
category_id: Filter by category
date_from: Start date (datetime object)
date_to: End date (datetime object)
min_amount: Minimum amount
max_amount: Maximum amount
tags: Tag string to search for
paid_by: Person who paid
Returns:
List of matching expenses
"""
conditions = [Expense.user_id == user_id]
if category_id:
conditions.append(Expense.category_id == category_id)
if date_from:
conditions.append(Expense.date >= date_from)
if date_to:
conditions.append(Expense.date <= date_to)
if min_amount is not None:
conditions.append(Expense.amount >= min_amount)
if max_amount is not None:
conditions.append(Expense.amount <= max_amount)
if tags:
conditions.append(Expense.tags.ilike(f'%{tags}%'))
if paid_by:
conditions.append(Expense.paid_by.ilike(f'%{paid_by}%'))
expenses = Expense.query.filter(and_(*conditions)).order_by(Expense.date.desc()).all()
return expenses
def quick_search_suggestions(query, user_id, limit=5):
"""
Quick search for autocomplete suggestions
Returns top matches across all types
Args:
query: Search string
user_id: Current user ID
limit: Maximum suggestions
Returns:
List of suggestion objects
"""
if not query or len(query) < 2:
return []
search_term = f'%{query}%'
suggestions = []
# Recent expenses
recent_expenses = Expense.query.filter(
Expense.user_id == user_id,
Expense.description.ilike(search_term)
).order_by(Expense.date.desc()).limit(limit).all()
for exp in recent_expenses:
suggestions.append({
'text': exp.description,
'type': 'expense',
'amount': float(exp.amount),
'date': exp.date.strftime('%Y-%m-%d'),
'icon': '💸'
})
# Categories
cats = Category.query.filter(
Category.user_id == user_id,
Category.name.ilike(search_term)
).limit(limit).all()
for cat in cats:
suggestions.append({
'text': cat.name,
'type': 'category',
'icon': '📁',
'color': cat.color
})
# Subscriptions
subs = Subscription.query.filter(
Subscription.user_id == user_id,
Subscription.name.ilike(search_term)
).limit(limit).all()
for sub in subs:
suggestions.append({
'text': sub.name,
'type': 'subscription',
'amount': float(sub.amount),
'icon': '🔄'
})
return suggestions[:limit * 2]

View file

@ -0,0 +1,354 @@
"""
Smart detection algorithms for recurring expenses and subscriptions
"""
from datetime import datetime, timedelta
from collections import defaultdict
import re
import json
from sqlalchemy import and_
from app import db
from app.models.category import Expense
from app.models.subscription import RecurringPattern, Subscription
def detect_recurring_expenses(user_id, min_occurrences=3, min_confidence=70):
"""
Detect recurring expenses for a user
Args:
user_id: User ID to analyze
min_occurrences: Minimum number of similar transactions to consider
min_confidence: Minimum confidence score (0-100) to suggest
Returns:
List of detected patterns
"""
# Get all expenses for the user from the last year
one_year_ago = datetime.now() - timedelta(days=365)
expenses = Expense.query.filter(
and_(
Expense.user_id == user_id,
Expense.date >= one_year_ago.date()
)
).order_by(Expense.date).all()
if len(expenses) < min_occurrences:
return []
# Group expenses by similarity
patterns = []
processed_ids = set()
for i, expense in enumerate(expenses):
if expense.id in processed_ids:
continue
similar_expenses = find_similar_expenses(expense, expenses[i+1:], processed_ids)
if len(similar_expenses) >= min_occurrences - 1: # -1 because we include the current expense
similar_expenses.insert(0, expense)
pattern = analyze_pattern(similar_expenses, user_id)
if pattern and pattern['confidence_score'] >= min_confidence:
patterns.append(pattern)
processed_ids.update([e.id for e in similar_expenses])
return patterns
def find_similar_expenses(target_expense, expenses, exclude_ids):
"""Find expenses similar to target expense"""
similar = []
target_amount = target_expense.amount
target_desc = normalize_description(target_expense.description or '')
# Amount tolerance: 5% or $5, whichever is larger
amount_tolerance = max(target_amount * 0.05, 5.0)
for expense in expenses:
if expense.id in exclude_ids:
continue
# Check category match
if expense.category_id != target_expense.category_id:
continue
# Check amount similarity
amount_diff = abs(expense.amount - target_amount)
if amount_diff > amount_tolerance:
continue
# Check description similarity
expense_desc = normalize_description(expense.description or '')
if not descriptions_similar(target_desc, expense_desc):
continue
similar.append(expense)
return similar
def normalize_description(desc):
"""Normalize description for comparison"""
# Remove common patterns like dates, numbers at end
desc = re.sub(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', '', desc)
desc = re.sub(r'#\d+', '', desc)
desc = re.sub(r'\s+\d+$', '', desc)
# Convert to lowercase and strip
desc = desc.lower().strip()
# Remove common words
common_words = ['payment', 'subscription', 'monthly', 'recurring', 'auto']
for word in common_words:
desc = desc.replace(word, '')
return desc.strip()
def descriptions_similar(desc1, desc2, threshold=0.6):
"""Check if two descriptions are similar enough"""
if not desc1 or not desc2:
return False
# Exact match
if desc1 == desc2:
return True
# Check if one contains the other
if desc1 in desc2 or desc2 in desc1:
return True
# Simple word overlap check
words1 = set(desc1.split())
words2 = set(desc2.split())
if not words1 or not words2:
return False
overlap = len(words1 & words2) / max(len(words1), len(words2))
return overlap >= threshold
def analyze_pattern(expenses, user_id):
"""Analyze a group of similar expenses to determine pattern"""
if len(expenses) < 2:
return None
# Sort by date
expenses = sorted(expenses, key=lambda e: e.date)
# Calculate intervals between expenses
intervals = []
for i in range(len(expenses) - 1):
days = (expenses[i + 1].date - expenses[i].date).days
intervals.append(days)
if not intervals:
return None
# Determine frequency
avg_interval = sum(intervals) / len(intervals)
frequency, confidence = determine_frequency(intervals, avg_interval)
if not frequency:
return None
# Calculate average amount
avg_amount = sum(e.amount for e in expenses) / len(expenses)
amount_variance = calculate_variance([e.amount for e in expenses])
# Adjust confidence based on amount consistency
if amount_variance < 0.05: # Less than 5% variance
confidence += 10
elif amount_variance > 0.2: # More than 20% variance
confidence -= 10
confidence = min(max(confidence, 0), 100) # Clamp between 0-100
# Generate suggested name
suggested_name = generate_subscription_name(expenses[0])
# Check if pattern already exists
existing = RecurringPattern.query.filter_by(
user_id=user_id,
suggested_name=suggested_name,
is_dismissed=False,
is_converted=False
).first()
if existing:
return None # Don't create duplicates
return {
'user_id': user_id,
'category_id': expenses[0].category_id,
'suggested_name': suggested_name,
'average_amount': round(avg_amount, 2),
'detected_frequency': frequency,
'confidence_score': round(confidence, 1),
'expense_ids': json.dumps([e.id for e in expenses]),
'first_occurrence': expenses[0].date,
'last_occurrence': expenses[-1].date,
'occurrence_count': len(expenses)
}
def determine_frequency(intervals, avg_interval):
"""Determine frequency from intervals"""
# Check consistency of intervals
variance = calculate_variance(intervals)
# Base confidence on consistency
base_confidence = 70 if variance < 0.15 else 50
# Determine frequency based on average interval
if 5 <= avg_interval <= 9:
return 'weekly', base_confidence + 10
elif 12 <= avg_interval <= 16:
return 'biweekly', base_confidence
elif 27 <= avg_interval <= 33:
return 'monthly', base_confidence + 15
elif 85 <= avg_interval <= 95:
return 'quarterly', base_confidence
elif 355 <= avg_interval <= 375:
return 'yearly', base_confidence
else:
# Check if it's a multiple of common frequencies
if 25 <= avg_interval <= 35:
return 'monthly', base_confidence - 10
elif 7 <= avg_interval <= 10:
return 'weekly', base_confidence - 10
return None, 0
def calculate_variance(values):
"""Calculate coefficient of variation"""
if not values or len(values) < 2:
return 0
avg = sum(values) / len(values)
if avg == 0:
return 0
variance = sum((x - avg) ** 2 for x in values) / len(values)
std_dev = variance ** 0.5
return std_dev / avg
def generate_subscription_name(expense):
"""Generate a friendly name for the subscription"""
desc = expense.description or 'Recurring Expense'
# Clean up description
desc = re.sub(r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', '', desc)
desc = re.sub(r'#\d+', '', desc)
desc = re.sub(r'\s+\d+$', '', desc)
desc = desc.strip()
# Capitalize first letter of each word
desc = ' '.join(word.capitalize() for word in desc.split())
# Limit length
if len(desc) > 50:
desc = desc[:47] + '...'
return desc or 'Recurring Expense'
def save_detected_patterns(patterns):
"""Save detected patterns to database"""
saved_count = 0
for pattern_data in patterns:
pattern = RecurringPattern(**pattern_data)
db.session.add(pattern)
saved_count += 1
try:
db.session.commit()
return saved_count
except Exception as e:
db.session.rollback()
print(f"Error saving patterns: {e}")
return 0
def get_user_suggestions(user_id):
"""Get all active suggestions for a user"""
return RecurringPattern.query.filter_by(
user_id=user_id,
is_dismissed=False,
is_converted=False
).order_by(RecurringPattern.confidence_score.desc()).all()
def convert_pattern_to_subscription(pattern_id, user_id):
"""Convert a detected pattern to a confirmed subscription"""
pattern = RecurringPattern.query.filter_by(
id=pattern_id,
user_id=user_id
).first()
if not pattern or pattern.is_converted:
return None
# Create subscription
subscription = Subscription(
name=pattern.suggested_name,
amount=pattern.average_amount,
frequency=pattern.detected_frequency,
category_id=pattern.category_id,
user_id=pattern.user_id,
next_due_date=pattern.last_occurrence + timedelta(days=get_frequency_days(pattern.detected_frequency)),
is_active=True,
is_confirmed=True,
auto_detected=True,
confidence_score=pattern.confidence_score
)
db.session.add(subscription)
# Mark pattern as converted
pattern.is_converted = True
try:
db.session.commit()
return subscription
except Exception as e:
db.session.rollback()
print(f"Error converting pattern: {e}")
return None
def get_frequency_days(frequency):
"""Get number of days for frequency"""
frequency_map = {
'weekly': 7,
'biweekly': 14,
'monthly': 30,
'quarterly': 90,
'yearly': 365
}
return frequency_map.get(frequency, 30)
def dismiss_pattern(pattern_id, user_id):
"""Dismiss a detected pattern"""
pattern = RecurringPattern.query.filter_by(
id=pattern_id,
user_id=user_id
).first()
if pattern:
pattern.is_dismissed = True
try:
db.session.commit()
return True
except Exception as e:
db.session.rollback()
print(f"Error dismissing pattern: {e}")
return False

View file

@ -0,0 +1,752 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #5b5fc7;
--primary-dark: #4338ca;
--success: #10b981;
--danger: #ef4444;
--glass-bg: rgba(255, 255, 255, 0.08);
--glass-border: rgba(255, 255, 255, 0.15);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.8);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #4c1d95 0%, #3b0764 100%);
min-height: 100vh;
color: var(--text-primary);
line-height: 1.6;
}
.alert {
position: fixed !important;
top: 80px !important;
right: 20px !important;
left: auto !important;
max-width: 350px !important;
width: auto !important;
padding: 1rem 1.5rem !important;
margin: 0 !important;
border-radius: 15px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5) !important;
z-index: 9999 !important;
animation: slideIn 0.3s ease-out !important;
}
@keyframes slideIn {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(400px); opacity: 0; }
}
.alert.hiding { animation: slideOut 0.3s ease-in forwards !important; }
.alert-success { background: rgba(16, 185, 129, 0.25) !important; border: 1px solid var(--success) !important; }
.alert-error { background: rgba(239, 68, 68, 0.25) !important; border: 1px solid var(--danger) !important; }
.alert-info { background: rgba(99, 102, 241, 0.25) !important; border: 1px solid var(--primary) !important; }
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.container { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; }
.auth-container { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
.auth-card { max-width: 500px; width: 100%; }
.auth-card h1 { font-size: 2rem; margin-bottom: 0.5rem; text-align: center; }
.subtitle { text-align: center; color: var(--text-secondary); margin-bottom: 2rem; }
.auth-form { display: flex; flex-direction: column; gap: 1.5rem; }
.form-group { display: flex; flex-direction: column; }
.form-group label { margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); }
.form-group input {
padding: 0.75rem;
border: 1px solid var(--glass-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
font-size: 1rem;
}
.form-group input:focus { outline: none; border-color: var(--primary); background: rgba(255, 255, 255, 0.18); }
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5); }
.btn-secondary { background: rgba(255, 255, 255, 0.12); color: var(--text-primary); border: 1px solid var(--glass-border); }
.btn-secondary:hover { background: rgba(255, 255, 255, 0.2); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-small { padding: 0.5rem 1rem; font-size: 0.875rem; }
.auth-link { text-align: center; margin-top: 1rem; color: var(--text-secondary); }
.auth-link a { color: #a5b4fc; text-decoration: none; }
.empty-state { text-align: center; padding: 3rem 2rem; }
.empty-state h2 { margin-bottom: 1rem; }
.empty-state p { color: var(--text-secondary); margin-bottom: 1.5rem; }
.stats-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card { text-align: center; }
.stat-card h3 { color: var(--text-secondary); font-size: 1rem; margin-bottom: 0.5rem; }
.stat-value { font-size: 2rem; font-weight: bold; }
.glass-nav {
background: rgba(59, 7, 100, 0.5);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
padding: 1rem 0;
margin-bottom: 2rem;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
}
.nav-brand {
font-size: 1.5rem;
font-weight: bold;
color: var(--text-primary);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.nav-logo {
width: 32px;
height: 32px;
}
/* Navigation Search */
.nav-search {
flex: 0 1 400px;
min-width: 200px;
}
.nav-search-form {
display: flex;
gap: 0.5rem;
}
.nav-search-input {
flex: 1;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
color: var(--text-primary);
font-size: 0.9rem;
transition: all 0.3s;
}
.nav-search-input:focus {
outline: none;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(102, 126, 234, 0.5);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.nav-search-input::placeholder {
color: var(--text-secondary);
}
.nav-search-btn {
padding: 0.5rem 1rem;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 1rem;
transition: opacity 0.3s;
}
.nav-search-btn:hover {
opacity: 0.7;
}
.nav-links {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.nav-links a {
color: var(--text-primary);
text-decoration: none;
transition: opacity 0.3s;
font-weight: 500;
white-space: nowrap;
}
.nav-links a:hover { opacity: 0.7; }
.metrics-section {
padding: 2rem;
margin-top: 0;
margin-bottom: 2rem;
max-height: 550px !important;
overflow: hidden !important;
}
.metrics-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.metrics-header h2 { margin: 0; }
.metrics-controls { display: flex; gap: 1rem; }
.metric-select {
padding: 0.75rem 1rem;
border: 1px solid var(--glass-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
font-size: 1rem;
cursor: pointer;
min-width: 150px;
font-weight: 500;
}
.metric-select:focus { outline: none; border-color: var(--primary); background: rgba(255, 255, 255, 0.18); }
.metric-select option { background: #3b0764; color: white; }
.charts-container {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1.5rem;
margin-top: 1rem;
height: 380px !important;
max-height: 380px !important;
}
.chart-box {
background: rgba(255, 255, 255, 0.06);
padding: 1rem;
border-radius: 15px;
border: 1px solid var(--glass-border);
height: 380px !important;
max-height: 380px !important;
overflow: hidden !important;
display: flex;
flex-direction: column;
}
.chart-box h3 { margin: 0 0 1rem 0; font-size: 1rem; text-align: center; flex-shrink: 0; }
.chart-box canvas { max-width: 100% !important; max-height: 320px !important; height: 320px !important; }
.chart-box-wide { height: 380px !important; max-height: 380px !important; }
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.category-card { text-decoration: none; color: inherit; cursor: pointer; transition: all 0.3s ease; display: block; }
.category-card:hover { transform: translateY(-5px); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); }
.category-content h3 { margin-bottom: 0.5rem; font-size: 1.3rem; }
.category-description { color: var(--text-secondary); font-size: 0.9rem; margin: 0.5rem 0 1rem; }
.category-amount { font-size: 2rem; font-weight: bold; color: var(--success); margin: 1rem 0; }
.category-info { color: var(--text-secondary); font-size: 0.85rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.form-group textarea { padding: 0.75rem; border: 1px solid var(--glass-border); border-radius: 10px; background: rgba(255, 255, 255, 0.12); color: var(--text-primary); font-size: 1rem; font-family: inherit; resize: vertical; }
.form-group textarea:focus { outline: none; border-color: var(--primary); background: rgba(255, 255, 255, 0.18); }
.form-group input[type="color"] { height: 50px; cursor: pointer; }
.form-actions { display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1rem; }
input[type="file"] { padding: 0.5rem; background: rgba(255, 255, 255, 0.12); border: 1px solid var(--glass-border); border-radius: 10px; color: var(--text-primary); }
@media (max-width: 1024px) {
.charts-container { grid-template-columns: 1fr; height: auto !important; }
.chart-box { height: 350px !important; }
.metrics-controls { flex-direction: column; width: 100%; }
.metric-select { width: 100%; }
.nav-container { flex-direction: column; gap: 1rem; }
}
/* SETTINGS PAGE */
.settings-container {
max-width: 1000px;
margin: 0 auto;
}
.settings-container h1 {
margin-bottom: 2rem;
font-size: 2rem;
}
.settings-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 2px solid var(--glass-border);
flex-wrap: wrap;
}
.tab-btn {
padding: 1rem 1.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-btn:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.tab-btn.active {
color: var(--text-primary);
border-bottom-color: var(--primary);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.tag-item {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.tag-name {
font-weight: 500;
font-size: 1.1rem;
}
.empty-message {
color: var(--text-secondary);
text-align: center;
padding: 2rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
margin-top: 1.5rem;
}
.users-table th,
.users-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--glass-border);
}
.users-table th {
font-weight: 600;
color: var(--text-secondary);
}
.users-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.form-container {
max-width: 600px;
margin: 0 auto;
}
.form-card h1 {
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
}
@media (max-width: 768px) {
.settings-tabs {
flex-direction: column;
}
.tab-btn {
width: 100%;
text-align: left;
}
.users-table {
font-size: 0.875rem;
}
.users-table th,
.users-table td {
padding: 0.5rem;
}
}
/* FINA Logo Styling */
.nav-logo {
height: 32px;
width: 32px;
margin-right: 0.5rem;
border-radius: 50%;
object-fit: cover;
vertical-align: middle;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-brand span {
font-size: 1.5rem;
font-weight: bold;
}
/* Language Switcher */
.language-switcher {
position: relative;
display: inline-block;
z-index: 9999;
}
.language-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
transition: transform 0.2s;
}
.language-btn:hover {
transform: scale(1.1);
}
.language-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: 10px;
min-width: 150px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 9999;
overflow: hidden;
}
.language-menu.show {
display: block;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.language-option {
display: block;
padding: 0.75rem 1rem;
color: var(--text-primary);
text-decoration: none;
transition: background 0.2s;
}
.language-option:hover {
background: rgba(255, 255, 255, 0.1);
}
/* PWA Install Prompt Styles */
.pwa-prompt {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
max-width: 500px;
width: calc(100% - 40px);
z-index: 10000;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateX(-50%) translateY(100px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
.pwa-prompt-content {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.pwa-icon {
width: 48px;
height: 48px;
border-radius: 12px;
}
.pwa-prompt-text {
flex: 1;
min-width: 150px;
}
.pwa-prompt-text h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.pwa-prompt-text p {
margin: 0.25rem 0 0 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.pwa-prompt-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.pwa-prompt-actions .btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
@media (max-width: 600px) {
.pwa-prompt-content {
flex-direction: column;
text-align: center;
}
.pwa-prompt-actions {
margin-left: 0;
width: 100%;
}
.pwa-prompt-actions .btn {
flex: 1;
}
}
/* Enhanced Mobile Responsiveness for PWA */
@media (max-width: 768px) {
/* Larger touch targets for mobile */
.btn {
min-height: 44px;
padding: 0.875rem 1.5rem;
font-size: 1rem;
}
.btn-small {
min-height: 40px;
padding: 0.625rem 1.25rem;
}
/* Stack header actions vertically on mobile */
.page-header {
flex-direction: column;
gap: 1rem;
align-items: stretch !important;
}
.header-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.header-actions .btn,
.header-actions form {
width: 100%;
}
.header-actions form button {
width: 100%;
}
/* Navigation adjustments for mobile */
.nav-container {
flex-wrap: wrap;
gap: 1rem;
}
.nav-brand {
font-size: 1.2rem;
}
.nav-logo {
width: 28px;
height: 28px;
}
.nav-search {
order: 3;
flex: 1 1 100%;
max-width: 100%;
}
.nav-search-input {
padding: 0.625rem 1rem;
min-height: 44px;
}
.nav-links {
gap: 0.75rem;
font-size: 0.9rem;
}
.nav-links a {
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
min-height: 40px;
display: flex;
align-items: center;
}
/* Better mobile navigation */
.nav-links {
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.nav-links a {
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
min-height: 40px;
display: flex;
align-items: center;
}
/* Subscription cards mobile-friendly */
.subscription-item {
flex-direction: column !important;
align-items: flex-start !important;
gap: 1rem;
}
.subscription-info {
width: 100%;
}
.subscription-actions {
width: 100%;
display: flex;
gap: 0.5rem;
}
.subscription-actions .btn,
.subscription-actions form {
flex: 1;
}
/* Stats grid mobile */
.stats-container {
grid-template-columns: 1fr;
gap: 1rem;
}
/* Form improvements */
.form-group input,
.form-group select,
.form-group textarea {
font-size: 16px; /* Prevents iOS zoom */
min-height: 44px;
}
/* Categories grid mobile */
.categories-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
/* Suggestion cards mobile */
.suggestion-details {
grid-template-columns: 1fr 1fr !important;
gap: 0.75rem !important;
}
.suggestion-actions {
flex-direction: column !important;
width: 100%;
}
.suggestion-actions .btn,
.suggestion-actions form {
width: 100% !important;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,187 @@
// PWA Service Worker Registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/js/service-worker.js')
.then(registration => {
console.log('[PWA] Service Worker registered successfully:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[PWA] New version available! Refresh to update.');
// Optionally show a notification to the user
if (confirm('A new version of FINA is available. Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
});
})
.catch(error => {
console.log('[PWA] Service Worker registration failed:', error);
});
});
}
// PWA Install Prompt
let deferredPrompt;
const installPrompt = document.getElementById('pwa-install-prompt');
const installBtn = document.getElementById('pwa-install-btn');
const dismissBtn = document.getElementById('pwa-dismiss-btn');
// Check if already installed (standalone mode)
const isInstalled = () => {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://');
};
// Detect iOS
const isIOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
// Show iOS install instructions
function showIOSInstallPrompt() {
if (installPrompt) {
const promptText = installPrompt.querySelector('.pwa-prompt-text p');
if (promptText && isIOS() && !window.navigator.standalone) {
promptText.textContent = 'Tap Share button and then "Add to Home Screen"';
installBtn.style.display = 'none'; // Hide install button on iOS
}
}
}
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent the default mini-infobar
e.preventDefault();
// Store the event for later use
deferredPrompt = e;
// Don't show if already installed
if (isInstalled()) {
return;
}
// Show custom install prompt if not dismissed
const dismissed = localStorage.getItem('pwa-install-dismissed');
const dismissedUntil = parseInt(dismissed || '0');
if (Date.now() > dismissedUntil && installPrompt) {
installPrompt.style.display = 'block';
}
});
// Handle iOS separately
if (isIOS() && !isInstalled()) {
const dismissed = localStorage.getItem('pwa-install-dismissed');
const dismissedUntil = parseInt(dismissed || '0');
if (Date.now() > dismissedUntil && installPrompt) {
setTimeout(() => {
installPrompt.style.display = 'block';
showIOSInstallPrompt();
}, 2000); // Show after 2 seconds
}
}
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) {
return;
}
// Show the install prompt
deferredPrompt.prompt();
// Wait for the user's response
const { outcome } = await deferredPrompt.userChoice;
console.log(`[PWA] User response: ${outcome}`);
// Clear the saved prompt since it can't be used again
deferredPrompt = null;
// Hide the prompt
installPrompt.style.display = 'none';
});
}
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
installPrompt.style.display = 'none';
// Remember dismissal for 7 days
localStorage.setItem('pwa-install-dismissed', Date.now() + (7 * 24 * 60 * 60 * 1000));
});
}
// Check if app is installed
window.addEventListener('appinstalled', () => {
console.log('[PWA] App installed successfully');
if (installPrompt) {
installPrompt.style.display = 'none';
}
localStorage.removeItem('pwa-install-dismissed');
});
// Online/Offline status
window.addEventListener('online', () => {
console.log('[PWA] Back online');
// Show notification or update UI
showToast('Connection restored', 'success');
});
window.addEventListener('offline', () => {
console.log('[PWA] Gone offline');
showToast('You are offline. Some features may be limited.', 'info');
});
// Toast notification function
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} glass-card`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Language menu toggle
function toggleLanguageMenu() {
const menu = document.getElementById('language-menu');
menu.classList.toggle('show');
}
// Close language menu when clicking outside
document.addEventListener('click', function(event) {
const switcher = document.querySelector('.language-switcher');
const menu = document.getElementById('language-menu');
if (menu && switcher && !switcher.contains(event.target)) {
menu.classList.remove('show');
}
});
document.addEventListener('DOMContentLoaded', function() {
console.log('Finance Tracker loaded');
// Auto-hide flash messages after 2 seconds
const alerts = document.querySelectorAll('.alert');
alerts.forEach(function(alert) {
// Add hiding animation after 2 seconds
setTimeout(function() {
alert.classList.add('hiding');
// Remove from DOM after animation completes
setTimeout(function() {
alert.remove();
}, 300); // Wait for animation to finish
}, 2000); // Show for 2 seconds
});
});

View file

@ -0,0 +1,161 @@
const CACHE_NAME = 'fina-v1';
const STATIC_CACHE = 'fina-static-v1';
const DYNAMIC_CACHE = 'fina-dynamic-v1';
// Assets to cache on install
const STATIC_ASSETS = [
'/',
'/static/css/style.css',
'/static/js/script.js',
'/static/js/chart.min.js',
'/static/images/fina-logo.png'
];
// Install event - cache static assets
self.addEventListener('install', event => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => {
console.log('[Service Worker] Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map(name => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Fetch event - network first, then cache
self.addEventListener('fetch', event => {
const { request } = event;
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip chrome extension and other non-http(s) requests
if (!request.url.startsWith('http')) {
return;
}
// API requests - network first, cache fallback
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE).then(cache => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => caches.match(request))
);
return;
}
// Static assets - cache first, network fallback
if (
request.url.includes('/static/') ||
request.url.endsWith('.css') ||
request.url.endsWith('.js') ||
request.url.endsWith('.png') ||
request.url.endsWith('.jpg') ||
request.url.endsWith('.jpeg') ||
request.url.endsWith('.gif') ||
request.url.endsWith('.svg')
) {
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then(response => {
const responseClone = response.clone();
caches.open(STATIC_CACHE).then(cache => {
cache.put(request, responseClone);
});
return response;
});
})
);
return;
}
// HTML pages - network first, cache fallback
event.respondWith(
fetch(request)
.then(response => {
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE).then(cache => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
// Return offline page if available
return caches.match('/');
});
})
);
});
// Handle messages from clients
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync for offline form submissions
self.addEventListener('sync', event => {
if (event.tag === 'sync-expenses') {
event.waitUntil(syncExpenses());
}
});
async function syncExpenses() {
// Placeholder for syncing offline data
console.log('[Service Worker] Syncing expenses...');
}
// Push notifications support (for future feature)
self.addEventListener('push', event => {
const options = {
body: event.data ? event.data.text() : 'New notification from FINA',
icon: '/static/images/fina-logo.png',
badge: '/static/images/fina-logo.png',
vibrate: [200, 100, 200]
};
event.waitUntil(
self.registration.showNotification('FINA', options)
);
});
// Notification click handler
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow('/')
);
});

View file

@ -0,0 +1,49 @@
{
"name": "FINA - Personal Finance Tracker",
"short_name": "FINA",
"description": "Track your expenses, manage categories, and visualize spending patterns",
"start_url": "/",
"display": "standalone",
"background_color": "#3b0764",
"theme_color": "#5b5fc7",
"orientation": "portrait-primary",
"categories": ["finance", "productivity"],
"icons": [
{
"src": "/static/images/fina-logo.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/images/fina-logo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "View your expense dashboard",
"url": "/dashboard",
"icons": [{ "src": "/static/images/fina-logo.png", "sizes": "96x96" }]
},
{
"name": "New Category",
"short_name": "Category",
"description": "Create a new expense category",
"url": "/create-category",
"icons": [{ "src": "/static/images/fina-logo.png", "sizes": "96x96" }]
},
{
"name": "Subscriptions",
"short_name": "Subscriptions",
"description": "Manage recurring expenses",
"url": "/subscriptions",
"icons": [{ "src": "/static/images/fina-logo.png", "sizes": "96x96" }]
}
],
"screenshots": []
}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Login - FINA{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<div style="text-align: center; margin-bottom: 1rem;">
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" style="height: 80px; border-radius: 50%;">
</div>
<h1>FINA</h1>
<p class="subtitle">Login to your account</p>
<form method="POST" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<div class="auth-link">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Register - FINA{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<div style="text-align: center; margin-bottom: 1rem;">
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" style="height: 80px; border-radius: 50%;">
</div>
<h1>FINA</h1>
<p class="subtitle">Create your account</p>
<form method="POST" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="6">
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div class="auth-link">
Already have an account? <a href="{{ url_for('auth.login') }}">Login</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Verify 2FA - Finance Tracker{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<h1>🔐 Two-Factor Authentication</h1>
<p class="subtitle">Enter the code from your authenticator app</p>
<form method="POST" action="{{ url_for('auth.verify_2fa') }}" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="token">Authentication Code</label>
<input type="text" id="token" name="token" required autofocus
pattern="[0-9]{6}" maxlength="6" placeholder="000000"
style="font-size: 1.5rem; text-align: center; letter-spacing: 0.5rem;">
</div>
<button type="submit" class="btn btn-primary">Verify & Login</button>
</form>
<div class="auth-link">
<a href="{{ url_for('auth.login') }}">Back to Login</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,204 @@
{% extends "base.html" %}
{% block title %}{{ _('bank.import_title') }}{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1><i class="fas fa-file-import"></i> {{ _('bank.import_title') }}</h1>
<p class="text-muted">{{ _('bank.import_subtitle') }}</p>
</div>
<div class="card">
<div class="card-header">
<h3>{{ _('bank.upload_file') }}</h3>
</div>
<div class="card-body">
<!-- Upload Form -->
<form method="POST" enctype="multipart/form-data" id="uploadForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="file">{{ _('bank.select_file') }}</label>
<div class="custom-file-upload" id="dropZone">
<input type="file"
id="file"
name="file"
accept=".pdf,.csv"
required>
<div class="upload-placeholder">
<i class="fas fa-cloud-upload-alt fa-3x"></i>
<p>{{ _('bank.drag_drop') }}</p>
<p class="text-muted">{{ _('bank.or_click') }}</p>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('file').click()">
{{ _('bank.browse_files') }}
</button>
</div>
<div class="file-selected" style="display: none;">
<i class="fas fa-file fa-2x"></i>
<p id="fileName"></p>
<button type="button" class="btn btn-sm btn-danger" onclick="clearFile()">
<i class="fas fa-times"></i> {{ _('bank.remove') }}
</button>
</div>
</div>
</div>
<div class="form-group">
<div class="alert alert-info">
<strong><i class="fas fa-info-circle"></i> {{ _('bank.supported_formats') }}:</strong>
<ul>
<li>{{ _('bank.format_pdf') }}</li>
<li>{{ _('bank.format_csv') }}</li>
</ul>
<p class="mb-0"><small>{{ _('bank.format_hint') }}</small></p>
</div>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i> {{ _('bank.not_all_banks_supported') }}
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary btn-block">
<i class="fas fa-upload"></i> {{ _('bank.upload_parse') }}
</button>
</div>
</form>
<!-- Loading State -->
<div id="loadingState" style="display: none;">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">{{ _('bank.processing') }}</span>
</div>
<p class="mt-3">{{ _('bank.processing') }}...</p>
</div>
</div>
</div>
</div>
<!-- Help Section -->
<div class="card mt-4">
<div class="card-header">
<h4><i class="fas fa-question-circle"></i> {{ _('bank.how_it_works') }}</h4>
</div>
<div class="card-body">
<ol>
<li>{{ _('bank.step_1') }}</li>
<li>{{ _('bank.step_2') }}</li>
<li>{{ _('bank.step_3') }}</li>
<li>{{ _('bank.step_4') }}</li>
<li>{{ _('bank.step_5') }}</li>
</ol>
</div>
</div>
</div>
<style>
.custom-file-upload {
position: relative;
border: 2px dashed #007bff;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
background: #f8f9fa;
cursor: pointer;
transition: all 0.3s ease;
}
.custom-file-upload:hover {
border-color: #0056b3;
background: #e9ecef;
}
.custom-file-upload.drag-over {
border-color: #28a745;
background: #d4edda;
}
.custom-file-upload input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.upload-placeholder i {
color: #007bff;
margin-bottom: 15px;
}
.file-selected i {
color: #28a745;
margin-bottom: 15px;
}
@media (max-width: 768px) {
.custom-file-upload {
padding: 30px 15px;
}
}
</style>
<script>
// File selection
document.getElementById('file').addEventListener('change', function(e) {
if (this.files.length > 0) {
document.querySelector('.upload-placeholder').style.display = 'none';
document.querySelector('.file-selected').style.display = 'block';
document.getElementById('fileName').textContent = this.files[0].name;
}
});
// Clear file
function clearFile() {
document.getElementById('file').value = '';
document.querySelector('.upload-placeholder').style.display = 'block';
document.querySelector('.file-selected').style.display = 'none';
}
// Drag and drop
const dropZone = document.getElementById('dropZone');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', function(e) {
const files = e.dataTransfer.files;
if (files.length > 0) {
document.getElementById('file').files = files;
document.querySelector('.upload-placeholder').style.display = 'none';
document.querySelector('.file-selected').style.display = 'block';
document.getElementById('fileName').textContent = files[0].name;
}
});
// Show loading on submit
document.getElementById('uploadForm').addEventListener('submit', function() {
document.querySelector('.card-body').style.display = 'none';
document.getElementById('loadingState').style.display = 'block';
});
</script>
{% endblock %}

View file

@ -0,0 +1,304 @@
{% extends "base.html" %}
{% block title %}{{ _('bank.review_title') }}{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1><i class="fas fa-check-circle"></i> {{ _('bank.review_title') }}</h1>
<p class="text-muted">{{ _('bank.review_subtitle') }}</p>
</div>
<!-- Summary Card -->
<div class="card mb-4">
<div class="card-body">
<div class="row text-center">
<div class="col-md-3">
<h3 class="text-primary">{{ total_found }}</h3>
<p class="text-muted">{{ _('bank.transactions_found') }}</p>
</div>
<div class="col-md-3">
<h3 class="text-info">{{ bank_format }}</h3>
<p class="text-muted">{{ _('bank.detected_format') }}</p>
</div>
<div class="col-md-3">
<h3 class="text-success" id="selectedCount">0</h3>
<p class="text-muted">{{ _('bank.selected') }}</p>
</div>
<div class="col-md-3">
<h3 class="text-warning" id="unmappedCount">{{ total_found }}</h3>
<p class="text-muted">{{ _('bank.unmapped') }}</p>
</div>
</div>
</div>
</div>
<!-- Parse Errors (if any) -->
{% if parse_errors %}
<div class="alert alert-warning">
<strong><i class="fas fa-exclamation-triangle"></i> {{ _('bank.parse_warnings') }}:</strong>
<ul class="mb-0">
{% for error in parse_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Bank Format Warning -->
{% if bank_format in ['generic', 'unknown', 'Generic'] %}
<div class="alert alert-warning">
<strong><i class="fas fa-exclamation-triangle"></i> {{ _('bank.format_not_recognized') }}</strong><br>
{{ _('bank.format_not_recognized_hint') }}
</div>
{% endif %}
<!-- Import Form -->
<form method="POST" action="{{ url_for('main.bank_import_confirm') }}" id="importForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Bulk Actions -->
<div class="card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-6">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="selectAll"
onchange="toggleAll(this)">
<label class="form-check-label" for="selectAll">
{{ _('bank.select_all') }}
</label>
</div>
</div>
<div class="col-md-6 text-right">
<div class="btn-group" role="group">
<button type="button"
class="btn btn-sm btn-secondary"
onclick="selectExpenses()">
<i class="fas fa-arrow-down"></i> {{ _('bank.select_expenses') }}
</button>
<button type="button"
class="btn btn-sm btn-secondary"
onclick="selectIncome()">
<i class="fas fa-arrow-up"></i> {{ _('bank.select_income') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Transactions Table -->
<div class="card">
<div class="card-header">
<h3>{{ _('bank.transactions_to_import') }}</h3>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="thead-dark">
<tr>
<th width="5%">
<input type="checkbox" id="selectAllTable" onchange="toggleAll(this)">
</th>
<th width="10%">{{ _('bank.date') }}</th>
<th width="35%">{{ _('bank.description') }}</th>
<th width="15%">{{ _('bank.amount') }}</th>
<th width="10%">{{ _('bank.type') }}</th>
<th width="25%">{{ _('bank.category') }}</th>
</tr>
</thead>
<tbody>
{% for trans in transactions %}
<tr class="transaction-row {% if trans.amount < 0 %}expense-row{% else %}income-row{% endif %}">
<td>
<input type="checkbox"
name="selected_transactions"
value="{{ loop.index0 }}"
class="transaction-checkbox"
onchange="updateCounts()">
</td>
<td>{{ trans.date.strftime('%Y-%m-%d') }}</td>
<td>
<small>{{ trans.description }}</small>
</td>
<td>
<span class="{% if trans.amount < 0 %}text-danger{% else %}text-success{% endif %}">
{% if trans.amount < 0 %}-{% else %}+{% endif %}
{{ "%.2f"|format(trans.amount|abs) }} {{ _('expense.currency') }}
</span>
</td>
<td>
{% if trans.amount < 0 %}
<span class="badge badge-danger">{{ _('bank.expense') }}</span>
{% else %}
<span class="badge badge-success">{{ _('bank.income') }}</span>
{% endif %}
</td>
<td>
<select name="category_{{ loop.index0 }}"
class="form-control form-control-sm category-select"
onchange="updateCounts()">
<option value="">{{ _('bank.select_category') }}</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-4">
<div class="row">
<div class="col-md-6">
<a href="{{ url_for('main.bank_import') }}" class="btn btn-secondary btn-block">
<i class="fas fa-arrow-left"></i> {{ _('bank.back') }}
</a>
</div>
<div class="col-md-6">
<button type="submit" class="btn btn-primary btn-block" id="submitBtn" disabled>
<i class="fas fa-check"></i> {{ _('bank.import_selected') }}
</button>
</div>
</div>
</div>
</form>
</div>
<style>
.transaction-row {
transition: background-color 0.2s ease;
}
.transaction-row:hover {
background-color: #f8f9fa;
}
.category-select {
min-width: 150px;
}
.expense-row {
border-left: 3px solid #dc3545;
}
.income-row {
border-left: 3px solid #28a745;
}
@media (max-width: 768px) {
.table {
font-size: 0.85rem;
}
.btn-group {
flex-direction: column;
width: 100%;
}
.btn-group .btn {
width: 100%;
margin-bottom: 5px;
}
}
</style>
<script>
// Toggle all checkboxes
function toggleAll(checkbox) {
const checkboxes = document.querySelectorAll('.transaction-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateCounts();
}
// Select expenses only
function selectExpenses() {
const checkboxes = document.querySelectorAll('.expense-row .transaction-checkbox');
checkboxes.forEach(cb => {
cb.checked = true;
});
updateCounts();
}
// Select income only
function selectIncome() {
const checkboxes = document.querySelectorAll('.income-row .transaction-checkbox');
checkboxes.forEach(cb => {
cb.checked = true;
});
updateCounts();
}
// Update counts and enable/disable submit button
function updateCounts() {
const checkboxes = document.querySelectorAll('.transaction-checkbox:checked');
const selectedCount = checkboxes.length;
// Count how many selected transactions have a category
let mappedCount = 0;
checkboxes.forEach(cb => {
const row = cb.closest('tr');
const categorySelect = row.querySelector('.category-select');
if (categorySelect && categorySelect.value) {
mappedCount++;
}
});
const unmappedCount = selectedCount - mappedCount;
// Update UI
document.getElementById('selectedCount').textContent = selectedCount;
document.getElementById('unmappedCount').textContent = unmappedCount;
// Enable submit button only if at least one transaction is selected with a category
document.getElementById('submitBtn').disabled = (mappedCount === 0);
}
// Auto-select checkbox when category is selected
document.querySelectorAll('.category-select').forEach(select => {
select.addEventListener('change', function() {
if (this.value) {
const row = this.closest('tr');
const checkbox = row.querySelector('.transaction-checkbox');
checkbox.checked = true;
}
updateCounts();
});
});
// Confirm before submit
document.getElementById('importForm').addEventListener('submit', function(e) {
const selectedCount = document.querySelectorAll('.transaction-checkbox:checked').length;
if (selectedCount === 0) {
e.preventDefault();
alert('{{ _('bank.no_transactions_selected') }}');
return false;
}
const unmappedCount = parseInt(document.getElementById('unmappedCount').textContent);
if (unmappedCount > 0) {
const msg = '{{ _('bank.confirm_unmapped') }}'.replace('{count}', unmappedCount);
if (!confirm(msg)) {
e.preventDefault();
return false;
}
}
// Show loading state
document.getElementById('submitBtn').disabled = true;
document.getElementById('submitBtn').innerHTML = '<i class="fas fa-spinner fa-spin"></i> {{ _('bank.importing') }}...';
});
// Initial update
updateCounts();
</script>
{% endblock %}

View file

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FINA{% endblock %}</title>
<!-- PWA Meta Tags -->
<meta name="description" content="FINA - Track your expenses, manage categories, and visualize spending patterns">
<meta name="theme-color" content="#5b5fc7">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="FINA">
<meta name="mobile-web-app-capable" content="yes">
<!-- Manifest -->
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/fina-logo.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/fina-logo.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='images/fina-logo.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/fina-logo.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/fina-logo.png') }}">
<!-- Stylesheets -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="glass-nav">
<div class="nav-container">
<a href="{{ url_for('main.dashboard') }}" class="nav-brand">
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" class="nav-logo">
<span>FINA</span>
</a>
<!-- Global Search Bar -->
<div class="nav-search">
<form method="GET" action="{{ url_for('main.search_page') }}" class="nav-search-form">
<input type="text"
name="q"
class="nav-search-input"
placeholder="{{ _('search.quick_search') }}"
autocomplete="off">
<button type="submit" class="nav-search-btn">🔍</button>
</form>
</div>
<div class="nav-links">
<a href="{{ url_for('main.create_category') }}">{{ _('nav.new_category') }}</a>
<a href="{{ url_for('subscriptions.index') }}">{{ _('nav.subscriptions') }}</a>
<a href="{{ url_for('main.bank_import') }}">
<i class="fas fa-file-import"></i> {{ _('bank.import_title') }}
</a>
<a href="{{ url_for('main.predictions') }}">
<i class="fas fa-chart-line"></i> {{ _('predictions.title') }}
</a>
<a href="{{ url_for('settings.index') }}">{{ _('nav.settings') }}</a>
<div class="language-switcher">
<button class="language-btn" onclick="toggleLanguageMenu()">
{% if get_lang() == 'ro' %}🇷🇴{% elif get_lang() == 'es' %}🇪🇸{% else %}🇬🇧{% endif %}
</button>
<div class="language-menu" id="language-menu">
<a href="{{ url_for('language.switch_language', lang='en') }}" class="language-option">🇬🇧 English</a>
<a href="{{ url_for('language.switch_language', lang='ro') }}" class="language-option">🇷🇴 Română</a>
<a href="{{ url_for('language.switch_language', lang='es') }}" class="language-option">🇪🇸 Español</a>
</div>
</div>
<a href="{{ url_for('auth.logout') }}">{{ _('nav.logout') }}</a>
</div>
</div>
</nav>
{% endif %}
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} glass-card">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<!-- PWA Install Prompt -->
<div id="pwa-install-prompt" class="pwa-prompt glass-card" style="display: none;">
<div class="pwa-prompt-content">
<img src="{{ url_for('static', filename='images/fina-logo.png') }}" alt="FINA" class="pwa-icon">
<div class="pwa-prompt-text">
<h3>{{ _('pwa.install_title') }}</h3>
<p>{{ _('pwa.install_description') }}</p>
</div>
<div class="pwa-prompt-actions">
<button id="pwa-install-btn" class="btn btn-primary">{{ _('pwa.install') }}</button>
<button id="pwa-dismiss-btn" class="btn btn-secondary">{{ _('pwa.not_now') }}</button>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Create Category - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>{{ _('category.create') }}</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="name">{{ _('category.name') }} *</label>
<input type="text" id="name" name="name" required autofocus>
</div>
<div class="form-group">
<label for="description">{{ _('category.description') }}</label>
<textarea id="description" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="color">{{ _('category.color') }}</label>
<input type="color" id="color" name="color" value="#6366f1">
</div>
<div class="form-actions">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
<button type="submit" class="btn btn-primary">{{ _('category.create') }}</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,424 @@
{% extends "base.html" %}
{% block title %}Add Expense - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>💸 Add Expense to {{ category.name }}</h1>
<form method="POST" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="description">Description *</label>
<input type="text" id="description" name="description" required autofocus placeholder="e.g., Grocery shopping">
</div>
<div class="form-group">
<label for="amount">Amount *</label>
<input type="number" id="amount" name="amount" step="0.01" min="0.01" required placeholder="0.00">
</div>
<div class="form-group">
<label for="date">Date *</label>
<input type="date" id="date" name="date" value="{{ today }}" required>
</div>
<div class="form-group">
<label for="paid_by">Paid By</label>
<input type="text" id="paid_by" name="paid_by" placeholder="e.g., John, Cash, Credit Card">
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input type="text" id="tags" name="tags" placeholder="Type or click tags below">
{% if user_tags %}
<div class="tag-suggestions">
{% for tag in user_tags %}
<button type="button" class="tag-btn" data-tag="{{ tag.name }}" style="background: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }};">
🏷️ {{ tag.name }}
</button>
{% endfor %}
</div>
<small>Click tags to add them. Multiple tags can be separated by commas.</small>
{% else %}
<small>No tags yet. <a href="{{ url_for('settings.create_tag') }}" style="color: #a5b4fc;">Create tags in Settings</a></small>
{% endif %}
</div>
<div class="form-group">
<label for="file">{{ _('expense.receipt') }}</label>
<div class="receipt-upload-container">
<!-- Camera button for mobile -->
<button type="button" class="btn btn-camera" id="cameraBtn" onclick="openCamera()">
📸 {{ _('ocr.take_photo') }}
</button>
<!-- File input -->
<input type="file" id="file" name="file" accept="image/*,.pdf" capture="environment">
<!-- OCR processing indicator -->
<div id="ocrProcessing" class="ocr-processing" style="display: none;">
<div class="spinner"></div>
<span>{{ _('ocr.processing') }}</span>
</div>
<!-- OCR results -->
<div id="ocrResults" class="ocr-results" style="display: none;"></div>
</div>
<small>{{ _('expense.receipt_hint') }} | 🤖 {{ _('ocr.ai_extraction') }}</small>
</div>
<div class="form-actions">
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
<button type="submit" class="btn btn-primary">💾 {{ _('expense.create') }}</button>
</div>
</form>
</div>
</div>
<style>
.tag-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.tag-btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.1);
}
.tag-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.tag-btn.active {
opacity: 0.6;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tagsInput = document.getElementById('tags');
const tagButtons = document.querySelectorAll('.tag-btn');
tagButtons.forEach(button => {
button.addEventListener('click', function() {
const tagName = this.getAttribute('data-tag');
const currentTags = tagsInput.value;
// Check if tag already exists
const tagsArray = currentTags.split(',').map(t => t.trim()).filter(t => t);
if (tagsArray.includes(tagName)) {
// Remove tag
const newTags = tagsArray.filter(t => t !== tagName);
tagsInput.value = newTags.join(', ');
this.classList.remove('active');
} else {
// Add tag
if (currentTags.trim()) {
tagsInput.value = currentTags.trim() + ', ' + tagName;
} else {
tagsInput.value = tagName;
}
this.classList.add('active');
}
});
});
// Update button states based on input
tagsInput.addEventListener('input', function() {
const tagsArray = this.value.split(',').map(t => t.trim()).filter(t => t);
tagButtons.forEach(button => {
const tagName = button.getAttribute('data-tag');
if (tagsArray.includes(tagName)) {
button.classList.add('active');
} else {
button.classList.remove('active');
}
});
});
// OCR functionality
const fileInput = document.getElementById('file');
const ocrProcessing = document.getElementById('ocrProcessing');
const ocrResults = document.getElementById('ocrResults');
const amountInput = document.getElementById('amount');
const descriptionInput = document.getElementById('description');
const dateInput = document.getElementById('date');
if (fileInput) {
fileInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
// Only process images for OCR
if (!file.type.startsWith('image/')) return;
// Show processing indicator
ocrProcessing.style.display = 'flex';
ocrResults.style.display = 'none';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/ocr/process', {
method: 'POST',
body: formData
});
const data = await response.json();
ocrProcessing.style.display = 'none';
if (data.success) {
displayOCRResults(data);
} else {
ocrResults.innerHTML = `<div class="ocr-error">❌ ${data.error || 'OCR failed'}</div>`;
ocrResults.style.display = 'block';
}
} catch (error) {
ocrProcessing.style.display = 'none';
ocrResults.innerHTML = `<div class="ocr-error">❌ Error: ${error.message}</div>`;
ocrResults.style.display = 'block';
}
});
}
function displayOCRResults(data) {
const confidenceClass = data.confidence === 'high' ? 'success' :
data.confidence === 'medium' ? 'warning' : 'low';
let html = `<div class="ocr-result-box ${confidenceClass}">`;
html += `<div class="ocr-header">🤖 AI Detected</div>`;
if (data.amount) {
html += `<div class="ocr-field">
<strong>Amount:</strong> ${data.amount.toFixed(2)}
<button type="button" class="btn-apply" onclick="applyOCRAmount(${data.amount})">Use This</button>
</div>`;
}
if (data.merchant) {
html += `<div class="ocr-field">
<strong>Merchant:</strong> ${data.merchant}
<button type="button" class="btn-apply" onclick="applyOCRMerchant('${escapeHtml(data.merchant)}')">Use This</button>
</div>`;
}
if (data.date) {
html += `<div class="ocr-field">
<strong>Date:</strong> ${data.date}
<button type="button" class="btn-apply" onclick="applyOCRDate('${data.date}')">Use This</button>
</div>`;
}
const confidenceIcons = {
'high': '✅',
'medium': '⚠️',
'low': '❌',
'none': '❌'
};
html += `<div class="ocr-confidence">${confidenceIcons[data.confidence]} Confidence: ${data.confidence.toUpperCase()}</div>`;
html += `</div>`;
ocrResults.innerHTML = html;
ocrResults.style.display = 'block';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
});
// Camera functionality
function openCamera() {
const fileInput = document.getElementById('file');
if (fileInput) fileInput.click();
}
// Apply OCR results
function applyOCRAmount(amount) {
const input = document.getElementById('amount');
if (input) {
input.value = amount.toFixed(2);
input.focus();
}
}
function applyOCRMerchant(merchant) {
const input = document.getElementById('description');
if (input) {
input.value = merchant;
input.focus();
}
}
function applyOCRDate(date) {
const input = document.getElementById('date');
if (input) {
input.value = date;
input.focus();
}
}
</script>
<style>
/* Receipt upload styles */
.receipt-upload-container {
margin-top: 0.5rem;
}
.btn-camera {
display: block;
width: 100%;
margin-bottom: 0.5rem;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: transform 0.2s;
}
.btn-camera:hover {
transform: translateY(-2px);
}
.ocr-processing {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
margin-top: 0.5rem;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ocr-results {
margin-top: 0.5rem;
}
.ocr-result-box {
padding: 1rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ocr-result-box.success {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.1);
}
.ocr-result-box.warning {
border-color: rgba(251, 191, 36, 0.5);
background: rgba(251, 191, 36, 0.1);
}
.ocr-result-box.low {
border-color: rgba(239, 68, 68, 0.5);
background: rgba(239, 68, 68, 0.1);
}
.ocr-header {
font-weight: 600;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.ocr-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.ocr-field:last-of-type {
border-bottom: none;
}
.btn-apply {
padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-apply:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.ocr-confidence {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.85rem;
text-align: center;
}
.ocr-error {
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.5);
border-radius: 8px;
color: #fca5a5;
}
/* Mobile optimization */
@media (max-width: 768px) {
.btn-camera {
font-size: 1rem;
padding: 1rem;
}
.ocr-field {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.btn-apply {
width: 100%;
text-align: center;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,320 @@
{% extends "base.html" %}
{% block title %}Dashboard - FINA{% endblock %}
{% block content %}
<div class="dashboard">
{% if categories and categories|length > 0 %}
<div class="metrics-section glass-card" style="margin-bottom: 2rem; margin-top: 0;">
<div class="metrics-header">
<h2>📈 {{ _('dashboard.metrics') }}</h2>
<div class="metrics-controls">
<select id="metricCategory" class="metric-select">
<option value="all">{{ _('dashboard.all_categories') }}</option>
{% for cat in categories %}
<option value="{{ cat.id }}" data-color="{{ cat.color }}">{{ cat.name }}</option>
{% endfor %}
</select>
<select id="metricYear" class="metric-select">
{% for year in available_years %}
<option value="{{ year }}" {% if year == current_year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="charts-container">
<div class="chart-box chart-box-pie">
<h3>{{ _('dashboard.expenses_by_category') }}</h3>
<div class="chart-wrapper">
<canvas id="pieChart"></canvas>
</div>
</div>
<div class="chart-box chart-box-bar">
<h3>{{ _('dashboard.monthly_expenses') }}</h3>
<div class="chart-wrapper">
<canvas id="barChart"></canvas>
</div>
</div>
</div>
</div>
<div class="stats-container">
<div class="glass-card stat-card">
<h3>{{ _('dashboard.total_spent') }}</h3>
<p class="stat-value">{{ total_spent|currency }}</p>
</div>
<div class="glass-card stat-card">
<h3>{{ _('dashboard.categories_section') }}</h3>
<p class="stat-value">{{ (categories|default([]))|length }}</p>
</div>
<div class="glass-card stat-card">
<h3>{{ _('dashboard.total_expenses') }}</h3>
<p class="stat-value">{{ total_expenses }}</p>
</div>
</div>
<div class="section-header" style="margin-top: 2rem;">
<h2>📁 {{ _('dashboard.categories_section') }}</h2>
</div>
<div class="categories-grid">
{% for category in categories %}
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="category-card glass-card" style="border-left: 4px solid {{ category.color }}">
<div class="category-content">
<h3>{{ category.name }}</h3>
{% if category.description %}
<p class="category-description">{{ category.description }}</p>
{% endif %}
<div class="category-amount">{{ category.get_total_spent()|currency }}</div>
<div class="category-info">
<span>{{ category.expenses|length }} {{ _('category.expenses') | lower }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="stats-container">
<div class="glass-card stat-card">
<h3>{{ _('dashboard.total_spent') }}</h3>
<p class="stat-value">{{ total_spent|currency }}</p>
</div>
<div class="glass-card stat-card">
<h3>{{ _('category.expenses') }}</h3>
<p class="stat-value">{{ (categories|default([]))|length }}</p>
</div>
<div class="glass-card stat-card">
<h3>{{ _('dashboard.total_expenses') }}</h3>
<p class="stat-value">{{ total_expenses }}</p>
</div>
</div>
<div class="glass-card empty-state">
<h2>{{ _('empty.welcome_title') }}</h2>
<p>{{ _('empty.welcome_message') }}</p>
<a href="{{ url_for('main.create_category') }}" class="btn btn-primary">{{ _('empty.create_category') }}</a>
</div>
{% endif %}
<!-- Upcoming Subscriptions Widget -->
{% if upcoming_subscriptions or suggestions_count > 0 %}
<div class="glass-card" style="margin-top: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2>🔄 {{ _('subscription.title') }}</h2>
<a href="{{ url_for('subscriptions.index') }}" class="btn btn-secondary btn-sm">{{ _('dashboard.view_all') }}</a>
</div>
{% if suggestions_count > 0 %}
<div class="alert" style="background: rgba(245, 158, 11, 0.2); border: 1px solid #f59e0b; margin-bottom: 1rem; position: relative;">
💡 <strong>{{ suggestions_count }}</strong> {{ _('subscription.suggestions') | lower }} -
<a href="{{ url_for('subscriptions.index') }}" style="color: #fbbf24; text-decoration: underline;">{{ _('common.view') }}</a>
</div>
{% endif %}
{% if upcoming_subscriptions %}
<div class="subscriptions-widget">
{% for sub in upcoming_subscriptions %}
<div class="subscription-widget-item" style="display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--glass-border);">
<div>
<strong>{{ sub.name }}</strong>
<br>
<small style="color: var(--text-secondary);">
{{ sub.amount|currency }} -
{% if sub.next_due_date %}
{% set days_until = (sub.next_due_date - today).days %}
{% if days_until == 0 %}
{{ _('subscription.today') }}
{% elif days_until == 1 %}
{{ _('subscription.tomorrow') }}
{% elif days_until < 7 %}
in {{ days_until }} {{ _('subscription.days') }}
{% else %}
{{ sub.next_due_date.strftime('%b %d') }}
{% endif %}
{% endif %}
</small>
</div>
<span style="font-weight: 600;">{{ sub.amount|currency }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p style="color: var(--text-secondary); text-align: center; padding: 1rem;">
{{ _('subscription.no_upcoming') }}
</p>
{% endif %}
</div>
{% endif %}
</div>
<style>
.charts-container {
display: grid;
grid-template-columns: 400px 1fr;
gap: 2rem;
margin-top: 1.5rem;
}
.chart-box-pie {
display: flex;
flex-direction: column;
}
.chart-box-bar {
display: flex;
flex-direction: column;
}
.chart-wrapper {
position: relative;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.chart-box-pie .chart-wrapper {
width: 400px;
height: 400px;
}
@media (max-width: 1024px) {
.charts-container {
grid-template-columns: 1fr;
}
.chart-box-pie .chart-wrapper {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
const chartData = {{ chart_data|tojson }};
const categories = {{ categories_json|tojson }};
const currentYear = {{ current_year }};
const currencySymbol = '{{ current_user.currency }}';
let pieChart, barChart;
function initCharts() {
const pieCtx = document.getElementById('pieChart').getContext('2d');
pieChart = new Chart(pieCtx, {
type: 'pie',
data: {
labels: chartData.map(d => d.name),
datasets: [{
data: chartData.map(d => d.value),
backgroundColor: chartData.map(d => d.color),
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#ffffff', padding: 15, font: { size: 12 } }
},
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + formatCurrency(context.parsed);
}
}
}
}
}
});
const barCtx = document.getElementById('barChart').getContext('2d');
barChart = new Chart(barCtx, {
type: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
datasets: []
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2,
scales: {
y: { beginAtZero: true, ticks: { color: '#ffffff' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } },
x: { ticks: { color: '#ffffff' }, grid: { color: 'rgba(255, 255, 255, 0.1)' } }
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return formatCurrency(context.parsed.y);
}
}
}
}
}
});
updateCharts('all', currentYear);
}
function formatCurrency(amount) {
const formatted = amount.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
if (currencySymbol === 'RON') {
return formatted + ' Lei';
} else if (currencySymbol === 'EUR') {
return '€' + formatted;
} else if (currencySymbol === 'GBP') {
return '£' + formatted;
} else {
return '$' + formatted;
}
}
function updateCharts(categoryId, year) {
fetch(`/api/metrics?category=${categoryId}&year=${year}`)
.then(response => response.json())
.then(data => {
barChart.data.datasets = [{
label: data.category_name,
data: data.monthly_data,
backgroundColor: data.color || '#6366f1',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1
}];
barChart.update();
if (categoryId === 'all') {
pieChart.data.labels = data.pie_labels;
pieChart.data.datasets[0].data = data.pie_data;
pieChart.data.datasets[0].backgroundColor = data.pie_colors;
pieChart.update();
}
});
}
document.getElementById('metricCategory').addEventListener('change', function() {
updateCharts(this.value, document.getElementById('metricYear').value);
});
document.getElementById('metricYear').addEventListener('change', function() {
updateCharts(document.getElementById('metricCategory').value, this.value);
});
document.addEventListener('DOMContentLoaded', initCharts);
</script>
{% endblock %}

View file

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Edit {{ category.name }} - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>✏️ Edit Category</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="name">Category Name *</label>
<input type="text" id="name" name="name" value="{{ category.name }}" required autofocus>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="3">{{ category.description or '' }}</textarea>
</div>
<div class="form-group">
<label for="color">Color</label>
<input type="color" id="color" name="color" value="{{ category.color }}">
</div>
<hr style="margin: 2rem 0; border: none; border-top: 1px solid var(--glass-border);">
<h3 style="margin-bottom: 1rem;">💰 Budget Settings</h3>
<div class="form-group">
<label for="monthly_budget">{{ _('budget.monthly_limit') }} ({{ _('common.optional') }})</label>
<input type="number" id="monthly_budget" name="monthly_budget" step="0.01" min="0"
value="{{ category.monthly_budget if category.monthly_budget else '' }}"
placeholder="e.g., 500">
<small style="color: var(--text-secondary);">{{ _('budget.monthly_limit_desc') }}</small>
</div>
<div class="form-group">
<label for="budget_alert_threshold">{{ _('budget.alert_threshold') }} (%)</label>
<input type="number" id="budget_alert_threshold" name="budget_alert_threshold"
min="50" max="200" step="5"
value="{{ (category.budget_alert_threshold * 100)|int if category.budget_alert_threshold else 100 }}">
<small style="color: var(--text-secondary);">{{ _('budget.alert_threshold_desc') }}</small>
</div>
{% if category.monthly_budget %}
<div style="background: rgba(99, 102, 241, 0.1); padding: 1rem; border-radius: 8px; margin-top: 1rem;">
{% set status = category.get_budget_status() %}
<p style="margin: 0; font-size: 0.9rem;">
<strong>{{ _('budget.current_month') }}:</strong><br>
{{ _('budget.spent') }}: {{ status.spent|currency }}<br>
{{ _('budget.budget') }}: {{ status.budget|currency }}<br>
<span style="color: {% if status.over_budget %}#ef4444{% else %}#10b981{% endif %};">
{{ _('budget.remaining') }}: {{ status.remaining|currency }}
</span>
</p>
</div>
{% endif %}
<div class="form-actions">
<a href="{{ url_for('main.view_category', category_id=category.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,408 @@
{% extends "base.html" %}
{% block title %}Edit Expense - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>✏️ Edit Expense</h1>
<form method="POST" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="description">Description *</label>
<input type="text" id="description" name="description" value="{{ expense.description }}" required>
</div>
<div class="form-group">
<label for="amount">Amount *</label>
<input type="number" id="amount" name="amount" step="0.01" min="0.01" value="{{ expense.amount }}" required>
</div>
<div class="form-group">
<label for="date">Date *</label>
<input type="date" id="date" name="date" value="{{ expense.date.strftime('%Y-%m-%d') }}" required>
</div>
<div class="form-group">
<label for="paid_by">Paid By</label>
<input type="text" id="paid_by" name="paid_by" value="{{ expense.paid_by or '' }}">
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input type="text" id="tags" name="tags" value="{{ expense.tags or '' }}" placeholder="Type or click tags below">
{% if user_tags %}
<div class="tag-suggestions">
{% for tag in user_tags %}
<button type="button" class="tag-btn" data-tag="{{ tag.name }}" style="background: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }};">
🏷️ {{ tag.name }}
</button>
{% endfor %}
</div>
<small>Click tags to add/remove them. Multiple tags can be separated by commas.</small>
{% else %}
<small>No tags yet. <a href="{{ url_for('settings.create_tag') }}" style="color: #a5b4fc;">Create tags in Settings</a></small>
{% endif %}
</div>
<div class="form-group">
<label for="file">{{ _('expense.receipt') }}</label>
{% if expense.file_path %}
<p style="color: var(--text-secondary); margin-bottom: 0.5rem;">
Current: <a href="{{ url_for('main.download_file', expense_id=expense.id) }}" style="color: #a5b4fc;">{{ _('common.download') }}</a>
</p>
{% endif %}
<div class="receipt-upload-container">
<button type="button" class="btn-camera" onclick="openCamera()">
📸 {{ _('ocr.take_photo') }}
</button>
<input type="file" id="file" name="file" accept="image/*,.pdf" capture="environment">
<div id="ocrProcessing" class="ocr-processing" style="display: none;">
<div class="spinner"></div>
<span>{{ _('ocr.processing') }}</span>
</div>
<div id="ocrResults" class="ocr-results" style="display: none;"></div>
</div>
<small>{{ _('expense.receipt_hint') }} | 🤖 {{ _('ocr.ai_extraction') }}</small>
</div>
<div class="form-actions">
<a href="{{ url_for('main.view_category', category_id=expense.category_id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
</div>
</form>
</div>
</div>
<style>
.tag-suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.tag-btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.1);
}
.tag-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.tag-btn.active {
opacity: 0.6;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tagsInput = document.getElementById('tags');
const tagButtons = document.querySelectorAll('.tag-btn');
// Initialize active states based on existing tags
const existingTags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
tagButtons.forEach(button => {
const tagName = button.getAttribute('data-tag');
if (existingTags.includes(tagName)) {
button.classList.add('active');
}
});
tagButtons.forEach(button => {
button.addEventListener('click', function() {
const tagName = this.getAttribute('data-tag');
const currentTags = tagsInput.value;
const tagsArray = currentTags.split(',').map(t => t.trim()).filter(t => t);
if (tagsArray.includes(tagName)) {
const newTags = tagsArray.filter(t => t !== tagName);
tagsInput.value = newTags.join(', ');
this.classList.remove('active');
} else {
if (currentTags.trim()) {
tagsInput.value = currentTags.trim() + ', ' + tagName;
} else {
tagsInput.value = tagName;
}
this.classList.add('active');
}
});
});
tagsInput.addEventListener('input', function() {
const tagsArray = this.value.split(',').map(t => t.trim()).filter(t => t);
tagButtons.forEach(button => {
const tagName = button.getAttribute('data-tag');
if (tagsArray.includes(tagName)) {
button.classList.add('active');
} else {
button.classList.remove('active');
}
});
});
// OCR functionality (same as create form)
const fileInput = document.getElementById('file');
const ocrProcessing = document.getElementById('ocrProcessing');
const ocrResults = document.getElementById('ocrResults');
if (fileInput) {
fileInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file || !file.type.startsWith('image/')) return;
ocrProcessing.style.display = 'flex';
ocrResults.style.display = 'none';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/ocr/process', {
method: 'POST',
body: formData
});
const data = await response.json();
ocrProcessing.style.display = 'none';
if (data.success) {
displayOCRResults(data);
} else {
ocrResults.innerHTML = `<div class="ocr-error">❌ ${data.error || 'OCR failed'}</div>`;
ocrResults.style.display = 'block';
}
} catch (error) {
ocrProcessing.style.display = 'none';
ocrResults.innerHTML = `<div class="ocr-error">❌ Error: ${error.message}</div>`;
ocrResults.style.display = 'block';
}
});
}
function displayOCRResults(data) {
const confidenceClass = data.confidence === 'high' ? 'success' :
data.confidence === 'medium' ? 'warning' : 'low';
let html = `<div class="ocr-result-box ${confidenceClass}">`;
html += `<div class="ocr-header">🤖 AI Detected</div>`;
if (data.amount) {
html += `<div class="ocr-field">
<strong>Amount:</strong> ${data.amount.toFixed(2)}
<button type="button" class="btn-apply" onclick="applyOCRAmount(${data.amount})">Use This</button>
</div>`;
}
if (data.merchant) {
html += `<div class="ocr-field">
<strong>Merchant:</strong> ${data.merchant}
<button type="button" class="btn-apply" onclick="applyOCRMerchant('${escapeHtml(data.merchant)}')">Use This</button>
</div>`;
}
if (data.date) {
html += `<div class="ocr-field">
<strong>Date:</strong> ${data.date}
<button type="button" class="btn-apply" onclick="applyOCRDate('${data.date}')">Use This</button>
</div>`;
}
html += `<div class="ocr-confidence">${data.confidence === 'high' ? '✅' : data.confidence === 'medium' ? '⚠️' : '❌'} Confidence: ${data.confidence.toUpperCase()}</div>`;
html += `</div>`;
ocrResults.innerHTML = html;
ocrResults.style.display = 'block';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
});
function openCamera() {
const fileInput = document.getElementById('file');
if (fileInput) fileInput.click();
}
function applyOCRAmount(amount) {
const input = document.getElementById('amount');
if (input) {
input.value = amount.toFixed(2);
input.focus();
}
}
function applyOCRMerchant(merchant) {
const input = document.getElementById('description');
if (input) {
input.value = merchant;
input.focus();
}
}
function applyOCRDate(date) {
const input = document.getElementById('date');
if (input) {
input.value = date;
input.focus();
}
}
</script>
<style>
/* Receipt OCR styles (same as create form) */
.receipt-upload-container {
margin-top: 0.5rem;
}
.btn-camera {
display: block;
width: 100%;
margin-bottom: 0.5rem;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: transform 0.2s;
}
.btn-camera:hover {
transform: translateY(-2px);
}
.ocr-processing {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
margin-top: 0.5rem;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ocr-results {
margin-top: 0.5rem;
}
.ocr-result-box {
padding: 1rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ocr-result-box.success {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.1);
}
.ocr-result-box.warning {
border-color: rgba(251, 191, 36, 0.5);
background: rgba(251, 191, 36, 0.1);
}
.ocr-result-box.low {
border-color: rgba(239, 68, 68, 0.5);
background: rgba(239, 68, 68, 0.1);
}
.ocr-header {
font-weight: 600;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.ocr-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.ocr-field:last-of-type {
border-bottom: none;
}
.btn-apply {
padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-apply:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.ocr-confidence {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.85rem;
text-align: center;
}
.ocr-error {
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.5);
border-radius: 8px;
color: #fca5a5;
}
@media (max-width: 768px) {
.btn-camera {
font-size: 1rem;
padding: 1rem;
}
.ocr-field {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.btn-apply {
width: 100%;
text-align: center;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Login - Finance Tracker{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<h1>{{ _('auth.welcome_back') }}</h1>
<p class="subtitle">{{ _('auth.login') }}</p>
<form method="POST" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">{{ _('auth.username') }}</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">{{ _('auth.password') }}</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">{{ _('auth.sign_in') }}</button>
</form>
<p class="auth-link">{{ _('auth.no_account') }} <a href="{{ url_for('auth.register') }}">{{ _('auth.sign_up') }}</a></p>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,355 @@
{% extends "base.html" %}
{% block title %}{{ _('predictions.title') }} - FINA{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-2">
<i class="fas fa-chart-line me-2"></i>
{{ _('predictions.title') }}
</h1>
<p class="text-muted">{{ _('predictions.subtitle') }}</p>
</div>
</div>
{% if predictions.total_months < 3 %}
<!-- Not enough data warning -->
<div class="row">
<div class="col-12">
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>
{{ _('predictions.no_data') }}
</h5>
<p class="mb-0">{{ _('predictions.no_data_desc') }}</p>
</div>
</div>
</div>
{% else %}
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">{{ _('predictions.total_predicted') }}</h6>
<h3 class="mb-0">{{ predictions.total.amount|round(2) }} RON</h3>
<small class="text-muted">
{{ _('predictions.based_on', n=predictions.total_months) }}
</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">{{ _('predictions.confidence') }}</h6>
<h3 class="mb-0">
{% if predictions.total.confidence == 'high' %}
<span class="badge bg-success">{{ _('predictions.confidence_high') }}</span>
{% elif predictions.total.confidence == 'medium' %}
<span class="badge bg-warning">{{ _('predictions.confidence_medium') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('predictions.confidence_low') }}</span>
{% endif %}
</h3>
<small class="text-muted">{{ predictions.total.months_of_data }} {{ _('predictions.month') }}</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">{{ _('predictions.trend') }}</h6>
<h3 class="mb-0">
{% if predictions.total.trend == 'increasing' %}
<i class="fas fa-arrow-up text-danger"></i>
{{ _('predictions.trend_increasing') }}
{% elif predictions.total.trend == 'decreasing' %}
<i class="fas fa-arrow-down text-success"></i>
{{ _('predictions.trend_decreasing') }}
{% else %}
<i class="fas fa-minus text-info"></i>
{{ _('predictions.trend_stable') }}
{% endif %}
</h3>
</div>
</div>
</div>
</div>
<!-- Smart Insights -->
{% if insights %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-lightbulb me-2"></i>
{{ _('predictions.insights') }}
</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
{% for insight in insights %}
<li class="mb-2">
<i class="fas fa-check-circle text-success me-2"></i>
{{ insight }}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Predictions Chart -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ _('predictions.forecast') }}</h5>
</div>
<div class="card-body">
<canvas id="predictionsChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<!-- Category Breakdown -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ _('predictions.by_category') }}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{{ _('common.category') }}</th>
<th>{{ _('predictions.amount') }}</th>
<th>{{ _('predictions.confidence') }}</th>
<th>{{ _('predictions.trend') }}</th>
<th>{{ _('common.actions') }}</th>
</tr>
</thead>
<tbody>
{% for category_name, prediction in predictions.by_category.items() %}
<tr>
<td>
<i class="fas fa-tag me-2"></i>
{{ category_name }}
</td>
<td>
<strong>{{ prediction.predicted_amount|round(2) }} RON</strong>
</td>
<td>
{% if prediction.confidence == 'high' %}
<span class="badge bg-success">{{ _('predictions.confidence_high') }}</span>
{% elif prediction.confidence == 'medium' %}
<span class="badge bg-warning">{{ _('predictions.confidence_medium') }}</span>
{% else %}
<span class="badge bg-secondary">{{ _('predictions.confidence_low') }}</span>
{% endif %}
</td>
<td>
{% if prediction.trend == 'increasing' %}
<i class="fas fa-arrow-up text-danger"></i>
{{ _('predictions.trend_increasing') }}
{% elif prediction.trend == 'decreasing' %}
<i class="fas fa-arrow-down text-success"></i>
{{ _('predictions.trend_decreasing') }}
{% else %}
<i class="fas fa-minus text-info"></i>
{{ _('predictions.trend_stable') }}
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="showCategoryForecast({{ prediction.category_id }}, '{{ category_name }}')">
{{ _('predictions.view_details') }}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Methodology Info -->
<div class="row">
<div class="col-12">
<div class="card bg-light">
<div class="card-body">
<h6 class="mb-2">
<i class="fas fa-info-circle me-2"></i>
{{ _('predictions.methodology') }}
</h6>
<p class="mb-0 text-muted small">
{{ _('predictions.methodology_desc') }}
</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Category Forecast Modal -->
<div class="modal fade" id="categoryForecastModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="categoryForecastTitle"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<canvas id="categoryForecastChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<script>
// Predictions data
const predictionsData = {{ predictions|tojson }};
// Main predictions chart
const ctx = document.getElementById('predictionsChart');
if (ctx && predictionsData.by_category) {
const categories = Object.keys(predictionsData.by_category);
const amounts = categories.map(cat => predictionsData.by_category[cat].predicted_amount);
const colors = [
'rgba(255, 99, 132, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
'rgba(75, 192, 192, 0.7)',
'rgba(153, 102, 255, 0.7)',
'rgba(255, 159, 64, 0.7)',
'rgba(201, 203, 207, 0.7)'
];
new Chart(ctx, {
type: 'bar',
data: {
labels: categories,
datasets: [{
label: '{{ _("predictions.total_predicted") }}',
data: amounts,
backgroundColor: colors.slice(0, categories.length),
borderColor: colors.slice(0, categories.length).map(c => c.replace('0.7', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return context.parsed.y.toFixed(2) + ' RON';
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value + ' RON';
}
}
}
}
}
});
}
// Show category forecast
async function showCategoryForecast(categoryId, categoryName) {
try {
const response = await fetch(`/api/predictions/category/${categoryId}`);
const data = await response.json();
if (data.error) {
alert(data.error);
return;
}
// Update modal title
document.getElementById('categoryForecastTitle').textContent =
'{{ _("predictions.forecast") }}: ' + categoryName;
// Create chart
const modalCtx = document.getElementById('categoryForecastChart');
// Destroy existing chart if any
if (window.categoryChart) {
window.categoryChart.destroy();
}
window.categoryChart = new Chart(modalCtx, {
type: 'line',
data: {
labels: data.forecast.map(f => f.month),
datasets: [{
label: '{{ _("predictions.amount") }}',
data: data.forecast.map(f => f.amount),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return context.parsed.y.toFixed(2) + ' RON';
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value + ' RON';
}
}
}
}
}
});
// Show modal
new bootstrap.Modal(document.getElementById('categoryForecastModal')).show();
} catch (error) {
console.error('Error fetching forecast:', error);
alert('{{ _("common.error") }}');
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Register - Finance Tracker{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<h1>{{ _('auth.create_account') }}</h1>
<p class="subtitle">{{ _('auth.register') }}</p>
<form method="POST" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">{{ _('auth.username') }}</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="email">{{ _('auth.email') }}</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">{{ _('auth.password') }}</label>
<input type="password" id="password" name="password" required minlength="8">
<small>At least 8 characters</small>
</div>
<div class="form-group">
<label for="confirm_password">{{ _('auth.confirm_password') }}</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary">{{ _('auth.sign_up') }}</button>
</form>
<p class="auth-link">{{ _('auth.have_account') }} <a href="{{ url_for('auth.login') }}">{{ _('auth.sign_in') }}</a></p>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,592 @@
{% extends "base.html" %}
{% block title %}{{ _('search.title') }} - Finance Tracker{% endblock %}
{% block content %}
<div class="search-page">
<div class="glass-card">
<div class="search-header">
<h1>🔍 {{ _('search.title') }}</h1>
<p class="search-subtitle">{{ _('search.subtitle') }}</p>
</div>
<!-- Search Form -->
<form method="GET" action="{{ url_for('main.search_page') }}" class="search-form">
<div class="search-input-wrapper">
<input type="text"
name="q"
id="searchInput"
value="{{ query }}"
placeholder="{{ _('search.placeholder') }}"
autocomplete="off"
autofocus>
<button type="submit" class="btn-search">
<span class="desktop-text">{{ _('search.button') }}</span>
<span class="mobile-icon">🔍</span>
</button>
</div>
<div id="searchSuggestions" class="search-suggestions"></div>
</form>
{% if results %}
<div class="search-results">
<div class="results-summary">
<h2>{{ _('search.results_for') }} "<strong>{{ query }}</strong>"</h2>
<p class="results-count">{{ results.total }} {{ _('search.results_found') }}</p>
</div>
<!-- Expenses Results -->
{% if results.expenses %}
<div class="result-section">
<h3 class="section-title">💸 {{ _('search.expenses') }} ({{ results.expenses|length }})</h3>
<div class="result-list">
{% for expense in results.expenses %}
<a href="{{ expense.url }}" class="result-item">
<div class="result-icon" style="background: {{ expense.category_color }}20; color: {{ expense.category_color }};">
💸
</div>
<div class="result-content">
<div class="result-title">{{ expense.description }}</div>
<div class="result-meta">
<span class="meta-badge" style="background: {{ expense.category_color }}20; color: {{ expense.category_color }};">
{{ expense.category_name }}
</span>
<span>{{ expense.date }}</span>
{% if expense.paid_by %}
<span>👤 {{ expense.paid_by }}</span>
{% endif %}
{% if expense.tags %}
<span>🏷️ {{ expense.tags }}</span>
{% endif %}
{% if expense.has_receipt %}
<span>📎</span>
{% endif %}
</div>
</div>
<div class="result-amount">{{ expense.amount|currency }}</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Categories Results -->
{% if results.categories %}
<div class="result-section">
<h3 class="section-title">📁 {{ _('search.categories') }} ({{ results.categories|length }})</h3>
<div class="result-list">
{% for category in results.categories %}
<a href="{{ category.url }}" class="result-item">
<div class="result-icon" style="background: {{ category.color }}20; color: {{ category.color }};">
📁
</div>
<div class="result-content">
<div class="result-title">{{ category.name }}</div>
<div class="result-meta">
{% if category.description %}
<span>{{ category.description[:50] }}{% if category.description|length > 50 %}...{% endif %}</span>
{% endif %}
<span>{{ category.expense_count }} {{ _('search.expenses_count') }}</span>
</div>
</div>
<div class="result-amount">{{ category.total_spent|currency }}</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Subscriptions Results -->
{% if results.subscriptions %}
<div class="result-section">
<h3 class="section-title">🔄 {{ _('search.subscriptions') }} ({{ results.subscriptions|length }})</h3>
<div class="result-list">
{% for sub in results.subscriptions %}
<a href="{{ sub.url }}" class="result-item">
<div class="result-icon" style="background: #10b98120; color: #10b981;">
🔄
</div>
<div class="result-content">
<div class="result-title">{{ sub.name }}</div>
<div class="result-meta">
<span class="meta-badge">{{ sub.frequency }}</span>
{% if sub.next_due %}
<span>📅 {{ sub.next_due }}</span>
{% endif %}
<span>{{ sub.category_name }}</span>
{% if not sub.is_active %}
<span class="inactive-badge">{{ _('search.inactive') }}</span>
{% endif %}
</div>
</div>
<div class="result-amount">{{ sub.amount|currency }}</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Tags Results -->
{% if results.tags %}
<div class="result-section">
<h3 class="section-title">🏷️ {{ _('search.tags') }} ({{ results.tags|length }})</h3>
<div class="result-list">
{% for tag in results.tags %}
<a href="{{ tag.url }}" class="result-item">
<div class="result-icon" style="background: {{ tag.color }}20; color: {{ tag.color }};">
🏷️
</div>
<div class="result-content">
<div class="result-title">{{ tag.name }}</div>
<div class="result-meta">
<span>{{ tag.expense_count }} {{ _('search.expenses_count') }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if results.total == 0 %}
<div class="no-results">
<div class="empty-icon">🔍</div>
<h3>{{ _('search.no_results') }}</h3>
<p>{{ _('search.no_results_message') }}</p>
<ul class="search-tips">
<li>{{ _('search.tip_spelling') }}</li>
<li>{{ _('search.tip_keywords') }}</li>
<li>{{ _('search.tip_date') }}</li>
<li>{{ _('search.tip_amount') }}</li>
</ul>
</div>
{% endif %}
</div>
{% elif query %}
<div class="no-results">
<div class="empty-icon">🔍</div>
<h3>{{ _('search.no_results') }}</h3>
<p>{{ _('search.no_results_message') }}</p>
</div>
{% else %}
<div class="search-welcome">
<div class="search-icon">🔍</div>
<h2>{{ _('search.welcome_title') }}</h2>
<p>{{ _('search.welcome_message') }}</p>
<div class="search-examples">
<h4>{{ _('search.examples_title') }}</h4>
<div class="example-chips">
<button type="button" class="example-chip" onclick="searchFor('groceries')">🛒 groceries</button>
<button type="button" class="example-chip" onclick="searchFor('45.99')">💰 45.99</button>
<button type="button" class="example-chip" onclick="searchFor('2024-12-15')">📅 2024-12-15</button>
<button type="button" class="example-chip" onclick="searchFor('netflix')">🔄 netflix</button>
<button type="button" class="example-chip" onclick="searchFor('restaurant')">🏷️ restaurant</button>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<style>
.search-page {
max-width: 1000px;
margin: 0 auto;
padding: 1rem;
}
.search-header {
text-align: center;
margin-bottom: 2rem;
}
.search-header h1 {
margin-bottom: 0.5rem;
font-size: 2rem;
}
.search-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.search-form {
margin-bottom: 2rem;
position: relative;
}
.search-input-wrapper {
display: flex;
gap: 0.5rem;
}
.search-input-wrapper input {
flex: 1;
padding: 1rem 1.5rem;
font-size: 1rem;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
transition: all 0.3s;
}
.search-input-wrapper input:focus {
border-color: #667eea;
background: rgba(255, 255, 255, 0.08);
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn-search {
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-search:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
}
.btn-search .mobile-icon {
display: none;
}
.search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 80px;
background: rgba(30, 30, 50, 0.98);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
margin-top: 0.5rem;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.search-suggestions.active {
display: block;
}
.suggestion-item {
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.suggestion-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.suggestion-item:last-child {
border-bottom: none;
}
.results-summary {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
}
.results-summary h2 {
margin-bottom: 0.5rem;
}
.results-count {
color: var(--text-secondary);
font-size: 0.95rem;
}
.result-section {
margin-bottom: 2rem;
}
.section-title {
font-size: 1.2rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.result-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.result-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
.result-item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.result-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 1.5rem;
flex-shrink: 0;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 1.05rem;
font-weight: 600;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.meta-badge {
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
}
.inactive-badge {
padding: 0.25rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 6px;
font-size: 0.8rem;
}
.result-amount {
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
flex-shrink: 0;
}
.no-results, .search-welcome {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon, .search-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-results h3 {
margin-bottom: 0.5rem;
}
.search-tips {
text-align: left;
display: inline-block;
margin-top: 1.5rem;
}
.search-tips li {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.search-examples {
margin-top: 2rem;
}
.search-examples h4 {
margin-bottom: 1rem;
color: var(--text-secondary);
}
.example-chips {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.example-chip {
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
font-size: 0.95rem;
}
.example-chip:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.search-header h1 {
font-size: 1.5rem;
}
.search-input-wrapper {
flex-direction: row;
}
.search-input-wrapper input {
padding: 0.875rem 1rem;
font-size: 0.95rem;
}
.btn-search {
padding: 0.875rem 1rem;
min-width: 60px;
}
.btn-search .desktop-text {
display: none;
}
.btn-search .mobile-icon {
display: inline;
font-size: 1.2rem;
}
.result-item {
flex-wrap: wrap;
padding: 0.875rem;
}
.result-icon {
width: 40px;
height: 40px;
font-size: 1.2rem;
}
.result-amount {
width: 100%;
text-align: right;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.result-meta {
font-size: 0.8rem;
}
.search-suggestions {
right: 60px;
}
}
</style>
<script>
let searchTimeout;
const searchInput = document.getElementById('searchInput');
const suggestionsBox = document.getElementById('searchSuggestions');
// Auto-suggest functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (query.length < 2) {
suggestionsBox.classList.remove('active');
suggestionsBox.innerHTML = '';
return;
}
searchTimeout = setTimeout(() => {
fetch(`/api/search/suggestions?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
if (data.suggestions && data.suggestions.length > 0) {
displaySuggestions(data.suggestions);
} else {
suggestionsBox.classList.remove('active');
}
})
.catch(error => {
console.error('Suggestions error:', error);
});
}, 300);
});
// Hide suggestions when clicking outside
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !suggestionsBox.contains(e.target)) {
suggestionsBox.classList.remove('active');
}
});
}
function displaySuggestions(suggestions) {
let html = '';
suggestions.forEach(sugg => {
html += `<div class="suggestion-item" onclick="selectSuggestion('${escapeHtml(sugg.text)}')">`;
html += `<span>${sugg.icon} ${escapeHtml(sugg.text)}</span>`;
if (sugg.amount) {
html += ` <span style="float: right; color: var(--text-secondary);">${sugg.amount.toFixed(2)}</span>`;
}
html += `</div>`;
});
suggestionsBox.innerHTML = html;
suggestionsBox.classList.add('active');
}
function selectSuggestion(text) {
searchInput.value = text;
suggestionsBox.classList.remove('active');
searchInput.form.submit();
}
function searchFor(query) {
searchInput.value = query;
searchInput.form.submit();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Create Tag - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>🏷️ Create Tag</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="name">Tag Name *</label>
<input type="text" id="name" name="name" required autofocus placeholder="e.g., urgent, monthly, personal">
</div>
<div class="form-group">
<label for="color">Color</label>
<input type="color" id="color" name="color" value="#6366f1">
</div>
<div class="form-actions">
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">💾 Create Tag</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Create User - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>👤 Create User</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">Username *</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Password *</label>
<input type="password" id="password" name="password" required minlength="6">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin"> Admin User
</label>
</div>
<div class="form-actions">
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">💾 Create User</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Edit Profile - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>✏️ {{ _('settings.edit_profile') }}</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">{{ _('auth.username') }}</label>
<input type="text" id="username" name="username" value="{{ current_user.username }}" required>
</div>
<div class="form-group">
<label for="email">{{ _('auth.email') }}</label>
<input type="email" id="email" name="email" value="{{ current_user.email }}" required>
</div>
<div class="form-group">
<label for="currency">{{ _('settings.currency') }}</label>
<select id="currency" name="currency" class="metric-select">
<option value="USD" {% if current_user.currency == 'USD' %}selected{% endif %}>$ USD</option>
<option value="EUR" {% if current_user.currency == 'EUR' %}selected{% endif %}>€ EUR</option>
<option value="RON" {% if current_user.currency == 'RON' %}selected{% endif %}>RON (Lei)</option>
<option value="GBP" {% if current_user.currency == 'GBP' %}selected{% endif %}>£ GBP</option>
</select>
</div>
<div class="form-group">
<label for="language">{{ _('settings.language') }}</label>
<select id="language" name="language" class="metric-select">
{% for lang in languages %}
<option value="{{ lang.code }}" {% if current_user.language == lang.code %}selected{% endif %}>
{{ lang.flag }} {{ lang.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="new_password">{{ _('auth.password') }} (leave blank to keep current)</label>
<input type="password" id="new_password" name="new_password" minlength="6">
</div>
<hr style="margin: 2rem 0; border: none; border-top: 1px solid var(--glass-border);">
<h3 style="margin-bottom: 1rem;">📧 {{ _('budget.alert_settings') }}</h3>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="budget_alerts_enabled" name="budget_alerts_enabled"
{% if current_user.budget_alerts_enabled %}checked{% endif %} style="width: auto;">
<span>{{ _('budget.enable_alerts') }}</span>
</label>
<small style="color: var(--text-secondary);">{{ _('budget.enable_alerts_desc') }}</small>
</div>
<div class="form-group">
<label for="alert_email">{{ _('budget.alert_email') }} ({{ _('common.optional') }})</label>
<input type="email" id="alert_email" name="alert_email"
value="{{ current_user.alert_email or '' }}"
placeholder="{{ current_user.email }}">
<small style="color: var(--text-secondary);">{{ _('budget.alert_email_desc') }}</small>
</div>
<div class="form-actions">
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">{{ _('settings.cancel') }}</a>
<button type="submit" class="btn btn-primary">💾 {{ _('settings.save') }}</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Edit User - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>✏️ Edit User: {{ user.username }}</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">Username *</label>
<input type="text" id="username" name="username" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" value="{{ user.email }}" required>
</div>
<div class="form-group">
<label for="new_password">New Password (leave blank to keep current)</label>
<input type="password" id="new_password" name="new_password" minlength="6">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}> Admin User
</label>
</div>
<div class="form-actions">
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Settings - Finance Tracker{% endblock %}
{% block content %}
<div class="settings-container">
<h1>⚙️ {{ _('settings.title') }}</h1>
<div class="settings-tabs">
<button class="tab-btn active" onclick="openTab(event, 'profile')">{{ _('settings.profile') }}</button>
<button class="tab-btn" onclick="openTab(event, 'security')">{{ _('settings.security') }}</button>
<button class="tab-btn" onclick="openTab(event, 'tags')">{{ _('settings.tags') }}</button>
<button class="tab-btn" onclick="openTab(event, 'import-export')">{{ _('settings.import_export') }}</button>
{% if current_user.is_admin %}
<button class="tab-btn" onclick="openTab(event, 'users')">{{ _('settings.users') }}</button>
{% endif %}
</div>
<!-- Profile Tab -->
<div id="profile" class="tab-content active">
<div class="glass-card">
<h2>👤 {{ _('settings.profile_settings') }}</h2>
<form method="POST" action="{{ url_for('settings.edit_profile') }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">{{ _('settings.username') }}</label>
<input type="text" id="username" name="username" value="{{ current_user.username }}" required>
</div>
<div class="form-group">
<label for="email">{{ _('settings.email') }}</label>
<input type="email" id="email" name="email" value="{{ current_user.email }}" required>
</div>
<div class="form-group">
<label for="currency">{{ _('settings.currency') }}</label>
<select id="currency" name="currency" class="metric-select">
<option value="USD" {% if current_user.currency == 'USD' %}selected{% endif %}>$ USD</option>
<option value="EUR" {% if current_user.currency == 'EUR' %}selected{% endif %}>€ EUR</option>
<option value="RON" {% if current_user.currency == 'RON' %}selected{% endif %}>RON (Lei)</option>
<option value="GBP" {% if current_user.currency == 'GBP' %}selected{% endif %}>£ GBP</option>
</select>
</div>
<div class="form-group">
<label for="new_password">{{ _('settings.new_password') }}</label>
<input type="password" id="new_password" name="new_password" placeholder="{{ _('settings.new_password_placeholder') }}">
</div>
<button type="submit" class="btn btn-primary">💾 {{ _('settings.save') }}</button>
</form>
</div>
</div>
<!-- Security Tab (2FA) -->
<div id="security" class="tab-content">
<div class="glass-card">
<h2>🔐 {{ _('settings.2fa_title') }}</h2>
{% if current_user.is_2fa_enabled %}
<div style="padding: 1.5rem; background: rgba(16, 185, 129, 0.1); border: 1px solid var(--success); border-radius: 10px; margin-bottom: 1.5rem;">
<p style="color: var(--success); font-weight: 600; margin-bottom: 0.5rem;">✅ {{ _('settings.2fa_enabled') }}</p>
<p style="color: var(--text-secondary); margin: 0;">{{ _('settings.2fa_enabled_desc') }}</p>
</div>
<form method="POST" action="{{ url_for('settings.disable_2fa') }}" onsubmit="return confirm('{{ _('settings.2fa_disable_confirm') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">🔓 {{ _('settings.disable_2fa') }}</button>
</form>
{% else %}
<div style="padding: 1.5rem; background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 10px; margin-bottom: 1.5rem;">
<p style="color: var(--danger); font-weight: 600; margin-bottom: 0.5rem;">⚠️ {{ _('settings.2fa_disabled') }}</p>
<p style="color: var(--text-secondary); margin: 0;">{{ _('settings.2fa_disabled_desc') }}</p>
</div>
<a href="{{ url_for('settings.setup_2fa') }}" class="btn btn-primary">🔒 {{ _('settings.enable_2fa') }}</a>
{% endif %}
<div style="margin-top: 2rem; padding: 1rem; background: rgba(99, 102, 241, 0.1); border-radius: 10px;">
<h3 style="margin-top: 0;">{{ _('settings.2fa_what_is') }}</h3>
<p style="color: var(--text-secondary); margin: 0;">
{{ _('settings.2fa_what_is_desc') }}
</p>
</div>
</div>
</div>
<!-- Tags Tab -->
<div id="tags" class="tab-content">
<div class="glass-card">
<div class="section-header">
<h2>🏷️ {{ _('settings.manage_tags') }}</h2>
<a href="{{ url_for('settings.create_tag') }}" class="btn btn-primary">+ {{ _('settings.create_tag_btn') }}</a>
</div>
{% if tags %}
<div class="tags-grid">
{% for tag in tags %}
<div class="tag-item glass-card" style="border-left: 4px solid {{ tag.color }}">
<span class="tag-name">{{ tag.name }}</span>
<form method="POST" action="{{ url_for('settings.delete_tag', tag_id=tag.id) }}" style="display: inline;" onsubmit="return confirm('{{ _('settings.delete_tag_confirm').replace('{name}', tag.name) }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-small btn-danger">{{ _('common.delete') }}</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-message">{{ _('empty.no_tags_message') }}</p>
{% endif %}
</div>
</div>
<!-- Import/Export Tab -->
<div id="import-export" class="tab-content">
<div class="glass-card" style="margin-bottom: 2rem;">
<h2>📤 {{ _('settings.export_title') }}</h2>
<p style="margin-bottom: 1rem;">{{ _('settings.export_desc') }}</p>
<a href="{{ url_for('settings.export_data') }}" class="btn btn-primary">⬇️ {{ _('settings.export_btn') }}</a>
</div>
<div class="glass-card">
<h2>📥 {{ _('settings.import_title') }}</h2>
<p style="margin-bottom: 1rem;">{{ _('settings.import_desc') }}</p>
<form method="POST" action="{{ url_for('settings.import_data') }}" enctype="multipart/form-data" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="file">{{ _('settings.import_file_label') }}</label>
<input type="file" id="file" name="file" accept=".csv" required>
</div>
<button type="submit" class="btn btn-primary">⬆️ {{ _('settings.import_btn') }}</button>
</form>
</div>
</div>
<!-- User Management Tab (Admin Only) -->
{% if current_user.is_admin %}
<div id="users" class="tab-content">
<div class="glass-card">
<div class="section-header">
<h2>👥 {{ _('settings.users_title') }}</h2>
<a href="{{ url_for('settings.create_user') }}" class="btn btn-primary">+ {{ _('settings.create_user_btn') }}</a>
</div>
{% if users %}
<div style="overflow-x: auto;">
<table class="users-table">
<thead>
<tr>
<th>{{ _('settings.table_username') }}</th>
<th>{{ _('settings.table_email') }}</th>
<th>{{ _('settings.table_role') }}</th>
<th>{{ _('settings.table_currency') }}</th>
<th>{{ _('settings.table_2fa') }}</th>
<th>{{ _('settings.table_actions') }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
{% if user.is_admin %}
<span style="color: #fbbf24;">⭐ {{ _('settings.role_admin') }}</span>
{% else %}
<span>{{ _('settings.role_user') }}</span>
{% endif %}
</td>
<td>{{ user.currency }}</td>
<td>
{% if user.is_2fa_enabled %}
<span style="color: var(--success);">✅ {{ _('settings.2fa_status_enabled') }}</span>
{% else %}
<span style="color: var(--text-secondary);">❌ {{ _('settings.2fa_status_disabled') }}</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('settings.edit_user', user_id=user.id) }}" class="btn btn-small btn-secondary">{{ _('common.edit') }}</a>
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('settings.delete_user', user_id=user.id) }}" style="display: inline;" onsubmit="return confirm('{{ _('settings.delete_user_confirm').replace('{name}', user.username) }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-small btn-danger">{{ _('common.delete') }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-message">{{ _('settings.no_users') }}</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].classList.remove("active");
}
tablinks = document.getElementsByClassName("tab-btn");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].classList.remove("active");
}
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
}
</script>
{% endblock %}

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Setup 2FA - Finance Tracker{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>🔐 Setup Two-Factor Authentication</h1>
<div style="margin: 2rem 0;">
<h3>Step 1: Scan QR Code</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.)
</p>
{% if qr_code %}
<div style="text-align: center; padding: 2rem; background: white; border-radius: 15px; margin: 1rem 0;">
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 300px; display: block; margin: 0 auto;">
</div>
{% else %}
<div style="padding: 2rem; background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 15px; margin: 1rem 0;">
<p style="color: var(--danger); margin: 0;">❌ QR code generation failed. Please use manual entry below.</p>
</div>
{% endif %}
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: var(--text-secondary); font-weight: 500;">Can't scan? Enter manually</summary>
<div style="margin-top: 1rem; padding: 1.5rem; background: rgba(255, 255, 255, 0.05); border-radius: 10px;">
<p style="margin-bottom: 0.5rem;"><strong>Secret Key:</strong></p>
<code style="font-size: 1.2rem; color: #10b981; word-break: break-all; display: block; padding: 1rem; background: rgba(0, 0, 0, 0.2); border-radius: 8px;">{{ secret }}</code>
<p style="margin-top: 1rem; color: var(--text-secondary); font-size: 0.9rem;">
Enter this key manually in your authenticator app under "Enter a setup key" or "Manual entry"
</p>
</div>
</details>
</div>
<div style="margin: 2rem 0;">
<h3>Step 2: Verify Code</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Enter the 6-digit code from your authenticator app to complete setup
</p>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="token">Authentication Code *</label>
<input type="text" id="token" name="token" required autofocus
pattern="[0-9]{6}" maxlength="6" placeholder="000000"
style="font-size: 2rem; text-align: center; letter-spacing: 0.8rem; font-weight: 600;">
</div>
<div class="form-actions">
<a href="{{ url_for('settings.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">✅ Enable 2FA</button>
</div>
</form>
</div>
<div style="margin-top: 2rem; padding: 1.5rem; background: rgba(99, 102, 241, 0.1); border: 1px solid var(--primary); border-radius: 10px;">
<p style="margin: 0;">
<strong>💡 Important:</strong> Save your secret key in a secure location. You'll need it if you lose access to your authenticator app.
</p>
</div>
</div>
</div>
<style>
details summary:hover {
color: var(--text-primary);
}
input[type="text"]#token {
background: rgba(255, 255, 255, 0.15);
}
</style>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Setup 2FA - Finance Tracker{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<h1>🔐 Setup Two-Factor Authentication</h1>
<p class="subtitle">Scan this QR code with your authenticator app</p>
<div class="qr-container">
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="qr-code">
</div>
<div class="secret-container">
<p><strong>Manual Entry Key:</strong></p>
<code class="secret-key">{{ secret }}</code>
</div>
<p class="info-text">Use Google Authenticator, Authy, or any TOTP app</p>
<form method="POST" action="{{ url_for('auth.verify_2fa') }}" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="token">Enter 6-digit code</label>
<input type="text" id="token" name="token" required pattern="[0-9]{6}" maxlength="6" autofocus>
</div>
<button type="submit" class="btn btn-primary">Verify & Complete Setup</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}{{ _('subscription.add') }} - FINA{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1> {{ _('subscription.add') }}</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="name">{{ _('subscription.name') }}</label>
<input type="text" id="name" name="name" required placeholder="Netflix, Spotify, etc.">
</div>
<div class="form-group">
<label for="amount">{{ _('expense.amount') }}</label>
<input type="number" id="amount" name="amount" step="0.01" min="0" required>
</div>
<div class="form-group">
<label for="frequency">{{ _('subscription.frequency') }}</label>
<select id="frequency" name="frequency" required onchange="toggleCustomInterval()">
<option value="weekly">{{ _('subscription.freq_weekly') }}</option>
<option value="biweekly">{{ _('subscription.freq_biweekly') }}</option>
<option value="monthly" selected>{{ _('subscription.freq_monthly') }}</option>
<option value="quarterly">{{ _('subscription.freq_quarterly') }}</option>
<option value="yearly">{{ _('subscription.freq_yearly') }}</option>
<option value="custom">{{ _('subscription.freq_custom') }}</option>
</select>
</div>
<div class="form-group" id="custom-interval-group" style="display: none;">
<label for="custom_interval_days">{{ _('subscription.custom_interval') }}</label>
<input type="number" id="custom_interval_days" name="custom_interval_days" min="1" placeholder="e.g., 45 days">
<small style="color: var(--text-secondary);">{{ _('subscription.custom_interval_desc') }}</small>
</div>
<div class="form-group">
<label for="category_id">{{ _('category.name') }}</label>
<select id="category_id" name="category_id" required>
<option value="">{{ _('common.select') }}</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.icon }} {{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="start_date">{{ _('subscription.start_date') }}</label>
<input type="date" id="start_date" name="start_date" value="{{ today }}" required>
<small style="color: var(--text-secondary);">{{ _('subscription.start_date_desc') }}</small>
</div>
<div class="form-group">
<label for="end_date">{{ _('subscription.end_date') }} ({{ _('common.optional') }})</label>
<input type="date" id="end_date" name="end_date">
<small style="color: var(--text-secondary);">{{ _('subscription.end_date_desc') }}</small>
</div>
<div class="form-group">
<label for="total_occurrences">{{ _('subscription.total_occurrences') }} ({{ _('common.optional') }})</label>
<input type="number" id="total_occurrences" name="total_occurrences" min="1" placeholder="e.g., 12">
<small style="color: var(--text-secondary);">{{ _('subscription.total_occurrences_desc') }}</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="auto_create_expense" name="auto_create_expense" style="width: auto;">
<span>{{ _('subscription.auto_create') }}</span>
</label>
<small style="color: var(--text-secondary);">{{ _('subscription.auto_create_desc') }}</small>
</div>
<div class="form-group">
<label for="notes">{{ _('subscription.notes') }}</label>
<textarea id="notes" name="notes" rows="3" placeholder="{{ _('subscription.notes_placeholder') }}"></textarea>
</div>
<div class="form-actions">
<a href="{{ url_for('subscriptions.index') }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
<button type="submit" class="btn btn-primary">{{ _('common.save') }}</button>
</div>
</form>
</div>
</div>
<script>
function toggleCustomInterval() {
const frequency = document.getElementById('frequency').value;
const customGroup = document.getElementById('custom-interval-group');
const customInput = document.getElementById('custom_interval_days');
if (frequency === 'custom') {
customGroup.style.display = 'block';
customInput.required = true;
} else {
customGroup.style.display = 'none';
customInput.required = false;
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}{{ _('common.edit') }} {{ subscription.name }} - FINA{% endblock %}
{% block content %}
<div class="form-container">
<div class="glass-card form-card">
<h1>✏️ {{ _('common.edit') }} {{ _('subscription.title') }}</h1>
<form method="POST" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="name">{{ _('subscription.name') }}</label>
<input type="text" id="name" name="name" value="{{ subscription.name }}" required>
</div>
<div class="form-group">
<label for="amount">{{ _('expense.amount') }}</label>
<input type="number" id="amount" name="amount" step="0.01" min="0" value="{{ subscription.amount }}" required>
</div>
<div class="form-group">
<label for="frequency">{{ _('subscription.frequency') }}</label>
<select id="frequency" name="frequency" required onchange="toggleCustomInterval()">
<option value="weekly" {% if subscription.frequency == 'weekly' %}selected{% endif %}>{{ _('subscription.freq_weekly') }}</option>
<option value="biweekly" {% if subscription.frequency == 'biweekly' %}selected{% endif %}>{{ _('subscription.freq_biweekly') }}</option>
<option value="monthly" {% if subscription.frequency == 'monthly' %}selected{% endif %}>{{ _('subscription.freq_monthly') }}</option>
<option value="quarterly" {% if subscription.frequency == 'quarterly' %}selected{% endif %}>{{ _('subscription.freq_quarterly') }}</option>
<option value="yearly" {% if subscription.frequency == 'yearly' %}selected{% endif %}>{{ _('subscription.freq_yearly') }}</option>
<option value="custom" {% if subscription.frequency == 'custom' %}selected{% endif %}>{{ _('subscription.freq_custom') }}</option>
</select>
</div>
<div class="form-group" id="custom-interval-group" style="display: {% if subscription.frequency == 'custom' %}block{% else %}none{% endif %};">
<label for="custom_interval_days">{{ _('subscription.custom_interval') }}</label>
<input type="number" id="custom_interval_days" name="custom_interval_days" min="1" value="{{ subscription.custom_interval_days or '' }}">
</div>
<div class="form-group">
<label for="category_id">{{ _('category.name') }}</label>
<select id="category_id" name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if subscription.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="formend_date">{{ _('subscription.end_date') }} ({{ _('common.optional') }})</label>
<input type="date" id="end_date" name="end_date" value="{{ subscription.end_date.strftime('%Y-%m-%d') if subscription.end_date else '' }}">
</div>
<div class="form-group">
<label for="total_occurrences">{{ _('subscription.total_occurrences') }} ({{ _('common.optional') }})</label>
<input type="number" id="total_occurrences" name="total_occurrences" min="1" value="{{ subscription.total_occurrences or '' }}">
<small style="color: var(--text-secondary);">{{ _('subscription.occurrences_remaining') }}: {{ (subscription.total_occurrences - subscription.occurrences_count) if subscription.total_occurrences else '∞' }}</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="auto_create_expense" name="auto_create_expense" {% if subscription.auto_create_expense %}checked{% endif %} style="width: auto;">
<span>{{ _('subscription.auto_create') }}</span>
</label>
<small style="color: var(--text-secondary);">{{ _('subscription.auto_create_desc') }}</small>
</div>
<script>
function toggleCustomInterval() {
const frequency = document.getElementById('frequency').value;
const customGroup = document.getElementById('custom-interval-group');
const customInput = document.getElementById('custom_interval_days');
if (frequency === 'custom') {
customGroup.style.display = 'block';
customInput.required = true;
} else {
customGroup.style.display = 'none';
customInput.required = false;
}
}
</script>
<div class="form-group">
<label for="-group">
<label for="next_due_date">{{ _('subscription.next_payment') }}</label>
<input type="date" id="next_due_date" name="next_due_date" value="{{ subscription.next_due_date.strftime('%Y-%m-%d') if subscription.next_due_date else '' }}">
</div>
<div class="form-group">
<label for="notes">{{ _('subscription.notes') }}</label>
<textarea id="notes" name="notes" rows="3">{{ subscription.notes or '' }}</textarea>
</div>
<div class="form-actions">
<a href="{{ url_for('subscriptions.index') }}" class="btn btn-secondary">{{ _('common.cancel') }}</a>
<button type="submit" class="btn btn-primary">{{ _('common.save') }}</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}{{ _('subscription.title') }} - FINA{% endblock %}
{% block content %}
<div class="subscriptions-page">
<div class="page-header">
<h1>🔄 {{ _('subscription.title') }}</h1>
<div class="header-actions">
<form method="POST" action="{{ url_for('subscriptions.auto_create_expenses') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-secondary" title="{{ _('subscription.auto_create_tooltip') }}">⚡ {{ _('subscription.create_due') }}</button>
</form>
<form method="POST" action="{{ url_for('subscriptions.detect') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-secondary">🔍 {{ _('subscription.detect') }}</button>
</form>
<a href="{{ url_for('subscriptions.create') }}" class="btn btn-primary"> {{ _('subscription.add') }}</a>
</div>
</div>
<!-- Summary Cards -->
<div class="stats-container" style="margin-bottom: 2rem;">
<div class="glass-card stat-card">
<h3>{{ _('subscription.active') }}</h3>
<p class="stat-value">{{ subscriptions|length }}</p>
</div>
<div class="glass-card stat-card">
<h3>{{ _('subscription.monthly_cost') }}</h3>
<p class="stat-value">{{ monthly_cost|currency }}</p>
</div>
<div class="glass-card stat-card">
<h3>{{ _('subscription.yearly_cost') }}</h3>
<p class="stat-value">{{ yearly_cost|currency }}</p>
</div>
</div>
<!-- Suggestions -->
{% if suggestions %}
<div class="glass-card suggestions-section" style="margin-bottom: 2rem;">
<h2>💡 {{ _('subscription.suggestions') }}</h2>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
{{ _('subscription.suggestions_desc') }}
</p>
{% for suggestion in suggestions %}
<div class="suggestion-card glass-card" style="margin-bottom: 1rem; padding: 1rem; border-left: 3px solid #f59e0b;">
<div class="suggestion-content">
<div class="suggestion-header">
<h3>{{ suggestion.suggested_name }}</h3>
<span class="confidence-badge" style="background: rgba(245, 158, 11, 0.2); padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.85rem;">
{{ suggestion.confidence_score|round(0)|int }}% {{ _('subscription.confidence') }}
</span>
</div>
<div class="suggestion-details" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin: 1rem 0;">
<div>
<small style="color: var(--text-secondary);">{{ _('expense.amount') }}</small>
<p style="font-weight: 600;">{{ suggestion.average_amount|currency }}</p>
</div>
<div>
<small style="color: var(--text-secondary);">{{ _('subscription.frequency') }}</small>
<p style="font-weight: 600;">{{ _(('subscription.freq_' + suggestion.detected_frequency)) }}</p>
</div>
<div>
<small style="color: var(--text-secondary);">{{ _('subscription.occurrences') }}</small>
<p style="font-weight: 600;">{{ suggestion.occurrence_count }} {{ _('subscription.times') }}</p>
</div>
<div>
<small style="color: var(--text-secondary);">{{ _('subscription.period') }}</small>
<p style="font-weight: 600;">{{ suggestion.first_occurrence.strftime('%b %Y') }} - {{ suggestion.last_occurrence.strftime('%b %Y') }}</p>
</div>
</div>
<div class="suggestion-actions" style="display: flex; gap: 0.5rem;">
<form method="POST" action="{{ url_for('subscriptions.accept_suggestion', pattern_id=suggestion.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary btn-sm">✅ {{ _('subscription.accept') }}</button>
</form>
<form method="POST" action="{{ url_for('subscriptions.dismiss_suggestion', pattern_id=suggestion.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-secondary btn-sm">❌ {{ _('subscription.dismiss') }}</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Active Subscriptions -->
{% if subscriptions %}
<div class="glass-card">
<h2>{{ _('subscription.active_list') }}</h2>
<div class="subscriptions-list">
{% for sub in subscriptions %}
<div class="subscription-item" style="display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--glass-border);">
<div class="subscription-info" style="flex: 1;">
<h3 style="margin: 0;">
{{ sub.name }}
{% if sub.auto_create_expense %}
<span style="background: rgba(34, 197, 94, 0.2); color: #4ade80; padding: 0.2rem 0.5rem; border-radius: 5px; font-size: 0.75rem; margin-left: 0.5rem;" title="{{ _('subscription.auto_create_tooltip') }}">⚡ {{ _('subscription.auto') }}</span>
{% endif %}
</h3>
<div style="display: flex; gap: 2rem; margin-top: 0.5rem; color: var(--text-secondary); font-size: 0.9rem; flex-wrap: wrap;">
<span>💰 {{ sub.amount|currency }} /
{% if sub.frequency == 'custom' %}
{{ _('subscription.every') }} {{ sub.custom_interval_days }} {{ _('subscription.days') }}
{% else %}
{{ _(('subscription.freq_' + sub.frequency)) }}
{% endif %}
</span>
{% if sub.next_due_date %}
<span>📅 {{ _('subscription.next_payment') }}: {{ sub.next_due_date.strftime('%b %d, %Y') }}</span>
{% endif %}
<span>📊 {{ _('subscription.annual') }}: {{ sub.get_annual_cost()|currency }}</span>
{% if sub.total_occurrences %}
<span>🔢 {{ sub.occurrences_count }}/{{ sub.total_occurrences }} {{ _('subscription.times') }}</span>
{% endif %}
</div>
{% if sub.notes %}
<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-secondary);">{{ sub.notes }}</p>
{% endif %}
</div>
<div class="subscription-actions" style="display: flex; gap: 0.5rem;">
<a href="{{ url_for('subscriptions.edit', subscription_id=sub.id) }}" class="btn btn-secondary btn-sm">{{ _('common.edit') }}</a>
<form method="POST" action="{{ url_for('subscriptions.toggle', subscription_id=sub.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-secondary btn-sm">
{% if sub.is_active %}⏸️{% else %}▶️{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('subscriptions.delete', subscription_id=sub.id) }}" onsubmit="return confirm('{{ _('subscription.delete_confirm') }}');" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-secondary btn-sm">🗑️</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="glass-card empty-state">
<h2>{{ _('subscription.no_subscriptions') }}</h2>
<p>{{ _('subscription.no_subscriptions_desc') }}</p>
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;">
<form method="POST" action="{{ url_for('subscriptions.detect') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary">🔍 {{ _('subscription.detect') }}</button>
</form>
<a href="{{ url_for('subscriptions.create') }}" class="btn btn-secondary"> {{ _('subscription.add_manual') }}</a>
</div>
</div>
{% endif %}
</div>
<style>
.subscriptions-page {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.subscription-item:last-child {
border-bottom: none;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.subscription-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.subscription-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Verify Login - Finance Tracker{% endblock %}
{% block content %}
<div class="auth-container">
<div class="glass-card auth-card">
<h1>🔐 Two-Factor Authentication</h1>
<p class="subtitle">Enter the code from your authenticator app</p>
<form method="POST" class="auth-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="token">6-digit code</label>
<input type="text" id="token" name="token" required pattern="[0-9]{6}" maxlength="6" autofocus>
</div>
<button type="submit" class="btn btn-primary">Verify</button>
</form>
<p class="auth-link"><a href="{{ url_for('auth.login') }}">Back to login</a></p>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,193 @@
{% extends "base.html" %}
{% block title %}{{ category.name }} - Finance Tracker{% endblock %}
{% block content %}
<div class="category-view">
<div class="category-header glass-card" style="border-left: 4px solid {{ category.color }}">
<div>
<h1>{{ category.name }}</h1>
{% if category.description %}
<p class="category-description">{{ category.description }}</p>
{% endif %}
<p class="category-total">Total: <strong>{{ total_spent|currency }}</strong></p>
</div>
<div class="category-actions">
<a href="{{ url_for('main.create_expense', category_id=category.id) }}" class="btn btn-primary">+ Add Expense</a>
<a href="{{ url_for('main.edit_category', category_id=category.id) }}" class="btn btn-secondary">Edit</a>
<form method="POST" action="{{ url_for('main.delete_category', category_id=category.id) }}" style="display: inline;" onsubmit="return confirm('Delete category and all expenses?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
{% if expenses %}
<div class="glass-card">
<h2>Expenses</h2>
<table class="expenses-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Amount</th>
<th>Paid By</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for expense in expenses %}
<tr>
<td>{{ expense.date.strftime('%Y-%m-%d') }}</td>
<td>{{ expense.description }}</td>
<td><strong>{{ expense.amount|currency }}</strong></td>
<td>{{ expense.paid_by or '-' }}</td>
<td>{{ expense.tags or '-' }}</td>
<td>
<a href="{{ url_for('main.edit_expense', expense_id=expense.id) }}" class="btn btn-small btn-secondary">Edit</a>
{% if expense.file_path %}
<button onclick="viewAttachment('{{ url_for('main.view_file', expense_id=expense.id) }}', '{{ expense.file_path }}')" class="btn btn-small btn-secondary">👁️ View</button>
<a href="{{ url_for('main.download_file', expense_id=expense.id) }}" class="btn btn-small btn-secondary">⬇️</a>
{% endif %}
<form method="POST" action="{{ url_for('main.delete_expense', expense_id=expense.id) }}" style="display: inline;" onsubmit="return confirm('Delete this expense?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-small btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="glass-card empty-state">
<h2>{{ _('empty.no_expenses_title') }}</h2>
<p>{{ _('empty.no_expenses_message') }}</p>
<a href="{{ url_for('main.create_expense', category_id=category.id) }}" class="btn btn-primary">+ {{ _('empty.add_expense') }}</a>
</div>
{% endif %}
</div>
<!-- Attachment Preview Modal -->
<div id="attachmentModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span>
<div id="attachmentViewer"></div>
</div>
</div>
<style>
.category-view { max-width: 1200px; margin: 0 auto; }
.category-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem; }
.category-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.category-total { font-size: 1.2rem; margin-top: 1rem; }
.expenses-table { width: 100%; border-collapse: collapse; margin-top: 1.5rem; }
.expenses-table th, .expenses-table td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--glass-border); }
.expenses-table th { font-weight: 600; color: var(--text-secondary); }
.expenses-table tr:hover { background: rgba(255, 255, 255, 0.05); }
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
position: relative;
margin: 2% auto;
padding: 2rem;
width: 90%;
max-width: 1200px;
max-height: 90vh;
overflow: auto;
background: rgba(59, 7, 100, 0.95);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.close {
position: absolute;
top: 1rem;
right: 1.5rem;
color: #fff;
font-size: 2rem;
font-weight: bold;
cursor: pointer;
z-index: 1;
}
.close:hover {
color: #ff6b6b;
}
#attachmentViewer img {
max-width: 100%;
height: auto;
border-radius: 10px;
}
#attachmentViewer iframe {
width: 100%;
height: 80vh;
border: none;
border-radius: 10px;
}
</style>
<script>
function viewAttachment(url, filePath) {
const modal = document.getElementById('attachmentModal');
const viewer = document.getElementById('attachmentViewer');
// Determine file type
const fileExt = filePath.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif'].includes(fileExt)) {
// Image preview
viewer.innerHTML = '<img src="' + url + '" alt="Attachment">';
} else if (fileExt === 'pdf') {
// PDF viewer
viewer.innerHTML = '<iframe src="' + url + '"></iframe>';
} else {
// Fallback for other files
viewer.innerHTML = '<p style="text-align: center; padding: 2rem;">Preview not available. <a href="' + url + '" download class="btn btn-primary">Download File</a></p>';
}
modal.style.display = 'block';
}
function closeModal() {
const modal = document.getElementById('attachmentModal');
modal.style.display = 'none';
document.getElementById('attachmentViewer').innerHTML = '';
}
// Close modal on click outside
window.onclick = function(event) {
const modal = document.getElementById('attachmentModal');
if (event.target == modal) {
closeModal();
}
}
// Close modal on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
def get_currency_symbol(currency_code):
"""Get currency symbol for display"""
symbols = {
'USD': '$',
'EUR': '',
'RON': 'Lei',
'GBP': '£'
}
return symbols.get(currency_code, '$')
def format_currency(amount, currency_code='USD'):
"""Format amount with currency symbol"""
symbol = get_currency_symbol(currency_code)
# Format number with 2 decimals
formatted_amount = f"{amount:,.2f}"
# Position symbol based on currency
if currency_code == 'RON':
return f"{formatted_amount} {symbol}" # Romanian Leu after amount
else:
return f"{symbol}{formatted_amount}" # Symbol before amount