diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..006be8c --- /dev/null +++ b/.example.env @@ -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=*************** \ No newline at end of file diff --git a/.gitignore b/.gitignore index b316843..20bea99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__/ .env app.log -.DS_Store \ No newline at end of file +.DS_Store +migrations/__pycache__/ +migrations/*.pycg \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fa0ff11..0bc8e59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,5 +17,6 @@ EXPOSE 5000 ENV FLASK_APP=app.py ENV FLASK_RUN_HOST=0.0.0.0 -# Run the application -CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "wsgi:wsgi_app"] \ No newline at end of file +RUN chmod +x scripts/entrypoint.sh + +ENTRYPOINT ["scripts/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 7911c12..76963e2 100644 --- a/README.md +++ b/README.md @@ -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 -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 -# Environment Variables -BASIC_AUTH_USERNAME=user -BASIC_AUTH_PASSWORD=password -SWAGGER_URL="/documentation" -API_URL="/swagger.json" +- Database credentials: + ```ini + DATABASE_USER=your_database_username + DATABASE_PASSWORD=your_database_password + DATABASE_NAME=your_database_name + 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 diff --git a/app/__init__.py b/app/__init__.py index e7b7836..d2fab1c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,6 +5,9 @@ from flask_cors import CORS from app.config import Config from app.api.routes import api 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(): """ Factory function to create a Flask app instance """ @@ -30,8 +33,13 @@ def create_app(): # Error Handlers register_error_handlers(app) - - + + from . import models + # Database and Migrations + db.init_app(app) + + + migrate.init_app(app, db) return app diff --git a/app/api/enums/__init__.py b/app/api/enums/__init__.py new file mode 100644 index 0000000..2e3a1d8 --- /dev/null +++ b/app/api/enums/__init__.py @@ -0,0 +1 @@ +from .transaction_type import TransactionType \ No newline at end of file diff --git a/app/api/enums/transaction_type.py b/app/api/enums/transaction_type.py new file mode 100644 index 0000000..7d14546 --- /dev/null +++ b/app/api/enums/transaction_type.py @@ -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" diff --git a/app/api/integrations/__init__.py b/app/api/integrations/__init__.py new file mode 100644 index 0000000..54ad10b --- /dev/null +++ b/app/api/integrations/__init__.py @@ -0,0 +1 @@ +from .simbrella import SimbrellaClient \ No newline at end of file diff --git a/app/api/integrations/simbrella.py b/app/api/integrations/simbrella.py new file mode 100644 index 0000000..7b6296a --- /dev/null +++ b/app/api/integrations/simbrella.py @@ -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)} diff --git a/app/api/services/base_service.py b/app/api/services/base_service.py new file mode 100644 index 0000000..40413c3 --- /dev/null +++ b/app/api/services/base_service.py @@ -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"), + ) diff --git a/app/api/services/customer_consent.py b/app/api/services/customer_consent.py index cd76120..2d1048a 100644 --- a/app/api/services/customer_consent.py +++ b/app/api/services/customer_consent.py @@ -1,10 +1,15 @@ from flask import request, jsonify +from app.api.services.base_service import BaseService from marshmallow import ValidationError 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 def process_request(data): """ @@ -17,34 +22,50 @@ class CustomerConsentService: dict: A standardized response. """ try: - logger.info("Processing CustomerConsent request") - # Validate input data using the CustomerConsent schema - schema = CustomerConsentSchema() - validated_data = schema.load(data) # Raises ValidationError if invalid + validated_data = CustomerConsentService.validate_data(data, CustomerConsentSchema()) + account_id = validated_data.get('accountId') + 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 response_data = { "resultCode": "00", "resultDescription": "Request is received" } - - # return ResponseHelper.success( - # data=response_data, - # message="Customer consent processed successfully" - # ) - return response_data - + except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) return jsonify({ "message": "Internal Server Error" - }) , 500 + }) , 500 \ No newline at end of file diff --git a/app/api/services/eligibility_check.py b/app/api/services/eligibility_check.py index 2d7cd63..c5a5d15 100644 --- a/app/api/services/eligibility_check.py +++ b/app/api/services/eligibility_check.py @@ -1,9 +1,14 @@ from flask import session, jsonify from app.utils.logger import logger +from app.api.services.base_service import BaseService from app.api.schemas.eligibility_check import EligibilityCheckSchema 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 def process_request(data): """ @@ -16,11 +21,37 @@ class EligibilityCheckService: dict: A standardized response. """ try: - logger.info("Processing EligibilityCheck request") - # Validate input data using Schema - schema = EligibilityCheckSchema() - validated_data = schema.load(data) # Raises an error if invalid + validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema()) + account_id = validated_data.get('accountId') + 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 = [ { @@ -51,19 +82,21 @@ class EligibilityCheckService: "accountId": "ACN8263457" } - - # Return a success response - # return ResponseHelper.success( - # data=response_data, - # message="Eligibility check completed successfully" - # ) - return response_data except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) diff --git a/app/api/services/loan_status.py b/app/api/services/loan_status.py index b163e89..95d5048 100644 --- a/app/api/services/loan_status.py +++ b/app/api/services/loan_status.py @@ -1,9 +1,14 @@ from flask import request, jsonify from marshmallow import ValidationError 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 def process_request(data): """ @@ -16,11 +21,23 @@ class LoanStatusService: dict: A standardized response. """ 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 - schema = LoanStatusSchema() - validated_data = schema.load(data) # Raises ValidationError if invalid + if (LoanStatusService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + transaction = LoanStatusService.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 + loans = [ { @@ -46,21 +63,25 @@ class LoanStatusService: } - # return ResponseHelper.success( - # data=response_data, - # message="Loan information retrieved successfully" - # ) - return response_data except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) return jsonify({ "message": "Internal Server Error" - }) , 500 + }) , 500 \ No newline at end of file diff --git a/app/api/services/notification_callback.py b/app/api/services/notification_callback.py index 2c876db..b3a1c8b 100644 --- a/app/api/services/notification_callback.py +++ b/app/api/services/notification_callback.py @@ -1,9 +1,13 @@ from flask import request, jsonify 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.api.schemas.notification_callback import NotificationCallbackSchema -class NotificationCallbackService: +class NotificationCallbackService(BaseService): + TRANSACTION_TYPE = TransactionType.NOTIFICATION_CALLBACK + @staticmethod def process_request(data): """ @@ -37,10 +41,19 @@ class NotificationCallbackService: return response_data except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py index 8d57343..49dcefe 100644 --- a/app/api/services/provide_loan.py +++ b/app/api/services/provide_loan.py @@ -1,9 +1,14 @@ from flask import request, jsonify 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.api.schemas.provide_loan import ProvideLoanSchema -class ProvideLoanService: +class ProvideLoanService(BaseService): + TRANSACTION_TYPE = TransactionType.PROVIDE_LOAN + + @staticmethod def process_request(data): """ @@ -16,13 +21,24 @@ class ProvideLoanService: dict: A standardized response. """ 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 - schema = ProvideLoanSchema() - validated_data = schema.load(data) # Raises ValidationError if invalid + if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + transaction = ProvideLoanService.log_transaction(validated_data = validated_data) - # 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 = { "requestId": "202111170001371256908", "transactionId": "Tr201712RK9232P115", @@ -34,21 +50,26 @@ class ProvideLoanService: } - # return ResponseHelper.success( - # data=response_data, - # message="Loan successfully provided" - # ) return response_data except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) return jsonify({ "message": "Internal Server Error" - }) , 500 + }) , 500 \ No newline at end of file diff --git a/app/api/services/repayment.py b/app/api/services/repayment.py index 1aff386..7a53452 100644 --- a/app/api/services/repayment.py +++ b/app/api/services/repayment.py @@ -1,9 +1,13 @@ from flask import request, jsonify from marshmallow import ValidationError 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 def process_request(data): """ @@ -16,11 +20,22 @@ class RepaymentService: dict: A standardized response. """ 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 - schema = RepaymentSchema() - validated_data = schema.load(data) # Raises ValidationError if invalid + if (RepaymentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + transaction = RepaymentService.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 response_data = { @@ -39,10 +54,19 @@ class RepaymentService: return response_data except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) diff --git a/app/api/services/select_offer.py b/app/api/services/select_offer.py index 8656cbc..9d502c5 100644 --- a/app/api/services/select_offer.py +++ b/app/api/services/select_offer.py @@ -1,9 +1,13 @@ from flask import request, jsonify 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.api.schemas.select_offer import SelectOfferSchema -class SelectOfferService: +class SelectOfferService(BaseService): + TRANSACTION_TYPE = TransactionType.SELECT_OFFER + @staticmethod def process_request(data): """ @@ -16,12 +20,23 @@ class SelectOfferService: dict: A standardized response. """ 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 - schema = SelectOfferSchema() - validated_data = schema.load(data) # Raises ValidationError if invalid + if (SelectOfferService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + transaction = SelectOfferService.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 + offers = [ { "offerId": "14451", @@ -54,21 +69,25 @@ class SelectOfferService: } - # return ResponseHelper.success( - # data=response_data, - # message="Offer selection completed successfully" - # ) - return response_data except ValidationError as err: - logger.error(f"Validation Error: {err.messages}") + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + return jsonify({ "message": "Validation exception" }) , 422 + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + + return jsonify({ + "message": str(err) + }) , 400 except Exception as e: logger.error(f"An error occurred: {str(e)}", exc_info=True) return jsonify({ "message": "Internal Server Error" - }) , 500 + }) , 500 \ No newline at end of file diff --git a/app/config.py b/app/config.py index 9d37a22..fb66b2d 100644 --- a/app/config.py +++ b/app/config.py @@ -3,9 +3,6 @@ import os class Config: """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") API_URL = os.getenv("API_URL", "/swagger.json") @@ -14,4 +11,19 @@ class Config: VALID_APP_ID = os.getenv("VALID_APP_ID", "app1") VALID_API_KEY = os.getenv("VALID_API_KEY", "test-api-key-12345") BASIC_AUTH_USERNAME = os.environ.get("BASIC_AUTH_USERNAME", "user") - BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password") \ No newline at end of file + 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() \ No newline at end of file diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..de1947d --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,5 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..8fd1277 --- /dev/null +++ b/app/models/__init__.py @@ -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'] \ No newline at end of file diff --git a/app/models/account.py b/app/models/account.py new file mode 100644 index 0000000..95ddbc4 --- /dev/null +++ b/app/models/account.py @@ -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'' + \ No newline at end of file diff --git a/app/models/customer.py b/app/models/customer.py new file mode 100644 index 0000000..a41efe3 --- /dev/null +++ b/app/models/customer.py @@ -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'' diff --git a/app/models/loan.py b/app/models/loan.py new file mode 100644 index 0000000..6fa73a1 --- /dev/null +++ b/app/models/loan.py @@ -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'' \ No newline at end of file diff --git a/app/models/offer.py b/app/models/offer.py new file mode 100644 index 0000000..ce62fa2 --- /dev/null +++ b/app/models/offer.py @@ -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'' \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..62831ae --- /dev/null +++ b/app/models/transaction.py @@ -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'' + + @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) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e3765f2..ef41164 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,14 @@ services: - digifi-flaska002: + digifi-bank-to-product-core: build: . + env_file: + - .env ports: - - "4500:5000" + - "${APP_PORT:-4500}:5000" environment: - - FLASK_APP=app.py - - FLASK_RUN_HOST=0.0.0.0 + - FLASK_APP=${FLASK_APP} + - FLASK_ENV=${FLASK_ENV} + - DATABASE_URL=postgresql+psycopg2://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME} volumes: - .:/app - restart: always + restart: always \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/fd58e10e4968_update_offers.py b/migrations/versions/fd58e10e4968_update_offers.py new file mode 100644 index 0000000..eb2d52b --- /dev/null +++ b/migrations/versions/fd58e10e4968_update_offers.py @@ -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 ### diff --git a/requirements.txt b/requirements.txt index d5cb30f..7e47465 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,29 @@ # Flask and Extensions Flask==2.3.3 + +# Database +flask-sqlalchemy +flask-migrate +psycopg2-binary +alembic + +# Schema for validations Flask-Marshmallow==0.15.0 marshmallow==3.19.0 + +# CORS Flask-Cors==3.0.10 + +# Deployment gunicorn + +# Swagger flask-swagger-ui +# Env +python-dotenv - -# Logging (Python Standard Library, for reference) - +# Requests +requests diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..8c7018a --- /dev/null +++ b/scripts/entrypoint.sh @@ -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