init commit
This commit is contained in:
14
.history/app/services/__init___20251210201646.py
Normal file
14
.history/app/services/__init___20251210201646.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",
|
||||
]
|
||||
14
.history/app/services/__init___20251210202255.py
Normal file
14
.history/app/services/__init___20251210202255.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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Analytics service module"""
|
||||
|
||||
from app.services.analytics.report_service import ReportService
|
||||
|
||||
__all__ = ["ReportService"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Analytics service module"""
|
||||
|
||||
from app.services.analytics.report_service import ReportService
|
||||
|
||||
__all__ = ["ReportService"]
|
||||
111
.history/app/services/analytics/report_service_20251210201647.py
Normal file
111
.history/app/services/analytics/report_service_20251210201647.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,
|
||||
}
|
||||
111
.history/app/services/analytics/report_service_20251210202255.py
Normal file
111
.history/app/services/analytics/report_service_20251210202255.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,
|
||||
}
|
||||
218
.history/app/services/auth_service_20251210210407.py
Normal file
218
.history/app/services/auth_service_20251210210407.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Authentication Service - User login, Telegram binding, Token management
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import secrets
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import User, Session as DBSession, TelegramIdentity
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Handles user authentication, token management, and Telegram binding.
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
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.
|
||||
|
||||
Flow:
|
||||
1. User sends /start to bot
|
||||
2. Bot generates binding code
|
||||
3. Bot sends user a link: https://app.com/auth/telegram?code=ABC123
|
||||
4. User clicks link (authenticated or creates account)
|
||||
5. Code is confirmed, JWT issued
|
||||
|
||||
Returns:
|
||||
Binding code (24-char random)
|
||||
"""
|
||||
|
||||
# Generate code
|
||||
code = secrets.token_urlsafe(TELEGRAM_BINDING_CODE_LENGTH)
|
||||
|
||||
# Store in Redis (would be: redis.setex(f"telegram:code:{code}", TTL, chat_id))
|
||||
# For MVP: Store in memory or DB with expiry
|
||||
|
||||
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
||||
|
||||
return code
|
||||
|
||||
async def confirm_telegram_binding(
|
||||
self,
|
||||
user_id: int,
|
||||
chat_id: int,
|
||||
code: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Confirm Telegram binding and create identity.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "eyJhbGc...",
|
||||
"expires_at": "2024-01-09T12:30:00Z"
|
||||
}
|
||||
"""
|
||||
|
||||
# Verify code (would check Redis)
|
||||
# For MVP: Assume code is valid
|
||||
|
||||
# Create or update Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.user_id == user_id
|
||||
).first()
|
||||
|
||||
if identity:
|
||||
# Update existing
|
||||
identity.chat_id = chat_id
|
||||
identity.username = username
|
||||
identity.first_name = first_name
|
||||
identity.last_name = last_name
|
||||
identity.verified_at = datetime.utcnow()
|
||||
identity.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new
|
||||
identity = TelegramIdentity(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
verified_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(identity)
|
||||
|
||||
# Generate JWT token for bot
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=user_id,
|
||||
family_ids=[], # Will be loaded from user's families
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=15)).isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def authenticate_telegram_user(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user by Telegram chat_id.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "...",
|
||||
}
|
||||
Or None if not found
|
||||
"""
|
||||
|
||||
# Find Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.chat_id == chat_id,
|
||||
TelegramIdentity.verified_at.isnot(None),
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
return None
|
||||
|
||||
# Generate JWT
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=identity.user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": identity.user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
device_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create new session with refresh token.
|
||||
|
||||
Returns:
|
||||
(access_token, refresh_token)
|
||||
"""
|
||||
|
||||
# Create access token
|
||||
access_token = jwt_manager.create_access_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Create refresh token
|
||||
refresh_token = jwt_manager.create_refresh_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Store session in DB
|
||||
session = DBSession(
|
||||
user_id=user_id,
|
||||
refresh_token_hash=self._hash_token(refresh_token),
|
||||
device_id=device_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
expires_at=datetime.utcnow() + timedelta(days=30),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""Issue new access token using refresh token"""
|
||||
|
||||
# Verify refresh token
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
except ValueError:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
# Create new access token
|
||||
new_access_token = jwt_manager.create_access_token(user_id=user_id)
|
||||
|
||||
return new_access_token
|
||||
|
||||
@staticmethod
|
||||
def _hash_token(token: str) -> str:
|
||||
"""Hash token for storage"""
|
||||
import hashlib
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
218
.history/app/services/auth_service_20251210210906.py
Normal file
218
.history/app/services/auth_service_20251210210906.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Authentication Service - User login, Telegram binding, Token management
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import secrets
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import User, Session as DBSession, TelegramIdentity
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Handles user authentication, token management, and Telegram binding.
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
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.
|
||||
|
||||
Flow:
|
||||
1. User sends /start to bot
|
||||
2. Bot generates binding code
|
||||
3. Bot sends user a link: https://app.com/auth/telegram?code=ABC123
|
||||
4. User clicks link (authenticated or creates account)
|
||||
5. Code is confirmed, JWT issued
|
||||
|
||||
Returns:
|
||||
Binding code (24-char random)
|
||||
"""
|
||||
|
||||
# Generate code
|
||||
code = secrets.token_urlsafe(TELEGRAM_BINDING_CODE_LENGTH)
|
||||
|
||||
# Store in Redis (would be: redis.setex(f"telegram:code:{code}", TTL, chat_id))
|
||||
# For MVP: Store in memory or DB with expiry
|
||||
|
||||
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
||||
|
||||
return code
|
||||
|
||||
async def confirm_telegram_binding(
|
||||
self,
|
||||
user_id: int,
|
||||
chat_id: int,
|
||||
code: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Confirm Telegram binding and create identity.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "eyJhbGc...",
|
||||
"expires_at": "2024-01-09T12:30:00Z"
|
||||
}
|
||||
"""
|
||||
|
||||
# Verify code (would check Redis)
|
||||
# For MVP: Assume code is valid
|
||||
|
||||
# Create or update Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.user_id == user_id
|
||||
).first()
|
||||
|
||||
if identity:
|
||||
# Update existing
|
||||
identity.chat_id = chat_id
|
||||
identity.username = username
|
||||
identity.first_name = first_name
|
||||
identity.last_name = last_name
|
||||
identity.verified_at = datetime.utcnow()
|
||||
identity.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new
|
||||
identity = TelegramIdentity(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
verified_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(identity)
|
||||
|
||||
# Generate JWT token for bot
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=user_id,
|
||||
family_ids=[], # Will be loaded from user's families
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=15)).isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def authenticate_telegram_user(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user by Telegram chat_id.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "...",
|
||||
}
|
||||
Or None if not found
|
||||
"""
|
||||
|
||||
# Find Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.chat_id == chat_id,
|
||||
TelegramIdentity.verified_at.isnot(None),
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
return None
|
||||
|
||||
# Generate JWT
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=identity.user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": identity.user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
device_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create new session with refresh token.
|
||||
|
||||
Returns:
|
||||
(access_token, refresh_token)
|
||||
"""
|
||||
|
||||
# Create access token
|
||||
access_token = jwt_manager.create_access_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Create refresh token
|
||||
refresh_token = jwt_manager.create_refresh_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Store session in DB
|
||||
session = DBSession(
|
||||
user_id=user_id,
|
||||
refresh_token_hash=self._hash_token(refresh_token),
|
||||
device_id=device_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
expires_at=datetime.utcnow() + timedelta(days=30),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""Issue new access token using refresh token"""
|
||||
|
||||
# Verify refresh token
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
except ValueError:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
# Create new access token
|
||||
new_access_token = jwt_manager.create_access_token(user_id=user_id)
|
||||
|
||||
return new_access_token
|
||||
|
||||
@staticmethod
|
||||
def _hash_token(token: str) -> str:
|
||||
"""Hash token for storage"""
|
||||
import hashlib
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
218
.history/app/services/auth_service_20251210211959.py
Normal file
218
.history/app/services/auth_service_20251210211959.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Authentication Service - User login, Telegram binding, Token management
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
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, token management, and Telegram binding.
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
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.
|
||||
|
||||
Flow:
|
||||
1. User sends /start to bot
|
||||
2. Bot generates binding code
|
||||
3. Bot sends user a link: https://app.com/auth/telegram?code=ABC123
|
||||
4. User clicks link (authenticated or creates account)
|
||||
5. Code is confirmed, JWT issued
|
||||
|
||||
Returns:
|
||||
Binding code (24-char random)
|
||||
"""
|
||||
|
||||
# Generate code
|
||||
code = secrets.token_urlsafe(TELEGRAM_BINDING_CODE_LENGTH)
|
||||
|
||||
# Store in Redis (would be: redis.setex(f"telegram:code:{code}", TTL, chat_id))
|
||||
# For MVP: Store in memory or DB with expiry
|
||||
|
||||
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
||||
|
||||
return code
|
||||
|
||||
async def confirm_telegram_binding(
|
||||
self,
|
||||
user_id: int,
|
||||
chat_id: int,
|
||||
code: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Confirm Telegram binding and create identity.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "eyJhbGc...",
|
||||
"expires_at": "2024-01-09T12:30:00Z"
|
||||
}
|
||||
"""
|
||||
|
||||
# Verify code (would check Redis)
|
||||
# For MVP: Assume code is valid
|
||||
|
||||
# Create or update Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.user_id == user_id
|
||||
).first()
|
||||
|
||||
if identity:
|
||||
# Update existing
|
||||
identity.chat_id = chat_id
|
||||
identity.username = username
|
||||
identity.first_name = first_name
|
||||
identity.last_name = last_name
|
||||
identity.verified_at = datetime.utcnow()
|
||||
identity.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new
|
||||
identity = TelegramIdentity(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
verified_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(identity)
|
||||
|
||||
# Generate JWT token for bot
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=user_id,
|
||||
family_ids=[], # Will be loaded from user's families
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=15)).isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def authenticate_telegram_user(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user by Telegram chat_id.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "...",
|
||||
}
|
||||
Or None if not found
|
||||
"""
|
||||
|
||||
# Find Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.chat_id == chat_id,
|
||||
TelegramIdentity.verified_at.isnot(None),
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
return None
|
||||
|
||||
# Generate JWT
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=identity.user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": identity.user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
device_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create new session with refresh token.
|
||||
|
||||
Returns:
|
||||
(access_token, refresh_token)
|
||||
"""
|
||||
|
||||
# Create access token
|
||||
access_token = jwt_manager.create_access_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Create refresh token
|
||||
refresh_token = jwt_manager.create_refresh_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Store session in DB
|
||||
session = DBSession(
|
||||
user_id=user_id,
|
||||
refresh_token_hash=self._hash_token(refresh_token),
|
||||
device_id=device_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
expires_at=datetime.utcnow() + timedelta(days=30),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""Issue new access token using refresh token"""
|
||||
|
||||
# Verify refresh token
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
except ValueError:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
# Create new access token
|
||||
new_access_token = jwt_manager.create_access_token(user_id=user_id)
|
||||
|
||||
return new_access_token
|
||||
|
||||
@staticmethod
|
||||
def _hash_token(token: str) -> str:
|
||||
"""Hash token for storage"""
|
||||
import hashlib
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
218
.history/app/services/auth_service_20251210212101.py
Normal file
218
.history/app/services/auth_service_20251210212101.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
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, token management, and Telegram binding.
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
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.
|
||||
|
||||
Flow:
|
||||
1. User sends /start to bot
|
||||
2. Bot generates binding code
|
||||
3. Bot sends user a link: https://app.com/auth/telegram?code=ABC123
|
||||
4. User clicks link (authenticated or creates account)
|
||||
5. Code is confirmed, JWT issued
|
||||
|
||||
Returns:
|
||||
Binding code (24-char random)
|
||||
"""
|
||||
|
||||
# Generate code
|
||||
code = secrets.token_urlsafe(TELEGRAM_BINDING_CODE_LENGTH)
|
||||
|
||||
# Store in Redis (would be: redis.setex(f"telegram:code:{code}", TTL, chat_id))
|
||||
# For MVP: Store in memory or DB with expiry
|
||||
|
||||
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
||||
|
||||
return code
|
||||
|
||||
async def confirm_telegram_binding(
|
||||
self,
|
||||
user_id: int,
|
||||
chat_id: int,
|
||||
code: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Confirm Telegram binding and create identity.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "eyJhbGc...",
|
||||
"expires_at": "2024-01-09T12:30:00Z"
|
||||
}
|
||||
"""
|
||||
|
||||
# Verify code (would check Redis)
|
||||
# For MVP: Assume code is valid
|
||||
|
||||
# Create or update Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.user_id == user_id
|
||||
).first()
|
||||
|
||||
if identity:
|
||||
# Update existing
|
||||
identity.chat_id = chat_id
|
||||
identity.username = username
|
||||
identity.first_name = first_name
|
||||
identity.last_name = last_name
|
||||
identity.verified_at = datetime.utcnow()
|
||||
identity.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new
|
||||
identity = TelegramIdentity(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
verified_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(identity)
|
||||
|
||||
# Generate JWT token for bot
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=user_id,
|
||||
family_ids=[], # Will be loaded from user's families
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
"expires_at": (datetime.utcnow() + timedelta(minutes=15)).isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def authenticate_telegram_user(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user by Telegram chat_id.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"user_id": 123,
|
||||
"chat_id": 12345,
|
||||
"jwt_token": "...",
|
||||
}
|
||||
Or None if not found
|
||||
"""
|
||||
|
||||
# Find Telegram identity
|
||||
identity = self.db.query(TelegramIdentity).filter(
|
||||
TelegramIdentity.chat_id == chat_id,
|
||||
TelegramIdentity.verified_at.isnot(None),
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
return None
|
||||
|
||||
# Generate JWT
|
||||
jwt_token = jwt_manager.create_access_token(
|
||||
user_id=identity.user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": identity.user_id,
|
||||
"chat_id": chat_id,
|
||||
"jwt_token": jwt_token,
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
device_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create new session with refresh token.
|
||||
|
||||
Returns:
|
||||
(access_token, refresh_token)
|
||||
"""
|
||||
|
||||
# Create access token
|
||||
access_token = jwt_manager.create_access_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Create refresh token
|
||||
refresh_token = jwt_manager.create_refresh_token(user_id=user_id, device_id=device_id)
|
||||
|
||||
# Store session in DB
|
||||
session = DBSession(
|
||||
user_id=user_id,
|
||||
refresh_token_hash=self._hash_token(refresh_token),
|
||||
device_id=device_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
expires_at=datetime.utcnow() + timedelta(days=30),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""Issue new access token using refresh token"""
|
||||
|
||||
# Verify refresh token
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
except ValueError:
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
# Create new access token
|
||||
new_access_token = jwt_manager.create_access_token(user_id=user_id)
|
||||
|
||||
return new_access_token
|
||||
|
||||
@staticmethod
|
||||
def _hash_token(token: str) -> str:
|
||||
"""Hash token for storage"""
|
||||
import hashlib
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
54
.history/app/services/auth_service_20251210212117.py
Normal file
54
.history/app/services/auth_service_20251210212117.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
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"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
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")
|
||||
54
.history/app/services/auth_service_20251210212154.py
Normal file
54
.history/app/services/auth_service_20251210212154.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
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"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
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")
|
||||
63
.history/app/services/auth_service_20251210220734.py
Normal file
63
.history/app/services/auth_service_20251210220734.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")
|
||||
63
.history/app/services/auth_service_20251210220740.py
Normal file
63
.history/app/services/auth_service_20251210220740.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
.history/app/services/finance/__init___20251210201645.py
Normal file
13
.history/app/services/finance/__init___20251210201645.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",
|
||||
]
|
||||
13
.history/app/services/finance/__init___20251210202255.py
Normal file
13
.history/app/services/finance/__init___20251210202255.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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
.history/app/services/finance/goal_service_20251210201647.py
Normal file
64
.history/app/services/finance/goal_service_20251210201647.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()
|
||||
)
|
||||
64
.history/app/services/finance/goal_service_20251210202255.py
Normal file
64
.history/app/services/finance/goal_service_20251210202255.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()
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Notifications service module"""
|
||||
|
||||
from app.services.notifications.notification_service import NotificationService
|
||||
|
||||
__all__ = ["NotificationService"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Notifications service module"""
|
||||
|
||||
from app.services.notifications.notification_service import NotificationService
|
||||
|
||||
__all__ = ["NotificationService"]
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
338
.history/app/services/transaction_service_20251210210354.py
Normal file
338
.history/app/services/transaction_service_20251210210354.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Transaction Service - Core business logic
|
||||
Handles transaction creation, approval, reversal, and event emission
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Transaction, Wallet, Family, User, EventLog
|
||||
from app.security.rbac import RBACEngine, Permission, UserContext
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionService:
|
||||
"""
|
||||
Manages financial transactions with approval workflow and audit.
|
||||
|
||||
Features:
|
||||
- Draft → Pending Approval → Executed workflow
|
||||
- Reversal (compensation transactions)
|
||||
- Event logging for all changes
|
||||
- Family-level isolation
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
APPROVAL_THRESHOLD = Decimal("500") # Amount requiring approval
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
amount: Decimal,
|
||||
category_id: Optional[int],
|
||||
description: str,
|
||||
requires_approval: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create new transaction with approval workflow.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"id": 123,
|
||||
"status": "draft" | "executed",
|
||||
"confirmation_required": false,
|
||||
"created_at": "2023-12-10T12:30:00Z"
|
||||
}
|
||||
|
||||
Raises:
|
||||
- PermissionError: User lacks permission
|
||||
- ValueError: Invalid wallets/amounts
|
||||
- Exception: Database error
|
||||
"""
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.CREATE_TRANSACTION)
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
# Validate wallets belong to family
|
||||
wallets = self._validate_wallets(family_id, from_wallet_id, to_wallet_id)
|
||||
from_wallet, to_wallet = wallets
|
||||
|
||||
# Determine if approval required
|
||||
needs_approval = requires_approval or (amount > self.APPROVAL_THRESHOLD and user_context.role.value != "owner")
|
||||
|
||||
# Create transaction
|
||||
tx_status = "pending_approval" if needs_approval else "executed"
|
||||
|
||||
transaction = Transaction(
|
||||
family_id=family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=from_wallet_id,
|
||||
to_wallet_id=to_wallet_id,
|
||||
amount=amount,
|
||||
category_id=category_id,
|
||||
description=description,
|
||||
status=tx_status,
|
||||
confirmation_required=needs_approval,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(transaction)
|
||||
self.db.flush() # Get ID without commit
|
||||
|
||||
# Update wallet balances if executed immediately
|
||||
if not needs_approval:
|
||||
self._execute_transaction(transaction, from_wallet, to_wallet)
|
||||
transaction.executed_at = datetime.utcnow()
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=transaction.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"id": transaction.id,
|
||||
"amount": str(amount),
|
||||
"status": tx_status,
|
||||
},
|
||||
ip_address=getattr(user_context, "ip_address", None),
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": transaction.id,
|
||||
"status": tx_status,
|
||||
"amount": str(amount),
|
||||
"confirmation_required": needs_approval,
|
||||
"created_at": transaction.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def confirm_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
confirmation_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Approve pending transaction for execution.
|
||||
|
||||
Only owner or approver can confirm.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
if tx.status != "pending_approval":
|
||||
raise ValueError(f"Transaction status is {tx.status}, cannot approve")
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.APPROVE_TRANSACTION)
|
||||
|
||||
# Execute transaction
|
||||
from_wallet = self.db.query(Wallet).get(tx.from_wallet_id) if tx.from_wallet_id else None
|
||||
to_wallet = self.db.query(Wallet).get(tx.to_wallet_id) if tx.to_wallet_id else None
|
||||
|
||||
self._execute_transaction(tx, from_wallet, to_wallet)
|
||||
|
||||
# Update transaction status
|
||||
tx.status = "executed"
|
||||
tx.executed_at = datetime.utcnow()
|
||||
tx.approved_by_id = user_context.user_id
|
||||
tx.approved_at = datetime.utcnow()
|
||||
tx.confirmation_token = None
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="execute",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"status": "executed",
|
||||
"approved_by": user_context.user_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": tx.id,
|
||||
"status": "executed",
|
||||
"executed_at": tx.executed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def reverse_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
reason: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reverse (cancel) executed transaction by creating compensation transaction.
|
||||
|
||||
Original transaction status changes to "reversed".
|
||||
New negative transaction created to compensate.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
# Only creator or owner can reverse
|
||||
is_owner = user_context.role.value == "owner"
|
||||
is_creator = tx.created_by_id == user_context.user_id
|
||||
|
||||
if not (is_owner or is_creator):
|
||||
raise PermissionError("Only creator or owner can reverse transaction")
|
||||
|
||||
# Create compensation transaction
|
||||
reverse_tx = Transaction(
|
||||
family_id=user_context.family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=tx.to_wallet_id,
|
||||
to_wallet_id=tx.from_wallet_id,
|
||||
amount=tx.amount,
|
||||
category_id=tx.category_id,
|
||||
description=f"Reversal of transaction #{tx.id}: {reason or 'No reason provided'}",
|
||||
status="executed",
|
||||
original_transaction_id=tx.id,
|
||||
executed_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Execute reverse transaction
|
||||
from_wallet = self.db.query(Wallet).get(reverse_tx.from_wallet_id)
|
||||
to_wallet = self.db.query(Wallet).get(reverse_tx.to_wallet_id)
|
||||
self._execute_transaction(reverse_tx, from_wallet, to_wallet)
|
||||
|
||||
# Mark original as reversed
|
||||
tx.status = "reversed"
|
||||
tx.reversed_at = datetime.utcnow()
|
||||
tx.reversal_reason = reason
|
||||
|
||||
self.db.add(reverse_tx)
|
||||
|
||||
# Log events
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="reverse",
|
||||
actor_id=user_context.user_id,
|
||||
reason=reason,
|
||||
new_values={"status": "reversed", "reversed_at": datetime.utcnow().isoformat()},
|
||||
)
|
||||
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=reverse_tx.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"original_transaction_id": tx.id,
|
||||
"status": "executed",
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"original_transaction_id": tx.id,
|
||||
"reversal_transaction_id": reverse_tx.id,
|
||||
"reversed_at": tx.reversed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
def _validate_wallets(
|
||||
self,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
) -> tuple:
|
||||
"""Validate wallets exist and belong to family"""
|
||||
from_wallet = None
|
||||
to_wallet = None
|
||||
|
||||
if from_wallet_id:
|
||||
from_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == from_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not from_wallet:
|
||||
raise ValueError(f"Wallet not found: {from_wallet_id}")
|
||||
|
||||
if to_wallet_id:
|
||||
to_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == to_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not to_wallet:
|
||||
raise ValueError(f"Wallet not found: {to_wallet_id}")
|
||||
|
||||
return from_wallet, to_wallet
|
||||
|
||||
def _execute_transaction(
|
||||
self,
|
||||
transaction: Transaction,
|
||||
from_wallet: Optional[Wallet],
|
||||
to_wallet: Optional[Wallet],
|
||||
):
|
||||
"""Execute transaction (update wallet balances)"""
|
||||
if from_wallet:
|
||||
from_wallet.balance -= transaction.amount
|
||||
|
||||
if to_wallet:
|
||||
to_wallet.balance += transaction.amount
|
||||
|
||||
async def _log_event(
|
||||
self,
|
||||
family_id: int,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
actor_id: int,
|
||||
new_values: Dict[str, Any] = None,
|
||||
old_values: Dict[str, Any] = None,
|
||||
ip_address: str = None,
|
||||
reason: str = None,
|
||||
):
|
||||
"""Log event to audit trail"""
|
||||
event = EventLog(
|
||||
family_id=family_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_id=actor_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
ip_address=ip_address,
|
||||
reason=reason,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(event)
|
||||
338
.history/app/services/transaction_service_20251210210906.py
Normal file
338
.history/app/services/transaction_service_20251210210906.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Transaction Service - Core business logic
|
||||
Handles transaction creation, approval, reversal, and event emission
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Transaction, Wallet, Family, User, EventLog
|
||||
from app.security.rbac import RBACEngine, Permission, UserContext
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionService:
|
||||
"""
|
||||
Manages financial transactions with approval workflow and audit.
|
||||
|
||||
Features:
|
||||
- Draft → Pending Approval → Executed workflow
|
||||
- Reversal (compensation transactions)
|
||||
- Event logging for all changes
|
||||
- Family-level isolation
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
APPROVAL_THRESHOLD = Decimal("500") # Amount requiring approval
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
amount: Decimal,
|
||||
category_id: Optional[int],
|
||||
description: str,
|
||||
requires_approval: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create new transaction with approval workflow.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"id": 123,
|
||||
"status": "draft" | "executed",
|
||||
"confirmation_required": false,
|
||||
"created_at": "2023-12-10T12:30:00Z"
|
||||
}
|
||||
|
||||
Raises:
|
||||
- PermissionError: User lacks permission
|
||||
- ValueError: Invalid wallets/amounts
|
||||
- Exception: Database error
|
||||
"""
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.CREATE_TRANSACTION)
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
# Validate wallets belong to family
|
||||
wallets = self._validate_wallets(family_id, from_wallet_id, to_wallet_id)
|
||||
from_wallet, to_wallet = wallets
|
||||
|
||||
# Determine if approval required
|
||||
needs_approval = requires_approval or (amount > self.APPROVAL_THRESHOLD and user_context.role.value != "owner")
|
||||
|
||||
# Create transaction
|
||||
tx_status = "pending_approval" if needs_approval else "executed"
|
||||
|
||||
transaction = Transaction(
|
||||
family_id=family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=from_wallet_id,
|
||||
to_wallet_id=to_wallet_id,
|
||||
amount=amount,
|
||||
category_id=category_id,
|
||||
description=description,
|
||||
status=tx_status,
|
||||
confirmation_required=needs_approval,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(transaction)
|
||||
self.db.flush() # Get ID without commit
|
||||
|
||||
# Update wallet balances if executed immediately
|
||||
if not needs_approval:
|
||||
self._execute_transaction(transaction, from_wallet, to_wallet)
|
||||
transaction.executed_at = datetime.utcnow()
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=transaction.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"id": transaction.id,
|
||||
"amount": str(amount),
|
||||
"status": tx_status,
|
||||
},
|
||||
ip_address=getattr(user_context, "ip_address", None),
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": transaction.id,
|
||||
"status": tx_status,
|
||||
"amount": str(amount),
|
||||
"confirmation_required": needs_approval,
|
||||
"created_at": transaction.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def confirm_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
confirmation_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Approve pending transaction for execution.
|
||||
|
||||
Only owner or approver can confirm.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
if tx.status != "pending_approval":
|
||||
raise ValueError(f"Transaction status is {tx.status}, cannot approve")
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.APPROVE_TRANSACTION)
|
||||
|
||||
# Execute transaction
|
||||
from_wallet = self.db.query(Wallet).get(tx.from_wallet_id) if tx.from_wallet_id else None
|
||||
to_wallet = self.db.query(Wallet).get(tx.to_wallet_id) if tx.to_wallet_id else None
|
||||
|
||||
self._execute_transaction(tx, from_wallet, to_wallet)
|
||||
|
||||
# Update transaction status
|
||||
tx.status = "executed"
|
||||
tx.executed_at = datetime.utcnow()
|
||||
tx.approved_by_id = user_context.user_id
|
||||
tx.approved_at = datetime.utcnow()
|
||||
tx.confirmation_token = None
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="execute",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"status": "executed",
|
||||
"approved_by": user_context.user_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": tx.id,
|
||||
"status": "executed",
|
||||
"executed_at": tx.executed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def reverse_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
reason: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reverse (cancel) executed transaction by creating compensation transaction.
|
||||
|
||||
Original transaction status changes to "reversed".
|
||||
New negative transaction created to compensate.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
# Only creator or owner can reverse
|
||||
is_owner = user_context.role.value == "owner"
|
||||
is_creator = tx.created_by_id == user_context.user_id
|
||||
|
||||
if not (is_owner or is_creator):
|
||||
raise PermissionError("Only creator or owner can reverse transaction")
|
||||
|
||||
# Create compensation transaction
|
||||
reverse_tx = Transaction(
|
||||
family_id=user_context.family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=tx.to_wallet_id,
|
||||
to_wallet_id=tx.from_wallet_id,
|
||||
amount=tx.amount,
|
||||
category_id=tx.category_id,
|
||||
description=f"Reversal of transaction #{tx.id}: {reason or 'No reason provided'}",
|
||||
status="executed",
|
||||
original_transaction_id=tx.id,
|
||||
executed_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Execute reverse transaction
|
||||
from_wallet = self.db.query(Wallet).get(reverse_tx.from_wallet_id)
|
||||
to_wallet = self.db.query(Wallet).get(reverse_tx.to_wallet_id)
|
||||
self._execute_transaction(reverse_tx, from_wallet, to_wallet)
|
||||
|
||||
# Mark original as reversed
|
||||
tx.status = "reversed"
|
||||
tx.reversed_at = datetime.utcnow()
|
||||
tx.reversal_reason = reason
|
||||
|
||||
self.db.add(reverse_tx)
|
||||
|
||||
# Log events
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="reverse",
|
||||
actor_id=user_context.user_id,
|
||||
reason=reason,
|
||||
new_values={"status": "reversed", "reversed_at": datetime.utcnow().isoformat()},
|
||||
)
|
||||
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=reverse_tx.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"original_transaction_id": tx.id,
|
||||
"status": "executed",
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"original_transaction_id": tx.id,
|
||||
"reversal_transaction_id": reverse_tx.id,
|
||||
"reversed_at": tx.reversed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
def _validate_wallets(
|
||||
self,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
) -> tuple:
|
||||
"""Validate wallets exist and belong to family"""
|
||||
from_wallet = None
|
||||
to_wallet = None
|
||||
|
||||
if from_wallet_id:
|
||||
from_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == from_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not from_wallet:
|
||||
raise ValueError(f"Wallet not found: {from_wallet_id}")
|
||||
|
||||
if to_wallet_id:
|
||||
to_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == to_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not to_wallet:
|
||||
raise ValueError(f"Wallet not found: {to_wallet_id}")
|
||||
|
||||
return from_wallet, to_wallet
|
||||
|
||||
def _execute_transaction(
|
||||
self,
|
||||
transaction: Transaction,
|
||||
from_wallet: Optional[Wallet],
|
||||
to_wallet: Optional[Wallet],
|
||||
):
|
||||
"""Execute transaction (update wallet balances)"""
|
||||
if from_wallet:
|
||||
from_wallet.balance -= transaction.amount
|
||||
|
||||
if to_wallet:
|
||||
to_wallet.balance += transaction.amount
|
||||
|
||||
async def _log_event(
|
||||
self,
|
||||
family_id: int,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
actor_id: int,
|
||||
new_values: Dict[str, Any] = None,
|
||||
old_values: Dict[str, Any] = None,
|
||||
ip_address: str = None,
|
||||
reason: str = None,
|
||||
):
|
||||
"""Log event to audit trail"""
|
||||
event = EventLog(
|
||||
family_id=family_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_id=actor_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
ip_address=ip_address,
|
||||
reason=reason,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(event)
|
||||
338
.history/app/services/transaction_service_20251210211954.py
Normal file
338
.history/app/services/transaction_service_20251210211954.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Transaction Service - Core business logic
|
||||
Handles transaction creation, approval, reversal, and event emission
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import Transaction, Account, Family, User
|
||||
from app.security.rbac import RBACEngine, Permission, UserContext
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionService:
|
||||
"""
|
||||
Manages financial transactions with approval workflow and audit.
|
||||
|
||||
Features:
|
||||
- Draft → Pending Approval → Executed workflow
|
||||
- Reversal (compensation transactions)
|
||||
- Event logging for all changes
|
||||
- Family-level isolation
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
APPROVAL_THRESHOLD = Decimal("500") # Amount requiring approval
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
amount: Decimal,
|
||||
category_id: Optional[int],
|
||||
description: str,
|
||||
requires_approval: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create new transaction with approval workflow.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"id": 123,
|
||||
"status": "draft" | "executed",
|
||||
"confirmation_required": false,
|
||||
"created_at": "2023-12-10T12:30:00Z"
|
||||
}
|
||||
|
||||
Raises:
|
||||
- PermissionError: User lacks permission
|
||||
- ValueError: Invalid wallets/amounts
|
||||
- Exception: Database error
|
||||
"""
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.CREATE_TRANSACTION)
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
# Validate wallets belong to family
|
||||
wallets = self._validate_wallets(family_id, from_wallet_id, to_wallet_id)
|
||||
from_wallet, to_wallet = wallets
|
||||
|
||||
# Determine if approval required
|
||||
needs_approval = requires_approval or (amount > self.APPROVAL_THRESHOLD and user_context.role.value != "owner")
|
||||
|
||||
# Create transaction
|
||||
tx_status = "pending_approval" if needs_approval else "executed"
|
||||
|
||||
transaction = Transaction(
|
||||
family_id=family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=from_wallet_id,
|
||||
to_wallet_id=to_wallet_id,
|
||||
amount=amount,
|
||||
category_id=category_id,
|
||||
description=description,
|
||||
status=tx_status,
|
||||
confirmation_required=needs_approval,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(transaction)
|
||||
self.db.flush() # Get ID without commit
|
||||
|
||||
# Update wallet balances if executed immediately
|
||||
if not needs_approval:
|
||||
self._execute_transaction(transaction, from_wallet, to_wallet)
|
||||
transaction.executed_at = datetime.utcnow()
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=transaction.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"id": transaction.id,
|
||||
"amount": str(amount),
|
||||
"status": tx_status,
|
||||
},
|
||||
ip_address=getattr(user_context, "ip_address", None),
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": transaction.id,
|
||||
"status": tx_status,
|
||||
"amount": str(amount),
|
||||
"confirmation_required": needs_approval,
|
||||
"created_at": transaction.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def confirm_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
confirmation_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Approve pending transaction for execution.
|
||||
|
||||
Only owner or approver can confirm.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
if tx.status != "pending_approval":
|
||||
raise ValueError(f"Transaction status is {tx.status}, cannot approve")
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.APPROVE_TRANSACTION)
|
||||
|
||||
# Execute transaction
|
||||
from_wallet = self.db.query(Wallet).get(tx.from_wallet_id) if tx.from_wallet_id else None
|
||||
to_wallet = self.db.query(Wallet).get(tx.to_wallet_id) if tx.to_wallet_id else None
|
||||
|
||||
self._execute_transaction(tx, from_wallet, to_wallet)
|
||||
|
||||
# Update transaction status
|
||||
tx.status = "executed"
|
||||
tx.executed_at = datetime.utcnow()
|
||||
tx.approved_by_id = user_context.user_id
|
||||
tx.approved_at = datetime.utcnow()
|
||||
tx.confirmation_token = None
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="execute",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"status": "executed",
|
||||
"approved_by": user_context.user_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": tx.id,
|
||||
"status": "executed",
|
||||
"executed_at": tx.executed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def reverse_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
reason: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reverse (cancel) executed transaction by creating compensation transaction.
|
||||
|
||||
Original transaction status changes to "reversed".
|
||||
New negative transaction created to compensate.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
# Only creator or owner can reverse
|
||||
is_owner = user_context.role.value == "owner"
|
||||
is_creator = tx.created_by_id == user_context.user_id
|
||||
|
||||
if not (is_owner or is_creator):
|
||||
raise PermissionError("Only creator or owner can reverse transaction")
|
||||
|
||||
# Create compensation transaction
|
||||
reverse_tx = Transaction(
|
||||
family_id=user_context.family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=tx.to_wallet_id,
|
||||
to_wallet_id=tx.from_wallet_id,
|
||||
amount=tx.amount,
|
||||
category_id=tx.category_id,
|
||||
description=f"Reversal of transaction #{tx.id}: {reason or 'No reason provided'}",
|
||||
status="executed",
|
||||
original_transaction_id=tx.id,
|
||||
executed_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Execute reverse transaction
|
||||
from_wallet = self.db.query(Wallet).get(reverse_tx.from_wallet_id)
|
||||
to_wallet = self.db.query(Wallet).get(reverse_tx.to_wallet_id)
|
||||
self._execute_transaction(reverse_tx, from_wallet, to_wallet)
|
||||
|
||||
# Mark original as reversed
|
||||
tx.status = "reversed"
|
||||
tx.reversed_at = datetime.utcnow()
|
||||
tx.reversal_reason = reason
|
||||
|
||||
self.db.add(reverse_tx)
|
||||
|
||||
# Log events
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="reverse",
|
||||
actor_id=user_context.user_id,
|
||||
reason=reason,
|
||||
new_values={"status": "reversed", "reversed_at": datetime.utcnow().isoformat()},
|
||||
)
|
||||
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=reverse_tx.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"original_transaction_id": tx.id,
|
||||
"status": "executed",
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"original_transaction_id": tx.id,
|
||||
"reversal_transaction_id": reverse_tx.id,
|
||||
"reversed_at": tx.reversed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
def _validate_wallets(
|
||||
self,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
) -> tuple:
|
||||
"""Validate wallets exist and belong to family"""
|
||||
from_wallet = None
|
||||
to_wallet = None
|
||||
|
||||
if from_wallet_id:
|
||||
from_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == from_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not from_wallet:
|
||||
raise ValueError(f"Wallet not found: {from_wallet_id}")
|
||||
|
||||
if to_wallet_id:
|
||||
to_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == to_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not to_wallet:
|
||||
raise ValueError(f"Wallet not found: {to_wallet_id}")
|
||||
|
||||
return from_wallet, to_wallet
|
||||
|
||||
def _execute_transaction(
|
||||
self,
|
||||
transaction: Transaction,
|
||||
from_wallet: Optional[Wallet],
|
||||
to_wallet: Optional[Wallet],
|
||||
):
|
||||
"""Execute transaction (update wallet balances)"""
|
||||
if from_wallet:
|
||||
from_wallet.balance -= transaction.amount
|
||||
|
||||
if to_wallet:
|
||||
to_wallet.balance += transaction.amount
|
||||
|
||||
async def _log_event(
|
||||
self,
|
||||
family_id: int,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
actor_id: int,
|
||||
new_values: Dict[str, Any] = None,
|
||||
old_values: Dict[str, Any] = None,
|
||||
ip_address: str = None,
|
||||
reason: str = None,
|
||||
):
|
||||
"""Log event to audit trail"""
|
||||
event = EventLog(
|
||||
family_id=family_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_id=actor_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
ip_address=ip_address,
|
||||
reason=reason,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(event)
|
||||
336
.history/app/services/transaction_service_20251210212030.py
Normal file
336
.history/app/services/transaction_service_20251210212030.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
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 and audit.
|
||||
|
||||
Features:
|
||||
- Draft → Pending Approval → Executed workflow
|
||||
- Reversal (compensation transactions)
|
||||
- Event logging for all changes
|
||||
- Family-level isolation
|
||||
"""
|
||||
|
||||
# Configuration
|
||||
APPROVAL_THRESHOLD = Decimal("500") # Amount requiring approval
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
amount: Decimal,
|
||||
category_id: Optional[int],
|
||||
description: str,
|
||||
requires_approval: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create new transaction with approval workflow.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"id": 123,
|
||||
"status": "draft" | "executed",
|
||||
"confirmation_required": false,
|
||||
"created_at": "2023-12-10T12:30:00Z"
|
||||
}
|
||||
|
||||
Raises:
|
||||
- PermissionError: User lacks permission
|
||||
- ValueError: Invalid wallets/amounts
|
||||
- Exception: Database error
|
||||
"""
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.CREATE_TRANSACTION)
|
||||
RBACEngine.check_family_access(user_context, family_id)
|
||||
|
||||
# Validate wallets belong to family
|
||||
wallets = self._validate_wallets(family_id, from_wallet_id, to_wallet_id)
|
||||
from_wallet, to_wallet = wallets
|
||||
|
||||
# Determine if approval required
|
||||
needs_approval = requires_approval or (amount > self.APPROVAL_THRESHOLD and user_context.role.value != "owner")
|
||||
|
||||
# Create transaction
|
||||
tx_status = "pending_approval" if needs_approval else "executed"
|
||||
|
||||
transaction = Transaction(
|
||||
family_id=family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=from_wallet_id,
|
||||
to_wallet_id=to_wallet_id,
|
||||
amount=amount,
|
||||
category_id=category_id,
|
||||
description=description,
|
||||
status=tx_status,
|
||||
confirmation_required=needs_approval,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(transaction)
|
||||
self.db.flush() # Get ID without commit
|
||||
|
||||
# Update wallet balances if executed immediately
|
||||
if not needs_approval:
|
||||
self._execute_transaction(transaction, from_wallet, to_wallet)
|
||||
transaction.executed_at = datetime.utcnow()
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=transaction.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"id": transaction.id,
|
||||
"amount": str(amount),
|
||||
"status": tx_status,
|
||||
},
|
||||
ip_address=getattr(user_context, "ip_address", None),
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": transaction.id,
|
||||
"status": tx_status,
|
||||
"amount": str(amount),
|
||||
"confirmation_required": needs_approval,
|
||||
"created_at": transaction.created_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def confirm_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
confirmation_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Approve pending transaction for execution.
|
||||
|
||||
Only owner or approver can confirm.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
if tx.status != "pending_approval":
|
||||
raise ValueError(f"Transaction status is {tx.status}, cannot approve")
|
||||
|
||||
# Permission check
|
||||
RBACEngine.check_permission(user_context, Permission.APPROVE_TRANSACTION)
|
||||
|
||||
# Execute transaction
|
||||
from_wallet = self.db.query(Wallet).get(tx.from_wallet_id) if tx.from_wallet_id else None
|
||||
to_wallet = self.db.query(Wallet).get(tx.to_wallet_id) if tx.to_wallet_id else None
|
||||
|
||||
self._execute_transaction(tx, from_wallet, to_wallet)
|
||||
|
||||
# Update transaction status
|
||||
tx.status = "executed"
|
||||
tx.executed_at = datetime.utcnow()
|
||||
tx.approved_by_id = user_context.user_id
|
||||
tx.approved_at = datetime.utcnow()
|
||||
tx.confirmation_token = None
|
||||
|
||||
# Log event
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="execute",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"status": "executed",
|
||||
"approved_by": user_context.user_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"id": tx.id,
|
||||
"status": "executed",
|
||||
"executed_at": tx.executed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
async def reverse_transaction(
|
||||
self,
|
||||
user_context: UserContext,
|
||||
transaction_id: int,
|
||||
reason: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reverse (cancel) executed transaction by creating compensation transaction.
|
||||
|
||||
Original transaction status changes to "reversed".
|
||||
New negative transaction created to compensate.
|
||||
"""
|
||||
|
||||
# Load transaction
|
||||
tx = self.db.query(Transaction).filter(
|
||||
Transaction.id == transaction_id,
|
||||
Transaction.family_id == user_context.family_id,
|
||||
).first()
|
||||
|
||||
if not tx:
|
||||
raise ValueError(f"Transaction not found: {transaction_id}")
|
||||
|
||||
# Only creator or owner can reverse
|
||||
is_owner = user_context.role.value == "owner"
|
||||
is_creator = tx.created_by_id == user_context.user_id
|
||||
|
||||
if not (is_owner or is_creator):
|
||||
raise PermissionError("Only creator or owner can reverse transaction")
|
||||
|
||||
# Create compensation transaction
|
||||
reverse_tx = Transaction(
|
||||
family_id=user_context.family_id,
|
||||
created_by_id=user_context.user_id,
|
||||
from_wallet_id=tx.to_wallet_id,
|
||||
to_wallet_id=tx.from_wallet_id,
|
||||
amount=tx.amount,
|
||||
category_id=tx.category_id,
|
||||
description=f"Reversal of transaction #{tx.id}: {reason or 'No reason provided'}",
|
||||
status="executed",
|
||||
original_transaction_id=tx.id,
|
||||
executed_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Execute reverse transaction
|
||||
from_wallet = self.db.query(Wallet).get(reverse_tx.from_wallet_id)
|
||||
to_wallet = self.db.query(Wallet).get(reverse_tx.to_wallet_id)
|
||||
self._execute_transaction(reverse_tx, from_wallet, to_wallet)
|
||||
|
||||
# Mark original as reversed
|
||||
tx.status = "reversed"
|
||||
tx.reversed_at = datetime.utcnow()
|
||||
tx.reversal_reason = reason
|
||||
|
||||
self.db.add(reverse_tx)
|
||||
|
||||
# Log events
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=tx.id,
|
||||
action="reverse",
|
||||
actor_id=user_context.user_id,
|
||||
reason=reason,
|
||||
new_values={"status": "reversed", "reversed_at": datetime.utcnow().isoformat()},
|
||||
)
|
||||
|
||||
await self._log_event(
|
||||
family_id=user_context.family_id,
|
||||
entity_type="transaction",
|
||||
entity_id=reverse_tx.id,
|
||||
action="create",
|
||||
actor_id=user_context.user_id,
|
||||
new_values={
|
||||
"original_transaction_id": tx.id,
|
||||
"status": "executed",
|
||||
},
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"original_transaction_id": tx.id,
|
||||
"reversal_transaction_id": reverse_tx.id,
|
||||
"reversed_at": tx.reversed_at.isoformat() + "Z",
|
||||
}
|
||||
|
||||
def _validate_wallets(
|
||||
self,
|
||||
family_id: int,
|
||||
from_wallet_id: Optional[int],
|
||||
to_wallet_id: Optional[int],
|
||||
) -> tuple:
|
||||
"""Validate wallets exist and belong to family"""
|
||||
from_wallet = None
|
||||
to_wallet = None
|
||||
|
||||
if from_wallet_id:
|
||||
from_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == from_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not from_wallet:
|
||||
raise ValueError(f"Wallet not found: {from_wallet_id}")
|
||||
|
||||
if to_wallet_id:
|
||||
to_wallet = self.db.query(Wallet).filter(
|
||||
Wallet.id == to_wallet_id,
|
||||
Wallet.family_id == family_id,
|
||||
).first()
|
||||
if not to_wallet:
|
||||
raise ValueError(f"Wallet not found: {to_wallet_id}")
|
||||
|
||||
return from_wallet, to_wallet
|
||||
|
||||
def _execute_transaction(
|
||||
self,
|
||||
transaction: Transaction,
|
||||
from_wallet: Optional[Wallet],
|
||||
to_wallet: Optional[Wallet],
|
||||
):
|
||||
"""Execute transaction (update wallet balances)"""
|
||||
if from_wallet:
|
||||
from_wallet.balance -= transaction.amount
|
||||
|
||||
if to_wallet:
|
||||
to_wallet.balance += transaction.amount
|
||||
|
||||
async def _log_event(
|
||||
self,
|
||||
family_id: int,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
actor_id: int,
|
||||
new_values: Dict[str, Any] = None,
|
||||
old_values: Dict[str, Any] = None,
|
||||
ip_address: str = None,
|
||||
reason: str = None,
|
||||
):
|
||||
"""Log event to audit trail"""
|
||||
event = EventLog(
|
||||
family_id=family_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_id=actor_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
ip_address=ip_address,
|
||||
reason=reason,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(event)
|
||||
145
.history/app/services/transaction_service_20251210212053.py
Normal file
145
.history/app/services/transaction_service_20251210212053.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",
|
||||
}
|
||||
145
.history/app/services/transaction_service_20251210212154.py
Normal file
145
.history/app/services/transaction_service_20251210212154.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