init commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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()

View 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()

View 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()

View 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()

View 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")

View 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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)

View 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)

View 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)

View 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)

View File

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

View File

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