first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-13 15:17:32 +01:00
commit 986ffb200a
91 changed files with 4423 additions and 0 deletions

0
app/services/__init__.py Normal file
View File

115
app/services/analytics.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from collections import defaultdict
from decimal import Decimal
from sqlalchemy import extract, func
from ..models import Budget, Expense
from .i18n import get_locale
def _uncategorized() -> str:
return 'Uncategorized' if get_locale() == 'en' else 'Bez kategorii'
def _base_user_query(user_id: int, year: int, month: int | None = None):
query = Expense.query.filter_by(user_id=user_id, is_deleted=False).filter(extract('year', Expense.purchase_date) == year)
if month:
query = query.filter(extract('month', Expense.purchase_date) == month)
return query
def monthly_summary(user_id: int, year: int, month: int):
expenses = _base_user_query(user_id, year, month).order_by(Expense.purchase_date.desc(), Expense.id.desc()).all()
total = sum((expense.amount for expense in expenses), Decimal('0.00'))
category_totals = defaultdict(Decimal)
for expense in expenses:
category_name = expense.category.localized_name(get_locale()) if expense.category else _uncategorized()
category_totals[category_name] += expense.amount
budgets = Budget.query.filter_by(user_id=user_id, year=year, month=month).all()
budget_map = {budget.category_id: budget for budget in budgets}
alerts = []
for expense in expenses:
budget = budget_map.get(expense.category_id)
if budget and expense.category:
spent = category_totals.get(expense.category.localized_name(get_locale()), Decimal('0.00'))
ratio = (spent / budget.amount * 100) if budget.amount else 0
if ratio >= budget.alert_percent:
alerts.append({'category': expense.category.localized_name(get_locale()), 'ratio': float(ratio), 'budget': float(budget.amount)})
return expenses, total, category_totals, alerts
def yearly_totals(user_id: int, year: int, month: int | None = None):
if month:
return daily_totals(user_id, year, month)
rows = _base_user_query(user_id, year).with_entities(extract('month', Expense.purchase_date).label('month'), func.sum(Expense.amount)).group_by('month').order_by('month').all()
return [{'month': int(month), 'amount': float(amount)} for month, amount in rows]
def daily_totals(user_id: int, year: int, month: int | None = None):
rows = _base_user_query(user_id, year, month).with_entities(extract('day', Expense.purchase_date).label('day'), func.sum(Expense.amount)).group_by('day').order_by('day').all()
return [{'month': int(day), 'amount': float(amount)} for day, amount in rows]
def yearly_category_totals(user_id: int, year: int, month: int | None = None):
expenses = _base_user_query(user_id, year, month).all()
grouped = defaultdict(Decimal)
for expense in expenses:
name = expense.category.localized_name(get_locale()) if expense.category else _uncategorized()
grouped[name] += expense.amount
return [{'category': name, 'amount': float(amount)} for name, amount in grouped.items()]
def payment_method_totals(user_id: int, year: int, month: int | None = None):
rows = _base_user_query(user_id, year, month).with_entities(Expense.payment_method, func.sum(Expense.amount)).group_by(Expense.payment_method).all()
return [{'method': method, 'amount': float(amount)} for method, amount in rows]
def top_expenses(user_id: int, year: int, month: int | None = None, limit: int = 10):
rows = _base_user_query(user_id, year, month).order_by(Expense.amount.desc()).limit(limit).all()
return [{'title': row.title, 'amount': float(row.amount), 'date': row.purchase_date.isoformat()} for row in rows]
def yearly_overview(user_id: int, year: int, month: int | None = None):
expenses = _base_user_query(user_id, year, month).all()
total = sum((expense.amount for expense in expenses), Decimal('0.00'))
count = len(expenses)
average = (total / count) if count else Decimal('0.00')
refunds = sum((expense.amount for expense in expenses if expense.is_refund), Decimal('0.00'))
business_total = sum((expense.amount for expense in expenses if expense.is_business), Decimal('0.00'))
return {'total': float(total), 'count': count, 'average': float(average), 'refunds': float(refunds), 'business_total': float(business_total)}
def compare_years(user_id: int, year: int, month: int | None = None):
current = yearly_overview(user_id, year, month)
previous = yearly_overview(user_id, year - 1, month)
diff = current['total'] - previous['total']
pct = ((diff / previous['total']) * 100) if previous['total'] else 0
return {'current_year': year, 'previous_year': year - 1, 'current_total': current['total'], 'previous_total': previous['total'], 'difference': diff, 'percent_change': pct}
def range_totals(user_id: int, start_year: int, end_year: int, month: int | None = None):
rows = Expense.query.with_entities(extract('year', Expense.purchase_date).label('year'), func.sum(Expense.amount)).filter_by(user_id=user_id, is_deleted=False).filter(extract('year', Expense.purchase_date) >= start_year, extract('year', Expense.purchase_date) <= end_year)
if month:
rows = rows.filter(extract('month', Expense.purchase_date) == month)
rows = rows.group_by('year').order_by('year').all()
return [{'year': int(year), 'amount': float(amount)} for year, amount in rows]
def quarterly_totals(user_id: int, year: int, month: int | None = None):
expenses = _base_user_query(user_id, year, month).all()
quarters = {1: Decimal('0.00'), 2: Decimal('0.00'), 3: Decimal('0.00'), 4: Decimal('0.00')}
for expense in expenses:
quarter = ((expense.purchase_date.month - 1) // 3) + 1
quarters[quarter] += expense.amount
return [{'quarter': f'Q{quarter}', 'amount': float(amount)} for quarter, amount in quarters.items() if amount > 0]
def weekday_totals(user_id: int, year: int, month: int | None = None):
expenses = _base_user_query(user_id, year, month).all()
labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] if get_locale() == 'en' else ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Niedz']
totals = [Decimal('0.00') for _ in range(7)]
for expense in expenses:
totals[expense.purchase_date.weekday()] += expense.amount
return [{'day': labels[index], 'amount': float(amount)} for index, amount in enumerate(totals)]

18
app/services/assets.py Normal file
View File

@@ -0,0 +1,18 @@
import hashlib
from functools import lru_cache
from pathlib import Path
from flask import url_for
@lru_cache(maxsize=128)
def asset_version(file_path: str) -> str:
path = Path(file_path)
if not path.exists():
return '0'
return hashlib.md5(path.read_bytes()).hexdigest()[:10]
def asset_url(relative_path: str) -> str:
base_path = Path(__file__).resolve().parent.parent / 'static' / relative_path
return url_for('static', filename=relative_path, v=asset_version(str(base_path)))

20
app/services/audit.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import json
from flask_login import current_user
from ..extensions import db
from ..models import AuditLog
def log_action(action: str, target_type: str = '', target_id: str = '', **details) -> None:
user_id = current_user.id if getattr(current_user, 'is_authenticated', False) else None
entry = AuditLog(
user_id=user_id,
action=action,
target_type=target_type,
target_id=str(target_id or ''),
details=json.dumps(details, ensure_ascii=False) if details else '',
)
db.session.add(entry)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from ..models import Expense
def suggest_category_id(user_id: int, vendor: str = '', title: str = '') -> int | None:
vendor = (vendor or '').strip().lower()
title = (title or '').strip().lower()
if not vendor and not title:
return None
recent = (
Expense.query.filter_by(user_id=user_id, is_deleted=False)
.filter(Expense.category_id.isnot(None))
.order_by(Expense.id.desc())
.limit(100)
.all()
)
for expense in recent:
ev = (expense.vendor or '').strip().lower()
et = (expense.title or '').strip().lower()
if vendor and ev == vendor:
return expense.category_id
if title and et == title:
return expense.category_id
return None

47
app/services/export.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import csv
from io import BytesIO, StringIO
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
def export_expenses_csv(expenses):
output = StringIO()
writer = csv.writer(output)
writer.writerow(['Date', 'Title', 'Vendor', 'Category', 'Amount', 'Currency', 'Payment method', 'Tags'])
for expense in expenses:
writer.writerow([
expense.purchase_date.isoformat(),
expense.title,
expense.vendor,
expense.category.name_en if expense.category else '',
str(expense.amount),
expense.currency,
expense.payment_method,
expense.tags,
])
return output.getvalue()
def export_expenses_pdf(expenses, title: str = 'Expenses'):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
y = height - 40
pdf.setFont('Helvetica-Bold', 14)
pdf.drawString(40, y, title)
y -= 30
pdf.setFont('Helvetica', 10)
for expense in expenses:
line = f'{expense.purchase_date.isoformat()} | {expense.title} | {expense.amount} {expense.currency}'
pdf.drawString(40, y, line[:100])
y -= 16
if y < 60:
pdf.showPage()
y = height - 40
pdf.setFont('Helvetica', 10)
pdf.save()
buffer.seek(0)
return buffer.read()

63
app/services/files.py Normal file
View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import re
from pathlib import Path
from uuid import uuid4
from PIL import Image, ImageDraw
from pillow_heif import register_heif_opener
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
register_heif_opener()
def allowed_file(filename: str, allowed_extensions: set[str]) -> bool:
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
def _apply_image_ops(image: Image.Image, rotate: int = 0, crop_box: tuple[int, int, int, int] | None = None, scale_percent: int = 100) -> Image.Image:
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGB')
if rotate:
image = image.rotate(-rotate, expand=True)
if crop_box:
x, y, w, h = crop_box
if w > 0 and h > 0:
image = image.crop((x, y, x + w, y + h))
if scale_percent and scale_percent != 100:
width = max(1, int(image.width * (scale_percent / 100)))
height = max(1, int(image.height * (scale_percent / 100)))
image = image.resize((width, height))
return image
def _pdf_placeholder(preview_path: Path, original_name: str) -> None:
image = Image.new('RGB', (800, 1100), color='white')
draw = ImageDraw.Draw(image)
draw.text((40, 40), f'PDF preview\n{original_name}', fill='black')
image.save(preview_path, 'WEBP', quality=80)
def save_document(file: FileStorage, upload_dir: Path, preview_dir: Path, rotate: int = 0, crop_box: tuple[int, int, int, int] | None = None, scale_percent: int = 100) -> tuple[str, str]:
upload_dir.mkdir(parents=True, exist_ok=True)
preview_dir.mkdir(parents=True, exist_ok=True)
original_name = secure_filename(file.filename or 'document')
extension = original_name.rsplit('.', 1)[1].lower() if '.' in original_name else 'bin'
stem = re.sub(r'[^a-zA-Z0-9_-]+', '-', Path(original_name).stem).strip('-') or 'document'
unique_name = f'{stem}-{uuid4().hex}.{extension}'
saved_path = upload_dir / unique_name
file.save(saved_path)
preview_name = f'{stem}-{uuid4().hex}.webp'
preview_path = preview_dir / preview_name
if extension in {'jpg', 'jpeg', 'png', 'heic'}:
image = Image.open(saved_path)
image = _apply_image_ops(image, rotate=rotate, crop_box=crop_box, scale_percent=scale_percent)
image.save(preview_path, 'WEBP', quality=80)
elif extension == 'pdf':
_pdf_placeholder(preview_path, original_name)
else:
preview_name = ''
return unique_name, preview_name

37
app/services/i18n.py Normal file
View File

@@ -0,0 +1,37 @@
import json
from functools import lru_cache
from pathlib import Path
from flask import current_app, session
from flask_login import current_user
@lru_cache(maxsize=8)
def load_language(language: str) -> dict:
base = Path(__file__).resolve().parent.parent / 'static' / 'i18n'
file_path = base / f'{language}.json'
if not file_path.exists():
file_path = base / 'pl.json'
return json.loads(file_path.read_text(encoding='utf-8'))
def get_locale() -> str:
if getattr(current_user, 'is_authenticated', False):
return current_user.language or current_app.config['DEFAULT_LANGUAGE']
return session.get('language', current_app.config['DEFAULT_LANGUAGE'])
def translate(key: str, default: str | None = None) -> str:
language = get_locale()
data = load_language(language)
if key in data:
return data[key]
fallback = load_language(current_app.config.get('DEFAULT_LANGUAGE', 'pl'))
if key in fallback:
return fallback[key]
en = load_language('en')
return en.get(key, default or key)
def inject_i18n():
return {'t': translate, 'current_language': get_locale()}

55
app/services/mail.py Normal file
View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from flask import current_app, render_template
from .settings import get_bool_setting, get_int_setting, get_str_setting
class MailService:
def _settings(self) -> dict:
security = get_str_setting('smtp_security', '') or ('ssl' if get_bool_setting('smtp_use_ssl', current_app.config.get('MAIL_USE_SSL', True)) else ('starttls' if get_bool_setting('smtp_use_tls', current_app.config.get('MAIL_USE_TLS', False)) else 'plain'))
return {
'host': get_str_setting('smtp_host', current_app.config.get('MAIL_SERVER', '')),
'port': get_int_setting('smtp_port', current_app.config.get('MAIL_PORT', 465)),
'username': get_str_setting('smtp_username', current_app.config.get('MAIL_USERNAME', '')),
'password': get_str_setting('smtp_password', current_app.config.get('MAIL_PASSWORD', '')),
'sender': get_str_setting('smtp_sender', current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@example.com')),
'security': security,
}
def is_configured(self) -> bool:
return bool(self._settings()['host'])
def send(self, to_email: str, subject: str, body: str, html: str | None = None) -> bool:
cfg = self._settings()
if not cfg['host']:
current_app.logger.info('Mail skipped for %s: %s', to_email, subject)
return False
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = cfg['sender']
msg['To'] = to_email
msg.set_content(body)
if html:
msg.add_alternative(html, subtype='html')
if cfg['security'] == 'ssl':
with smtplib.SMTP_SSL(cfg['host'], cfg['port']) as smtp:
if cfg['username']:
smtp.login(cfg['username'], cfg['password'])
smtp.send_message(msg)
else:
with smtplib.SMTP(cfg['host'], cfg['port']) as smtp:
if cfg['security'] == 'starttls':
smtp.starttls()
if cfg['username']:
smtp.login(cfg['username'], cfg['password'])
smtp.send_message(msg)
return True
def send_template(self, to_email: str, subject: str, template_name: str, **context) -> bool:
html = render_template(f'mail/{template_name}.html', **context)
text = render_template(f'mail/{template_name}.txt', **context)
return self.send(to_email, subject, text, html=html)

52
app/services/ocr.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import re
from pathlib import Path
import pytesseract
from PIL import Image, ImageOps
AMOUNT_REGEXES = [
re.compile(r'(?:suma|total|razem)\s*[:]?\s*(\d+[\.,]\d{2})', re.I),
re.compile(r'(\d+[\.,]\d{2})'),
]
DATE_REGEX = re.compile(r'(\d{4}-\d{2}-\d{2}|\d{2}[./-]\d{2}[./-]\d{4})')
NIP_REGEX = re.compile(r'(?:NIP|TIN)\s*[:]?\s*([0-9\- ]{8,20})', re.I)
class OCRResult(dict):
@property
def status(self) -> str:
return self.get('status', 'pending')
class OCRService:
def extract(self, file_path: Path) -> OCRResult:
if file_path.suffix.lower() not in {'.jpg', '.jpeg', '.png', '.heic', '.webp'}:
return OCRResult(status='pending', title=file_path.stem, vendor='', amount=None, purchase_date=None)
try:
image = Image.open(file_path)
image = ImageOps.exif_transpose(image)
text = pytesseract.image_to_string(image, lang='eng')
except Exception:
return OCRResult(status='pending', title=file_path.stem, vendor='', amount=None, purchase_date=None)
lines = [line.strip() for line in text.splitlines() if line.strip()]
vendor = lines[0][:255] if lines else ''
amount = None
for pattern in AMOUNT_REGEXES:
match = pattern.search(text)
if match:
amount = match.group(1).replace(',', '.')
break
date_match = DATE_REGEX.search(text)
nip_match = NIP_REGEX.search(text)
return OCRResult(
status='review' if amount or vendor else 'pending',
title=vendor or file_path.stem,
vendor=vendor,
amount=amount,
purchase_date=date_match.group(1) if date_match else None,
tax_id=nip_match.group(1).strip() if nip_match else None,
raw_text=text[:4000],
)

42
app/services/reporting.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
from ..extensions import db
from ..models import Expense, ReportLog, User
from .mail import MailService
from .settings import get_bool_setting
def _range_for_frequency(frequency: str, today: date):
if frequency == 'daily':
start = today - timedelta(days=1)
label = start.isoformat()
elif frequency == 'weekly':
start = today - timedelta(days=7)
label = f'{start.isoformat()}..{today.isoformat()}'
else:
start = date(today.year, today.month, 1)
label = f'{today.year}-{today.month:02d}'
return start, today, label
def send_due_reports(today: date | None = None) -> int:
if not get_bool_setting('reports_enabled', True):
return 0
today = today or date.today()
sent = 0
users = User.query.filter(User.report_frequency.in_(['daily', 'weekly', 'monthly'])).all()
for user in users:
start, end, label = _range_for_frequency(user.report_frequency, today)
if ReportLog.query.filter_by(user_id=user.id, frequency=user.report_frequency, period_label=label).first():
continue
q = Expense.query.filter_by(user_id=user.id, is_deleted=False).filter(Expense.purchase_date >= start, Expense.purchase_date <= end)
rows = q.order_by(Expense.purchase_date.desc()).all()
total = sum((expense.amount for expense in rows), Decimal('0.00'))
MailService().send_template(user.email, f'Expense report {label}', 'expense_report', user=user, period_label=label, total=total, currency=user.default_currency, expenses=rows[:10])
db.session.add(ReportLog(user_id=user.id, frequency=user.report_frequency, period_label=label))
sent += 1
db.session.commit()
return sent

32
app/services/scheduler.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from apscheduler.schedulers.background import BackgroundScheduler
from .reporting import send_due_reports
from .settings import get_bool_setting
_scheduler: BackgroundScheduler | None = None
def get_scheduler() -> BackgroundScheduler:
global _scheduler
if _scheduler is None:
_scheduler = BackgroundScheduler(timezone='UTC')
return _scheduler
def start_scheduler(app) -> None:
if app.config.get('TESTING'):
return
if not get_bool_setting('report_scheduler_enabled', False):
return
scheduler = get_scheduler()
if scheduler.running:
return
def _job():
with app.app_context():
send_due_reports()
scheduler.add_job(_job, 'interval', minutes=60, id='send_due_reports', replace_existing=True)
scheduler.start()

21
app/services/settings.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from ..models import AppSetting
def get_bool_setting(key: str, default: bool = False) -> bool:
raw = AppSetting.get(key, 'true' if default else 'false')
return str(raw).lower() == 'true'
def get_int_setting(key: str, default: int) -> int:
raw = AppSetting.get(key)
try:
return int(raw) if raw is not None else default
except (TypeError, ValueError):
return default
def get_str_setting(key: str, default: str = '') -> str:
raw = AppSetting.get(key)
return str(raw) if raw is not None else default