05/31 Phase 7

This commit is contained in:
2026-05-31 17:04:18 -04:00
parent 46a604a73a
commit 84f48a7b4d
14 changed files with 1202 additions and 1 deletions
+2
View File
@@ -28,6 +28,7 @@ def create_app(config_name=None):
from app.routes.investments import investments_bp from app.routes.investments import investments_bp
from app.routes.ai import ai_bp from app.routes.ai import ai_bp
from app.routes.reports import reports_bp from app.routes.reports import reports_bp
from app.routes.settings import settings_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
@@ -39,6 +40,7 @@ def create_app(config_name=None):
app.register_blueprint(investments_bp) app.register_blueprint(investments_bp)
app.register_blueprint(ai_bp) app.register_blueprint(ai_bp)
app.register_blueprint(reports_bp) app.register_blueprint(reports_bp)
app.register_blueprint(settings_bp)
with app.app_context(): with app.app_context():
from app.models import ( from app.models import (
+3
View File
@@ -36,6 +36,9 @@ class DevelopmentConfig(Config):
class ProductionConfig(Config): class ProductionConfig(Config):
DEBUG = False DEBUG = False
SQLALCHEMY_ECHO = False SQLALCHEMY_ECHO = False
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
config = { config = {
+401
View File
@@ -0,0 +1,401 @@
import os
import uuid
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, send_from_directory)
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import (StringField, SelectField, PasswordField, SubmitField,
DecimalField, DateField, BooleanField)
from wtforms.validators import DataRequired, Optional, Length, EqualTo, NumberRange
from app.extensions import db
from app.models.recurring_rule import RecurringRule
from app.models.account import Account
from app.models.category import Category
from app.models.receipt import Receipt
from app.models.transaction import Transaction
from app.services.recurring_service import get_upcoming, process_due_rules
from app.services.import_service import parse_csv, import_rows
from datetime import date
from werkzeug.utils import secure_filename
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
GROQ_MODELS = [
('llama-3.3-70b-versatile', 'Llama 3.3 70B — Best quality'),
('llama-3.1-8b-instant', 'Llama 3.1 8B — Fastest'),
('mixtral-8x7b-32768', 'Mixtral 8x7B — Balanced'),
]
CURRENCIES = [
('USD', 'USD — US Dollar ($)'),
('VND', 'VND — Vietnamese Dong (₫)'),
('EUR', 'EUR — Euro (€)'),
('GBP', 'GBP — British Pound (£)'),
('JPY', 'JPY — Japanese Yen (¥)'),
('AUD', 'AUD — Australian Dollar (A$)'),
('CAD', 'CAD — Canadian Dollar (C$)'),
('SGD', 'SGD — Singapore Dollar (S$)'),
]
CURRENCY_SYMBOLS = {
'USD': '$', 'VND': '', 'EUR': '', 'GBP': '£',
'JPY': '¥', 'AUD': 'A$', 'CAD': 'C$', 'SGD': 'S$',
}
FREQ_CHOICES = [
('daily', 'Daily'),
('weekly', 'Weekly'),
('biweekly', 'Bi-weekly'),
('monthly', 'Monthly'),
('quarterly', 'Quarterly'),
('yearly', 'Yearly'),
]
class ProfileForm(FlaskForm):
display_name = StringField('Display Name', validators=[Optional(), Length(max=100)])
email = StringField('Email', validators=[Optional(), Length(max=120)])
timezone = SelectField('Timezone', choices=[
('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh (Vietnam)'),
('America/New_York', 'America/New_York (EST)'),
('America/Los_Angeles', 'America/Los_Angeles (PST)'),
('Europe/London', 'Europe/London (GMT)'),
('Asia/Tokyo', 'Asia/Tokyo (JST)'),
('Asia/Singapore', 'Asia/Singapore (SGT)'),
('UTC', 'UTC'),
])
currency = SelectField('Currency', choices=CURRENCIES)
groq_model = SelectField('AI Model', choices=GROQ_MODELS)
submit = SubmitField('Save Profile')
class PasswordForm(FlaskForm):
current_password = PasswordField('Current Password', validators=[DataRequired()])
new_password = PasswordField('New Password', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(), EqualTo('new_password')])
submit = SubmitField('Change Password')
class RecurringRuleForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(1, 100)])
transaction_type = SelectField('Type', choices=[('income', 'Income'), ('expense', 'Expense')])
account_id = SelectField('Account', validators=[DataRequired()])
category_id = SelectField('Category', validators=[Optional()])
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)], places=2)
description = StringField('Description', validators=[DataRequired()])
frequency = SelectField('Frequency', choices=FREQ_CHOICES)
start_date = DateField('Start Date', validators=[DataRequired()], default=date.today)
end_date = DateField('End Date', validators=[Optional()])
submit = SubmitField('Save Rule')
class ImportForm(FlaskForm):
csv_file = FileField('CSV File', validators=[
DataRequired(),
FileAllowed(['csv'], 'CSV files only'),
])
default_account_id = SelectField('Default Account (if not in CSV)', validators=[Optional()])
skip_duplicates = BooleanField('Skip duplicate transactions', default=True)
submit = SubmitField('Preview Import')
def _account_choices():
return [('', '— None —')] + [
(str(a.id), a.name)
for a in Account.query.filter_by(is_active=True).order_by(Account.name).all()
]
def _category_choices(txn_type='expense'):
cats = Category.query.filter(
Category.category_type.in_([txn_type, 'both']),
Category.is_active == True,
).order_by(Category.name).all()
return [('', '— None —')] + [(str(c.id), c.name) for c in cats]
@settings_bp.route('/')
@login_required
def index():
upcoming = get_upcoming(days=30)
rules = RecurringRule.query.filter_by(is_active=True).order_by(RecurringRule.name).all()
return render_template('settings/index.html',
upcoming=upcoming,
rules=rules)
@settings_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
form = ProfileForm(obj=current_user)
if form.validate_on_submit():
current_user.display_name = form.display_name.data
current_user.email = form.email.data
current_user.timezone = form.timezone.data
current_user.currency = form.currency.data
current_user.currency_symbol = CURRENCY_SYMBOLS.get(form.currency.data, '$')
current_user.groq_model = form.groq_model.data
db.session.commit()
flash('Profile updated.', 'success')
return redirect(url_for('settings.profile'))
return render_template('settings/profile.html', form=form)
@settings_bp.route('/password', methods=['GET', 'POST'])
@login_required
def password():
form = PasswordForm()
if form.validate_on_submit():
if not current_user.check_password(form.current_password.data):
flash('Current password is incorrect.', 'danger')
else:
current_user.set_password(form.new_password.data)
db.session.commit()
flash('Password changed successfully.', 'success')
return redirect(url_for('settings.profile'))
return render_template('settings/password.html', form=form)
# ── Recurring rules ───────────────────────────────────────────────────────────
@settings_bp.route('/recurring')
@login_required
def recurring():
rules = RecurringRule.query.order_by(RecurringRule.is_active.desc(),
RecurringRule.name).all()
upcoming = get_upcoming(30)
return render_template('settings/recurring.html', rules=rules, upcoming=upcoming)
@settings_bp.route('/recurring/new', methods=['GET', 'POST'])
@login_required
def recurring_new():
form = RecurringRuleForm()
form.account_id.choices = [(str(a.id), a.name)
for a in Account.query.filter_by(is_active=True).all()]
form.category_id.choices = _category_choices(form.transaction_type.data or 'expense')
if form.validate_on_submit():
from app.services.recurring_service import next_occurrence
start = form.start_date.data
rule = RecurringRule(
name=form.name.data.strip(),
transaction_type=form.transaction_type.data,
account_id=int(form.account_id.data),
category_id=int(form.category_id.data) if form.category_id.data else None,
amount=form.amount.data,
description=form.description.data.strip(),
frequency=form.frequency.data,
start_date=start,
end_date=form.end_date.data,
next_run=start,
)
db.session.add(rule)
db.session.commit()
flash(f'Recurring rule "{rule.name}" created.', 'success')
return redirect(url_for('settings.recurring'))
return render_template('settings/recurring_form.html', form=form, title='New Recurring Rule')
@settings_bp.route('/recurring/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def recurring_edit(id):
rule = db.get_or_404(RecurringRule, id)
form = RecurringRuleForm(obj=rule)
form.account_id.choices = [(str(a.id), a.name)
for a in Account.query.filter_by(is_active=True).all()]
form.category_id.choices = _category_choices(rule.transaction_type)
if request.method == 'GET':
form.account_id.data = str(rule.account_id)
form.category_id.data = str(rule.category_id) if rule.category_id else ''
if form.validate_on_submit():
rule.name = form.name.data.strip()
rule.transaction_type = form.transaction_type.data
rule.account_id = int(form.account_id.data)
rule.category_id = int(form.category_id.data) if form.category_id.data else None
rule.amount = form.amount.data
rule.description = form.description.data.strip()
rule.frequency = form.frequency.data
rule.start_date = form.start_date.data
rule.end_date = form.end_date.data
db.session.commit()
flash('Rule updated.', 'success')
return redirect(url_for('settings.recurring'))
return render_template('settings/recurring_form.html', form=form,
title='Edit Recurring Rule', rule=rule)
@settings_bp.route('/recurring/<int:id>/toggle', methods=['POST'])
@login_required
def recurring_toggle(id):
rule = db.get_or_404(RecurringRule, id)
rule.is_active = not rule.is_active
db.session.commit()
flash(f'Rule {"enabled" if rule.is_active else "disabled"}.', 'info')
return redirect(url_for('settings.recurring'))
@settings_bp.route('/recurring/<int:id>/delete', methods=['POST'])
@login_required
def recurring_delete(id):
rule = db.get_or_404(RecurringRule, id)
db.session.delete(rule)
db.session.commit()
flash('Rule deleted.', 'info')
return redirect(url_for('settings.recurring'))
@settings_bp.route('/recurring/run', methods=['POST'])
@login_required
def recurring_run():
created = process_due_rules()
if created:
flash(f'Processed {len(created)} recurring transaction(s).', 'success')
else:
flash('No recurring transactions due.', 'info')
return redirect(url_for('settings.recurring'))
# ── CSV Import ────────────────────────────────────────────────────────────────
@settings_bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_csv():
form = ImportForm()
form.default_account_id.choices = _account_choices()
preview = None
parse_errors = []
if form.validate_on_submit():
f = form.csv_file.data
content = f.read()
default_acc = int(form.default_account_id.data) if form.default_account_id.data else None
rows, parse_errors = parse_csv(content, default_account_id=default_acc)
if rows and not parse_errors:
# Store in session for confirm step
import json
from flask import session
session['import_rows'] = [
{**r, 'date': r['date'].isoformat()}
for r in rows
]
session['import_skip_dupes'] = form.skip_duplicates.data
preview = rows
return render_template('settings/import.html',
form=form,
preview=preview,
parse_errors=parse_errors)
@settings_bp.route('/import/confirm', methods=['POST'])
@login_required
def import_confirm():
from flask import session
from datetime import date as date_cls
import json
raw_rows = session.pop('import_rows', [])
skip = session.pop('import_skip_dupes', True)
if not raw_rows:
flash('No import data found. Please upload again.', 'warning')
return redirect(url_for('settings.import_csv'))
rows = []
for r in raw_rows:
r['date'] = date_cls.fromisoformat(r['date'])
rows.append(r)
imported, skipped = import_rows(rows, skip_duplicates=skip)
flash(f'Imported {imported} transaction(s). Skipped {skipped} duplicate(s).', 'success')
return redirect(url_for('transactions.index'))
# ── Receipt upload ────────────────────────────────────────────────────────────
ALLOWED_RECEIPT_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
def _allowed_receipt(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_RECEIPT_EXTENSIONS
@settings_bp.route('/receipt/upload/<int:txn_id>', methods=['POST'])
@login_required
def upload_receipt(txn_id):
txn = db.get_or_404(Transaction, txn_id)
if 'receipt' not in request.files:
flash('No file selected.', 'warning')
return redirect(request.referrer or url_for('transactions.index'))
f = request.files['receipt']
if not f.filename or not _allowed_receipt(f.filename):
flash('Invalid file type. Allowed: PNG, JPG, GIF, PDF', 'warning')
return redirect(request.referrer or url_for('transactions.index'))
max_size = current_app.config.get('MAX_CONTENT_LENGTH', 10 * 1024 * 1024)
f.seek(0, 2)
file_size = f.tell()
f.seek(0)
if file_size > max_size:
flash('File too large. Maximum 10MB.', 'warning')
return redirect(request.referrer or url_for('transactions.index'))
ext = f.filename.rsplit('.', 1)[1].lower()
unique_name = f'{uuid.uuid4().hex}.{ext}'
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
os.makedirs(upload_dir, exist_ok=True)
f.save(os.path.join(upload_dir, unique_name))
# Remove old receipt if exists
if txn.receipt:
old_path = os.path.join(upload_dir, txn.receipt.filename)
if os.path.exists(old_path):
os.remove(old_path)
db.session.delete(txn.receipt)
receipt = Receipt(
filename=unique_name,
original_filename=secure_filename(f.filename),
file_size=file_size,
mime_type=f.content_type,
)
db.session.add(receipt)
db.session.flush()
txn.receipt_id = receipt.id
db.session.commit()
flash('Receipt uploaded.', 'success')
return redirect(request.referrer or url_for('transactions.index'))
@settings_bp.route('/receipt/<int:txn_id>/delete', methods=['POST'])
@login_required
def delete_receipt(txn_id):
txn = db.get_or_404(Transaction, txn_id)
if txn.receipt:
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
path = os.path.join(upload_dir, txn.receipt.filename)
if os.path.exists(path):
os.remove(path)
db.session.delete(txn.receipt)
txn.receipt_id = None
db.session.commit()
flash('Receipt deleted.', 'info')
return redirect(request.referrer or url_for('transactions.index'))
@settings_bp.route('/receipt/view/<filename>')
@login_required
def view_receipt(filename):
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
return send_from_directory(upload_dir, filename)
+169
View File
@@ -0,0 +1,169 @@
"""
Import Service — parses CSV files for bulk transaction import.
Expected columns: date, type, description, category, account, amount, notes
Date formats: YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY
"""
import csv
import io
import logging
from datetime import datetime
from app.extensions import db
from app.models.transaction import Transaction
from app.models.category import Category
from app.models.account import Account
from app.services.account_service import calc_balance
log = logging.getLogger(__name__)
REQUIRED_COLS = {'date', 'type', 'description', 'amount'}
DATE_FORMATS = ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d', '%d-%m-%Y']
def _parse_date(s):
s = s.strip()
for fmt in DATE_FORMATS:
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise ValueError(f'Unrecognised date format: {s!r}')
def _match_category(name, txn_type):
if not name:
return None
cat = Category.query.filter(
Category.name.ilike(name.strip()),
Category.category_type.in_([txn_type, 'both']),
Category.is_active == True,
).first()
return cat.id if cat else None
def _match_account(name):
if not name:
return None
acct = Account.query.filter(
Account.name.ilike(name.strip()),
Account.is_active == True,
).first()
return acct.id if acct else None
def parse_csv(file_content, default_account_id=None):
"""
Parse CSV content (str or bytes).
Returns (preview_rows, errors, column_map)
preview_rows: list of dicts ready for import
errors: list of error strings
"""
if isinstance(file_content, bytes):
file_content = file_content.decode('utf-8-sig') # handle BOM
reader = csv.DictReader(io.StringIO(file_content))
headers = {h.strip().lower() for h in (reader.fieldnames or [])}
missing = REQUIRED_COLS - headers
if missing:
return [], [f'Missing required columns: {", ".join(missing)}'], {}
rows = []
errors = []
for i, row in enumerate(reader, start=2):
clean = {k.strip().lower(): v.strip() for k, v in row.items()}
row_errors = []
# Date
try:
txn_date = _parse_date(clean.get('date', ''))
except ValueError as e:
row_errors.append(f'Row {i}: {e}')
continue
# Type
txn_type = clean.get('type', '').lower()
if txn_type not in ('income', 'expense'):
row_errors.append(f'Row {i}: type must be "income" or "expense", got {txn_type!r}')
continue
# Amount
try:
amount = float(clean.get('amount', '0').replace(',', '').replace('$', '').strip())
if amount <= 0:
raise ValueError('Amount must be > 0')
except ValueError as e:
row_errors.append(f'Row {i}: invalid amount — {e}')
continue
# Description
description = clean.get('description', '').strip()
if not description:
row_errors.append(f'Row {i}: description is required')
continue
# Optional fields
category_id = _match_category(clean.get('category', ''), txn_type)
account_id = _match_account(clean.get('account', '')) or default_account_id
notes = clean.get('notes', '')
if row_errors:
errors.extend(row_errors)
else:
rows.append({
'date': txn_date,
'transaction_type': txn_type,
'description': description,
'amount': amount,
'category_id': category_id,
'account_id': account_id,
'notes': notes,
'category_name': clean.get('category', ''),
'account_name': clean.get('account', ''),
})
return rows, errors
def import_rows(rows, skip_duplicates=True):
"""
Insert parsed rows into DB.
Returns (imported_count, skipped_count)
"""
imported = 0
skipped = 0
affected_accounts = set()
for row in rows:
if skip_duplicates:
existing = Transaction.query.filter_by(
date=row['date'],
description=row['description'],
amount=row['amount'],
transaction_type=row['transaction_type'],
).first()
if existing:
skipped += 1
continue
txn = Transaction(
date=row['date'],
transaction_type=row['transaction_type'],
description=row['description'],
amount=row['amount'],
category_id=row.get('category_id'),
account_id=row.get('account_id'),
notes=row.get('notes', ''),
)
db.session.add(txn)
if row.get('account_id'):
affected_accounts.add(row['account_id'])
imported += 1
db.session.commit()
for account_id in affected_accounts:
calc_balance(account_id)
return imported, skipped
+115
View File
@@ -0,0 +1,115 @@
"""
Recurring Service — processes recurring transaction rules and creates
due transactions. Called by cron daily at 6AM.
"""
import logging
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from app.extensions import db
from app.models.recurring_rule import RecurringRule
from app.models.transaction import Transaction
from app.services.account_service import calc_balance
log = logging.getLogger(__name__)
def next_occurrence(last_run, frequency):
"""Calculate the next due date given the last run date and frequency."""
if frequency == 'daily':
return last_run + timedelta(days=1)
elif frequency == 'weekly':
return last_run + timedelta(weeks=1)
elif frequency == 'biweekly':
return last_run + timedelta(weeks=2)
elif frequency == 'monthly':
return last_run + relativedelta(months=1)
elif frequency == 'quarterly':
return last_run + relativedelta(months=3)
elif frequency == 'yearly':
return last_run + relativedelta(years=1)
return last_run + relativedelta(months=1)
def process_due_rules(dry_run=False):
"""
Find all active recurring rules that are due today or overdue.
Create transactions for each due occurrence.
Returns list of created transaction descriptions.
"""
today = date.today()
created = []
rules = RecurringRule.query.filter(
RecurringRule.is_active == True,
RecurringRule.next_run <= today,
).all()
for rule in rules:
# Check end date
if rule.end_date and today > rule.end_date:
rule.is_active = False
if not dry_run:
db.session.commit()
continue
# Create transaction for each missed occurrence up to today
run_date = rule.next_run or rule.start_date
affected_accounts = set()
while run_date <= today:
if not dry_run:
txn = Transaction(
transaction_type=rule.transaction_type,
account_id=rule.account_id,
category_id=rule.category_id,
amount=rule.amount,
description=rule.description,
date=run_date,
is_recurring=True,
recurring_rule_id=rule.id,
)
db.session.add(txn)
affected_accounts.add(rule.account_id)
created.append(f'{rule.description} ({rule.transaction_type}) on {run_date}')
log.info(f'[recurring] {"DRY " if dry_run else ""}Created: {rule.description} on {run_date}')
rule.last_run = run_date
run_date = next_occurrence(run_date, rule.frequency)
rule.next_run = run_date
if not dry_run:
db.session.commit()
for account_id in affected_accounts:
calc_balance(account_id)
return created
def get_upcoming(days=30):
"""Return list of upcoming recurring transactions in the next N days."""
today = date.today()
cutoff = today + timedelta(days=days)
rules = RecurringRule.query.filter(
RecurringRule.is_active == True,
).all()
upcoming = []
for rule in rules:
next_date = rule.next_run or rule.start_date
while next_date <= cutoff:
if next_date >= today:
upcoming.append({
'rule': rule,
'date': next_date,
'description': rule.description,
'amount': float(rule.amount),
'type': rule.transaction_type,
})
next_date = next_occurrence(next_date, rule.frequency)
upcoming.sort(key=lambda x: x['date'])
return upcoming
+1 -1
View File
@@ -213,7 +213,7 @@
<a href="{{ url_for('categories.index') }}" class="sb-link {% if request.blueprint == 'categories' %}active{% endif %}"> <a href="{{ url_for('categories.index') }}" class="sb-link {% if request.blueprint == 'categories' %}active{% endif %}">
<i class="bi bi-tags"></i><span class="lt">Categories</span> <i class="bi bi-tags"></i><span class="lt">Categories</span>
</a> </a>
<a href="#" class="sb-link"> <a href="{{ url_for('settings.profile') }}" class="sb-link {% if request.blueprint == 'settings' %}active{% endif %}">
<i class="bi bi-gear"></i><span class="lt">Settings</span> <i class="bi bi-gear"></i><span class="lt">Settings</span>
</a> </a>
<a href="{{ url_for('auth.logout') }}" class="sb-link"> <a href="{{ url_for('auth.logout') }}" class="sb-link">
+117
View File
@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}Import Transactions{% endblock %}
{% block page_title %}Import CSV{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="pcard">
<div class="pcard-title mb-3">Upload CSV File</div>
<!-- Format guide -->
<div class="mb-4 p-3" style="background:#f8fafc;border-radius:8px;font-size:12px;">
<div class="fw-semibold mb-2" style="font-size:13px;">Required CSV format:</div>
<code style="font-size:11px;background:#0f172a;color:#10b981;padding:8px 12px;border-radius:6px;display:block;line-height:1.8;">
date,type,description,category,account,amount,notes<br>
2025-01-15,expense,Groceries,Food &amp; Dining,Checking,85.50,Weekly shop<br>
2025-01-16,income,Salary,Salary,Checking,3000.00,
</code>
<div class="mt-2 text-muted">
<strong>Required:</strong> date, type (income/expense), description, amount<br>
<strong>Optional:</strong> category, account, notes<br>
<strong>Date formats:</strong> YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY
</div>
</div>
<form method="POST" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.csv_file.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.csv_file(class="form-control" + (" is-invalid" if form.csv_file.errors else "")) }}
{% for e in form.csv_file.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.default_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.default_account_id(class="form-select") }}
<small class="text-muted" style="font-size:11px;">Used when account column is missing or unrecognised.</small>
</div>
<div class="mb-4">
<div class="form-check">
{{ form.skip_duplicates(class="form-check-input") }}
{{ form.skip_duplicates.label(class="form-check-label", style="font-size:13px;") }}
</div>
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
</div>
{% if parse_errors %}
<div class="col-12 col-lg-6">
<div class="pcard" style="border-color:#fca5a5;">
<div class="pcard-title mb-2" style="color:#ef4444;">Parse Errors</div>
{% for err in parse_errors %}
<div style="font-size:12px;color:#ef4444;padding:4px 0;border-bottom:1px solid #fee2e2;">{{ err }}</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if preview %}
<div class="col-12">
<div class="pcard p-0">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Preview — {{ preview | length }} rows ready to import</span>
<form method="POST" action="{{ url_for('settings.import_confirm') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-success" style="font-size:12px;">
<i class="bi bi-check-lg me-1"></i>Confirm Import
</button>
</form>
</div>
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Date</th>
<th>Type</th>
<th>Description</th>
<th>Category</th>
<th>Account</th>
<th class="text-end" style="padding-right:20px;">Amount</th>
</tr>
</thead>
<tbody>
{% for row in preview[:20] %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);">{{ row.date.strftime('%b %d, %Y') }}</td>
<td><span class="badge {% if row.transaction_type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">{{ row.transaction_type | title }}</span></td>
<td style="font-size:13px;">{{ row.description }}</td>
<td style="font-size:12px;color:var(--muted);">
{{ row.category_name or '—' }}
{% if row.category_name and not row.category_id %}
<span title="Category not found — will be uncategorized" style="color:#f59e0b;"></span>
{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">
{{ row.account_name or '—' }}
{% if not row.account_id %}
<span title="Account not found — will be unlinked" style="color:#f59e0b;"></span>
{% endif %}
</td>
<td class="text-end mono {% if row.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;padding-right:20px;">{{ row.amount | currency }}</td>
</tr>
{% endfor %}
{% if preview | length > 20 %}
<tr><td colspan="6" class="text-center py-2" style="font-size:12px;color:var(--muted);">... and {{ (preview | length) - 20 }} more rows</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Settings{% endblock %}
{% block page_title %}Settings{% endblock %}
{% block content %}
<div class="row g-3">
<!-- Nav cards -->
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#3b82f6'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-person-circle" style="font-size:2rem;color:#3b82f6;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Profile</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Name, currency, timezone, AI model</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.recurring') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#10b981'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-repeat" style="font-size:2rem;color:#10b981;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Recurring</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Automate recurring income & expenses</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.import_csv') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#f59e0b'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-upload" style="font-size:2rem;color:#f59e0b;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Import</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Bulk import from CSV file</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.password') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#8b5cf6'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-shield-lock" style="font-size:2rem;color:#8b5cf6;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Security</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Change password</div>
</div>
</a>
</div>
</div>
<!-- Upcoming recurring -->
{% if upcoming %}
<div class="pcard mt-4">
<div class="pcard-title mb-3">Upcoming Recurring (next 30 days)</div>
<table class="pfm-table">
<thead><tr><th>Date</th><th>Description</th><th>Type</th><th class="text-end">Amount</th></tr></thead>
<tbody>
{% for item in upcoming[:10] %}
<tr>
<td style="font-size:12px;color:var(--muted);white-space:nowrap;">{{ item.date.strftime('%b %d') }}</td>
<td style="font-size:13px;">{{ item.description }}</td>
<td><span class="badge {% if item.type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">{{ item.type | title }}</span></td>
<td class="text-end mono {% if item.type == 'income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;">{{ item.amount | currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Change Password{% endblock %}
{% block page_title %}Change Password{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-7 col-lg-5">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.current_password.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.current_password(class="form-control" + (" is-invalid" if form.current_password.errors else "")) }}
{% for e in form.current_password.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.new_password.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.new_password(class="form-control" + (" is-invalid" if form.new_password.errors else "")) }}
{% for e in form.new_password.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
<small class="text-muted" style="font-size:11px;">Minimum 6 characters.</small>
</div>
<div class="mb-4">
{{ form.confirm_password.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else "")) }}
{% for e in form.confirm_password.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('settings.profile') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}Profile{% endblock %}
{% block page_title %}Profile & Preferences{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="pcard">
<div class="pcard-title mb-3">Profile</div>
<form method="POST" action="{{ url_for('settings.profile') }}" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.display_name.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.display_name(class="form-control", placeholder="Your name") }}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.email(class="form-control", placeholder="email@example.com") }}
</div>
<div class="mb-3">
{{ form.timezone.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.timezone(class="form-select") }}
</div>
<hr style="border-color:var(--border);">
<div class="mb-3">
{{ form.currency.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.currency(class="form-select") }}
<small class="text-muted" style="font-size:11px;">All transactions use this single currency.</small>
</div>
<div class="mb-4">
{{ form.groq_model.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.groq_model(class="form-select") }}
<small class="text-muted" style="font-size:11px;">AI model used for chat and daily insights.</small>
</div>
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('settings.password') }}" class="btn btn-outline-secondary ms-2">Change Password</a>
</form>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="pcard">
<div class="pcard-title mb-3">Account Info</div>
<table style="width:100%;font-size:13px;">
<tr><td class="text-muted py-2" style="width:40%;border-bottom:1px solid var(--border);">Username</td><td class="py-2 fw-medium" style="border-bottom:1px solid var(--border);">{{ current_user.username }}</td></tr>
<tr><td class="text-muted py-2" style="border-bottom:1px solid var(--border);">Last login</td><td class="py-2" style="border-bottom:1px solid var(--border);">{{ current_user.last_login.strftime('%b %d, %Y %H:%M') if current_user.last_login else '—' }}</td></tr>
<tr><td class="text-muted py-2" style="border-bottom:1px solid var(--border);">Member since</td><td class="py-2" style="border-bottom:1px solid var(--border);">{{ current_user.created_at.strftime('%b %d, %Y') }}</td></tr>
<tr><td class="text-muted py-2">Currency</td><td class="py-2 mono fw-bold">{{ current_user.currency_symbol }} {{ current_user.currency }}</td></tr>
</table>
</div>
<div class="pcard mt-3">
<div class="pcard-title mb-3">Quick Links</div>
<div class="d-flex flex-column gap-2">
<a href="{{ url_for('settings.recurring') }}" style="font-size:13px;color:var(--accent);"><i class="bi bi-repeat me-2"></i>Recurring Rules</a>
<a href="{{ url_for('settings.import_csv') }}" style="font-size:13px;color:var(--accent);"><i class="bi bi-upload me-2"></i>Import CSV</a>
<a href="{{ url_for('categories.index') }}" style="font-size:13px;color:var(--accent);"><i class="bi bi-tags me-2"></i>Manage Categories</a>
<a href="{{ url_for('reports.index') }}" style="font-size:13px;color:var(--accent);"><i class="bi bi-file-earmark-bar-graph me-2"></i>Reports & Export</a>
</div>
</div>
</div>
</div>
{% endblock %}
+96
View File
@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Recurring Rules{% endblock %}
{% block page_title %}Recurring Transactions{% endblock %}
{% block topbar_actions %}
<form method="POST" action="{{ url_for('settings.recurring_run') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;" title="Process all due rules now">
<i class="bi bi-play-fill me-1"></i>Run Now
</button>
</form>
<a href="{{ url_for('settings.recurring_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>New Rule</a>
{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-12 col-lg-7">
<div class="pcard p-0">
<div class="px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Rules ({{ rules | length }})</span>
</div>
{% if rules %}
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Name</th>
<th>Frequency</th>
<th class="text-end">Amount</th>
<th class="text-end">Next Run</th>
<th class="text-end" style="padding-right:20px;"></th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr style="{% if not rule.is_active %}opacity:.5;{% endif %}">
<td style="padding-left:20px;">
<div style="font-size:13px;font-weight:500;">{{ rule.name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ rule.description }}</div>
</td>
<td>
<span class="badge" style="font-size:11px;background:#f1f5f9;color:var(--muted);">{{ rule.frequency | title }}</span>
<span class="badge ms-1 {% if rule.transaction_type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">{{ rule.transaction_type | title }}</span>
</td>
<td class="text-end mono {% if rule.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:500;">{{ rule.amount | currency }}</td>
<td class="text-end" style="font-size:12px;color:var(--muted);white-space:nowrap;">
{{ rule.next_run.strftime('%b %d') if rule.next_run else '—' }}
</td>
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
<a href="{{ url_for('settings.recurring_edit', id=rule.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 7px;">Edit</a>
<form method="POST" action="{{ url_for('settings.recurring_toggle', id=rule.id) }}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:11px;padding:2px 7px;">
{% if rule.is_active %}Pause{% else %}Enable{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('settings.recurring_delete', id=rule.id) }}" style="display:inline;" onsubmit="return confirm('Delete this rule?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 7px;">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-5">
<i class="bi bi-repeat text-muted" style="font-size:2.5rem;"></i>
<p class="text-muted small mt-2 mb-2">No recurring rules yet.</p>
<a href="{{ url_for('settings.recurring_new') }}" class="btn btn-sm btn-primary">Create First Rule</a>
</div>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-5">
<div class="pcard">
<div class="pcard-title mb-3">Upcoming (30 days)</div>
{% if upcoming %}
{% for item in upcoming[:15] %}
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);">
<div>
<div style="font-size:13px;">{{ item.description }}</div>
<div style="font-size:11px;color:var(--muted);">{{ item.date.strftime('%b %d, %Y') }}</div>
</div>
<span class="mono {% if item.type == 'income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:500;">
{% if item.type == 'income' %}+{% else %}-{% endif %}{{ item.amount | currency }}
</span>
</div>
{% endfor %}
{% else %}
<p class="text-muted small">No upcoming recurring transactions.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3 mb-3">
<div class="col-8">
{{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.name(class="form-control", placeholder="e.g. Monthly Rent") }}
</div>
<div class="col-4">
{{ form.transaction_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.transaction_type(class="form-select") }}
</div>
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.description(class="form-control", placeholder="Transaction description") }}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:12px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control", placeholder="0.00") }}
</div>
</div>
<div class="col-6">
{{ form.frequency.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.frequency(class="form-select") }}
</div>
</div>
<div class="mb-3">
{{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_id(class="form-select") }}
</div>
<div class="mb-3">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select") }}
</div>
<div class="row g-3 mb-4">
<div class="col-6">
{{ form.start_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.start_date(class="form-control") }}
</div>
<div class="col-6">
{{ form.end_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.end_date(class="form-control") }}
<small class="text-muted" style="font-size:11px;">Leave blank for no end date</small>
</div>
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('settings.recurring') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+30
View File
@@ -47,6 +47,36 @@
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }} {{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }}
</div> </div>
{% if txn and txn.receipt %}
<div class="mb-3 p-3" style="background:#f8fafc;border-radius:8px;">
<div class="d-flex justify-content-between align-items-center">
<div style="font-size:13px;">
<i class="bi bi-paperclip me-1 text-muted"></i>
<a href="{{ url_for('settings.view_receipt', filename=txn.receipt.filename) }}" target="_blank">
{{ txn.receipt.original_filename }}
</a>
</div>
<form method="POST" action="{{ url_for('settings.delete_receipt', txn_id=txn.id) }}" onsubmit="return confirm('Delete receipt?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;padding:2px 8px;">Remove</button>
</form>
</div>
</div>
{% elif txn %}
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Receipt</label>
<form method="POST" action="{{ url_for('settings.upload_receipt', txn_id=txn.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group input-group-sm">
<input type="file" name="receipt" class="form-control" accept=".png,.jpg,.jpeg,.gif,.pdf" style="font-size:12px;">
<button type="submit" class="btn btn-outline-secondary" style="font-size:12px;">Upload</button>
</div>
<small class="text-muted" style="font-size:11px;">PNG, JPG, GIF, PDF — max 10MB</small>
</form>
</div>
{% endif %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}"> <button type="submit" class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
Save {{ txn_type | title }} Save {{ txn_type | title }}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""
Cron script: process due recurring transaction rules.
Run by systemd timer pfm-recurring.timer at 6AM daily.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app
from app.services.recurring_service import process_due_rules
app = create_app()
if __name__ == '__main__':
with app.app_context():
print('[recurring] Processing due rules...')
created = process_due_rules()
if created:
for desc in created:
print(f' Created: {desc}')
print(f'[recurring] {len(created)} transaction(s) created.')
else:
print('[recurring] No transactions due.')