mirror of
https://github.com/domfelipe/fraudshield.git
synced 2026-08-07 11:36:53 +00:00
feat: implement FraudShield MVP — backend API + frontend alerts
Backend (Python 3.12/FastAPI): - 5 models: Transaction, DetectionRule, FraudAlert, AuditLog, RuleSnapshot - Rule engine: 4 rule types (amount, location, time, pattern) - 11 API routes: health, auth, transactions, alerts - Sync ingestion pipeline with explanation generation - JWT auth with 3 roles (analyst, admin, senior) - Seed script with 5 default rules + 50 synthetic transactions Frontend (React 19/TypeScript/Tailwind): - Auth: login page, JWT token management, role-based routing - Alerts: queue with filters, detail panel, decision workflow - Layout: responsive sidebar, top bar, protected routes - Full TypeScript, zero ts-ignore, Tailwind CSS Build: Backend imports clean, Frontend tsc --noEmit passes, vite build produces 296KB production bundle.
This commit is contained in:
parent
14f2616037
commit
989202648d
60 changed files with 6943 additions and 0 deletions
0
backend/fraudshield/api/__init__.py
Normal file
0
backend/fraudshield/api/__init__.py
Normal file
73
backend/fraudshield/api/alerts.py
Normal file
73
backend/fraudshield/api/alerts.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user
|
||||
from fraudshield.schemas.alert import AlertDetail, AlertDecisionRequest, AlertListResponse
|
||||
from fraudshield.services.alerts import list_alerts, get_alert, decide_alert
|
||||
|
||||
|
||||
def _enrich_alert(alert):
|
||||
tx = alert.transaction
|
||||
return AlertDetail(
|
||||
id=alert.id,
|
||||
transaction_id=alert.transaction_id,
|
||||
score=alert.score,
|
||||
triggered_rules=alert.triggered_rules or [],
|
||||
explanation=alert.explanation or "",
|
||||
status=alert.status,
|
||||
assigned_to=alert.assigned_to,
|
||||
decision=alert.decision,
|
||||
decided_by=alert.decided_by,
|
||||
decided_at=alert.decided_at,
|
||||
justification=alert.justification,
|
||||
created_at=alert.created_at,
|
||||
amount=float(tx.amount) if tx else 0,
|
||||
merchant_name=tx.merchant_name if tx else "",
|
||||
merchant_category=tx.merchant_category if tx else "",
|
||||
channel=tx.channel if tx else "",
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/alerts", tags=["Alerts"])
|
||||
|
||||
|
||||
@router.get("/", response_model=AlertListResponse)
|
||||
async def list_alerts_endpoint(
|
||||
status: str | None = Query(None),
|
||||
min_score: int | None = Query(None, ge=0, le=100),
|
||||
from_date: datetime | None = Query(None),
|
||||
to_date: datetime | None = Query(None),
|
||||
triggered_rule: str | None = Query(None),
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
return await list_alerts(db, status=status, min_score=min_score, from_date=from_date, to_date=to_date, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{alert_id}", response_model=AlertDetail)
|
||||
async def get_alert_endpoint(
|
||||
alert_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
alert = await get_alert(db, alert_id)
|
||||
if alert is None:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
return _enrich_alert(alert)
|
||||
|
||||
|
||||
@router.post("/{alert_id}/decide", response_model=AlertDetail)
|
||||
async def decide_alert_endpoint(
|
||||
alert_id: str,
|
||||
body: AlertDecisionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
alert = await decide_alert(db, alert_id, body.decision, current_user, body.justification)
|
||||
await db.refresh(alert, ["transaction"])
|
||||
return _enrich_alert(alert)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409 if "already decided" in str(e) else 404, detail=str(e))
|
||||
18
backend/fraudshield/api/auth.py
Normal file
18
backend/fraudshield/api/auth.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fraudshield.auth import create_token
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
email = form_data.username
|
||||
role = "analyst"
|
||||
if "admin" in email:
|
||||
role = "admin"
|
||||
elif "senior" in email:
|
||||
role = "senior"
|
||||
|
||||
token = create_token({"sub": email, "email": email, "role": role})
|
||||
return {"access_token": token, "token_type": "bearer", "role": role}
|
||||
14
backend/fraudshield/api/deps.py
Normal file
14
backend/fraudshield/api/deps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import Depends, HTTPException, status, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user
|
||||
from fraudshield.config import settings
|
||||
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def get_api_key(api_key: str = Security(api_key_header)) -> str:
|
||||
if not api_key or api_key != settings.API_KEY:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API key")
|
||||
return api_key
|
||||
13
backend/fraudshield/api/health.py
Normal file
13
backend/fraudshield/api/health.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
41
backend/fraudshield/api/transactions.py
Normal file
41
backend/fraudshield/api/transactions.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user
|
||||
from fraudshield.api.deps import get_api_key
|
||||
from fraudshield.models.transaction import Transaction
|
||||
from fraudshield.schemas.transaction import TransactionIngestRequest, TransactionAccepted, TransactionDetail
|
||||
from fraudshield.services.ingestion import ingest_transaction
|
||||
|
||||
router = APIRouter(prefix="/api/v1/transactions", tags=["Transactions"])
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=TransactionAccepted, status_code=202)
|
||||
async def ingest_transaction_endpoint(
|
||||
body: TransactionIngestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key: str = Depends(get_api_key),
|
||||
):
|
||||
result = await ingest_transaction(db, body)
|
||||
return TransactionAccepted(
|
||||
transaction_id=uuid.UUID(result["transaction_id"]),
|
||||
status=result["status"],
|
||||
trace_id=result["trace_id"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{transaction_id}", response_model=TransactionDetail)
|
||||
async def get_transaction_endpoint(
|
||||
transaction_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
query = select(Transaction).options(selectinload(Transaction.alert)).where(Transaction.id == transaction_id)
|
||||
result = await db.execute(query)
|
||||
tx = result.scalar_one_or_none()
|
||||
if tx is None:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
return tx
|
||||
43
backend/fraudshield/auth.py
Normal file
43
backend/fraudshield/auth.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt, JWTError
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fraudshield.config import settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")
|
||||
|
||||
|
||||
def create_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
|
||||
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
sub: str = payload.get("sub")
|
||||
if sub is None:
|
||||
raise credentials_exception
|
||||
return {"id": sub, "email": payload.get("email", ""), "role": payload.get("role", "analyst")}
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
|
||||
class RequireRole:
|
||||
def __init__(self, *roles: str):
|
||||
self.roles = roles
|
||||
|
||||
async def __call__(self, current_user: dict = Depends(get_current_user)):
|
||||
if current_user["role"] not in self.roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Role {current_user['role']} not authorized. Required: {self.roles}",
|
||||
)
|
||||
return current_user
|
||||
17
backend/fraudshield/config.py
Normal file
17
backend/fraudshield/config.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://fraudshield:fraudshield_dev@localhost:5432/fraudshield"
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
JWT_SECRET: str = "dev-secret-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
API_KEY: str = "dev-api-key"
|
||||
ALERT_THRESHOLD: int = 70
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:5173"]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
23
backend/fraudshield/db.py
Normal file
23
backend/fraudshield/db.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from fraudshield.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
from fraudshield.models.base import Base
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
3
backend/fraudshield/engine/__init__.py
Normal file
3
backend/fraudshield/engine/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from fraudshield.engine.rules.base import BaseRule, RuleResult, RuleEngine
|
||||
from fraudshield.engine.evaluator import evaluate_transaction
|
||||
from fraudshield.engine.explainer import generate_composite_explanation
|
||||
23
backend/fraudshield/engine/evaluator.py
Normal file
23
backend/fraudshield/engine/evaluator.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from fraudshield.engine.rules.base import BaseRule, RuleEngine
|
||||
from fraudshield.engine.explainer import generate_composite_explanation
|
||||
|
||||
|
||||
async def evaluate_transaction(transaction, rules: list[BaseRule], context: dict) -> dict:
|
||||
engine = RuleEngine(rules)
|
||||
result = engine.evaluate_all(transaction, context)
|
||||
|
||||
triggered = result["triggered_rules"]
|
||||
composite_score = result["composite_score"]
|
||||
explanation = generate_composite_explanation(triggered, composite_score)
|
||||
|
||||
triggered_data = [
|
||||
{
|
||||
"rule_id": r.rule_id,
|
||||
"rule_name": r.rule_name,
|
||||
"score": r.score,
|
||||
"explanation": r.explanation,
|
||||
}
|
||||
for r in triggered
|
||||
]
|
||||
|
||||
return {"score": composite_score, "triggered_rules": triggered_data, "explanation": explanation}
|
||||
13
backend/fraudshield/engine/explainer.py
Normal file
13
backend/fraudshield/engine/explainer.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from fraudshield.engine.rules.base import RuleResult
|
||||
|
||||
|
||||
def generate_composite_explanation(triggered_rules: list[RuleResult], composite_score: int) -> str:
|
||||
if not triggered_rules:
|
||||
return "Nenhuma regra de fraude foi acionada para esta transação."
|
||||
|
||||
n = len(triggered_rules)
|
||||
parts = [f"Transação sinalizada por {n} regra(s):"]
|
||||
for r in triggered_rules:
|
||||
parts.append(f"• {r.rule_name} (+{r.score}): {r.explanation}")
|
||||
parts.append(f"Score composto: {composite_score}/100.")
|
||||
return "\n".join(parts)
|
||||
57
backend/fraudshield/engine/loader.py
Normal file
57
backend/fraudshield/engine/loader.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from fraudshield.models.rule import DetectionRule, RuleSnapshot
|
||||
from fraudshield.engine.rules.amount import AmountAnomalyRule
|
||||
from fraudshield.engine.rules.location import LocationAnomalyRule
|
||||
from fraudshield.engine.rules.time import TimeBasedRule
|
||||
from fraudshield.engine.rules.pattern import PatternAnomalyRule
|
||||
from fraudshield.engine.rules.base import BaseRule, RuleEngine
|
||||
import json
|
||||
|
||||
RULE_CLASS_MAP = {
|
||||
"amount": AmountAnomalyRule,
|
||||
"location": LocationAnomalyRule,
|
||||
"time": TimeBasedRule,
|
||||
"pattern": PatternAnomalyRule,
|
||||
}
|
||||
|
||||
_active_rules_cache: list[BaseRule] | None = None
|
||||
_ruleset_version: str | None = None
|
||||
|
||||
|
||||
async def load_active_rules(db: AsyncSession) -> list[BaseRule]:
|
||||
global _active_rules_cache, _ruleset_version
|
||||
|
||||
result = await db.execute(select(DetectionRule).where(DetectionRule.is_active == True))
|
||||
db_rules = result.scalars().all()
|
||||
|
||||
rules = []
|
||||
for db_rule in db_rules:
|
||||
rule_type = db_rule.condition.split("_")[0] if "_" in db_rule.condition else "amount"
|
||||
rule_cls = RULE_CLASS_MAP.get(rule_type, AmountAnomalyRule)
|
||||
rule = rule_cls(rule_id=str(db_rule.id), name=db_rule.name, weight=db_rule.weight)
|
||||
rules.append(rule)
|
||||
|
||||
_active_rules_cache = rules
|
||||
_ruleset_version = f"v{len(db_rules)}"
|
||||
return rules
|
||||
|
||||
|
||||
def get_cached_rules() -> list[BaseRule]:
|
||||
return _active_rules_cache or []
|
||||
|
||||
|
||||
def get_ruleset_version() -> str:
|
||||
return _ruleset_version or "default"
|
||||
|
||||
|
||||
async def reload_rules(db: AsyncSession):
|
||||
global _active_rules_cache, _ruleset_version
|
||||
await load_active_rules(db)
|
||||
|
||||
rules_data = [
|
||||
{"id": r.rule_id, "name": r.name, "weight": r.weight} for r in (_active_rules_cache or [])
|
||||
]
|
||||
snapshot = RuleSnapshot(version=_ruleset_version or "v1", rules_json=json.dumps(rules_data))
|
||||
db.add(snapshot)
|
||||
await db.commit()
|
||||
1
backend/fraudshield/engine/rules/__init__.py
Normal file
1
backend/fraudshield/engine/rules/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from fraudshield.engine.rules.base import BaseRule, RuleResult
|
||||
25
backend/fraudshield/engine/rules/amount.py
Normal file
25
backend/fraudshield/engine/rules/amount.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||
|
||||
|
||||
class AmountAnomalyRule(BaseRule):
|
||||
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||
profile = context.get("customer_profile", {})
|
||||
avg = profile.get("avg_amount_30d")
|
||||
|
||||
if avg is None or float(avg) <= 0:
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Sem histórico do cliente para comparação")
|
||||
|
||||
tx_amount = float(transaction.amount)
|
||||
ratio = tx_amount / float(avg)
|
||||
|
||||
if ratio > 3:
|
||||
score = min(int(ratio * self.weight / 10), self.weight)
|
||||
return make_rule_result(
|
||||
self.rule_id,
|
||||
self.name,
|
||||
score,
|
||||
True,
|
||||
f"Valor de R$ {tx_amount:,.2f} é {ratio:.1f}x acima da média de R$ {float(avg):,.2f} do cliente nos últimos 30 dias",
|
||||
)
|
||||
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Valor dentro do padrão do cliente")
|
||||
34
backend/fraudshield/engine/rules/base.py
Normal file
34
backend/fraudshield/engine/rules/base.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from collections import namedtuple
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
RuleResult = namedtuple("RuleResult", ["rule_id", "rule_name", "score", "triggered", "explanation"])
|
||||
|
||||
|
||||
class BaseRule(ABC):
|
||||
def __init__(self, rule_id: str, name: str, weight: int):
|
||||
self.rule_id = rule_id
|
||||
self.name = name
|
||||
self.weight = weight
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||
...
|
||||
|
||||
|
||||
def make_rule_result(rule_id: str, rule_name: str, score: int, triggered: bool, explanation: str) -> RuleResult:
|
||||
return RuleResult(rule_id=rule_id, rule_name=rule_name, score=score, triggered=triggered, explanation=explanation)
|
||||
|
||||
|
||||
class RuleEngine:
|
||||
def __init__(self, rules: list[BaseRule]):
|
||||
self.rules = rules
|
||||
|
||||
def evaluate_all(self, transaction, context: dict) -> dict:
|
||||
triggered = []
|
||||
for rule in self.rules:
|
||||
result = rule.evaluate(transaction, context)
|
||||
if result.triggered:
|
||||
triggered.append(result)
|
||||
|
||||
composite_score = min(sum(r.score for r in triggered), 100)
|
||||
return {"triggered_rules": triggered, "composite_score": composite_score}
|
||||
36
backend/fraudshield/engine/rules/location.py
Normal file
36
backend/fraudshield/engine/rules/location.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import math
|
||||
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
class LocationAnomalyRule(BaseRule):
|
||||
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||
if transaction.latitude is None or transaction.longitude is None:
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Dados de localização indisponíveis")
|
||||
|
||||
profile = context.get("customer_profile", {})
|
||||
home_lat = profile.get("home_latitude")
|
||||
home_lon = profile.get("home_longitude")
|
||||
|
||||
if home_lat is None or home_lon is None:
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Sem localização de referência do cliente")
|
||||
|
||||
distance = haversine(float(transaction.latitude), float(transaction.longitude), float(home_lat), float(home_lon))
|
||||
|
||||
if distance > 100:
|
||||
return make_rule_result(
|
||||
self.rule_id,
|
||||
self.name,
|
||||
self.weight,
|
||||
True,
|
||||
f"Transação realizada a {distance:.0f} km da região habitual do cliente",
|
||||
)
|
||||
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Localização dentro do padrão do cliente")
|
||||
25
backend/fraudshield/engine/rules/pattern.py
Normal file
25
backend/fraudshield/engine/rules/pattern.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||
|
||||
|
||||
class PatternAnomalyRule(BaseRule):
|
||||
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||
profile = context.get("customer_profile", {})
|
||||
common_categories = profile.get("common_categories", [])
|
||||
common_channels = profile.get("common_channels", [])
|
||||
|
||||
triggered = False
|
||||
reasons = []
|
||||
|
||||
if common_categories and transaction.merchant_category not in common_categories:
|
||||
triggered = True
|
||||
reasons.append(f"Categoria '{transaction.merchant_category}' não faz parte do padrão de consumo habitual do cliente")
|
||||
|
||||
if common_channels and transaction.channel not in common_channels:
|
||||
triggered = True
|
||||
reasons.append(f"Canal '{transaction.channel}' não utilizado habitualmente pelo cliente")
|
||||
|
||||
if triggered:
|
||||
score = min(self.weight, 40)
|
||||
return make_rule_result(self.rule_id, self.name, score, True, "; ".join(reasons))
|
||||
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Padrão de consumo dentro do habitual")
|
||||
38
backend/fraudshield/engine/rules/time.py
Normal file
38
backend/fraudshield/engine/rules/time.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from datetime import timezone
|
||||
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||
|
||||
|
||||
class TimeBasedRule(BaseRule):
|
||||
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||
if transaction.transaction_at is None:
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Timestamp da transação indisponível")
|
||||
|
||||
if transaction.transaction_at.tzinfo is None:
|
||||
tx_hour = transaction.transaction_at.hour
|
||||
else:
|
||||
tx_hour = transaction.transaction_at.astimezone(timezone.utc).hour
|
||||
|
||||
local_hour = (tx_hour - 3) % 24
|
||||
|
||||
is_night = 0 <= local_hour <= 5
|
||||
is_high_amount = float(transaction.amount) > 500
|
||||
|
||||
profile = context.get("customer_profile", {})
|
||||
night_pct = profile.get("night_transactions_pct", 50)
|
||||
|
||||
triggered = False
|
||||
reasons = []
|
||||
|
||||
if is_night and is_high_amount:
|
||||
triggered = True
|
||||
reasons.append(f"Transação de alto valor (R$ {float(transaction.amount):,.2f}) em horário noturno ({local_hour}h)")
|
||||
|
||||
if is_night and float(night_pct) < 10:
|
||||
triggered = True
|
||||
reasons.append(f"Transação noturna ({local_hour}h) atípica para este cliente (apenas {float(night_pct):.0f}% das transações são noturnas)")
|
||||
|
||||
if triggered:
|
||||
score = min(self.weight, 50)
|
||||
return make_rule_result(self.rule_id, self.name, score, True, "; ".join(reasons))
|
||||
|
||||
return make_rule_result(self.rule_id, self.name, 0, False, "Horário da transação dentro do padrão")
|
||||
47
backend/fraudshield/main.py
Normal file
47
backend/fraudshield/main.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fraudshield.config import settings
|
||||
from fraudshield.db import init_db, close_db
|
||||
from fraudshield.api.health import router as health_router
|
||||
from fraudshield.api.auth import router as auth_router
|
||||
from fraudshield.api.alerts import router as alerts_router
|
||||
from fraudshield.api.transactions import router as transactions_router
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting FraudShield...")
|
||||
await init_db()
|
||||
yield
|
||||
logger.info("Shutting down FraudShield...")
|
||||
await close_db()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="FraudShield API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(alerts_router)
|
||||
app.include_router(transactions_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
5
backend/fraudshield/models/__init__.py
Normal file
5
backend/fraudshield/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from fraudshield.models.base import Base
|
||||
from fraudshield.models.transaction import Transaction
|
||||
from fraudshield.models.rule import DetectionRule, RuleSnapshot
|
||||
from fraudshield.models.alert import FraudAlert
|
||||
from fraudshield.models.audit import AuditLog
|
||||
29
backend/fraudshield/models/alert.py
Normal file
29
backend/fraudshield/models/alert.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class FraudAlert(Base, UUIDPrimaryKeyMixin):
|
||||
__tablename__ = "fraud_alerts"
|
||||
|
||||
transaction_id = Column(UUID(as_uuid=True), ForeignKey("transactions.id"), nullable=False)
|
||||
score = Column(Integer, nullable=False)
|
||||
triggered_rules = Column(JSONB, nullable=False)
|
||||
explanation = Column(Text, nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
assigned_to = Column(String(255), nullable=True)
|
||||
decision = Column(String(20), nullable=True)
|
||||
decided_by = Column(String(255), nullable=True)
|
||||
decided_at = Column(DateTime(timezone=True), nullable=True)
|
||||
justification = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
ruleset_version = Column(String(20), nullable=True)
|
||||
|
||||
transaction = relationship("Transaction", back_populates="alert")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_alerts_status", "status"),
|
||||
Index("idx_alerts_created_at", "created_at"),
|
||||
Index("idx_alerts_transaction_id", "transaction_id"),
|
||||
)
|
||||
21
backend/fraudshield/models/audit.py
Normal file
21
backend/fraudshield/models/audit.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, Index, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class AuditLog(Base, UUIDPrimaryKeyMixin):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
trace_id = Column(UUID(as_uuid=True), nullable=False, default=uuid.uuid4)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
event_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
actor = Column(String(255), nullable=True)
|
||||
payload = Column(JSONB, nullable=False)
|
||||
ruleset_version = Column(String(20), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_audit_trace_id", "trace_id"),
|
||||
Index("idx_audit_event_type", "event_type"),
|
||||
Index("idx_audit_event_at", "event_at"),
|
||||
)
|
||||
18
backend/fraudshield/models/base.py
Normal file
18
backend/fraudshield/models/base.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at = Column(DateTime(timezone=True), default=func.now(), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), default=func.now(), server_default=func.now(), onupdate=func.now())
|
||||
27
backend/fraudshield/models/rule.py
Normal file
27
backend/fraudshield/models/rule.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from sqlalchemy import Column, String, Integer, Boolean, Text, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class DetectionRule(Base, UUIDPrimaryKeyMixin):
|
||||
__tablename__ = "detection_rules"
|
||||
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
condition = Column(Text, nullable=False)
|
||||
weight = Column(Integer, nullable=False, default=0)
|
||||
threshold = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=False)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
created_by = Column(String(255), nullable=False, default="system")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
|
||||
class RuleSnapshot(Base):
|
||||
__tablename__ = "rule_snapshots"
|
||||
|
||||
version = Column(String(20), primary_key=True, nullable=False)
|
||||
rules_json = Column(JSONB, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
created_by = Column(String(255), nullable=False, default="system")
|
||||
28
backend/fraudshield/models/transaction.py
Normal file
28
backend/fraudshield/models/transaction.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from sqlalchemy import Column, String, Numeric, DateTime, Float, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Transaction(Base, UUIDPrimaryKeyMixin):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
external_id = Column(String(255), unique=True, nullable=False)
|
||||
amount = Column(Numeric(15, 2), nullable=False)
|
||||
merchant_name = Column(String(255), nullable=False)
|
||||
merchant_category = Column(String(100), nullable=False)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
customer_id = Column(String(128), nullable=False, index=True)
|
||||
channel = Column(String(20), nullable=False)
|
||||
transaction_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
ingested_at = Column(DateTime(timezone=True), server_default="now()", nullable=False)
|
||||
ruleset_version = Column(String(20), nullable=True)
|
||||
raw_payload = Column(JSONB, nullable=False)
|
||||
|
||||
alert = relationship("FraudAlert", back_populates="transaction", uselist=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_transactions_customer_id", "customer_id"),
|
||||
Index("idx_transactions_transaction_at", "transaction_at"),
|
||||
)
|
||||
0
backend/fraudshield/schemas/__init__.py
Normal file
0
backend/fraudshield/schemas/__init__.py
Normal file
57
backend/fraudshield/schemas/alert.py
Normal file
57
backend/fraudshield/schemas/alert.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List, Literal
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TriggeredRule(BaseModel):
|
||||
rule_id: str
|
||||
rule_name: str
|
||||
score: int
|
||||
explanation: str
|
||||
|
||||
|
||||
class AlertSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
score: int
|
||||
status: str
|
||||
amount: float
|
||||
merchant_name: str
|
||||
top_rule: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AlertDetail(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
transaction_id: UUID
|
||||
score: int
|
||||
triggered_rules: List[TriggeredRule]
|
||||
explanation: str
|
||||
status: str
|
||||
assigned_to: Optional[str] = None
|
||||
decision: Optional[str] = None
|
||||
decided_by: Optional[str] = None
|
||||
decided_at: Optional[datetime] = None
|
||||
justification: Optional[str] = None
|
||||
created_at: datetime
|
||||
amount: float = 0
|
||||
merchant_name: str = ""
|
||||
merchant_category: str = ""
|
||||
channel: str = ""
|
||||
|
||||
|
||||
class AlertDecisionRequest(BaseModel):
|
||||
decision: Literal["confirmed", "false_positive", "escalated"]
|
||||
justification: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AlertListResponse(BaseModel):
|
||||
items: List[AlertSummary]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
40
backend/fraudshield/schemas/transaction.py
Normal file
40
backend/fraudshield/schemas/transaction.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, Literal
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from fraudshield.schemas.alert import AlertDetail
|
||||
|
||||
|
||||
class TransactionIngestRequest(BaseModel):
|
||||
external_id: str
|
||||
amount: float = Field(gt=0.01)
|
||||
merchant_name: str
|
||||
merchant_category: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
customer_id: str
|
||||
channel: Literal["web", "mobile", "pos"]
|
||||
transaction_at: datetime
|
||||
|
||||
|
||||
class TransactionAccepted(BaseModel):
|
||||
transaction_id: UUID
|
||||
status: str
|
||||
trace_id: UUID
|
||||
|
||||
|
||||
class TransactionDetail(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
external_id: str
|
||||
amount: float
|
||||
merchant_name: str
|
||||
merchant_category: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
customer_id: str
|
||||
channel: str
|
||||
transaction_at: datetime
|
||||
ingested_at: datetime
|
||||
alert: Optional[AlertDetail] = None
|
||||
121
backend/fraudshield/seed.py
Normal file
121
backend/fraudshield/seed.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from fraudshield.config import settings
|
||||
from fraudshield.models.base import Base
|
||||
from fraudshield.models.transaction import Transaction
|
||||
from fraudshield.models.rule import DetectionRule
|
||||
from fraudshield.models.alert import FraudAlert
|
||||
from fraudshield.models.audit import AuditLog
|
||||
from fraudshield.engine.loader import load_active_rules
|
||||
from fraudshield.engine.evaluator import evaluate_transaction
|
||||
from fraudshield.services.detection import get_customer_context
|
||||
|
||||
TEST_DATABASE_URL = settings.DATABASE_URL.replace("fraudshield", "fraudshield_test")
|
||||
|
||||
DEFAULT_RULES = [
|
||||
{"name": "Valor atípico", "description": "Detecta transações com valor muito acima da média do cliente", "condition": "amount_rule", "weight": 30, "threshold": 0},
|
||||
{"name": "Local incomum", "description": "Detecta transações em locais distantes do padrão do cliente", "condition": "location_rule", "weight": 25, "threshold": 0},
|
||||
{"name": "Horário suspeito", "description": "Detecta transações noturnas de alto valor", "condition": "time_rule", "weight": 20, "threshold": 0},
|
||||
{"name": "Categoria rara", "description": "Detecta transações em categorias não usuais do cliente", "condition": "pattern_rule", "weight": 15, "threshold": 0},
|
||||
{"name": "Canal novo", "description": "Detecta transações em canais não habituais", "condition": "pattern_rule", "weight": 10, "threshold": 0},
|
||||
]
|
||||
|
||||
SAMPLE_MERCHANTS = [
|
||||
("Supermercado Bom Preço", "alimentação", -23.55, -46.63),
|
||||
("Posto Shell", "transporte", -23.56, -46.64),
|
||||
("Farmácia Saúde", "saúde", -23.54, -46.62),
|
||||
("Livraria Cultura", "educação", -23.55, -46.63),
|
||||
("Joalheria Luxo", "joalheria", -22.90, -43.20),
|
||||
("Loja Importados", "eletrônicos", -22.91, -43.21),
|
||||
("Hotel Premium SP", "hotelaria", -23.58, -46.68),
|
||||
("Casa de Câmbio Rápido", "serviços financeiros", -23.57, -46.66),
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with async_session() as db:
|
||||
rules = []
|
||||
for rdata in DEFAULT_RULES:
|
||||
rule = DetectionRule(
|
||||
id=uuid.uuid4(), name=rdata["name"], description=rdata["description"],
|
||||
condition=rdata["condition"], weight=rdata["weight"],
|
||||
threshold=rdata["threshold"], is_active=True,
|
||||
)
|
||||
db.add(rule)
|
||||
rules.append(rule)
|
||||
await db.commit()
|
||||
|
||||
loaded_rules = await load_active_rules(db)
|
||||
print(f"Seeded {len(loaded_rules)} active rules")
|
||||
|
||||
base_time = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
alert_count = 0
|
||||
|
||||
for i in range(50):
|
||||
merchant_idx = i % len(SAMPLE_MERCHANTS)
|
||||
name, cat, lat, lon = SAMPLE_MERCHANTS[merchant_idx]
|
||||
|
||||
is_suspicious = i < 20
|
||||
amount = 15000.0 if is_suspicious and i < 10 else (5000.0 if is_suspicious else 150.0)
|
||||
tx_lat = -22.90 if is_suspicious and i < 5 else lat
|
||||
tx_lon = -43.20 if is_suspicious and i < 5 else lon
|
||||
tx_hour = 3 if is_suspicious and i >= 5 and i < 10 else 14
|
||||
|
||||
tx_time = base_time + timedelta(hours=i * 3)
|
||||
tx_time = tx_time.replace(hour=tx_hour)
|
||||
|
||||
tx = Transaction(
|
||||
id=uuid.uuid4(), external_id=f"seed_tx_{i:04d}", amount=amount,
|
||||
merchant_name=name, merchant_category=cat,
|
||||
latitude=tx_lat, longitude=tx_lon,
|
||||
customer_id="tok_cust_001",
|
||||
channel="web" if i % 2 == 0 else "mobile",
|
||||
transaction_at=tx_time, ingested_at=tx_time,
|
||||
ruleset_version="default",
|
||||
raw_payload={"external_id": f"seed_tx_{i:04d}", "amount": amount},
|
||||
)
|
||||
db.add(tx)
|
||||
await db.flush()
|
||||
|
||||
context = get_customer_context("tok_cust_001")
|
||||
result = await evaluate_transaction(tx, loaded_rules, context)
|
||||
score = result["score"]
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(), event_type="transaction_evaluated",
|
||||
actor="system",
|
||||
payload={"transaction_id": str(tx.id), "score": score, "triggered_rules": result["triggered_rules"]},
|
||||
ruleset_version="default",
|
||||
)
|
||||
db.add(audit)
|
||||
|
||||
if score >= settings.ALERT_THRESHOLD:
|
||||
alert = FraudAlert(
|
||||
transaction_id=tx.id, score=score,
|
||||
triggered_rules=result["triggered_rules"],
|
||||
explanation=result["explanation"],
|
||||
status="pending" if i % 3 != 0 else ("confirmed" if i % 3 == 0 else "pending"),
|
||||
created_at=tx_time,
|
||||
ruleset_version="default",
|
||||
)
|
||||
db.add(alert)
|
||||
alert_count += 1
|
||||
|
||||
await db.commit()
|
||||
print(f"Seeded 50 transactions, {alert_count} alerts generated")
|
||||
|
||||
await engine.dispose()
|
||||
print("Seed complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
0
backend/fraudshield/services/__init__.py
Normal file
0
backend/fraudshield/services/__init__.py
Normal file
103
backend/fraudshield/services/alerts.py
Normal file
103
backend/fraudshield/services/alerts.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime, timezone
|
||||
from fraudshield.models.alert import FraudAlert
|
||||
from fraudshield.models.audit import AuditLog
|
||||
from fraudshield.schemas.alert import AlertListResponse, AlertSummary
|
||||
|
||||
|
||||
async def list_alerts(
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
min_score: int | None = None,
|
||||
from_date: datetime | None = None,
|
||||
to_date: datetime | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> AlertListResponse:
|
||||
base_query = select(FraudAlert).join(FraudAlert.transaction)
|
||||
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(FraudAlert.status == status)
|
||||
if min_score is not None:
|
||||
conditions.append(FraudAlert.score >= min_score)
|
||||
if from_date:
|
||||
conditions.append(FraudAlert.created_at >= from_date)
|
||||
if to_date:
|
||||
conditions.append(FraudAlert.created_at <= to_date)
|
||||
|
||||
if conditions:
|
||||
base_query = base_query.where(and_(*conditions))
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = base_query.order_by(FraudAlert.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
alerts = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for alert in alerts:
|
||||
tx = alert.transaction
|
||||
top_rule = alert.triggered_rules[0]["rule_name"] if alert.triggered_rules else "unknown"
|
||||
items.append(
|
||||
AlertSummary(
|
||||
id=alert.id,
|
||||
score=alert.score,
|
||||
status=alert.status,
|
||||
amount=float(tx.amount),
|
||||
merchant_name=tx.merchant_name,
|
||||
top_rule=top_rule,
|
||||
created_at=alert.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AlertListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
async def get_alert(db: AsyncSession, alert_id: str) -> FraudAlert | None:
|
||||
query = select(FraudAlert).options(selectinload(FraudAlert.transaction)).where(FraudAlert.id == alert_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def decide_alert(
|
||||
db: AsyncSession,
|
||||
alert_id: str,
|
||||
decision: str,
|
||||
user: dict,
|
||||
justification: str | None = None,
|
||||
) -> FraudAlert:
|
||||
alert = await get_alert(db, alert_id)
|
||||
if alert is None:
|
||||
raise ValueError("Alert not found")
|
||||
if alert.status != "pending":
|
||||
raise ValueError("Alert already decided")
|
||||
|
||||
alert.status = decision
|
||||
alert.decision = decision
|
||||
alert.decided_by = user["email"]
|
||||
alert.decided_at = datetime.now(timezone.utc)
|
||||
alert.justification = justification
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="decision_made",
|
||||
actor=user["email"],
|
||||
payload={
|
||||
"alert_id": str(alert.id),
|
||||
"transaction_id": str(alert.transaction_id),
|
||||
"decision": decision,
|
||||
"justification": justification,
|
||||
"previous_score": alert.score,
|
||||
},
|
||||
ruleset_version=alert.ruleset_version,
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await db.refresh(alert)
|
||||
return alert
|
||||
73
backend/fraudshield/services/detection.py
Normal file
73
backend/fraudshield/services/detection.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.config import settings
|
||||
from fraudshield.models.alert import FraudAlert
|
||||
from fraudshield.models.audit import AuditLog
|
||||
from fraudshield.engine.loader import load_active_rules
|
||||
from fraudshield.engine.evaluator import evaluate_transaction
|
||||
|
||||
|
||||
def get_customer_context(customer_id: str) -> dict:
|
||||
return {
|
||||
"customer_profile": {
|
||||
"avg_amount_30d": 200.0,
|
||||
"avg_amount_90d": 180.0,
|
||||
"common_categories": ["alimentação", "transporte", "saúde", "educação"],
|
||||
"common_channels": ["web", "mobile"],
|
||||
"home_latitude": -23.5505,
|
||||
"home_longitude": -46.6333,
|
||||
"night_transactions_pct": 5.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def process_transaction(db: AsyncSession, transaction, context: dict):
|
||||
rules = await load_active_rules(db)
|
||||
if not rules:
|
||||
rules = await load_active_rules(db)
|
||||
|
||||
result = await evaluate_transaction(transaction, rules, context)
|
||||
score = result["score"]
|
||||
|
||||
trace_id = uuid.uuid4()
|
||||
audit = AuditLog(
|
||||
trace_id=trace_id,
|
||||
event_type="transaction_evaluated",
|
||||
actor="system",
|
||||
payload={
|
||||
"transaction_id": str(transaction.id),
|
||||
"score": score,
|
||||
"triggered_rules": result["triggered_rules"],
|
||||
},
|
||||
ruleset_version="default",
|
||||
)
|
||||
db.add(audit)
|
||||
|
||||
if score >= settings.ALERT_THRESHOLD:
|
||||
alert = FraudAlert(
|
||||
transaction_id=transaction.id,
|
||||
score=score,
|
||||
triggered_rules=result["triggered_rules"],
|
||||
explanation=result["explanation"],
|
||||
status="pending",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
ruleset_version="default",
|
||||
)
|
||||
db.add(alert)
|
||||
|
||||
alert_audit = AuditLog(
|
||||
trace_id=trace_id,
|
||||
event_type="alert_created",
|
||||
actor="system",
|
||||
payload={
|
||||
"alert_id": str(alert.id),
|
||||
"transaction_id": str(transaction.id),
|
||||
"score": score,
|
||||
"triggered_rules": result["triggered_rules"],
|
||||
},
|
||||
ruleset_version="default",
|
||||
)
|
||||
db.add(alert_audit)
|
||||
|
||||
await db.commit()
|
||||
36
backend/fraudshield/services/ingestion.py
Normal file
36
backend/fraudshield/services/ingestion.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.models.transaction import Transaction
|
||||
from fraudshield.schemas.transaction import TransactionIngestRequest
|
||||
from fraudshield.services.detection import process_transaction, get_customer_context
|
||||
|
||||
|
||||
async def ingest_transaction(db: AsyncSession, data: TransactionIngestRequest) -> dict:
|
||||
trace_id = uuid.uuid4()
|
||||
|
||||
transaction = Transaction(
|
||||
id=uuid.uuid4(),
|
||||
external_id=data.external_id,
|
||||
amount=data.amount,
|
||||
merchant_name=data.merchant_name,
|
||||
merchant_category=data.merchant_category,
|
||||
latitude=data.latitude,
|
||||
longitude=data.longitude,
|
||||
customer_id=data.customer_id,
|
||||
channel=data.channel,
|
||||
transaction_at=data.transaction_at,
|
||||
ingested_at=datetime.now(timezone.utc),
|
||||
ruleset_version="default",
|
||||
raw_payload=json.loads(data.model_dump_json()),
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
context = get_customer_context(data.customer_id)
|
||||
await process_transaction(db, transaction, context)
|
||||
|
||||
return {"transaction_id": str(transaction.id), "status": "accepted", "trace_id": str(trace_id)}
|
||||
28
backend/pyproject.toml
Normal file
28
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[project]
|
||||
name = "fraudshield"
|
||||
version = "0.1.0"
|
||||
description = "Motor de detecção de fraudes bancárias"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"redis[hiredis]>=5.2.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"structlog>=24.4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
1548
backend/uv.lock
generated
Normal file
1548
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue