Spaces:
Running
Running
sghorbal commited on
Commit ·
4aed8ef
1
Parent(s): d8f2220
Initial commit
Browse files- .env.example +12 -0
- migrations/version_0.sql +38 -0
- migrations/version_1.sql +14 -0
- requirements.txt +7 -0
- src/.gitignore +1 -0
- src/__init__.py +0 -0
- src/entity/__init__.py +3 -0
- src/entity/api/__init__.py +0 -0
- src/entity/api/transaction_api.py +98 -0
- src/entity/fraud_details.py +18 -0
- src/entity/transaction.py +39 -0
- src/main.py +168 -0
- src/repository/__init__.py +0 -0
- src/repository/common.py +22 -0
- src/repository/fraud_details_repo.py +27 -0
- src/repository/transaction_repo.py +39 -0
- src/service/__init__.py +0 -0
- src/service/fraud_service.py +40 -0
- src/service/notification_service.py +90 -0
- tests/__init__.py +0 -0
- tests/conftest.py +79 -0
- tests/test_fraud_service.py +19 -0
- tests/test_notification_service.py +15 -0
- tests/test_transaction_api_entity.py +31 -0
.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PostgreSQL database connection information
|
| 2 |
+
DATABASE_URL=
|
| 3 |
+
|
| 4 |
+
# If set, protects the API from unauthorized called
|
| 5 |
+
FASTAPI_API_KEY=
|
| 6 |
+
|
| 7 |
+
# SMTP config
|
| 8 |
+
SMTP_SERVER="smtp.gmail.com"
|
| 9 |
+
SMTP_PORT=587
|
| 10 |
+
SENDER_EMAIL=
|
| 11 |
+
SENDER_PASSWORD=
|
| 12 |
+
RECEIVER_EMAIL="jedha.fraud@yopmail.com"
|
migrations/version_0.sql
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Create table TRANSACTION
|
| 2 |
+
CREATE TABLE IF NOT EXISTS TRANSACTIONS (
|
| 3 |
+
id SERIAL PRIMARY KEY,
|
| 4 |
+
transaction_number VARCHAR NOT NULL,
|
| 5 |
+
transaction_amount DECIMAL(10, 2) NOT NULL,
|
| 6 |
+
transaction_datetime TIMESTAMP NOT NULL,
|
| 7 |
+
transaction_category VARCHAR,
|
| 8 |
+
customer_firstname VARCHAR,
|
| 9 |
+
customer_lastname VARCHAR,
|
| 10 |
+
customer_gender VARCHAR,
|
| 11 |
+
customer_credit_card_num VARCHAR NOT NULL,
|
| 12 |
+
customer_address_street VARCHAR,
|
| 13 |
+
customer_address_city VARCHAR,
|
| 14 |
+
customer_address_state VARCHAR,
|
| 15 |
+
customer_address_zip VARCHAR,
|
| 16 |
+
customer_address_latitude DECIMAL(9, 6),
|
| 17 |
+
customer_address_longitude DECIMAL(9, 6),
|
| 18 |
+
customer_address_city_population INT,
|
| 19 |
+
customer_job VARCHAR,
|
| 20 |
+
customer_dob DATE,
|
| 21 |
+
merchant_name VARCHAR NOT NULL,
|
| 22 |
+
merchant_address_latitude DECIMAL(9, 6),
|
| 23 |
+
merchant_address_longitude DECIMAL(9, 6),
|
| 24 |
+
is_fraud BOOLEAN,
|
| 25 |
+
-- Add unique constraint on (transaction_num, transaction_datetime)
|
| 26 |
+
CONSTRAINT unique_transaction UNIQUE (transaction_number, transaction_datetime)
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
-- Add index on transaction_number
|
| 30 |
+
CREATE INDEX idx_transaction_number ON TRANSACTIONS (transaction_number);
|
| 31 |
+
-- Add index on transaction_datetime
|
| 32 |
+
CREATE INDEX idx_transaction_datetime ON TRANSACTIONS (transaction_datetime);
|
| 33 |
+
-- Add index on transaction_category
|
| 34 |
+
CREATE INDEX idx_transaction_category ON TRANSACTIONS (transaction_category);
|
| 35 |
+
-- Add index on transaction_amount
|
| 36 |
+
CREATE INDEX idx_transaction_amount ON TRANSACTIONS (transaction_amount);
|
| 37 |
+
-- Add index on is_fraud
|
| 38 |
+
CREATE INDEX idx_is_fraud ON TRANSACTIONS (is_fraud);
|
migrations/version_1.sql
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE TABLE IF NOT EXISTS FRAUD_DETAILS (
|
| 2 |
+
id SERIAL PRIMARY KEY,
|
| 3 |
+
fk_transaction_id INT NOT NULL,
|
| 4 |
+
FOREIGN KEY (fk_transaction_id) REFERENCES TRANSACTIONS(id),
|
| 5 |
+
fraud_score DECIMAL(5, 2) NOT NULL,
|
| 6 |
+
model_version VARCHAR(50) NOT NULL,
|
| 7 |
+
notification_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
| 8 |
+
notification_recipients VARCHAR,
|
| 9 |
+
notification_datetime TIMESTAMP,
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
CREATE INDEX idx_fraud_score ON FRAUD_DETAILS (fraud_score);
|
| 13 |
+
CREATE INDEX idx_model_version ON FRAUD_DETAILS (model_version);
|
| 14 |
+
CREATE INDEX idx_notification_sent ON FRAUD_DETAILS (notification_sent);
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python-dotenv
|
| 2 |
+
fastapi
|
| 3 |
+
sqlalchemy
|
| 4 |
+
psycopg[binary]
|
| 5 |
+
pytest
|
| 6 |
+
appengine-python-standard>=1.0.0
|
| 7 |
+
uvicorn
|
src/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.env
|
src/__init__.py
ADDED
|
File without changes
|
src/entity/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from sqlalchemy.orm import declarative_base
|
| 3 |
+
Base = declarative_base()
|
src/entity/api/__init__.py
ADDED
|
File without changes
|
src/entity/api/transaction_api.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, Literal
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from src.entity.transaction import Transaction
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
# Set up logging
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class TransactionApi(BaseModel):
|
| 12 |
+
"""
|
| 13 |
+
TransactionApi is a class that represents a transaction.
|
| 14 |
+
"""
|
| 15 |
+
transaction_number: str = Field(..., title="Transaction number", description="The ID of the transaction.")
|
| 16 |
+
transaction_timestamp: int = Field(..., title="Timestamp", description="The timestamp of the transaction.")
|
| 17 |
+
transaction_amount: float = Field(..., ge=0, title="Amount", description="The transaction amount.")
|
| 18 |
+
transaction_category: Optional[str] = Field(None, title="Product category", description="The category of product of the transaction.")
|
| 19 |
+
merchant_name: str = Field(..., title="Name", description="The name of the merchant.")
|
| 20 |
+
merchant_latitude: float = Field(..., title="Latitude", description="The latitude of the merchant.")
|
| 21 |
+
merchant_longitude: float = Field(..., title="Longitude", description="The longitude of the merchant.")
|
| 22 |
+
customer_credit_card_number: str = Field(..., title="Customer's credit card", description="The credit card number of the customer.")
|
| 23 |
+
customer_gender: Optional[Literal['M', 'F']] = Field(None, title="Customer's gender", description="The gender of the customer.")
|
| 24 |
+
customer_first_name: Optional[str] = Field(None, title="Customer's first name", description="The first name of the customer.")
|
| 25 |
+
customer_last_name: Optional[str] = Field(None, title="Customer's last name", description="The last name of the customer.")
|
| 26 |
+
customer_date_of_birth: Optional[str] = Field(None, title="Customer's date of birth", description="The date of birth of the customer.")
|
| 27 |
+
customer_job: Optional[str] = Field(None, title="Customer's job", description="The job of the customer.")
|
| 28 |
+
customer_street: Optional[str] = Field(None, title="Customer's street", description="The street of the customer.")
|
| 29 |
+
customer_city: Optional[str] = Field(None, title="Customer's city", description="The city of the customer.")
|
| 30 |
+
customer_state: Optional[str] = Field(None, title="Customer's state", description="The state of the customer.")
|
| 31 |
+
customer_postal_code: Optional[int] = Field(None, title="Customer's postal code", description="The postal code of the customer.")
|
| 32 |
+
customer_latitude: float = Field(..., title="Customer's latitude", description="The latitude of the customer.")
|
| 33 |
+
customer_longitude: float = Field(..., title="Customer's longitude", description="The longitude of the customer.")
|
| 34 |
+
customer_city_population: Optional[int] = Field(None, ge=0, title="Customer's city population", description="The population of the city.")
|
| 35 |
+
|
| 36 |
+
def is_valid(self) -> bool:
|
| 37 |
+
"""
|
| 38 |
+
Check if the transaction is valid.
|
| 39 |
+
"""
|
| 40 |
+
logger.debug("Validating transaction...")
|
| 41 |
+
now = datetime.now().replace(microsecond=0)
|
| 42 |
+
|
| 43 |
+
# The transaction amount should be greater than 0
|
| 44 |
+
if self.transaction_amount < 0:
|
| 45 |
+
logger.error("Transaction amount is negative.")
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
# Validate the transaction timestamp
|
| 49 |
+
try:
|
| 50 |
+
# Unix timestamps may not be very precise, so we divide by 1000 to get seconds
|
| 51 |
+
dt_object = datetime.fromtimestamp(self.transaction_timestamp // 1000)
|
| 52 |
+
if dt_object > now:
|
| 53 |
+
logger.error(f"Transaction timestamp is in the future ({dt_object} > {now}).")
|
| 54 |
+
return False
|
| 55 |
+
except ValueError:
|
| 56 |
+
logger.error("Invalid transaction timestamp.")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
# Validate the customer date of birth
|
| 60 |
+
# The customer should be 10 years old at least to possess a credit card
|
| 61 |
+
# If the customer date of birth is not provided, we assume the customer is older than 10
|
| 62 |
+
# If the customer date of birth is provided, we check if the customer is older than 10
|
| 63 |
+
if self.customer_date_of_birth:
|
| 64 |
+
try:
|
| 65 |
+
dob = datetime.strptime(self.customer_date_of_birth, "%Y-%m-%d")
|
| 66 |
+
age = (now - dob).days // 365
|
| 67 |
+
if age < 10:
|
| 68 |
+
logger.error("Customer is younger than 10 years old.")
|
| 69 |
+
return False
|
| 70 |
+
except ValueError:
|
| 71 |
+
logger.error("Invalid customer date of birth format. Expected YYYY-MM-DD.")
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
def to_transaction(self) -> Transaction:
|
| 77 |
+
return Transaction(
|
| 78 |
+
transaction_number=self.transaction_number,
|
| 79 |
+
transaction_amount=self.transaction_amount,
|
| 80 |
+
transaction_datetime=datetime.fromtimestamp(self.transaction_timestamp / 1000),
|
| 81 |
+
transaction_category=self.transaction_category,
|
| 82 |
+
merchant_name=self.merchant_name,
|
| 83 |
+
merchant_address_latitude=self.merchant_latitude,
|
| 84 |
+
merchant_address_longitude=self.merchant_longitude,
|
| 85 |
+
customer_credit_card_number=self.customer_credit_card_number,
|
| 86 |
+
customer_gender=self.customer_gender,
|
| 87 |
+
customer_firstname=self.customer_first_name,
|
| 88 |
+
customer_lastname=self.customer_last_name,
|
| 89 |
+
customer_dob=self.customer_date_of_birth,
|
| 90 |
+
customer_job=self.customer_job,
|
| 91 |
+
customer_address_street=self.customer_street,
|
| 92 |
+
customer_address_city=self.customer_city,
|
| 93 |
+
customer_address_state=self.customer_state,
|
| 94 |
+
customer_address_zip=self.customer_postal_code,
|
| 95 |
+
customer_address_latitude=self.customer_latitude,
|
| 96 |
+
customer_address_longitude=self.customer_longitude,
|
| 97 |
+
customer_address_city_population=self.customer_city_population
|
| 98 |
+
)
|
src/entity/fraud_details.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import String, Float, Boolean, DateTime, ForeignKey
|
| 2 |
+
from sqlalchemy.orm import relationship, mapped_column, Mapped
|
| 3 |
+
|
| 4 |
+
from . import Base
|
| 5 |
+
|
| 6 |
+
class FraudDetails(Base):
|
| 7 |
+
__tablename__ = "fraud_details"
|
| 8 |
+
|
| 9 |
+
# Transaction table columns
|
| 10 |
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
| 11 |
+
fraud_score: Mapped[float] = mapped_column(Float, nullable=True)
|
| 12 |
+
model_version: Mapped[str] = mapped_column(String(50), nullable=True)
|
| 13 |
+
notification_sent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
| 14 |
+
notification_recipients: Mapped[str] = mapped_column(String(255), nullable=True)
|
| 15 |
+
notification_datetime: Mapped[DateTime] = mapped_column(DateTime, nullable=True)
|
| 16 |
+
|
| 17 |
+
fk_transaction_id: Mapped[int] = mapped_column(ForeignKey("transactions.id"))
|
| 18 |
+
transaction: Mapped["Transaction"] = relationship("Transaction", back_populates="fraud_details")
|
src/entity/transaction.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from sqlalchemy import Integer, String, Float, Boolean, DateTime
|
| 3 |
+
from sqlalchemy.orm import relationship, mapped_column, Mapped
|
| 4 |
+
from src.entity.fraud_details import FraudDetails
|
| 5 |
+
|
| 6 |
+
from . import Base
|
| 7 |
+
|
| 8 |
+
class Transaction(Base):
|
| 9 |
+
"""
|
| 10 |
+
Transaction table
|
| 11 |
+
"""
|
| 12 |
+
__tablename__ = "transactions"
|
| 13 |
+
|
| 14 |
+
# Transaction table columns
|
| 15 |
+
id: Mapped[int] = mapped_column(primary_key=True)
|
| 16 |
+
transaction_number: Mapped[str] = mapped_column(String, nullable=False)
|
| 17 |
+
transaction_amount: Mapped[float] = mapped_column(Float, nullable=False)
|
| 18 |
+
transaction_datetime: Mapped[DateTime] = mapped_column(DateTime, nullable=False)
|
| 19 |
+
transaction_category: Mapped[str] = mapped_column(String, nullable=True)
|
| 20 |
+
customer_firstname: Mapped[str] = mapped_column(String, nullable=True)
|
| 21 |
+
customer_lastname: Mapped[str] = mapped_column(String, nullable=True)
|
| 22 |
+
customer_gender: Mapped[str] = mapped_column(String, nullable=True)
|
| 23 |
+
customer_credit_card_number: Mapped[str] = mapped_column('customer_credit_card_num', String, nullable=True)
|
| 24 |
+
customer_address_street: Mapped[str] = mapped_column(String, nullable=True)
|
| 25 |
+
customer_address_city: Mapped[str] = mapped_column(String, nullable=True)
|
| 26 |
+
customer_address_state: Mapped[str] = mapped_column(String, nullable=True)
|
| 27 |
+
customer_address_zip: Mapped[str] = mapped_column(String, nullable=True)
|
| 28 |
+
customer_address_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
| 29 |
+
customer_address_longitude: Mapped[float] = mapped_column(Float, nullable=True)
|
| 30 |
+
customer_address_city_population: Mapped[int] = mapped_column(Integer, nullable=True)
|
| 31 |
+
customer_job: Mapped[str] = mapped_column(String, nullable=True)
|
| 32 |
+
customer_dob: Mapped[DateTime] = mapped_column(DateTime, nullable=True)
|
| 33 |
+
merchant_name: Mapped[str] = mapped_column(String, nullable=True)
|
| 34 |
+
merchant_address_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
| 35 |
+
merchant_address_longitude: Mapped[float] = mapped_column(Float, nullable=True)
|
| 36 |
+
is_fraud: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
| 37 |
+
|
| 38 |
+
# Dependent table
|
| 39 |
+
fraud_details: Mapped[FraudDetails] = relationship("FraudDetails", back_populates="transaction", cascade="all, delete-orphan", uselist=False)
|
src/main.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import secrets
|
| 4 |
+
from typing import Annotated
|
| 5 |
+
from fastapi import (
|
| 6 |
+
FastAPI,
|
| 7 |
+
Request,
|
| 8 |
+
HTTPException,
|
| 9 |
+
Query,
|
| 10 |
+
Security,
|
| 11 |
+
Depends
|
| 12 |
+
)
|
| 13 |
+
from fastapi.background import BackgroundTasks
|
| 14 |
+
from fastapi.responses import RedirectResponse
|
| 15 |
+
from fastapi.security.api_key import APIKeyHeader
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
from psycopg.errors import UniqueViolation, IntegrityError
|
| 18 |
+
from starlette.status import (
|
| 19 |
+
HTTP_403_FORBIDDEN,
|
| 20 |
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
| 21 |
+
HTTP_500_INTERNAL_SERVER_ERROR)
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
from src.entity.api.transaction_api import TransactionApi
|
| 24 |
+
from sqlalchemy.orm import Session
|
| 25 |
+
|
| 26 |
+
from src.service.fraud_service import check_for_fraud
|
| 27 |
+
from src.service.notification_service import send_notification
|
| 28 |
+
from src.repository.common import get_session
|
| 29 |
+
from src.repository.fraud_details_repo import insert_fraud
|
| 30 |
+
from src.repository.transaction_repo import insert_transaction
|
| 31 |
+
|
| 32 |
+
# ------------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
load_dotenv()
|
| 35 |
+
FASTAPI_API_KEY = os.getenv("FASTAPI_API_KEY")
|
| 36 |
+
safe_clients = ['127.0.0.1']
|
| 37 |
+
|
| 38 |
+
api_key_header = APIKeyHeader(name='Authorization', auto_error=False)
|
| 39 |
+
|
| 40 |
+
async def validate_api_key(request: Request, key: str = Security(api_key_header)):
|
| 41 |
+
'''
|
| 42 |
+
Check if the API key is valid
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
key (str): The API key to check
|
| 46 |
+
|
| 47 |
+
Raises:
|
| 48 |
+
HTTPException: If the API key is invalid
|
| 49 |
+
'''
|
| 50 |
+
if request.client.host not in safe_clients and not secrets.compare_digest(str(key), str(FASTAPI_API_KEY)):
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=HTTP_403_FORBIDDEN, detail="Unauthorized - API Key is wrong"
|
| 53 |
+
)
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
app = FastAPI(dependencies=[Depends(validate_api_key)] if FASTAPI_API_KEY else None,
|
| 57 |
+
title="Fraud Detection Service API")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------------------
|
| 61 |
+
@app.get("/", include_in_schema=False)
|
| 62 |
+
def redirect_to_docs():
|
| 63 |
+
'''
|
| 64 |
+
Redirect to the API documentation.
|
| 65 |
+
'''
|
| 66 |
+
return RedirectResponse(url='/docs')
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TransactionProcessingOutput(BaseModel):
|
| 70 |
+
"""
|
| 71 |
+
TransactionProcessingOutput is a class that represents the output of the transaction processing endpoint.
|
| 72 |
+
It contains the fraud detection result and the fraud score.
|
| 73 |
+
This class is used as the response model for the /transaction/process endpoint.
|
| 74 |
+
"""
|
| 75 |
+
is_fraud: int = Field(description="The prediction result. 1 if the transaction is detected as fraudulent, 0 otherwise.", example=1)
|
| 76 |
+
fraud_score: float = Field(description="Probability of transaction being fraudulent.", example=0.85)
|
| 77 |
+
|
| 78 |
+
@app.post("/transaction/process",
|
| 79 |
+
tags=["transaction"],
|
| 80 |
+
description="Process a transaction",
|
| 81 |
+
response_model=TransactionProcessingOutput,
|
| 82 |
+
)
|
| 83 |
+
async def process_transaction(
|
| 84 |
+
background_tasks: BackgroundTasks,
|
| 85 |
+
transactionApi: Annotated[TransactionApi, Query()],
|
| 86 |
+
db: Annotated[Session, Depends(get_session)]
|
| 87 |
+
):
|
| 88 |
+
"""
|
| 89 |
+
Process a transaction
|
| 90 |
+
"""
|
| 91 |
+
# Check the transaction
|
| 92 |
+
if not transactionApi.is_valid():
|
| 93 |
+
raise HTTPException(
|
| 94 |
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
| 95 |
+
detail="Transaction is not valid. Check input values."
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Convert the API object to a Transaction object
|
| 99 |
+
transaction = transactionApi.to_transaction()
|
| 100 |
+
|
| 101 |
+
# Process the transaction
|
| 102 |
+
try:
|
| 103 |
+
# Insert every single transaction into the database
|
| 104 |
+
transaction = insert_transaction(db, transaction)
|
| 105 |
+
except UniqueViolation as e:
|
| 106 |
+
logging.error(e)
|
| 107 |
+
raise HTTPException(
|
| 108 |
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
| 109 |
+
detail=f"Transaction {transaction.transaction_number} already exists"
|
| 110 |
+
)
|
| 111 |
+
except IntegrityError as e:
|
| 112 |
+
logging.error(e)
|
| 113 |
+
raise HTTPException(
|
| 114 |
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
| 115 |
+
detail=f"Transaction {transaction.transaction_number} is not valid. Check input values."
|
| 116 |
+
)
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logging.error(e)
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
|
| 121 |
+
detail="An error occurred while processing the transaction. See logs for details."
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Check for fraud
|
| 125 |
+
is_fraud = check_for_fraud(transaction)
|
| 126 |
+
|
| 127 |
+
if is_fraud:
|
| 128 |
+
insert_fraud(
|
| 129 |
+
db=db,
|
| 130 |
+
transaction=transaction,
|
| 131 |
+
fraud_score=0.5,
|
| 132 |
+
model_version='latest'
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Send notification to the user
|
| 136 |
+
background_tasks.add_task(
|
| 137 |
+
func=send_notification,
|
| 138 |
+
transaction_id=transaction.id)
|
| 139 |
+
|
| 140 |
+
# Return the result
|
| 141 |
+
output = {
|
| 142 |
+
'is_fraud': 1 if is_fraud else 0,
|
| 143 |
+
'fraud_score': 0.5 if is_fraud else 0.0
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return output
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------------------
|
| 149 |
+
@app.get("/check_health", tags=["general"], description="Check the health of the API")
|
| 150 |
+
async def check_health(session: Annotated[Session, Depends(get_session)]):
|
| 151 |
+
"""
|
| 152 |
+
Check all the services in the infrastructure are working
|
| 153 |
+
"""
|
| 154 |
+
healthy = 0
|
| 155 |
+
unhealthy = 1
|
| 156 |
+
|
| 157 |
+
# DB check
|
| 158 |
+
db_status = False
|
| 159 |
+
try:
|
| 160 |
+
session.execute("SELECT 1")
|
| 161 |
+
db_status = True
|
| 162 |
+
except Exception:
|
| 163 |
+
pass
|
| 164 |
+
|
| 165 |
+
if db_status:
|
| 166 |
+
return healthy
|
| 167 |
+
else:
|
| 168 |
+
return unhealthy
|
src/repository/__init__.py
ADDED
|
File without changes
|
src/repository/common.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generator
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 11 |
+
engine = create_engine(DATABASE_URL)
|
| 12 |
+
SessionLocal = sessionmaker(bind=engine)
|
| 13 |
+
|
| 14 |
+
def get_session() -> Generator:
|
| 15 |
+
"""
|
| 16 |
+
Get a connection to the Postgres database
|
| 17 |
+
"""
|
| 18 |
+
db = SessionLocal()
|
| 19 |
+
try:
|
| 20 |
+
yield db
|
| 21 |
+
finally:
|
| 22 |
+
db.close()
|
src/repository/fraud_details_repo.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from psycopg import Connection
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from src.entity.fraud_details import FraudDetails
|
| 4 |
+
from src.entity.transaction import Transaction
|
| 5 |
+
|
| 6 |
+
def insert_fraud(db: Connection,
|
| 7 |
+
transaction: Transaction,
|
| 8 |
+
fraud_score: float,
|
| 9 |
+
model_version: str) -> Optional[Transaction]:
|
| 10 |
+
"""
|
| 11 |
+
Insert a fraud into the database
|
| 12 |
+
"""
|
| 13 |
+
if transaction.fraud_details:
|
| 14 |
+
raise ValueError("Transaction already has fraud details")
|
| 15 |
+
|
| 16 |
+
fraud = FraudDetails()
|
| 17 |
+
fraud.transaction = transaction
|
| 18 |
+
fraud.notification_sent = False
|
| 19 |
+
fraud.notification_recipients = None
|
| 20 |
+
fraud.notification_datetime = None
|
| 21 |
+
fraud.fraud_score = fraud_score
|
| 22 |
+
fraud.model_version = model_version
|
| 23 |
+
|
| 24 |
+
db.add(fraud)
|
| 25 |
+
db.commit()
|
| 26 |
+
|
| 27 |
+
return transaction
|
src/repository/transaction_repo.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from psycopg import Connection
|
| 3 |
+
from sqlalchemy.orm import joinedload
|
| 4 |
+
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from src.entity.transaction import Transaction
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def fetch_transaction(db: Connection, transaction_id: int) -> Optional[Transaction]:
|
| 10 |
+
"""
|
| 11 |
+
Fetch a transaction from the database
|
| 12 |
+
"""
|
| 13 |
+
transaction = db.query(Transaction).options(
|
| 14 |
+
joinedload(Transaction.fraud_details)
|
| 15 |
+
).filter(Transaction.id == transaction_id).first()
|
| 16 |
+
|
| 17 |
+
if transaction is None:
|
| 18 |
+
raise ValueError(f"Transaction {transaction_id} not found")
|
| 19 |
+
else:
|
| 20 |
+
logging.info(f"Transaction {transaction_id} found")
|
| 21 |
+
|
| 22 |
+
return transaction
|
| 23 |
+
|
| 24 |
+
def commit_transaction(db: Connection, transaction: Transaction):
|
| 25 |
+
"""
|
| 26 |
+
Commit a transaction to the database
|
| 27 |
+
"""
|
| 28 |
+
db.merge(transaction) # Make sure the object is in the session
|
| 29 |
+
db.commit()
|
| 30 |
+
|
| 31 |
+
def insert_transaction(db: Connection, transaction: Transaction) -> Optional[Transaction]:
|
| 32 |
+
"""
|
| 33 |
+
Insert a transaction into the database
|
| 34 |
+
"""
|
| 35 |
+
db.add(transaction)
|
| 36 |
+
db.commit()
|
| 37 |
+
db.refresh(transaction)
|
| 38 |
+
|
| 39 |
+
return transaction
|
src/service/__init__.py
ADDED
|
File without changes
|
src/service/fraud_service.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from src.entity.transaction import Transaction
|
| 3 |
+
|
| 4 |
+
# Configure logging
|
| 5 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def check_for_fraud(transaction: Transaction) -> bool:
|
| 10 |
+
"""
|
| 11 |
+
Check for fraud in the transaction.
|
| 12 |
+
"""
|
| 13 |
+
logger.debug("Checking for fraud...")
|
| 14 |
+
|
| 15 |
+
# Check if the transaction amount is greater than 1000
|
| 16 |
+
transaction_amount_threshold = 1000
|
| 17 |
+
if transaction.transaction_amount > transaction_amount_threshold:
|
| 18 |
+
logger.info(f"Transaction amount ({transaction.transaction_amount}) is greater than {transaction_amount_threshold}.")
|
| 19 |
+
return True
|
| 20 |
+
|
| 21 |
+
# Check if the transaction category is 'electronics'
|
| 22 |
+
if transaction.transaction_category == 'electronics':
|
| 23 |
+
logger.info("Transaction category is electronics.")
|
| 24 |
+
return True
|
| 25 |
+
|
| 26 |
+
# Check if the customer address city population is less than 1000
|
| 27 |
+
city_population_threshold = 1000
|
| 28 |
+
if transaction.customer_address_city_population < city_population_threshold:
|
| 29 |
+
logger.info(f"Customer address city population {transaction.customer_address_city_population} is less than {city_population_threshold}.")
|
| 30 |
+
return True
|
| 31 |
+
|
| 32 |
+
# Check if the customer job is 'unemployed'
|
| 33 |
+
if transaction.customer_job == 'unemployed':
|
| 34 |
+
logger.info("Customer job is unemployed.")
|
| 35 |
+
return True
|
| 36 |
+
|
| 37 |
+
# If none of the above conditions are met, return False
|
| 38 |
+
logger.debug("No fraud detected.")
|
| 39 |
+
|
| 40 |
+
return False
|
src/service/notification_service.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import smtplib
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from email.message import EmailMessage
|
| 6 |
+
from src.repository.common import get_session
|
| 7 |
+
from src.repository.transaction_repo import fetch_transaction
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# Configure logging
|
| 12 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# Load environment variables
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
SMTP_SERVER = os.getenv("SMTP_SERVER")
|
| 19 |
+
SMTP_PORT = os.getenv("SMTP_PORT")
|
| 20 |
+
SENDER_EMAIL = os.getenv("SENDER_EMAIL")
|
| 21 |
+
RECEIVER_EMAIL = os.getenv("RECEIVER_EMAIL")
|
| 22 |
+
SENDER_PASSWORD = os.getenv("SENDER_PASSWORD")
|
| 23 |
+
|
| 24 |
+
def send_notification(transaction_id: int):
|
| 25 |
+
"""
|
| 26 |
+
Send a notification email when a transaction is detected as fraudulent.
|
| 27 |
+
"""
|
| 28 |
+
logger.info("Sending notification email...")
|
| 29 |
+
|
| 30 |
+
# Fetch the transaction details from the database
|
| 31 |
+
with next(get_session()) as session:
|
| 32 |
+
transaction = fetch_transaction(session, transaction_id)
|
| 33 |
+
|
| 34 |
+
if transaction is None:
|
| 35 |
+
logger.error(f"Transaction {transaction_id} not found")
|
| 36 |
+
return
|
| 37 |
+
if transaction.fraud_details.notification_sent:
|
| 38 |
+
logger.info(f"Notification already sent for transaction {transaction_id}")
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
# Create the email
|
| 42 |
+
email_body = _build_notification_body(
|
| 43 |
+
transaction_number=transaction.transaction_number,
|
| 44 |
+
fraud_score=transaction.fraud_details.fraud_score,
|
| 45 |
+
model_version=transaction.fraud_details.model_version,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
msg = EmailMessage()
|
| 49 |
+
msg["Subject"] = "Fraud detected!"
|
| 50 |
+
msg["From"] = SENDER_EMAIL
|
| 51 |
+
msg["To"] = RECEIVER_EMAIL
|
| 52 |
+
msg.set_content(email_body)
|
| 53 |
+
|
| 54 |
+
# Send the email
|
| 55 |
+
logging.debug(f"Connecting to SMTP server {SMTP_SERVER} using port {SMTP_PORT}...")
|
| 56 |
+
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
|
| 57 |
+
server.starttls() # Secure the connection
|
| 58 |
+
server.login(SENDER_EMAIL, SENDER_PASSWORD)
|
| 59 |
+
server.send_message(msg)
|
| 60 |
+
|
| 61 |
+
logging.info("Email sent!")
|
| 62 |
+
|
| 63 |
+
# Update the transaction details in the database
|
| 64 |
+
transaction.fraud_details.notification_sent = True
|
| 65 |
+
transaction.fraud_details.notification_recipients = RECEIVER_EMAIL
|
| 66 |
+
transaction.fraud_details.notification_datetime = datetime.now()
|
| 67 |
+
session.commit()
|
| 68 |
+
|
| 69 |
+
def _build_notification_body(
|
| 70 |
+
transaction_number: str,
|
| 71 |
+
fraud_score: float,
|
| 72 |
+
model_version: str,
|
| 73 |
+
) -> str:
|
| 74 |
+
"""
|
| 75 |
+
Build the body of the notification email.
|
| 76 |
+
"""
|
| 77 |
+
logger.debug("Building notification email body...")
|
| 78 |
+
|
| 79 |
+
# Create the email body
|
| 80 |
+
email_body = f"""
|
| 81 |
+
Fraud detected!
|
| 82 |
+
|
| 83 |
+
Transaction number: {transaction_number}
|
| 84 |
+
Fraud score: {fraud_score}
|
| 85 |
+
Model version: {model_version}
|
| 86 |
+
|
| 87 |
+
Please check the transaction.
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
return email_body
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from src.entity.transaction import Transaction
|
| 4 |
+
from src.entity.api.transaction_api import TransactionApi
|
| 5 |
+
|
| 6 |
+
@pytest.fixture
|
| 7 |
+
def valid_transaction():
|
| 8 |
+
return Transaction(
|
| 9 |
+
customer_address_city_population=333497,
|
| 10 |
+
customer_firstname='Jeff',
|
| 11 |
+
customer_lastname='Elliott',
|
| 12 |
+
customer_gender='M',
|
| 13 |
+
customer_job='Mechanical engineer',
|
| 14 |
+
customer_credit_card_number='2291163933867244',
|
| 15 |
+
customer_address_street='351 Darlene Green',
|
| 16 |
+
customer_address_city='Columbia',
|
| 17 |
+
customer_address_state='SC',
|
| 18 |
+
customer_address_zip='29209',
|
| 19 |
+
customer_address_latitude=33.9659,
|
| 20 |
+
customer_address_longitude=-80.9355,
|
| 21 |
+
customer_dob='1968-03-19',
|
| 22 |
+
transaction_number='2da90c7d74bd46a0caf3777415b3ebd3',
|
| 23 |
+
transaction_amount=2.86,
|
| 24 |
+
transaction_datetime=pd.to_datetime('2020-06-21 12:14:25'),
|
| 25 |
+
transaction_category='personal_care',
|
| 26 |
+
merchant_name='fraud_Kirlin and Sons',
|
| 27 |
+
merchant_address_latitude=33.986391,
|
| 28 |
+
merchant_address_longitude=-81.200714,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def fraudulent_transaction():
|
| 33 |
+
return Transaction(
|
| 34 |
+
customer_address_city_population=23,
|
| 35 |
+
customer_firstname='Brooke',
|
| 36 |
+
customer_lastname='Smith',
|
| 37 |
+
customer_gender='F',
|
| 38 |
+
customer_job='Cytogeneticist',
|
| 39 |
+
customer_credit_card_number='3560725013359375',
|
| 40 |
+
customer_address_street='63542 Luna Brook Apt. 012',
|
| 41 |
+
customer_address_city='Notrees',
|
| 42 |
+
customer_address_state='TX',
|
| 43 |
+
customer_address_zip='79759',
|
| 44 |
+
customer_address_latitude=31.8599,
|
| 45 |
+
customer_address_longitude=-102.7413,
|
| 46 |
+
customer_dob='1969-09-15',
|
| 47 |
+
transaction_number='16bf2e46c54369a8eab2214649506425',
|
| 48 |
+
transaction_amount=2400000.84,
|
| 49 |
+
transaction_datetime=pd.to_datetime('2020-06-21 22:06:39'),
|
| 50 |
+
transaction_category='health_fitness',
|
| 51 |
+
merchant_name="fraud_Hamill-D'Amore",
|
| 52 |
+
merchant_address_latitude=32.575873,
|
| 53 |
+
merchant_address_longitude=-102.60429,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@pytest.fixture
|
| 57 |
+
def transaction_api():
|
| 58 |
+
return TransactionApi(
|
| 59 |
+
transaction_number="123456789",
|
| 60 |
+
transaction_timestamp=1633036800,
|
| 61 |
+
transaction_amount=100.0,
|
| 62 |
+
transaction_category="Electronics",
|
| 63 |
+
merchant_name="Best Buy",
|
| 64 |
+
merchant_latitude=37.7749,
|
| 65 |
+
merchant_longitude=-122.4194,
|
| 66 |
+
customer_credit_card_number="4111111111111111",
|
| 67 |
+
customer_gender="M",
|
| 68 |
+
customer_first_name="John",
|
| 69 |
+
customer_last_name="Doe",
|
| 70 |
+
customer_date_of_birth="1980-01-01",
|
| 71 |
+
customer_job="Engineer",
|
| 72 |
+
customer_street="123 Main St",
|
| 73 |
+
customer_city="San Francisco",
|
| 74 |
+
customer_state="CA",
|
| 75 |
+
customer_postal_code=94105,
|
| 76 |
+
customer_latitude=37.7749,
|
| 77 |
+
customer_longitude=-122.4194,
|
| 78 |
+
customer_city_population=870000,
|
| 79 |
+
)
|
tests/test_fraud_service.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.service.fraud_service import check_for_fraud
|
| 2 |
+
from src.entity.transaction import Transaction
|
| 3 |
+
|
| 4 |
+
def test_check_for_fraud_valid(valid_transaction: Transaction):
|
| 5 |
+
"""
|
| 6 |
+
Test the method check_for_fraud
|
| 7 |
+
Case: valid transaction
|
| 8 |
+
"""
|
| 9 |
+
is_fraud = check_for_fraud(valid_transaction)
|
| 10 |
+
assert is_fraud is False
|
| 11 |
+
|
| 12 |
+
def test_check_for_fraud_invalid(fraudulent_transaction: Transaction):
|
| 13 |
+
"""
|
| 14 |
+
Test the method check_for_fraud
|
| 15 |
+
Case: invalid transaction
|
| 16 |
+
"""
|
| 17 |
+
is_fraud = check_for_fraud(fraudulent_transaction)
|
| 18 |
+
assert is_fraud is True
|
| 19 |
+
|
tests/test_notification_service.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.service.notification_service import _build_notification_body
|
| 2 |
+
|
| 3 |
+
def test__build_notification_body():
|
| 4 |
+
"""
|
| 5 |
+
Test the method _build_notification_body
|
| 6 |
+
"""
|
| 7 |
+
email_body = _build_notification_body(
|
| 8 |
+
transaction_number="123456789",
|
| 9 |
+
fraud_score=0.95,
|
| 10 |
+
model_version="v1.0",
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
assert "123456789" in email_body
|
| 14 |
+
assert "0.95" in email_body
|
| 15 |
+
assert "v1.0" in email_body
|
tests/test_transaction_api_entity.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.entity.api.transaction_api import TransactionApi
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
|
| 4 |
+
def test_is_valid(transaction_api: TransactionApi):
|
| 5 |
+
"""
|
| 6 |
+
Test the is_valid method of the TransactionApi class
|
| 7 |
+
Case: valid transaction_api object
|
| 8 |
+
"""
|
| 9 |
+
# Check if the transaction_api object is valid
|
| 10 |
+
assert transaction_api.is_valid() is True
|
| 11 |
+
|
| 12 |
+
def test_is_valid_negative_amount(transaction_api: TransactionApi):
|
| 13 |
+
# Check if the transaction_api object is invalid with negative amount
|
| 14 |
+
transaction_api.transaction_amount = -100.0
|
| 15 |
+
assert transaction_api.is_valid() is False
|
| 16 |
+
|
| 17 |
+
def test_is_valid_future_timestamp(transaction_api: TransactionApi):
|
| 18 |
+
# Check if the transaction_api object is invalid with future timestamp
|
| 19 |
+
transaction_api.transaction_timestamp = 999999999999999
|
| 20 |
+
assert transaction_api.is_valid() is False
|
| 21 |
+
|
| 22 |
+
def test_is_valid_future_dob(transaction_api: TransactionApi):
|
| 23 |
+
# Check if the transaction_api object is invalid with too young date of birth
|
| 24 |
+
nine_years_ago = datetime.now() - timedelta(days=365 * 9) # 9 years old customer
|
| 25 |
+
transaction_api.customer_date_of_birth = nine_years_ago.strftime("%Y-%m-%d")
|
| 26 |
+
assert transaction_api.is_valid() is False
|
| 27 |
+
|
| 28 |
+
def test_is_valid_invalid_date_format(transaction_api: TransactionApi):
|
| 29 |
+
# Check if the transaction_api object is invalid with invalid date of birth format
|
| 30 |
+
transaction_api.customer_date_of_birth = "01-01-1980"
|
| 31 |
+
assert transaction_api.is_valid() is False
|