commit 987b7d6383aed341220e70cfff120f1f1dd75b4d Author: CHIEFSOFT\ameye Date: Sun Jun 22 20:45:07 2025 -0400 mercore starter diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..3521430 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,35 @@ +# Environment Variables +BASIC_AUTH_USERNAME=user +BASIC_AUTH_PASSWORD=password + +#swagger Configuration +SWAGGER_URL="/documentation" +API_URL="/swagger.json" + +# Database Configuration +DATABASE_USER=firstadvance +DATABASE_PASSWORD=FirstAdvance! +DATABASE_HOST=dev-data.simbrellang.net +DATABASE_PORT=10532 +DATABASE_NAME=firstadvancedev + +# DATABASE_HOST=10.20.30.60 +# DATABASE_USER=firstadvance +# DATABASE_PASSWORD=firstadvance +# DATABASE_NAME=firstadvancedev +# DATABASE_PORT=5432 + +# Flask Configuration +FLASK_APP=wsgi.py +FLASK_ENV=development +APP_PORT=4500 + + +# Bank Call Service Connection +SIMBRELLA_BASE_URL="https://bank-emulator.dev.simbrellang.net" +VALID_APP_ID=app1 +VALID_API_KEY=test-api-key-12345 + + +# Event Bus Broker Configuration +KAFKA_BROKER="10.0.0.246:9092" diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..47a154c --- /dev/null +++ b/.example.env @@ -0,0 +1,26 @@ +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-BankToProductCore.iml b/.idea/digifi-BankToProductCore.iml new file mode 100644 index 0000000..ae45667 --- /dev/null +++ b/.idea/digifi-BankToProductCore.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..9402635 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,29 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a6218fe --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..7d8aea8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..f324872 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ 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..140eb7a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# 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"] + +CMD ["sh", "-c", \ + "echo 'Starting Gunicorn server...' && \ + exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app"] + + 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/SQL/site_data.sql b/SQL/site_data.sql new file mode 100644 index 0000000..c2c4176 --- /dev/null +++ b/SQL/site_data.sql @@ -0,0 +1,14 @@ + +CREATE TABLE transactions ( + id SERIAL, + transaction_id VARCHAR(50) NOT NULL, + account_id VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL, + channel VARCHAR(8) NOT NULL, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now() +); +ALTER TABLE ONLY transactions + ADD CONSTRAINT transactions_id_key UNIQUE (id); + + diff --git a/Swagger Draft First Advance Integration Details v1.3.docx - Google Docs.pdf b/Swagger Draft First Advance Integration Details v1.3.docx - Google Docs.pdf new file mode 100644 index 0000000..5c472ea Binary files /dev/null and b/Swagger Draft First Advance Integration Details v1.3.docx - Google Docs.pdf differ 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..fa9f0c5 --- /dev/null +++ b/app/api/enums/loan_status.py @@ -0,0 +1,8 @@ +from enum import Enum + +class LoanStatus(str, Enum): + PENDING = "pending" + ACTIVE = "active" + ACTIVE_PARTIAL = "active_partial" + START_REPAY = "start_repay" + 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..2380c61 --- /dev/null +++ b/app/api/helpers/response_helper.py @@ -0,0 +1,112 @@ +from flask import jsonify +from typing import Optional, Union, Dict, List, Any + + +class ResponseHelper: + """ + A helper class for building standardized JSON responses using resultCode and resultDescription. + """ + + @staticmethod + def build_response( + result_code: str, + result_description: str, + data: Optional[Union[Dict, List, str]] = None + ) -> Dict[str, Any]: + response = { + "resultCode": result_code, + "resultDescription": result_description + } + + if isinstance(data, dict): + response.update(data) + + return jsonify(response) + + @staticmethod + def success( + result_description: str = "Successful", + result_code: str = "0", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def error( + result_description: str = "An error occurred", + result_code: str = "01", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def created( + result_description: str = "Resource created successfully", + result_code: str = "00", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def updated( + result_description: str = "Resource updated successfully", + result_code: str = "00", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def internal_server_error( + result_description: str = "Internal Server Error", + result_code: str = "500", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def unauthorized( + result_description: str = "Unauthorized", + result_code: str = "401", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def forbidden( + result_description: str = "Forbidden", + result_code: str = "403", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def not_found( + result_description: str = "Resource not found", + result_code: str = "404", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def unprocessable_entity( + result_description: str = "Unprocessable entity", + result_code: str = "422", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def method_not_allowed( + result_description: str = "Method Not Allowed", + result_code: str = "405", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) + + @staticmethod + def bad_request( + result_description: str = "Bad Request", + result_code: str = "400", + data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + return ResponseHelper.build_response(result_code, result_description, data) \ 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..3455aef --- /dev/null +++ b/app/api/integrations/simbrella.py @@ -0,0 +1,45 @@ +import httpx +import json +from app.utils.logger import logger +from app.config import settings +import logging + + +class SimbrellaIntegration: + BASE_URL = settings.SIMBRELLA_BASE_URL + ENDPOINT_RAC_CHECKS = settings.SIMBRELLA_ENDPOINT_RAC_CHECKS + + @staticmethod + def rac_check(customer_id, account_id, transaction_id): + """ + Calls the RACCheck endpoit + """ + url = f"{SimbrellaIntegration.BASE_URL}/{SimbrellaIntegration.ENDPOINT_RAC_CHECKS}" + logger.info(f"Contacting Rack Checks EndPoint: {str(url)}", exc_info=True) + + payload = { + "customerId": customer_id, + "accountId": account_id, + "transactionId": str(transaction_id), + "fbnTransactionId": str(transaction_id), + "countryCode": "NG", + "channel": "USSD" + } + + headers = { + "Content-Type": "application/json", + "x-api-key": f"{settings.VALID_API_KEY}", + "App-Id": f"{settings.VALID_APP_ID}", + } + + try: + response = httpx.post(url, json=payload, headers=headers, timeout=10.0) + + logger.info(f"This is Response: {str(response)}", exc_info=True) + + return response + + except Exception as e: + logger.error(f"RACCheck API call failed: {str(e)}", exc_info=True) + raise Exception(f"RACCheck API call failed: {str(e)}") + 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..d4eb7ef --- /dev/null +++ b/app/api/routes/routes.py @@ -0,0 +1,149 @@ +from flask import Blueprint, request, jsonify, send_from_directory +from app.api.services import ( + LoginService, + 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("/Login", methods=["POST"]) +@jwt_required() +def merms_login(): + data = request.get_json() + # logger.info(f"EligibilityCheck request received: {data}") + response = LoginService.process_request(data) + return response + + +# 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.error(f"HERE 0000a **** ") + # 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..3899223 --- /dev/null +++ b/app/api/schemas/loan_status.py @@ -0,0 +1,9 @@ +from marshmallow import Schema, fields + +# Loan Information Schema +class LoanStatusSchema(Schema): + transactionId = fields.Str(required=True) + accountId = 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/login.py b/app/api/schemas/login.py new file mode 100644 index 0000000..fd74cb0 --- /dev/null +++ b/app/api/schemas/login.py @@ -0,0 +1,5 @@ +from marshmallow import Schema, fields + +class LoginSchema(Schema): + username = fields.Str(required=True) + password = fields.Str(required=True) 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..ad6c8b1 --- /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.Str(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..b0d4df1 --- /dev/null +++ b/app/api/schemas/repayment.py @@ -0,0 +1,12 @@ +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) + transactionId = fields.Str(required=True) + accountId = fields.Str(required=True) + customerId = fields.Str(required=True) + loanRef = fields.Str(required=True) + initiatedBy = fields.Str(required=False) diff --git a/app/api/schemas/select_offer.py b/app/api/schemas/select_offer.py new file mode 100644 index 0000000..defb702 --- /dev/null +++ b/app/api/schemas/select_offer.py @@ -0,0 +1,14 @@ +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) + offerId = 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..4d4c307 --- /dev/null +++ b/app/api/services/__init__.py @@ -0,0 +1,10 @@ +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 +from app.api.services.offer_analysis import OfferAnalysis +from app.api.services.login import LoginService \ No newline at end of file diff --git a/app/api/services/authorization.py b/app/api/services/authorization.py new file mode 100644 index 0000000..4374b9f --- /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(result_description="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(result_description="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={"data": response_data}, result_description="Authorization processed successfully" + ) + + except ValidationError as e: + logger.error(f"Validation error: {e}") + return ResponseHelper.bad_request(result_description=f"Validation error: {e}") + + except Exception as e: + logger.error(f"Error processing Authorization request: {e}") + return ResponseHelper.internal_server_error( + result_description=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={"data": response_data}, result_description="RefreshToken processed successfully" + ) + + except Exception as e: + logger.error(f"Error processing RefreshToken request: {e}") + return ResponseHelper.internal_server_error( + result_description=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..4a6fd86 --- /dev/null +++ b/app/api/services/base_service.py @@ -0,0 +1,175 @@ +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. + """ + channel = "USSD" if validated_data.get("channel") is None else validated_data.get("channel") + + return Transaction.create_transaction( + transaction_id = validated_data.get("transactionId"), + customer_id = validated_data.get('customerId', None), + account_id = validated_data.get("accountId", None), + type = cls.TRANSACTION_TYPE, + channel = 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() + + + @classmethod + def calculate_charges(cls, offer, amount): + """ + Calculates and returns the charges for the given offer and amount. + + Args: + offer (Offer): The offer object that contains the charges. + amount (float): The requested loan amount. + + Returns: + dict: A dictionary containing the calculated charges. + """ + if not offer or not offer.charges: + logger.error(f"No charges found for offer ID {offer.id}") + return {"error": "No charges found for the offer"} + + loan_charges = offer.charges + tenor = offer.schedule # offer.tenor // 30 # Convert to months + interest = cls.get_charge_detail(rates = offer.interest_rate, charges = loan_charges, code = "INTEREST", amount = amount) + management = cls.get_charge_detail(rates = offer.management_rate, charges = loan_charges, code = "MGTFEE", amount = amount) + insurance = cls.get_charge_detail(rates = offer.insurance_rate, charges = loan_charges, code = "INSURANCE", amount = amount) + vat = cls.get_charge_detail(rates = offer.vat_rate, charges = loan_charges, code = "VAT", amount = amount, management_fee = management["fee"]) + + # Separate fees into upfront and postpaid + upfront_fees = [ + fee["fee"] + for fee in [interest, management, insurance, vat] + if fee["due_days"] == 0 + ] + + postpaid_fees = [ + fee["fee"] + for fee in [interest, management, insurance, vat] + if fee["due_days"] != 0 + ] + vat_test = vat["fee"] + logger.info(f"VAT fee == *************** : {vat_test}") + + # Up-front payment: (only those fees due immediately i.e due_days == 0) + # upfront_payment = sum(upfront_fees) + if offer.schedule == 1: + upfront_payment = vat["fee"] + management["fee"] + insurance["fee"] + interest["fee"] + interest_amount = interest["fee"] + repayment_amount = amount + else: + upfront_payment = vat["fee"] + insurance["fee"]+management["fee"] + interest_amount = interest["fee"]*offer.schedule + repayment_amount = amount + interest_amount + + + # Repayment amount: (principal + only those fees not due immediately i.e due_days != 0) + # repayment_amount = amount + (sum(postpaid_fees) * tenor) + + # Total amount: (upfront_payment + repayment_amount) + total_amount = upfront_payment + repayment_amount + + # Calculate the installment amount + installment_amount = repayment_amount / offer.schedule + + return { + "interest": interest, + "interest_amount": interest_amount, + "management": management, + "insurance": insurance, + "vat": vat, + "upfront_payment": round(upfront_payment, 2), + "repayment_amount": round(repayment_amount, 2), + "installment_amount": round(installment_amount, 2), + "total_amount": round(total_amount, 2) + } + + + @classmethod + def get_charge_detail(cls, rates, charges, code, amount, management_fee= 0.0): + """ + Get details for a specific charge code from a list of loan charges. + + Returns default values if not found. + """ + fee = 0.0 + + if code == "VAT" and management_fee > 0: + fee = management_fee * rates / 100 + else: + fee = amount * rates / 100 + + return { + "rate": rates, + "fee": round(fee, 2), + "due_days": 30, + "code": code, + "description" : "have no idea how to get this yet" + } + + # if charge.code == code: + # if code == "VAT" and management_fee > 0: + # fee = management_fee * rates / 100 + # else: + # fee = amount * rates / 100 + # + # return { + # "rate": rates, + # "fee": round(fee, 2), + # "due_days": charge.due + # } + + # return {"rate": 0, "fee": 0, "due_days": 0} + + diff --git a/app/api/services/customer_consent.py b/app/api/services/customer_consent.py new file mode 100644 index 0000000..e2663d3 --- /dev/null +++ b/app/api/services/customer_consent.py @@ -0,0 +1,60 @@ +from flask import request, jsonify +from app.api.helpers.response_helper import ResponseHelper +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 +from app.extensions import db + + +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: + with db.session.begin(): + 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 ResponseHelper.error(result_description="Failed to log transaction.") + else: + return ResponseHelper.error(result_description="Invalid Customer or Account") + + + db.session.commit() + return ResponseHelper.success(result_description="Request is received") + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() \ 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..d9a7dd8 --- /dev/null +++ b/app/api/services/eligibility_check.py @@ -0,0 +1,198 @@ +from flask import session, jsonify +from app.models.loan import Loan +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 +from app.extensions import db +from app.models import Offer, RACCheck +from app.api.services.offer_analysis import OfferAnalysis +from app.api.helpers.response_helper import ResponseHelper + +import random + + +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: + with db.session.begin(): + + 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 ResponseHelper.error(result_description="Failed to log transaction.") + else: + return ResponseHelper.error(result_description="Invalid Customer or Account") + + db.session.flush() + + # Determine Loan count + is_eligible = EligibilityCheckService.check_loan_limits(customer_id) + + if not is_eligible: + return ResponseHelper.error(result_description="Max loan count reached") + + # Call RACCheck + response = SimbrellaIntegration.rac_check( + customer_id = customer_id, + account_id = account_id, + transaction_id = transaction.transaction_id, + ) + + # this chek for error is not valid + if response.status_code != 200: + return ResponseHelper.error(result_description="RACCheck failed") + + response = response.json() + + logger.info(f"This is Response (from Eligibility Check): {str(response)}", exc_info=True) + + + if not response or response['responseCode'] != '00': + + if response: + logger.error(f"{response['responseMessage']}") + + return ResponseHelper.error(result_description=f"RACCheck failed") + + rack_checks_response = response['data']['racResponse'] + + rac_check = RACCheck.add_rac_check( + customer_id = customer_id, + account_id = account_id, + transaction_id = transaction.transaction_id, + data = rack_checks_response + ) + + if not rac_check: + logger.error(f"Failed to save RACCheck") + return ResponseHelper.error(result_description="Failed to save RACCheck.") + + # -----------------TIME FOR ANALYSIS TO REGISTER OFFER ---------------------- + # eligible_offers = [] + try: + eligible_offers = OfferAnalysis.decide_offer( + transaction_id=transactionId, + rac_check=rac_check, + validated_data=validated_data, + customer_id=customer_id, + rack_checks_response =rack_checks_response + ) + except ValueError as ve: + logger.error(str(ve)) + return ResponseHelper.error(result_description= str(ve)) +# ----------------------------------------------------------------------- +# s = Offer.get_all_offers() + + # eligible_offers = [] + + # for offer in offers: + # # Determine an approved amount + # random_float = random.random() # temporary to play data + # approved_amount = min(offer.max_amount, offer.max_amount * random_float) #temporary for now + # approved_amount = round(approved_amount, 2) + # + # transaction_offer = TransactionOffer.create_transaction_offer( + # customer_id = customer.id, + # transaction_id = transaction.transaction_id, + # offer_id = offer.id, + # min_amount = offer.min_amount, + # max_amount = offer.max_amount, + # eligible_amount = approved_amount, + # product_id = offer.product_id, + # tenor = offer.tenor + # ) + # + # # Visible offer ID: offer_id + padded(transaction_offer.id) + # padded_id = str(transaction_offer.id).zfill(6) + # public_offer_id = f"{offer.id}{padded_id}" + # + # eligible_offers.append({ + # "offerId": public_offer_id, + # "product_id": offer.product_id, + # "min_amount": offer.min_amount, + # "max_amount": approved_amount, + # "tenor": offer.tenor + # }) + + # Simulate processing + response_data = { + "customerId": customer_id, + "transactionId": transactionId, + "countryCode": "NG", + "msisdn": msisdn, + "eligibleOffers": eligible_offers, + "accountId": account_id + } + + return ResponseHelper.success(data=response_data) + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() + + + @staticmethod + def check_loan_limits(customer_id): + """ + Checks if a customer has exceeded the loan limits for given offer. + """ + loan = Loan.get_customer_last_loan(customer_id) + + if not loan: + return True + + offer_id = loan.offer_id[:5] + + offer = Offer.get_offer_by_id(offer_id) + if not offer: + logger.error(f"Offer not found for offer_id: {offer_id} (customer_id: {customer_id})") + return False + + daily_count = Loan.get_daily_loan_count(customer_id, offer.product_id) + + + logger.info(f"daily_count: {daily_count}, Max: {offer.max_daily_loans}") + + if offer.max_daily_loans is not None and daily_count >= offer.max_daily_loans: + return False + + + return True diff --git a/app/api/services/loan_status.py b/app/api/services/loan_status.py new file mode 100644 index 0000000..a9f2ad5 --- /dev/null +++ b/app/api/services/loan_status.py @@ -0,0 +1,81 @@ +from flask import request, jsonify +from marshmallow import ValidationError +from app.api.enums.loan_status import LoanStatus +from app.models import Customer +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 +from app.extensions import db +from app.api.helpers.response_helper import ResponseHelper + + +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: + with db.session.begin(): + # Validate data + validated_data = LoanStatusService.validate_data(data, LoanStatusSchema()) + + customer_id = validated_data.get('customerId') + + logger.info(f"Looking for customer *** {customer_id}") + customer = Customer.get_customer_with_loan_list(customer_id) + + transactionId = validated_data.get('transactionId') + account_id = validated_data.get('accountId') + + if(LoanStatusService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + # Get loans + loans = [loan.to_dict() for loan in customer.loans if loan.status == LoanStatus.ACTIVE] + transaction = LoanStatusService.log_transaction(validated_data = validated_data) + if not transaction: + logger.error(f"Failed to log transaction") + return ResponseHelper.error(result_description="Failed to log transaction.") + else: + return ResponseHelper.error(result_description="Invalid Customer or Account") + + total_debt_amount = sum( + loan.get("currentLoanAmount") or 0 + for loan in loans + ) + + # Simulated processing logic + response_data = { + "customerId": customer_id, + "accountId": account_id, + "transactionId": transactionId, + "loans": loans, + "totalDebtAmount": total_debt_amount, + } + + db.session.commit() + return ResponseHelper.success(data=response_data) + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() \ No newline at end of file diff --git a/app/api/services/login.py b/app/api/services/login.py new file mode 100644 index 0000000..f947ac9 --- /dev/null +++ b/app/api/services/login.py @@ -0,0 +1,137 @@ +from flask import session, jsonify +from app.models.loan import Loan +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 +from app.extensions import db +from app.models import Offer, RACCheck, Members +from app.api.services.offer_analysis import OfferAnalysis +from app.api.helpers.response_helper import ResponseHelper +from werkzeug.security import generate_password_hash, check_password_hash +from app.api.schemas.login import LoginSchema +import datetime +import jwt +import random +from app.config import Config + + +class LoginService(BaseService): + + @staticmethod + def process_request(data): + """ + Process the Login request. + + Args: + data (dict): The request data. + + Returns: + dict: A standardized response. + """ + try: + with db.session.begin(): + + validated_data = LoginService.validate_data(data, LoginSchema()) + username = validated_data.get('username') + password = validated_data.get('password') + + member = Members.get_member_by_username(username) + # pass22 = generate_password_hash(password) + # logger.info("Password generated = > {}".format(pass22) ) + + pass_check = check_password_hash(member.password, password) + logger.info("Password check: {}".format(pass_check)) + if not member or not pass_check: + invalid_data = { + "error_message": "invalid username or password", + "message_key": "invalid_username_or_password", + } + return ResponseHelper.success(data=invalid_data) + + user_data = {} + user_data["id"] = member.id, + user_data["member_id"]= member.id, + user_data["uid"] = str(member.uid), + + user_token = jwt.encode( + {"user": user_data, 'exp' : datetime.datetime.utcnow() + datetime.timedelta(minutes=3330)}, + Config.JWT_SECRET_KEY, + algorithm="HS256" + ) + + # Simulate processing + response_data = { + "member_id": member.id, + "uid": str(member.uid), + "username": member.username, + "account_name": member.account_name, + "firstname":member.firstname, + "lastname": member.lastname, + "room": member.uid, + "token": user_token + } + + # user = {} + # user_data = {} + # user_data["id"] = result_data["member_id"] + # user_data["member_id"] =result_data["member_id"] + # user_data["uid"] = result_data["uid"] + + # token should expire after 24 hrs + # user["token"] = jwt.encode( + # {"user": user_data, 'exp' : datetime.datetime.utcnow() + datetime.timedelta(minutes=3330)}, + # Config.JWT_SECRET_KEY, + # algorithm="HS256" + # ) + # user["room"] = result_data["uid"] + # response_data = { + # "message": "Successfully fetched auth token", + # "data": user_data + # } + + return ResponseHelper.success(data=response_data) + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() + + @staticmethod + def check_loan_limits(customer_id): + """ + Checks if a customer has exceeded the loan limits for given offer. + """ + loan = Loan.get_customer_last_loan(customer_id) + + if not loan: + return True + + offer_id = loan.offer_id[:5] + + offer = Offer.get_offer_by_id(offer_id) + if not offer: + logger.error(f"Offer not found for offer_id: {offer_id} (customer_id: {customer_id})") + return False + + daily_count = Loan.get_daily_loan_count(customer_id, offer.product_id) + + logger.info(f"daily_count: {daily_count}, Max: {offer.max_daily_loans}") + + if offer.max_daily_loans is not None and daily_count >= offer.max_daily_loans: + return False + + return True diff --git a/app/api/services/notification_callback.py b/app/api/services/notification_callback.py new file mode 100644 index 0000000..e8f78fe --- /dev/null +++ b/app/api/services/notification_callback.py @@ -0,0 +1,47 @@ +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 +from app.extensions import db +from app.api.helpers.response_helper import ResponseHelper + +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 + + return ResponseHelper.success() + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() diff --git a/app/api/services/offer_analysis.py b/app/api/services/offer_analysis.py new file mode 100644 index 0000000..279de6f --- /dev/null +++ b/app/api/services/offer_analysis.py @@ -0,0 +1,250 @@ +from decimal import Decimal +from app.models import Offer, TransactionOffer +from app.models.loan import Loan +import random +import logging + +from app.config import Config + +RAC_TRUE_CHECK_RULES = Config.rac_true_rules +RAC_FALSE_CHECK_RULES = Config.rac_false_rules +RAC_SALARY_PAYMENTS = Config.rac_salary_payments + +logger = logging.getLogger(__name__) + +class OfferAnalysis: + + @staticmethod + def get_offer(transaction_id, rac_response, validated_data): + customer_id = validated_data.get("customerId") + product_id = validated_data.get("productId") + offer_id = validated_data.get("offerId") + + transaction_offer_id = int(offer_id[5:]) # The last part is int + + logger.info(f"customer_id == *************** : {customer_id}") + logger.info(f"product_id == *************** : {product_id}") + logger.info(f"offer_id == *************** : {offer_id}") + logger.info(f"transaction_offer_id == *************** : {transaction_offer_id}") + + transaction_offer = TransactionOffer.is_valid_transaction_offer(transaction_offer_id, customer_id, product_id) + + if not transaction_offer: + raise ValueError("Invalid Transaction Offer.") + + eligible_amount = transaction_offer.eligible_amount + offer = Offer.is_valid_offer( transaction_offer.offer_id) + + if not offer: + raise ValueError("Invalid Offer.") + original_transaction = transaction_id + + return transaction_offer, offer, eligible_amount, original_transaction + @staticmethod + def _analyze_rack_checks(rack_response, offer): + logger.info(f"This is PayLoad for ANALYSYS ***** : {str(rack_response)}", exc_info=True) + logger.info(f"RACk TRUE RULES {str(RAC_TRUE_CHECK_RULES)}", exc_info=True) + logger.info(f"RACk FALSE RULES {str(RAC_FALSE_CHECK_RULES)}", exc_info=True) + logger.info(f"RACk SALARY PAYMENTS {str(RAC_SALARY_PAYMENTS)}", exc_info=True) + + if not isinstance(rack_response, dict) or not offer : + raise ValueError("Invalid RAC response format.") + + + + failed_true_rules = [] + failed_false_rules = [] + salaries = [] + + # Expects true + for rule in RAC_TRUE_CHECK_RULES: + if not rack_response.get(rule, False): + failed_true_rules.append(rule) + + # Expects false + for rule in RAC_FALSE_CHECK_RULES: + if rack_response.get(rule, True): + failed_false_rules.append(rule) + + + # Salary rules + for key in RAC_SALARY_PAYMENTS: + value = rack_response.get(key) + + + if isinstance(value, Decimal): + # Only use values greater than 0 + if value > 0: + salaries.append(value) + elif isinstance(value, (int, float, str)): + try: + value = Decimal(str(value)) + if value > 0: + salaries.append(value) + except: + logger.warning(f"Could not convert value of {key} to Decimal: {value}") + + + if failed_true_rules or failed_false_rules or not salaries: + logger.warning(f"Failed TRUE rules: {failed_true_rules}") + logger.warning(f"Failed FALSE rules: {failed_false_rules}") + logger.warning("No salary records found in RAC response.") + raise ValueError(f"RAC analysis failed") + + + + logger.info(f"These are the salary amounts ***** : {str(salaries)}", exc_info=True) + + #Least salary in the last 6 months + min_salary = min(salaries) + + # Check consistency rule + consistent_income = rack_response.get("rule7_consistent_salary_amount", False) + + # Determine percentage based on offer tenor + tenor = offer.tenor + + if tenor == 30 and consistent_income: + eligible_amount = min_salary * Decimal("0.5") + logger.info("Applying 50% of least salary in 6 months due to 1-month offer tenor with stable income.") + elif tenor == 90 and consistent_income: + eligible_amount = min_salary * Decimal("0.75") + logger.info("Applying 75% of least salary in 6 months due to 3-months offer tenor with stable income.") + + else: # Income is not consistent + eligible_amount = 0 + logger.info("Applying no percentage on least salary due unstable income.") + + + + logger.info(f"Calculated eligible amount from RAC: {eligible_amount} based on {'stable' if consistent_income else 'unstable'} income.") + + return eligible_amount.quantize(Decimal("1.00")) + + # "racResponse": { + # "accountStatus": true, + # "bvnValidated": true, + # "creditBureauCheck": false, + # "crmsCheck": true, + # "hasLien": false, + # "hasPastDueLoan": false, + # "hasSalaryAccount": true, + # "isWhitelisted": true, + # "noBouncedCheck": true + # }, + # + + ''' + 30 days + Eligibility amount (monthly SOL) - Adoption of 50% of the least salary inflow in the past 6 months + to determine loan eligibility for potential customers. + + 3 months + Adoption of 75% of the least salary inflow in the past 6 months to determine loan eligibility for + potential customers" for customers that have unstable income. 3 months + ''' + # rac_true_rules + + return 0 + + @staticmethod + def decide_offer(transaction_id, rac_check, validated_data, customer_id, rack_checks_response): + eligible_offers = [] + # if we have active offers - we have to feed off it + logger.info(f"**RACK ANALYSIS** {customer_id}") + # Analyze Rack Checks + # new_eligible_amount = OfferAnalysis._analyze_rack_checks(rack_checks_response) #--> We need detail analysis + + # we can now find the origin transactions + # Find the last loan - it will have original_transaction + last_customer_loan = Loan.get_customer_last_loan(customer_id) + # logger.info(f"{last_customer_loan}") + + if last_customer_loan: + original_transaction = last_customer_loan.original_transaction or last_customer_loan.transaction_id + logger.info(f"transaction_id |-| original_transaction === > {transaction_id} {original_transaction}") + original_loan = Loan.get_customer_original_loan(customer_id, original_transaction) + if original_loan is not None: + logger.info(f"original_loan === > {original_loan}") + logger.info(f"loan_offer_id === > {original_loan.offer_id}") + + original_offer_id = str(original_loan.offer_id[:5]) # The last part is str + transaction_offer_id = int(original_loan.offer_id[5:]) # The last part is int + original_transaction_offer = TransactionOffer.is_valid_transaction_offer(transaction_offer_id, customer_id, original_loan.product_id) + + active_loans = Loan.get_active_loans_by_original_transaction(original_transaction) + sum_active_loans = sum(loan.current_loan_amount for loan in active_loans) + logger.info(f"sum_active_loans === > {sum_active_loans}") + real_eligible_amount = original_loan.eligible_amount - sum_active_loans + + if real_eligible_amount < original_transaction_offer.min_amount: + logger.error(f"Max eligible amount ({real_eligible_amount}) is less than the minimum offer amount ({original_transaction_offer.min_amount}).") + raise ValueError("You are not eligible for a loan at this time.") + + transaction_offer = TransactionOffer.create_transaction_offer( + customer_id=customer_id, + transaction_id=transaction_id, + original_transaction=original_transaction, + offer_id=original_offer_id, + min_amount=original_transaction_offer.min_amount, + max_amount=original_transaction_offer.max_amount, + eligible_amount=real_eligible_amount, + product_id=original_loan.product_id, + tenor=original_loan.tenor + ) + + # Visible offer ID: offer_id + padded(transaction_offer.id) + padded_id = str(transaction_offer.id).zfill(6) + public_offer_id = f"{original_offer_id}{padded_id}" + + eligible_offers.append({ + "offerId": public_offer_id, + "product_id": original_transaction_offer.product_id, + "min_amount": original_transaction_offer.min_amount, + "max_amount": round(real_eligible_amount, 2), + "tenor": original_loan.tenor + }) + return eligible_offers + + + offers = Offer.get_all_offers() + + + for offer in offers: + + new_eligible_amount = OfferAnalysis._analyze_rack_checks(rack_checks_response, offer) + + + approved_amount = new_eligible_amount + approved_amount = round(approved_amount, 2) + + if approved_amount < offer.min_amount: + logger.error(f"Max eligible amount ({approved_amount}) is less than the minimum offer amount ({offer.min_amount}).") + raise ValueError("You are not eligible for a loan at this time.") + + + transaction_offer = TransactionOffer.create_transaction_offer( + customer_id=customer_id, + transaction_id=transaction_id, + original_transaction=transaction_id, + offer_id=offer.id, + min_amount=offer.min_amount, + max_amount=offer.max_amount, + eligible_amount=approved_amount, + product_id=offer.product_id, + tenor=offer.tenor + ) + + # Visible offer ID: offer_id + padded(transaction_offer.id) + padded_id = str(transaction_offer.id).zfill(6) + public_offer_id = f"{offer.id}{padded_id}" + + eligible_offers.append({ + "offerId": public_offer_id, + "product_id": offer.product_id, + "min_amount": offer.min_amount, + "max_amount": approved_amount, + "tenor": offer.tenor + }) + + return eligible_offers \ No newline at end of file diff --git a/app/api/services/provide_loan.py b/app/api/services/provide_loan.py new file mode 100644 index 0000000..f09cd31 --- /dev/null +++ b/app/api/services/provide_loan.py @@ -0,0 +1,198 @@ +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.models.customer import Customer +from app.models.loan_charge import LoanCharge +from app.utils.logger import logger +from app.api.schemas.provide_loan import ProvideLoanSchema +from threading import Thread +from app.models import Loan, Offer, Charge , TransactionOffer, RACCheck +from app.api.enums import LoanStatus +from app.extensions import db +from datetime import datetime, timezone +from dateutil.relativedelta import relativedelta +from app.models import LoanRepaymentSchedule +from app.api.services.offer_analysis import OfferAnalysis +from app.api.helpers.response_helper import ResponseHelper + +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: + with db.session.begin(): + 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') + collection_type = validated_data.get('collectionType') + transaction_id = validated_data.get('transactionId') + offer_id = validated_data.get('offerId') + amount = validated_data.get("requestedAmount") + product_id = validated_data.get("productId") + channel = validated_data.get('channel') + + customer = Customer.is_valid_customer(customer_id) + + if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + + rac_response = RACCheck.get_rac_check(customer_id = customer_id, account_id = account_id) + + try: + transaction_offer, offer, eligible_amount, original_transaction = OfferAnalysis.get_offer( + transaction_id=transaction_id, + rac_response=rac_response, + validated_data=validated_data + ) + except ValueError as ve: + logger.error(str(ve)) + return ResponseHelper.error(result_description=str(ve)) + + + if(amount < transaction_offer.min_amount): + return ResponseHelper.error(result_description="The amount is less than the minimum allowed transaction amount.") + elif amount > transaction_offer.max_amount: + return ResponseHelper.error(result_description="The amount is greater than the maximum allowed transaction amount.") + + + # transaction_offer_id = int(offer_id[5:]) # The last part is int + + # transaction_offer = TransactionOffer.is_valid_transaction_offer(transaction_offer_id) + + # if not transaction_offer: + # logger.error(f"Invalid Transaction Offer") + # return jsonify({ + # "message": "Invalid Transaction Offer." + # }), 400 + + # eligible_amount = transaction_offer.eligible_amount + # offer = Offer.is_valid_offer( transaction_offer.offer_id) + + # if not offer: + # logger.error(f"Invalid Offer") + # return jsonify({ + # "message": "Invalid Offer." + # }), 400 + + + # Log Transaction + transaction = ProvideLoanService.log_transaction(validated_data=validated_data) + + if not transaction: + logger.error(f"Failed to log transaction") + return ResponseHelper.error(result_description="Failed to log transaction.") + + + db.session.flush() + + charges = ProvideLoanService.calculate_charges(offer, amount) + upfront_fee = charges["upfront_payment"] + repayment_amount = charges["repayment_amount"] + #installment_amount = charges["installment_amount"] + num_schedules = offer.schedule + + upfront_payment = charges["upfront_payment"] + total_amount = charges["total_amount"] + installment_amount = charges["installment_amount"] + interest = charges["interest"] + management = charges["management"] + insurance = charges["insurance"] + vat = charges["vat"] + + padded_id = str(transaction_id).zfill(12) + loan_ref = f"{padded_id}{channel}{offer.product_id}" + + + # Save the loan details + loan = Loan.create_loan( + customer_id = customer_id, + account_id = account_id, + offer_id = offer_id, + product_id = offer.product_id, + collection_type = collection_type, + transaction_id = validated_data.get('transactionId'), + original_transaction = transaction_offer.original_transaction, + initial_loan_amount = validated_data.get('requestedAmount'), + upfront_fee = upfront_fee, + repayment_amount = repayment_amount, + installment_amount = installment_amount, + eligible_amount=eligible_amount, + status = LoanStatus.ACTIVE, + tenor = offer.tenor, + reference = loan_ref + ) + + if not loan: + logger.error(f"Failed to save loan details") + + return ResponseHelper.error(result_description="Failed to save loan details.") + + db.session.flush() + current_product_id = offer.product_id + schedule = LoanRepaymentSchedule.add_repayment_schedule(loan = loan, num_schedules = num_schedules, transaction_id = transaction_id) + + + if not schedule: + logger.error(f"Failed to create repayment schedule for loan ID {loan.id}") + return ResponseHelper.error(result_description="Failed to generate loan repayment schedule.") + + # charges = Charge.get_offer_charges(offer.id) + + # logger.info(f"{charges}") + + loan_id = loan.id + + loan_charges = LoanCharge.create_charges_for_loan(loan_id = loan_id, transaction_id = transaction_id, referenced_amount = 800, charges = charges) + + + + else: + return ResponseHelper.error(result_description="Invalid Customer or Account") + + + + response_data = { + "requestId": request_id, + "transactionId": transaction_id, + "loanRef": loan_ref, + "customerId": customer_id, + "accountId": account_id, + "msisdn": customer.msisdn + } + + # 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() + + db.session.commit() + return ResponseHelper.success(data=response_data) + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() \ No newline at end of file diff --git a/app/api/services/repayment.py b/app/api/services/repayment.py new file mode 100644 index 0000000..8def45d --- /dev/null +++ b/app/api/services/repayment.py @@ -0,0 +1,103 @@ +from flask import request, jsonify +from marshmallow import ValidationError +from app.api.enums.loan_status import LoanStatus +from app.api.helpers.response_helper import ResponseHelper +from app.models import Repayment +from app.models.customer import Customer +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 +from app.extensions import db + +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: + with db.session.begin(): + validated_data = RepaymentService.validate_data(data, RepaymentSchema()) + + customer_id = validated_data.get('customerId') + request_id = validated_data.get('requestId') + loan_id = validated_data.get('debtId') + account_id = validated_data.get('accountId') + loan_ref = validated_data.get('loanRef') + # customer = Customer.get_customer_with_loan_list(customer_id) + transaction_id = validated_data.get('transactionId') + initiated_by = validated_data.get('initiatedBy') + logger.error(f"HERE 0002a **** ") + if(RepaymentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)): + logger.error(f"HERE 0001a **** ") + # Check loan exists + loan = Loan.get_customer_loan(loan_id = loan_id, customer_id = customer_id) + + # Save the repayment details + repayment = Repayment.create_repayment( + customer_id = customer_id, + loan = loan, + transaction_id = transaction_id + ) + + if not repayment: + logger.error(f"Failed to save repayment details") + return ResponseHelper.error(result_description="Failed to save repayment details.") + + #Update Loan status + Loan.update_status(loan_id = loan_id, status = LoanStatus.START_REPAY) # repay started bu user + transaction = RepaymentService.log_transaction(validated_data = validated_data) + + if not transaction: + logger.error(f"Failed to log transaction") + return ResponseHelper.error(result_description="Failed to log transaction.") + else: + logger.error(f"Invalid Customer or AccountID {account_id} to CustomerID{customer_id} ") + return ResponseHelper.error(result_description="Invalid Customer or Account") + + # Simulated processing logic + # TODO start using repayment_id instead if id or Id + response_data = { + "Id": repayment.id, + "repayment_id": repayment.id, + "initiated_by": repayment.initiated_by, + "transactionId": transaction_id, + "customerId": customer_id, + "productId": loan.product_id, + "loanRef": loan_ref, + "debtId": loan_id + } + + # Call Kafka in a background thread + thread = Thread(target=RepaymentService.async_send_to_kafka, args=(response_data, request_id, "LOAN_REPAYMENT")) + thread.start() + + db.session.commit() + return ResponseHelper.success(data=response_data) + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() diff --git a/app/api/services/select_offer.py b/app/api/services/select_offer.py new file mode 100644 index 0000000..e2908e3 --- /dev/null +++ b/app/api/services/select_offer.py @@ -0,0 +1,168 @@ +from flask import request, jsonify +from marshmallow import ValidationError +from app.api.helpers.response_helper import ResponseHelper +from app.api.services.base_service import BaseService +from app.api.enums import TransactionType +from app.models.transaction_offers import TransactionOffer +from app.utils.logger import logger +from app.api.schemas.select_offer import SelectOfferSchema +from app.extensions import db +from app.models import Offer +from datetime import date +from dateutil.relativedelta import relativedelta + +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: + with db.session.begin(): + validated_data = SelectOfferService.validate_data( + data, SelectOfferSchema() + ) + account_id = validated_data.get("accountId") + customer_id = validated_data.get("customerId") + amount = validated_data.get("requestedAmount") + product_id = validated_data.get("productId") + transaction_offer_id = validated_data.get("offerId") + transaction_id = validated_data.get("transactionId") + request_id = validated_data.get("requestId") + + offer_id = int(transaction_offer_id[5:]) # The last part is int + + #"offerId": "SAL30001129", + + 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 ResponseHelper.error(result_description="Failed to log transaction.") + else: + return ResponseHelper.error(result_description="Invalid Customer or Account") + + # Get the offer by product ID + offer = Offer.get_offer_by_product_id(product_id) + + transaction_offer = TransactionOffer.get_transaction_offer(transaction_offer_id=offer_id) + + if not transaction_offer: + logger.error(f"offer {offer_id} not found for customer {customer_id} and transaction {transaction_id}.") + return ResponseHelper.error(result_description="Offer not found.") + + db.session.flush() + + if amount < transaction_offer.min_amount: + logger.error(f"The amount {amount} is less than the minimum allowed offer amount {transaction_offer.min_amount}.") + return ResponseHelper.error(result_description="The amount is less than the minimum allowed offer amount.") + elif amount > transaction_offer.eligible_amount: + logger.error(f"The amount {amount} is greater than the eligible offer amount {transaction_offer.eligible_amount}.") + return ResponseHelper.error(result_description="The amount is greater than the eligible offer amount.") + + + + charges = SelectOfferService.calculate_charges(offer, amount) + upfront_payment = charges["upfront_payment"] + total_amount = charges["total_amount"] + installment_amount = charges["installment_amount"] + interest = charges["interest"] + management = charges["management"] + insurance = charges["insurance"] + vat = charges["vat"] + repayment_amount = charges["repayment_amount"] + interest_amount = charges["interest_amount"] + + + # Calculate the repayment dates + tenor = offer.tenor + start_date = date.today() + + # Convert tenor to months + months = offer.schedule # tenor // 30 + + recommended_repayment_dates = [ + (start_date + relativedelta(months=i + 1)).isoformat() + for i in range(months) + ] + + + + offers = [ + { + "offerId": transaction_offer_id, + "productId": product_id, + "amount": amount, + "upfrontPayment": upfront_payment, + "interestRate": offer.interest_rate, + "interestFee": interest_amount, + "managementRate": offer.management_rate, + "managementFee": management["fee"], + "insuranceRate": offer.insurance_rate, + "insuranceFee": insurance["fee"], + "VATRate": offer.vat_rate, + "VATAmount": vat["fee"], + "recommendedRepaymentDates": recommended_repayment_dates, + "repaymentAmount": repayment_amount, + "installmentAmount": installment_amount, + "totalRepaymentAmount": total_amount, + } + ] + + # "offerId": offer.id, + # "productId": product_id, + # "amount": amount, + # "upfrontPayment": upfront_payment, + # "interestRate": interest["rate"], + # "managementRate": management["rate"], + # "managementFee": management["fee"], + # "insuranceRate": insurance["rate"], + # "insuranceFee": insurance["fee"], + # "VATRate": vat["rate"], + # "VATAmount": vat["fee"], + # "recommendedRepaymentDates": recommended_repayment_dates, + # "installmentAmount": installment_amount, + # "totalRepaymentAmount": total_amount, + # + # Business logic - selecting an offer + response_data = { + "outstandingDebtAmount": 0, + "requestId": request_id, + "transactionId": transaction_id, + "customerId": customer_id, + "accountId": account_id, + "loan": offers, + } + + db.session.commit() + return ResponseHelper.success(data=response_data) + + except ValidationError as err: + + logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.unprocessable_entity(result_description="Validation exception") + + except ValueError as err: + logger.error(f"{getattr(err, 'messages', str(err))}") + db.session.rollback() + return ResponseHelper.error(result_description=str(err)) + + except Exception as e: + logger.error(f"An error occurred: {str(e)}", exc_info=True) + db.session.rollback() + return ResponseHelper.internal_server_error() + \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a589cb3 --- /dev/null +++ b/app/config.py @@ -0,0 +1,86 @@ +import os +from datetime import timedelta + +class Config: + """Base configuration for Flask app""" + JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") + + SWAGGER_URL = os.getenv("SWAGGER_URL", "/documentation") + API_URL = os.getenv("API_URL", "/swagger.json") + + DEBUG = True + BASIC_AUTH_USERNAME = os.environ.get("BASIC_AUTH_USERNAME", "user") + BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password") + + # Database Configuration + 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", 5432) + DATABASE_NAME = os.environ.get("DATABASE_NAME") + + # Database Connection + SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}" + SQLALCHEMY_TRACK_MODIFICATIONS = False + + + 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_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085") + + # SIMBRELLA_ENDPOINT_RAC_CHECKS = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS", "RACCheck") + VALID_APP_ID = os.getenv("SIMBRELLA_APP_ID", "app1") + VALID_API_KEY = os.getenv("SIMBRELLA_API_KEY", "test-api-key-12345") + SIMBRELLA_BASE_URL = os.getenv("SIMBRELLA_BASE_URL", "http://127.0.0.1:6337") + SIMBRELLA_ENDPOINT_RAC_CHECKS = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS","api/rac-check") + + RAC_RESULT_accountStatus = os.environ.get("RAC_RESULT_accountStatus", "true") + RAC_RESULT_bvnValidated = os.environ.get("RAC_RESULT_bvnValidated", "true") + RAC_RESULT_creditBureauCheck = os.environ.get("RAC_RESULT_creditBureauCheck", "false") + RAC_RESULT_crmsCheck = os.environ.get("RAC_RESULT_crmsCheck", "true") + RAC_RESULT_hasLien = os.environ.get("RAC_RESULT_hasLien", "false") + RAC_RESULT_hasPastDueLoan = os.environ.get("RAC_RESULT_hasPastDueLoan", "false") + RAC_RESULT_hasSalaryAccount = os.environ.get("RAC_RESULT_hasSalaryAccount", "true") + RAC_RESULT_isWhitelisted = os.environ.get("RAC_RESULT_isWhitelisted", "true") + RAC_RESULT_noBouncedCheck = os.environ.get("RAC_RESULT_noBouncedCheck", "true") + + rac_true_rules = [ + "rule1_45day_sal", + "rule2_2m_sal", + "rule3_no_bounced_check", + "rule4_current_loan_payments", + "rule5_no_past_due_fadv_loan", + "rule6_no_past_due_other_loan", + "rule7_consistent_salary_amount", + "rule8_whitelisted", + "rule9_regular_account", + "rule10_bvn_validation", + "rule11_CRC_no_delinquency", + "rule12_CRMS_no_delinquency", + "rule13_BVN_ignore", + "rule14_no_lien", + "rule15_null_ignore" + ] + + rac_false_rules = [ + + ] + + rac_salary_payments = [ + "salarypaymenT_1", + "salarypaymenT_2", + "salarypaymenT_3", + "salarypaymenT_4", + "salarypaymenT_5", + "salarypaymenT_6" + ] + + + + +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..567b212 --- /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 ResponseHelper.error(result_description=e.description, result_code=e.code ) + + @app.errorhandler(405) + def method_not_allowed(error): + return ResponseHelper.method_not_allowed() + + @app.errorhandler(404) + def not_found(error): + return ResponseHelper.not_found() + + @app.errorhandler(400) + def bad_request(error): + return ResponseHelper.bad_request() + + @app.errorhandler(415) + def unsupported_media_type(error): + return ResponseHelper.error(result_description="Unsupported Media Type", result_code="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..3d980b1 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,18 @@ +from .customer import Customer +from .account import Account +from .loan import Loan +from .transaction import Transaction +from .repayment import Repayment +from .loan_charge import LoanCharge +from .offer import Offer +from .charge import Charge +from .rac_checks import RACCheck +from .loan_repayment_schedule import LoanRepaymentSchedule +from .transaction_offers import TransactionOffer +from .repayments_data import RepaymentsData +from .salary import Salary +from .members import Members + + + +__all__ = ['Members','Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck', 'LoanRepaymentSchedule', 'TransactionOffer', 'RepaymentsData', 'Salary'] \ No newline at end of file diff --git a/app/models/account.py b/app/models/account.py new file mode 100644 index 0000000..30774e7 --- /dev/null +++ b/app/models/account.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import relationship +from app.extensions import db +from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql import func + +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(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + 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, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + try: + db.session.add(account) + except IntegrityError as err: + 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 account + + def __repr__(self): + return f'' + \ No newline at end of file diff --git a/app/models/charge.py b/app/models/charge.py new file mode 100644 index 0000000..91fa42c --- /dev/null +++ b/app/models/charge.py @@ -0,0 +1,97 @@ +from datetime import datetime, timezone, timedelta +from app.extensions import db +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + + +class Charge(db.Model): + __tablename__ = 'charges' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + offer_id = db.Column(db.String(50), nullable=False) + code = db.Column(db.String(50), nullable=False) + percent = db.Column(db.Float, default=0.0) + description = db.Column(db.Text, nullable=True) + due = db.Column(db.Integer, nullable=False) + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + offer = relationship( + "Offer", + primaryjoin="Charge.offer_id == Offer.id", + foreign_keys=[offer_id], + back_populates="charges", + ) + + @classmethod + def add_charges(cls, offer_id, charges): + """ + Add charges to an offer. + + Args: + offer_id (int): ID of the offer to associate charges with. + charges (list): A list of dictionaries with keys: + code (str), amount (float), percent (float), description (str), due (int) + """ + if not charges or not isinstance(charges, list): + raise ValueError("Charges must be a non-empty list of dictionaries") + + if offer_id is None: + raise ValueError("offer_id cannot be None") + + offer_charges = [] + + + for charge in charges: + code = charge.get("code") + percent = charge.get("percent", 0.0) + description = charge.get("description", "") + due_days = charge.get("due", 0) + + existing = cls.query.filter_by(offer_id=offer_id, code=code).first() + + if existing: + continue + + charge_obj = cls( + offer_id = offer_id, + code = code, + percent = percent, + description = description, + due = due_days, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.session.add(charge_obj) + offer_charges.append(charge_obj) + + return offer_charges + + @classmethod + def get_offer_charges(cls, offer_id): + """ + Get all charges for a particular offer as a dictionary + + Args: + offer_id (str): The offer ID. + """ + if not offer_id: + raise ValueError("offer_id not found") + + charges = cls.query.filter_by(offer_id=offer_id).all() + + return charges + + + def to_dict(self): + return { + 'id': self.id, + 'offerId': self.offer_id, + 'code': self.code, + 'percent': self.percent, + 'description': self.description, + 'due': self.due + } + + def __repr__(self): + return f"" diff --git a/app/models/customer.py b/app/models/customer.py new file mode 100644 index 0000000..1469dad --- /dev/null +++ b/app/models/customer.py @@ -0,0 +1,90 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import relationship +# +# from app.api.services.offer_analysis import logger +from app.extensions import db +from app.models.account import Account +from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql import func +# from app.utils.logger import logger + +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(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + accounts = relationship( + "Account", + primaryjoin="Customer.id == Account.customer_id", + foreign_keys="Account.customer_id", + back_populates="customer", + ) + + loans = relationship( + "Loan", + primaryjoin="Customer.id == Loan.customer_id", + foreign_keys="Loan.customer_id", + back_populates="customer", + ) + + transaction_offers = relationship( + "TransactionOffer", + primaryjoin="Customer.id == TransactionOffer.customer_id", + foreign_keys="TransactionOffer.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 customer + + @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") + elif Account.query.filter_by(id=account_id).first(): + raise ValueError("Account already exists") + elif cls.query.filter_by(msisdn=msisdn).first(): + raise ValueError("msisdn already exists") + + # Create the customer + customer = cls( + id=id, + msisdn=msisdn, + country_code=country_code, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + try: + db.session.add(customer) + + # Create an associated account + account = Account.create_account( + id=account_id, + customer_id=id, + account_type=account_type + ) + + except IntegrityError as err: + raise ValueError(f"Database integrity error: {err}") + return customer + + @classmethod + def get_customer_with_loan_list(cls, customer_id): + """ + Get customer by ID. + """ + customer = cls.query.filter_by(id=customer_id).first() + + if not customer: + raise ValueError(f"Customer does not exist") + 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..6c22a51 --- /dev/null +++ b/app/models/loan.py @@ -0,0 +1,261 @@ +from datetime import datetime, timezone, timedelta +from itertools import product +from app.extensions import db +from app.models.customer import Customer +from app.models.account import Account +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import relationship +from dateutil.relativedelta import relativedelta +from datetime import timedelta +import logging +from sqlalchemy import and_, or_, not_ +from sqlalchemy.sql import func + +logger = logging.getLogger(__name__) + + +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) + transaction_id = db.Column(db.String(50), nullable=True) + original_transaction = db.Column(db.String(50), nullable=True) + account_id = db.Column(db.String(50), nullable=False) + offer_id = db.Column(db.String(20), nullable=False) + product_id = db.Column(db.String(20), nullable=True) + collection_type = db.Column(db.String(20), nullable=True) + current_loan_amount = db.Column(db.Float, nullable=True) + initial_loan_amount = db.Column(db.Float, nullable=False) + default_penalty_fee = db.Column(db.Float, default=0) + continuous_fee = db.Column(db.Float, default=0) + upfront_fee = db.Column(db.Float, nullable=True, default=0.0) + repayment_amount = db.Column(db.Float, nullable=True, default=0.0) + installment_amount = db.Column(db.Float, nullable=True, default=0.0) + status = db.Column(db.String(20), default='pending') + tenor = db.Column(db.Integer, nullable=True) + due_date = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + eligible_amount = db.Column(db.Float, nullable=True, default=0.0) + disburse_date = db.Column(db.DateTime, nullable=True) + disburse_verify = db.Column(db.DateTime, nullable=True) + reference = db.Column(db.String(50), nullable=True) + disburse_result = db.Column(db.String(10), nullable=True) + disburse_description = db.Column(db.String(100), nullable=True) + verify_result = db.Column(db.String(10), nullable=True) + verify_description = db.Column(db.String(100), nullable=True) + + customer = relationship( + "Customer", + primaryjoin="Customer.id == Loan.customer_id", + foreign_keys=[customer_id], + back_populates="loans", + ) + + loan_charges = relationship( + "LoanCharge", + primaryjoin="LoanCharge.loan_id == Loan.id", + foreign_keys="LoanCharge.loan_id", + back_populates="loan", + ) + + loan_repayment_schedules = relationship( + "LoanRepaymentSchedule", + primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id", + foreign_keys="LoanRepaymentSchedule.loan_id", + back_populates="loan", + ) + + + @classmethod + def create_loan( + cls, + customer_id, + account_id, + offer_id, + product_id, + initial_loan_amount, + collection_type, + transaction_id, + original_transaction, + upfront_fee, + repayment_amount, + installment_amount, + tenor, + eligible_amount, + reference, + status = "pending", + ): + # Check if customer exists + customer = Customer.is_valid_customer(customer_id) + if not customer: + raise ValueError("Customer does not exist") + + now = datetime.now(timezone.utc) + due_date = now + timedelta(days=tenor) + + # Create and save the loan + loan = cls( + customer_id = customer_id, + account_id = account_id, + offer_id = offer_id, + product_id = product_id, + collection_type = collection_type, + transaction_id = transaction_id, + original_transaction = original_transaction, + initial_loan_amount = initial_loan_amount, + current_loan_amount = initial_loan_amount, + upfront_fee = upfront_fee, + repayment_amount = repayment_amount, + installment_amount = installment_amount, + due_date=due_date, + tenor = tenor, + status = status, + eligible_amount =eligible_amount, + reference = reference, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + try: + db.session.add(loan) + except IntegrityError as err: + 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 by loan_id. + """ + 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 get_customer_original_loan(cls, customer_id, original_transaction): + """ + Get customer's original loan offer. + """ + original_loan = cls.query.filter(and_( cls.customer_id ==customer_id, cls.original_transaction==original_transaction, cls.transaction_id==original_transaction )).first() + if not original_loan: + return None + + logger.info(f" get_customer_original_loan ==>>>> {original_loan}") + return original_loan + + @classmethod + def get_customer_last_loan(cls, customer_id): + """ + Get customer's active loans. + """ + logger.info(f"get_customer_last_loan [customer_id] ==>>>> {customer_id}") + # loan = cls.query.filter_by( cls.customer_id == customer_id).first() + loan = cls.query.filter(and_( cls.customer_id ==customer_id, cls.status=='active')).first() + + if not loan: + return None + # loan = { + # "original_transaction":"", + # "eligible_amount": 0, + # "loan_amount": 0, + # "customer_id": customer_id, + # "transaction_id": "", + # "resultDescription": "No Active Loan" + # } + logger.info(f" get_customer_last_loan ==>>>> {loan}") + return loan + + @classmethod + def get_active_loans_by_original_transaction(cls, original_transaction_id): + """ + Get all active loans with the same original_transaction ID. + """ + + active_loans = cls.query.filter_by( + original_transaction=original_transaction_id, + # status='active' + ).all() + + return active_loans + + + @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 + + + @classmethod + def get_daily_loan_count(cls, customer_id, product_id): + """ + Returns the count of loans created today for a customer. + """ + + start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + end_of_day = start_of_day + timedelta(days=1) + + return cls.query.filter_by( + customer_id=customer_id, + product_id=product_id, + ).filter( + cls.created_at >= start_of_day, + cls.created_at < end_of_day + ).count() + + + def to_dict(self): + """ + Convert the Loan object to a dictionary format for JSON serialization. + """ + return { + 'debtId': self.id, + 'transactionId': self.transaction_id, + 'loanRef': self.reference, + 'productId': self.product_id, + 'initialLoanAmount': self.initial_loan_amount, + 'currentLoanAmount': self.current_loan_amount, + 'defaultPenaltyFee': self.default_penalty_fee, + 'continuousFee': self.continuous_fee, + 'collectionType': self.collection_type, + 'upfrontFee': self.upfront_fee, + 'repaymentAmount': self.repayment_amount, + 'installmentAmount': self.installment_amount, + 'status': self.status, + 'tenor': self.tenor, + 'dueDate': self.due_date.isoformat() if self.due_date else None, + 'loanDate': self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/loan_charge.py b/app/models/loan_charge.py new file mode 100644 index 0000000..ce9ca22 --- /dev/null +++ b/app/models/loan_charge.py @@ -0,0 +1,91 @@ +from datetime import datetime, timezone, timedelta +from app.extensions import db +from sqlalchemy.orm import relationship +from app.utils.logger import logger +from sqlalchemy.sql import func + + +class LoanCharge(db.Model): + __tablename__ = 'loan_charges' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + loan_id = db.Column(db.Integer, nullable=False) + transaction_id = db.Column(db.String(50), nullable=True) + code = db.Column(db.String(50), nullable=False) + amount = db.Column(db.Float, default=0.0) + percent = db.Column(db.Float, default=0.0) + description = db.Column(db.Text, nullable=True) + due = db.Column(db.Integer, nullable=False) + due_date = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + loan = relationship( + "Loan", + primaryjoin="LoanCharge.loan_id == Loan.id", + foreign_keys=[loan_id], + back_populates="loan_charges", + ) + + @classmethod + def create_charges_for_loan(cls, loan_id, transaction_id, charges, referenced_amount = 0.0): + """ + Create loan charges for a given loan. + + Args: + loan_id (int): ID of the loan to associate charges with. + charges (list): A list of dictionaries with keys: + code (str), amount (float), percent (float), description (str), due (int) + """ + # if not charges or not isinstance(charges, list): + # raise ValueError("Charges must be a non-empty list of dictionaries") + + if loan_id is None: + raise ValueError("loan_id cannot be None") + + loan_charges = [] + now = datetime.now(timezone.utc) + + + subset_keys = ['interest', 'management', 'insurance', 'vat'] + for item in subset_keys: + charge = charges[item] + due_days = charge['due_days'] # getattr(charge, "due_days", 0) + amount = charge['fee'] # getattr(charge, "fee", 0.0) + percent = charge['rate'] # getattr(charge, "rate", 0.0) + code = charge['code'] # getattr(charge, "code","") + description = charge['description'] # getattr(charge, "description", "") + + charge_obj = cls( + loan_id = loan_id, + transaction_id = transaction_id, + code = code, + amount = round(amount, 2), + percent = percent, + description = description, + due = due_days, + due_date = now + timedelta(days=due_days), + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.session.add(charge_obj) + loan_charges.append(charge_obj) + + return loan_charges + + + + def to_dict(self): + return { + 'id': self.id, + 'loanId': self.loan_id, + 'transactionId': self.transaction_id, + 'code': self.code, + 'amount': self.amount, + 'percent': self.percent, + 'description': self.description, + 'due': self.due, + } + + def __repr__(self): + return f"" diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py new file mode 100644 index 0000000..948b66a --- /dev/null +++ b/app/models/loan_repayment_schedule.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.orm import relationship +from dateutil.relativedelta import relativedelta +from sqlalchemy.sql import func + +class LoanRepaymentSchedule(db.Model): + __tablename__ = 'loan_repayment_schedules' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + loan_id = db.Column(db.Integer, nullable=False) + transaction_id = db.Column(db.String(50), nullable=True) + product_id = db.Column(db.String(20), nullable=True) + installment_number = db.Column(db.Integer, nullable=False) + due_date = db.Column(db.DateTime, nullable=False) + installment_amount= db.Column(db.Float, default=0.0) + total_repayment_amount = db.Column(db.Float, default=0.0) + paid = db.Column(db.Boolean, default=False) + paid_at = db.Column(db.DateTime, nullable=True) + + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + loan = relationship( + "Loan", + primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id", + foreign_keys=[loan_id], + back_populates="loan_repayment_schedules", + ) + + + @classmethod + def add_repayment_schedule(cls, loan, num_schedules, transaction_id): + """ + Add repayment schedules for a given loan. + """ + now = datetime.now(timezone.utc) + schedules = [] + + for i in range(num_schedules): + due_date = now + relativedelta(months=i + 1) + schedule = LoanRepaymentSchedule( + loan_id=loan.id, + installment_number=i + 1, + due_date=due_date, + total_repayment_amount = round(loan.repayment_amount, 2), + installment_amount=round(loan.installment_amount, 2), + product_id = loan.product_id, + transaction_id = transaction_id, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.session.add(schedule) + schedules.append(schedule) + + return schedules + + def to_dict(self): + return { + 'id': self.id, + 'loanId': self.loan_id, + 'installmentNumber': self.installment_number, + 'dueDate': self.due_date.isoformat(), + 'principalAmount': self.principal_amount, + 'interestAmount': self.interest_amount, + 'totalInstallment': self.total_installment, + 'paid': self.paid, + 'paidAt': self.paid_at.isoformat() if self.paid_at else None + } + + def __repr__(self): + return f'' diff --git a/app/models/members.py b/app/models/members.py new file mode 100644 index 0000000..2be4047 --- /dev/null +++ b/app/models/members.py @@ -0,0 +1,51 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.sql import func + + +class Members(db.Model): + __tablename__ = 'members' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + uid = db.Column(db.String(150), nullable=False) + username = db.Column(db.String(25), nullable=False) + password = db.Column(db.String(100), nullable=True) + loc = db.Column(db.String(20), nullable=True) + status = db.Column(db.Integer, default=1) + added = db.Column(db.DateTime(timezone=False), server_default=func.now()) + updated = db.Column(db.DateTime(timezone=False), server_default=func.now(), onupdate=func.now()) + email = db.Column(db.String(100), nullable=False) + account_name = db.Column(db.String(100), nullable=True) + firstname = db.Column(db.String(25), nullable=False) + lastname = db.Column(db.String(100), nullable=True) + + def to_dict(self): + return { + "id": self.id, + "uid": self.uid, + "username": self.account_id, + "account_id": self.username, + "password": self.password, + "loc": self.loc, + "status": self.status, + "added": self.added.isoformat() if self.added else None, + "updated": self.updated.isoformat() if self.updated else None, + "email": self.email, + "account_name": self.account_name, + "firstname": self.firstname, + "lastname": self.lastname + } + + def __repr__(self): + return f'' + + @classmethod + def get_member_by_username(cls, username): + """ + Return an offer by its ID. + """ + member = cls.query.filter_by(username=str(username)).first() + + if not member: + raise ValueError(f"Username = {username} not found") + return member \ No newline at end of file diff --git a/app/models/offer.py b/app/models/offer.py new file mode 100644 index 0000000..a3250ae --- /dev/null +++ b/app/models/offer.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +from app.extensions import db +from app.models.charge import Charge +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +class Offer(db.Model): + __tablename__ = 'offers' + + id = db.Column(db.String, 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) + schedule = db.Column(db.Integer, nullable=True) + interest_rate = db.Column(db.Float, default=3.0) + management_rate = db.Column(db.Float, default=1.0) + insurance_rate = db.Column(db.Float, default=1.0) + vat_rate = db.Column(db.Float, default=7.5) + list_order = db.Column(db.Integer, nullable=True) + max_daily_loans = db.Column(db.Integer, nullable=True) + max_active_loans = db.Column(db.Integer, nullable=True) + max_life_loans = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + charges = relationship( + "Charge", + primaryjoin="Offer.id == Charge.offer_id", + foreign_keys="Charge.offer_id", + back_populates="offer", + ) + + @classmethod + def get_all_offers(cls): + """ + Return all offers in dictionary format. + """ + offers = cls.query.all() + + if not offers: + raise ValueError(f"No available offers") + return offers + + @classmethod + def is_valid_offer(cls, offer_id): + offer = cls.query.filter_by(id=str(offer_id)).first() + + + if not offer: + return False + return offer + + @classmethod + def get_offer_by_id(cls, offer_id): + """ + Return an offer by its ID. + """ + offer = cls.query.filter_by(id=str(offer_id)).first() + + if not offer: + raise ValueError(f"Offer with ID {offer_id} not found") + return offer + + @classmethod + def get_offer_by_product_id(cls, product_id): + """ + Return an offer by its product ID. + """ + offer = cls.query.filter_by(product_id=str(product_id)).first() + + if not offer: + raise ValueError(f"Offer with Product ID {product_id} not found") + return offer + + def to_dict(self): + return { + "offerId": self.id, + "productId": self.product_id, + "minAmount": self.min_amount, + "maxAmount": self.max_amount, + "tenor": self.tenor, + "interest_rate": self.interest_rate, + "management_rate": self.management_rate, + "insurance_rate": self.insurance_rate, + "vat_rate": self.vat_rate, + "maxDailyLoans": self.max_daily_loans, + "maxActiveLoans": self.max_active_loans, + "maxLifeLoans": self.max_life_loans + + } + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/rac_checks.py b/app/models/rac_checks.py new file mode 100644 index 0000000..a4d56b5 --- /dev/null +++ b/app/models/rac_checks.py @@ -0,0 +1,75 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.orm import relationship +from sqlalchemy.exc import IntegrityError +from uuid import uuid4 +from sqlalchemy.types import JSON +from sqlalchemy.sql import func + +class RACCheck(db.Model): + __tablename__ = 'rac_checks' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + transaction_id = db.Column(db.String(50), nullable=False) + customer_id = db.Column(db.String, nullable=False) + account_id = db.Column(db.String, nullable=False) + rac_response = db.Column(db.JSON, nullable=False) + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + @classmethod + def add_rac_check(cls, customer_id, account_id, transaction_id, data = None): + + + # Save the response + rac_check = cls( + customer_id = customer_id, + account_id = account_id, + transaction_id = transaction_id, + rac_response = data, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + try: + db.session.add(rac_check) + except IntegrityError as err: + raise ValueError(f"Database integrity error: {err}") + return rac_check + + + @classmethod + def get_all_rac_checks(cls): + """ + Return all RAC checks in dictionary format. + """ + rac_checks = cls.query.all() + + if not rac_checks: + return None + return rac_checks + + @classmethod + def get_rac_check(cls, customer_id, account_id): + """ + Return a RAC check by its ID. + """ + rac_check = cls.query.filter_by( customer_id = customer_id, + account_id = account_id,).first() + + if not rac_check: + raise ValueError(f"RAC Check for customer not found") + return rac_check + + def to_dict(self): + return { + "id": str(self.id), + "transactionId": str(self.transaction_id), + "customerId": self.customer_id, + "accountId": self.account_id, + "racResponse": self.rac_response, + "createdAt": self.created_at.isoformat(), + "updatedAt": self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self): + return f'' diff --git a/app/models/repayment.py b/app/models/repayment.py new file mode 100644 index 0000000..cc5f87a --- /dev/null +++ b/app/models/repayment.py @@ -0,0 +1,80 @@ +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 +from sqlalchemy.sql import func + + +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(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + transaction_id = db.Column(db.String(50), nullable=True) + # repay_date = db.Column(db.DateTime, default=datetime.now(timezone.utc)) + repay_date = db.Column(db.DateTime, nullable=True) + repay_result = db.Column(db.String(10), nullable=True) + repay_description = db.Column(db.String(100), nullable=True) + # verify_date = db.Column(db.DateTime, default=datetime.now(timezone.utc)) + verify_date = db.Column(db.DateTime, nullable=True) + verify_result = db.Column(db.String(10), nullable=True) + verify_description = db.Column(db.String(100), nullable=True) + initiated_by = db.Column(db.String(50), nullable=True) + salary_amount = db.Column(db.Float, nullable=True, default=0.0) + + @classmethod + def create_repayment(cls, customer_id, loan, transaction_id): + + # Check that the loan is active + if loan.status not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY]: + raise ValueError(f"Repayment cannot be processed. Loan status: ({loan.status})") + + + repayment = cls( + customer_id=customer_id, + loan_id=loan.id, + product_id=loan.product_id, + transaction_id = transaction_id, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + initiated_by='USER_INITIATED' + ) + + try: + db.session.add(repayment) + except IntegrityError as err: + raise ValueError(f"Database integrity error: {err}") + + return repayment + + def to_dict(self): + return { + "id": self.id, + "loan_id": self.loan_id, + "customer_id": self.customer_id, + "product_id": self.product_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "transaction_id": self.transaction_id, + "repay_date": self.repay_date.isoformat() if self.repay_date else None, + "repay_result": self.repay_result, + "repay_description": self.repay_description, + "verify_date": self.verify_date.isoformat() if self.verify_date else None, + "verify_result": self.verify_result, + "verify_description": self.verify_description, + "initiated_by": self.initiated_by, + "salary_amount": self.salary_amount + } + + def __repr__(self): + return f'' diff --git a/app/models/repayments_data.py b/app/models/repayments_data.py new file mode 100644 index 0000000..2e58551 --- /dev/null +++ b/app/models/repayments_data.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from app.extensions import db + + +class RepaymentsData(db.Model): + __tablename__ = "repayments_data" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + transaction_id = db.Column(db.String(50), nullable=False) + fbn_transaction_id = db.Column(db.String(50), nullable=True) + customer_id = db.Column(db.String(50), nullable=True) + account_id = db.Column(db.String(50), nullable=True) + repayment_amount = db.Column(db.Float, nullable=True, default=0.0) + amount_collected = db.Column(db.Float, nullable=True, default=0.0) + added_date = db.Column(db.DateTime(timezone=True), default=datetime.now(timezone.utc), nullable=False) + response_code = db.Column(db.String(10), nullable=True) + response_descr = db.Column(db.String(255), nullable=True) + balance = db.Column(db.Float, nullable=True, default=0.0) + + def to_dict(self): + return { + "id": self.id, + "transaction_id": self.transaction_id, + "fbn_transaction_id": self.fbn_transaction_id, + "customer_id": self.customer_id, + "account_id": self.account_id, + "repayment_amount": self.repayment_amount, + "amount_collected": self.amount_collected, + "added_date": self.added_date.isoformat() if self.added_date else None, + "response_code": self.response_code, + "response_descr": self.response_descr, + "balance": self.balance, + } + + def __repr__(self): + return f"" diff --git a/app/models/salary.py b/app/models/salary.py new file mode 100644 index 0000000..ab145d0 --- /dev/null +++ b/app/models/salary.py @@ -0,0 +1,31 @@ +from datetime import datetime, timezone +from app.extensions import db +from sqlalchemy.sql import func + +class Salary(db.Model): + __tablename__ = 'salaries' + + 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=True) + status = db.Column(db.String(20), default='active') + amount = db.Column(db.Float, nullable=False, default=0.0) + salary_date = db.Column(db.DateTime(timezone=False), server_default=func.now()) + created_at = db.Column(db.DateTime(timezone=False), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=False), server_default=func.now(), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "customer_id": self.customer_id, + "account_id": self.account_id, + "status": self.status, + "amount": self.amount, + "salary_date": self.salary_date.isoformat() if self.salary_date else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self): + return f'' + \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..5a6db18 --- /dev/null +++ b/app/models/transaction.py @@ -0,0 +1,57 @@ +from datetime import datetime, timezone +from app.extensions import db +from app.models import account +from sqlalchemy.exc import IntegrityError +from sqlalchemy import and_, or_, not_ +from sqlalchemy.sql import func + +class Transaction(db.Model): + __tablename__ = 'transactions' + id = db.Column( + db.Integer, + primary_key=True, + autoincrement=True, + ) + transaction_id = db.Column(db.String(50), nullable=False) + account_id = db.Column(db.String(50), nullable=True) + customer_id = db.Column(db.String(50), nullable=True) + type = db.Column(db.String(50), nullable=False) + channel = db.Column(db.String(50), nullable=False) + phone_number = db.Column(db.String(50), nullable=True) + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + def __repr__(self): + return f'' + + @classmethod + def create_transaction(cls, transaction_id, account_id, customer_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, + customer_id = customer_id, + account_id = account_id, + type = type, + channel = channel, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + try: + db.session.add(transaction) + except IntegrityError as err: + 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/models/transaction_offers.py b/app/models/transaction_offers.py new file mode 100644 index 0000000..b0e7508 --- /dev/null +++ b/app/models/transaction_offers.py @@ -0,0 +1,115 @@ +from datetime import datetime, timezone, timedelta +from app.api.enums.loan_status import LoanStatus +from app.extensions import db +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import logging +logger = logging.getLogger(__name__) + + + +class TransactionOffer(db.Model): + __tablename__ = 'transaction_offers' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + customer_id = db.Column(db.String(50), nullable=False) + transaction_id = db.Column(db.String(50), nullable=False) + original_transaction = db.Column(db.String(50), nullable=True) + offer_id = db.Column(db.String(20), nullable=False) + product_id = db.Column(db.String(20), nullable=True) + min_amount = db.Column(db.Float, nullable=False) + max_amount = db.Column(db.Float, nullable=False) + eligible_amount = db.Column(db.Float, nullable=True) + tenor = db.Column(db.Integer, nullable=True) # tenor in months, typically + + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + customer = relationship( + "Customer", + primaryjoin="Customer.id == TransactionOffer.customer_id", + foreign_keys=[customer_id], + back_populates="transaction_offers", + ) + + @classmethod + def is_valid_transaction_offer(cls, transaction_offer, customer_id, product_id): + transaction_offer = cls.query.filter_by( + id = transaction_offer, + customer_id = customer_id, + # product_id = product_id + # transaction_id = transaction_id, + ).first() + + + if not transaction_offer: + return False + return transaction_offer + + @classmethod + def create_transaction_offer(cls, customer_id, transaction_id, original_transaction, offer_id, min_amount, max_amount, eligible_amount=None, product_id=None, tenor=None): + """ + Class method to create and save a TransactionOffer. + """ + transaction_offer = cls( + customer_id=customer_id, + transaction_id=transaction_id, + original_transaction=original_transaction, + offer_id=offer_id, + min_amount=min_amount, + max_amount=max_amount, + eligible_amount=eligible_amount, + product_id=product_id, + tenor=tenor, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + db.session.add(transaction_offer) + db.session.flush() + + return transaction_offer + + @classmethod + def get_lifetime_loan_count(cls, customer_id): + """ + Returns the total number of loans ever created for a customer. + """ + return cls.query.filter_by(customer_id=customer_id).count() + + + @classmethod + def get_latest_transaction_offer(cls, customer_id): + """ + Returns the most recent transaction offer for the given customer based on creation time. + """ + return cls.query.filter_by(customer_id=customer_id) \ + .order_by(cls.created_at.desc()) \ + .first() + + @classmethod + def get_transaction_offer(cls, transaction_offer_id): + """ + Returns a transaction offer by its ID. + """ + return cls.query.get(transaction_offer_id) + + + + + def to_dict(self): + return { + 'id': self.id, + 'customerId': self.customer_id, + 'transactionId': self.transaction_id, + 'offerId': self.offer_id, + 'productId': self.product_id, + 'minAmount': self.min_amount, + 'maxAmount': self.max_amount, + 'eligibleAmount': self.eligible_amount, + 'tenor': self.tenor, + 'createdAt': self.created_at.isoformat() if self.created_at else None, + 'updatedAt': self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f'' diff --git a/app/swagger/digifi_swagger.json b/app/swagger/digifi_swagger.json new file mode 100644 index 0000000..65a3414 --- /dev/null +++ b/app/swagger/digifi_swagger.json @@ -0,0 +1,123 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Swagger MERM Core - OpenAPI 3.0", + "description": "This is MERMS Backend Server with the OpenAPI 3.0 specification. \n\n\nSome useful links:\n- [Product Page](https://www.mermsemr.com/)\n- [Dev Product Web](https://panel.mermsemr.com/)", + "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:14700" + }, + { + "url": "https://devapi.mermsemr.com" + }, + { + "url": "https://api.mermsemr.com" + } + ], + "tags": [ + { + "name": "Authorize", + "description": "This feature will be used for authorizing customers.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.mermsemr.com" + } + }, + { + "name": "AuthorizeRefresh", + "description": "This feature will be used for refreshing authorized customers.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.mermsemr.com" + } + }, + { + "name": "Login", + "description": "User Login", + "externalDocs": { + "description": "Find out more", + "url": "https://www.mermsemr.com" + } + }, + { + "name": "Register", + "description": "Register a new user.", + "externalDocs": { + "description": "Find out more", + "url": "https://www.mermsemr.com" + } + } + ], + "paths": { + "/Authorize": { + "$ref": "swagger/paths/Authorize.json" + }, + "/AuthorizeRefresh": { + "$ref": "swagger/paths/AuthorizeRefresh.json" + }, + "/Login": { + "$ref": "swagger/paths/Login.json" + }, + "/Register": { + "$ref": "swagger/paths/SelectOffer.json" + } + }, + "components": { + "schemas": { + "Login": { + "$ref": "swagger/schemas/LoginRequest.json" + }, + "LoginResponse": { + "$ref": "swagger/schemas/LoginResponse.json" + }, + "Register": { + "$ref": "swagger/schemas/SelectOfferRequest.json" + }, + "RegisterResponse": { + "$ref": "swagger/schemas/RegisterResponse.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/Login.json b/app/swagger/paths/Login.json new file mode 100644 index 0000000..fe2c681 --- /dev/null +++ b/app/swagger/paths/Login.json @@ -0,0 +1,57 @@ +{ + "post": { + "tags": [ + "Login" + ], + "summary": "Start the process - initiate steps to eligibility RAC Checks ", + "description": "Initiate Login Request", + "operationId": "startLogin", + "requestBody": { + "description": "Post JSON to conduct eligibility tests", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/LoginRequest.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/LoginRequest.json" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "../schemas/LoginRequest.json" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/LoginResponse.json" + } + }, + "application/xml": { + "schema": { + "$ref": "../schemas/LoginResponse.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..43adf6b --- /dev/null +++ b/app/swagger/schemas/LoanStatusRequest.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "customerId": { + "type": "string", + "example": "CN621868" + }, + "msisdn": { + "type": "string", + "example": "3451342" + }, + "channel": { + "type": "string", + "example": "USSD" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + } + }, + "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..790ce34 --- /dev/null +++ b/app/swagger/schemas/LoanStatusResponse.json @@ -0,0 +1,83 @@ +{ + "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" + }, + "installment": { + "type": "array", + "items": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "format": "float", + "example": 10000.0 + }, + "repaymentDate": { + "type": "string", + "example": "2025-04-24 10:31:" + } + } + } + } + } + } + }, + "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/LoginRequest.json b/app/swagger/schemas/LoginRequest.json new file mode 100644 index 0000000..7922cfa --- /dev/null +++ b/app/swagger/schemas/LoginRequest.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "testaccount" + }, + "password": { + "type": "string", + "example": "merms.user.panel" + } + }, + "xml": { + "name": "LoginRequest" + } +} \ No newline at end of file diff --git a/app/swagger/schemas/LoginResponse.json b/app/swagger/schemas/LoginResponse.json new file mode 100644 index 0000000..5c659a2 --- /dev/null +++ b/app/swagger/schemas/LoginResponse.json @@ -0,0 +1,49 @@ +{ + "type": "object", + "properties": { + "member_id": { + "type": "string", + "example": "200" + }, + "uid": { + "type": "string", + "example": "8888-999998-9999" + }, + "username": { + "type": "string", + "example": "username" + }, + "account_name": { + "type": "string", + "example": "account_name" + }, + "firstname": { + "type": "string", + "example": "firstname" + }, + "lastname": { + "type": "string", + "example": "lastname" + }, + "room": { + "type": "string", + "example": "room" + }, + "token": { + "type": "string", + "example": "username" + }, + "resultDescription": { + "type": "string", + "example": "Successful" + }, + "resultCode": { + "type": "string", + "example": "00" + } + }, + "xml": { + "name": "LoginResponse" + } +} + 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..1da4cb2 --- /dev/null +++ b/app/swagger/schemas/ProvideLoanResponse.json @@ -0,0 +1,40 @@ +{ + "type": "object", + "properties": { + "requestId": { + "type": "string", + "example": "202111170001371256908" + }, + "transactionId": { + "type": "string", + "example": "Tr201712RK9232P115" + }, + "loanRef": { + "type": "string", + "example": "1620029887USSDAMPC" + }, + "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..1c28b71 --- /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" + }, + "transactionId": { + "type": "string", + "example": "20171209232115" + }, + "customerId": { + "type": "string", + "example": "CID0000025585" + }, + "loanRef": { + "type": "string", + "example": "Trx5847365252USSD3MPC" + }, + "accountId": { + "type": "string", + "example": "ACN8263457" + } + }, + "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..0540a8e --- /dev/null +++ b/app/swagger/schemas/SelectOfferRequest.json @@ -0,0 +1,45 @@ +{ + "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": "3MPC" + }, + "offerId": { + "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..f32d39a --- /dev/null +++ b/app/swagger/schemas/SelectOfferResponse.json @@ -0,0 +1,121 @@ +{ + "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": "3MPC" + }, + "amount": { + "type": "number", + "format": "float", + "example": 10000.0 + }, + "dueDate": { + "type": "string", + "example": "2025-04-24 10:31:" + }, + "upfrontPayment": { + "type": "number", + "format": "float", + "example": 1000.0 + }, + "interestRate": { + "type": "number", + "format": "float", + "example": 3.0 + }, + "interestFee": { + "type": "number", + "format": "float", + "example": 3000.00 + }, + "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 + }, + "installmentRepaymentDates": { + "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..d74b463 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + merms-backend-core: + build: . + env_file: + - .env + ports: + - "${APP_PORT:-14703}: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/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..8c7018a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +echo "Running DB migrations..." +flask db upgrade + +echo "Starting Gunicorn server..." +exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7289956 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +# 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 +httpx + + +# JWT +flask-jwt-extended +pyjwt + +# Kafka +confluent-kafka==1.9.2 + +werkzeug + +python-dateutil + diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..9a12075 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# echo "Running DB migrations..." +# flask db migrate -m "Migration on $(date)" +# flask db upgrade + +echo "Starting Gunicorn server..." +exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app diff --git a/tests/jmeter/digifi-BankToProductCore.jmx b/tests/jmeter/digifi-BankToProductCore.jmx new file mode 100644 index 0000000..6722498 --- /dev/null +++ b/tests/jmeter/digifi-BankToProductCore.jmx @@ -0,0 +1,651 @@ + + + + + true + + + + baseUrl + http://localhost:4500 + = + + + username + user + = + + + password + password + = + + + + false + false + + + + 1 + 1 + true + stopthread + + 1 + false + + + + + localhost + 4500 + http + /Authorize + true + POST + true + true + + + + false + { + "username":"${username}", + "password":"${password}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + + + + access_token + $.data.access_token + 1 + NOT_FOUND + variable + + all + + + + refresh_token + $.data.refresh_token + 1 + NOT_FOUND + variable + + all + + + + true + + + props.put("GLOBAL_ACCESS_TOKEN", vars.get("access_token")); +props.put("GLOBAL_REFRESH_TOKEN", vars.get("refresh_token")); + groovy + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + false + true + false + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + + 1 + 1 + true + continue + + 1 + false + + + + + localhost + 4500 + http + /AuthorizeRefresh + true + POST + true + true + + + + false + { + +} + + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_REFRESH_TOKEN)} + + + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + groovy + + + true + // Generate random IDs and store them in JMeter variables +def transactionId = "TR" + org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric(12) +def customerId = "CN" + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999) +def accountId = "ACN" + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999) +def msisdn = "809" + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999) + +// Generate requestId: current timestamp + 6-digit random number +def timestamp = new Date().format("yyyyMMddHHmmssSSS") // e.g., 20250414161243123 +def randomSuffix = org.apache.commons.lang3.RandomStringUtils.randomNumeric(6) +def requestId = timestamp + randomSuffix + +vars.put("transactionId", transactionId) +vars.put("customerId", customerId.toString()) +vars.put("accountId", accountId.toString()) +vars.put("msisdn", msisdn.toString()) +vars.put("requestId", requestId) + + + + + + localhost + 4500 + http + /EligibilityCheck + true + POST + true + true + + + + false + { + "transactionId":"${transactionId}", + "countryCode":"NGR", + "customerId":"${customerId}", + "msisdn":"${msisdn}", + "channel":"100", + "accountId":"${accountId}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + productId + $.eligibleOffers[0].productId + 0 + NOT_FOUND + variable + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /SelectOffer + true + POST + true + true + + + + false + { + "requestId": "${requestId}", + "transactionId": "${transactionId}", + "customerId":"${customerId}", + "msisdn": "${msisdn}", + "requestedAmount": ${__Random(500000,1000000,)}.00, + "accountId":"${accountId}", + "productId": "${productId}", + "channel": "100" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + offerId + $.loan[0].offerId + 0 + variable + NOT_FOUND + all + + + + amount + $.loan[0].amount + 0 + 800 + variable + all + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /ProvideLoan + true + POST + true + true + + + + false + { + "requestId":"${requestId}", + "transactionId":"${transactionId}", + "customerId":"${customerId}", + "accountId":"${accountId}", + "msisdn":"${msisdn}", + "requestedAmount":${amount}, + "collectionType":1, + "offerId":"${offerId}", + "channel":"100" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /LoanStatus + true + POST + true + true + + + + false + { + "transactionId":"${transactionId}", + "customerId":"${customerId}", + "msisdn":"${msisdn}", + "channel":"100", + "accountId":"${accountId}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + debtId + $.loans[0].debtId + 0 + 800 + variable + all + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + localhost + 4500 + http + /Repayment + true + POST + true + true + + + + false + { + "debtId":"${debtId}", + "transactionId":"${transactionId}", + "customerId":"${customerId}", + "msisdn":"${msisdn}", + "channel":"100", + "accountId":"${accountId}", + "productId": "${productId}" +} + = + + + + + + + + + Content-Type + application/json + + + Accept + application/json + + + Authorization + Bearer ${__P(GLOBAL_ACCESS_TOKEN)} + + + + + + + + 200 + + Assertion.response_code + false + 16 + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + false + true + false + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + + 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