Files
finance_bot/.history/app/api/transactions_20251210210906.py
2025-12-10 22:09:31 +09:00

276 lines
7.6 KiB
Python

"""
Transaction API Endpoints - CRUD + Approval Workflow
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List
from decimal import Decimal
from datetime import datetime
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.services.transaction_service import TransactionService
from app.security.rbac import UserContext, RBACEngine, MemberRole, Permission
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/transactions", tags=["transactions"])
# Request/Response Models
class TransactionCreateRequest(BaseModel):
family_id: int
from_wallet_id: Optional[int] = None
to_wallet_id: Optional[int] = None
category_id: Optional[int] = None
amount: Decimal
description: str
notes: Optional[str] = None
class Config:
schema_extra = {
"example": {
"family_id": 1,
"from_wallet_id": 10,
"to_wallet_id": 11,
"category_id": 5,
"amount": 50.00,
"description": "Rent payment",
}
}
class TransactionResponse(BaseModel):
id: int
status: str # draft, pending_approval, executed, reversed
amount: Decimal
description: str
confirmation_required: bool
created_at: datetime
class Config:
from_attributes = True
class TransactionConfirmRequest(BaseModel):
confirmation_token: Optional[str] = None
class TransactionReverseRequest(BaseModel):
reason: Optional[str] = None
# Dependency to extract user context
async def get_user_context(request: Request) -> UserContext:
"""Extract user context from JWT"""
user_id = getattr(request.state, "user_id", None)
family_id = getattr(request.state, "family_id", None)
if not user_id or not family_id:
raise HTTPException(status_code=401, detail="Invalid authentication")
# Load user role from DB (simplified for MVP)
# In production: Load from users->family_members join
role = MemberRole.OWNER # TODO: Load from DB
permissions = RBACEngine.get_permissions(role)
return UserContext(
user_id=user_id,
family_id=family_id,
role=role,
permissions=permissions,
family_ids=[family_id],
device_id=getattr(request.state, "device_id", None),
client_id=getattr(request.state, "client_id", None),
)
@router.post(
"",
response_model=TransactionResponse,
status_code=201,
summary="Create new transaction",
)
async def create_transaction(
request: TransactionCreateRequest,
user_context: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
) -> TransactionResponse:
"""
Create a new financial transaction.
**Request Headers Required:**
- Authorization: Bearer <jwt_token>
- X-Client-Id: telegram_bot | web_frontend | ios_app
- X-Signature: HMAC_SHA256(...)
- X-Timestamp: unix timestamp
**Response:**
- If amount ≤ threshold: status="executed" immediately
- If amount > threshold: status="pending_approval", requires confirmation
**Events Emitted:**
- transaction.created
"""
try:
service = TransactionService(db)
result = await service.create_transaction(
user_context=user_context,
family_id=request.family_id,
from_wallet_id=request.from_wallet_id,
to_wallet_id=request.to_wallet_id,
amount=request.amount,
category_id=request.category_id,
description=request.description,
)
return TransactionResponse(**result)
except PermissionError as e:
logger.warning(f"Permission denied: {e} (user: {user_context.user_id})")
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
logger.warning(f"Validation error: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error creating transaction: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post(
"/{transaction_id}/confirm",
response_model=TransactionResponse,
summary="Confirm pending transaction",
)
async def confirm_transaction(
transaction_id: int,
request: TransactionConfirmRequest,
user_context: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""
Approve a pending transaction for execution.
Only owner or designated approver can confirm.
**Events Emitted:**
- transaction.confirmed
- transaction.executed
"""
try:
service = TransactionService(db)
result = await service.confirm_transaction(
user_context=user_context,
transaction_id=transaction_id,
confirmation_token=request.confirmation_token,
)
return TransactionResponse(**result)
except (PermissionError, ValueError) as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete(
"/{transaction_id}",
response_model=dict,
summary="Reverse (cancel) transaction",
)
async def reverse_transaction(
transaction_id: int,
request: TransactionReverseRequest,
user_context: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""
Reverse (cancel) executed transaction.
Creates a compensation (reverse) transaction instead of deletion.
Original transaction status changes to "reversed".
**Events Emitted:**
- transaction.reversed
- transaction.created (compensation)
"""
try:
service = TransactionService(db)
result = await service.reverse_transaction(
user_context=user_context,
transaction_id=transaction_id,
reason=request.reason,
)
return result
except (PermissionError, ValueError) as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get(
"",
response_model=List[TransactionResponse],
summary="List transactions",
)
async def list_transactions(
family_id: int,
skip: int = 0,
limit: int = 20,
user_context: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""
List all transactions for family.
**Filtering:**
- ?family_id=1
- ?wallet_id=10
- ?category_id=5
- ?status=executed
- ?from_date=2023-12-01&to_date=2023-12-31
**Pagination:**
- ?skip=0&limit=20
"""
# Verify family access
RBACEngine.check_family_access(user_context, family_id)
from app.db.models import Transaction
transactions = db.query(Transaction).filter(
Transaction.family_id == family_id,
).offset(skip).limit(limit).all()
return [TransactionResponse.from_orm(t) for t in transactions]
@router.get(
"/{transaction_id}",
response_model=TransactionResponse,
summary="Get transaction details",
)
async def get_transaction(
transaction_id: int,
user_context: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""Get detailed transaction information"""
from app.db.models import Transaction
transaction = db.query(Transaction).filter(
Transaction.id == transaction_id,
Transaction.family_id == user_context.family_id,
).first()
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
return TransactionResponse.from_orm(transaction)