bank-fraud / app /db /models.py
root
init
942b115
Raw
History Blame Contribute Delete
13.4 kB
"""SQLAlchemy 2.0 typed ORM models for the fraud-detection persistence schema.
Seven tables:
- `users` -- login accounts (officer or client role), bcrypt
password hashes.
- `sessions` -- server-side session tokens (auth-04): opaque token
-> user_id + expires_at, checked on every
authenticated request. Chosen over JWT so access
can be instantly revoked (delete the row) rather
than waiting out a token's expiry.
- `clients` -- a bank customer's account: banking fields
(starting_balance, account_type) plus a 1:1 FK to
the `users` row that logs into it, and
`created_by_officer_id` recording which officer
created the account. A client's *name* lives on
`users.name`, not duplicated here.
- `transactions` -- every scored transaction, both officer-submitted
(via /predict, /transactions/batch -- backfill/
testing, `client_id` NULL) and client-submitted
(via /client/transactions, `client_id` set). One
unified table so the fraud alert queue is a single
source of truth: `/officer/alerts` sees everything,
`/client/alerts` is the same query filtered to the
caller's own `client_id`.
- `fraud_predictions` -- one row per scored transaction: probability, risk
tier, model version, and a structured top-SHAP-
features JSON column (a list of
`{"feature": str, "shap_value": float}` objects,
ordered by descending absolute SHAP value) so later
phases can query/filter by feature name instead of
storing an opaque blob.
- `fraud_alerts` -- one row per prediction whose probability crosses
FRAUD_ALERT_THRESHOLD; carries `disposition`
(open/reviewed/dismissed/rejected) and a nullable
`reviewed_at` so a future analyst-labeling /
retraining pipeline can consume reviewed alerts
without a schema rewrite (DB-04). Rejecting an
alert also flips `transactions.reversed` on the
underlying transaction, excluding it from balance
calculations -- the officer's way of saying "this
was fraud, undo it."
`isFlaggedFraud` from the raw PaySim dataset is intentionally NOT modeled as
a feature or a label anywhere in this schema. It has near-zero recall
against true `isFraud` (PITFALLS.md Pitfall 2) and must never be treated as
a usable signal -- it is simply absent from `Transaction` below, not even
retained as a reference column, to remove any risk of it re-entering a
feature matrix via a wildcard column selection later.
"""
from __future__ import annotations
import enum
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
)
from sqlalchemy import Enum as SAEnum
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""Shared declarative base for every ORM model in this schema."""
class AlertDisposition(str, enum.Enum):
"""Analyst-facing lifecycle state for a `fraud_alerts` row (DB-04).
Structured now, ahead of the analyst-labeling UI, so a future retraining
pipeline can select on `disposition` / `reviewed_at` without a schema
rewrite.
"""
OPEN = "open"
REVIEWED = "reviewed"
DISMISSED = "dismissed"
REJECTED = "rejected"
class UserRole(str, enum.Enum):
"""A user is either a banking officer (back-office) or a client
(self-service, scoped to their own account)."""
OFFICER = "officer"
CLIENT = "client"
class User(Base):
"""A login-capable account. Password is always stored as a bcrypt hash
(`app/auth.py` via passlib) -- never plaintext, never reversible.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(
SAEnum(
UserRole,
native_enum=True,
length=10,
name="user_role",
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
index=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
sessions: Mapped[list["Session"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
client_profile: Mapped["Client | None"] = relationship(
back_populates="user",
foreign_keys="Client.user_id",
uselist=False,
cascade="all, delete-orphan",
)
class Session(Base):
"""A server-side session token (opaque, random -- not a JWT), checked
on every authenticated request via a DB lookup. Deleting the row is an
instant, unconditional revocation (used by logout, and available for a
future "disable this client" officer action) -- the tradeoff a JWT
wouldn't give without an extra denylist table.
"""
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
user: Mapped["User"] = relationship(back_populates="sessions")
class Client(Base):
"""A bank customer's account -- banking fields only; identity (name,
email, password) lives on the linked `users` row (`user_id`, 1:1).
`created_by_officer_id` records which officer created the account, for
audit purposes.
"""
__tablename__ = "clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
)
created_by_officer_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
starting_balance: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
account_type: Mapped[str] = mapped_column(String(30), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
user: Mapped["User"] = relationship(back_populates="client_profile", foreign_keys=[user_id])
created_by: Mapped["User"] = relationship(foreign_keys=[created_by_officer_id])
transactions: Mapped[list["Transaction"]] = relationship(back_populates="client")
class Transaction(Base):
"""A transaction scored for fraud risk -- both officer-submitted
(`/predict`, `/transactions/batch`; `client_id` NULL) and
client-submitted (`/client/transactions`; `client_id` set) share this
one table, so the fraud alert queue has a single source of truth.
Mirrors the PaySim column set needed for scoring: step, type, amount,
nameOrig, oldbalanceOrg, newbalanceOrig, nameDest, oldbalanceDest,
newbalanceDest, and the isFraud label. `is_fraud` is nullable because a
freshly-submitted real-time transaction has no known ground-truth label
at score time; historical PaySim rows loaded for reference/training
carry the known label.
"""
__tablename__ = "transactions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_id: Mapped[int | None] = mapped_column(
ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True
)
step: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
name_orig: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
oldbalance_org: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
newbalance_orig: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
name_dest: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
oldbalance_dest: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
newbalance_dest: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
is_fraud: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
reversed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
client: Mapped["Client | None"] = relationship(back_populates="transactions")
predictions: Mapped[list["FraudPrediction"]] = relationship(
back_populates="transaction", cascade="all, delete-orphan"
)
class FraudPrediction(Base):
"""One row per scored transaction.
`top_features` is a structured, queryable JSON column -- a list of
`{"feature": <name>, "shap_value": <float>}` objects ordered by
descending absolute SHAP value (top-N) -- never an opaque blob.
`model_version` records the artifact identifier (e.g. "model_v1") the
prediction was produced with, so the UI can show which model produced a
given result (PITFALLS.md UX Pitfalls: stale cached predictions after a
redeploy).
"""
__tablename__ = "fraud_predictions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
transaction_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
probability: Mapped[float] = mapped_column(
Numeric(6, 5, asdecimal=False), nullable=False
)
risk_tier: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
model_version: Mapped[str] = mapped_column(String(50), nullable=False)
top_features: Mapped[list[dict]] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False, index=True
)
transaction: Mapped["Transaction"] = relationship(back_populates="predictions")
alerts: Mapped[list["FraudAlert"]] = relationship(
back_populates="prediction", cascade="all, delete-orphan"
)
class FraudAlert(Base):
"""One row per prediction whose probability crossed FRAUD_ALERT_THRESHOLD.
`disposition` is constrained to open/reviewed/dismissed (DB-04);
`reviewed_at` is nullable and set when an officer moves the alert out of
`open`. This shape lets a future labeling/retraining pipeline select
reviewed alerts (and treat disposition as a soft label signal) without
altering the schema. Reviewer identity isn't tracked per-alert yet
(single-officer-role assumption still holds even with multiple officer
accounts) -- see threat register T-02-04.
"""
__tablename__ = "fraud_alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
prediction_id: Mapped[int] = mapped_column(
ForeignKey("fraud_predictions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
risk_tier: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
disposition: Mapped[AlertDisposition] = mapped_column(
SAEnum(
AlertDisposition,
native_enum=True,
length=20,
name="alert_disposition",
# Use the enum *values* ("open"/"reviewed"/"dismissed") as the
# DB-level ENUM labels rather than the Python member names
# ("OPEN"/"REVIEWED"/"DISMISSED") -- otherwise server_default
# below (which is a value) would not match any label the
# database actually accepts.
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
default=AlertDisposition.OPEN,
server_default=AlertDisposition.OPEN.value,
index=True,
)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False, index=True
)
prediction: Mapped["FraudPrediction"] = relationship(back_populates="alerts")