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 @@
"""API routes"""

View File

@@ -0,0 +1 @@
"""API routes"""

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

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

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

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

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

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

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

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