Merge branch 'bank-simbrella-calls' of DigiFi/digifi-BankToProductCore into master

This commit is contained in:
2025-04-01 10:18:19 +00:00
committed by Gogs
33 changed files with 892 additions and 102 deletions
+23
View File
@@ -0,0 +1,23 @@
VALID_APP_ID=**********
VALID_API_KEY=*************
BASIC_AUTH_USERNAME=******
BASIC_AUTH_PASSWORD=******
SWAGGER_URL="/documentation"
API_URL="/swagger.json"
DATABASE_USER=*****
DATABASE_PASSWORD=*****
DATABASE_HOST=******
DATABASE_PORT=******
DATABASE_NAME=*****
# Flask Configuration
FLASK_APP=wsgi.py
FLASK_ENV=development
APP_PORT=4500
SIMBRELLA_BASE_URL=***************
+3 -1
View File
@@ -1,4 +1,6 @@
__pycache__/ __pycache__/
.env .env
app.log app.log
.DS_Store .DS_Store
migrations/__pycache__/
migrations/*.pycg
+3 -2
View File
@@ -17,5 +17,6 @@ EXPOSE 5000
ENV FLASK_APP=app.py ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0 ENV FLASK_RUN_HOST=0.0.0.0
# Run the application RUN chmod +x scripts/entrypoint.sh
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "wsgi:wsgi_app"]
ENTRYPOINT ["scripts/entrypoint.sh"]
+25 -11
View File
@@ -20,25 +20,39 @@ cd repository
``` ```
### 2. Create a `.env` File ### 2. Configure Environment Variables
Before running the application, create a `.env` file in the root directory and add the required environment variables: Instead of creating a new `.env` file, rename the provided `.env.example` file and update the necessary variables:
```bash ```bash
touch .env cp .env.example .env
``` ```
Then, open the `.env` file and add the following: Then, open the `.env` file and **update the following variables with your actual configuration:**
```ini - Database credentials:
# Environment Variables ```ini
BASIC_AUTH_USERNAME=user DATABASE_USER=your_database_username
BASIC_AUTH_PASSWORD=password DATABASE_PASSWORD=your_database_password
SWAGGER_URL="/documentation" DATABASE_NAME=your_database_name
API_URL="/swagger.json" DATABASE_PORT=5432
```
- App ID and API Key:
```ini
APP_ID=your_app_id
API_KEY=your_api_key
```
This ensures that the application is properly configured with your environment variables.
> **Optional:** Change file permissions for the entrypoint script
```bash
chmod +x scripts/entrypoint.sh
``` ```
This ensures that the application uses secure API keys and app IDs. ---
### 3. Run the Application with Docker Compose ### 3. Run the Application with Docker Compose
+10 -2
View File
@@ -5,6 +5,9 @@ from flask_cors import CORS
from app.config import Config from app.config import Config
from app.api.routes import api from app.api.routes import api
from app.errors import register_error_handlers from app.errors import register_error_handlers
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from app.extensions import db, migrate
def create_app(): def create_app():
""" Factory function to create a Flask app instance """ """ Factory function to create a Flask app instance """
@@ -30,8 +33,13 @@ def create_app():
# Error Handlers # Error Handlers
register_error_handlers(app) register_error_handlers(app)
from . import models
# Database and Migrations
db.init_app(app)
migrate.init_app(app, db)
return app return app
+1
View File
@@ -0,0 +1 @@
from .transaction_type import TransactionType
+10
View File
@@ -0,0 +1,10 @@
from enum import Enum
class TransactionType(str, Enum):
ELIGIBILITY_CHECK = "eligibility_check"
CUSTOMER_CONSENT = "customer_consent"
LOAN_STATUS = "loan_status"
NOTIFICATION_CALLBACK = "notification_callback"
PROVIDE_LOAN = "provide_loan"
REPAYMENT = "repayment"
SELECT_OFFER = "select_offer"
+1
View File
@@ -0,0 +1 @@
from .simbrella import SimbrellaClient
+46
View File
@@ -0,0 +1,46 @@
import requests
from app.utils.logger import logger
from app.config import settings
class SimbrellaClient:
BASE_URL = settings.SIMBRELLA_BASE_URL
@staticmethod
def rac_check(customer_id, account_id, transaction_id):
"""
Calls the RACCheck endpoit
"""
url = f"{SimbrellaClient.BASE_URL}/RACCheck"
payload = {
"customerId": customer_id,
"accountId": account_id,
"transactionId": transaction_id,
"RAC_Array": [
{
"salaryAccount": True,
"bvn": "12345678901",
"crc": False,
"crms": True,
"accountStatus": "active",
"lien": False,
"noBouncedCheck": True,
"existingLoan": False,
"whitelist": True,
"noPastDueSalaryLoan": True,
"noPastDueOtherLoans": False
}
]
}
try:
response = requests.post(url, json=payload, timeout=10)
# Raise an error for non-200 responses
# response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as err:
logger.error(f"RACCheck API call failed: {str(err)}", exc_info=True)
return {"error": "RACCheck API error", "details": str(err)}
+55
View File
@@ -0,0 +1,55 @@
from app.models import Customer, Account, Transaction
from app.api.enums import TransactionType
from flask import jsonify
from marshmallow import ValidationError
import logging
logger = logging.getLogger(__name__)
class BaseService:
TRANSACTION_TYPE = None
@classmethod
def validate_data(cls, data, schema):
"""
Validate input data based on the provided schema.
"""
logger.info(f"Processing {cls.TRANSACTION_TYPE} request")
return schema.load(data)
@classmethod
def get_or_create_customer(cls, validated_data):
"""
Check if a customer exists; if not, create one.
"""
customer = Customer.query.filter_by(id=validated_data.get("customerId")).first()
if not customer:
customer = Customer.create_customer(
id=validated_data.get("customerId"),
msisdn=validated_data.get("msisdn"),
country_code=validated_data.get("countryCode"),
account_id=validated_data.get("accountId"),
)
return customer
@classmethod
def validate_account_ownership(cls, account_id, customer_id):
"""
Check if the provided account belongs to the customer.
"""
is_valid = Account.is_valid_account(account_id, customer_id)
return is_valid
@classmethod
def log_transaction(cls, validated_data):
"""
Create a new transaction.
"""
return Transaction.create_transaction(
id=validated_data.get("transactionId"),
account_id=validated_data.get("accountId"),
type=cls.TRANSACTION_TYPE,
channel=validated_data.get("channel"),
)
+36 -15
View File
@@ -1,10 +1,15 @@
from flask import request, jsonify from flask import request, jsonify
from app.api.services.base_service import BaseService
from marshmallow import ValidationError from marshmallow import ValidationError
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.customer_consent import CustomerConsentSchema from app.api.schemas.customer_consent import CustomerConsentSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
class CustomerConsentService: class CustomerConsentService(BaseService):
TRANSACTION_TYPE = TransactionType.CUSTOMER_CONSENT
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -17,34 +22,50 @@ class CustomerConsentService:
dict: A standardized response. dict: A standardized response.
""" """
try: try:
logger.info("Processing CustomerConsent request")
# Validate input data using the CustomerConsent schema validated_data = CustomerConsentService.validate_data(data, CustomerConsentSchema())
schema = CustomerConsentSchema() account_id = validated_data.get('accountId')
validated_data = schema.load(data) # Raises ValidationError if invalid customer_id = validated_data.get('customerId')
if(CustomerConsentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = CustomerConsentService.log_transaction(validated_data = validated_data)
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
# Simulated processing logic # Simulated processing logic
response_data = { response_data = {
"resultCode": "00", "resultCode": "00",
"resultDescription": "Request is received" "resultDescription": "Request is received"
} }
# return ResponseHelper.success(
# data=response_data,
# message="Customer consent processed successfully"
# )
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({ return jsonify({
"message": "Internal Server Error" "message": "Internal Server Error"
}) , 500 }) , 500
+46 -13
View File
@@ -1,9 +1,14 @@
from flask import session, jsonify from flask import session, jsonify
from app.utils.logger import logger from app.utils.logger import logger
from app.api.services.base_service import BaseService
from app.api.schemas.eligibility_check import EligibilityCheckSchema from app.api.schemas.eligibility_check import EligibilityCheckSchema
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.enums import TransactionType
from app.api.integrations import SimbrellaClient
class EligibilityCheckService(BaseService):
TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK
class EligibilityCheckService:
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -16,11 +21,37 @@ class EligibilityCheckService:
dict: A standardized response. dict: A standardized response.
""" """
try: try:
logger.info("Processing EligibilityCheck request")
# Validate input data using Schema validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema())
schema = EligibilityCheckSchema() account_id = validated_data.get('accountId')
validated_data = schema.load(data) # Raises an error if invalid customer_id = validated_data.get('customerId')
customer = EligibilityCheckService.get_or_create_customer(validated_data = validated_data)
if (EligibilityCheckService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = EligibilityCheckService.log_transaction(validated_data = validated_data)
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
# Call RACCheck
response = SimbrellaClient.rac_check(
customer_id = customer_id,
account_id = account_id,
transaction_id = transaction.id,
)
if "error" in response or response.get("status") != 200:
return jsonify({"message": "RACCheck failed", "error": response.get("message", response)}), 400
offers = [ offers = [
{ {
@@ -51,19 +82,21 @@ class EligibilityCheckService:
"accountId": "ACN8263457" "accountId": "ACN8263457"
} }
# Return a success response
# return ResponseHelper.success(
# data=response_data,
# message="Eligibility check completed successfully"
# )
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
+34 -13
View File
@@ -1,9 +1,14 @@
from flask import request, jsonify from flask import request, jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.loan_status import LoanStatusSchema from app.api.schemas.loan_status import LoanStatusSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
class LoanStatusService(BaseService):
TRANSACTION_TYPE = TransactionType.LOAN_STATUS
class LoanStatusService:
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -16,11 +21,23 @@ class LoanStatusService:
dict: A standardized response. dict: A standardized response.
""" """
try: try:
logger.info("Processing LoanStatus request") validated_data = LoanStatusService.validate_data(data, LoanStatusSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
# Validate input data using the imported schema if (LoanStatusService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
schema = LoanStatusSchema() transaction = LoanStatusService.log_transaction(validated_data = validated_data)
validated_data = schema.load(data) # Raises ValidationError if invalid
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
loans = [ loans = [
{ {
@@ -46,21 +63,25 @@ class LoanStatusService:
} }
# return ResponseHelper.success(
# data=response_data,
# message="Loan information retrieved successfully"
# )
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({ return jsonify({
"message": "Internal Server Error" "message": "Internal Server Error"
}) , 500 }) , 500
+15 -2
View File
@@ -1,9 +1,13 @@
from flask import request, jsonify from flask import request, jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.notification_callback import NotificationCallbackSchema from app.api.schemas.notification_callback import NotificationCallbackSchema
class NotificationCallbackService: class NotificationCallbackService(BaseService):
TRANSACTION_TYPE = TransactionType.NOTIFICATION_CALLBACK
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -37,10 +41,19 @@ class NotificationCallbackService:
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
+33 -12
View File
@@ -1,9 +1,14 @@
from flask import request, jsonify from flask import request, jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.provide_loan import ProvideLoanSchema from app.api.schemas.provide_loan import ProvideLoanSchema
class ProvideLoanService: class ProvideLoanService(BaseService):
TRANSACTION_TYPE = TransactionType.PROVIDE_LOAN
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -16,13 +21,24 @@ class ProvideLoanService:
dict: A standardized response. dict: A standardized response.
""" """
try: try:
logger.info("Processing ProvideLoan request") validated_data = ProvideLoanService.validate_data(data, ProvideLoanSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
# Validate input data using the imported schema if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
schema = ProvideLoanSchema() transaction = ProvideLoanService.log_transaction(validated_data = validated_data)
validated_data = schema.load(data) # Raises ValidationError if invalid
# Business logic - providing a loan if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
response_data = { response_data = {
"requestId": "202111170001371256908", "requestId": "202111170001371256908",
"transactionId": "Tr201712RK9232P115", "transactionId": "Tr201712RK9232P115",
@@ -34,21 +50,26 @@ class ProvideLoanService:
} }
# return ResponseHelper.success(
# data=response_data,
# message="Loan successfully provided"
# )
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({ return jsonify({
"message": "Internal Server Error" "message": "Internal Server Error"
}) , 500 }) , 500
+31 -7
View File
@@ -1,9 +1,13 @@
from flask import request, jsonify from flask import request, jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.repayment import RepaymentSchema from app.api.schemas.repayment import RepaymentSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
class RepaymentService(BaseService):
TRANSACTION_TYPE = TransactionType.REPAYMENT
class RepaymentService:
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -16,11 +20,22 @@ class RepaymentService:
dict: A standardized response. dict: A standardized response.
""" """
try: try:
logger.info("Processing Repayment request") validated_data = RepaymentService.validate_data(data, RepaymentSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
# Validate input data using the Repayment schema if (RepaymentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
schema = RepaymentSchema() transaction = RepaymentService.log_transaction(validated_data = validated_data)
validated_data = schema.load(data) # Raises ValidationError if invalid
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
# Simulated processing logic # Simulated processing logic
response_data = { response_data = {
@@ -39,10 +54,19 @@ class RepaymentService:
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
+31 -12
View File
@@ -1,9 +1,13 @@
from flask import request, jsonify from flask import request, jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.utils.logger import logger from app.utils.logger import logger
from app.api.schemas.select_offer import SelectOfferSchema from app.api.schemas.select_offer import SelectOfferSchema
class SelectOfferService: class SelectOfferService(BaseService):
TRANSACTION_TYPE = TransactionType.SELECT_OFFER
@staticmethod @staticmethod
def process_request(data): def process_request(data):
""" """
@@ -16,12 +20,23 @@ class SelectOfferService:
dict: A standardized response. dict: A standardized response.
""" """
try: try:
logger.info("Processing SelectOffer request") validated_data = SelectOfferService.validate_data(data, SelectOfferSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
# Validate input data using the imported schema if (SelectOfferService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
schema = SelectOfferSchema() transaction = SelectOfferService.log_transaction(validated_data = validated_data)
validated_data = schema.load(data) # Raises ValidationError if invalid
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
offers = [ offers = [
{ {
"offerId": "14451", "offerId": "14451",
@@ -54,21 +69,25 @@ class SelectOfferService:
} }
# return ResponseHelper.success(
# data=response_data,
# message="Offer selection completed successfully"
# )
return response_data return response_data
except ValidationError as err: except ValidationError as err:
logger.error(f"Validation Error: {err.messages}")
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({ return jsonify({
"message": "Validation exception" "message": "Validation exception"
}) , 422 }) , 422
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
except Exception as e: except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True) logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({ return jsonify({
"message": "Internal Server Error" "message": "Internal Server Error"
}) , 500 }) , 500
+16 -4
View File
@@ -3,9 +3,6 @@ import os
class Config: class Config:
"""Base configuration for Flask app""" """Base configuration for Flask app"""
# SQLALCHEMY_DATABASE_URI = "mysql://root:password@localhost/flask_app"
# SQLALCHEMY_TRACK_MODIFICATIONS = False
# SECRET_KEY = os.environ.get("SECRET_KEY", "your_secret_key")
SWAGGER_URL = os.getenv("SWAGGER_URL", "/documentation") SWAGGER_URL = os.getenv("SWAGGER_URL", "/documentation")
API_URL = os.getenv("API_URL", "/swagger.json") API_URL = os.getenv("API_URL", "/swagger.json")
@@ -14,4 +11,19 @@ class Config:
VALID_APP_ID = os.getenv("VALID_APP_ID", "app1") VALID_APP_ID = os.getenv("VALID_APP_ID", "app1")
VALID_API_KEY = os.getenv("VALID_API_KEY", "test-api-key-12345") VALID_API_KEY = os.getenv("VALID_API_KEY", "test-api-key-12345")
BASIC_AUTH_USERNAME = os.environ.get("BASIC_AUTH_USERNAME", "user") BASIC_AUTH_USERNAME = os.environ.get("BASIC_AUTH_USERNAME", "user")
BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password") BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password")
DATABASE_USER = os.environ.get("DATABASE_USER")
DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
DATABASE_HOST = os.environ.get("DATABASE_HOST")
DATABASE_PORT = os.environ.get("DATABASE_PORT", 10532)
DATABASE_NAME = os.environ.get("DATABASE_NAME")
SQLALCHEMY_DATABASE_URI = (
f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SIMBRELLA_BASE_URL = os.getenv("SIMBRELLA_BASE_URL", "http://127.0.0.1:6337")
settings = Config()
+5
View File
@@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
+6
View File
@@ -0,0 +1,6 @@
from .customer import Customer
from .account import Account
from .loan import Loan
from .transaction import Transaction
__all__ = ['Customer', 'Account', 'Loan', 'Transaction']
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime, timezone
from app.extensions import db
class Account(db.Model):
__tablename__ = 'accounts'
id = db.Column(db.String(50), primary_key=True)
customer_id = db.Column(db.String(50), nullable=False)
account_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
lien_amount = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
@classmethod
def create_account(cls, id, customer_id, account_type, status='active'):
account = cls(
id=id,
customer_id=customer_id,
account_type=account_type
)
db.session.add(account)
db.session.commit()
return account
@classmethod
def is_valid_account(cls, account_id, customer_id):
account = cls.query.filter_by(id=account_id, customer_id=customer_id).first()
if not account:
return False
if account.lien_amount > 0:
return False
return True
def __repr__(self):
return f'<Account {self.id}>'
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime, timezone
from app.extensions import db
from app.models.account import Account
class Customer(db.Model):
__tablename__ = 'customers'
id = db.Column(db.String(50), primary_key=True)
msisdn = db.Column(db.String(20), unique=True, nullable=False)
country_code = db.Column(db.String(3), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
@classmethod
def is_eligible(cls, customer_id):
customer = cls.query.filter_by(id=customer_id).first()
if not customer:
return False, "Customer not found"
return True, "Customer is eligible"
@classmethod
def create_customer(cls, id, msisdn, country_code, account_id, account_type='savings'):
if cls.query.filter_by(id=id).first():
raise ValueError("Customer already exists")
# Create the customer
customer = cls(id=id, msisdn=msisdn, country_code=country_code)
db.session.add(customer)
# Create an associated account
account = Account.create_account(
id=account_id,
customer_id=id,
account_type=account_type
)
db.session.commit()
return customer
def __repr__(self):
return f'<Customer {self.id}>'
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from app.extensions import db
class Loan(db.Model):
__tablename__ = 'loans'
id = db.Column(db.String(50), primary_key=True)
customer_id = db.Column(db.String(50), nullable=False)
account_id = db.Column(db.String(50), nullable=False)
product_id = db.Column(db.String(20), nullable=False)
principal_amount = db.Column(db.Float, nullable=False)
status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
@classmethod
def has_active_loans(cls, customer_id):
active_loans = cls.query.filter_by(
customer_id=customer_id,
status='active'
).count()
if active_loans > 0:
return False, "Customer has active loans"
return True, "No active loans"
def __repr__(self):
return f'<Loan {self.id}>'
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime, timezone
from app.extensions import db
class Offer(db.Model):
__tablename__ = 'offers'
id = db.Column(db.Integer, primary_key=True)
product_id = db.Column(db.String, nullable=False)
min_amount = db.Column(db.Float, nullable=False)
max_amount = db.Column(db.Float, nullable=False)
tenor = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
def __repr__(self):
return f'<LoanOffer {self.id}>'
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.exc import IntegrityError
class Transaction(db.Model):
__tablename__ = 'transactions'
id = db.Column(db.String(50), primary_key=True)
account_id = db.Column(db.String(50), nullable=False)
type = db.Column(db.String(50), nullable=False)
channel = db.Column(db.String(50), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
def __repr__(self):
return f'<Transaction {self.id}>'
@classmethod
def create_transaction(cls, id, account_id, type, channel):
if cls.query.filter_by(id=id).first():
raise ValueError("Duplicate Transaction")
transaction = cls(
id=id,
account_id=account_id,
type=type,
channel=channel
)
try:
db.session.add(transaction)
db.session.commit()
except IntegrityError as err:
db.session.rollback()
raise ValueError(f"Database integrity error: {err}")
return transaction
@classmethod
def get_transaction_by_id(cls, transaction_id):
return cls.query.get(transaction_id)
+8 -5
View File
@@ -1,11 +1,14 @@
services: services:
digifi-flaska002: digifi-bank-to-product-core:
build: . build: .
env_file:
- .env
ports: ports:
- "4500:5000" - "${APP_PORT:-4500}:5000"
environment: environment:
- FLASK_APP=app.py - FLASK_APP=${FLASK_APP}
- FLASK_RUN_HOST=0.0.0.0 - FLASK_ENV=${FLASK_ENV}
- DATABASE_URL=postgresql+psycopg2://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}
volumes: volumes:
- .:/app - .:/app
restart: always restart: always
+1
View File
@@ -0,0 +1 @@
Single-database configuration for Flask.
+50
View File
@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+113
View File
@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,75 @@
"""Update Offers
Revision ID: fd58e10e4968
Revises:
Create Date: 2025-03-28 15:47:35.620664
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fd58e10e4968'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accounts',
sa.Column('id', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.String(length=50), nullable=False),
sa.Column('account_type', sa.String(length=50), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('lien_amount', sa.Float(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('customers',
sa.Column('id', sa.String(length=50), nullable=False),
sa.Column('msisdn', sa.String(length=20), nullable=False),
sa.Column('country_code', sa.String(length=3), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('msisdn')
)
op.create_table('loans',
sa.Column('id', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.String(length=50), nullable=False),
sa.Column('account_id', sa.String(length=50), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=False),
sa.Column('principal_amount', sa.Float(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.String(length=50), nullable=False),
sa.Column('account_id', sa.String(length=50), nullable=False),
sa.Column('type', sa.String(length=50), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.drop_table('test')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('test',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=125), autoincrement=False, nullable=True)
)
op.drop_table('transactions')
op.drop_table('loans')
op.drop_table('customers')
op.drop_table('accounts')
# ### end Alembic commands ###
+18 -3
View File
@@ -1,14 +1,29 @@
# Flask and Extensions # Flask and Extensions
Flask==2.3.3 Flask==2.3.3
# Database
flask-sqlalchemy
flask-migrate
psycopg2-binary
alembic
# Schema for validations
Flask-Marshmallow==0.15.0 Flask-Marshmallow==0.15.0
marshmallow==3.19.0 marshmallow==3.19.0
# CORS
Flask-Cors==3.0.10 Flask-Cors==3.0.10
# Deployment
gunicorn gunicorn
# Swagger
flask-swagger-ui flask-swagger-ui
# Env
python-dotenv
# Requests
# Logging (Python Standard Library, for reference) requests
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
echo "Running DB migrations..."
flask db upgrade
echo "Starting Gunicorn server..."
exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app