init commit
This commit is contained in:
1
.history/app/api/__init___20251210201724.py
Normal file
1
.history/app/api/__init___20251210201724.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes"""
|
||||
1
.history/app/api/__init___20251210202255.py
Normal file
1
.history/app/api/__init___20251210202255.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes"""
|
||||
279
.history/app/api/auth_20251210210440.py
Normal file
279
.history/app/api/auth_20251210210440.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.auth_service import AuthService
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
user_id: int
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingStartRequest(BaseModel):
|
||||
chat_id: int
|
||||
|
||||
|
||||
class TelegramBindingStartResponse(BaseModel):
|
||||
code: str
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingConfirmRequest(BaseModel):
|
||||
code: str
|
||||
chat_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramBindingConfirmResponse(BaseModel):
|
||||
success: bool
|
||||
user_id: int
|
||||
jwt_token: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class TokenRefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenRefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginResponse,
|
||||
summary="User login with email & password",
|
||||
)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginResponse:
|
||||
"""
|
||||
Authenticate user and create session.
|
||||
|
||||
**Returns:**
|
||||
- access_token: Short-lived JWT (15 min)
|
||||
- refresh_token: Long-lived refresh token (30 days)
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
X-Device-Id: device_uuid # For tracking
|
||||
```
|
||||
"""
|
||||
|
||||
# TODO: Verify email + password
|
||||
# For MVP: Assume credentials are valid
|
||||
|
||||
from app.db.models import User
|
||||
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
service = AuthService(db)
|
||||
access_token, refresh_token = await service.create_session(
|
||||
user_id=user.id,
|
||||
device_id=request.__dict__.get("device_id"),
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user_id=user.id,
|
||||
expires_in=15 * 60, # 15 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenRefreshResponse,
|
||||
summary="Refresh access token",
|
||||
)
|
||||
async def refresh_token(
|
||||
request: TokenRefreshRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenRefreshResponse:
|
||||
"""
|
||||
Issue new access token using refresh token.
|
||||
|
||||
**Flow:**
|
||||
1. Access token expires
|
||||
2. Send refresh_token to this endpoint
|
||||
3. Receive new access_token (without creating new session)
|
||||
"""
|
||||
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
|
||||
service = AuthService(db)
|
||||
new_access_token = await service.refresh_access_token(
|
||||
refresh_token=request.refresh_token,
|
||||
user_id=token_payload.sub,
|
||||
)
|
||||
|
||||
return TokenRefreshResponse(
|
||||
access_token=new_access_token,
|
||||
expires_in=15 * 60,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/start",
|
||||
response_model=TelegramBindingStartResponse,
|
||||
summary="Start Telegram binding flow",
|
||||
)
|
||||
async def telegram_binding_start(
|
||||
request: TelegramBindingStartRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate binding code for Telegram user.
|
||||
|
||||
**Bot Flow:**
|
||||
1. User sends /start
|
||||
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||
3. Bot receives code and generates link
|
||||
4. Bot sends message with link to user
|
||||
5. User clicks link (goes to confirm endpoint)
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||
|
||||
return TelegramBindingStartResponse(
|
||||
code=code,
|
||||
expires_in=600, # 10 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/confirm",
|
||||
response_model=TelegramBindingConfirmResponse,
|
||||
summary="Confirm Telegram binding",
|
||||
)
|
||||
async def telegram_binding_confirm(
|
||||
request: TelegramBindingConfirmRequest,
|
||||
current_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Confirm Telegram binding and issue JWT.
|
||||
|
||||
**Flow:**
|
||||
1. User logs in or creates account
|
||||
2. User clicks binding link with code
|
||||
3. Frontend calls this endpoint with code + user context
|
||||
4. Backend creates TelegramIdentity record
|
||||
5. Backend returns JWT for bot to use
|
||||
|
||||
**Bot Usage:**
|
||||
```python
|
||||
# Bot stores JWT for user
|
||||
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||
|
||||
# Bot makes API calls
|
||||
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||
```
|
||||
"""
|
||||
|
||||
# Get authenticated user from JWT
|
||||
user_id = getattr(current_request.state, "user_id", None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.confirm_telegram_binding(
|
||||
user_id=user_id,
|
||||
chat_id=request.chat_id,
|
||||
code=request.code,
|
||||
username=request.username,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail="Binding failed")
|
||||
|
||||
return TelegramBindingConfirmResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/authenticate",
|
||||
response_model=dict,
|
||||
summary="Authenticate by Telegram chat_id",
|
||||
)
|
||||
async def telegram_authenticate(
|
||||
chat_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get JWT token for Telegram user.
|
||||
|
||||
**Usage in Bot:**
|
||||
```python
|
||||
# After user binding is confirmed
|
||||
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||
jwt_token = response["jwt_token"]
|
||||
```
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
summary="Logout user",
|
||||
)
|
||||
async def logout(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Revoke session and blacklist tokens.
|
||||
|
||||
**TODO:** Implement token blacklisting in Redis
|
||||
"""
|
||||
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# TODO: Add token to Redis blacklist
|
||||
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
279
.history/app/api/auth_20251210210906.py
Normal file
279
.history/app/api/auth_20251210210906.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.auth_service import AuthService
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
user_id: int
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingStartRequest(BaseModel):
|
||||
chat_id: int
|
||||
|
||||
|
||||
class TelegramBindingStartResponse(BaseModel):
|
||||
code: str
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TelegramBindingConfirmRequest(BaseModel):
|
||||
code: str
|
||||
chat_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class TelegramBindingConfirmResponse(BaseModel):
|
||||
success: bool
|
||||
user_id: int
|
||||
jwt_token: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class TokenRefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenRefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginResponse,
|
||||
summary="User login with email & password",
|
||||
)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginResponse:
|
||||
"""
|
||||
Authenticate user and create session.
|
||||
|
||||
**Returns:**
|
||||
- access_token: Short-lived JWT (15 min)
|
||||
- refresh_token: Long-lived refresh token (30 days)
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
X-Device-Id: device_uuid # For tracking
|
||||
```
|
||||
"""
|
||||
|
||||
# TODO: Verify email + password
|
||||
# For MVP: Assume credentials are valid
|
||||
|
||||
from app.db.models import User
|
||||
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
service = AuthService(db)
|
||||
access_token, refresh_token = await service.create_session(
|
||||
user_id=user.id,
|
||||
device_id=request.__dict__.get("device_id"),
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user_id=user.id,
|
||||
expires_in=15 * 60, # 15 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenRefreshResponse,
|
||||
summary="Refresh access token",
|
||||
)
|
||||
async def refresh_token(
|
||||
request: TokenRefreshRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenRefreshResponse:
|
||||
"""
|
||||
Issue new access token using refresh token.
|
||||
|
||||
**Flow:**
|
||||
1. Access token expires
|
||||
2. Send refresh_token to this endpoint
|
||||
3. Receive new access_token (without creating new session)
|
||||
"""
|
||||
|
||||
try:
|
||||
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||
if token_payload.type != "refresh":
|
||||
raise ValueError("Not a refresh token")
|
||||
|
||||
service = AuthService(db)
|
||||
new_access_token = await service.refresh_access_token(
|
||||
refresh_token=request.refresh_token,
|
||||
user_id=token_payload.sub,
|
||||
)
|
||||
|
||||
return TokenRefreshResponse(
|
||||
access_token=new_access_token,
|
||||
expires_in=15 * 60,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/start",
|
||||
response_model=TelegramBindingStartResponse,
|
||||
summary="Start Telegram binding flow",
|
||||
)
|
||||
async def telegram_binding_start(
|
||||
request: TelegramBindingStartRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate binding code for Telegram user.
|
||||
|
||||
**Bot Flow:**
|
||||
1. User sends /start
|
||||
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||
3. Bot receives code and generates link
|
||||
4. Bot sends message with link to user
|
||||
5. User clicks link (goes to confirm endpoint)
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||
|
||||
return TelegramBindingStartResponse(
|
||||
code=code,
|
||||
expires_in=600, # 10 minutes
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/confirm",
|
||||
response_model=TelegramBindingConfirmResponse,
|
||||
summary="Confirm Telegram binding",
|
||||
)
|
||||
async def telegram_binding_confirm(
|
||||
request: TelegramBindingConfirmRequest,
|
||||
current_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Confirm Telegram binding and issue JWT.
|
||||
|
||||
**Flow:**
|
||||
1. User logs in or creates account
|
||||
2. User clicks binding link with code
|
||||
3. Frontend calls this endpoint with code + user context
|
||||
4. Backend creates TelegramIdentity record
|
||||
5. Backend returns JWT for bot to use
|
||||
|
||||
**Bot Usage:**
|
||||
```python
|
||||
# Bot stores JWT for user
|
||||
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||
|
||||
# Bot makes API calls
|
||||
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||
```
|
||||
"""
|
||||
|
||||
# Get authenticated user from JWT
|
||||
user_id = getattr(current_request.state, "user_id", None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.confirm_telegram_binding(
|
||||
user_id=user_id,
|
||||
chat_id=request.chat_id,
|
||||
code=request.code,
|
||||
username=request.username,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail="Binding failed")
|
||||
|
||||
return TelegramBindingConfirmResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/authenticate",
|
||||
response_model=dict,
|
||||
summary="Authenticate by Telegram chat_id",
|
||||
)
|
||||
async def telegram_authenticate(
|
||||
chat_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get JWT token for Telegram user.
|
||||
|
||||
**Usage in Bot:**
|
||||
```python
|
||||
# After user binding is confirmed
|
||||
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||
jwt_token = response["jwt_token"]
|
||||
```
|
||||
"""
|
||||
|
||||
service = AuthService(db)
|
||||
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
summary="Logout user",
|
||||
)
|
||||
async def logout(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Revoke session and blacklist tokens.
|
||||
|
||||
**TODO:** Implement token blacklisting in Redis
|
||||
"""
|
||||
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# TODO: Add token to Redis blacklist
|
||||
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
41
.history/app/api/main_20251210201725.py
Normal file
41
.history/app/api/main_20251210201725.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""FastAPI application"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="REST API for family finance management",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Finance Bot API",
|
||||
"docs": "/docs",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
41
.history/app/api/main_20251210202255.py
Normal file
41
.history/app/api/main_20251210202255.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""FastAPI application"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Finance Bot API",
|
||||
description="REST API for family finance management",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "Finance Bot API",
|
||||
"docs": "/docs",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
275
.history/app/api/transactions_20251210210425.py
Normal file
275
.history/app/api/transactions_20251210210425.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
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)
|
||||
275
.history/app/api/transactions_20251210210906.py
Normal file
275
.history/app/api/transactions_20251210210906.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
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)
|
||||
275
.history/app/api/transactions_20251210212821.py
Normal file
275
.history/app/api/transactions_20251210212821.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
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)
|
||||
275
.history/app/api/transactions_20251210212833.py
Normal file
275
.history/app/api/transactions_20251210212833.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user