146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
"""
|
|
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",
|
|
}
|