From 075f953dbcf38135069c7ee47fdf7658863d0bbe Mon Sep 17 00:00:00 2001 From: Azeez Muibi Date: Thu, 10 Apr 2025 19:32:50 +0100 Subject: [PATCH] first commit --- .example.env | 24 ++ .gitignore | 8 + .idea/.gitignore | 8 + .idea/digifi-FirstCore.iml | 10 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + Dockerfile | 22 ++ README.md | 101 +++++++ app.py | 6 + app/__init__.py | 51 ++++ app/api/enums/__init__.py | 2 + app/api/enums/loan_status.py | 6 + app/api/enums/transaction_type.py | 10 + app/api/helpers/response_helper.py | 251 ++++++++++++++++++ app/api/integrations/__init__.py | 2 + app/api/integrations/kafka.py | 82 ++++++ app/api/integrations/simbrella.py | 55 ++++ app/api/middlewares/__init__.py | 4 + app/api/middlewares/app_id_checker.py | 27 ++ app/api/middlewares/basic_auth.py | 30 +++ app/api/middlewares/cors.py | 7 + app/api/middlewares/verify_api_key.py | 25 ++ app/api/routes/__init__.py | 1 + app/api/routes/routes.py | 138 ++++++++++ app/api/schemas/__init__.py | 0 app/api/schemas/authorization.py | 6 + app/api/schemas/customer_consent.py | 11 + app/api/schemas/eligibility_check.py | 10 + app/api/schemas/loan_status.py | 8 + app/api/schemas/notification_callback.py | 14 + app/api/schemas/provide_loan.py | 16 ++ app/api/schemas/repayment.py | 11 + app/api/schemas/select_offer.py | 13 + app/api/services/__init__.py | 8 + app/api/services/authorization.py | 102 +++++++ app/api/services/base_service.py | 61 +++++ app/api/services/customer_consent.py | 71 +++++ app/api/services/eligibility_check.py | 108 ++++++++ app/api/services/loan_status.py | 88 ++++++ app/api/services/notification_callback.py | 62 +++++ app/api/services/provide_loan.py | 109 ++++++++ app/api/services/repayment.py | 106 ++++++++ app/api/services/select_offer.py | 93 +++++++ app/config.py | 38 +++ app/errors/__init__.py | 1 + app/errors/handlers.py | 24 ++ app/extensions.py | 5 + app/models/__init__.py | 7 + app/models/account.py | 51 ++++ app/models/customer.py | 54 ++++ app/models/loan.py | 99 +++++++ app/models/offer.py | 16 ++ app/models/repayment.py | 55 ++++ app/models/transaction.py | 53 ++++ app/swagger/digifi_swagger.json | 180 +++++++++++++ app/swagger/paths/Authorize.json | 54 ++++ app/swagger/paths/AuthorizeRefresh.json | 54 ++++ app/swagger/paths/CustomerConsent.json | 56 ++++ app/swagger/paths/EligibilityCheck.json | 57 ++++ app/swagger/paths/LoanStatus.json | 57 ++++ app/swagger/paths/NotificationCallback.json | 57 ++++ app/swagger/paths/ProvideLoan.json | 57 ++++ app/swagger/paths/Repayment.json | 56 ++++ app/swagger/paths/SelectOffer.json | 57 ++++ app/swagger/schemas/ApiResponse.json | 18 ++ .../schemas/AuthorizeRefreshRequest.json | 7 + .../schemas/AuthorizeRefreshResponse.json | 12 + app/swagger/schemas/AuthorizeRequest.json | 16 ++ app/swagger/schemas/AuthorizeResponse.json | 16 ++ .../schemas/CustomerConsentRequest.json | 37 +++ .../schemas/CustomerConsentResponse.json | 16 ++ .../schemas/EligibilityCheckRequest.json | 32 +++ .../schemas/EligibilityCheckResponse.json | 82 ++++++ app/swagger/schemas/LoanStatusRequest.json | 24 ++ app/swagger/schemas/LoanStatusResponse.json | 70 +++++ .../schemas/NotificationCallbackRequest.json | 50 ++++ .../schemas/NotificationCallbackResponse.json | 16 ++ app/swagger/schemas/ProvideLoanRequest.json | 45 ++++ app/swagger/schemas/ProvideLoanResponse.json | 36 +++ app/swagger/schemas/RepaymentRequest.json | 32 +++ app/swagger/schemas/RepaymentResponse.json | 28 ++ app/swagger/schemas/SelectOfferRequest.json | 41 +++ app/swagger/schemas/SelectOfferResponse.json | 112 ++++++++ app/utils/logger.py | 13 + docker-compose.yml | 14 + requirements.txt | 36 +++ wsgi.py | 7 + 89 files changed, 3641 insertions(+) create mode 100644 .example.env create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/digifi-FirstCore.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/api/enums/__init__.py create mode 100644 app/api/enums/loan_status.py create mode 100644 app/api/enums/transaction_type.py create mode 100644 app/api/helpers/response_helper.py create mode 100644 app/api/integrations/__init__.py create mode 100644 app/api/integrations/kafka.py create mode 100644 app/api/integrations/simbrella.py create mode 100644 app/api/middlewares/__init__.py create mode 100644 app/api/middlewares/app_id_checker.py create mode 100644 app/api/middlewares/basic_auth.py create mode 100644 app/api/middlewares/cors.py create mode 100644 app/api/middlewares/verify_api_key.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/routes.py create mode 100644 app/api/schemas/__init__.py create mode 100644 app/api/schemas/authorization.py create mode 100644 app/api/schemas/customer_consent.py create mode 100644 app/api/schemas/eligibility_check.py create mode 100644 app/api/schemas/loan_status.py create mode 100644 app/api/schemas/notification_callback.py create mode 100644 app/api/schemas/provide_loan.py create mode 100644 app/api/schemas/repayment.py create mode 100644 app/api/schemas/select_offer.py create mode 100644 app/api/services/__init__.py create mode 100644 app/api/services/authorization.py create mode 100644 app/api/services/base_service.py create mode 100644 app/api/services/customer_consent.py create mode 100644 app/api/services/eligibility_check.py create mode 100644 app/api/services/loan_status.py create mode 100644 app/api/services/notification_callback.py create mode 100644 app/api/services/provide_loan.py create mode 100644 app/api/services/repayment.py create mode 100644 app/api/services/select_offer.py create mode 100644 app/config.py create mode 100644 app/errors/__init__.py create mode 100644 app/errors/handlers.py create mode 100644 app/extensions.py create mode 100644 app/models/__init__.py create mode 100644 app/models/account.py create mode 100644 app/models/customer.py create mode 100644 app/models/loan.py create mode 100644 app/models/offer.py create mode 100644 app/models/repayment.py create mode 100644 app/models/transaction.py create mode 100644 app/swagger/digifi_swagger.json create mode 100644 app/swagger/paths/Authorize.json create mode 100644 app/swagger/paths/AuthorizeRefresh.json create mode 100644 app/swagger/paths/CustomerConsent.json create mode 100644 app/swagger/paths/EligibilityCheck.json create mode 100644 app/swagger/paths/LoanStatus.json create mode 100644 app/swagger/paths/NotificationCallback.json create mode 100644 app/swagger/paths/ProvideLoan.json create mode 100644 app/swagger/paths/Repayment.json create mode 100644 app/swagger/paths/SelectOffer.json create mode 100644 app/swagger/schemas/ApiResponse.json create mode 100644 app/swagger/schemas/AuthorizeRefreshRequest.json create mode 100644 app/swagger/schemas/AuthorizeRefreshResponse.json create mode 100644 app/swagger/schemas/AuthorizeRequest.json create mode 100644 app/swagger/schemas/AuthorizeResponse.json create mode 100644 app/swagger/schemas/CustomerConsentRequest.json create mode 100644 app/swagger/schemas/CustomerConsentResponse.json create mode 100644 app/swagger/schemas/EligibilityCheckRequest.json create mode 100644 app/swagger/schemas/EligibilityCheckResponse.json create mode 100644 app/swagger/schemas/LoanStatusRequest.json create mode 100644 app/swagger/schemas/LoanStatusResponse.json create mode 100644 app/swagger/schemas/NotificationCallbackRequest.json create mode 100644 app/swagger/schemas/NotificationCallbackResponse.json create mode 100644 app/swagger/schemas/ProvideLoanRequest.json create mode 100644 app/swagger/schemas/ProvideLoanResponse.json create mode 100644 app/swagger/schemas/RepaymentRequest.json create mode 100644 app/swagger/schemas/RepaymentResponse.json create mode 100644 app/swagger/schemas/SelectOfferRequest.json create mode 100644 app/swagger/schemas/SelectOfferResponse.json create mode 100644 app/utils/logger.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 wsgi.py diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..2bd2f2b --- /dev/null +++ b/.example.env @@ -0,0 +1,24 @@ +VALID_APP_ID=********** +VALID_API_KEY=************* +BASIC_AUTH_USERNAME=****** +BASIC_AUTH_PASSWORD=****** + +SWAGGER_URL="/documentation" +API_URL="/swagger.json" + +JWT_SECRET_KEY=****** +JWT_ACCESS_TOKEN_EXPIRES=****** +JWT_REFRESH_TOKEN_EXPIRES=****** + +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 new file mode 100644 index 0000000..e4b6f4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +.env +app.log +.DS_Store +migrations/__pycache__/ +migrations/*.pycg +./vscode +.vscode/settings.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/digifi-FirstCore.iml b/.idea/digifi-FirstCore.iml new file mode 100644 index 0000000..e2fec49 --- /dev/null +++ b/.idea/digifi-FirstCore.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8bb642f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..82bd6d1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0bc8e59 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Use an official Python runtime as a parent image +FROM python:3.9-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Expose port 5000 for the Flask app +EXPOSE 5000 + +# Set environment variables +ENV FLASK_APP=app.py +ENV FLASK_RUN_HOST=0.0.0.0 + +RUN chmod +x scripts/entrypoint.sh + +ENTRYPOINT ["scripts/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..76963e2 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Setup + +This guide provides instructions on how to set up and run the application. + +## Prerequisites + +Ensure you have the following installed on your system: +- **Docker**: Install Docker from [docker.com](https://www.docker.com/get-started) +- **Docker Compose**: Docker Compose is included with Docker Desktop (for macOS/Windows) or can be installed separately on Linux. + +## Steps to Set Up the Application + +### 1. Clone the Repository + +First, clone the repository to your local machine: + +```bash +git clone https://github.com/username/repository.git +cd repository +``` + + +### 2. Configure Environment Variables + +Instead of creating a new `.env` file, rename the provided `.env.example` file and update the necessary variables: + +```bash +cp .env.example .env +``` + +Then, open the `.env` file and **update the following variables with your actual configuration:** + +- 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 +``` + +--- + + +### 3. Run the Application with Docker Compose + +Once you have the repository cloned, you can easily set up and run the application using Docker Compose. Simply execute the following command: + +```bash +docker-compose up --build +``` + +This command will build the Docker image and start the Flask application in a container. By default, the application will be accessible at `http://localhost:4500`. + +### 4. Health Check + +You can check if the Flask application is running by accessing the `/health` endpoint. To perform a health check, run the following command: + +```bash +curl http://localhost:4500/health +``` + +If the application is running properly, you should receive a response similar to this: + +```json +{ + "status": "ok" +} +``` + + +### 5. Documentation + +You can check the Swagger Doc by accessing the `/documentation` endpoint. Run the following command: + +```bash +curl http://localhost:4500/documentation +``` + + +### 6. Stop the Application + +To stop the application, use: + +```bash +docker-compose down +``` + +This will stop and remove the containers created by Docker Compose. diff --git a/app.py b/app.py new file mode 100644 index 0000000..f61366d --- /dev/null +++ b/app.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1530810 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,51 @@ +from flask import Flask +import os +from flask_swagger_ui import get_swaggerui_blueprint +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 +from flask_jwt_extended import ( + JWTManager, + jwt_required, + create_access_token, + get_jwt_identity, +) + + +def create_app(): + """Factory function to create a Flask app instance""" + app = Flask(__name__) + + # Load configuration + app.config.from_object(Config) + + CORS(app) + + JWTManager(app) + CORS(app, supports_credentials=True) + + # Swagger Doc + SWAGGER_URL = app.config.get("SWAGGER_URL") + API_URL = app.config.get("API_URL") + + # Register blueprints + app.register_blueprint(api) + + swagger_ui_blueprint = get_swaggerui_blueprint(SWAGGER_URL, API_URL) + app.register_blueprint(swagger_ui_blueprint, url_prefix=SWAGGER_URL) + + # 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..deb3f94 --- /dev/null +++ b/app/api/enums/__init__.py @@ -0,0 +1,2 @@ +from .transaction_type import TransactionType +from .loan_status import LoanStatus \ No newline at end of file diff --git a/app/api/enums/loan_status.py b/app/api/enums/loan_status.py new file mode 100644 index 0000000..dace234 --- /dev/null +++ b/app/api/enums/loan_status.py @@ -0,0 +1,6 @@ +from enum import Enum + +class LoanStatus(str, Enum): + PENDING = "pending" + ACTIVE = "active" + REPAID = "repaid" \ 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/helpers/response_helper.py b/app/api/helpers/response_helper.py new file mode 100644 index 0000000..adcc178 --- /dev/null +++ b/app/api/helpers/response_helper.py @@ -0,0 +1,251 @@ +from flask import jsonify +from typing import List, Dict, Union, Optional, Any + + +class ResponseHelper: + """ + A helper class for building standardized JSON responses in Flask. + """ + + @staticmethod + def build_response( + status: bool, + message: str, + data: Optional[Union[Dict, List, str]] = None, + status_code: int = 200, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Build a standardized JSON response. + + Args: + status (bool): Indicates whether the request was successful. + message (str): A message describing the result of the request. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + status_code (int): The HTTP status code for the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + response = { + "status": status, + "statusCode": status_code, + "message": message, + "data": data if data is not None else {}, + "error": error if error is not None else {}, + } + return jsonify(response), status_code + + @staticmethod + def success( + data: Optional[Union[Dict, List, str]] = None, + message: str = "Successful", + status_code: int = 200, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a success response. + + Args: + data (Optional[Union[Dict, List, str]]): The data to return in the response. + message (str): A message describing the result of the request. + status_code (int): The HTTP status code for the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(True, message, data, status_code, error) + + @staticmethod + def error( + message: str = "An error occurred", + status_code: int = 400, + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return an error response. + + Args: + message (str): A message describing the error. + status_code (int): The HTTP status code for the response. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, status_code, error) + + @staticmethod + def created( + data: Optional[Union[Dict, List, str]] = None, + message: str = "Resource created successfully", + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a created resource. + + Args: + data (Optional[Union[Dict, List, str]]): The data to return in the response. + message (str): A message describing the result of the request. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(True, message, data, 201, error) + + @staticmethod + def updated( + data: Optional[Union[Dict, List, str]] = None, + message: str = "Resource updated successfully", + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an updated resource. + + Args: + data (Optional[Union[Dict, List, str]]): The data to return in the response. + message (str): A message describing the result of the request. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(True, message, data, 200, error) + + @staticmethod + def internal_server_error( + message: str = "Internal Server Error", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an internal server error. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 500, error) + + @staticmethod + def unauthorized( + message: str = "Unauthorized", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an unauthorized request. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 401, error) + + @staticmethod + def forbidden( + message: str = "Forbidden", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a forbidden request. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 403, error) + + @staticmethod + def not_found( + message: str = "Resource not found", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a not found resource. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 404, error) + + @staticmethod + def unprocessable_entity( + message: str = "Unprocessable entity", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an unprocessable entity. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 422, error) + + @staticmethod + def method_not_allowed( + message: str = "Method Not Allowed", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a method not allowed error. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 405, error) + + @staticmethod + def bad_request( + message: str = "Bad Request", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a bad request error. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 400, error) \ No newline at end of file diff --git a/app/api/integrations/__init__.py b/app/api/integrations/__init__.py new file mode 100644 index 0000000..0fce6e8 --- /dev/null +++ b/app/api/integrations/__init__.py @@ -0,0 +1,2 @@ +from .simbrella import SimbrellaIntegration +from .kafka import KafkaIntegration \ No newline at end of file diff --git a/app/api/integrations/kafka.py b/app/api/integrations/kafka.py new file mode 100644 index 0000000..fa3f8d1 --- /dev/null +++ b/app/api/integrations/kafka.py @@ -0,0 +1,82 @@ +from confluent_kafka import Producer +import json +import logging +from app.config import settings + +logger = logging.getLogger(__name__) + + +class KafkaIntegration: + _producer = None + _config = { + "bootstrap.servers": settings.KAFKA_BROKER, + "client.id": "loan-service-producer", + "acks": "all", + "retries": 3, + "debug": "broker,topic,msg", + } + + @staticmethod + def _get_producer(): + """Kafka producer""" + if not KafkaIntegration._producer: + KafkaIntegration._producer = Producer(KafkaIntegration._config) + + logger.info( + f"Connected to Kafka broker at {KafkaIntegration._config['bootstrap.servers']}" + ) + + return KafkaIntegration._producer + + + @staticmethod + def delivery_report(err, msg): + """Called once for each message produced""" + if err is not None: + + logger.error(f'Message delivery failed: {err}') + raise RuntimeError(f"Message delivery failed: {err}") + + else: + logger.debug(f'Message delivered to {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}') + + + + @staticmethod + def send_loan_request(loan_data, request_id, topic): + """ + Send loan request to topic + + Args: + loan_data: Loan request payload as dict + request_id: Unique request identifier (used as Kafka key) + """ + try: + + # Proceed to send loan request to Kafka + producer = KafkaIntegration._get_producer() + + # Sending loan request message to Kafka + producer.produce( + topic=topic, + key=str(request_id), + value=json.dumps(loan_data).encode("utf-8"), + callback=KafkaIntegration.delivery_report, + + ) + + producer.poll(0) + + logger.info(f"Loan request {request_id} queued for processing") + + except Exception as e: + logger.error( + f"Failed to send loan request to Kafka: {str(e)}", exc_info=True + ) + raise Exception(f"Failed to send loan request to Kafka: {str(e)}") + + @staticmethod + def flush(): + """Shutdown""" + producer = KafkaIntegration._get_producer() + producer.flush() diff --git a/app/api/integrations/simbrella.py b/app/api/integrations/simbrella.py new file mode 100644 index 0000000..1bfae3e --- /dev/null +++ b/app/api/integrations/simbrella.py @@ -0,0 +1,55 @@ +import requests +import json +from requests.auth import HTTPBasicAuth +from app.utils.logger import logger +from app.config import settings + +class SimbrellaIntegration: + BASE_URL = settings.SIMBRELLA_BASE_URL + + @staticmethod + def rac_check(customer_id, account_id, transaction_id): + """ + Calls the RACCheck endpoit + """ + url = f"{SimbrellaIntegration.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 + } + ] + } + + logger.error(f"This is PayLoad: {str(payload)}",exc_info=True) + headers = { + 'Content-Type': 'application/json', + 'x-api-key': f'{settings.VALID_API_KEY}', + 'App-Id': f'{settings.VALID_APP_ID}' + } + + try: + response = requests.post(url, json=payload, timeout=10, headers=headers) + logger.error(f"This is Response: {str(response)}", exc_info=True) + # Raise an error for non-200 responses + if response.status_code != 200: + 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"} diff --git a/app/api/middlewares/__init__.py b/app/api/middlewares/__init__.py new file mode 100644 index 0000000..ecf8fb2 --- /dev/null +++ b/app/api/middlewares/__init__.py @@ -0,0 +1,4 @@ +from .verify_api_key import require_api_key +from .app_id_checker import require_app_id +from .cors import enforce_json +from .basic_auth import require_auth \ No newline at end of file diff --git a/app/api/middlewares/app_id_checker.py b/app/api/middlewares/app_id_checker.py new file mode 100644 index 0000000..8f45a45 --- /dev/null +++ b/app/api/middlewares/app_id_checker.py @@ -0,0 +1,27 @@ +from functools import wraps +from flask import request, jsonify +from app.utils.logger import logger +from app.config import Config + + + +VALID_APP_ID = Config.VALID_APP_ID + +def require_app_id(f): + """Decorator to enforce App-ID validation.""" + @wraps(f) + def decorated_function(*args, **kwargs): + app_id = request.headers.get("App-ID") + + if not app_id: + logger.error("Unauthorized access: Missing App-ID.") + return jsonify({"message": "Invalid request"}), 400 + + + if app_id != VALID_APP_ID: + logger.error(f"Unauthorized access: Invalid App-ID {app_id}.") + return jsonify({"message": "Invalid request"}), 400 + + return f(*args, **kwargs) + + return decorated_function diff --git a/app/api/middlewares/basic_auth.py b/app/api/middlewares/basic_auth.py new file mode 100644 index 0000000..fb0e8aa --- /dev/null +++ b/app/api/middlewares/basic_auth.py @@ -0,0 +1,30 @@ +from functools import wraps +from flask import request, jsonify +import base64 +from app.config import Config + +USERNAME = Config.BASIC_AUTH_USERNAME +PASSWORD = Config.BASIC_AUTH_PASSWORD + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.headers.get('Authorization') + if not auth or not check_auth(auth): + return jsonify({"message": "Invalid request"}), 401 + return f(*args, **kwargs) + return decorated + +def check_auth(auth_header): + if not auth_header: + return False + try: + auth_type, credentials = auth_header.split() + if auth_type.lower() != "basic": + return False + + decoded_credentials = base64.b64decode(credentials).decode("utf-8") + user, pwd = decoded_credentials.split(":", 1) + return user == USERNAME and pwd == PASSWORD + except Exception: + return False \ No newline at end of file diff --git a/app/api/middlewares/cors.py b/app/api/middlewares/cors.py new file mode 100644 index 0000000..9964c15 --- /dev/null +++ b/app/api/middlewares/cors.py @@ -0,0 +1,7 @@ +from flask import request, jsonify + + +def enforce_json(): + """Middleware to enforce JSON Content-Type for incoming requests""" + if request.method in ["POST", "PUT", "PATCH"] and request.content_type != "application/json": + return jsonify({"message": "Invalid request"}), 400 diff --git a/app/api/middlewares/verify_api_key.py b/app/api/middlewares/verify_api_key.py new file mode 100644 index 0000000..57a6fee --- /dev/null +++ b/app/api/middlewares/verify_api_key.py @@ -0,0 +1,25 @@ +from functools import wraps +from flask import request, jsonify +from app.utils.logger import logger +from app.config import Config + +# Load valid API key from environment variables (fallback for testing) +VALID_API_KEY = Config.VALID_API_KEY + +def require_api_key(f): + """Decorator to enforce API key authentication.""" + @wraps(f) + def decorated_function(*args, **kwargs): + api_key = request.headers.get("X-API-KEY") + + if not api_key: + logger.error("Unauthorized access: Missing API key.") + return jsonify({"message": "Invalid request"}), 400 + + if api_key != VALID_API_KEY: + logger.error("Unauthorized access: Invalid API key.") + return jsonify({"message": "Invalid request"}), 400 + + return f(*args, **kwargs) + + return decorated_function diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..4ebb14a --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1 @@ +from .routes import api diff --git a/app/api/routes/routes.py b/app/api/routes/routes.py new file mode 100644 index 0000000..5e9c4d3 --- /dev/null +++ b/app/api/routes/routes.py @@ -0,0 +1,138 @@ +from flask import Blueprint, request, jsonify, send_from_directory +from app.api.services import ( + EligibilityCheckService, + SelectOfferService, + ProvideLoanService, + LoanStatusService, + RepaymentService, + CustomerConsentService, + NotificationCallbackService, + AuthorizationService, +) +from app.utils.logger import logger +from app.api.middlewares import enforce_json, require_auth +import os +from flask_jwt_extended import ( + JWTManager, + jwt_required, + create_access_token, + get_jwt_identity, + create_refresh_token, +) + + +api = Blueprint("api", __name__) + + +@api.before_request +def cors_middleware(): + """Middleware applied globally to all API routes in this blueprint""" + return enforce_json() + + +# Swagger JSON file +@api.route("/swagger.json", methods=["GET"]) +def swagger_json(): + swagger_dir = os.path.join("swagger") + return send_from_directory(swagger_dir, "digifi_swagger.json") + + +@api.route("/swagger/") +def serve_paths(filename): + swagger_dir = os.path.join("swagger") + return send_from_directory(swagger_dir, filename) + + +# EligibilityCheck Endpoint +@api.route("/EligibilityCheck", methods=["POST"]) +@jwt_required() +def eligibility_check(): + data = request.get_json() + # logger.info(f"EligibilityCheck request received: {data}") + response = EligibilityCheckService.process_request(data) + return response + + +# SelectOffer Endpoint +@api.route("/SelectOffer", methods=["POST"]) +@jwt_required() +def select_offer(): + data = request.get_json() + # logger.info(f"SelectOffer request received: {data}") + response = SelectOfferService.process_request(data) + return response + + +# ProvideLoan Endpoint +@api.route("/ProvideLoan", methods=["POST"]) +@jwt_required() +def provide_loan(): + data = request.get_json() + # logger.info(f"ProvideLoan request received: {data}") + response = ProvideLoanService.process_request(data) + return response + + +# LoanStatus Endpoint +@api.route("/LoanStatus", methods=["POST"]) +@jwt_required() +def loan_status(): + data = request.get_json() + # logger.info(f"LoanStatus request received: {data}") + response = LoanStatusService.process_request(data) + return response + + +# Repayment Endpoint +@api.route("/Repayment", methods=["POST"]) +@jwt_required() +def repayment(): + data = request.get_json() + # logger.info(f"Repayment request received: {data}") + response = RepaymentService.process_request(data) + return response + + +# CustomerConsent Endpoint +@api.route("/CustomerConsent", methods=["POST"]) +@jwt_required() +def customer_consent(): + data = request.get_json() + # logger.info(f"CustomerConsent request received: {data}") + response = CustomerConsentService.process_request(data) + return response + + +# NotificationCallback Endpoint +@api.route("/NotificationCallback", methods=["POST"]) +@jwt_required() +def notification_callback(): + data = request.get_json() + # logger.info(f"NotificationCallback request received: {data}") + response = NotificationCallbackService.process_request(data) + return response + + +# Health Check Endpoint +@api.route("/health", methods=["GET"]) +def health_check(): + return {"status": "ok"}, 200 + + +# Authorize endpoint +@api.route("/Authorize", methods=["POST"]) +def authorize(): + data = request.get_json() + # logger.info(f"Authorize request received: {data}") + response = AuthorizationService.process_request(data) + return response + + +# Authorize refresh endpoint +@api.route("/AuthorizeRefresh", methods=["POST"]) +@jwt_required(refresh=True) +def refresh(): + data = request.get_json() + # logger.info(f"Authorize refresh request received: {data}") + response = AuthorizationService.process_refresh_request() + return response diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/authorization.py b/app/api/schemas/authorization.py new file mode 100644 index 0000000..785dd2b --- /dev/null +++ b/app/api/schemas/authorization.py @@ -0,0 +1,6 @@ +from marshmallow import Schema, fields + + +class AuthorizeRequestSchema(Schema): + username = fields.Str(required=True) + password = fields.Str(required=True) diff --git a/app/api/schemas/customer_consent.py b/app/api/schemas/customer_consent.py new file mode 100644 index 0000000..09c2d9c --- /dev/null +++ b/app/api/schemas/customer_consent.py @@ -0,0 +1,11 @@ +from marshmallow import Schema, fields + +# Customer Consent Schema +class CustomerConsentSchema(Schema): + type = fields.Str(required=True) + transactionId = fields.Str(required=True) + customerId = fields.Str(required=True) + accountId = fields.Str(required=True) + requestTime = fields.DateTime(required=True, format="%Y-%m-%d %H:%M:%S.%f") + consentType = fields.Str(required=True) + channel = fields.Str(required=True) \ No newline at end of file diff --git a/app/api/schemas/eligibility_check.py b/app/api/schemas/eligibility_check.py new file mode 100644 index 0000000..7b901c2 --- /dev/null +++ b/app/api/schemas/eligibility_check.py @@ -0,0 +1,10 @@ +from marshmallow import Schema, fields + +class EligibilityCheckSchema(Schema): + transactionId = fields.Str(required=True) + countryCode = fields.Str(required=True) + customerId = fields.Str(required=True) + accountId = fields.Str(required=True) + msisdn = fields.Str(required=True) + accountId = fields.Str(required=True) + channel = fields.Str(required=True) diff --git a/app/api/schemas/loan_status.py b/app/api/schemas/loan_status.py new file mode 100644 index 0000000..7599d5f --- /dev/null +++ b/app/api/schemas/loan_status.py @@ -0,0 +1,8 @@ +from marshmallow import Schema, fields + +# Loan Information Schema +class LoanStatusSchema(Schema): + transactionId = fields.Str(required=True) + customerId = fields.Str(required=True) + msisdn = fields.Str(required=False) + channel = fields.Str(required=True) \ No newline at end of file diff --git a/app/api/schemas/notification_callback.py b/app/api/schemas/notification_callback.py new file mode 100644 index 0000000..19e6b4d --- /dev/null +++ b/app/api/schemas/notification_callback.py @@ -0,0 +1,14 @@ +from marshmallow import Schema, fields + +# Notification Callback Schema +class NotificationCallbackSchema(Schema): + fbnTransactionId = fields.Str(required=True) + transactionId = fields.Str(required=True) + customerId = fields.Str(required=True) + accountId = fields.Str(required=True) + debtId = fields.Str(required=True) + transactionType = fields.Str(required=True) + amountProvided = fields.Float(required=True) + amountCollected = fields.Float(required=True) + responseCode = fields.Str(required=True) + responseDescription = fields.Str(required=True) \ No newline at end of file diff --git a/app/api/schemas/provide_loan.py b/app/api/schemas/provide_loan.py new file mode 100644 index 0000000..5959e29 --- /dev/null +++ b/app/api/schemas/provide_loan.py @@ -0,0 +1,16 @@ +from marshmallow import Schema, fields + +# Provide Loan Schema +class ProvideLoanSchema(Schema): + type = fields.Str(required=False) + requestId = fields.Str(required=True) + transactionId = fields.Str(required=True) + customerId = fields.Str(required=True) + accountId = fields.Str(required=True) + msisdn = fields.Str(required=False) + # productId = fields.Str(required=True) + # lienAmount = fields.Float(required=True) + requestedAmount = fields.Float(required=True) + collectionType = fields.Int(required=True) + offerId = fields.Int(required=True) + channel = fields.Str(required=True) \ No newline at end of file diff --git a/app/api/schemas/repayment.py b/app/api/schemas/repayment.py new file mode 100644 index 0000000..2d05321 --- /dev/null +++ b/app/api/schemas/repayment.py @@ -0,0 +1,11 @@ +from marshmallow import Schema, fields + +# Repayment Schema +class RepaymentSchema(Schema): + type = fields.Str(required=False) + msisdn = fields.Str(required=False) #optional + debtId = fields.Str(required=True) + productId = fields.Str(required=True) + transactionId = fields.Str(required=True) + customerId = fields.Str(required=True) + channel = fields.Str(required=True) diff --git a/app/api/schemas/select_offer.py b/app/api/schemas/select_offer.py new file mode 100644 index 0000000..f3544aa --- /dev/null +++ b/app/api/schemas/select_offer.py @@ -0,0 +1,13 @@ +from marshmallow import Schema, fields + +# Select Offer Schema +class SelectOfferSchema(Schema): + requestId = fields.Str(required=True) + transactionId = fields.Str(required=True) + customerId = fields.Str(required=True) + accountId = fields.Str(required=True) + msisdn = fields.Str(required=True) + requestedAmount = fields.Float(required=True) + productId = fields.Str(required=True) + channel = fields.Str(required=True) + diff --git a/app/api/services/__init__.py b/app/api/services/__init__.py new file mode 100644 index 0000000..e11319d --- /dev/null +++ b/app/api/services/__init__.py @@ -0,0 +1,8 @@ +from app.api.services.eligibility_check import EligibilityCheckService +from app.api.services.select_offer import SelectOfferService +from app.api.services.provide_loan import ProvideLoanService +from app.api.services.loan_status import LoanStatusService +from app.api.services.repayment import RepaymentService +from app.api.services.customer_consent import CustomerConsentService +from app.api.services.notification_callback import NotificationCallbackService +from app.api.services.authorization import AuthorizationService diff --git a/app/api/services/authorization.py b/app/api/services/authorization.py new file mode 100644 index 0000000..39108a9 --- /dev/null +++ b/app/api/services/authorization.py @@ -0,0 +1,102 @@ +from flask import request, jsonify +from marshmallow import ValidationError +from app.api.services.base_service import BaseService +from app.utils.logger import logger +from app.api.schemas.authorization import AuthorizeRequestSchema +from app.api.helpers.response_helper import ResponseHelper +from flask_jwt_extended import ( + JWTManager, + jwt_required, + create_access_token, + create_refresh_token, + get_jwt_identity, +) +from app.config import Config + +USERNAME = Config.BASIC_AUTH_USERNAME +PASSWORD = Config.BASIC_AUTH_PASSWORD + + +class AuthorizationService(BaseService): + + @staticmethod + def process_request(data): + """ + Process the Authorization request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + logger.info("Processing Authorization request") + + if not data: + return ResponseHelper.bad_request(message="Missing JSON in request") + + # Validate input data using the Authorization schema + schema = AuthorizeRequestSchema() + validated_data = schema.load(data) # Raises ValidationError if invalid + + if ( + validated_data["username"] != USERNAME + or validated_data["password"] != PASSWORD + ): + return ResponseHelper.unauthorized(message="Invalid credentials") + + access_token = create_access_token(identity=validated_data["username"]) + refresh_token = create_refresh_token(identity=validated_data["username"]) + + # Simulated processing logic + response_data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + return ResponseHelper.success( + data=response_data, message="Authorization processed successfully" + ) + + except ValidationError as e: + logger.error(f"Validation error: {e}") + return ResponseHelper.bad_request(message=f"Validation error: {e}") + + except Exception as e: + logger.error(f"Error processing Authorization request: {e}") + return ResponseHelper.internal_server_error( + message=f"Error processing Authorization request: {e}" + ) + + @staticmethod + def process_refresh_request(): + """ + Process the RefreshToken request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + logger.info("Processing RefreshToken request") + + identity = get_jwt_identity() + access_token = create_access_token(identity=identity) + + # Simulated processing logic + response_data = { + "access_token": access_token, + } + + return ResponseHelper.success( + data=response_data, message="RefreshToken processed successfully" + ) + + except Exception as e: + logger.error(f"Error processing RefreshToken request: {e}") + return ResponseHelper.internal_server_error( + message=f"Error processing RefreshToken request: {e}" + ) diff --git a/app/api/services/base_service.py b/app/api/services/base_service.py new file mode 100644 index 0000000..aa678e0 --- /dev/null +++ b/app/api/services/base_service.py @@ -0,0 +1,61 @@ +from app.models import Customer, Account, Transaction +from app.api.enums import TransactionType +from flask import jsonify +from marshmallow import ValidationError +import logging +from app.api.integrations import KafkaIntegration + +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( + transaction_id =validated_data.get("transactionId"), + account_id=validated_data.get("accountId"), + type=cls.TRANSACTION_TYPE, + channel=validated_data.get("channel"), + ) + + @classmethod + def async_send_to_kafka(cls, loan_data, request_id, topic): + KafkaIntegration.send_loan_request(loan_data = loan_data, request_id = request_id, topic = topic) + KafkaIntegration.flush() diff --git a/app/api/services/customer_consent.py b/app/api/services/customer_consent.py new file mode 100644 index 0000000..2d1048a --- /dev/null +++ b/app/api/services/customer_consent.py @@ -0,0 +1,71 @@ +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.services.base_service import BaseService +from app.api.enums import TransactionType + + +class CustomerConsentService(BaseService): + TRANSACTION_TYPE = TransactionType.CUSTOMER_CONSENT + + @staticmethod + def process_request(data): + """ + Process the CustomerConsent request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + + 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 response_data + + except ValidationError as err: + + 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 \ No newline at end of file diff --git a/app/api/services/eligibility_check.py b/app/api/services/eligibility_check.py new file mode 100644 index 0000000..d7dff72 --- /dev/null +++ b/app/api/services/eligibility_check.py @@ -0,0 +1,108 @@ +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 SimbrellaIntegration + +class EligibilityCheckService(BaseService): + TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK + + @staticmethod + def process_request(data): + """ + Process the EligibilityCheck request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + + validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema()) + account_id = validated_data.get('accountId') + customer_id = validated_data.get('customerId') + transactionId = validated_data.get('transactionId') + msisdn = validated_data.get('msisdn') + + 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 = SimbrellaIntegration.rac_check( + customer_id = customer_id, + account_id = account_id, + transaction_id = transaction.id, + ) + logger.error(f"This is Response Returned ****** : {str(response)}") + + # this chck for error is not valid + logger.error(f"Check for ERROR is not valid ****** FIX THIS !!!!!") + #if "error" in response or response.get("status") != 200: + # return jsonify({"message": "RACCheck failed"}), 400 + + offers = [ + { + "offerId": "SAL90", + "productId": "2030", + "minAmount": 5000, + "maxAmount": 100000, + "tenor": 30 + }, + { + "offerId": "SAL30", + "productId": "2090", + "minAmount": 3000, + "maxAmount": 500000, + "tenor": 90 + } + ] + + # Simulate processing + response_data = { + "customerId": customer_id, + "transactionId": transactionId, + "countryCode": "NG", + "msisdn": msisdn, + "eligibleOffers": offers, + "resultDescription": "Successful", + "resultCode": "00", + "accountId": account_id + } + + return response_data + except ValidationError as err: + + 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 \ No newline at end of file diff --git a/app/api/services/loan_status.py b/app/api/services/loan_status.py new file mode 100644 index 0000000..cdd161c --- /dev/null +++ b/app/api/services/loan_status.py @@ -0,0 +1,88 @@ +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.services.base_service import BaseService +from app.api.enums import TransactionType + + +class LoanStatusService(BaseService): + TRANSACTION_TYPE = TransactionType.LOAN_STATUS + + @staticmethod + def process_request(data): + """ + Process the Loan Information request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + validated_data = LoanStatusService.validate_data(data, LoanStatusSchema()) + customer_id = validated_data.get('customerId') + customer = LoanStatusService.get_or_create_customer(validated_data) + account = customer.accounts[0] + + 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 = [ + { + "debtId": "123456789", + "loanDate": "2019-10-18 14:26:21.063", + "dueDate": "2019-11-20 14:26:21.063", + "currentLoanAmount": 8500, + "initialLoanAmount": 10000, + "defaultPenaltyFee": 0, + "continuousFee": 0, + "productId": "101" + } + ] + + # Simulated processing logic + response_data = { + "customerId": "CN621868", + "transactionId": "Tr201712RK9232P115", + "loans": loans, + "totalDebtAmount": 8500, + "resultCode": "00", + "resultDescription": "Successful" + } + + + return response_data + + except ValidationError as err: + + 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 \ No newline at end of file diff --git a/app/api/services/notification_callback.py b/app/api/services/notification_callback.py new file mode 100644 index 0000000..b3a1c8b --- /dev/null +++ b/app/api/services/notification_callback.py @@ -0,0 +1,62 @@ +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(BaseService): + TRANSACTION_TYPE = TransactionType.NOTIFICATION_CALLBACK + + @staticmethod + def process_request(data): + """ + Process the NotificationCallback request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + logger.info("Processing NotificationCallback request") + + # Validate input data using the NotificationCallback schema + schema = NotificationCallbackSchema() + validated_data = schema.load(data) # Raises ValidationError if invalid + + # Simulated processing logic + response_data = { + "resultCode": "00", + "resultDescription": "Successful" + } + + + # return ResponseHelper.success( + # data=response_data, + # message="Notification callback processed successfully" + # ) + + return response_data + + except ValidationError as err: + + 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 diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py new file mode 100644 index 0000000..e268a92 --- /dev/null +++ b/app/api/services/provide_loan.py @@ -0,0 +1,109 @@ +from flask import request, jsonify +from marshmallow import ValidationError +from app.api.integrations.kafka import KafkaIntegration +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 +from threading import Thread +from app.models.loan import Loan +from app.api.enums import LoanStatus + +class ProvideLoanService(BaseService): + TRANSACTION_TYPE = TransactionType.PROVIDE_LOAN + + + @staticmethod + def process_request(data): + """ + Process the ProvideLoan request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + validated_data = ProvideLoanService.validate_data(data, ProvideLoanSchema()) + account_id = validated_data.get('accountId') + customer_id = validated_data.get('customerId') + request_id = validated_data.get('requestId') + transaction_id = validated_data.get('transactionId') + + if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + + # Save the loan details + loan = Loan.create_loan( + customer_id=customer_id, + account_id=account_id, + offer_id=validated_data.get('offerId'), + principal_amount=validated_data.get('requestedAmount'), + status=LoanStatus.ACTIVE + ) + + if not loan: + logger.error(f"Failed to save loan details") + return jsonify({ + "message": "Failed to save loan details." + }), 400 + + # Log Transaction + transaction = ProvideLoanService.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 + + + response_data = { + "requestId": request_id, + "transactionId": transaction_id, + "customerId": customer_id, + "accountId": account_id, + "msisdn": "3451342", + "resultCode": "00", + "resultDescription": "Successful" + } + + + # KafkaIntegration.send_loan_request(loan_data = response_data, request_id = request_id) + # Call Kafka in a background thread + thread = Thread(target=ProvideLoanService.async_send_to_kafka, args=(response_data, request_id, "PROCESS_PAYMENT")) + thread.start() + + return response_data + + except ValidationError as err: + + 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 + + + + diff --git a/app/api/services/repayment.py b/app/api/services/repayment.py new file mode 100644 index 0000000..d92f8b8 --- /dev/null +++ b/app/api/services/repayment.py @@ -0,0 +1,106 @@ +from flask import request, jsonify +from marshmallow import ValidationError +from app.api.enums.loan_status import LoanStatus +from app.models import Repayment +from app.models.loan import Loan +from app.utils.logger import logger +from app.api.schemas.repayment import RepaymentSchema +from app.api.services.base_service import BaseService +from app.api.enums import TransactionType +from threading import Thread + +class RepaymentService(BaseService): + TRANSACTION_TYPE = TransactionType.REPAYMENT + + @staticmethod + def process_request(data): + """ + Process the Repayment request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + validated_data = RepaymentService.validate_data(data, RepaymentSchema()) + customer_id = validated_data.get('customerId') + customer = RepaymentService.get_or_create_customer(validated_data) + account = customer.accounts[0] + validated_data['accountId'] = account.id + request_id = validated_data.get('requestId') + loan_id = validated_data.get('debtId') + + + if (RepaymentService.validate_account_ownership(account_id = account.id, customer_id = customer_id)): + + # Save the repayment details + repayment = Repayment.create_repayment( + customer_id = customer_id, + loan_id = loan_id, + product_id = validated_data.get('productId') + + ) + + if not repayment: + logger.error(f"Failed to save repayment details") + return jsonify({ + "message": "Failed to save repayment details." + }), 400 + + #Update Loan status + Loan.update_status(loan_id = loan_id, status = LoanStatus.REPAID) + + 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 = { + "customerId": "CN621868", + "productId": "101", + "debtId": "273194670", + "resultCode": "00", + "resultDescription": "Successful" + } + + # return ResponseHelper.success( + # data=response_data, + # message="Repayment processed successfully" + # ) + + # Call Kafka in a background thread + thread = Thread(target=RepaymentService.async_send_to_kafka, args=(response_data, request_id, "LOAN_REPAYMENT")) + thread.start() + + return response_data + + except ValidationError as err: + + 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 diff --git a/app/api/services/select_offer.py b/app/api/services/select_offer.py new file mode 100644 index 0000000..1f07cd5 --- /dev/null +++ b/app/api/services/select_offer.py @@ -0,0 +1,93 @@ +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(BaseService): + TRANSACTION_TYPE = TransactionType.SELECT_OFFER + + @staticmethod + def process_request(data): + """ + Process the SelectOffer request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + validated_data = SelectOfferService.validate_data(data, SelectOfferSchema()) + account_id = validated_data.get('accountId') + customer_id = validated_data.get('customerId') + + 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", + "productId": "2030", + "amount": 10000.0, + "upfrontPayment": 1000.0, + "interestRate": 3.0, + "managementRate": 1.0, + "managementFee": 1.0, + "insuranceRate": 1.0, + "insuranceFee": 100.0, + "VATRate": 7.5, + "VATAmount": 100.0, + "recommendedRepaymentDates": ["2022-11-30"], + "installmentAmount": 11000.0, + "totalRepaymentAmount": 11000.0 + } + ] + + # Business logic - selecting an offer + response_data = { + "outstandingDebtAmount": 0, + "requestId": "202111170001371256908", + "transactionId": transaction.id, + "customerId": customer_id, + "accountId": account_id, + "loan": offers, + "resultCode": "00", + "resultDescription": "Successful" + } + + + return response_data + + except ValidationError as err: + + 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 \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b23548a --- /dev/null +++ b/app/config.py @@ -0,0 +1,38 @@ +import os +from datetime import timedelta + + +class Config: + """Base configuration for Flask app""" + + SWAGGER_URL = os.getenv("SWAGGER_URL", "/documentation") + API_URL = os.getenv("API_URL", "/swagger.json") + + DEBUG = True + 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") + + 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") + + + JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "secret-key") + JWT_ACCESS_TOKEN_EXPIRES = os.getenv("JWT_ACCESS_TOKEN_EXPIRES", timedelta(hours=1)) + JWT_REFRESH_TOKEN_EXPIRES = os.getenv( + "JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30) + ) + + KAFKA_BROKER = 'dev-events.simbrellang.net:9085' + KAFKA_PAYMENT_TOPIC = 'PROCESS_PAYMENT' + + +settings = Config() diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 0000000..77d4fe1 --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1 @@ +from .handlers import register_error_handlers \ No newline at end of file diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 0000000..02b665f --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,24 @@ +from werkzeug.exceptions import HTTPException +from flask import jsonify +from app.api.helpers.response_helper import ResponseHelper + +def register_error_handlers(app): + @app.errorhandler(HTTPException) + def handle_http_exception(e): + return jsonify({'error': e.description}), e.code + + @app.errorhandler(405) + def method_not_allowed(error): + return jsonify({"message": "Method Not Allowed"}), 405 + + @app.errorhandler(404) + def not_found(error): + return jsonify({"message": "Resource not found"}), 404 + + @app.errorhandler(400) + def bad_request(error): + return jsonify({"message": "Bad Request"}), 400 + + @app.errorhandler(415) + def unsupported_media_type(error): + return jsonify({"message": "Unsupported Media Type"}), 415 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..af5353a --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,7 @@ +from .customer import Customer +from .account import Account +from .loan import Loan +from .transaction import Transaction +from .repayment import Repayment + +__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment'] \ No newline at end of file diff --git a/app/models/account.py b/app/models/account.py new file mode 100644 index 0000000..af4501e --- /dev/null +++ b/app/models/account.py @@ -0,0 +1,51 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import relationship +from app.extensions import db +from sqlalchemy.exc import IntegrityError + +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)) + + customer = relationship( + "Customer", + primaryjoin="Customer.id == Account.customer_id", + foreign_keys=[customer_id], + back_populates="accounts", + ) + + @classmethod + def create_account(cls, id, customer_id, account_type, status='active'): + account = cls( + id=id, + customer_id=customer_id, + account_type=account_type + ) + + try: + db.session.add(account) + db.session.commit() + except IntegrityError as err: + db.session.rollback() + raise ValueError(f"Database integrity error: {err}") + 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..e0fb316 --- /dev/null +++ b/app/models/customer.py @@ -0,0 +1,54 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import relationship +from app.extensions import db +from app.models.account import Account +from sqlalchemy.exc import IntegrityError + +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)) + + accounts = relationship( + "Account", + primaryjoin="Customer.id == Account.customer_id", + foreign_keys="Account.customer_id", + back_populates="customer", + ) + + @classmethod + def is_valid_customer(cls, customer_id): + customer = cls.query.filter_by(id=customer_id).first() + if not customer: + return False + return True + + @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) + try: + 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() + except IntegrityError as err: + db.session.rollback() + raise ValueError(f"Database integrity error: {err}") + 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..0fec3d2 --- /dev/null +++ b/app/models/loan.py @@ -0,0 +1,99 @@ +from datetime import datetime, timezone +from app.extensions import db +from app.models.customer import Customer +from app.models.account import Account +from sqlalchemy.exc import IntegrityError + + +class Loan(db.Model): + __tablename__ = 'loans' + + id = db.Column( + db.Integer, + primary_key=True, + autoincrement=True, + ) + customer_id = db.Column(db.String(50), nullable=False) + account_id = db.Column(db.String(50), nullable=False) + offer_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 create_loan(cls, customer_id, account_id, offer_id, principal_amount, status='pending'): + + # Check if customer exists + is_valid = Customer.is_valid_customer(customer_id) + if not is_valid: + raise ValueError("Customer does not exist") + + # # Check for active loans + # has_active_loans = cls.has_active_loans(customer_id) + # if has_active_loans: + # raise ValueError("Customer has active loans") + + + # Create and save the loan + loan = cls( + customer_id=customer_id, + account_id=account_id, + offer_id=offer_id, + principal_amount=principal_amount, + status=status + ) + + try: + db.session.add(loan) + db.session.commit() + except IntegrityError as err: + db.session.rollback() + raise ValueError(f"Database integrity error: {err}") + return loan + + + @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 + return True + + + @classmethod + def get_customer_loan(cls, loan_id, customer_id): + """ + Get customer's active loans. + """ + loan = cls.query.filter_by(id = loan_id, customer_id = customer_id).first() + if not loan: + raise ValueError(f"Loan with ID {loan_id} does not exist or does not belong to customer {customer_id}.") + return loan + + @classmethod + def update_status(cls, loan_id, status): + """ + Update the status of the loan with the given loan_id. + """ + # Retrieve loan + loan = cls.query.get(loan_id) + + if not loan: + raise ValueError(f"Loan with ID {loan_id} does not exist.") + + if loan.status == status: + return + + # Update loan status and the updated_at timestamp + loan.status = status + + db.session.commit() + + 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/repayment.py b/app/models/repayment.py new file mode 100644 index 0000000..06b872c --- /dev/null +++ b/app/models/repayment.py @@ -0,0 +1,55 @@ +from datetime import datetime, timezone +from app.api.enums.loan_status import LoanStatus +from app.extensions import db +from app.models.customer import Customer +from app.models.loan import Loan +from sqlalchemy.exc import IntegrityError + + +class Repayment(db.Model): + __tablename__ = 'repayments' + + id = db.Column( + db.Integer, + primary_key=True, + autoincrement=True, + ) + loan_id = db.Column(db.String(50), nullable=False) + customer_id = db.Column(db.String(50), nullable=False) + product_id = db.Column(db.String(20), nullable=True) + 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_repayment(cls, customer_id, loan_id, product_id): + + + # Check customer exists + if not Customer.is_valid_customer(customer_id): + raise ValueError("Invalid customer") + + # Check loan exists + loan = Loan.get_customer_loan(loan_id = loan_id, customer_id = customer_id) + + # Check that the loan is active + if loan.status != LoanStatus.ACTIVE: + raise ValueError(f"Repayment cannot be processed. Loan status: ({loan.status})") + + + repayment = cls( + customer_id=customer_id, + loan_id=loan.id, + product_id=product_id, + ) + + try: + db.session.add(repayment) + db.session.commit() + except IntegrityError as err: + db.session.rollback() + raise ValueError(f"Database integrity error: {err}") + + return repayment + + def __repr__(self): + return f'' diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..5bde2db --- /dev/null +++ b/app/models/transaction.py @@ -0,0 +1,53 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.exc import IntegrityError +from sqlalchemy import and_, or_, not_ + +class Transaction(db.Model): + __tablename__ = 'transactions' + id = db.Column( + db.Integer, + primary_key=True, + autoincrement=True, + ) + #id = db.Column(db.Int, primary_key=True) + transaction_id = db.Column(db.String(50), nullable=False) + 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, transaction_id, account_id, type, channel): + + # if cls.query.filter_by(transaction_id=transaction_id).first(): + # raise ValueError("Duplicate Transaction") + + if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first(): + raise ValueError("Duplicate Transaction") + + + + transaction = cls( + transaction_id=transaction_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/app/swagger/digifi_swagger.json b/app/swagger/digifi_swagger.json new file mode 100644 index 0000000..436abcf --- /dev/null +++ b/app/swagger/digifi_swagger.json @@ -0,0 +1,180 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Swagger Bank Channel to Simbrella FirstAdvance - OpenAPI 3.0", + "description": "This is a Simbrella FirstAdvance Backend Server with the OpenAPI 3.0 specification. \n\n\nSome useful links:\n- [Web Simulated Demo Page](https://digifi-salaryloan.chiefsoft.net/)\n- [Web Management Support Portal](https://digifi-office.chiefsoft.net/auth/login)", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "support@chiefsoft.com" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.11" + }, + "servers": [ + { + "url": "http://localhost:4500" + }, + { + "url": "http://api.dev.simbrellang.net:4500" + }, + { + "url": "https://api.dev.simbrellang.net" + } + ], + "tags": [ + { + "name": "Authorize", + "description": "This feature will be used for authorizing customers.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + }, + { + "name": "AuthorizeRefresh", + "description": "This feature will be used for refreshing authorized customers.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + }, + { + "name": "EligibilityCheck", + "description": "Eligibility Check Request", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + }, + { + "name": "SelectOffer", + "description": "This method is used the send the offer the customer selected to Simbrella.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + }, + { + "name": "ProvideLoan", + "description": "Provide Loan Request.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + }, + { + "name": "LoanStatus", + "description": "Loan Information Request.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + }, + { + "name": "Repayment", + "description": "Repayment Request.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.simbrellang.net" + } + } + ], + "paths": { + "/Authorize": { + "$ref": "swagger/paths/Authorize.json" + }, + "/AuthorizeRefresh": { + "$ref": "swagger/paths/AuthorizeRefresh.json" + }, + "/EligibilityCheck": { + "$ref": "swagger/paths/EligibilityCheck.json" + }, + "/SelectOffer": { + "$ref": "swagger/paths/SelectOffer.json" + }, + "/ProvideLoan": { + "$ref": "swagger/paths/ProvideLoan.json" + }, + "/LoanStatus": { + "$ref": "swagger/paths/LoanStatus.json" + }, + "/Repayment": { + "$ref": "swagger/paths/Repayment.json" + } + }, + "components": { + "schemas": { + "EligibilityCheckRequest": { + "$ref": "swagger/schemas/EligibilityCheckRequest.json" + }, + "EligibilityCheckResponse": { + "$ref": "swagger/schemas/EligibilityCheckResponse.json" + }, + "SelectOfferRequest": { + "$ref": "swagger/schemas/SelectOfferRequest.json" + }, + "SelectOfferResponse": { + "$ref": "swagger/schemas/SelectOfferResponse.json" + }, + "LoanStatusRequest": { + "$ref": "swagger/schemas/LoanStatusRequest.json" + }, + "LoanStatusResponse": { + "$ref": "swagger/schemas/LoanStatusResponse.json" + }, + "RepaymentRequest": { + "$ref": "swagger/schemas/RepaymentRequest.json" + }, + "RepaymentResponse": { + "$ref": "swagger/schemas/RepaymentResponse.json" + }, + "CustomerConsentRequest": { + "$ref": "swagger/schemas/CustomerConsentRequest.json" + }, + "CustomerConsentResponse": { + "$ref": "swagger/schemas/CustomerConsentResponse.json" + }, + "NotificationCallbackRequest": { + "$ref": "swagger/schemas/NotificationCallbackRequest.json" + }, + "NotificationCallbackResponse": { + "$ref": "swagger/schemas/NotificationCallbackResponse.json" + }, + "ApiResponse": { + "$ref": "swagger/schemas/ApiResponse.json" + }, + "AuthorizeResponse": { + "$ref": "swagger/schemas/AuthorizeResponse.json" + }, + "AuthorizeRequest": { + "$ref": "swagger/schemas/AuthorizeRequest.json" + }, + "AuthorizeRefreshResponse": { + "$ref": "swagger/schemas/AuthorizeRefreshResponse.json" + }, + "AuthorizeRefreshRequest": { + "$ref": "swagger/schemas/AuthorizeRefreshRequest.json" + } + }, + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + { + "basicAuth": [], + "bearerAuth": [] + } + ] +} diff --git a/app/swagger/paths/Authorize.json b/app/swagger/paths/Authorize.json new file mode 100644 index 0000000..081e820 --- /dev/null +++ b/app/swagger/paths/Authorize.json @@ -0,0 +1,54 @@ +{ + "post": { + "tags": ["Authorize"], + "summary": "Customer Authorize Request", + "description": "Customer Authorize Request", + "operationId": "Authorize", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/AuthorizeRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/AuthorizeRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/AuthorizeRequest.json" + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/AuthorizeResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/AuthorizeResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request parameters" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } +} diff --git a/app/swagger/paths/AuthorizeRefresh.json b/app/swagger/paths/AuthorizeRefresh.json new file mode 100644 index 0000000..a5f19ee --- /dev/null +++ b/app/swagger/paths/AuthorizeRefresh.json @@ -0,0 +1,54 @@ +{ + "post": { + "tags": ["Authorize Refresh"], + "summary": "Customer Authorize Refresh Request", + "description": "Customer Authorize Refresh Request", + "operationId": "AuthorizeRefresh", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/AuthorizeRefreshRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/AuthorizeRefreshRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/AuthorizeRefreshRequest.json" + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/AuthorizeRefreshResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/AuthorizeRefreshResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request parameters" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } +} diff --git a/app/swagger/paths/CustomerConsent.json b/app/swagger/paths/CustomerConsent.json new file mode 100644 index 0000000..f246704 --- /dev/null +++ b/app/swagger/paths/CustomerConsent.json @@ -0,0 +1,56 @@ +{ + "post": { + "tags": [ + "CustomerConsent" + ], + "summary": "Customer Consent Request", + "description": "Customer Consent Request", + "operationId": "CustomerConsent", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/CustomerConsentRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/CustomerConsentRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/CustomerConsentRequest.json" + } + } + } + }, + "responses": { + "200": { + "description": "Successful", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/CustomerConsentResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/CustomerConsentResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } + } \ No newline at end of file diff --git a/app/swagger/paths/EligibilityCheck.json b/app/swagger/paths/EligibilityCheck.json new file mode 100644 index 0000000..f2142b1 --- /dev/null +++ b/app/swagger/paths/EligibilityCheck.json @@ -0,0 +1,57 @@ +{ + "post": { + "tags": [ + "EligibilityCheck" + ], + "summary": "Start the process - initiate steps to eligibility RAC Checks ", + "description": "Initiate Eligibility Check Request", + "operationId": "startEligibilityCheck", + "requestBody": { + "description": "Post JSON to conduct eligibility tests", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/EligibilityCheckRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/EligibilityCheckRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/EligibilityCheckRequest.json" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/EligibilityCheckResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/EligibilityCheckResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } +} \ No newline at end of file diff --git a/app/swagger/paths/LoanStatus.json b/app/swagger/paths/LoanStatus.json new file mode 100644 index 0000000..59d165b --- /dev/null +++ b/app/swagger/paths/LoanStatus.json @@ -0,0 +1,57 @@ +{ + "post": { + "tags": [ + "LoanStatus" + ], + "summary": "Loan Information Request ", + "description": "Loan Information Request", + "operationId": "LoanStatus", + "requestBody": { + "description": "Post JSON to conduct eligibility tests", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/LoanStatusRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/LoanStatusRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/LoanStatusRequest.json" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/LoanStatusResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/LoanStatusResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } + } \ No newline at end of file diff --git a/app/swagger/paths/NotificationCallback.json b/app/swagger/paths/NotificationCallback.json new file mode 100644 index 0000000..26af904 --- /dev/null +++ b/app/swagger/paths/NotificationCallback.json @@ -0,0 +1,57 @@ +{ + "post": { + "tags": [ + "NotificationCallback" + ], + "summary": "Loan Information Request ", + "description": "Loan Information Request", + "operationId": "NotificationCallback", + "requestBody": { + "description": "Post JSON to conduct eligibility tests", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/NotificationCallbackRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/NotificationCallbackRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/NotificationCallbackRequest.json" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/NotificationCallbackResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/NotificationCallbackResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } + } \ No newline at end of file diff --git a/app/swagger/paths/ProvideLoan.json b/app/swagger/paths/ProvideLoan.json new file mode 100644 index 0000000..e875312 --- /dev/null +++ b/app/swagger/paths/ProvideLoan.json @@ -0,0 +1,57 @@ +{ + "post": { + "tags": [ + "ProvideLoan" + ], + "summary": "Provide Loan Request ", + "description": "Provide Loan Request", + "operationId": "ProvideLoan", + "requestBody": { + "description": "Post JSON to conduct eligibility tests", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/ProvideLoanRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/ProvideLoanRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/ProvideLoanRequest.json" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/ProvideLoanResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/ProvideLoanResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } + } \ No newline at end of file diff --git a/app/swagger/paths/Repayment.json b/app/swagger/paths/Repayment.json new file mode 100644 index 0000000..e88d18c --- /dev/null +++ b/app/swagger/paths/Repayment.json @@ -0,0 +1,56 @@ +{ + "post": { + "tags": [ + "Repayment" + ], + "summary": "Repayment Request", + "description": "Repayment Request", + "operationId": "Repayment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/RepaymentRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/RepaymentRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/RepaymentRequest.json" + } + } + } + }, + "responses": { + "200": { + "description": "Repayment Successful", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/RepaymentResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/RepaymentResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } +} \ No newline at end of file diff --git a/app/swagger/paths/SelectOffer.json b/app/swagger/paths/SelectOffer.json new file mode 100644 index 0000000..06c20bd --- /dev/null +++ b/app/swagger/paths/SelectOffer.json @@ -0,0 +1,57 @@ +{ + "post": { + "tags": [ + "SelectOffer" + ], + "summary": "This method is used the send the offer the customer selected to Simbrella ", + "description": "This method is used the send the offer the customer selected to Simbrella", + "operationId": "SelectOffer", + "requestBody": { + "description": "Post JSON to conduct eligibility tests", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/SelectOfferRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/SelectOfferRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/SelectOfferRequest.json" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/SelectOfferResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/SelectOfferResponse.json" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "422": { + "description": "Validation exception" + }, + "500": { + "description": "Internal server error" + } + } + } +} \ No newline at end of file diff --git a/app/swagger/schemas/ApiResponse.json b/app/swagger/schemas/ApiResponse.json new file mode 100644 index 0000000..2cd2bdd --- /dev/null +++ b/app/swagger/schemas/ApiResponse.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/AuthorizeRefreshRequest.json b/app/swagger/schemas/AuthorizeRefreshRequest.json new file mode 100644 index 0000000..bdee421 --- /dev/null +++ b/app/swagger/schemas/AuthorizeRefreshRequest.json @@ -0,0 +1,7 @@ +{ + "type": "object", + "properties": {}, + "xml": { + "name": "AuthorizeRefreshRequest" + } +} diff --git a/app/swagger/schemas/AuthorizeRefreshResponse.json b/app/swagger/schemas/AuthorizeRefreshResponse.json new file mode 100644 index 0000000..e23094e --- /dev/null +++ b/app/swagger/schemas/AuthorizeRefreshResponse.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "access_token" + } + }, + "xml": { + "name": "AuthorizeRefreshResponse" + } +} diff --git a/app/swagger/schemas/AuthorizeRequest.json b/app/swagger/schemas/AuthorizeRequest.json new file mode 100644 index 0000000..62728e6 --- /dev/null +++ b/app/swagger/schemas/AuthorizeRequest.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "user" + }, + "password": { + "type": "string", + "example": "password" + } + }, + "xml": { + "name": "AuthorizeRequest" + } +} diff --git a/app/swagger/schemas/AuthorizeResponse.json b/app/swagger/schemas/AuthorizeResponse.json new file mode 100644 index 0000000..6dc148e --- /dev/null +++ b/app/swagger/schemas/AuthorizeResponse.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "access_token" + }, + "refresh_token": { + "type": "string", + "example": "refresh_token" + } + }, + "xml": { + "name": "AuthorizeResponse" + } +} diff --git a/app/swagger/schemas/CustomerConsentRequest.json b/app/swagger/schemas/CustomerConsentRequest.json new file mode 100644 index 0000000..a86c53a --- /dev/null +++ b/app/swagger/schemas/CustomerConsentRequest.json @@ -0,0 +1,37 @@ +{ + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "CustomerConsentRequest" + }, + "transactionId": { + "type": "string", + "example": "20171209232177" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + }, + "requestTime": { + "type": "string", + "format": "date-time", + "example": "2019-10-18 14:26:21.063" + }, + "consentType": { + "type": "string", + "example": "Revoke" + }, + "channel": { + "type": "string", + "example": "USSD" + } + }, + "xml": { + "name": "CustomerConsentRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/CustomerConsentResponse.json b/app/swagger/schemas/CustomerConsentResponse.json new file mode 100644 index 0000000..fb46f6a --- /dev/null +++ b/app/swagger/schemas/CustomerConsentResponse.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "resultCode": { + "type": "string", + "example": "00" + }, + "resultDescription": { + "type": "string", + "example": "Request is received" + } + }, + "xml": { + "name": "CustomerConsentResponse" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/EligibilityCheckRequest.json b/app/swagger/schemas/EligibilityCheckRequest.json new file mode 100644 index 0000000..8bb14de --- /dev/null +++ b/app/swagger/schemas/EligibilityCheckRequest.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "countryCode": { + "type": "string", + "example": "NGR" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "msisdn": { + "type": "string", + "example": "8093451342" + }, + "channel": { + "type": "string", + "example": "100" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + } + }, + "xml": { + "name": "EligibilityCheckRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/EligibilityCheckResponse.json b/app/swagger/schemas/EligibilityCheckResponse.json new file mode 100644 index 0000000..1f91239 --- /dev/null +++ b/app/swagger/schemas/EligibilityCheckResponse.json @@ -0,0 +1,82 @@ +{ + "type": "object", + "properties": { + "customerId": { + "type": "string", + "example": "CN621868" + }, + "transactionId": { + "type": "string", + "example": "TX12345" + }, + "countryCode": { + "type": "string", + "example": "NG" + }, + "msisdn": { + "type": "string", + "example": "3451342" + }, + "eligibleOffers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "offerId": { + "type": "string", + "example": "Offer1" + }, + "productId": { + "type": "string", + "example": "Product1" + }, + "minAamount": { + "type": "number", + "format": "decimal", + "example": 100.00 + }, + "maxAamount": { + "type": "number", + "format": "decimal", + "example": 1000.00 + }, + "tenor": { + "type": "integer", + "example": 12 + } + } + }, + "example": [ + { + "offerId": "Offer1", + "productId": "Product1", + "minAamount": 100.00, + "maxAamount": 1000.00, + "tenor": 12 + }, + { + "offerId": "Offer2", + "productId": "Product2", + "minAamount": 200.00, + "maxAamount": 2000.00, + "tenor": 24 + } + ] + }, + "resultDescription": { + "type": "string", + "example": "Successful" + }, + "resultCode": { + "type": "string", + "example": "00" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + } + }, + "xml": { + "name": "EligibilityCheckResponse" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/LoanStatusRequest.json b/app/swagger/schemas/LoanStatusRequest.json new file mode 100644 index 0000000..0252f26 --- /dev/null +++ b/app/swagger/schemas/LoanStatusRequest.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "msisdn": { + "type": "string", + "example": "3451342" + }, + "channel": { + "type": "string", + "example": "USSD" + } + }, + "xml": { + "name": "LoanStatusRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/LoanStatusResponse.json b/app/swagger/schemas/LoanStatusResponse.json new file mode 100644 index 0000000..6e9e877 --- /dev/null +++ b/app/swagger/schemas/LoanStatusResponse.json @@ -0,0 +1,70 @@ +{ + "type": "object", + "properties": { + "customerId": { + "type": "string", + "example": "CN621868" + }, + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "loans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "debtId": { + "type": "string", + "example": "123456789" + }, + "loanDate": { + "type": "string", + "format": "date-time", + "example": "2019-10-18 14:26:21.063" + }, + "dueDate": { + "type": "string", + "format": "date-time", + "example": "2019-11-20 14:26:21.063" + }, + "currentLoanAmount": { + "type": "integer", + "example": 8500 + }, + "initialLoanAmount": { + "type": "integer", + "example": 10000 + }, + "defaultPenaltyFee": { + "type": "integer", + "example": 0 + }, + "continuousFee": { + "type": "integer", + "example": 0 + }, + "productId": { + "type": "string", + "example": "101" + } + } + } + }, + "totalDebtAmount": { + "type": "integer", + "example": 8500 + }, + "resultCode": { + "type": "string", + "example": "00" + }, + "resultDescription": { + "type": "string", + "example": "Successful" + } + }, + "xml": { + "name": "LoanStatusResponse" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/NotificationCallbackRequest.json b/app/swagger/schemas/NotificationCallbackRequest.json new file mode 100644 index 0000000..d5457fb --- /dev/null +++ b/app/swagger/schemas/NotificationCallbackRequest.json @@ -0,0 +1,50 @@ +{ + "type": "object", + "properties": { + "fbnTransactionId": { + "type": "string", + "example": "123456789" + }, + "transactionId": { + "type": "string", + "example": "123456789" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + }, + "debtId": { + "type": "string", + "example": "987654321" + }, + "transactionType": { + "type": "string", + "example": "Disbursement" + }, + "amountProvided": { + "type": "number", + "format": "float", + "example": 1000.00 + }, + "amountCollected": { + "type": "number", + "format": "float", + "example": 0.00 + }, + "responseCode": { + "type": "string", + "example": "00" + }, + "responseDescription": { + "type": "string", + "example": "Successful" + } + }, + "xml": { + "name": "NotificationCallbackRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/NotificationCallbackResponse.json b/app/swagger/schemas/NotificationCallbackResponse.json new file mode 100644 index 0000000..c4a6a78 --- /dev/null +++ b/app/swagger/schemas/NotificationCallbackResponse.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "resultCode": { + "type": "string", + "example": "00" + }, + "resultDescription": { + "type": "string", + "example": "Successful" + } + }, + "xml": { + "name": "NotificationCallbackResponse" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/ProvideLoanRequest.json b/app/swagger/schemas/ProvideLoanRequest.json new file mode 100644 index 0000000..721d8b9 --- /dev/null +++ b/app/swagger/schemas/ProvideLoanRequest.json @@ -0,0 +1,45 @@ +{ + "type": "object", + "properties": { + "requestId": { + "type": "string", + "example": "202111170001371256908" + }, + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + }, + "msisdn": { + "type": "string", + "example": "3451342" + }, + "requestedAmount": { + "type": "number", + "format": "decimal", + "example": 900 + }, + "collectionType": { + "type": "integer", + "example": 1 + }, + "offerId": { + "type": "integer", + "example": 1127 + }, + "channel": { + "type": "string", + "example": "100" + } + }, + "xml": { + "name": "ProvideLoanRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/ProvideLoanResponse.json b/app/swagger/schemas/ProvideLoanResponse.json new file mode 100644 index 0000000..d7ddb3c --- /dev/null +++ b/app/swagger/schemas/ProvideLoanResponse.json @@ -0,0 +1,36 @@ +{ + "type": "object", + "properties": { + "requestId": { + "type": "string", + "example": "202111170001371256908" + }, + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + }, + "msisdn": { + "type": "string", + "example": "3451342" + }, + "resultCode": { + "type": "string", + "example": "00" + }, + "resultDescription": { + "type": "string", + "example": "Successful" + } + }, + "xml": { + "name": "ProvideLoanResponse" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/RepaymentRequest.json b/app/swagger/schemas/RepaymentRequest.json new file mode 100644 index 0000000..c31d585 --- /dev/null +++ b/app/swagger/schemas/RepaymentRequest.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "msisdn": { + "type": "string", + "example": "3451342" + }, + "debtId": { + "type": "string", + "example": "10" + }, + "productId": { + "type": "string", + "example": "101" + }, + "transactionId": { + "type": "string", + "example": "20171209232115" + }, + "customerId": { + "type": "string", + "example": "CID0000025585" + }, + "channel": { + "type": "string", + "example": "USSD" + } + }, + "xml": { + "name": "RepaymentRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/RepaymentResponse.json b/app/swagger/schemas/RepaymentResponse.json new file mode 100644 index 0000000..06e4666 --- /dev/null +++ b/app/swagger/schemas/RepaymentResponse.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "customerId": { + "type": "string", + "example": "CN621868" + }, + "productId": { + "type": "string", + "example": "101" + }, + "debtId": { + "type": "string", + "example": "273194670" + }, + "resultCode": { + "type": "string", + "example": "00" + }, + "resultDescription": { + "type": "string", + "example": "Successful" + } + }, + "xml": { + "name": "RepaymentResponse" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/SelectOfferRequest.json b/app/swagger/schemas/SelectOfferRequest.json new file mode 100644 index 0000000..a874ed3 --- /dev/null +++ b/app/swagger/schemas/SelectOfferRequest.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties": { + "requestId": { + "type": "string", + "example": "202111170001371256908" + }, + "transactionId": { + "type": "string", + "example": "1231231321232" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "msisdn": { + "type": "string", + "example": "123456789" + }, + "requestedAmount": { + "type": "number", + "format": "double", + "example": 10000.55 + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + }, + "productId": { + "type": "string", + "example": "101" + }, + "channel": { + "type": "string", + "example": "" + } + }, + "xml": { + "name": "SelectOffersRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/SelectOfferResponse.json b/app/swagger/schemas/SelectOfferResponse.json new file mode 100644 index 0000000..1d0ebdf --- /dev/null +++ b/app/swagger/schemas/SelectOfferResponse.json @@ -0,0 +1,112 @@ +{ + "type": "object", + "properties": { + "requestId": { + "type": "string", + "example": "202111170001371256908" + }, + "transactionId": { + "type": "string", + "example": "1231231321232" + }, + "customerId": { + "type": "string", + "example": "1256907" + }, + "accountId": { + "type": "string", + "example": "5948306019" + }, + "loan": { + "type": "array", + "items": { + "type": "object", + "properties": { + "offerId": { + "type": "string", + "example": "14451" + }, + "productId": { + "type": "string", + "example": "2030" + }, + "amount": { + "type": "number", + "format": "float", + "example": 10000.0 + }, + "upfrontPayment": { + "type": "number", + "format": "float", + "example": 1000.0 + }, + "interestRate": { + "type": "number", + "format": "float", + "example": 3.0 + }, + "ManagementRate": { + "type": "number", + "format": "float", + "example": 1.0 + }, + "ManagementFee": { + "type": "number", + "format": "float", + "example": 1.0 + }, + "InsuranceRate": { + "type": "number", + "format": "float", + "example": 1.0 + }, + "InsuranceFee": { + "type": "number", + "format": "float", + "example": 100.0 + }, + "VATRate": { + "type": "number", + "format": "float", + "example": 7.5 + }, + "VATamount": { + "type": "number", + "format": "float", + "example": 100.0 + }, + "recommendedRepaymentDates": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "2022-11-30" + ] + }, + "installmentAmount": { + "type": "number", + "format": "float", + "example": 11000.0 + }, + "totalRepaymentAmount": { + "type": "number", + "format": "float", + "example": 11000.0 + } + } + } + }, + "resultCode": { + "type": "string", + "example": "00" + }, + "resultDescription": { + "type": "string", + "example": "Successful" + } + }, + "xml": { + "name": "SelectOffersResponse" + } +} \ No newline at end of file diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..4ee0e98 --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,13 @@ +import logging + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[ + # logging.StreamHandler(), + logging.FileHandler("app.log", mode='a') # Log to file + ] +) + +logger = logging.getLogger("DetectionService") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ef41164 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + digifi-bank-to-product-core: + build: . + env_file: + - .env + ports: + - "${APP_PORT:-4500}:5000" + environment: + - 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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d5c945 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,36 @@ +# 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 + +# Requests +requests + +# JWT +flask-jwt-extended + + +# Kafka +confluent-kafka==1.9.2 + diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..49f70e1 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,7 @@ +from app import create_app + +app = create_app() + +if __name__ != "__main__": + # Expose WSGI app instance for Gunicorn + wsgi_app = app