diff --git a/app/__init__.py b/app/__init__.py index f193214..1766761 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -28,6 +28,7 @@ def create_app(config_name=None): from app.routes.investments import investments_bp from app.routes.ai import ai_bp from app.routes.reports import reports_bp + from app.routes.settings import settings_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -39,6 +40,7 @@ def create_app(config_name=None): app.register_blueprint(investments_bp) app.register_blueprint(ai_bp) app.register_blueprint(reports_bp) + app.register_blueprint(settings_bp) with app.app_context(): from app.models import ( diff --git a/app/config.py b/app/config.py index dc74c17..7438aa5 100644 --- a/app/config.py +++ b/app/config.py @@ -36,6 +36,9 @@ class DevelopmentConfig(Config): class ProductionConfig(Config): DEBUG = False SQLALCHEMY_ECHO = False + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' config = { diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..ae80015 --- /dev/null +++ b/app/routes/settings.py @@ -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//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//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//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/', 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//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/') +@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) diff --git a/app/services/import_service.py b/app/services/import_service.py new file mode 100644 index 0000000..e995add --- /dev/null +++ b/app/services/import_service.py @@ -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 diff --git a/app/services/recurring_service.py b/app/services/recurring_service.py new file mode 100644 index 0000000..18bff51 --- /dev/null +++ b/app/services/recurring_service.py @@ -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 diff --git a/app/templates/base.html b/app/templates/base.html index 6925603..2f6a28a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -213,7 +213,7 @@ Categories - + Settings diff --git a/app/templates/settings/import.html b/app/templates/settings/import.html new file mode 100644 index 0000000..3d9e17b --- /dev/null +++ b/app/templates/settings/import.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block title %}Import Transactions{% endblock %} +{% block page_title %}Import CSV{% endblock %} + +{% block content %} +
+
+
+
Upload CSV File
+ + +
+
Required CSV format:
+ + date,type,description,category,account,amount,notes
+ 2025-01-15,expense,Groceries,Food & Dining,Checking,85.50,Weekly shop
+ 2025-01-16,income,Salary,Salary,Checking,3000.00, +
+
+ Required: date, type (income/expense), description, amount
+ Optional: category, account, notes
+ Date formats: YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY +
+
+ +
+ {{ form.hidden_tag() }} + +
+ {{ 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 %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.default_account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.default_account_id(class="form-select") }} + Used when account column is missing or unrecognised. +
+ +
+
+ {{ form.skip_duplicates(class="form-check-input") }} + {{ form.skip_duplicates.label(class="form-check-label", style="font-size:13px;") }} +
+
+ + {{ form.submit(class="btn btn-primary") }} +
+
+
+ +{% if parse_errors %} +
+
+
Parse Errors
+ {% for err in parse_errors %} +
{{ err }}
+ {% endfor %} +
+
+{% endif %} + +{% if preview %} +
+
+
+ Preview — {{ preview | length }} rows ready to import +
+ + +
+
+ + + + + + + + + + + + + {% for row in preview[:20] %} + + + + + + + + + {% endfor %} + {% if preview | length > 20 %} + + {% endif %} + +
DateTypeDescriptionCategoryAccountAmount
{{ row.date.strftime('%b %d, %Y') }}{{ row.transaction_type | title }}{{ row.description }} + {{ row.category_name or '—' }} + {% if row.category_name and not row.category_id %} + + {% endif %} + + {{ row.account_name or '—' }} + {% if not row.account_id %} + + {% endif %} + {{ row.amount | currency }}
... and {{ (preview | length) - 20 }} more rows
+
+
+{% endif %} +
+{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html new file mode 100644 index 0000000..eb8c2de --- /dev/null +++ b/app/templates/settings/index.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Settings{% endblock %} +{% block page_title %}Settings{% endblock %} + +{% block content %} +
+ + +{% if upcoming %} +
+
Upcoming Recurring (next 30 days)
+ + + + {% for item in upcoming[:10] %} + + + + + + + {% endfor %} + +
DateDescriptionTypeAmount
{{ item.date.strftime('%b %d') }}{{ item.description }}{{ item.type | title }}{{ item.amount | currency }}
+
+{% endif %} +{% endblock %} diff --git a/app/templates/settings/password.html b/app/templates/settings/password.html new file mode 100644 index 0000000..2edaf4a --- /dev/null +++ b/app/templates/settings/password.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Change Password{% endblock %} +{% block page_title %}Change Password{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ 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 %}
{{ e }}
{% endfor %} +
+
+ {{ 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 %}
{{ e }}
{% endfor %} + Minimum 6 characters. +
+
+ {{ 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 %}
{{ e }}
{% endfor %} +
+
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/app/templates/settings/profile.html b/app/templates/settings/profile.html new file mode 100644 index 0000000..a3ebc23 --- /dev/null +++ b/app/templates/settings/profile.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}Profile{% endblock %} +{% block page_title %}Profile & Preferences{% endblock %} + +{% block content %} +
+
+
+
Profile
+
+ {{ form.hidden_tag() }} + +
+ {{ form.display_name.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.display_name(class="form-control", placeholder="Your name") }} +
+ +
+ {{ form.email.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.email(class="form-control", placeholder="email@example.com") }} +
+ +
+ {{ form.timezone.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.timezone(class="form-select") }} +
+ +
+ +
+ {{ form.currency.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.currency(class="form-select") }} + All transactions use this single currency. +
+ +
+ {{ form.groq_model.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.groq_model(class="form-select") }} + AI model used for chat and daily insights. +
+ + {{ form.submit(class="btn btn-primary") }} + Change Password +
+
+
+ +
+
+
Account Info
+ + + + + +
Username{{ current_user.username }}
Last login{{ current_user.last_login.strftime('%b %d, %Y %H:%M') if current_user.last_login else '—' }}
Member since{{ current_user.created_at.strftime('%b %d, %Y') }}
Currency{{ current_user.currency_symbol }} {{ current_user.currency }}
+
+ + +
+
+{% endblock %} diff --git a/app/templates/settings/recurring.html b/app/templates/settings/recurring.html new file mode 100644 index 0000000..262a089 --- /dev/null +++ b/app/templates/settings/recurring.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Recurring Rules{% endblock %} +{% block page_title %}Recurring Transactions{% endblock %} + +{% block topbar_actions %} +
+ + +
+New Rule +{% endblock %} + +{% block content %} +
+
+
+
+ Rules ({{ rules | length }}) +
+ {% if rules %} + + + + + + + + + + + + {% for rule in rules %} + + + + + + + + {% endfor %} + +
NameFrequencyAmountNext Run
+
{{ rule.name }}
+
{{ rule.description }}
+
+ {{ rule.frequency | title }} + {{ rule.transaction_type | title }} + {{ rule.amount | currency }} + {{ rule.next_run.strftime('%b %d') if rule.next_run else '—' }} + + Edit +
+ + +
+
+ + +
+
+ {% else %} +
+ +

No recurring rules yet.

+ Create First Rule +
+ {% endif %} +
+
+ +
+
+
Upcoming (30 days)
+ {% if upcoming %} + {% for item in upcoming[:15] %} +
+
+
{{ item.description }}
+
{{ item.date.strftime('%b %d, %Y') }}
+
+ + {% if item.type == 'income' %}+{% else %}-{% endif %}{{ item.amount | currency }} + +
+ {% endfor %} + {% else %} +

No upcoming recurring transactions.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/settings/recurring_form.html b/app/templates/settings/recurring_form.html new file mode 100644 index 0000000..5309d88 --- /dev/null +++ b/app/templates/settings/recurring_form.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.name(class="form-control", placeholder="e.g. Monthly Rent") }} +
+
+ {{ form.transaction_type.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.transaction_type(class="form-select") }} +
+
+ +
+ {{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.description(class="form-control", placeholder="Transaction description") }} +
+ +
+
+ {{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.amount(class="form-control", placeholder="0.00") }} +
+
+
+ {{ form.frequency.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.frequency(class="form-select") }} +
+
+ +
+ {{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.account_id(class="form-select") }} +
+ +
+ {{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.category_id(class="form-select") }} +
+ +
+
+ {{ form.start_date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.start_date(class="form-control") }} +
+
+ {{ form.end_date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.end_date(class="form-control") }} + Leave blank for no end date +
+
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/app/templates/transactions/form.html b/app/templates/transactions/form.html index adf7c35..8a7fd5c 100644 --- a/app/templates/transactions/form.html +++ b/app/templates/transactions/form.html @@ -47,6 +47,36 @@ {{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }} + + {% if txn and txn.receipt %} +
+ +
+ {% elif txn %} +
+ +
+ +
+ + +
+ PNG, JPG, GIF, PDF — max 10MB +
+
+ {% endif %} +