05/31 Phase 7
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -213,7 +213,7 @@
|
||||
<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>
|
||||
</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>
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="sb-link">
|
||||
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -47,6 +47,36 @@
|
||||
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }}
|
||||
</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">
|
||||
<button type="submit" class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
|
||||
Save {{ txn_type | title }}
|
||||
|
||||
@@ -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.')
|
||||
Reference in New Issue
Block a user