276 lines
7.6 KiB
Python
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:
|
|
json_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)
|