init commit
This commit is contained in:
14
app/services/__init__.py
Normal file
14
app/services/__init__.py
Normal 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",
|
||||
]
|
||||
5
app/services/analytics/__init__.py
Normal file
5
app/services/analytics/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Analytics service module"""
|
||||
|
||||
from app.services.analytics.report_service import ReportService
|
||||
|
||||
__all__ = ["ReportService"]
|
||||
111
app/services/analytics/report_service.py
Normal file
111
app/services/analytics/report_service.py
Normal 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,
|
||||
}
|
||||
63
app/services/auth_service.py
Normal file
63
app/services/auth_service.py
Normal 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")
|
||||
13
app/services/finance/__init__.py
Normal file
13
app/services/finance/__init__.py
Normal 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",
|
||||
]
|
||||
60
app/services/finance/account_service.py
Normal file
60
app/services/finance/account_service.py
Normal 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,
|
||||
}
|
||||
67
app/services/finance/budget_service.py
Normal file
67
app/services/finance/budget_service.py
Normal 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)
|
||||
64
app/services/finance/goal_service.py
Normal file
64
app/services/finance/goal_service.py
Normal 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()
|
||||
)
|
||||
94
app/services/finance/transaction_service.py
Normal file
94
app/services/finance/transaction_service.py
Normal 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
|
||||
5
app/services/notifications/__init__.py
Normal file
5
app/services/notifications/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications service module"""
|
||||
|
||||
from app.services.notifications.notification_service import NotificationService
|
||||
|
||||
__all__ = ["NotificationService"]
|
||||
57
app/services/notifications/notification_service.py
Normal file
57
app/services/notifications/notification_service.py
Normal 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}"
|
||||
145
app/services/transaction_service.py
Normal file
145
app/services/transaction_service.py
Normal 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",
|
||||
}
|
||||
Reference in New Issue
Block a user