init commit

This commit is contained in:
2025-12-10 22:09:31 +09:00
commit b79adf1c69
361 changed files with 47414 additions and 0 deletions

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

@@ -0,0 +1,14 @@
"""Main services package"""
from app.services.finance import TransactionService, BudgetService, GoalService, AccountService
from app.services.analytics import ReportService
from app.services.notifications import NotificationService
__all__ = [
"TransactionService",
"BudgetService",
"GoalService",
"AccountService",
"ReportService",
"NotificationService",
]

View File

@@ -0,0 +1,5 @@
"""Analytics service module"""
from app.services.analytics.report_service import ReportService
__all__ = ["ReportService"]

View File

@@ -0,0 +1,111 @@
"""Report service for analytics"""
from typing import List, Dict
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.db.repositories import TransactionRepository, CategoryRepository
from app.db.models import TransactionType
class ReportService:
"""Service for generating financial reports"""
def __init__(self, session: Session):
self.session = session
self.transaction_repo = TransactionRepository(session)
self.category_repo = CategoryRepository(session)
def get_expenses_by_category(
self, family_id: int, start_date: datetime, end_date: datetime
) -> Dict[str, float]:
"""Get expense breakdown by category"""
transactions = self.transaction_repo.get_transactions_by_period(
family_id, start_date, end_date
)
expenses_by_category = {}
for transaction in transactions:
if transaction.transaction_type == TransactionType.EXPENSE:
category_name = transaction.category.name if transaction.category else "Без категории"
if category_name not in expenses_by_category:
expenses_by_category[category_name] = 0
expenses_by_category[category_name] += transaction.amount
# Sort by amount descending
return dict(sorted(expenses_by_category.items(), key=lambda x: x[1], reverse=True))
def get_expenses_by_user(
self, family_id: int, start_date: datetime, end_date: datetime
) -> Dict[str, float]:
"""Get expense breakdown by user"""
transactions = self.transaction_repo.get_transactions_by_period(
family_id, start_date, end_date
)
expenses_by_user = {}
for transaction in transactions:
if transaction.transaction_type == TransactionType.EXPENSE:
user_name = f"{transaction.user.first_name or ''} {transaction.user.last_name or ''}".strip()
if not user_name:
user_name = transaction.user.username or f"User {transaction.user.id}"
if user_name not in expenses_by_user:
expenses_by_user[user_name] = 0
expenses_by_user[user_name] += transaction.amount
return dict(sorted(expenses_by_user.items(), key=lambda x: x[1], reverse=True))
def get_daily_expenses(
self, family_id: int, days: int = 30
) -> Dict[str, float]:
"""Get daily expenses for period"""
end_date = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = end_date - timedelta(days=days)
transactions = self.transaction_repo.get_transactions_by_period(
family_id, start_date, end_date
)
daily_expenses = {}
for transaction in transactions:
if transaction.transaction_type == TransactionType.EXPENSE:
date_key = transaction.transaction_date.date().isoformat()
if date_key not in daily_expenses:
daily_expenses[date_key] = 0
daily_expenses[date_key] += transaction.amount
return dict(sorted(daily_expenses.items()))
def get_month_comparison(self, family_id: int) -> Dict[str, float]:
"""Compare expenses: current month vs last month"""
today = datetime.utcnow()
current_month_start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Last month
last_month_end = current_month_start - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
current_transactions = self.transaction_repo.get_transactions_by_period(
family_id, current_month_start, today
)
last_transactions = self.transaction_repo.get_transactions_by_period(
family_id, last_month_start, last_month_end
)
current_expenses = sum(
t.amount for t in current_transactions
if t.transaction_type == TransactionType.EXPENSE
)
last_expenses = sum(
t.amount for t in last_transactions
if t.transaction_type == TransactionType.EXPENSE
)
difference = current_expenses - last_expenses
percent_change = ((difference / last_expenses * 100) if last_expenses > 0 else 0)
return {
"current_month": current_expenses,
"last_month": last_expenses,
"difference": difference,
"percent_change": percent_change,
}

View File

@@ -0,0 +1,63 @@
"""
Authentication Service - User login, token management
"""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import secrets
from sqlalchemy.orm import Session
from app.db.models import User
from app.security.jwt_manager import jwt_manager
import logging
logger = logging.getLogger(__name__)
class AuthService:
"""Handles user authentication and token management"""
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
BINDING_CODE_LENGTH = 24
def __init__(self, db: Session):
self.db = db
async def create_telegram_binding_code(self, chat_id: int) -> str:
"""Generate temporary code for Telegram user binding"""
code = secrets.token_urlsafe(self.BINDING_CODE_LENGTH)
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
return code
async def login(self, email: str, password: str) -> Dict[str, Any]:
"""Authenticate user with email/password"""
user = self.db.query(User).filter_by(email=email).first()
if not user:
raise ValueError("User not found")
# In production: verify password with bcrypt
# For MVP: simple comparison (change this!)
access_token = jwt_manager.create_access_token(user_id=user.id)
logger.info(f"User {user.id} logged in")
return {
"user_id": user.id,
"access_token": access_token,
"token_type": "bearer",
}
async def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token"""
try:
payload = jwt_manager.verify_token(refresh_token)
new_token = jwt_manager.create_access_token(user_id=payload.user_id)
return {
"access_token": new_token,
"token_type": "bearer",
}
except Exception as e:
logger.error(f"Token refresh failed: {e}")
raise ValueError("Invalid refresh token")

View File

@@ -0,0 +1,13 @@
"""Finance service module"""
from app.services.finance.transaction_service import TransactionService
from app.services.finance.budget_service import BudgetService
from app.services.finance.goal_service import GoalService
from app.services.finance.account_service import AccountService
__all__ = [
"TransactionService",
"BudgetService",
"GoalService",
"AccountService",
]

View File

@@ -0,0 +1,60 @@
"""Account service"""
from typing import Optional, List
from sqlalchemy.orm import Session
from app.db.repositories import AccountRepository
from app.db.models import Account
from app.schemas import AccountCreateSchema
class AccountService:
"""Service for account operations"""
def __init__(self, session: Session):
self.session = session
self.account_repo = AccountRepository(session)
def create_account(self, family_id: int, owner_id: int, data: AccountCreateSchema) -> Account:
"""Create new account"""
return self.account_repo.create(
family_id=family_id,
owner_id=owner_id,
name=data.name,
account_type=data.account_type,
description=data.description,
balance=data.initial_balance,
initial_balance=data.initial_balance,
)
def transfer_between_accounts(
self, from_account_id: int, to_account_id: int, amount: float
) -> bool:
"""Transfer money between accounts"""
from_account = self.account_repo.update_balance(from_account_id, -amount)
to_account = self.account_repo.update_balance(to_account_id, amount)
return from_account is not None and to_account is not None
def get_family_total_balance(self, family_id: int) -> float:
"""Get total balance of all family accounts"""
accounts = self.account_repo.get_family_accounts(family_id)
return sum(acc.balance for acc in accounts)
def archive_account(self, account_id: int) -> Optional[Account]:
"""Archive account (hide but keep data)"""
return self.account_repo.archive_account(account_id)
def get_account_summary(self, account_id: int) -> dict:
"""Get account summary"""
account = self.account_repo.get_by_id(account_id)
if not account:
return {}
return {
"account_id": account.id,
"name": account.name,
"type": account.account_type,
"balance": account.balance,
"is_active": account.is_active,
"is_archived": account.is_archived,
"created_at": account.created_at,
}

View File

@@ -0,0 +1,67 @@
"""Budget service"""
from typing import Optional, List
from datetime import datetime
from sqlalchemy.orm import Session
from app.db.repositories import BudgetRepository, TransactionRepository, CategoryRepository
from app.db.models import Budget, TransactionType
from app.schemas import BudgetCreateSchema
class BudgetService:
"""Service for budget operations"""
def __init__(self, session: Session):
self.session = session
self.budget_repo = BudgetRepository(session)
self.transaction_repo = TransactionRepository(session)
self.category_repo = CategoryRepository(session)
def create_budget(self, family_id: int, data: BudgetCreateSchema) -> Budget:
"""Create new budget"""
return self.budget_repo.create(
family_id=family_id,
name=data.name,
limit_amount=data.limit_amount,
period=data.period,
alert_threshold=data.alert_threshold,
category_id=data.category_id,
start_date=data.start_date,
)
def get_budget_status(self, budget_id: int) -> dict:
"""Get budget status with spent amount and percentage"""
budget = self.budget_repo.get_by_id(budget_id)
if not budget:
return {}
spent_percent = (budget.spent_amount / budget.limit_amount * 100) if budget.limit_amount > 0 else 0
remaining = budget.limit_amount - budget.spent_amount
is_exceeded = spent_percent > 100
is_warning = spent_percent >= budget.alert_threshold
return {
"budget_id": budget.id,
"name": budget.name,
"limit": budget.limit_amount,
"spent": budget.spent_amount,
"remaining": remaining,
"spent_percent": spent_percent,
"is_exceeded": is_exceeded,
"is_warning": is_warning,
"alert_threshold": budget.alert_threshold,
}
def get_family_budget_status(self, family_id: int) -> List[dict]:
"""Get status of all budgets in family"""
budgets = self.budget_repo.get_family_budgets(family_id)
return [self.get_budget_status(budget.id) for budget in budgets]
def check_budget_exceeded(self, budget_id: int) -> bool:
"""Check if budget limit exceeded"""
status = self.get_budget_status(budget_id)
return status.get("is_exceeded", False)
def reset_budget(self, budget_id: int) -> Optional[Budget]:
"""Reset budget spent amount for new period"""
return self.budget_repo.update(budget_id, spent_amount=0.0)

View File

@@ -0,0 +1,64 @@
"""Goal service"""
from typing import Optional, List
from sqlalchemy.orm import Session
from app.db.repositories import GoalRepository
from app.db.models import Goal
from app.schemas import GoalCreateSchema
class GoalService:
"""Service for goal operations"""
def __init__(self, session: Session):
self.session = session
self.goal_repo = GoalRepository(session)
def create_goal(self, family_id: int, data: GoalCreateSchema) -> Goal:
"""Create new savings goal"""
return self.goal_repo.create(
family_id=family_id,
name=data.name,
description=data.description,
target_amount=data.target_amount,
priority=data.priority,
target_date=data.target_date,
account_id=data.account_id,
)
def add_to_goal(self, goal_id: int, amount: float) -> Optional[Goal]:
"""Add amount to goal progress"""
return self.goal_repo.update_progress(goal_id, amount)
def get_goal_progress(self, goal_id: int) -> dict:
"""Get goal progress information"""
goal = self.goal_repo.get_by_id(goal_id)
if not goal:
return {}
progress_percent = (goal.current_amount / goal.target_amount * 100) if goal.target_amount > 0 else 0
return {
"goal_id": goal.id,
"name": goal.name,
"target": goal.target_amount,
"current": goal.current_amount,
"remaining": goal.target_amount - goal.current_amount,
"progress_percent": progress_percent,
"is_completed": goal.is_completed,
"target_date": goal.target_date,
}
def get_family_goals_progress(self, family_id: int) -> List[dict]:
"""Get progress for all family goals"""
goals = self.goal_repo.get_family_goals(family_id)
return [self.get_goal_progress(goal.id) for goal in goals]
def complete_goal(self, goal_id: int) -> Optional[Goal]:
"""Mark goal as completed"""
from datetime import datetime
return self.goal_repo.update(
goal_id,
is_completed=True,
completed_at=datetime.utcnow()
)

View File

@@ -0,0 +1,94 @@
"""Transaction service"""
from typing import Optional, List
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.db.repositories import TransactionRepository, AccountRepository, BudgetRepository
from app.db.models import Transaction, TransactionType
from app.schemas import TransactionCreateSchema
class TransactionService:
"""Service for transaction operations"""
def __init__(self, session: Session):
self.session = session
self.transaction_repo = TransactionRepository(session)
self.account_repo = AccountRepository(session)
self.budget_repo = BudgetRepository(session)
def create_transaction(
self,
family_id: int,
user_id: int,
account_id: int,
data: TransactionCreateSchema,
) -> Transaction:
"""Create new transaction and update account balance"""
# Create transaction
transaction = self.transaction_repo.create(
family_id=family_id,
user_id=user_id,
account_id=account_id,
amount=data.amount,
transaction_type=data.transaction_type,
description=data.description,
notes=data.notes,
tags=data.tags,
category_id=data.category_id,
receipt_photo_url=data.receipt_photo_url,
transaction_date=data.transaction_date,
)
# Update account balance
if data.transaction_type == TransactionType.EXPENSE:
self.account_repo.update_balance(account_id, -data.amount)
elif data.transaction_type == TransactionType.INCOME:
self.account_repo.update_balance(account_id, data.amount)
# Update budget if expense
if (
data.transaction_type == TransactionType.EXPENSE
and data.category_id
):
budget = self.budget_repo.get_category_budget(family_id, data.category_id)
if budget:
self.budget_repo.update_spent_amount(budget.id, data.amount)
return transaction
def get_family_summary(self, family_id: int, days: int = 30) -> dict:
"""Get financial summary for family"""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
transactions = self.transaction_repo.get_transactions_by_period(
family_id, start_date, end_date
)
income = sum(t.amount for t in transactions if t.transaction_type == TransactionType.INCOME)
expenses = sum(t.amount for t in transactions if t.transaction_type == TransactionType.EXPENSE)
net = income - expenses
return {
"period_days": days,
"income": income,
"expenses": expenses,
"net": net,
"average_daily_expense": expenses / days if days > 0 else 0,
"transaction_count": len(transactions),
}
def delete_transaction(self, transaction_id: int) -> bool:
"""Delete transaction and rollback balance"""
transaction = self.transaction_repo.get_by_id(transaction_id)
if transaction:
# Rollback balance
if transaction.transaction_type == TransactionType.EXPENSE:
self.account_repo.update_balance(transaction.account_id, transaction.amount)
elif transaction.transaction_type == TransactionType.INCOME:
self.account_repo.update_balance(transaction.account_id, -transaction.amount)
# Delete transaction
return self.transaction_repo.delete(transaction_id)
return False

View File

@@ -0,0 +1,5 @@
"""Notifications service module"""
from app.services.notifications.notification_service import NotificationService
__all__ = ["NotificationService"]

View File

@@ -0,0 +1,57 @@
"""Notification service"""
from typing import Optional
from sqlalchemy.orm import Session
from app.db.models import Family
class NotificationService:
"""Service for managing notifications"""
def __init__(self, session: Session):
self.session = session
def should_notify(self, family: Family, notification_type: str) -> bool:
"""Check if notification should be sent based on family settings"""
if family.notification_level == "none":
return False
elif family.notification_level == "important":
return notification_type in ["budget_exceeded", "goal_completed"]
else: # all
return True
def format_transaction_notification(
self, user_name: str, amount: float, category: str, account: str
) -> str:
"""Format transaction notification message"""
return (
f"💰 {user_name} добавил запись:\n"
f"Сумма: {amount}\n"
f"Категория: {category}\n"
f"Счет: {account}"
)
def format_budget_warning(
self, budget_name: str, spent: float, limit: float, percent: float
) -> str:
"""Format budget warning message"""
return (
f"⚠️ Внимание по бюджету!\n"
f"Бюджет: {budget_name}\n"
f"Потрачено: {spent}₽ из {limit}\n"
f"Превышено на: {percent:.1f}%"
)
def format_goal_progress(
self, goal_name: str, current: float, target: float, percent: float
) -> str:
"""Format goal progress message"""
return (
f"🎯 Прогресс цели: {goal_name}\n"
f"Накоплено: {current}₽ из {target}\n"
f"Прогресс: {percent:.1f}%"
)
def format_goal_completed(self, goal_name: str) -> str:
"""Format goal completion message"""
return f"✅ Цель достигнута! 🎉\n{goal_name}"

View File

@@ -0,0 +1,145 @@
"""
Transaction Service - Core business logic
Handles transaction creation, approval, reversal with audit trail
"""
from datetime import datetime
from typing import Optional, Dict, Any
from decimal import Decimal
import logging
from sqlalchemy.orm import Session
from app.db.models import Transaction, Account, Family, User
from app.security.rbac import RBACEngine, Permission, UserContext
logger = logging.getLogger(__name__)
class TransactionService:
"""Manages financial transactions with approval workflow"""
APPROVAL_THRESHOLD = 500.0
def __init__(self, db: Session):
self.db = db
async def create_transaction(
self,
user_context: UserContext,
family_id: int,
from_account_id: Optional[int],
to_account_id: Optional[int],
amount: Decimal,
category_id: Optional[int] = None,
description: str = "",
requires_approval: bool = False,
) -> Dict[str, Any]:
"""Create new transaction"""
RBACEngine.check_permission(user_context, Permission.CREATE_TRANSACTION)
RBACEngine.check_family_access(user_context, family_id)
if amount <= 0:
raise ValueError("Amount must be positive")
needs_approval = requires_approval or (float(amount) > self.APPROVAL_THRESHOLD and user_context.role.value != "owner")
tx_status = "pending_approval" if needs_approval else "executed"
transaction = Transaction(
family_id=family_id,
created_by_id=user_context.user_id,
from_account_id=from_account_id,
to_account_id=to_account_id,
amount=float(amount),
category_id=category_id,
description=description,
created_at=datetime.utcnow(),
)
self.db.add(transaction)
self.db.commit()
logger.info(f"Transaction created: {transaction.id}")
return {
"id": transaction.id,
"status": tx_status,
"amount": float(amount),
"requires_approval": needs_approval,
}
async def confirm_transaction(
self,
user_context: UserContext,
transaction_id: int,
family_id: int,
) -> Dict[str, Any]:
"""Approve pending transaction"""
RBACEngine.check_permission(user_context, Permission.APPROVE_TRANSACTION)
RBACEngine.check_family_access(user_context, family_id)
tx = self.db.query(Transaction).filter_by(
id=transaction_id,
family_id=family_id,
).first()
if not tx:
raise ValueError(f"Transaction {transaction_id} not found")
tx.status = "executed"
tx.approved_by_id = user_context.user_id
tx.approved_at = datetime.utcnow()
self.db.commit()
logger.info(f"Transaction {transaction_id} approved")
return {
"id": tx.id,
"status": "executed",
}
async def reverse_transaction(
self,
user_context: UserContext,
transaction_id: int,
family_id: int,
) -> Dict[str, Any]:
"""Reverse transaction by creating compensation"""
RBACEngine.check_permission(user_context, Permission.REVERSE_TRANSACTION)
RBACEngine.check_family_access(user_context, family_id)
original = self.db.query(Transaction).filter_by(
id=transaction_id,
family_id=family_id,
).first()
if not original:
raise ValueError(f"Transaction {transaction_id} not found")
if original.status == "reversed":
raise ValueError("Transaction already reversed")
reversal = Transaction(
family_id=family_id,
created_by_id=user_context.user_id,
from_account_id=original.to_account_id,
to_account_id=original.from_account_id,
amount=original.amount,
category_id=original.category_id,
description=f"Reversal of transaction #{original.id}",
status="executed",
created_at=datetime.utcnow(),
)
original.status = "reversed"
original.reversed_at = datetime.utcnow()
original.reversed_by_id = user_context.user_id
self.db.add(reversal)
self.db.commit()
logger.info(f"Transaction {transaction_id} reversed, created {reversal.id}")
return {
"original_id": original.id,
"reversal_id": reversal.id,
"status": "reversed",
}