sghorbal commited on
Commit
4aed8ef
·
1 Parent(s): d8f2220

Initial commit

Browse files
.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